How-to › Integrate beyond REST

How to validate a webhook payload's shape before handling it#

Check that a verified delivery carries the fields your handler reads, with Zod, Ajv, or shape, and park a failing delivery instead of letting the sender retry it.

Audience
API consumer
Level
intermediate
Topic
Receive and send webhooks
Languages
TypeScript and JavaScript
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
Ajv with the sender’s JSON SchemaThe sender publishes an OpenAPI 3.1 document and you want to check deliveries against its words rather than yoursA 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 documentThe sender publishes no schema, or your handler reads a tenth of what the schema describes
shapeYou want the schema to read as a sample payload, with every failing field reported in one passA 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 IntegerThe sender publishes a schema you can run as it stands, or your codebase already uses one of the other two
ZodTypeScript handlers that want the payload type inferred from the schemaA builder chain for every nested object, and a parsed value that leaves out the keys you did not declarePlain 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.

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

  1. 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

  2. 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

  3. 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

  4. Zod’s z.toJSONSchema describes the parsed output by default, and the parsed output of a plain z.object has no unknown keys, because parsing removed them. The exported schema therefore says additionalProperties: false for a validator that accepts anything, a document stricter than the code it describes. The option io: "input" describes what the schema accepts instead. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.