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

Source: https://voxgig.com/howto/validate-webhook-payload-shape-before-processing

- Audience: api-consumer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-10-21
- Published: 2026-10-17

## 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](/howto/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](https://ajv.js.org/guide/getting-started.html) | 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](https://github.com/rjrodger/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](https://zod.dev/) | 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](https://rjrodger.github.io/shape/docs/reference/typescript-types.html) 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.

```js title="checks.mjs"
// 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](https://spec.openapis.org/oas/v3.1.0.html#openapi-object) added for the requests an
API sends rather than serves.

```json title="sender-openapi.json"
  "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.

```bash
node compare.mjs
```

```text output
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.

```js title="receiver.mjs"
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](https://docs.stripe.com/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.

```bash
node receive.mjs
```

```text output
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](https://docs.github.com/en/rest/about-the-rest-api/breaking-changes)).[^3]
Stripe renders each webhook payload in the API version pinned on the endpoint
([Stripe API versioning](https://docs.stripe.com/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.

```bash
node strict.mjs
```

```text output
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](https://json-schema.org/draft/2020-12/json-schema-core#section-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](https://rjrodger.github.io/shape/docs/how-to/validate-objects.html)).

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

```js title="receiver.test.mjs"
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'])
})
```

```bash
node --test receiver.test.mjs
```

```text output
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.

```bash
node pitfall.mjs
```

```text output
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

- [Acknowledge a webhook fast and process it from a queue](/howto/acknowledge-webhooks-fast-and-process-from-a-queue)

- [Verify a Standard Webhooks signature in PHP](/howto/verify-a-standard-webhooks-signature-in-php)

- [Write a validator whose schema looks like the data](/howto/write-a-schema-that-looks-like-the-data)

- [Validate JSON model output before you use it](/howto/validate-json-model-output-in-typescript)

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

[^1]: JSON Schema and Ajv disagree about unknown keywords, each with a reason. The
[core specification](https://json-schema.org/draft/2020-12/json-schema-core#section-6.5) 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](https://ajv.js.org/strict-mode.html) fails compilation on the same keyword, because
a misspelled keyword that is ignored is a check that never runs.

[^2]: The retry a validation failure provokes by accident is the one Stripe's
[upgrade guide](https://docs.stripe.com/upgrades) 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.

[^3]: GitHub's
[list of additive changes](https://docs.github.com/en/rest/about-the-rest-api/breaking-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.

[^4]: Zod's [`z.toJSONSchema`](https://zod.dev/json-schema) 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.