Designing the JSON Your Integration Sends Out
A webhook payload is an API contract with the same obligations as a REST response. What belongs in the envelope, and what a fat payload quietly freezes.
Devon Parker · @dparker
APIs and integrations
- Topic
- API
- Section
- APIs & Integrations
- Published
- 03 DEC 2025
- Reading time
- 8 MIN
- Words
- 1874W
An order sync breaks on a Tuesday afternoon with no deploy on either side. The webhook still
arrives, the JSON still parses, and the ERP still writes a row. What changed is the meaning of one
field: total used to be a string with two decimal places and now it is a number. The
receiver has been doing str_pad on it for a year, so every order queued after the
release lands in the reconciliation queue at 12.5 instead of 12.50. Nobody
gets an alert. The mismatch only surfaces when somebody counts the till.
Payload design failures do not look like outages. They look like quiet disagreements about what a field means. The receiver is never compiled against your schema, there is no type checker at the boundary, and the only contract is the one you wrote down somewhere and the other team read once.
Treat the JSON you push as an API response you happen to deliver over HTTP POST instead of a GET. It has the same obligations. It also has one extra constraint that a REST response usually escapes: you choose the delivery moment, so the receiver has no way to ask for a different shape on the next request.
What the envelope has to carry before the data does
Five fields earn their place in every payload regardless of subject, because the receiver has to answer questions your internal code never had to ask. Which event is this, and did I already process it? What kind of change was it? When did it happen? Which object was affected? And which version of the contract am I reading?
An event ID is the first one, and it must identify the state change, not the delivery attempt.
If the ID is regenerated on each retry, the receiver loses the only cheap way to deduplicate and a
network timeout turns into a duplicate order. A UUID or ULID generated once, when the event is
created, and carried unchanged through every retry, is enough. On a WooCommerce 9.x store on PHP
8.3 pushing order events to an ERP, I have watched a flaky receiver turn three retries of one
order.updated into three supplier invoices, purely because the ID changed each time.
The type string is next, and the rule that matters is narrower than it sounds: never repurpose
it. Receivers filter on the type and they add filter clauses over time. If you reuse
order.updated eighteen months later for a different class of change, every receiver
that subscribed to the old meaning silently starts processing events it never agreed to. Add a new
type instead. Namespace it (order.created, order.refunded) so the string
stays readable in a log without a lookup table.
The timestamp should be UTC in ISO 8601 with an explicit offset, and it should describe when the event happened rather than when the delivery worker got around to sending it. Those two moments can be minutes apart under a backlog, and the receiver will order events by whatever you give it. Local time without an offset is the common mistake here, usually because the store's timezone setting was doing the formatting. Store UTC and format UTC.
The object identifier is what makes the payload actionable. A receiver holding
order_id: 88213 can fetch or patch the thing the event is about. A receiver holding
only a description of the change has to guess, and it will guess by email address or by customer
name, which works until two customers share one.
The API version is the field people add last, usually after the first break. A date-based
string such as 2025-12-01 is easy to reason about, because it tells the receiver which
documented shape to expect, and it gives you a way to run two shapes side by side for as long as
the migration takes.
Version scope
Nothing here depends on a WordPress or WooCommerce release. The envelope applies to any HTTP push, and the examples below assume a WooCommerce order object serialised by your own code, not by core.
A thin payload sends a reference, a fat one sends the world
The structural decision underneath all of this is how much of the object you include. Thin
means an envelope plus an ID. Fat means an envelope plus the whole serialised object, usually
nested under a data key.
{
"id": "01JC9V2K7Q8F3M5T0R4B6N1XZA",
"type": "order.updated",
"created_at": "2025-12-03T09:14:22Z",
"api_version": "2025-12-01",
"data": {
"order_id": 88213
}
}
That is the whole thin payload. The receiver now knows something changed about order 88213 and
can call GET /wp-json/wc/v3/orders/88213 with its own credentials to find out what.
{
"id": "01JC9V2K7Q8F3M5T0R4B6N1XZA",
"type": "order.updated",
"created_at": "2025-12-03T09:14:22Z",
"api_version": "2025-12-01",
"data": {
"order_id": 88213,
"status": "processing",
"currency": "EUR",
"total": "128.40",
"date_paid_gmt": "2025-12-03T09:12:04Z",
"billing": {
"first_name": "Marta",
"last_name": "Ruiz",
"email": "m.ruiz@example.com",
"country": "ES"
},
"line_items": [
{ "product_id": 4410, "sku": "WPS-TOTE-01", "quantity": 2 }
]
}
}
The fat version answers every question the receiver had, and it answers them with your field
names. That is the trade. A receiver that needs the shipping country no longer has to make a
second call, and a replay of last month's events still shows last month's totals instead of
today's. The cost arrives later, when somebody decides billing should have been
billing_address, or when the ERP team starts joining on line_items[].sku
and you realise you have published a schema you never reviewed.
| Axis | Thin payload | Fat payload |
|---|---|---|
| Receiver complexity | One extra authenticated fetch per event, plus retry and rate limit handling for it | Parses the JSON and stores it; no follow-up call for current fields |
| Coupling | Your public contract is the envelope and the endpoint the receiver calls | Every field you emit becomes part of the contract; renaming an internal key is a breaking change |
| Replay behaviour | Replay refetches current state, which may have moved on since the event | Replay reproduces the state at event time |
| Failure surface | Your read endpoint is now on the receiver's critical path | Delivery is the only moving part |
| Leakage | Receiver sees only what it asks for | Whatever you serialise goes to every subscriber, including personal data it did not need |
If you want a rule from that table: choose thin when the object is large, changes often, or contains fields the receiver has no business holding. Choose fat when the event is a historical record, when the receiver cannot reach your API from its network, or when the extra round trip is the thing that keeps failing.
Reusing a field name with a new meaning is the one break you cannot recover from
Receivers survive added fields. They survive a new event type. They do not survive a field that keeps its name and changes what it says, because nothing throws. The parse succeeds, the value looks plausible, and the wrong number gets written into an invoice. Everything in this article is in service of that one rule.
When the meaning has to change, publish a new field or a new API version. Adding
total_minor alongside the old total is uglier than editing the old one
and it costs one line of documentation. Deprecating total over two release cycles
costs one line and a calendar reminder.
The same reasoning applies to null and a missing key, which look similar in a
debugger and mean different things. A missing key says this version of the contract has no such
field. An explicit null says the field exists and the value is not known, not set, or
deliberately empty. Receivers rely on that difference for patching: absent means leave my copy
alone, null means clear it. Collapse the two and every partial update becomes
ambiguous, which is how a receiver ends up overwriting an address with nothing.
Your database column names are not an interface
The quickest way to build a payload is to hand the receiver whatever the query returned. That
is how post_status, _billing_email and a serialised
_wc_order_meta blob end up in a public contract, and it is a bigger cost than the
ugliness suggests. Column names tie your migrations to somebody else's parser. A plugin moving
orders from wp_posts to the WooCommerce HPOS tables, or a rename during a schema
change, becomes a breaking API change whether or not you intended to publish one.
It also leaks structure you would rather not explain. A column called
legacy_customer_ref invites questions about the migration you have not finished, and a
boolean stored as 'yes' strings inside a serialised array tells the receiver more about
your options table than about the order. Serialise through an explicit mapper instead: an array you
write by hand, or a small class with one method per output shape. The mapper is also the place
where you can guarantee the envelope fields are present, because it is the only code path that
produces a payload.
Mirroring the same mapper into the REST controller for the same object pays for itself. One serialiser means the pushed event and the pulled response cannot drift, and the receiver can debug a webhook by calling the endpoint with the same ID and comparing the two documents.
Where the advice stops working
Thin payloads put a read endpoint on the receiver's critical path, and that endpoint can rate limit, go read-only during a deploy, or return a 500 at the wrong moment. A receiver that handles the webhook correctly can still fail on the follow-up call, and it now has two failure modes to retry instead of one. If the receiving system is a hosted middleware you do not control, that second call may not even be possible, and fat is the only shape that works.
Fat payloads have a cost that is easy to underestimate. Versioning does not shrink the schema, it only lets you stop changing it. Once two versions exist in the wild, that mapper has two branches, the tests have two fixtures, and the deprecation calendar has a date on it that somebody has to honour. If you cannot commit to that process, the honest alternative is a thin payload, because a thin payload that needs no deprecation beats a fat payload you will break anyway.
Neither shape fixes ordering. Webhooks arrive out of order under load, and a
created event can land after the updated that followed it. Comparison on
the created_at field is the only tool the receiver has, so the timestamp has to be
accurate and monotonic enough to sort on. An event ID alone will not save you there.
What to do on Monday
- Open your outbound webhook code and check for the five envelope fields. Add whichever are missing before touching the data shape.
- Print one real payload and read it as an outsider. Circle every key that is a database column or an internal identifier, then rename them in the mapper.
- Send the same event twice and confirm the ID, the type and the timestamp are identical. If any of them move, deduplication is impossible for the receiver.
- Write down what each field means in one sentence. Any field you cannot describe without referencing a table gets removed or renamed.
- Decide thin or fat per event type, not once for the whole integration, and write the choice next to the payload documentation so the next person does not reverse it.
Devon Parker · @dparker
APIs and integrations
Works through REST contract design, webhook reliability, idempotency keys, retry policy and the unglamorous edge cases that appear when two systems disagree about what happened.
Continue