Platform
Webhooks
Signed events for users, sessions, organizations and security changes.
Endpoints#
Add endpoints per environment in the dashboard's Webhooks page, or with POST /app/environments/:environmentId/webhooks and your API key. An endpoint receives every event, or the list you give it. Its whsec_… signing secret is shown once, when it is created or rotated.
Events#
| Group | Types |
|---|---|
| Authentication | authentication.succeeded, authentication.failed |
| Users | user.created, user.updated, user.deleted, user.deprovisioned |
| Passwords | password.reset_requested, password.reset |
| Sessions and MFA | session.revoked, mfa.factor_enrolled |
| Organizations and directories | organization.created, organization.deleted, group.created, group.updated, group.deleted |
| Credentials and SSO | api_key.created, api_key.revealed, api_key.revoked, signing_key.rotated, signing_key.retired, sso_connection.created, sso_connection.updated, sso_connection.deleted |
Every event has the same envelope:
{
"id": "evt_…",
"object": "event",
"type": "user.created",
"created_at": "2026-09-29T10:15:00.000Z",
"environment_id": "…",
"organization_id": null,
"data": { "id": "…", "email": "ada@example.com" }
}Verifying signatures#
Each delivery carries 3een-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">. verifyWebhook checks it in constant time, refuses a timestamp more than five minutes away (a replay), and returns the parsed event. Pass the raw body, not re-serialized JSON.
// app/api/webhooks/3een/route.ts
import { NextResponse } from 'next/server';
import { verifyWebhook, WebhookVerificationError } from '@3een/auth/nextjs';
export async function POST(request: Request) {
// The raw body: the signature covers the exact bytes that were sent.
const rawBody = await request.text();
const secret = process.env.AUTH_WEBHOOK_SECRET; // whsec_…, server only
if (!secret) return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 });
try {
const event = verifyWebhook<{ id: string; email?: string }>(
rawBody,
request.headers.get('3een-Signature'),
secret,
);
if (event.type === 'user.created') {
// provision the user in your own database, keyed by event.data.id
}
return NextResponse.json({ received: true });
} catch (error) {
if (error instanceof WebhookVerificationError) {
// Bad signature, or a timestamp outside the 5-minute window (a replay).
return NextResponse.json({ error: error.message }, { status: 400 });
}
throw error;
}
}Delivery and retries#
- A delivery succeeds on any 2xx answer within 10 seconds.
- Failures are retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours, then marked dead.
- Dead deliveries can be replayed from the dashboard or the API; each delivery is sent by one worker only.
- Receivers should be idempotent: use the event
idto ignore a repeat.