Webhooks
reauth sends webhook events to your server when things happen in your domain — user signups, subscription changes, payment outcomes, and more.
Setup
- Go to the domain webhooks settings in the dashboard
- Add an endpoint URL (must be HTTPS)
- Select which events to subscribe to
- Save — you'll receive a webhook secret (
whsec_...)
Maximum 10 webhook endpoints per domain.
Signature Verification
Every webhook includes a signature for verification. Always verify signatures before processing events.
Headers
| Header | Description |
|---|---|
reauth-webhook-signature | Signature: t=<timestamp>,v1=<hmac-sha256> |
reauth-webhook-id | Unique event ID |
reauth-webhook-timestamp | Unix timestamp (seconds) |
Verification
import { verifyWebhookSignature, WebhookVerificationError } from '@reauth-dev/sdk/webhooks';
app.post('/webhooks/reauth', express.raw({ type: 'application/json' }), (req, res) => {
try {
const event = verifyWebhookSignature({
payload: req.body.toString(),
signature: req.headers['reauth-webhook-signature'] as string,
secret: process.env.WEBHOOK_SECRET!, // "whsec_..."
tolerance: 300, // Optional, default 300 seconds (5 minutes)
});
// event is verified and parsed
console.log('Event type:', event.type);
console.log('Event data:', event.data);
// Process the event...
res.status(200).json({ received: true });
} catch (err) {
if (err instanceof WebhookVerificationError) {
console.error('Verification failed:', err.message);
res.status(400).json({ error: 'Invalid signature' });
return;
}
res.status(500).json({ error: 'Internal error' });
}
});
Important: Use express.raw() (not express.json()) for the webhook route so you get the raw body for signature verification.
Signature Algorithm
- Parse the
reauth-webhook-signatureheader: extractt(timestamp) andv1(signature) - Build signed payload:
${timestamp}.${rawBody} - Compute HMAC-SHA256 of the signed payload using the webhook secret
- Compare with timing-safe equality
- Check timestamp is within tolerance (default 300 seconds)
The whsec_ prefix on the secret is stripped automatically.
Event Envelope
Every event has this structure:
type WebhookEvent = {
id: string; // Unique event ID (UUID)
type: string; // Event type (e.g., "user.created")
api_version: string; // API version (e.g., "2026-01-29")
created_at: string; // ISO 8601 timestamp
domain_id: string; // Your domain ID
data: Record<string, unknown>; // Event-specific payload
};
Event Types
User Events
| Event | Trigger | Data Fields |
|---|---|---|
user.created | New user signs up | user_id |
user.deleted | User deletes account | user_id |
user.login | User logs in | user_id, auth_method |
user.frozen | User account frozen | user_id |
user.unfrozen | User account unfrozen | user_id |
user.whitelisted | User whitelisted | user_id |
user.unwhitelisted | User unwhitelisted | user_id |
user.roles_changed | User roles updated | user_id, old_roles, new_roles |
user.invited | User invited to domain | user_id |
Subscription Events
| Event | Trigger | Data Fields |
|---|---|---|
subscription.created | New subscription | user_id, plan_id, status |
subscription.updated | Status or plan changed | See payload variants below |
subscription.canceled | Subscription canceled | user_id, subscription_id |
subscription.updated payload variants:
The subscription.updated event is emitted for both status changes and plan changes. Inspect the data fields to determine which:
| Variant | Data Fields | When |
|---|---|---|
| Status change | user_id, plan_id, old_status, new_status | Subscription transitions (e.g., trialing → active) |
| Plan change | user_id, from_plan_code, to_plan_code, change_type | User upgrades/downgrades (change_type: "upgrade", "downgrade", or "lateral") |
Payment Events
| Event | Trigger | Data Fields |
|---|---|---|
payment.succeeded | Payment processed | user_id, amount_cents, currency, invoice_id |
payment.failed | Payment failed | user_id, amount_cents, currency, invoice_id |
Test Events
| Event | Trigger | Data Fields |
|---|---|---|
webhook.test | Test from dashboard | endpoint_id |
Full Express Handler Example
import express from 'express';
import { verifyWebhookSignature, WebhookVerificationError } from '@reauth-dev/sdk/webhooks';
const app = express();
// IMPORTANT: Use raw body for webhooks
app.post(
'/webhooks/reauth',
express.raw({ type: 'application/json' }),
async (req, res) => {
let event;
try {
event = verifyWebhookSignature({
payload: req.body.toString(),
signature: req.headers['reauth-webhook-signature'] as string,
secret: process.env.WEBHOOK_SECRET!,
});
} catch (err) {
if (err instanceof WebhookVerificationError) {
return res.status(400).json({ error: err.message });
}
return res.status(500).json({ error: 'Verification error' });
}
// Handle events
switch (event.type) {
case 'user.created':
console.log('New user:', event.data.user_id);
// Provision resources, send welcome email, etc.
break;
case 'subscription.created':
console.log('New subscription:', event.data.plan_id);
// Activate features, update limits, etc.
break;
case 'payment.succeeded':
console.log('Payment received:', event.data.amount_cents, event.data.currency);
break;
case 'payment.failed':
console.log('Payment failed:', event.data.user_id);
// Notify user, pause features, etc.
break;
case 'webhook.test':
console.log('Test webhook received');
break;
default:
console.log('Unhandled event:', event.type);
}
res.status(200).json({ received: true });
},
);
Retry Behavior
| Setting | Value |
|---|---|
| Max attempts | 5 |
| Stale lock threshold | 5 minutes |
| Expected response | 2xx status code |
If your endpoint returns a non-2xx status or times out, reauth retries the delivery. After 5 failed attempts, the delivery is marked as failed.
Best practices:
- Return
200immediately after verifying the signature - Process events asynchronously if they involve slow operations
- Use the
event.idfor idempotent processing (events may be delivered more than once during retries)
Secret Rotation
Rotate webhook secrets from the dashboard. During rotation, both old and new secrets are valid temporarily. The SDK's verifyWebhookSignature checks all v1 signatures in the header, so rotation is seamless.