Skip to content

The CloudEvents wire format

Binary mode, as the CloudEvents specification defines it and as the CloudEvents SDK's own bindings implement it. Attributes ride in headers; the payload is the body, untouched. Everything below is written with the ce- prefix of the HTTP and NATS bindings, which is what Binary(HTTP) and Binary(NATS) write; other bindings change only the prefix. Structured mode is at the end.

Required attributes

All four must be present and non-empty, and the absence of any of them — specversion included — is ErrMissingAttribute.

ErrUnsupportedSpecVersion is for a specversion that is present and is not 1.0. The distinction is load bearing: "this is not a CloudEvent" and "this is a CloudEvent from a version we do not implement" are different problems, and an operator alerting on version skew wants only the second.

Header Field Notes
ce-id Event.ID With source, uniquely identifies the occurrence. Do not reuse the pair
ce-source Event.Source A URI-reference, so it may be relative — /phpbotscout/ingest is valid
ce-specversion Event.SpecVersion Always 1.0. Encode fills it in when empty
ce-type Event.Type What consumers filter on. Reverse-DNS by convention: uk.phpboyscout.orders.created

Optional attributes

Omitted entirely when empty: an absent optional attribute is an absent key, never an empty one.

Header Field Notes
content type Event.DataContentType Not prefixed, and not a header on write: it is Wire.ContentType. ce-datacontenttype is also accepted on read, see below
ce-dataschema Event.DataSchema An absolute URI. Estate schema identifiers are URNs, see the URN package
ce-subject Event.Subject Names the thing the event is about, within source
ce-time Event.Time RFC 3339. Written in UTC, with fractional seconds when they are non-zero

Percent-encoding

Both bindings make this a MUST, and binary mode does it in both directions.

A header value has space (U+0020), double-quote (U+0022), percent (U+0025) and everything outside printable ASCII U+0021–U+007E written as UTF-8 bytes in %XY form. A receiver performs exactly one round of decoding — one, so a value that legitimately contains %25 is not decoded twice into %.

Subject: "order 4172"   ->   ce-subject: order%204172
Subject: "Euro €"       ->   ce-subject: Euro%20%E2%82%AC

Upper-case hex is written and either case is read, as the specification asks. A value that decodes to invalid UTF-8, or carries a truncated or non-hexadecimal escape, is ErrInvalidEncoding.

The content type is exempt. It is a media type the transport interprets, it is never encoded on write, and a media type containing a literal % is not valid anyway.

Control characters

Refused, not encoded. The specification's String type excludes U+0000–U+001F and U+007F–U+009F, and while percent-encoding would carry them, transportable and permitted are different questions. net/http rewrites a newline in a header rather than refusing it, so without the check a receiver would get a different attribute value than the sender set, with no error anywhere.

Extensions

Any other header under the binding's prefix. The prefix is stripped on read and added on write, so Extensions["scope"] is ce-scope on the wire.

Names must be one or more lower-case ASCII letters or digits, must not be data, and must not be one of the eight specification attribute names. Without that last rule Extensions["id"] would overwrite ce-id on the way out, producing an event with a hijacked identity, no error, and a round trip that agrees with itself. See Add an extension attribute.

A complete event

ce-id: 01JQ8Z3F7K2M4N6P8R0T2V4W6X
ce-source: /phpbotscout/ingest
ce-specversion: 1.0
ce-type: uk.phpboyscout.orders.created
ce-subject: order-4172
ce-time: 2026-08-28T11:22:33Z
ce-dataschema: urn:phpboyscout:schema:orders.created:3
ce-scope: urn:phpboyscout:scope:continuity:01JQ8Z3F7K2M4N6P8R0T2V4W6X
content-type: application/json

{"order":"4172"}

That is testdata/full.txt in go/envelope-cloudevents, and it is read by the test suite rather than described by it. As a Wire, content-type is ContentType and the rest are Headers.

Payload ownership

Binary mode's Encode returns a Wire whose Body aliases Event.Data, and its Decode returns an Event whose Data aliases the Wire.Body it was given. Neither copies.

That is deliberate — a codec on a message hot path should not double every payload — but it makes the ownership yours. A producer reusing an encode buffer will mutate a message it has already handed to a transport, and an Event retained past an HTTP handler that read into a pooled buffer will change underneath you. Copy at the boundary if you need to hold either.

