AUTH4D-158 Flows event model and trigger points
Flows let a project react to what happens in its sign-in flows. This fragment defines the version 1 event and trigger shapes and where the API raises them. Nothing is delivered to your servers yet: webhook delivery and configurable hooks come in later Flows tickets. No endpoint changes.
The schemas are in packages/contracts (FlowEventSchema, FlowTriggerInputSchema,
FlowHookDecisionSchema, FlowHooks). The publish point and hook call sites are in
apps/api/src/modules/flows.
What a payload carries
Every event and trigger carries the project (tenant_id), the application (application_id, the
client id), the user (user_id), how they signed in (method) and coarse context. Context is an
optional two-letter country code from Cloudflare’s edge and an optional identity provider.
Payloads never carry tokens, authorization or device codes, identity proofs, email addresses, IP addresses or secrets. The schemas are strict, so any extra field is rejected.
method is one of:
| Value | Meaning |
|---|---|
email, discord, steam |
The user proved this identity in this flow. |
guest |
An anonymous guest account. |
session |
An existing hosted-login browser session was reused with no new proof. |
device |
A native device received tokens after a browser approved its code. |
Async events
Events are published after the action has been committed, one queue message per event:
{
"event_id": "evt_7wp-mPdHTZ_ug4hvHKYjjaV31ZK8b79pVRLZ0EjCBNU",
"version": 1,
"type": "user.signed_in",
"tenant_id": "ten_…",
"application_id": "cli_…",
"user_id": "usr_…",
"occurred_at": "2026-10-06T18:27:19.079Z",
"context": { "country": "ZA", "provider": "discord" },
"data": { "method": "discord" }
}
| Type | Published when | data |
|---|---|---|
user.created |
A guest is created, or an email, Discord or Steam sign-in stores a new user. | method: email, discord, steam or guest |
user.signed_in |
A guest is created, hosted login issues an authorization code, or a device collects its tokens. | method |
guest.upgraded |
A browser or native guest upgrade attaches a credential. | method: email, discord or steam |
session.revoked |
A session ends through logout, token revocation, an administrator or a password reset or change. | reason: logout, token_revocation, admin or password |
member.changed |
A project member joins through an invitation, changes role or is removed. | change: added, role_changed or removed; role or null |
Notes:
application_idis null formember.changedand for an administrator revoking all of a user’s sessions, which is one event rather than one per session. Management events have empty context.event_idis derived from the project, the event type and the action (for example the session or the authorization code). A retried publish of the same action has the sameevent_id, so consumers must deduplicate on it. Ids are unique per project.- Each message belongs to exactly one project and names it in
tenant_id. - Publishing is best effort and never fails the user’s request. A queue error is logged with the event type and project only.
- When a guest upgrades with email, Discord or Steam, the hosted login first creates a short-lived
account for the new credential. That account gets its own
user.created, then the credential moves to the guest andguest.upgradednames the guest.
The queue binding is FLOW_EVENTS (auth4-flow-events-{local,staging,production}). AUTH4D-157
consumes it and delivers the events to project webhook endpoints; see auth4d-157.md.
Synchronous triggers
Triggers run inside the action. Protocol checks always run first: PKCE, redirect_uri, origin and
CSRF checks, flow cookies and rate limits are unchanged and are never handed to a hook. A hook
answers { "action": "continue" } or { "action": "deny" }. The default implementation always
continues. A hook that throws or answers anything else denies the action.
| Trigger | Runs | On deny |
|---|---|---|
pre_registration |
Before a guest, email, Discord or Steam user is stored. There is no user_id yet. |
No user is stored. Guests get 403 access_denied; other methods fail as a failed sign-in. |
post_login |
After the user is authenticated, before a guest’s tokens or an authorization code are issued. | No tokens or code. Hosted login redirects with error=access_denied; guests get 403. |
pre_guest_upgrade |
After the upgrade proof is checked, before the credential is attached. method is its provider. |
403 access_denied; nothing is attached and the guest session stays. |
device_approval |
When a signed-in browser approves a user code. method is session. |
The request is recorded as denied, so the device’s next poll gets access_denied. |
Trigger input:
{
"version": 1,
"trigger": "post_login",
"tenant_id": "ten_…",
"application_id": "cli_…",
"user_id": "usr_…",
"method": "email",
"context": { "provider": "email" }
}
Tests
tests/integration/auth4d-158.flows.test.ts checks the schemas and that each action publishes its
events once, with no tokens, codes or email addresses, and only for its own project. It also checks
that a denying hook stops guest creation, token issuance and guest upgrades without publishing
anything.
Source: docs/api-spec/auth4d-158.md