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:  1
```

<Callout>
Webhooks 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.
</Callout>

## The receiver, in two lines

```ts
// 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

<Steps>

<Step>

### 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.

</Step>

<Step>

### Answer 2xx quickly

Acknowledge as soon as the event is stored, then do the work. Anything that is
not a 2xx is retried.

</Step>

<Step>

### 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.

</Step>

<Step>

### 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".

</Step>

</Steps>

## The envelope

```json
{
  "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](/docs/concepts/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](/docs/concepts/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:

```ts
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: true
```

It 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.

<Callout type="warn">
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.
</Callout>

## Endpoint setup

Register endpoints in the Console. Notes worth having up front:

- **`https` only.** 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.