The two spellings of datacontenttype

The bindings genuinely disagree. The HTTP binding maps datacontenttype onto Content-Type and says a ce-datacontenttype header MUST NOT also be present; the NATS binding prefixes it like every other attribute.

Binary mode writes the HTTP form, which is what the SDK's NATS binding emits in practice, and accepts either on read, because silently losing a conformant NATS peer's content type is worse than tolerating a spelling. Where both arrive, the unprefixed one wins.

Header case

Binary(HTTP), Binary(NATS) and Binary(Kafka) write lower-case keys; Binary(AMQP) writes cloudEvents_ as the binding spells it. Decode matches every key without regard to case, so an event that crossed http.Header, which canonicalises to Ce-Id, is read back identically.

http.Header.Get will not find them

Get canonicalises the key it is given, so it cannot see a map keyed ce-id. Read Encode's output by iteration or by exact key.

Two spellings of one key carrying different values is ErrAmbiguousHeader, not a choice. So are two values under one key.

Headers the format does not own are not compared: X-Forwarded-For with two hops is the ordinary shape behind a proxy, and refusing the event over it would be refusing over something that is none of the format's business. They come back in Foreign.Headers.

Other bindings

Binary(binding) takes the binding's prefix in place of ce-. Keys are matched without regard to case, on every binding.

Binding Writes Also reads
HTTP, NATS ce-id
Kafka ce_id
AMQP (1.0) cloudEvents_id cloudEvents:id, which the binding also permits

RabbitMQ speaks AMQP 0-9-1, which no binding covers; use HTTP, whose headers it most resembles.

A Format puts the content type in Wire.ContentType, not in a header, because AMQP and RabbitMQ carry it as a native property. On a transport without one, write it as the content-type header. Binary(b).Decode reads it from either place, and refuses the two with ErrAmbiguousHeader if they differ; Structured() uses it only to recognise the message, preferring Wire.ContentType.

Every header without the binding's prefix, other than content-type, comes back in Foreign.Headers. It is never promoted into the event: a bare traceparent from a broker does not replace the event's own. Binary mode never reports Foreign.Conflicts, because it has no envelope a header can shadow.

A binary datacontenttype must not start application/cloudevents. Every binding reserves that prefix for structured mode, so a reader asked to accept both modes could not tell which this was. Binary(b).Encode refuses it with ErrReadsAsStructured.

Structured mode

Structured() is the JSON event format: the whole event as one JSON object in the body, with content type application/cloudevents+json. Attributes and extensions are members by their own names; there are no attribute headers, so a transport's header limit no longer applies.

{"datacontenttype":"application/json","id":"01JQ8Z3F7K2M4N6P8R0T2V4W6X","source":"/phpbotscout/ingest",
 "specversion":"1.0","subject":"order-4172","time":"2026-08-28T11:22:33Z",
 "type":"uk.phpboyscout.orders.created","data":{"order":"4172"}}
Payload Travels as
JSON (application/json, text/json, any +json, or no content type), valid, no surrounding whitespace data, verbatim, so it reads back byte for byte
Anything else: protobuf, text, JSON with a margin data_base64

On read:

  • a member that appears twice is ErrAmbiguousHeader, as is a body with both data and data_base64;
  • null is absent;
  • an extension that is a JSON number or boolean becomes its JSON text, 3 or true, because extensions are strings here; an object or array is ErrInvalidJSON;
  • a string data with a content type that is not JSON is the payload's text;
  • every header but content-type is in Foreign.Headers, because in this mode the event is the body;
  • a refusal still returns every member read before it, keeping the first value of a repeated one.

Batch mode, application/cloudevents-batch+json, is not implemented and is not recognised.

Reading more than one format

envelope.Read(w, formats...) asks every format whether it recognises the message, and decodes with the one that does. It does not stop at the first, because no order is safe: binary mode recognises a message by its ce-specversion header and structured mode by its content type, and a message can carry both.

Formats that claim it Result
none ErrUnrecognised
one that format's Decode, and the format, so a reply can be written in it
two or more ErrAmbiguous, naming them; nothing is decoded