The envelope

What travels with a message besides its body: identity, causation, and the counters the retry engine keeps.

type Envelope struct {
    ID            string
    Type          string
    Version       int
    CorrelationID string
    CausationID   string
    Attempt       int
    FirstSeen     time.Time
    Origin        string
    Error         string
    ReplyTo       string
    Headers       map[string]any
}
Field Default
ID unique identifier, and the default idempotency key a UUID v4
Type logical type, such as order.placed the routing key
Version schema version of the payload 1
CorrelationID propagated unchanged across hops the ID
CausationID the message that caused this one empty
Attempt delivery attempt 1
FirstSeen when it was first published now
Origin the publishing process acemq@{hostname}
Error why it was dead-lettered empty
Claim where the payload is, when it travelled elsewhere empty
ReplyTo where an answer should go empty
Headers your own headers empty

Those defaults are part of the wire contract rather than conveniences. A port that starts Version at 0, or lets Type be empty where Java falls back to the routing key, produces messages that differ from Java's for the same logical content.

On the wire

Header
x-acemq-id ID
x-acemq-type Type
x-acemq-version Version, an integer
x-acemq-correlation CorrelationID
x-acemq-causation CausationID, omitted when empty
x-acemq-attempt Attempt, an integer
x-acemq-first-seen FirstSeen, epoch milliseconds
x-acemq-origin Origin, omitted when empty
x-acemq-error Error, omitted when empty. Present only in a dead-letter queue.
x-acemq-claim Claim, omitted when empty
x-acemq-route Route, the ordered step names of a declared pipeline, comma joined. All three below are omitted together when empty.
x-acemq-route-position RoutePosition, an integer, counting from zero
x-acemq-route-id RouteID, identifying one run across every hop

The three route headers are Java's form of a routing slip. They are on the envelope rather than among the application's headers for a reason that is not tidiness: the reserved prefix means the engine drops any x-acemq- header it does not know, so a Java-declared route reaching a Go consumer used to arrive on the wire and vanish before the handler saw it.

Envelope.RouteSteps() splits Route into the names. patterns.SlipFrom reads either these or this library's own JSON slip, which is what Go writes by default.

ReplyTo is deliberately not in that table. It is AMQP's own reply-to property rather than a header, which is where Java and .NET read it from — see where the reply address travels.

An absent value is an absent header, never a null one. Java omits x-acemq-causation entirely when there is no causation, and a port that writes a null there produces a different message for the same content.

The replay stamps are not in that table and not in this namespace. A replay writes acemq-replayed-from, acemq-replayed-at and acemq-replay-count — no x-, and that is the point. Anything under x-acemq- that the engine does not put on the envelope is dropped on the way in, so a stamp written there would reach the wire and vanish before the handler saw it. Unprefixed, the three arrive as ordinary entries in Envelope.Headers, which is where patterns.HeaderReplayedFrom and its two siblings read them.

acemq-replayed-at is an RFC 3339 string — 2026-02-03T04:05:06Z — unlike x-acemq-first-seen, which is epoch milliseconds. The two timestamps on the wire really are encoded differently. Go, Java, Python and Ruby all write RFC 3339 here; Java also reads the epoch milliseconds it wrote before 0.5.0.

.NET writes the prefixed x-acemq- spellings of all three instead, so its replay stamps are dropped on the way in here rather than reaching a handler — see replay.

x-acemq-claim is a real field and nothing in this library writes it by itself. It is for an application that wants an operator reading a dead-letter queue to see where a payload went without decoding the body:

pub.Send(ctx, reference, acemq.Claim("s3://payloads/2026/09/order-1"))
func(ctx context.Context, m acemq.Message[Reference]) acemq.Ack {
    if m.Envelope.Claim != "" {
        // the payload is over there
    }
    return acemq.Accept()
}

A convention rather than a mechanism: nothing here reads it or fetches anything, and what the string means is between the publisher and the consumer.

It is on the envelope rather than among your headers for the same reason the route headers are. Python and Ruby carry claim as a first-class envelope field, and any x-acemq- header this version does not materialise is dropped on the way in — so before it was a field, a claim set by a Python or Ruby publisher reached the wire and vanished before a Go handler saw it. Reserved and unmaterialised is the worst of both: a name that looks supported and is not.

This is not the claim check. That pattern frames the body rather than setting a header, because a header can be stripped by a shovel or a federation link and because a present-or-absent header cannot say whether a payload travelled inline. The two are unrelated: this field is a note about where a payload went, and the codec is a mechanism that moves one. Setting Claim does not make anything a claim check, and the claim check does not set Claim.

The reserved namespace

Anything beginning x-acemq- belongs to the engine. On the way in it is read onto the envelope if this version knows it, and dropped from Envelope.Headers either way — including headers this version has never heard of, so a header written by a newer library cannot masquerade as one the application set.

On the way out, Header refuses a reserved name rather than accepting one that would vanish:

acemq: header "x-acemq-id" is in the reserved "x-acemq-" namespace and would be
dropped on consume; use a namespace of your own, such as x-yourcompany-

Use x-yourcompany- for anything of yours.

Reading it in a handler

func(ctx context.Context, m acemq.Message[OrderPlaced]) acemq.Ack {
    log.Printf("%s type=%s attempt=%d age=%s from=%s tenant=%v",
        m.Envelope.ID,
        m.Envelope.Type,
        m.Envelope.Attempt,
        m.Envelope.Age(),
        m.Envelope.Origin,
        m.Envelope.Headers["x-tenant"])
    return acemq.Accept()
}

Age() is how long ago the message was first published — the figure a retry policy uses to give up on something too old to be worth delivering, and the one worth logging when a queue has backed up.

Building one

env, err := acemq.NewEnvelope("order.placed",
    acemq.MessageID("order-o-1"),
    acemq.CorrelationID("corr-1"),
    acemq.Header("x-tenant", "acme"))

It returns an error because Header can refuse a name. Send builds the envelope for you and returns the same error, so most code never calls this directly. See publishing.

NextAttempt() returns a copy with the counter advanced, leaving the original alone and keeping the identity — which matters, because the identity is what idempotency is keyed on.

Reading values off the wire

Broker clients are not consistent about header types: a long string may arrive as a string or as []byte, and an integer may be any width, or a string, or — from a JSON fixture — a float. All of it is coerced rather than one shape being assumed, because assuming is how a port works against one broker and fails against another.

How this is kept honest

internal/testdata/envelope-fixtures.json was generated by the Java implementation. A test reads each case, builds an envelope from it, writes it back, and compares: no header gained, none lost, none renamed, none with a different value.

The test was checked by breaking it. Dropping the causation header on the way out makes it fail and say so:

header "x-acemq-causation" was lost: Java wrote cause-1 (string)

A test that has never been seen to fail is a test nobody should trust.