Webhooks
The events Connect sends, how to verify them, what a receiver owes, and how to debug a delivery that never arrived.
Connect POSTs events to your endpoint, signed with a secret only you and Connect hold. Delivery is at-least-once and unordered.
Connect ──POST──► https://your-app.example.com/connect/events
Connect-Signature: t=…,v1=…
Connect-Event-Id: 01J…
Connect-Delivery: 1Webhooks are the fast channel, not the authoritative one. The readiness endpoint is what your product should reconcile against; webhooks are what stop it waiting for the next poll. An integration that treats an event's arrival as the only source of truth breaks the first time one is delayed.
The receiver, in two lines
// app/api/connect/events/route.ts
import { createConnectWebhookHandler } from "@cosmoops/connect-sdk/server";
export const POST = createConnectWebhookHandler({
secret: process.env.CONNECT_WEBHOOK_SECRET!,
on: {
"workspace.deletion_scheduled": ({ workspaceId, purgeAfter }) =>
freeze(workspaceId, purgeAfter),
"workspace.deletion_cancelled": ({ workspaceId }) => unfreeze(workspaceId),
"workspace.deleted": ({ workspaceId }) => purge(workspaceId),
"subscription.cancelled": ({ workspaceId }) => dropEntitlements(workspaceId),
},
});That handler already does the four things below correctly. Read them anyway — if you are writing your own receiver, they are the contract.
What a receiver owes
Verify before you parse
The signature is over the raw body.
JSON.stringify(await request.json()) is a different string — key order and
number formatting both differ — and will never verify. Read the body as text,
check the signature, then parse.
An unverified request gets a 401 and nothing else happens: no parsing, no logging of the body, no handler. The endpoint is a public URL, so anything reachable before the signature check is reachable by anyone.
Answer 2xx quickly
Acknowledge as soon as the event is stored, then do the work. Anything that is not a 2xx is retried.
Dedupe on the event id
Connect-Event-Id is the envelope's id. At-least-once means you will see the
same event twice; unordered means a later event can arrive first. Both are
normal, and both are your side of the contract.
Return 200 for events you do not handle
Connect adds event types over time. Answering 4xx to one you have no code for dead-letters it immediately and, after enough of them, switches your endpoint off entirely. Silence is the correct answer to "we do not care about this one".
The envelope
{
"id": "e2b4…",
"type": "workspace.deletion_scheduled",
"createdAt": "2026-01-01T00:00:00.000Z",
"apiVersion": "2026-08-01",
"data": {
"workspaceId": "wks_…",
"purgeAfter": "2026-01-31T00:00:00.000Z",
"requestedBy": "owner",
"graceDays": 30,
"policy": {
"version": 3,
"fiduciaryRole": "processor",
"exportBeforePurge": true,
"policyUrl": "https://example.com/retention",
"dataClasses": [
{ "key": "profile", "label": "Contact profiles", "disposition": "erase", "retainDays": null, "basis": null },
{ "key": "call_records", "label": "Call records", "disposition": "retain", "retainDays": 395, "basis": "CGST s.36 — 72 months from the annual return" }
],
"resourceReleases": [
{ "key": "phone_number", "label": "Business phone number", "quarantineDays": 90 }
]
}
}
}policy is your application's declared
deletion policy, frozen as it stood when the
customer confirmed — not a summary of every application's. retainDays and
quarantineDays are the numbers your handler acts on; purgeAfter is only
Connect's own clock. It rides on workspace.deleted too, and is null only
for a deletion confirmed before policies existed.
apiVersion moves only for a breaking change to the envelope shape — never for
a new event type and never for a new optional field. Pin against it; do not pin
against the set of event types.
Events
| Event | What has happened | What you should do |
|---|---|---|
workspace.deletion_scheduled | A customer confirmed deletion. Access is already denied; the grace window is running. | Start your own wind-down on the same clock. purgeAfter is when the data stops existing here; policy is what you promised about your own copy. |
workspace.deletion_cancelled | The deletion was withdrawn inside the window. | Unfreeze. Skipping this locks a paying customer out of a product that never found out. |
workspace.deleted | The window closed and the data is gone from Connect. | Apply the deletion policy the event carries — erase, anonymise, or archive for its retainDays. |
subscription.cancelled | A subscription ended. Fires on its own, not only as part of a deletion — and when it takes effect, not when a customer schedules it. | Drop the entitlements that plan granted. The workspace still exists. |
subscription.activated | A subscription became active for the first time: a free plan or a trial started, or a paid plan's first charge was captured. Not on a renewal, and not when a paused subscription recovers. | Grant what the plan includes and claim anything it entitles the workspace to. trialEndsAt is set when it started on a trial. |
Lifecycle, and activity
The five above are lifecycle: a customer relationship started, paused or ended, and ignoring one leaks access. The rest are activity — something that happened to a customer of yours, in a channel Connect operates on your behalf.
Activity events reach you only for workspaces subscribed to your application, so a lapsed customer stops emitting on the same boundary that stops their access. They exist because your own system of record cannot reconstruct them: nothing you can ask Connect for tells you a call rang out at 4pm.
| Event | What has happened | What you should do |
|---|---|---|
call.completed | A call ended, with its outcome, duration and whatever the agent captured while it ran. | File it against the caller. This is what puts a conversation that happened inside Connect into your own timeline with nobody retyping it. |
call.missed | A call reached a channel and nothing answered. | Ring them back, or raise a lead. Separate from call.completed because the answer is a person to chase rather than a record to file. |
booking.confirmed | An appointment was booked and is holding its slot — however it was booked, including by a voice agent mid-call. | Create or match your own record. One shape for every booking route, so there is nothing to reconcile about who created it. |
booking.cancelled | An appointment was cancelled and its slot is free. | Close your own record. Any preparation booked against it is void. |
booking.rescheduled | An appointment moved. | Reconcile on previousBookingId — see below. |
contact.created | Connect learned about a person, most often because an agent's skill captured them mid-call. | Create your own record. Without this the capture is invisible in the product your customer actually works in. |
contact.updated | A known contact's details changed. | Apply changed as a patch. Overwriting wholesale replaces fields your own users curated with what an agent heard over a bad line. |
Three conventions hold across all of them, and each has a failure mode worth knowing:
Phone numbers are E.164. That is the canonical form Connect stores, so you can match one against your own records without guessing a country.
A call identifies its party by number, never by a contact id. Connect
resolves a caller to a contact by the number, and at the moment a call ends
it often has not — a contactId would be null on most deliveries, which is
worse than absent because you would write a branch for it. Pass contactPhone
to contacts.lookupByPhone if you want the contact.
A reschedule names both bookings. Connect retires the old row and writes a
new one rather than mutating it, so booking.rescheduled carries
previousBookingId alongside bookingId. Match on the previous id — matching
on the new one leaves you holding two appointments.
Signature verification, by hand
For a receiver that is not on Node, or one you would rather own:
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verify(rawBody: string, header: string, secret: string): boolean {
const parts = new Map(
header.split(",").map((p) => p.trim().split("=") as [string, string]),
);
const t = Number(parts.get("t"));
const presented = parts.get("v1");
if (!presented || !Number.isFinite(t)) return false;
// Absolute difference: a timestamp from the future is as much a forgery
// signal as a stale one, and accepting it would let an attacker mint a
// signature that stays valid indefinitely.
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - t) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
if (presented.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(presented), Buffer.from(expected));
}The t=/v1= format is Stripe's. Matching it is worth more than improving on
it: a backend that already verifies Stripe webhooks needs its constants changed
and nothing else.
The timestamp is inside the signed string on purpose. Signing the body alone produces a token that stays valid forever, so a captured request could be replayed at any point in the future.
Retries, and when Connect gives up
A failed delivery is retried on a fixed ladder: 7 attempts over about 33 hours — 1 minute, 5, 30, 2 hours, 6, 24.
- 5xx and network failures retry. Something is wrong on your side and might not be in an hour.
- 4xx is terminal. You are telling us the request is wrong or the route does not exist, and repeating it for 33 hours turns that into load.
- 408 and 429 are the exceptions. Both explicitly mean "try again", so both keep retrying.
After 20 consecutive failures across deliveries, the endpoint is switched off automatically and its owner is emailed. That is roughly a week of a receiver being genuinely dead — far enough out that a bad afternoon never trips it. Re-enabling it also clears the count.
A delivery that exhausts the ladder is dead-lettered: terminal, visible in the Console, never retried automatically.
Debugging a delivery
Everything below is in the Console, on your application's Webhooks section.
- The delivery log shows every delivery, filtered by status, event type or endpoint, and paged all the way back — not just the recent ones.
- Opening a delivery shows the exact envelope that was sent (the same string the signature was computed over), the headers, and a per-attempt trace: what came back each time, how long it took, and why it stopped. That is what distinguishes a receiver that 500s once and recovers from one that has been timing out for a day.
- Re-send queues a dead-lettered delivery again. It is a new row with a new event id, so a receiver deduping on the id will process it as a distinct event — which is the intent: re-send is for an event that was never successfully handled.
- Send a test event puts a synthetic event down the real delivery path — same signature, same ladder, same trace — before a real deletion depends on it working.
Test events
A test delivery carries one extra header:
Connect-Test: trueIt is absent entirely on a real event, so a receiver that ignores it behaves
exactly as it did before the header existed. The payload names ids that do not
exist (wks_test_…), so a handler that checks either the header or the id will
no-op.
A handler that checks neither will act on a workspace it has never seen. That
is the same no-op it already owes any event for an unknown id — but if your
workspace.deleted handler deletes first and looks up second, find out from a
test event rather than from a real one.
Endpoint setup
Register endpoints in the Console. Notes worth having up front:
httpsonly. The payload names which of your customers is leaving; the signature proves who sent it, not who can read it. Use a tunnel locally — those are https anyway.- The signing secret is shown once, at creation. No read path returns it. To rotate, remove the endpoint and add it back.
- Subscriptions are explicit. An endpoint receives only the events it subscribes to. Empty is not shorthand for "all" — it means nothing arrives.
- Up to 25 endpoints per application. One per environment is the usual shape.