--- title: "The Envelope" description: "The frozen BabelQueue message envelope (schema_version 1): every field, the meta block, cross-language data rules, producer/consumer rules, and forbidden fields." source: https://babelqueue.com/docs/spec/1.x/envelope/ updated: 2026-06-07T00:00:00.000Z --- # The Envelope The single, frozen definition of the bytes that travel on the queue. Every SDK, in every language, MUST produce and consume exactly this shape. > **Status:** Authoritative · Frozen · `schema_version: 1` ## Canonical envelope ```json { "job": "urn:babel:orders:created", "trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b", "data": { "order_id": 1042, "amount": 99.90 }, "meta": { "id": "f1e2d3c4-b5a6-4789-90ab-cdef01234567", "queue": "default", "lang": "php", "schema_version": 1, "created_at": 1749132727000 }, "attempts": 0 } ``` Top-level key order is not significant (JSON objects are unordered), but producers SHOULD emit keys in the order above for readable logs. ## Field reference | Field | Type | Required | Mutable | Description | | :--- | :--- | :---: | :---: | :--- | | `job` | string | ✅ | no | The message **URN** — its language-agnostic identity. See [URN naming](/docs/spec/1.x/urn/). The canonical field name is `job`; consumers MUST also accept `urn` as an inbound alias. **Never** a PHP/Java/… class name. | | `trace_id` | string (UUID) | ✅ | no | Cross-service correlation id. Generated by the original producer; **preserved and forwarded unchanged** by every SDK across every hop. Enables end-to-end distributed tracing. | | `data` | object | ✅ | no | The business payload — a **pure**, JSON-encodable key/value object. No language-specific types (no PHP objects, closures, resources; no binary without base64). Numbers follow the [data rules](#cross-language-data-rules). | | `meta` | object | ✅ | no¹ | Producer-set descriptive metadata. Immutable after production. See [the meta block](#the-meta-block). | | `attempts` | integer | ✅ | **yes** | Transport-level delivery/retry counter, starting at `0`. Mutated by the broker/worker on each (re)delivery. Deliberately **top-level**, kept out of the immutable `meta` block. | ¹ `meta` is conceptually immutable end-to-end. The only counter that legitimately changes during a message's life is top-level `attempts` — it is *transport* state, not producer metadata. ## The meta block | Field | Type | Required | Description | | :--- | :--- | :---: | :--- | | `id` | string (UUID) | ✅ | Unique id for **this specific message**. Distinct from `trace_id`. | | `queue` | string | ✅ | The logical queue name the message was produced onto (not the broker key). | | `lang` | string (enum) | ✅ | Producer language tag — one of `php`, `go`, `python`, `java`, `dotnet`, `node`. For observability and debugging. | | `schema_version` | integer | ✅ | Envelope schema version. Currently `1`. Consumers MUST reject (or quarantine) versions they do not understand. | | `created_at` | integer | ✅ | Production time as **Unix epoch milliseconds, UTC** (milliseconds, not seconds). | Producers MAY add extra `meta.*` keys for their own observability, but consumers MUST ignore unknown `meta` keys (forward-compatibility), and those keys MUST NOT be required for correct routing or processing. ## `id` vs `trace_id` — do not conflate | | `meta.id` | `trace_id` | | :--- | :--- | :--- | | Scope | one message | one causal chain (may span many messages/services) | | Generated by | the producing SDK, per message | the **first** producer in the chain; reused thereafter | | Lifetime | dies with the message | propagates across every hop | | Use | dedupe, ack/nack, logs for one message | end-to-end tracing/observability | When a consumer produces a downstream message while handling one, it SHOULD copy the inbound `trace_id` onto the new envelope and mint a **new** `meta.id`. ## Cross-language data rules Because the same `data` is decoded by six languages, these are part of the contract, not implementation details: - **Encoding.** UTF-8 JSON only — no trailing commas, no comments, no `NaN`/`Infinity`. The reference encoders emit unescaped unicode and slashes so output is byte-identical across SDKs. - **Numbers.** Integers must fit signed 64-bit; do not exceed `2^53 − 1` if any consumer is JavaScript/Node unless carried as a string. Floats are IEEE-754 doubles — **never** put currency or exact-decimal values in floats; use integer minor units (e.g. cents) or strings. (The example uses `99.90` for readability only.) - **Time.** `meta.created_at` is Unix epoch **milliseconds** (UTC). Time values inside `data` SHOULD be Unix ms (integer) or RFC 3339 / ISO 8601 UTC strings — agreed per URN, never a locale-formatted string. - **Binary.** Raw bytes are not allowed in JSON. Base64-encode and document the field per URN. - **Booleans / null.** Use JSON `true`/`false`/`null` — never `0/1` or `"true"`. ## Producer rules A conformant producer MUST: 1. Set `job` to a **non-empty** URN. An empty URN is a programming error and MUST raise. 2. Set `trace_id` to a UUID — reuse an inbound one if continuing a trace, otherwise generate a new v4 UUID. 3. Emit `data` as a pure JSON object (see the data rules above). 4. Populate every required `meta` field — `meta.id` unique per message, `meta.lang` matching the producing language, `meta.schema_version = 1`, `meta.created_at` in Unix ms. 5. Initialize top-level `attempts` to `0`. 6. Encode as UTF-8 JSON and publish via the broker binding. ## Consumer rules A conformant consumer MUST: 1. Read the URN from `job`, accepting `urn` as an alias if `job` is absent. 2. Reject/quarantine any envelope whose `meta.schema_version` it does not support. 3. Route to the handler mapped to the URN. If none is mapped, apply the configured unknown-URN strategy (`fail` · `delete` · `release` · `dead-letter`). 4. Preserve `trace_id` for logging and for any downstream messages. 5. Treat `meta` and `data` as read-only; only the broker/worker mutates `attempts`. 6. Tolerate unknown extra keys (forward-compatibility) — never hard-fail on them. ## Forbidden fields These appeared in early drafts and are **not** part of the contract. Do not emit or rely on them: | Forbidden field | Use instead | | :--- | :--- | | `timestamp` (top-level) | `meta.created_at` (Unix **ms**) | | `meta.max_retries` | consumer-side config / per-URN policy | | `meta.attempts` | top-level `attempts` | | `meta.source` | `meta.lang` | | `meta.ts` | `meta.created_at` | | `urn` as the canonical producer field | `job` (with `urn` accepted only as an inbound **alias**) | ## Optional `dead_letter` block Messages sitting on a **dead-letter queue** carry one extra, optional top-level field, `dead_letter`, describing why they failed. It appears *only* on DLQ messages — never on a normal queue — and is additive, so the envelope stays at `schema_version: 1`. Normal consumers ignore it. ```json "dead_letter": { "reason": "failed", "error": "Payment gateway timeout", "exception": "App\\Exceptions\\GatewayTimeout", "failed_at": 1749132730000, "original_queue": "orders", "attempts": 3, "lang": "php" } ``` ## Change control - Any change to this envelope is **architecturally significant**. - Additive, optional, backward-compatible fields → keep `schema_version = 1`. - Removing/renaming/retyping a field, or changing semantics → **bump `schema_version`** and provide a migration/compat path for older consumers. Continue to [URN naming](/docs/spec/1.x/urn/).