BluAuth
Docs
Sign in
User FAQ
  • Reset my password
  • I can't sign in
  • Didn't get reset email
  • Account linking
  • Session expiry
  • Two-factor auth
Admin Guides
Theme Studio
  • Overview
  • Layouts
  • Styling tokens
  • Concept copy
  • Assets & backgrounds
  • Advanced CSS
Admin Shell
  • Users
  • Providers
  • Clients
  • Invitations
Integrations
  • OIDC flow
  • Legacy OAuth flow
  • Provider token brokering
  • Email triggers
  • Webhook events
  • Session contract
Reference
  • API
  • Error codes
  • Event shapes
  • Design tokens
Runbooks
  • Deployment
  • Local operations

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_events first (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_endpoints whose events[] 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.

EventAggregateWhen it fires
user.createduser UUIDA new user is created (self-signup, invitation accepted, admin provisioning).
user.updateduser UUIDA user's profile fields change. Payload includes changedFields[].
user.deleteduser UUIDA user is hard-deleted. Emitted before the row is removed.
user.deactivateduser UUIDAdmin soft-deletes / disables a user.
user.reactivateduser UUIDA previously deactivated user is reactivated.
account.linkeduser UUIDA user links a new provider identity.
account.unlinkeduser UUIDA user unlinks a provider identity.
session.createdsession UUIDSign-in completes — a new session is issued.
session.revokedsession UUIDA session is revoked (logout, admin, password change, expiry).
invitation.createdinvitation UUIDAn admin creates a new invitation.
invitation.acceptedinvitation UUIDAn invitee completes the invitation flow.
invitation.revokedinvitation UUIDAn 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:

HeaderNotes
Content-Type: application/jsonAlways.
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:

AttemptDelay
1Immediate
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-delivery log lines include webhookId, eventId, statusCode, and duration_ms. Search by webhookId in 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-Signature if 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 timestamp is 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.revoked with reason=password_change — force refresh of server-side cached sessions.
  • Forward invitation.accepted into 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.

On this page

  • Architecture
  • Event catalog
  • Event envelope
  • Registering an endpoint
  • Delivery format
  • Signature verification
  • Responding
  • Retry policy
  • Idempotency
  • Ordering
  • Debugging and replay
  • Security
  • What subscribers typically do
DocsPrivacyTerms
© 2026 Blu Digital Group