Skip to main content

Message envelope model

The message envelope is the channel-agnostic shape a message carries through Orbit in both directions: the declaration a send submits to the router, and the record the messages API, webhook delivery, and the SDKs hand back. Where Pricing and rate resolution embeds the envelope inside the pricing narrative, this page defines it on its own: the canonical fields, the per-channel overrides, and the channel_chain / costAware declaration you meet on the omnichannel send surfaces.

Why one envelope across channels

Each channel speaks its own carrier protocol: GSM segments and UCS-2 on SMS, MIME parts on email, submitted templates on WhatsApp, rich cards on RCS, media replies on Instagram. Your code should not need a parser per channel to answer the basic questions: who sent what to whom, when, on which channel, and did it arrive. The envelope is the contract that makes those answers identical. Every message normalizes to the same top-level fields, and channel particulars ride in declared slots — the per-channel content variant on send, the metadata object on read-back — rather than in ad-hoc per-channel shapes. One webhook consumer, one inbox, and one warehouse export then serve every channel, and a chain that falls through from RCS to SMS produces records that differ in channel, not in shape.

The canonical envelope

These are the top-level fields. One rule keeps the model honest: only fields every channel shares are top-level; anything channel-specific is metadata-resident (see The metadata object). The per-channel overrides to that canonical shape are exactly the ones the carrier matrix below pins.

Channel-specific carriers

The envelope does not change per channel; what changes is which carrier carries the content on send, and which keys surface on read-back. A send declares the universal text plus the per-channel variant it needs; the most-specific-wins rule below picks the variant per hop.

Direction semantics and multi-channel threading

direction names the originator. Inbound messages arrive with direction: inbound and land on received; outbound messages walk the status ladder below. Two ids do the threading, on different axes:
  • conversation_id pairs inbound with outbound into a thread — including across channels. A recipient who replies on WhatsApp to an SMS send continues the same conversation; the envelope’s channel labels the hop, the conversation holds the relationship. See Inbound message routing.
  • message_group_id binds one logical send’s attempts. When a cascade escalates a failed send to another channel, the group id ties the hops together (see Cascade failover policy and groups). The conversation answers “with whom”; the group answers “which send attempt”.

Statuses and the per-channel state machine

An outbound message starts at queued (or scheduled, with a future send time), moves through sending and sent, and ends in a channel-shaped terminal state. An inbound message lands directly on received. The shared ladder is queued, scheduled, sending, sent, delivered, read, failed, rejected, expired; each channel shapes the terminal set:
  • SMS and MMS have no read receipt. The success terminal is delivered, or submitted_no_receipt when the route accepted the message but never returned a receipt at all (see Submitted vs delivered: the three successes).
  • WhatsApp, Instagram, Messenger, and RCS extend delivered with read where the carrier surfaces read receipts.
  • Email adds bounced and undelivered for recipient-side rejections.
The raw carrier code never overwrites status: it is preserved as metadata.provider_status beside the normalized state. Message status map pins the provider-to-status translation; Message status DAG pins the allowed transitions.

The metadata object

metadata is free-form by design and namespaced by convention. The platform stamps utility keys into it, you may add your own keys for correlation, and nothing inside it participates in routing or pricing. The utility keys you will meet:
  • story_mention — flags an inbound Instagram reply to one of your stories.
  • template_type — the category of the WhatsApp template that carried the send.
  • provider_status — the carrier’s raw delivery receipt, kept beside the normalized status.
Treat metadata as additive and optional: parse it defensively, because new keys can appear without a version bump. The top-level field set above is the stable contract.

One envelope through SDKs and webhooks

The same normalized envelope is what the messages list endpoint returns, what each webhook delivery posts, and what the SDK message objects wrap. Write one parser against the canonical fields and it serves every channel: when a channel joins the enum, your consumer keeps working, because the new channel’s extras arrive in metadata rather than as new top-level fields. The webhook events reference and the payload cookbook carry worked payloads.

The send declaration: channel_chain and the content bag

On the send side, one envelope declaration carries everything the router needs for a single send attempt:
  • One recipient — to, the destination the envelope delivers.
  • One content bag — content, a universal text body plus optional per-channel rich variants (a WhatsApp template, an RCS rich body, MMS media, and so on).
  • An ordered channel_chain — your fallback preference for this send, in the order you declared it.
