Cardano transactions can carry arbitrary key-value data alongside the movement of ADA. This is transaction metadata: labels, notes, application-level identifiers, off-chain document hashes — anything a downstream service wants to read from the chain without interpreting validator logic.
Metadata is cheap, standardised, and indexed by every explorer and chain follower. In this lesson you'll attach a CIP-20 style "message" to a simple ADA transfer using Apollo's SetShelleyMetadata.
How Metadata Is Structured
Cardano ledger defines metadata as a map from label (unsigned integer key, sometimes called a "tag") to value (numbers, byte strings, text strings, lists, or maps of the same).
Conventionally, specific label numbers are reserved for specific uses. A small sample:
Label
Convention
Source
674
Transaction messages (free text note)
CIP-20
721
NFT asset metadata
CIP-25
61284
DRep metadata
CIP-119
1968
Pool metadata references
Long-standing convention
For this lesson we'll use 674 (CIP-20 messages) because it's the simplest: a single msg field containing an array of text strings.
The canonical shape for a CIP-20 message is:
{
"674": {
"msg": [
"Hello from the course"
]
}
}
That's what we want to put on-chain.
Apollo's Metadata Types
Apollo exposes metadata through github.com/Salvionied/apollo/serialization/Metadata. The types you'll touch:
type Metadata map[int]any // label → value
type ShelleyMaryMetadata struct { // the top-level wrapper we attach
Metadata Metadata
NativeScripts []NativeScript.NativeScript // optional
}
Metadata is just map[int]any. The any on the value side means you can nest map[string]any, []any, int, []byte, string — matching CBOR's major types.
The builder method is SetShelleyMetadata(Metadata.ShelleyMaryMetadata) *Apollo.
Prerequisites
- Completed 102.2 (working Apollo ADA transfer)
- Familiarity with the Apollo builder pattern
Step 1: Import the Metadata Package
Add the import to your 102.2 code:
import (
// ... existing imports
"github.com/Salvionied/apollo/serialization/Metadata"
)
Step 2: Construct the Metadata Value
Before .Complete() in your builder chain, build the metadata and attach it:
meta := Metadata.ShelleyMaryMetadata{
Metadata: Metadata.Metadata{
674: map[string]any{
"msg": []any{
"Hello from the course",
},
},
},
}
apollob, _, err = apollob.
AddLoadedUTxOs(utxos...).
PayToAddressBech32("your address here", 1_000_000).
SetShelleyMetadata(meta).
Complete()
Complete()returns(*Apollo, []byte, error). The middle[]byteis a CBOR preview of the built body — safe to discard with_in most flows, as we do in 102.2.
A few things to notice:
- The outer value for label
674is amap[string]any. This matches the CIP-20 shape:{"msg": [...]}. Apollo (viafxamacker/cbor) will serialise the map with string keys, which is what explorers expect. - The value of
msgis[]any{"Hello from the course"}, not[]string{...}. Useanyso mixed types work in future — the library CBOR-encodes based on runtime type. SetShelleyMetadataslots in anywhere before.Complete(). Order doesn't matter relative to other builder calls.
Step 3: Sign, Submit, Inspect
Same flow as 102.2:
apollob = apollob.Sign()
tx := apollob.GetTx()
// submit via bfc.SubmitTx(*tx) or the tx-submission program from 102.4
Once the transaction lands, open it on Cardanoscan preprod and scroll to the Metadata section. You should see:
Label 674
{
"msg": ["Hello from the course"]
}
Reading Metadata in Go
If your service needs to read metadata off the chain — for example, filtering transactions that carry a specific label — the TransactionEvent you get from Adder (Module 201) carries a Metadata lcommon.TransactionMetadatum field. You decode it by asserting against lcommon.TransactionMetadatum types and walking the map. We'll do this in Module 204.
For now, know that what you put in with SetShelleyMetadata is exactly what comes out on the other end — the round-trip is lossless.
Common Pitfalls
Putting a string key on a numeric label
The top-level map is map[int]any, keyed by label number. It must be an int, not a string. This is a fast fail at compile time:
// wrong — won't compile
Metadata.Metadata{"674": ...}
// right
Metadata.Metadata{674: ...}
Strings longer than 64 bytes
Cardano metadata text strings are limited to 64 bytes each. CIP-20 handles longer messages by splitting across multiple elements in the msg array. Apollo will return an error at Complete() time if you exceed the limit on any single string. Split long messages explicitly:
"msg": []any{
"First 64 bytes of the long message...",
"Next 64 bytes of the long message...",
}
Binary data
For byte payloads, use []byte(...) not string(...). CBOR distinguishes text strings (major type 3) from byte strings (major type 2). Wallets and explorers render them differently.
Label collisions
Labels are a shared namespace. Pick a number that's either reserved for your use case (see CIPs) or clearly in unused territory (large arbitrary numbers like 45102025). Don't silently reuse 721 unless you actually mean CIP-25 NFT metadata.
You'll Know You're Successful When
- Your transaction lands on preprod with the metadata visible on Cardanoscan
- The label you chose matches what you set (e.g.
674) - The structure matches CIP-20's
{"msg": [...]}shape - Adding
SetShelleyMetadatadidn't change the cost or success path of the transaction in any other way
Practice Tasks
- Build a transaction with two metadata labels —
674for the message and, say,45102025for a custom application ID. Verify both show up on the explorer. - Try submitting a transaction with a single 200-character string under
msg. Observe what happens. Fix it by splitting. - Read CIP-20 and CIP-25 side by side. In one sentence each, describe when you'd use each label.
What's Next
Module 102 is complete. You can now build, sign, submit, bound in time, and annotate transactions. Next up:
- Module 201 — watch transactions (including your own metadata) flow past an indexer
- Module 204 — decode this metadata back out of CBOR programmatically