# Webhooks

> Get told when users sign up, change or sign in, and when sessions and tokens are revoked: signed, retried, deduplicated.

Source: https://9in.io/docs/webhooks/

Webhooks tell your back end when something happens: a user signs up, changes, signs in or is deleted; a session or token is revoked. 9in POSTs a signed JSON event to your HTTPS endpoint and retries until you accept it.

## Add an endpoint

**Advanced → Webhooks → Add endpoint**: your HTTPS URL and the events to send. The **signing secret** is shown once; you can generate a new one later. **Send test** delivers a `webhook.test` event so you can check your handler.

Both production and sandbox events go to the same endpoints; check the event's `environment`.

## The request

```http
POST /webhooks/9in HTTP/1.1
Host: api.acme.com
Content-Type: application/json
User-Agent: 9in-webhooks/1
9in-signature: t=1794200000,v1=5f2c8e…
9in-event-id: evt_k3m9q…
9in-event-type: user.created
9in-delivery-id: whd_…

{
  "id": "evt_k3m9q…",
  "type": "user.created",
  "createdAt": "2026-11-09T08:00:00.000Z",
  "tenant": "tnt_…",
  "environment": "production",
  "data": { "userId": "usr_…", "user": { "id": "usr_…", "email": "jane@acme.com", … } }
}
```

`data` carries the event's details plus, for user events, `userId` and a `user` snapshot (the [Management API user object](https://9in.io/docs/reference/management-api/), `null` once deleted). Every event type and its fields: [webhook events reference](https://9in.io/docs/reference/webhook-events/). JSON Schema: [/schemas/webhook-event.json](https://9in.io/schemas/webhook-event.json).

## Verify the signature

`9in-signature` is `t=<unix seconds>,v1=<hex HMAC-SHA256>` over `t + ".POST." + path + "." + rawBody`, with your endpoint's secret. Check it against the **raw** body, before parsing.

```ts
import { verifyWebhook } from "@9in-io/sdk/server";

export async function POST(request: Request) {
  const event = await verifyWebhook(process.env.NINE_WEBHOOK_SECRET!, request); // throws NineError("invalid_signature")
  switch (event.type) {
    case "user.created": await crm.addContact(event.data.user); break;
    case "user.deleted": await crm.removeContact(event.data.userId); break;
  }
  return new Response(null, { status: 204 });
}
```

`verifyWebhook` allows 5 minutes of clock skew, accepts several secrets while you rotate (`[newSecret, oldSecret]`), and reads the body, so pass `request.clone()` if you need it again. Behind a proxy that rewrites the path, pass the path 9in posted to: `verifyWebhook(secret, request, { path: "/webhooks/9in" })`.

Without the SDK:

```python
import hmac, hashlib, time

def verify(secret: str, header: str, path: str, raw_body: bytes) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    if abs(time.time() - int(parts["t"])) > 300:
        return False
    signed = f'{parts["t"]}.POST.{path}.'.encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])
```

## Delivery and retries

- Answer with any **2xx within 10 seconds**. Do slow work in a queue.
- Anything else (another status, a timeout, a network error, a redirect, which isn't followed) is retried after **1 minute, 6 minutes, 36 minutes, 3.6 hours and 21.6 hours**: six attempts over about a day. Then the delivery is marked failed and the console shows an alert.
- The console lists every delivery with its status and response code, and can **redeliver** any event.
- Events are delivered **at least once**, and not necessarily in order: use `createdAt`, and re-read the user when order matters.

## Process each event once

Retries and redeliveries carry the same `id` (also in `9in-event-id`), and a retry can arrive while a slow first attempt is still running. `processEventOnce` runs your handler once per id:

```ts
import { processEventOnce, verifyWebhook } from "@9in-io/sdk/server";

const event = await verifyWebhook(secret, request);
const outcome = await processEventOnce(store, event.id, () => handle(event));
// processed or duplicate → 204; in_progress → 409, so 9in retries later
return new Response(null, { status: outcome.status === "in_progress" ? 409 : 204 });
```

`store` must add keys atomically: a Durable Object, SQL with a unique key, Redis `SET NX EX` (not Workers KV). `MemoryEventStore` covers a single process.

## Common uses

- **Sync users** to your database or CRM: `user.created`, `user.updated`, `user.deleted`.
- **Revoke cached tokens** in your API: `token.revoked`, `session.revoked`, `user.sessions_revoked` (see [hybrid verification](https://9in.io/docs/protecting-apis/#hybrid-recommended)).
- **Security alerts:** `user.login_failed`, `user.password_changed`, `user.mfa_disabled`.
