# Webhooks

> Register a URL, verify each delivery's signature, handle retries and forward deliveries to your machine while you develop.

## Register a URL

Webhooks deliver the [events](https://docs.cymatics.tech/events) your app subscribes to. Register a URL with [POST /v1/webhooks](https://docs.cymatics.tech/api/create-webhook). The response includes the webhook's signing `secret` (`whsec_…`), shown only this once. Test mode delivers for real: simulator events your webhook subscribes to are signed and sent to its URL. The URL must use https and resolve to a public address; redirects aren't followed.

`terminal`

```sh
curl -X POST "https://api.cymatics.tech/v1/webhooks" \
  -H "Authorization: Bearer $CYMO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/hooks/cymo","events":["bed.exited"],"enabled":true}'
```

Each delivery is a `POST` whose body is the usual envelope: `data` is the event and `meta.request_id` is the delivery ID.

`request`

```
POST /api/cymo-webhook
Content-Type: application/json
User-Agent: Cymo-Webhooks/1.0
Cymo-Event-Type: bed.exited
Cymo-Delivery-Id: del_01
Cymo-Signature: t=1792886131,v1=<64 hex characters>
```

## Retries and deduplication

A 2xx answer within 10 seconds counts as delivered. Anything else is retried, for up to five attempts: one straight away, then 10 seconds, 1 minute, 10 minutes and 1 hour after each failure. Every attempt keeps the same `Cymo-Delivery-Id` and is signed again with a fresh timestamp. [GET /v1/webhooks/{webhook}/deliveries](https://docs.cymatics.tech/api/list-webhook-deliveries) lists each delivery's status (`pending`, `retrying`, `delivered`, `failed` or `cancelled`), attempts, last response status and next attempt.

Delivery is at least once, so the same event can arrive twice. Answer quickly, do the work after the response, and deduplicate by event `id`, keeping the highest `revision`.

[POST /v1/webhooks/{webhook}/test](https://docs.cymatics.tech/api/test-webhook) sends one `webhook.test` delivery straight away, without retries, and reports `network_delivery: true` only if your endpoint answered with a 2xx.

## Verifying signatures

`Cymo-Signature` is `t=<unix seconds>,v1=<signature>`: the signing time and a hex HMAC-SHA256, keyed with the webhook's secret, over the time, the delivery ID and the raw body:

`signature`

```
signed_payload = t + "." + Cymo-Delivery-Id + "." + raw_body
v1             = hex(HMAC-SHA256(webhook_secret, signed_payload))
```

- Verify the raw body exactly as received. Parsing and re-serialising the JSON breaks the signature.
- Compare signatures in constant time, and reject a `t` more than five minutes away.
- `t` is the delivery attempt's time, not the event's, so retries of old events still pass the freshness check.
- A header can carry several `v1` values, for example while a secret is rotated. The delivery is genuine if any of them matches.

`verifyWebhookSignature` in `@cymos/sdk` does all of this. It throws a `CymoError` with code `invalid_webhook_signature` or `webhook_timestamp_out_of_tolerance`, takes `toleranceSec` (default 300) and accepts an array of secrets, any of which may match, while you rotate your own.

`TypeScript · Next.js route handler`

```ts
// app/api/cymo-webhook/route.ts
import { after } from "next/server";
import {
  CymoError,
  verifyWebhookSignature,
  type Envelope,
  type Event,
} from "@cymos/sdk";

const rejected = new Set([
  "invalid_webhook_signature",
  "webhook_timestamp_out_of_tolerance",
]);

export async function POST(request: Request) {
  // The exact bytes received: re-serialised JSON fails verification.
  const payload = await request.text();
  try {
    await verifyWebhookSignature({
      payload,
      header: request.headers.get("cymo-signature"),
      deliveryId: request.headers.get("cymo-delivery-id"),
      // whsec_…, returned once by webhooks.create
      secret: process.env.CYMO_WEBHOOK_SECRET ?? "",
    });
  } catch (error) {
    if (error instanceof CymoError && rejected.has(error.code)) {
      return new Response(error.code, { status: 400 });
    }
    throw error;
  }

  const { data: event } = JSON.parse(payload) as Envelope<Event>;
  // Answer within 10 seconds; do the work after the response.
  after(() => handleEvent(event));
  return new Response(null, { status: 204 });
}

async function handleEvent(event: Event) {
  // At least once: skip an event.id already stored at this revision or later.
}
```

## Receive webhooks locally

Your laptop has no public URL, so let the CLI forward deliveries to a local route while you develop:

`terminal`

```sh
$ cymatics listen --forward-to http://localhost:3000/api/cymo-webhook
```

Forwarded deliveries carry `Cymo-Forwarded: cli`, so your handler can tell them apart.
