Mapping Webhook and Callback Payload Fields Between Providers
10 min read · updated August 11, 2026
A webhook handler written against one provider is usually three different things tangled together: signature verification, event dispatch, and business logic keyed on a status string. Only the first two map cleanly onto another provider. The status vocabulary is where the translation loses information, and it is where the rewritten handler goes wrong.
Three layers, mapped separately
Take the payload apart before you try to translate it. Every asynchronous job notification, whatever produced it, is layered:
- The transport envelope. HTTP headers, the signature scheme, the retry and redelivery policy. Provider-specific, entirely mechanical, and the part with the fewest surprises once you have read the documentation.
- The event identity. An event type string, an event id, a timestamp, and a reference to the object the event is about. Structurally similar everywhere; the naming differs.
- The job status. An enumerated value describing what happened to the work. This is where providers genuinely disagree — not in spelling, but in how many distinct outcomes they distinguish and where the boundaries between them fall.
Map them in that order. A handler that mixes the layers — verifying the signature inside the same function that decides whether to retry the job — has to be rewritten whole for each provider. One that separates them replaces only the layer that changed.
The envelope and its signature
The Standard Webhooks specification, published by the Standard Webhooks project, is the closest thing to a common shape here: three headers, webhook-id, webhook-timestamp and webhook-signature, where the signature is a base64 HMAC over the concatenation of the id, the timestamp and the raw body, and the header may carry several space-separated versioned signatures so a secret can be rotated without dropping deliveries. The specification is worth reading once even if none of your providers implement it, because it names the properties a verification routine needs: the Standard Webhooks specification.
Providers that predate it use their own header, and the pattern is the same with different spelling: Stripe sends a Stripe-Signature header containing a timestamp and one or more signatures, GitHub sends X-Hub-Signature-256. Whichever you have, three properties decide whether your verification is real:
- It is computed over the raw body. Parse the JSON after verifying, never before. A framework that deserialises and re-serialises before your handler sees it will break the signature over whitespace and key order, and this is the single most common cause of a verification that fails only in production.
- It is compared in constant time, and against a freshness window on the timestamp, or the signature is replayable forever.
- It supports more than one active secret, because during a migration you will be receiving from two providers at once and rotating at least one of them.
Event identity and correlation
The fields that identify an event have direct counterparts almost everywhere, so this layer is a rename table rather than a design problem. What is worth being deliberate about is correlation: the way you get from a delivered event back to the row in your database that was waiting for it.
Both of the published batch-job APIs solve this the same way, with a caller-supplied identifier echoed back on every result. OpenAI’s Batch API takes a custom_id on each line of the input JSONL and returns it on each line of the output file, alongside the per-request response and error — OpenAI’s batch API reference. Anthropic’s Message Batches API takes a custom_id per request and returns results as JSONL where each entry carries the same custom_id and a result object — Anthropic’s message batches reference.
Use your own identifier there, always, and make it something you generate rather than something derived from the payload. The provider’s job id is a foreign key you cannot mint in advance, which means a crash between issuing the request and storing the returned id leaves you with an orphaned job. A caller-supplied id written to your database before the request goes out has no such window, and it is also what makes redelivery safe to handle idempotently — the same ground as migrating idempotency keys.
The status vocabulary is the lossy part
Here the shapes genuinely differ, and a naive rename produces a handler that is wrong on the paths nobody tests. Compare what the two published batch APIs distinguish.
OpenAI batch object, "status":
validating -> failed | in_progress -> finalizing -> completed
-> expired
cancelling -> cancelled
request_counts: { total, completed, failed }
Anthropic message batch, "processing_status":
in_progress -> ended
canceling -> ended
request_counts: { processing, succeeded, errored, canceled, expired }
per-result result.type: succeeded | errored | canceled | expiredThe structural difference is not the names. It is that one shape puts the outcome in the top-level job status and the other terminates every batch in a single ended state and puts the outcome in the per-request results. A handler that branched on the job status to decide success or failure has, on the second shape, nothing to branch on: ended means the work stopped, not that it worked.
Three specific translation losses to plan for:
- Partial success has no representation in a boolean. A batch where most requests succeeded and some errored is a normal outcome, and any internal status of your own with only
succeededandfailedwill force you to round it. Give your own model a partial state and counts, whichever provider you are on — this is the shape partial batch failures take everywhere. - Validation failure is a different thing from execution failure. One shape has an explicit pre-execution
validatingstate that can fail before any token is spent; the other surfaces malformed requests per result. Collapse them and you will retry a permanently malformed batch on a backoff schedule forever. - Expiry is not an error. Both shapes distinguish work that ran out of its window from work that failed, and the correct responses differ: a failed request may be retryable as-is, an expired one usually means resubmitting a smaller job. Mapping expiry onto your generic failure path throws that distinction away.
Writing a handler that survives the swap
The shape that ports is a thin adapter per provider and one internal event type. The adapter’s only job is to turn a delivery into your own record — job id, your correlation id, one of your own status values, counts, and the raw payload retained for debugging. Everything downstream reads your type and never the provider’s.
Two rules make that adapter reliable. First, acknowledge fast and process asynchronously: verify the signature, persist the raw event, return 2xx, and do the work from a queue. Providers retry on non-2xx and on timeout, so slow processing turns into duplicate deliveries, and duplicate deliveries turn into duplicate side effects unless you are deduplicating on the event id. Second, never trust the payload’s contents as the source of truth for anything expensive — treat the webhook as a notification that something changed and fetch the authoritative object, which also gives you a working path when a delivery is lost entirely.
Test the handler with recorded payloads rather than live ones, keeping one fixture per status value including the ones that are hard to produce on demand, and assert that an unknown status is tolerated. That fixture set is what lets you swap the adapter under the same suite — the same argument as testing webhook delivery for an async job.