Webhook Events
BluAuth publishes auth-domain events to downstream apps as signed HTTP webhooks. Subscribe once per endpoint; receive every future event the endpoint is registered for. This page covers registration, signature verification, retry policy, idempotency, ordering, and the full event catalog.
For exact JSON shapes of each event's data payload, see Event Shapes.
Source: src/events/publisher.ts, src/events/webhook-worker.ts, shared/types/events.ts.
Architecture
[BluAuth handler] --> outbound_events (Postgres) --> SNS topic
|
v
SQS queue
|
v
webhook-worker Lambda
|
v
for each matching endpoint:
POST <endpoint.url>
- BluAuth always writes the event to
outbound_eventsfirst (transactional outbox), then publishes to SNS. - SNS fans out to an SQS queue consumed by a Lambda worker (
webhook-worker.ts). - The worker looks up active
webhook_endpointswhoseevents[]array includes the event type, signs the payload, and POSTs it.
If SNS publish fails, the event sits in outbound_events with status=failed and can be replayed manually.
Event catalog
The canonical list is defined in shared/types/events.ts and re-exported as the EventType const. These are the only event types BluAuth emits today.
| Event | Aggregate | When it fires |
|---|---|---|
user.created | user UUID | A new user is created (self-signup, invitation accepted, admin provisioning). |
user.updated | user UUID | A user's profile fields change. Payload includes changedFields[]. |
user.deleted | user UUID | A user is hard-deleted. Emitted before the row is removed. |
user.deactivated | user UUID | Admin soft-deletes / disables a user. |
user.reactivated | user UUID | A previously deactivated user is reactivated. |
account.linked | user UUID | A user links a new provider identity. |
account.unlinked | user UUID | A user unlinks a provider identity. |
session.created | session UUID | Sign-in completes — a new session is issued. |
session.revoked | session UUID | A session is revoked (logout, admin, password change, expiry). |
invitation.created | invitation UUID | An admin creates a new invitation. |
invitation.accepted | invitation UUID | An invitee completes the invitation flow. |
invitation.revoked | invitation UUID | An admin revokes a pending invitation. |
Nothing else is emitted. If you see an event type not in this table, treat it as malformed and reject the delivery.
For full field lists, see Event Shapes.
Event envelope
Every delivery has the same JSON envelope, defined by EventPayloadSchema:
interface EventPayload {
eventType: string; // e.g. "user.created"
aggregateId: string; // UUID of the primary entity
timestamp: string; // ISO-8601 UTC
data: Record<string, unknown>; // event-specific
}
Example user.created:
{
"eventType": "user.created",
"aggregateId": "6f2a8e7e-8c3f-4f0e-b1a2-c3d4e5f60001",
"timestamp": "2026-04-16T17:23:45.000Z",
"data": {
"email": "user@example.com",
"name": "User Name",
"emailVerified": true,
"createdVia": "invitation",
"clientId": "7f9b0e7e-...-a11c",
"invitationId": "b1f2c3d4-...-0042"
}
}
Envelope fields are stable within a major version. Fields inside data are additive-compatible — new fields may appear, existing fields are not renamed or removed. Deprecated fields remain as null for at least one major version.
Registering an endpoint
From the admin UI at /admin/webhooks, or via the API:
POST https://auth.example.com/api/admin/webhooks
Authorization: Bearer <admin-token>
Content-Type: application/json
{
"clientId": "7f9b0e7e-...-a11c",
"url": "https://app.example.com/hooks/bluauth",
"secret": "<at-least-32-random-bytes-base64>",
"events": ["user.created", "user.updated", "invitation.accepted"],
"isActive": true
}
url— must be HTTPS in production. HTTP is rejected.secret— you supply the signing secret; BluAuth encrypts it with KMS at rest. Copy it into your downstream app's secret store at registration time — BluAuth will not return it again.events— array of event types the endpoint subscribes to. Empty array means no deliveries. There is no "all events" wildcard; list each event you want.clientId— the OAuth client this endpoint is associated with. Used for per-tenant filtering in downstream audit tooling.
Source: server/api/admin/webhooks/index.post.ts.
Delivery format
POST https://app.example.com/hooks/bluauth
Content-Type: application/json
X-BluAuth-Signature: sha256=9a3c0e2f1b8d4e7a...
X-BluAuth-Event: user.created
{"eventType":"user.created","aggregateId":"...","timestamp":"...","data":{...}}
Headers you will see:
| Header | Notes |
|---|---|
Content-Type: application/json | Always. |
X-BluAuth-Event: <eventType> | Convenience — lets you dispatch without parsing. |
X-BluAuth-Signature: sha256=<hex> | HMAC-SHA256 of the raw request body with the endpoint's signing secret, hex-encoded. |
Source: src/events/webhook-worker.ts (computeWebhookSignature).
Signature verification
Recompute HMAC-SHA256(secret, raw_body) and compare to the value after sha256= with a constant-time equality check. Use the raw bytes of the request body — not a re-serialized JSON string, which will whitespace-differ.
Node.js / TypeScript:
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyBluAuthSignature(rawBody: Buffer, headerValue: string | undefined, secret: string): boolean {
if (!headerValue?.startsWith('sha256=')) return false;
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
const received = headerValue.slice('sha256='.length);
if (expected.length !== received.length) return false;
return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(received, 'hex'));
}
Always verify. An unsigned user.deleted is trivial to forge if your endpoint is discoverable.
If your framework parses JSON before you see the raw body, configure it to expose the raw buffer on the webhook route. In Nuxt / H3 use readRawBody(event). In Express use express.raw({ type: 'application/json' }) on the webhook route only.
Responding
Return any 2xx status within 30 seconds to ack the delivery. Anything else is treated as a failure and scheduled for retry.
- The response body is not inspected. Return an empty 200 or a short JSON object — both work.
- Do not redirect. 3xx responses are treated as failures.
- Do the minimum synchronous work needed to accept the event (validate signature, enqueue to your own durable queue), then return. Heavy processing belongs in your queue, not on the webhook request path.
Retry policy
The delivery worker retries transient failures:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | +1 minute |
| 3 | +5 minutes |
| 4 | +15 minutes |
After four attempts (immediate + three retries), the event is marked failed and not retried. It remains visible in /admin/webhooks/:id/deliveries for manual replay.
Note: Earlier public descriptions suggested exponential backoff out to 24 hours. The current worker implementation caps at ~21 minutes total. Plan for a dead-letter workflow in your app if you need delivery guarantees across longer outages — we recommend a nightly reconciliation from the BluAuth admin API rather than relying solely on webhooks for critical state.
Idempotency
At-least-once delivery is the default. Any single event may be delivered more than once if:
- The downstream endpoint returns 2xx but BluAuth records the delivery as pending (partial write).
- SNS-to-SQS fan-out duplicates a message.
- An admin manually replays a delivery.
Use the event envelope's aggregateId + timestamp + eventType combo as an idempotency key (or, if present, the eventId field on the underlying outbound-events record). The recommended pattern is:
CREATE TABLE processed_bluauth_events (
event_key TEXT PRIMARY KEY, -- e.g. `${eventType}:${aggregateId}:${timestamp}`
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Insert the key with ON CONFLICT DO NOTHING before side-effecting work. If the insert reported zero rows, you've seen this event before — skip.
Ordering
Events are not guaranteed to arrive in order. You may receive user.updated before user.created, or session.revoked before session.created for the same user. Causes:
- SNS fan-out is lossy on ordering.
- Retries delay later-scheduled deliveries behind earlier-failed ones.
- Concurrent worker invocations interleave.
Reconcile on timestamp, not arrival order. If your app needs a strictly-consistent view of a user, do a GET /api/admin/users/:id read-through on any event you don't recognize, rather than building up state purely from the event stream.
Debugging and replay
/admin/webhooks/:id/deliveries— every delivery attempt with status code, response body snippet, next retry time, and a replay button.- Replay — manually re-send any delivery. Useful for staging — fire a replay after your endpoint deploys to make sure signature verification matches in prod.
- Structured delivery logs —
webhook-deliverylog lines includewebhookId,eventId,statusCode, andduration_ms. Search bywebhookIdin your log tool.
Security
- HTTPS only in production. BluAuth rejects
http://URLs on create. Staging tolerates HTTP for local tunneling. - Rotate the signing secret by PUTting a new one to
/api/admin/webhooks/:id. BluAuth accepts signatures from both the old and new secret for a 10-minute overlap window. - IP allowlist (optional). BluAuth publishes its egress CIDRs on the status page. If you ACL the webhook endpoint, include both production and staging ranges.
- Do not accept
X-BluAuth-Signatureif the endpoint is also publicly accessible for other traffic. Use a dedicated path (/hooks/bluauth) so a spoofed signature on an unrelated route can't be conflated. - Reject replays older than a window. BluAuth's envelope
timestampis trustworthy once the signature verifies. Drop deliveries with timestamps more than a few hours old to limit replay-attack blast radius.
What subscribers typically do
- Provision tenant state on
user.created— create your app's user row, grant default permissions, send product-specific onboarding. - Invalidate caches on
user.updated— especially profile-facing caches (display name, avatar). - Tombstone on
user.deleted— anonymize or purge your app's records; the BluAuth row disappears right after. - Drive off-boarding on
session.revokedwithreason=password_change— force refresh of server-side cached sessions. - Forward
invitation.acceptedinto your own welcome automation — the BluAuth welcome email covers the auth boundary, not your product onboarding.
For exhaustive field lists per event, see Event Shapes.