Envelope vs Payload: What Carrier Webhooks Actually Send
Envelope vs payload, defined and worked through a real carrier tracking webhook, so you can design schemas that route and version cleanly.
Envelope vs Payload: The One-Sentence Definition
In a carrier integration message, the envelope is the routing and transport metadata wrapped around an event (event ID, type, timestamp, tenant reference, signature), and the payload is the domain data it carries (the tracker, the shipment, the label). The envelope tells your infrastructure what to do with a message before anything has to look inside it; the payload tells your business logic what actually happened.
This sounds trivial until you build multi-tenant carrier middleware and watch a router try to deserialise fifteen different carrier-specific JSON shapes just to work out which tenant a webhook belongs to. Get the envelope vs payload split wrong, and every downstream service ends up coupled to every carrier's schema.
Where This Split Comes From
The envelope/payload pattern isn't a carrier-industry invention. It's the standard shape for any pub/sub or event-driven system, formalised most clearly by the CNCF's CloudEvents specification. CloudEvents defines a fixed set of context attributes that every CloudEvent conforming to this specification MUST include, and MAY include one or more OPTIONAL context attributes and MAY include one or more extension attributes. Crucially, these attributes are designed so they can be serialized independent of the event data, allowing them to be inspected at the destination without having to deserialize the event data.
That's the whole point of an envelope: a router, a dead-letter queue, or a rate limiter can act on it without trusting or even parsing the payload's shape. CloudEvents requires four attributes: id, source, type, and specversion, plus optional ones like time and subject. The spec is explicit that producers MUST ensure that source + id is unique for each distinct event, and consumers MAY assume that events with identical source and id are duplicates. That rule alone is why idempotency keys belong in the envelope, not buried in a carrier's tracking payload.
If you want an analogy that sticks for a logistics audience: the envelope is the shipping label, the payload is what's inside the box. A sorting hub reads the label to route the parcel. It never opens the box to decide which conveyor belt it goes on.
What Envelope vs Payload Is Not
Three things people conflate with this split, worth separating cleanly:
- It isn't HTTP headers vs body. Headers are transport-level and often stripped or altered when a message is replayed through a queue, a webhook relay, or a dead-letter store. An application-level envelope travels inside the JSON body itself, so it survives re-transport intact. CloudEvents supports both a "binary mode" (attributes as HTTP headers) and a "structured mode" (attributes and data together in one JSON body) precisely because you need the envelope to survive both paths.
- It isn't the fat-vs-thin payload debate. That's a separate, orthogonal decision about how much domain data to put inside the payload. Hookdeck frames it as three fundamental approaches: fat payloads that include everything, thin events that include almost nothing, and update payloads that include what changed. Stripe's newer webhook design leans thin: thin events provide a lightweight, version-stable alternative to snapshot events, where instead of receiving full resource objects in webhook payloads, you receive compact notifications and fetch the details you need. Whether you go fat or thin, the envelope fields stay the same.
- It isn't your versioning scheme. The envelope carries a pointer to the version (a field like
api_versionor a topic suffix like.v2), but the envelope pattern itself doesn't tell you how to version. Those are two different design decisions that happen to share a field.
A Worked Example: EasyPost's Tracking Webhook
EasyPost's tracking webhook is a clean, real-world illustration. Every tracking update arrives as an Event object, and tracking updates always use object: "Event", description: "tracker.updated", result: a nested Tracker object containing the latest status and details. Here, object, id, description, mode, and created_at form the envelope. The nested result object is the payload, an actual EasyPost Tracker resource with carrier, tracking code, and scan history.
{
"id": "evt_qatAiJDM",
"object": "Event",
"created_at": "2014-11-19T10:51:54Z",
"description": "tracker.updated",
"mode": "test",
"previous_attributes": { "status": "unknown" },
"result": {
"id": "trk_Txyy1vaM",
"object": "Tracker",
"status": "delivered",
"tracking_code": "EZ4000000004",
"carrier": "UPS"
}
}EasyPost even guarantees at-least-once delivery, which is exactly why the envelope's id field matters: every time an Event related to one of your objects is created, EasyPost guarantees at least one POST request will be sent to each of the webhook URLs set up for your account, and for this reason, they strongly encourage your webhook handler to be idempotent. Deduplicate on evt_qatAiJDM, the envelope's event ID, never on some combination of payload fields like tracking code and status, which can legitimately repeat.
Now compare that with a CloudEvents-shaped version of the same "package delivered" event, and with Stripe's convention of pinning a version directly in the envelope:
// CloudEvents shape
{
"id": "evt_qatAiJDM",
"source": "/carriers/ups/trackers",
"type": "com.easypost.tracker.updated",
"specversion": "1.0",
"time": "2014-11-19T10:51:54Z",
"data": { "status": "delivered", "tracking_code": "EZ4000000004" }
}
// Stripe-style shape
{
"id": "evt_1abc2def",
"api_version": "2026-01-28",
"type": "invoice.paid",
"data": { "object": { "id": "inv_4k9x7m", "amount_paid": 4999 } }
}Three different vendors, three different field names, the same structural idea: the consumer subscribes at a specific API version, and all webhooks they receive use that version's payload schema until they explicitly upgrade, with Stripe as the canonical example where each account is pinned to an API version identified by a date.
Why Multi-Tenant Carrier Middleware Needs This Split
In a platform routing webhooks for 1,000+ shippers across UPS, DHL, PostNL and FedEx, the tenant ID, correlation ID, and idempotency key have to live in the envelope. That's what lets a router, a dead-letter queue, or a rate limiter act on a message without deserialising or trusting the carrier-specific payload shape underneath. A DLQ that has to parse ten different carrier payload formats just to decide whether to retry is a DLQ that will eventually retry the wrong thing.
Multi-carrier platforms like Cargoson, alongside nShift, ShipEngine and EasyPost, all face the same underlying problem: dozens of carrier-specific payload shapes have to be normalised into one consistent envelope before anything downstream, billing, notifications, exception handling, gets to touch the data. The carrier's payload stays messy and carrier-specific. The envelope is where you buy back consistency.
Common Mistakes
- Tenant or auth data buried in the payload. If tenant identity only exists inside a carrier-specific nested object, your router has to understand every carrier's schema just to know whose queue a message belongs in.
- Deduplicating on payload business fields instead of the envelope's event ID. Tracking code plus status is not a safe dedup key, it repeats legitimately. The envelope's
id, unique per producers MUST ensure that source + id is unique for each distinct event, is the only field designed for this. - No version field in the envelope at all. Without one, consumers are forced to sniff the payload shape to guess what schema they're looking at. Hookdeck's guidance is blunt on this: if you don't have a versioning strategy from day one, you'll break consumer integrations the first time you ship a schema change, and those consumers won't be gentle about it.
FAQ
Is envelope vs payload the same as header vs body?
No. Headers are transport-level and can be dropped or rewritten when a message is replayed through a queue or relay. An envelope is application-level JSON that survives replay, retries, and re-transport intact.
Do I need CloudEvents to do this properly?
No. CloudEvents is a convention, not a requirement. Its own spec only mandates context attributes designated as REQUIRED, four fields, and leaves everything else optional. You can build a perfectly sound envelope with your own field names, as EasyPost and Stripe both do.
Where does the idempotency key live, envelope or payload?
Envelope, alongside the event ID. That's the field guaranteed unique per event, and the one EasyPost strongly encourage your webhook handler to be idempotent against.
How does this interact with schema versioning?
The envelope carries the version pointer, it doesn't define the versioning strategy itself. Stripe's approach puts "api_version": "2026-01-28" directly in the envelope alongside id and type, so a consumer can branch on schema version before it even touches the nested payload.
If you're designing a new carrier webhook consumer, start by writing down your envelope fields on paper before you write a line of payload-parsing code. If you can't route, dedupe, and version a message using only the envelope, the split isn't done yet.