Skip to main content
Webhooks handle inbound events from your provider. The transport validates the payload and signature; your handler processes the event with the same ctx every other handler gets.

Basic webhook

Declare the webhook

Event object

Payload is guaranteed to match your schema. Signature verification is the transport’s job — don’t do it in your handler.

Result contract

Both processed and ignored return 2xx, and detail is optional on both. Only throw for genuine failures that should be retried.

ID vs vendor event name

Your webhook id is not the vendor’s event name. Map them explicitly:
The validation error is clear, but it fires after you’ve named things — use a constant for the mapping.

Testing

Use invokeWebhook():
Payload validation is automatic — if the payload fails the schema, the handler never runs.

Signature verification

The transport verifies, not you. In the harness and under invokeWebhook(), verification is skipped and recorded as one warn log line. In production, the host ingress enforces it before your handler runs, using the scheme you declared (or the default HMAC-SHA256 of the raw body). Do not verify it yourself — you would be verifying a header the transport already consumed.

signature.scheme — when the provider is not a plain body digest

Without scheme, the transport expects the header to be an HMAC-SHA256 of the raw body, hex or base64, optionally labelled sha256= (GitHub, Linear). Most other providers differ in one of three ways: they pack the digest into k=v pairs, they sign a timestamp along with the body, or they use SHA-1. scheme describes that declaratively. The transport does the arithmetic; your handler still contains no verification code.
Common providers: Cross-field rules are enforced at parse time, so a declaration that cannot be verified fails in defineIntegration() and ekanos validate, not as a 401 on the first delivery. Each message below is the schema’s own text:
  • webhooks[].signature.scheme.digestKey is required when format is "keyed" — name the pair that carries the digest.
  • digestKey / timestampKey only apply to format: "keyed". Set format: "keyed" or remove them.
  • signedPayload must include "{body}", and only "{body}" and "{timestamp}" are available as placeholders.
  • signedPayload uses "{timestamp}" but neither timestampKey nor timestampHeader says where the timestamp comes from.
  • A timestamp source is declared but signedPayload does not include "{timestamp}". A replay window over an unsigned timestamp protects nothing, so either sign it or drop the timestamp source.
  • Both timestampKey and timestampHeader are declared. Use one.
  • toleranceSeconds needs a timestamp source to apply to.
  • timestampHeader is the same header as signature.header. A timestamp carried inside the signature header is a timestampKey.
  • header and timestampHeader may not be a transport header: authorization, cookie, set-cookie, host, content-length, transfer-encoding.
Not covered yet: Svix-style multi-signature headers (space-separated v1,<b64> values with a whsec_ base64 secret) and Slack’s v0= label over v0:{ts}:{body}. Declare signature: 'none' for those and say so in the webhook description. The type is exported as WebhookSignatureScheme from @ekanos/sdk/integration. In the harness, the Triggers tab shows the declared scheme as a badge (algorithm, signed payload, keyed pairs, timestamp source and replay window), so you can read back exactly what the host ingress will compute.

Payload schemas and zod 4

payloadSchema accepts a schema from zod 3 (^3.25.76) or zod 4. Deliveries are validated through the schema’s Standard Schema ~standard.validate, in the host and in invokeWebhook() alike.

Troubleshooting

Payload rejected by payloadSchema

invokeWebhook() throws Payload rejected by webhook "<id>"'s payloadSchema — the transport refuses such a delivery before the handler runs: followed by the zod issues by path. Check:
  • All required fields are present
  • Types match the schema
  • Enum values are exact

Webhook id rejected

defineIntegration() reports Webhook ids are kebab-case, e.g. "payment-updated" — the host ingress route addresses the handler by this exact string. Check for underscores, dots, and spaces. A duplicate id reports Webhook id "<id>" is declared more than once — give every webhook a unique id.

Webhook id not declared

invokeWebhook() throws Integration "<slug>" declares no webhook "<id>". and lists the declared ids.

Next steps

  • Schedules — cron-driven handlers
  • Testing — test webhooks with invokeWebhook()