Webhooks
Get notified when a user updates their profile, revokes your app, or closes their account. Every event is HMAC-signed; verify before you trust.
Setup
Set your webhook URL on the developer dashboard. Must be HTTPS in production; HTTP localhost is allowed for dev. We don't validate reachability — the delivery worker marks failed events and retries with backoff.
Event envelope
Every event is delivered as a POST to the webhook_url you registered in the developer dashboard.
The body shape varies by event family — user events vs. directory
events — but every delivery carries the same headers and signing
envelope.
Headers
- X-Whisp3r-Signature
t=<unix_seconds>,v1=<hex>. Thev1value is HMAC-SHA256 over<t>.<raw body>, keyed with your webhook signing secret — a per-app value distinct from the OAuthclient_secret. Both are shown on your OAuth app's edit page; copy them into separate env vars (WA_CLIENT_SECRETfor OAuth token exchange,WA_WEBHOOK_SIGNING_SECRETfor signature verification). The two values are DIFFERENT and cannot substitute — the OAuth secret is stored hashed and can't be used to verify HMACs.- X-Whisp3r-Event
- The event type (e.g.
user.profile.updated). Lets you route in your receiver without parsing the body. - X-Whisp3r-Delivery-Id
- Per-attempt id. Stable across retries — use it for idempotency on your side.
User-event body
User events ("something about a user changed") are deliberately
thin: event name + a per-app pairwise sub + a
timestamp. They tell you something moved; the right
response is to re-fetch /oauth2/userinfo for the
current values. The payload never carries field values — that
keeps the signed blast radius minimal AND ensures field-level
access enforcement happens at userinfo (where scopes are
authoritatively checked).
{
"event": "user.profile.updated",
"sub": "<pairwise sub for your client_id>",
"client_id": "<your client_id>",
"delivered_at": "2026-06-04T18:30:21.123Z"
} The sub is opaque per app — same user appears under
a different sub at every other connected app, by design. You
cannot correlate users across apps from this value.
Directory-event body
Directory events ("a container you can see has a new signature")
carry the new body inline — same shape as GET /v1/c/<alias>, scope-filtered to the
subset your app actually has access to. No re-fetch needed for
the fields included. See Identity directory for the full directory contract.
{
"event": "directory.profile.updated",
"sub": "<pairwise sub for your client_id>",
"alias": "profile@nick",
"kind": "profile",
"mutation": "edit",
"signature": "<new container signature>",
"dah": "v4",
"prior_dah": "v3",
"body": {
// Only the fields your scopes cover. Apps with wa:name.first
// see { displayName, firstName }. Apps with wa:name.full
// see the same plus middleName, lastName, suffix. Apps with no
// covered fields are skipped entirely.
},
"delivered_at": "2026-06-04T18:30:21.123Z",
"client_id": "<your client_id>"
} Username renames
When the changed field is the user's handle, the user.profile.updated envelope additionally carries a previous_username field at the top level (alongside event / sub / delivered_at).
Apps that keep a denormalized handle column in their own database
can match on this field to remap the row to the new handle in one
round-trip. The sub stays the same — apps key by it
for identity continuity.
{
"event": "user.profile.updated",
"sub": "<pairwise sub>",
"previous_username": "nick",
"body": { "username": "nclark" },
"delivered_at": "2026-06-04T18:30:21.123Z",
"client_id": "<your client_id>"
} The old handle remains resolvable via GET /v1/c/profile@<old> with a 301 to the canonical
alias for 30 days after the rename — see the wa:username scope reference for the freeze + reclaim window contract.
Signature verification
import crypto from 'node:crypto';
function verify(rawBody, signatureHeader, webhookSigningSecret) {
const parts = Object.fromEntries(
signatureHeader.split(',').map((p) => p.split('='))
);
const ts = parseInt(parts.t, 10);
const v1 = parts.v1;
// Reject anything outside ±5 minutes — replay defense.
if (Math.abs(Date.now() / 1000 - ts) > 300) return false;
const expected = crypto
.createHmac('sha256', webhookSigningSecret)
.update(`${ts}.${rawBody}`)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(v1)
);
} Event catalog
Two families. Both deliver to the same webhook_url — your receiver routes on the event header or body field.
Dispatch is deterministic and consent-matched:
a given mutation always produces the same recipient set, and
apps only receive notifications about fields their scopes cover.
A birthday correction that doesn't cross an age tier boundary
doesn't notify wa:age.tiers apps; a middle-name
change doesn't notify wa:name.first-only apps.
user.profile.updated
General-purpose profile change. Fires when one or more of the
fields you have a scope for actually changes — name parts,
username, pronouns, birthday, etc. Re-fetch /oauth2/userinfo to get the current values; the
payload itself only signals "something moved." Field-level
scoping is enforced at userinfo, so you only see what you're
entitled to either way.
user.photo.changed
Specifically the avatar. If you cache the picture URL,
invalidate. Fires as its own event because most apps cache photos
separately from the rest of the profile.
user.cookies.changed
The user updated their analytics or marketing cookie preferences.
Re-fetch cookie_preferences from userinfo and apply the
new opts — same UX they get on Whisp3r Auth itself.
user.language.changed
The user changed their preferred interface language. Re-fetch locale (BCP-47 tag) and switch the UI on next page load.
user.age_gate.passed
The user just crossed a tier boundary (turned 13, 16, 18, or 21).
Fires once per tier per user. Apps that gate access by age can flip
their feature on without polling. Re-fetch userinfo to read the
updated age_at_least_* booleans and the companion age_verification object (see Scopes reference).
Directory events
Apps that hold a directory-backed field scope automatically
receive push notifications at the same webhook_url when a matching container their consented users own mutates. avatar@<handle> mutations are delivered to
apps with wa:photo; profile@<handle> mutations are delivered to
apps with any of wa:name.first, wa:name.full, wa:username, wa:pronouns, or wa:language. Apps with
no matching field scope are skipped. No separate subscription
handshake — the field-scope grant IS the subscription. See identity directory for the resolver contract. The signing envelope and retry
semantics are identical to user events above; only the payload
shape differs.
directory.profile.updated- A
profile@<handle>container mutated. The payload body carries the new field values, filtered to the subset your scopes cover. directory.avatar.updated- An
avatar@<handle>container mutated. Body carries{ url, mediaSha256 }when your app holdswa:photo; otherwise the event is suppressed (no empty deliveries).
Every directory delivery includes alias, signature, dah, and prior_dah alongside the body so receivers can verify continuity and update
signature-keyed caches in one pass. See Identity directory for the full integration walkthrough.
user.consent.revoked
The user disconnected your app. All outstanding tokens are revoked;
subsequent calls fail with invalid_grant. Sign the user
out of your app and apply the data-retention policy the payload
specifies.
Payload carries two retention-related fields:
data_intent"delete" | "retain" | "archive"— the resolved user wish, gated by what your app'sretention_commitmentsallow. If the user picked "delete" but your app didn't commit to deletion, this falls back to the closest allowed option perresolveRevokeIntent.commitments- The
retention_commitmentsarray you registered on this app's metadata. Lets your receiver double-check the intent against the policy it claimed to honor.
See Data retention on revoke for the full mental model.
user.deleted
The user closed their Whisp3r Auth account entirely. Delete their row
from your database, including any data you've stored against the sub. This is one of the few times you must delete; the
pairwise sub is the only identifier you have for them,
and once their WA account is gone, you have no way to sign them in
again.
Delivery semantics
- At-least-once. A flaky retry can deliver the same
idtwice. Idempotency: maintain a seen-event-ID set. - Ordering not guaranteed. If you need strict order, treat the body as a "you should re-fetch" trigger and call userinfo.
- Retry policy. Any non-2xx response (including 4xx) triggers a retry on the schedule 15s, 1m, 5m, 30m, 2h, 12h — 6 attempts total, ~14h end-to-end. After the final attempt the delivery is dead-lettered and available for replay from the OAuth app dashboard. Persistent 4xx failures are still retries, not terminals — so fix your handler quickly to avoid the queue filling up.
- Response. Each attempt has a 25-second timeout. Return 2xx well before that — do slow work behind a queue.