Users

Webhooks

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

View .md llms.txt

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

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).
  • Security alerts: user.login_failed, user.password_changed, user.mfa_disabled.