Skip to content
System nominalUTC --:--:--Articles 041

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
Technical cover graphic for Designing the JSON Your Integration Sends Out
Generated schematic — API series, REV 2.6.

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.

AxisThin payloadFat payload
Receiver complexityOne extra authenticated fetch per event, plus retry and rate limit handling for itParses the JSON and stores it; no follow-up call for current fields
CouplingYour public contract is the envelope and the endpoint the receiver callsEvery field you emit becomes part of the contract; renaming an internal key is a breaking change
Replay behaviourReplay refetches current state, which may have moved on since the eventReplay reproduces the state at event time
Failure surfaceYour read endpoint is now on the receiver's critical pathDelivery is the only moving part
LeakageReceiver sees only what it asks forWhatever 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

  1. Open your outbound webhook code and check for the five envelope fields. Add whichever are missing before touching the data shape.
  2. 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.
  3. 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.
  4. Write down what each field means in one sentence. Any field you cannot describe without referencing a table gets removed or renamed.
  5. 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

Related in APIs & Integrations