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 your app subscribes to. Register a URL with POST /v1/webhooks. 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
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 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 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
// 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
$ cymatics listen --forward-to http://localhost:3000/api/cymo-webhook

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