Skip to content
Webhooks SDK
Esc
navigateopen⌘Jpreview
On this page

Errors & outcomes

Failures are returned, not thrown — every result carries an outcome, and every error a code and status.

handler.process() never throws. Every delivery produces a WebhookResult:

const result = await handler.process(request)

result.ok       // boolean
result.outcome  // 'handled' | 'unhandled' | 'duplicate' | 'handshake' | 'failed'
result.event    // present whenever verification succeeded
result.error    // present when something went wrong
result.response // present when outcome is 'handshake'
Outcome Meaning
handled Verified, and at least one handler ran.
unhandled Verified, but nothing was registered for event.type.
duplicate Suppressed by the idempotency store.
handshake A setup challenge was answered; no event was dispatched.
failed Rejected before dispatch, or a handler threw.

When you mount handler.fetch instead, the mapping to HTTP is automatic: a handshake Response passes through verbatim, an error becomes { error, message, provider } JSON with the error’s status, and everything else returns 200.

The error taxonomy

Every failure is a WebhookError carrying a code, an HTTP status, the provider id, and an isVerificationFailure flag:

Class code Status Verification failure? Meaning
MissingSignatureError missing_signature 400 No signature header, or a malformed one.
SignatureVerificationError invalid_signature 401 Signature present but didn’t match.
TimestampToleranceError timestamp_out_of_tolerance 400 Signed timestamp outside the replay window.
PayloadParseError invalid_payload 400 Body verified but unreadable as an event.
ConfigurationError invalid_configuration 500 Missing secret, malformed key, missing Twilio URL — your bug, surfaced loudly.
KeyUnavailableError key_unavailable 500 Remote JWKS/cert unfetchable — an outage, deliberately not a verification failure.
DuplicateEventError duplicate_event 200 Already processed; acknowledged so the provider stops redelivering.
UnknownProviderError unknown_provider 404 The router couldn’t resolve a provider.
HandlerError handler_failed 500 Your handler threw; the delivery itself was genuine, so the provider retries.

All classes are exported from the root, along with an isWebhookError(value) guard. isVerificationFailure is the flag to alert on: a burst of true means someone is probing your endpoint or a secret is wrong; false codes are operational.

Observing failures

onError runs for every failure — including suppressed duplicates — with the error and, when available, the raw request:

createWebhookHandler({
  provider: stripe({ secret }),
  onError: (error, raw) => {
    metrics.increment(`webhook.${error.code}`)
    if (error.isVerificationFailure) alertSecurity(error, raw?.header('user-agent'))
  },
  on: { /* … */ },
})

A throw inside onError is swallowed — observability can never mask the original failure.

Handler errors retry by design

When one of your handlers throws, the result is failed with a HandlerError (status 500), and — because the store only remembers an event after successful dispatch — the provider’s retry re-runs your handler instead of being swallowed as a duplicate. Transient failures heal themselves; persistent ones keep surfacing until you fix them.

Standalone functions are the exception to “never throws”: verifyStripeWebhook and friends throw these same error classes directly — see Standalone verification.

Was this page helpful?