> ## Documentation Index
> Fetch the complete documentation index at: https://sdk.ekanos.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Inbound event handlers from external services with signature validation and schema validation.

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

```ts theme={null}
import type { PartnerWebhookDeclaration } from '@ekanos/sdk/integration';
import { z } from 'zod';

const PaymentUpdatedPayloadSchema = z.object({
  eventType: z.literal('payment.updated'),
  payment: z.object({
    id: z.string(),
    customerName: z.string(),
    amountCents: z.number(),
    currency: z.string(),
    status: z.enum(['pending', 'succeeded', 'failed']),
  }),
  previousStatus: z.enum(['pending', 'succeeded', 'failed']),
});

type PaymentUpdatedPayload = z.infer<typeof PaymentUpdatedPayloadSchema>;

export const paymentUpdatedWebhook: PartnerWebhookDeclaration<MyStorage> = {
  id: 'payment-updated', // kebab-case, YOUR name (not vendor's)
  description: 'Acme posts one event per payment status change.',

  payloadSchema: PaymentUpdatedPayloadSchema,

  // How the TRANSPORT verifies. Use 'none' to state explicitly that the
  // source is unsigned. Your handler never checks a signature.
  signature: {
    header: 'x-acme-signature',
    secretName: 'acme_webhook_secret',
  },

  examplePayload: {
    eventType: 'payment.updated',
    payment: {
      id: 'pay_3RkQ9fLm2xTvB1',
      customerName: 'Northwind Logistics',
      amountCents: 128450,
      currency: 'USD',
      status: 'succeeded',
    },
    previousStatus: 'pending',
  },

  async handler(ctx, event) {
    // `payload` is already parsed and validated, but typed `unknown` — narrow it
    const { payment, previousStatus } = event.payload as PaymentUpdatedPayload;

    if (payment.status === previousStatus) {
      return { status: 'ignored', detail: `Still "${payment.status}".` };
    }

    await ctx.storage.account.set('sync_state/last-payment-update', {
      payment,
      previousStatus,
      receivedAt: event.receivedAt,
    });

    return {
      status: 'processed',
      detail: `${previousStatus} → ${payment.status}.`,
    };
  },
};

// In defineIntegration:
export const integration = defineIntegration({
  // ...
  webhooks: [paymentUpdatedWebhook],
});
```

## Declare the webhook

```ts theme={null}
webhooks: [
  {
    id: 'payment-updated', // kebab-case
    description: 'Acme posts events…',
    payloadSchema: PaymentUpdatedPayloadSchema,
    signature: {
      header: 'x-acme-signature',
      secretName: 'acme_webhook_secret',
    },
    examplePayload: {/* … */},
    async handler(ctx, event) {
      /* … */
    },
  },
];
```

## Event object

```ts theme={null}
{
  id: string; // Unique event ID
  receivedAt: string; // ISO 8601 timestamp
  headers: Record<string, string>; // Delivery headers
  payload: unknown; // Already parsed and validated against payloadSchema
}
```

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

## Result contract

| Outcome | How to signal | What the transport does |
| - | - | - |
| Handled | `return { status: 'processed' }` | 2xx — success |
| Valid but irrelevant | `return { status: 'ignored' }` | 2xx — sender must not retry |
| Could not handle | **throw** | retryable failure |

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:

```ts theme={null}
// ❌ Wrong — invalid because vendor names use underscores/dots
id: 'payment_intent.succeeded'  // Error at import time

// ✅ Right — your name, vendor's name in the description
{
  id: 'stripe-payment-success',
  description: 'Stripe payment_intent.succeeded event.',
  // Keep a map somewhere:
  // 'payment_intent.succeeded' → 'stripe-payment-success'
}
```

The validation error is clear, but it fires after you've named things — use a constant for the mapping.

## Testing

Use `invokeWebhook()`:

```ts theme={null}
import { invokeWebhook } from '@ekanos/sdk/testing';

const { result, ctx } = await invokeWebhook(
  integration,
  'payment-updated',
  {
    eventType: 'payment.updated',
    payment: { id: 'pay_123', status: 'succeeded' /* … */ },
    previousStatus: 'pending',
  },
  {
    contextOptions: { storage: {/* … */} },
  },
);

expect(result.status).toBe('processed');
expect(
  ctx.dumpStorage().account['sync_state/last-payment-update'],
).toBeDefined();
```

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.

```ts theme={null}
signature: {
  header: 'stripe-signature',
  secretName: 'stripe_webhook_secret',
  scheme: {
    format: 'keyed',
    digestKey: 'v1',
    timestampKey: 't',
    signedPayload: '{timestamp}.{body}',
    toleranceSeconds: 300,
  },
},
```

| Field | Type | Meaning |
| - | - | - |
| `algorithm` | `'sha256'` \| `'sha1'` | HMAC digest algorithm. Default `'sha256'`. Always the declared one, never the one a sender labels the header with. |
| `format` | `'digest'` \| `'keyed'` | `'digest'` (default): the header is the digest, optionally labelled `<algorithm>=`. `'keyed'`: comma-separated `k=v` pairs, one of which is the digest. |
| `digestKey` | string | Keyed only: the pair carrying the digest (Stripe/Calendly `v1`, Retell `d`). |
| `timestampKey` | string | Keyed only: the pair carrying the timestamp (Stripe/Calendly `t`, Retell `v`). |
| `timestampHeader` | string | A separate header carrying the timestamp (Svix-style `svix-timestamp`) instead of a pair. |
| `signedPayload` | string | What the HMAC covers, a template over `{body}` (required) and `{timestamp}`. Default `'{body}'`. |
| `toleranceSeconds` | integer, 0 to 86400 | Replay window, applied whenever a timestamp source is declared. Default 300; `0` disables the freshness check. Seconds and milliseconds are both accepted from the sender. |

Common providers:

| Provider | `scheme` |
| - | - |
| Stripe, Calendly (`t=…,v1=…`, HMAC over `"<t>.<body>"`) | `{ format: 'keyed', digestKey: 'v1', timestampKey: 't', signedPayload: '{timestamp}.{body}', toleranceSeconds: 300 }` |
| Retell (`v=<unix_ms>,d=<hex>`, HMAC over `body + timestamp`) | `{ format: 'keyed', digestKey: 'd', timestampKey: 'v', signedPayload: '{body}{timestamp}' }` |
| Intercom (`X-Hub-Signature: sha1=…`) | `{ algorithm: 'sha1' }` |
| Svix-style single signature (`svix-timestamp` header, `"<ts>.<body>"`) | `{ timestampHeader: 'svix-timestamp', signedPayload: '{timestamp}.{body}' }` |
| GitHub, Linear | omit `scheme`. Linear's replay guard is a field inside the signed body, so check `webhookTimestamp` in the handler. |

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](/sdk/surfaces/schedules) — cron-driven handlers
* [Testing](/sdk/testing) — test webhooks with invokeWebhook()


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.