A delivery passes your signature check, your handler reads data.object.amount_due, and a queue
worker throws undefined is not a number twenty minutes later. The sender renamed the field.
Nothing at the boundary read the body, so the receiver acknowledged the deliveries and queued them
before anyone found out, and the worker keeps retrying a job that cannot succeed.
What you get
You will end up with a boundary check that names the field a delivery lacks, parks the delivery with that error, and acknowledges it so the sender stops retrying. This is for you if a sender’s payload change reaches you as a stack trace.
Short answer
After the signature check passes, validate the parsed JSON against a schema of the fields your handler reads, and only those, before any work starts. Zod, Ajv with the sender’s published JSON Schema, and shape all do this in a few lines. Let unknown keys through, because senders add fields without notice. When a delivery fails the check, store it with the error and answer 200, so the sender does not retry bytes that cannot pass.
You will need
Node 22 or later, a handler whose signature check already passes, and the sender’s description of
its payload, as prose or as an OpenAPI document with a webhooks section. Verified 2026-10-21
against Node 22.22.0, Zod 4.6.7, Ajv 8.20.0, and shape 11.4.1, installed from npm in the sample
directory. The sample runs the three checks and a receiver as functions in one process, with no
HTTP server and no real sender. Signature verification is its own task;
verify a Standard Webhooks signature in PHP
shows it for one language.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Ajv with the sender’s JSON Schema | The sender publishes an OpenAPI 3.1 document and you want to check deliveries against its words rather than yours | A schema you did not write and cannot tighten, errors keyed by JSON Pointer, and a strict mode to switch off before Ajv will read the document | The sender publishes no schema, or your handler reads a tenth of what the schema describes |
| shape | You want the schema to read as a sample payload, with every failing field reported in one pass | A default that rejects unknown keys until you write Open, a String that refuses the empty string, and a Number that admits fractions until you write Integer | The sender publishes a schema you can run as it stands, or your codebase already uses one of the other two |
| Zod | TypeScript handlers that want the payload type inferred from the schema | A builder chain for every nested object, and a parsed value that leaves out the keys you did not declare | Plain JavaScript, or a sender whose published schema you would rather reuse than restate |
Voxgig maintains shape. It is one of three options here, not the recommendation.
Zod and Ajv sit at the two ends of one trade. Zod gives you a type and asks you to restate the sender’s schema as builder calls, so the two drift when the sender changes its document. Ajv reads the schema as published, so it drifts with the sender instead, and its errors arrive as JSON Pointers to map before a person reads them. A shape literal sits between them: quick to compare with a sample payload, exportable as JSON Schema, and typed as Zod is.
Write the schema for the fields you read
The handler reads five values: the event id, its type, and the invoice’s id, amount, and currency. The schema names those and nothing else. Three versions of it follow, one per library.
// Zod: the schema is code, and z.infer<typeof InvoicePaid> is the handler's input type.
// z.object() lets unknown keys through and leaves them out of the parsed value.
const InvoicePaid = z.object({
id: z.string(),
type: z.literal('invoice.paid'),
data: z.object({
object: z.object({
id: z.string(),
amount_due: z.int().nonnegative(),
currency: z.string().length(3),
}),
}),
})
// Ajv: the sender's own schema, read out of the webhooks section of its OpenAPI document.
// strictSchema is off because the document is an OpenAPI document rather than a schema, and
// Ajv would otherwise refuse its top-level keywords. The $ref inside resolves as written.
const ajv = new Ajv2020({ allErrors: true, strictSchema: false })
ajv.addSchema(sender, 'sender')
const validateInvoicePaid = ajv.getSchema('sender#/components/schemas/InvoicePaidEvent')
// shape: the schema is a literal shaped like the payload. Open() admits the keys it does not
// name, which is what lets the sender add one. A plain object literal rejects them.
const InvoicePaidShape = Shape(
Open({
id: String,
type: Exact('invoice.paid'),
data: Open({
object: Open({ id: String, amount_due: Min(0, Integer), currency: Len(3, String) }),
}),
}),
)
z.int().nonnegative() restates two constraints the sender’s document also carries, type: integer and minimum: 0, and z.literal pins the event type. The schema is yours, the type is
inferred from it, and the sender’s document is something you read once and transcribe.
Ajv is handed the whole OpenAPI document and asked to compile one pointer into it, so the $ref
to Invoice resolves as the sender wrote it. Ajv refuses unknown keywords such as openapi
unless strictSchema is off.1 The schema it compiles sits
under the sender’s webhooks entry, the object
OpenAPI 3.1 added for the requests an
API sends rather than serves.
"webhooks": {
"invoice.paid": {
"post": {
"summary": "An invoice was paid in full.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/InvoicePaidEvent" }
}
}
},
"responses": {
"200": { "description": "Any 2xx acknowledges the delivery. Anything else is retried." }
}
}
}
},
The shape version reads as a sample payload. String marks a required string,
Min(0, Integer) and Len(3, String) carry the sender’s constraints, Exact pins the event
type, and Open admits keys the literal does not name. A bare Number would let -5 and 12.5
through.
node compare.mjs
the documented payload
zod ok
ajv ok
shape ok
a field the sender added
zod ok
ajv ok
shape ok
amount_due as a string
zod data.object.amount_due: Invalid input: expected number, received string
ajv data.object.amount_due: must be integer
shape data.object.amount_due: Validation failed for property "data.object.amount_due" with string "1200" because the string is not of type integer.
two fields missing
zod data.object.amount_due: Invalid input: expected number, received undefined
data.object.currency: Invalid input: expected string, received undefined
ajv data.object.amount_due: must have required property 'amount_due'
data.object.currency: must have required property 'currency'
shape data.object.amount_due: Validation failed for property "data.object.amount_due" because the property is missing.
data.object.currency: Validation failed for property "data.object.currency" because the property is missing.
All three accept the documented payload and the one with the added field, and all three name the same path for the string amount. Both missing fields are reported in one pass, so a parked delivery is fixed once rather than field by field. The messages differ in register: Zod says what it expected and received, Ajv speaks in schema keywords, and shape writes a sentence.
Park the delivery and answer 200
A delivery that fails the check will fail it again, because a retry carries the same bytes. The receiver therefore keeps the delivery, records what was wrong with it, and acknowledges it.
export function createReceiver({ check, apply, parked, rejectInvalid = false }) {
return async function receive(delivery) {
const result = check(delivery.payload)
if (!result.ok) {
// A 4xx or 5xx here makes the sender deliver the same bytes again on its own schedule, and
// the same bytes fail the same check. Keep the delivery, record why, and acknowledge it:
// after a 200 the payload kept here is the only copy left to replay.
if (rejectInvalid) return { status: 400 }
parked.push({ id: delivery.id, type: delivery.payload?.type, payload: delivery.payload, errors: result.errors })
return { status: 200 }
}
await apply(result.value)
return { status: 200 }
}
}
The 200 is the decision. Stripe retries a delivery that gets any other answer for up to three days, with exponential backoff (Stripe webhooks). Its upgrade guide spells out both halves: answer 200 to prevent delivery retries, answer 400 when you want the event sent again.2 A validation failure is the first case. The parked row is the alert, and after the fix the parked deliveries are replayed from your store rather than theirs.
node receive.mjs
responses 200 200 200 200
applied in_7001 in_7001
parked evt_1003 invoice.paid: data.object.amount_due
parked evt_1004 invoice.paid: data.object.amount_due, data.object.currency
Four deliveries, four 200 answers. The two that validated reached the handler, both for invoice
in_7001, since the second is the same event with a field added. The two that did not are parked
beside the path each one lacks.
Allow the keys you do not read
Senders add fields. GitHub lists adding a response field among the additive changes it makes without a new API version (GitHub REST API breaking changes).3 Stripe renders each webhook payload in the API version pinned on the endpoint (Stripe API versioning). A schema that forbids unknown keys turns the sender’s additive change into your outage. Each library has a strict form, and each looks reasonable in review.
node strict.mjs
the sender adds hosted_invoice_url to data.object
z.strictObject rejected: Unrecognized key: "hosted_invoice_url" at data.object
additionalProperties: false rejected: must NOT have additional properties at /data/object
Shape({ ... }), closed by default rejected: because the property "hosted_invoice_url" is not allowed.
z.object accepted
the sender's schema, as published accepted
Shape(Open({ ... })) accepted
The first three reject a delivery on a key the handler never reads. The sender’s own schema
accepts it, because a JSON Schema without additionalProperties allows any value
(JSON Schema core 10.3.2.3).
z.object accepts it and drops it from the parsed value. The shape literal has to say Open,
because a declared object is closed by default
(shape, validating objects).
Check it worked
The test that guards the design is the parked delivery: it has to be acknowledged, kept with its errors, and never applied.
test('an invalid delivery is parked with its errors, acknowledged, and never applied', async () => {
const ledger = []
const parked = []
const receive = createReceiver({ check: checks.shape, apply: async (e) => ledger.push(e), parked })
const res = await receive({ id: twoMissing.id, payload: twoMissing })
assert.equal(res.status, 200)
assert.equal(ledger.length, 0)
assert.equal(parked.length, 1)
assert.equal(parked[0].id, 'evt_1004')
assert.deepEqual(parked[0].errors.map((e) => e.path).sort(), ['data.object.amount_due', 'data.object.currency'])
})
node --test receiver.test.mjs
1..9
# tests 9
# suites 0
# pass 9
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 275.893541
Nine tests pass. Five run the three checks against the payloads, so a library upgrade that
changes a path fails here first. The last sends the same invalid delivery through a parking
receiver and a rejecting one, and asserts deliveries: 1 against deliveries: 5.
When it goes wrong
The same delivery arrives every few minutes, for days. The receiver answered a 4xx or 5xx to a validation failure, and the sender read that as an invitation.
node pitfall.mjs
a delivery with two fields missing, from a sender that retries up to 5 times
answer 400 deliveries 5 acknowledged no parked 0
park it, answer 200 deliveries 1 acknowledged yes parked 1
memo arrives as an empty string
memo: String rejected: because an empty string is not allowed.
memo: Empty(String) accepted
A sender that gives up after five attempts sends the rejected delivery five times and leaves nothing to replay. The parked delivery is sent once and kept.
A field that is present and empty is rejected. In a shape literal, String means a non-empty
string, so a memo the sender sends as "" fails with an empty string is not allowed. Write
Empty(String) where empty is a value, as the second half of the output shows. Zod’s
z.string() and a JSON Schema string both accept the empty string.
The handler reads a field the schema did not declare and finds undefined. With Zod,
result.value is the parsed output, and the parsed output leaves out undeclared keys. Read the
field from the raw payload, or declare it, and treat the exported JSON Schema with the same
care.4
When not to do this
Do not validate a body before verifying its signature. An unsigned request that fails validation still costs you the parse and a row in the parked table. An attacker who can fill that table has found a cheaper target than your handler.
Do not reject keys you do not read, in any of the three libraries. Those strict forms read as diligence and behave as a dependency on the sender never changing.
Do not restate a schema in shape, or in Zod, when the sender publishes JSON Schema you can run as it stands. Two descriptions of one payload drift apart, and the copy you wrote is the copy nobody regenerates. Ajv with the published schema is the smaller maintenance surface there, and shape earns its place when the sender gives you prose and a sample.
Do not let the parked table become a dead letter nobody reads. Alert on the age of its oldest row, replay from it after the fix, and expire what you have given up on.
Do not validate more than you read. A constraint on a field the handler never touches parks deliveries you could have handled.
Related how-tos
Last verified
Verified 2026-10-21 against Node 22.22.0, Zod 4.6.7, Ajv 8.20.0, and shape 11.4.1. Every output block is what the command preceding it printed. The sender, its retries, and the receiver are functions in one process, so no HTTP request was made and no signature was checked. The Stripe and GitHub retry and compatibility rules are quoted from their documentation, not measured.
Footnotes
-
JSON Schema and Ajv disagree about unknown keywords, each with a reason. The core specification says an implementation SHOULD treat a keyword it does not support as an annotation, so a schema still does what it can under a validator that lacks one. Ajv’s strict mode fails compilation on the same keyword, because a misspelled keyword that is ignored is a check that never runs. ↩︎ Back to text
-
The retry a validation failure provokes by accident is the one Stripe’s upgrade guide provokes on purpose. During a version migration it recommends answering 400 from the old endpoint, so that Stripe keeps redelivering the events in case the new code has to be reverted. The new endpoint answers 200 to prevent delivery retries. Once the old endpoint is turned off, Stripe will not redeliver the events that were answered with a 400. ↩︎ Back to text
-
GitHub’s list of additive changes runs to six items: an operation, an optional parameter, an optional request header, a response field, a response header, and enum values. A response that gains a field is therefore, by the sender’s own rules, no reason for a new API version. ↩︎ Back to text
-
Zod’s
z.toJSONSchemadescribes the parsed output by default, and the parsed output of a plainz.objecthas no unknown keys, because parsing removed them. The exported schema therefore saysadditionalProperties: falsefor a validator that accepts anything, a document stricter than the code it describes. The optionio: "input"describes what the schema accepts instead. ↩︎ Back to text