Webhooks
Get told when users sign up, change or sign in, and when sessions and tokens are revoked: signed, retried, deduplicated.
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
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, null once deleted). Every event type and its fields: webhook events reference. JSON Schema: /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.
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:
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:
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). - Security alerts:
user.login_failed,user.password_changed,user.mfa_disabled.