Good Webhooks

Receiver verification

Authenticate raw request bytes and decode a typed webhook event.

good-webhooks/verify authenticates a webhook request from its raw bytes and decodes it into a typed event.

import { verifyWebhook, parseWebhook, WebhookError } from 'good-webhooks/verify'
import type { ParsedWebhook, WebhookErrorCode } from 'good-webhooks/verify'

This entry point does not load database or worker code. It remains a Node.js API. The receiving guide shows complete handlers for Next.js, Hono, Express, and node:http.

parseWebhook

parseWebhook<const E extends EventDefinitions>(
	input: Parameters<typeof verifyWebhook>[0] & { events: E },
): Promise<ParsedWebhook<E>>
import { parseWebhook } from 'good-webhooks/verify'
import { z } from 'zod'

const events = {
  'invoice.paid': z.object({ invoiceId: z.string(), amount: z.number().int() }),
}

export async function POST(request: Request) {
  const event = await parseWebhook({
    body: new Uint8Array(await request.arrayBuffer()),
    headers: request.headers,
    secret: process.env.WEBHOOK_SECRET!,
    events,
  })
  if (event.type === 'invoice.paid') console.log(event.data.amount)
  return new Response(null, { status: 204 })
}

Verifies the signature, validates the Good Webhooks JSON envelope, and decodes its payload with the selected Standard Schema validator. It accepts every verifyWebhook input plus events, which maps accepted event names to receiver validators.

ParsedWebhook<E> is a discriminated union by event type. Each member contains id: string, its event-name literal, occurredAt: string, and data with that validator's output type.

RuleContract
Body sizeAt most 512 KiB, checked before verification. Larger bodies produce PAYLOAD_INVALID.
EnvelopeA JSON object with id, type, occurredAt, and data.
idMust match the authenticated webhook-id header.
typeMust be a key of events. Unknown event types produce PAYLOAD_INVALID.
occurredAtA canonical ISO timestamp.
dataMust fit the sender's JSON limits: 256 KiB, maximum depth 64, finite numbers, and plain JSON values. Then the validator must accept it.

Receiver validators accept transmitted JSON and can decode it into local values such as Date. Expected error messages do not expose payload values.

verifyWebhook

verifyWebhook(input: {
  body: string | Uint8Array
  headers: Headers | Record<string, string | string[] | undefined>
  secret: string | readonly string[]
  now?: Date
  toleranceSeconds?: number
}): void
import { verifyWebhook } from 'good-webhooks/verify'

export async function POST(request: Request) {
  const body = await request.text()
  verifyWebhook({ body, headers: request.headers, secret: process.env.WEBHOOK_SECRET! })
  const payload: unknown = JSON.parse(body) // Parse only after verification.
  return new Response(null, { status: 204 })
}

Verifies Standard Webhooks HMAC-SHA256 signatures against the exact body bytes and checks timestamp tolerance. Successful verification returns undefined. It does not parse JSON or record the event ID.

The input table below is generated from the public function signature. VerifyOptions is a documentation alias, not a named package export.

Prop

Type

InputContract
bodyRequired raw string or Uint8Array, before JSON parsing or reserialization.
headersRequired Headers or record of string, string-array, or undefined values. Relevant values must each be one string.
secretOne signing secret or an array of 1 through 16 secrets.
nowOptional valid Date, defaulting to the current time.
toleranceSecondsOptional nonnegative safe integer, default 300. Applies to timestamps in the past and future.
HeaderRule
webhook-id1 through 255 visible ASCII characters without dots or commas.
webhook-timestampNonnegative integer Unix seconds.
webhook-signatureAt most 4,096 characters. Space-separated v1,<base64> entries.

Header names are case-insensitive. Duplicate relevant names or array-valued relevant headers are rejected.

Signing secrets have a whsec_ prefix and canonical base64 encoding of 24 through 64 bytes. Good Webhooks generates 32-byte secrets. Verification succeeds when a v1 signature matches any supplied secret, so pass [newSecret, oldSecret] during secret rotation.

Limits you own

verifyWebhook has no request-body size limit. Limit uploads before buffering. Receiver deduplication of webhook-id is also your responsibility. See process each event once.

Failures

Both functions throw WebhookError for expected failures. The receiving guide maps each code to an HTTP response.

FailureResult
Invalid body type, signing secret, clock, or toleranceINVALID_INPUT
Missing or malformed signature headers, or signature mismatchSIGNATURE_INVALID
Timestamp outside toleranceSIGNATURE_EXPIRED
Body over 512 KiBPAYLOAD_INVALID from parseWebhook
Invalid UTF-8, JSON, envelope, unknown event, or rejected event payloadPAYLOAD_INVALID from parseWebhook
Missing event map or invalid selected validatorINVALID_CONFIG from parseWebhook
Validator throws or returns an invalid resultOrdinary Error with a cause, not an expected request rejection

On this page