Guide

Webhooks

Register endpoints, verify signed payloads, understand the retry policy and deliveries log.

Webhooks deliver every event you care about to your own URL, signed and retried. The full event catalog lives at Webhook events.

Register an endpoint

curl -X POST "https://api.clement.so/v1/webhooks" \
  -H "Authorization: Bearer $CLEMENT_API_KEY" \
  -H "Content-Type: application/json" \
  --data '{
    "url": "https://your-app.example.com/clement",
    "enabled_events": ["message.replied", "message.bounced", "contact.unsubscribed"],
    "description": "CRM sync"
  }'

The response includes a secret — the signing key. This is shown once. Store it next to your other deployment secrets and rotate via POST /v1/webhooks/{id}/rotate if it leaks. (It isn’t stored for idempotent replay either: retrying the create with the same Idempotency-Key returns 409 idempotency_replay_unavailable naming the endpoint, never a second endpoint.)

Endpoints are mode-scoped. An endpoint takes the mode of the key that created it (mode in the response) and receives only events of that mode, and each key sees only its own mode’s endpoints. Dashboard endpoints are live. Live endpoint URLs must be https://; redirects are never followed, so register the final URL.

You can subscribe to specific events or pass enabled_events: ["*"] for everything. The wildcard genuinely subscribes to every event in the catalog — including high-volume ones like message.opened, message.clicked, and campaign.contact.advanced. Most integrations want a specific list.

Endpoint verification

Right after you register an endpoint (or change its url), Clement sends it a signed webhook.verification event. Answer it with any 2xx status, which your handler already does, and the endpoint’s verified_at is set and events start flowing. Until then the endpoint receives no other events. The verification request retries on the schedule below, and a successful test delivery also verifies the endpoint.

Verify the signature

Every delivery carries a Stripe-style signature:

Clement-Signature: t=1714298400,v1=hex(hmac_sha256(secret, "t.body"))
Clement-Event-Id: evt_01HZ...
Clement-Event-Type: message.replied
Clement-Delivery-Id: whd_01HZ...
Clement-Delivery-Attempt: 1

Verify it before doing anything with the payload:

import crypto from "node:crypto";

function verifyClementSignature(req: {
  headers: Record<string, string>;
  rawBody: string; // the raw bytes — do not parse before verifying
}, secret: string) {
  const header = req.headers["clement-signature"];
  if (!header) throw new Error("missing signature");
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = parts["t"];
  const v1 = parts["v1"];
  if (!t || !v1) throw new Error("malformed signature");

  // Reject if t is older than 5 minutes — replay protection.
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) throw new Error("stale signature");

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${req.rawBody}`)
    .digest("hex");

  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))) {
    throw new Error("bad signature");
  }
}

Two things to get right:

  • Verify the raw body, not a JSON-parsed-and-restringified copy. Express-style middleware that parses JSON before your handler will silently break signature checks unless you preserve the raw bytes.
  • Use a constant-time compare (crypto.timingSafeEqual) — naive === leaks signature bytes via timing.

Idempotent processing

Retries are real. Clement-Event-Id is stable across retries — dedupe on it.

async function handleEvent(eventId: string, payload: ClementEvent) {
  const seen = await db.events.findUnique({ where: { id: eventId } });
  if (seen) return; // already processed
  await db.events.create({ data: { id: eventId, processedAt: new Date() } });
  // ... your logic ...
}

Retry policy

A 2xx response within 15 seconds counts as success. Anything else — including a redirect, which is never followed — triggers retries with exponential backoff:

Attempt After
2 1 minute
3 5 minutes
4 30 minutes
5 2 hours
6 8 hours
7 24 hours

After the final failure, Clement emits webhook.delivery.failed (delivered to other registered endpoints of the same mode — a failing endpoint can’t notify itself). An endpoint that has failed every delivery for 7 days (and at least 7 attempts) since its last success auto-disables and shows as disabled in the dashboard; any successful delivery resets the clock. Re-enable it with PATCH /v1/webhooks/{id} and {"disabled": false} once it’s fixed. The deliveries log records the status code (or a short network reason) for each failed attempt, not your endpoint’s response body.

Order is not guaranteed across or within event types. Sort by created_at if you need order.

Deliveries log

Every delivery — success and failure — is logged for 30 days:

curl "https://api.clement.so/v1/webhooks/whk_.../deliveries" \
  -H "Authorization: Bearer $CLEMENT_API_KEY"

Each entry has the response code, latency, body, and headers. If a customer asks “why didn’t my webhook fire?” the answer is one curl away.

To replay a delivery (for example after fixing an outage on your end):

curl -X POST "https://api.clement.so/v1/webhooks/whk_.../deliveries/whd_.../redeliver" \
  -H "Authorization: Bearer $CLEMENT_API_KEY"

Test before you go live

curl -X POST "https://api.clement.so/v1/webhooks/whk_.../test" \
  -H "Authorization: Bearer $CLEMENT_API_KEY"

Sends a synthetic event with metadata.test = true. Run this from CI; if it doesn’t get a 2xx, your endpoint isn’t ready.