Event Shapes
Every event BluAuth emits conforms to the canonical envelope defined in shared/types/events.ts. Events flow out of the Nuxt server → SNS topic (BluAuthEvents) → SQS queue (WebhookDeliveryQueue) → per-client HTTPS webhook deliveries. Dead letters land in WebhookDLQ after 3 failed attempts.
Envelope
interface EventPayload {
eventType: string;
aggregateId: string; // UUID — the primary entity the event describes
timestamp: string; // ISO-8601 UTC with Z suffix
data: Record<string, unknown>;
}
eventType values are the string constants exported by EventType. What varies between events is the data object. This page documents the shape of data for each event.
Event type enumeration
The canonical list from EventType (see shared/types/events.ts):
| Constant | String value | Aggregate |
|---|---|---|
USER_CREATED | user.created | user |
USER_UPDATED | user.updated | user |
USER_DELETED | user.deleted | user |
USER_DEACTIVATED | user.deactivated | user |
USER_REACTIVATED | user.reactivated | user |
ACCOUNT_LINKED | account.linked | user (acting on account) |
ACCOUNT_UNLINKED | account.unlinked | user (acting on account) |
SESSION_CREATED | session.created | user (legacy) / session (new) |
SESSION_REVOKED | session.revoked | session |
INVITATION_CREATED | invitation.created | invitation |
INVITATION_ACCEPTED | invitation.accepted | invitation |
INVITATION_REVOKED | invitation.revoked | invitation |
Delivery contract
- Transport: HTTPS POST to the endpoint stored on
webhook_endpoints.url. - Signature:
X-BluAuth-Signatureheader — HMAC-SHA256 of the raw body, keyed by the webhook's KMS-decryptedsecret. Hex-encoded. Constant-time compare on your side. - Headers:
Content-Type: application/json,X-BluAuth-Event(event type),X-BluAuth-Delivery(delivery UUID),X-BluAuth-Timestamp(Unix seconds). - Retries: SQS redrives up to 3 times with exponential backoff before DLQing. Your endpoint must be idempotent on
aggregateId + eventType + timestamp. - Order: not guaranteed. Rely on
timestampand your own sequencing when ordering matters (e.g.user.createdbeforeaccount.linked). - Idempotency key:
webhook_deliveries.id(UUID) is unique per delivery attempt. Dedupe on(eventType, aggregateId, timestamp)if you need finer-grained replay protection. - At-least-once. Every subscriber must tolerate duplicate deliveries.
User events
user.created
Fired when a new user row is inserted. Sources: admin create, OAuth first-login, invitation accept, credential sign-up via Better Auth.
{
"eventType": "user.created",
"aggregateId": "<user-uuid>",
"timestamp": "2026-04-16T17:23:45.000Z",
"data": {
"email": "user@example.com",
"name": "User Name",
"emailVerified": true,
"createdVia": "invitation | signup | admin | oauth",
"clientId": "<uuid-if-initiated-by-a-client>",
"invitationId": "<uuid-if-from-invitation>",
"provider": "<provider-slug-if-from-oauth>"
}
}
createdVia distinguishes the entry path. clientId is present when the flow was initiated via a specific OAuth client (e.g. the hosted login was accessed with ?client_slug=).
user.updated
Fired on any write to users that changes email, name, profile fields, or role.
{
"eventType": "user.updated",
"aggregateId": "<user-uuid>",
"timestamp": "2026-04-16T17:23:45.000Z",
"data": {
"email": "new@example.com",
"name": "Updated Name",
"changedFields": ["email", "name"]
}
}
changedFields lists only the fields that actually changed (diff vs. prior row). Purely-metadata updates (e.g. updatedAt only) do not fire this event.
user.deleted
Fired before the row is soft-deleted. Consumers may still look up the user during processing.
{
"eventType": "user.deleted",
"aggregateId": "<user-uuid>",
"timestamp": "2026-04-16T17:23:45.000Z",
"data": {
"email": "user@example.com",
"actorId": "<admin-user-uuid-or-null>"
}
}
user.deactivated / user.reactivated
{
"eventType": "user.deactivated",
"aggregateId": "<user-uuid>",
"timestamp": "2026-04-16T17:23:45.000Z",
"data": {
"actorId": "<admin-user-uuid>",
"reason": "string or null"
}
}
Deactivation revokes all active sessions as a side effect — consumers will observe session.revoked events for each revoked session in close succession.
Account events
Provider-account linkage changes.
account.linked
Fired when a credentials-type or upstream-provider account is attached to a user. Fires in the OAuth callback when resolution.kind !== 'existing-account' and on admin-driven link operations.
{
"eventType": "account.linked",
"aggregateId": "<user-uuid>",
"timestamp": "2026-04-16T17:23:45.000Z",
"data": {
"providerSlug": "google",
"providerAccountId": "112233445566778899",
"providerAccountEmail": "user@example.com",
"linkedBy": "oauth-callback | admin | invitation"
}
}
providerAccountId is the upstream subject (not BluAuth's pairwise sub). providerAccountEmail is the email asserted by the upstream provider and may differ from the BluAuth user's canonical email.
account.unlinked
{
"eventType": "account.unlinked",
"aggregateId": "<user-uuid>",
"timestamp": "2026-04-16T17:23:45.000Z",
"data": {
"providerSlug": "google",
"providerAccountId": "112233445566778899",
"unlinkedBy": "user | admin"
}
}
Unlinking a credential account is prohibited if it's the user's only authentication method — the operation 422s before this event would fire.
Session events
session.created
Fired on every new session — credential login, OAuth callback success, invitation accept.
{
"eventType": "session.created",
"aggregateId": "<user-uuid>",
"timestamp": "2026-04-16T17:23:45.000Z",
"data": {
"sessionId": "<session-uuid>",
"isNewUser": false,
"provider": "google | github | microsoft | credential | oidc",
"clientId": "<uuid-or-null>",
"ipAddress": "1.2.3.4",
"userAgent": "Mozilla/5.0 ...",
"expiresAt": "2026-04-23T17:23:45.000Z"
}
}
isNewUser: true means this session was issued as part of a first-login that also created the user row — consumers doing JIT provisioning should provision exactly once per user based on this flag rather than counting user.created.
session.revoked
{
"eventType": "session.revoked",
"aggregateId": "<session-uuid>",
"timestamp": "2026-04-16T17:23:45.000Z",
"data": {
"userId": "<user-uuid>",
"reason": "logout | admin_revocation | password_change | expiry | deactivation | merge"
}
}
The merge reason is used when a user merge collapses sessions from a source user into a target user.
Invitation events
invitation.created
Fired when an admin creates an invitation via /api/admin/invitations. Email-send is fire-and-forget — this event fires even if the SES send fails later.
{
"eventType": "invitation.created",
"aggregateId": "<invitation-uuid>",
"timestamp": "2026-04-16T17:23:45.000Z",
"data": {
"email": "invitee@example.com",
"recipientName": "First Last | null",
"clientId": "<uuid>",
"clientSlug": "downstream-app",
"invitedByUserId": "<admin-user-uuid>",
"expiresAt": "2026-04-23T00:00:00.000Z",
"context": { "role": "editor", "onboardingPath": "/welcome", "requiredProviderKeys": ["tpauth"] }
}
}
context is the signed, admin-supplied payload attached to the invitation. Downstream apps use context to pre-configure the user on their side before accepting the invite (e.g. to assign team membership). requiredProviderKeys, when present, means the invite can only be redeemed through those providers.
invitation.accepted
Fired before the session cookie is issued, so downstream provisioning can complete before the user's first authenticated request.
{
"eventType": "invitation.accepted",
"aggregateId": "<invitation-uuid>",
"timestamp": "2026-04-16T17:23:45.000Z",
"data": {
"userId": "<user-uuid-created-or-linked>",
"email": "invitee@example.com",
"clientId": "<uuid>",
"clientSlug": "downstream-app",
"context": { "role": "editor", "onboardingPath": "/welcome" }
}
}
If the email matched an existing BluAuth user, userId is that user and the acceptance is treated as a client-association gesture — consumers should handle both "new user" and "existing user" cases from the same event. Correlate with session.created (fires immediately after) to distinguish.
invitation.revoked
{
"eventType": "invitation.revoked",
"aggregateId": "<invitation-uuid>",
"timestamp": "2026-04-16T17:23:45.000Z",
"data": {
"actorId": "<admin-user-uuid>",
"email": "invitee@example.com"
}
}
Only pending invitations can be revoked. Attempting to revoke an already-accepted or already-revoked invitation 400s and does not emit this event.
Versioning
Event shapes are considered additive-compatible. BluAuth will:
- Add new fields to
datawithout a version bump. - Add new event types without a version bump (consumers should handle unknown
eventTypeby ignoring). - Never remove or rename existing fields within the same major version.
- Keep deprecated fields as
nullfor at least one major version before removal.
When a breaking change is required, BluAuth will:
- Introduce a new event type with the new shape (e.g.
user.created.v2). - Dual-emit both versions for a deprecation window documented in the release notes.
- Stop emitting the old version only after the window closes.
Consumer checklist
For every webhook consumer:
- Verify
X-BluAuth-Signaturewith constant-time compare before parsing the body. - Dedupe on
(eventType, aggregateId, timestamp)orX-BluAuth-Delivery— deliveries are at-least-once. - Persist the event into an
eventstable before processing — this makes replay trivial. - Handle unknown
eventTypeby ignoring (don't 4xx) — BluAuth may add events between your deploys. - Respond 2xx within 10 seconds. Longer processing must be off-loaded to your own queue.
- Non-2xx responses are retried up to 3 times; delivery history is visible at
/api/admin/webhooks/:id/deliveries.