ctx every other handler gets.
Basic webhook
Declare the webhook
Event object
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 webhookid is not the vendor’s event name. Map them explicitly:
Testing
UseinvokeWebhook():
Signature verification
The transport verifies, not you. In the harness and underinvokeWebhook(), 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/timestampKeyonly apply toformat: "keyed". Setformat: "keyed"or remove them.signedPayloadmust include"{body}", and only"{body}"and"{timestamp}"are available as placeholders.signedPayloaduses"{timestamp}"but neithertimestampKeynortimestampHeadersays where the timestamp comes from.- A timestamp source is declared but
signedPayloaddoes not include"{timestamp}". A replay window over an unsigned timestamp protects nothing, so either sign it or drop the timestamp source. - Both
timestampKeyandtimestampHeaderare declared. Use one. toleranceSecondsneeds a timestamp source to apply to.timestampHeaderis the same header assignature.header. A timestamp carried inside the signature header is atimestampKey.headerandtimestampHeadermay not be a transport header:authorization,cookie,set-cookie,host,content-length,transfer-encoding.
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.