Identity directory
Whisp3r Auth publishes every user's identity-owned data — display
name, picture, language, pronouns, and any OAuth app icons their
developer administers — as signed, alias-addressed
containers. Your app reads them through GET /v1/c/:alias, caches by their signature, and
receives push updates at the same webhook URL it already uses for
user events. You never copy identity data into your own database.
The model in one paragraph
Every entity in this protocol is a container with an inbox and an outbox. A user is a container. An app is a container. A consent between them is a container. The user's name, picture, pronouns — each one is an inner container. When any container mutates, anyone following it receives a signed reference at their inbox URL.
"Webhook" is just the developer-familiar name for an app's
inbox URL. "Consent" is just a sub-container the
user grants the app permission to follow, with scopes naming which
inner containers the follow includes. There's no
separate subscription registry to maintain — granting consent
on a field scope (wa:photo, wa:name.*, wa:username, wa:pronouns, wa:language) IS the subscription, the same way a
user following another user on a social network IS the
subscription.
The directory is what this looks like when you point it at identity. Every other use of the same protocol (user-to-user follows on a social app, app-to-app integrations, indexers tailing public containers) reuses these same primitives.
What that means in practice
- One canonical source for identity data — the container.
- No name/picture duplication in your tables — store the alias, fetch on render, cache by signature.
- Cache invalidation that's correct by construction — the signature only changes when the body does.
- One webhook URL per app that receives every event the user consented to — no per-event-family endpoints.
Container kinds
profile@<handle>- The user's display data. Body:
{ displayName, firstName, middleName, lastName, suffix, username, pronouns, language }. avatar@<handle>- The user's picture. Body:
{ url, mediaSha256 }.urlpoints at a stable auth-served avatar endpoint that survives picture rotations;mediaSha256is reserved for content-addressed dedupe. icon@<slug>- An OAuth app's icon, owned by the developer who registered the
app.
slugis the app'sclient_idtoday; custom slugs are coming. Body shape mirrorsavatar.
<handle> is the lowercase form of the user's preferred_username claim. Handles match [a-z0-9_-]{1,40}. Slugs follow the same rule.
Directory claims in userinfo
Directory aliases are issued automatically alongside the field scopes they describe — there is no separate scope to request. When granted, the userinfo response gains:
profile_alias- e.g.
"profile@nick". Issued whenever any ofwa:name.first,wa:name.full,wa:username,wa:pronouns, orwa:languageis granted. picture_alias- e.g.
"avatar@nick". Issued wheneverwa:photois granted. directory- Base URL of the directory's resolver, e.g.
"https://auth.whisp3r.com". Issued whenever either alias above is issued. Convenience for clients that don't already know the IdP's directory base.
The standard OIDC name and picture claims continue to be issued alongside (when their underlying
scopes are granted) so older integrations keep working without
change. New integrations should treat the URL claims as fallback
and prefer the aliases.
Resolver — GET /v1/c/:alias
Two visibility tiers, chosen by whether the caller presents a Bearer access token:
- Anonymous (no
Authorizationheader) - Returns the public subset of the body. For
profile@<handle>that'sdisplayName,username,pronouns,language. Legal name parts (firstName,middleName,lastName,suffix) are NOT in the public view — they ride onwa:name.fulland require auth. - Authenticated (
Authorization: Bearer <access_token>) - When the token belongs to the container's owner, the response
body is filtered to the field scopes that token holds. Same
gating used by directory webhook delivery — so an app sees the
same shape on resolve, push, and userinfo. An app with
wa:name.fullgets the component parts back here too:firstName,middleName,lastName,suffixin addition todisplayName.
GET https://auth.whisp3r.com/v1/c/avatar@nick Response:
{
"alias": "avatar@nick",
"kind": "avatar",
"anchor_url": "https://auth.whisp3r.com",
"body": { "url": "https://media.whisp3r.com/avatars/<id>.jpg?v=1700000000000", "mediaSha256": null },
"signature": "1a567c51247ec02c...",
"dah": "v3",
"updated_at": "2026-06-04T18:42:01.823Z"
} Authenticated example — an app with wa:name.full resolves the user's profile container and gets every component back:
GET https://auth.whisp3r.com/v1/c/profile@nick
Authorization: Bearer <access_token>
{
"alias": "profile@nick",
"kind": "profile",
"anchor_url": "https://auth.whisp3r.com",
"body": {
"displayName": "Nick Clark",
"firstName": "Nick",
"middleName": null,
"lastName": "Clark",
"suffix": null
},
"signature": "9c1a...",
"dah": "v4",
"updated_at": "2026-06-04T18:42:01.823Z"
} Frozen-handle redirects
When a user renames their handle from old to new, the resolver continues to serve GET /v1/c/profile@old and GET /v1/c/avatar@old as a 301 redirect to the canonical profile@new / avatar@new for
the 30-day freeze window. Public bookmarks and inbound links keep
working transparently. After the window expires the row is purged
and the old alias returns 404. See the wa:username scope for the full rename + lineage contract.
Caching
The signature field doubles as the container's
cache key. The HTTP response also carries:
ETag: W/"<signature>"- Use this verbatim as
If-None-Matchon revalidation. Cache-Control: private, max-age=30, must-revalidate- Serve from cache for up to 30 seconds; revalidate after. Apps that have set up push subscriptions can safely extend this at their own cache layer since pushes invalidate proactively.
Revalidation:
GET /v1/c/avatar@nick
If-None-Match: W/"1a567c51247ec02c..."
→ 304 Not Modified
ETag: W/"1a567c51247ec02c..."
Cache-Control: private, max-age=30, must-revalidate A 304 means the container hasn't mutated; bump your TTL and keep using the cached body. A 200 with a new ETag means the signature advanced — replace the cached body wholesale.
Error responses
400 malformed_alias- Alias didn't parse as
(profile|avatar|icon)@<handle>. 404 no such container- Handle doesn't exist or the container hasn't been minted yet. Try again after the user next signs in — the directory backfills on user lifecycle hooks.
Push delivery
If your app holds any directory-backed field scope granted by a
user, directory mutations for the corresponding containers
automatically push to the webhook URL you registered in your
developer dashboard. avatar@<handle> mutations
push to apps with wa:photo; profile@<handle> mutations push to apps with
any of wa:name.first, wa:name.full, wa:username, wa:pronouns, or wa:language. No per-alias subscribe handshake to
maintain — one webhook URL per app receives every event family
the user has granted you.
This is the same delivery pipeline that powers user.profile.updated and other user events today: same outbox, same retry schedule, same signing envelope.
Your receiver routes on the event field; directory
events all start with directory..
The two events you'll receive when a user changes their identity:
directory.profile.updated— display data (profile@<handle>) changeddirectory.avatar.updated— picture (avatar@<handle>) changed
Webhook payload
When a subscribed container mutates, auth delivers:
POST <your callback_url>
Content-Type: application/json
X-Whisp3r-Signature: t=1717435200,v1=<hmac_hex>
{
"event": "directory.avatar.updated",
"alias": "avatar@nick",
"kind": "avatar",
"mutation": "edit",
"signature": "<new container signature>",
"dah": "v4",
"prior_dah": "v3",
"body": { "url": "...", "mediaSha256": null },
"delivered_at": "2026-06-04T18:42:03.117Z",
"client_id": "<your client_id>"
} Event names follow directory.<kind>.updated:
directory.profile.updateddirectory.avatar.updated
The mutation field is one of create, edit, delete so you can distinguish first-
publish from rotation.
Verifying the signature
HMAC-SHA256 keyed by your webhook signing secret —
a separate per-app value from the OAuth client_secret.
(Auth stores client_secret hashed and can't sign with the
hash, so a distinct plaintext signing secret lives on the app's
metadata blob. Both values are surfaced on your OAuth app's edit
page; copy them to two distinct env vars.) The signed payload is <t>.<body> where t is the
timestamp from the X-Whisp3r-Signature header and body is the raw request body.
// Node example
const expected = crypto
.createHmac('sha256', process.env.WHISP3R_WEBHOOK_SIGNING_SECRET)
.update(`$${ts}.$${rawBody}`)
.digest('hex');
const ok = crypto.timingSafeEqual(
Buffer.from(expected), Buffer.from(v1)
); Also reject requests whose timestamp is more than ±5 minutes from your server's clock — that's the replay-defense window. Auth uses the same window when validating its own inbound webhooks.
Retry semantics
Same as user-event webhooks. Auth retries on any non-2xx response with the schedule 15s, 1m, 5m, 30m, 2h, 12h (6 attempts total, ~14h end-to-end). After the final attempt the delivery is dead-lettered. Each attempt times out at 25 seconds — return 2xx well before that and do slow work behind a queue.
Worked example — your app, end to end
Below is the integration sequence for a typical connected app:
- One-time setup. In the developer dashboard,
mark the field scopes you need (
wa:photo,wa:name.*, etc.) as required and register your webhook URL (e.g.https://app.example.com/wa/webhook). The same URL receives both user events and directory events; you route on theeventfield inside the receiver. - After token exchange, read
profile_aliasandpicture_aliasfrom userinfo. Store the aliases on the user's row in your DB. Do not store the resolved values. - Render time: hit
GET /v1/c/<alias>withIf-None-Matchset to the signature you last saw. Cache the response. Most reads return 304. - On webhook receipt, verify the HMAC
(
X-Whisp3r-Signature), check the timestamp window, drop the alias from your cache, return 200. The next render fetches the fresh container in one round-trip.
Self-hosted identity (forward-compatible)
The anchor_url field on every container response
exists because identity-owned data is portable. Today every
container reports https://auth.whisp3r.com because
auth corporate-hosts identity. Tomorrow, a user who self-hosts
their profile will have anchor_url point at their
own server. Your app's code path doesn't change: you still GET
the same alias through auth; auth either serves directly or
resolves through to the user's host on your behalf.
Build your integration against the resolver, not against the hostname. The contract holds across the corporate-to-self-hosted migration with no code changes on your side.