The router walks that chain top-down. The first channel with both a registered provider and a renderable content variant wins the walk. Two edge rules fall out of that:
  • Most-specific-wins on content. A channel-specific variant beats the universal text body on its channel’s hop; absent a variant, the universal body renders. A channel that can render neither is skipped silently and the walk continues rather than failing the send.
  • Transient failure falls through. A provider-level transient failure (a retryable error, a 5xx, a timeout) advances the walk to the next declared channel. The channel you lose on is recorded in the attempt audit; the chain only ends when every declared channel is exhausted.
The envelope’s declared channel is also what pricing keys on — whichever channel wins the walk determines which rate-card ladder and which per-operator rules resolve its price (see the final section).

Cost-aware re-sequencing

Declaring costAware: true on the envelope re-ranks your chain cheapest-first before the walk starts. The preference set is yours; the rate card only re-sequences it — channels are never added, removed, or trimmed, only re-ordered. Per channel, the resolver reads the per-send cost override you supplied with the envelope when one exists, and the platform rate card otherwise. A channel with no published price is never assumed cheapest; equal-price channels keep your declared order, so the re-sequenced chain is always a stable re-ordering of what you declared. Re-sequencing happens once, at declaration time: after sorting, the walk follows the same first-provider-and-renderable-variant rule as an unsorted chain.

How the envelope differs from cascade and notify fallback

Orbit has three fallback planes, and all of them are escalation-by-failure — a brand-new send dispatched after the prior one failed:
  • The org-level cross-channel fallback chain advances on reachability and capability failures.
  • The cascade policy (org default or per-send cascade field) escalates on a terminal delivery receipt and binds the hops under one message_group_id.
  • The notify waterfall chases one recipient through bindings with per-hop and cumulative cost bounds.
The envelope is routing-by-choice on one attempt: the whole preference chain is declared up front and the router routes within it on the single send, rather than reacting to a failure signal from a previous send. No DLR-driven escalation ever fires, no group id is stamped, and no organization-level configuration participates. Choose between them by what drives the move: declare an envelope when you want the channel picked by preference (or price) inside one send; arm a cascade or the org chain when you want a failed send to re-try on the next channel; compose a notify waterfall when you want per-recipient escalation with explicit cost bounds.

The per-hop price-resolution contract

Every hop on the walk funnels through the same resolver pipeline a direct send on that channel uses — the precedence from Pricing and rate resolution runs identically per hop: per-operator override verbatim, then your organization’s markup, then the platform-default markup, and Contact sales when no published rate exists. One consequence: a fall-through hop is priced on the channel it lands on, so a chain declared RCS → SMS attributes an SMS hop as an SMS charge, never an RCS one. The quantity multiplier is channel-shaped and fixed:
  • SMS and MMS price per segment — the per-segment rate multiplied by the segment count of the rendered body.
  • Every other channel prices one unit per message.
The winning hop’s declaration is what the wallet debits, and the ledger row carries that attribution back, so the re-sequencing costAware performed is fully traceable per hop.

What an envelope is NOT

  • Not a cascade group. A cascade stamps one message_group_id on the primary and every fallback hop so the run reads back as one logical message. An envelope is a single declaration on one attempt — it groups to nothing.
  • Not a notify waterfall. A waterfall hoists the chain per recipient in one notify call and screens with max_price / max_total_price bounds. An envelope declares the chain on the send itself; the cost bounds do not apply to it, and costAware merely re-orders rather than bounds.
  • Not the org-fallback chain. The organization-level settings.cross_channel_fallback configuration arms every send uniformly. An envelope carries no organization-level configuration — the chain is declared per send and applies to that send only.

Common misunderstandings

  • Reaching for channel fields at the top level. story_mention, template_type, and provider_status are metadata-resident by design; the top level is reserved for fields every channel shares. A parser that expects them top-level will simply find nothing.
  • Reading body: null as “nothing was sent”. A media-only inbound carries attachments and no text, and a template send carries its structure in the template reference. Read body, attachments, and the channel carrier together.
  • Inlining media into the envelope. attachments holds references to hosted media; upload the media first, then reference it. The rule is the same on every channel.
  • Comparing statuses across channels without the terminal set. delivered is terminal on SMS; on WhatsApp the same message can still advance to read. Compare against the channel’s ladder, not a global one.
  • Confusing the envelope with the escalation planes. Cascades and waterfalls are separate sends reacting to failure (see What an envelope is NOT); the envelope is one attempt’s shape.