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#

GroupTypes
Authenticationauthentication.succeeded, authentication.failed
Usersuser.created, user.updated, user.deleted, user.deprovisioned
Passwordspassword.reset_requested, password.reset
Sessions and MFAsession.revoked, mfa.factor_enrolled
Organizations and directoriesorganization.created, organization.deleted, group.created, group.updated, group.deleted
Credentials and SSOapi_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:

Event

{
  "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.tstype-checked

// 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 id to ignore a repeat.