DocumentationSign in

Revocation and webhooks

The person disconnects the whole thing, and you hear about it — if you asked to.

A person disconnects your application. They do not switch off one claim: there is no scope-level revocation route, and there is no per-claim withdrawal anywhere in the platform.

The mirror of that is that you cannot ask for less either. A sign-in that requests fewer scopes than the person granted leaves the grant as it is; a sign-in that requests more asks them about the additions only. The one way a grant narrows is the person disconnecting and connecting again, which starts a new consent from exactly what they approve that time.

In the sandbox, the equivalent of disconnecting is resetting the test identity, which ends its sandbox consents and tokens. See Test identities.

After revocation, the next call with the old access token fails: userinfo answers only while the consent it was issued under — or the version that superseded it — is in force.

Keep the account; end the revoked sessions

After validating the callback, look up the existing account by the trusted issuer, your client ID and verified sub. Use a unique database constraint and an atomic find-or-create. Never create an account just because first_sign_in is true: it is true again after reconnection. Do not link accounts by email automatically.

Store the verified ID token's consent_id with every application session and refresh grant. Subscribe to consent.revoked. Verify the raw request bytes, signature timestamp, expected application_id and environment before acting; use the envelope subject.id and data.consentId. The webhook signing secret stays on your backend.

In one database transaction, deduplicate the event ID, record a revocation tombstone even if the callback has not arrived, invalidate all sessions and refresh grants for that identity and consent, and remove cached UnifyID tokens. Check that same tombstone atomically when creating a session. Return 2xx only after commit; return a retryable error if persistence fails. Delivery can be delayed or retried.

Every protected application request must check its server-side session. Mobile apps clear their local session on an application-backend 401. Offline devices learn about withdrawal when they next contact your backend. Reconnection reuses the account but creates a session under the new consent; an old webhook must not revoke that newer session. For an already deployed integration without consent bindings, conservatively end all that subject's sessions on withdrawal until those sessions are migrated or expire.

The server SDK exports createUnifyIDApplicationSession, verifyConsentRevokedWebhook and handleUnifyIDConsentRevoked. You provide the durable atomic ApplicationSessionStore. The hosted-server-bound reference demonstrates account reuse, signed webhooks and immediate server-session invalidation using a local-only store. Withdrawal keeps the application's account and previously received data; handle deletion through the separate privacy-request process.

A private sign-in application follows the same rules with less to go on. Key on issuer, client ID and sub; there is no email address, so there is nothing else to link accounts by. Its consent.revoked data carries no subject: take the person from the envelope subject.id. A revocation that follows the person deleting their UnifyID account can arrive up to 15 minutes late, and reconnecting after that deletion with the same identity document brings back the same sub. See Private sign-in.

The events that matter here

sign_in.completed
A person completed a sign-in to you, first time or returning. Labelled "Human signed in". oauth.authorization.completed is the older name for the same event, with the same data.
connection.created
A person created a connection to your application, including after a previous disconnect. Sent alongside the sign_in.completed that created this connection. Labelled "Human connected for the first time".
consent.granted
A person approved what you receive: their first approval, or one that added permissions. Not sent for a returning sign-in that changed nothing. Labelled "Human consent granted".
consent.revoked
A person disconnected you, or UnifyID ended the consent. Labelled "Human consent revoked".

Every one of them names the person in subject and data.subject by your application's pairwise sub — the same sub you received at sign-in, and never the same value for two people.

A private sign-in application's events carry less: no data.subject, no authorizationId, and occurred_at cut to the minute. See Private sign-in.

data on sign_in.completed, connection.created and oauth.authorization.completed

authorizationId
The hosted interaction, or silent:<correlation> for a prompt=none sign-in.
subject
The person's pairwise sub.
scopes
What this sign-in asked for.
consentId, consentVersion
The consent in force and its version.
firstSignIn
true on the sign-in that created the connection, as the ID token's first_sign_in.
connectedAt
ISO 8601: when the connection was made.
completedAt
ISO 8601.
consentDuration, consentExpiresAt
until_revoked and null for an application consent.

data on consent.granted and consent.revoked

consentId
The consent record; consentVersion on a grant.
status
The consent's status after the change.
scopes
The full set in force — on a revocation, everything that has just stopped.
duration
until_revoked for an application consent.
expiresAt
Null for an application consent.
occurredAt
ISO 8601.
subject
The person, named by your application's pairwise sub.
A sign_in.completed delivery
{
  "id": "4d2f…",
  "type": "sign_in.completed",
  "version": "2.0",
  "occurred_at": "2026-09-09T12:00:00.000Z",
  "environment": "production",
  "application_id": "7a68073e-…",
  "actor": { "type": "person", "id": "sub_7Xk…" },
  "subject": { "type": "person", "id": "sub_7Xk…" },
  "correlation_id": "…",
  "data": {
    "authorizationId": "9b1c…",
    "subject": "sub_7Xk…",
    "scopes": ["openid", "email"],
    "consentId": "c0ffee…",
    "consentVersion": 1,
    "firstSignIn": false,
    "connectedAt": "2026-08-01T09:00:00.000Z",
    "completedAt": "2026-09-09T12:00:00.000Z",
    "consentDuration": "until_revoked",
    "consentExpiresAt": null
  }
}

Registering an endpoint

Endpoints are registered in the Developer console, on your application's webhooks screen under /applications/{applicationId}/webhooks: a URL, the events to subscribe to, and an optional description. An application may hold at most ten endpoints. The full contract — every event, the retry schedule, delivery history — is on Webhooks.

Subscribe to the sign-in and consent events (what the console sends)
POST /v1/developer/applications/{applicationId}/webhooks
content-type: application/json

{
  "url": "https://example.com/hooks/unifyid",
  "description": "Sign-in and consent lifecycle",
  "events": ["sign_in.completed", "connection.created", "consent.granted", "consent.revoked"]
}

There is no developer API token: /v1/developer/* answers only to a Developer console session, so the request above is what the console sends on your behalf, not something you call yourself.

After an ownership transfer, signing secrets expire. When an application changes hands, every webhook signing secret it carries (and every client secret) stops working 72 hours later. An endpoint whose secret has expired is held — its deliveries are cancelled as CANCELLED_SIGNING_SECRET_EXPIRED and nothing goes out — until the new owner rotates it. The endpoint reports secretExpiresAt, so rotate before then.

Verifying a delivery

Every delivery carries these headers:

unifyid-event-id
The event id. Deduplicate on it — a retried delivery reuses it.
unifyid-event-type
For example sign_in.completed or consent.revoked.
unifyid-timestamp
Unix seconds.
unifyid-signature
t=<timestamp>,v1=<hex> where the hex is HMAC-SHA256 over <timestamp>.<raw body> keyed with your signing secret. For 24 hours after a secret rotation the header carries two v1= values, one per secret: a delivery is genuine when any of them matches, so split on commas and compare each.
Check the signature
# Reproduce the signature from a captured delivery:
printf '%s.%s' "$TIMESTAMP" "$RAW_BODY" \
  | openssl dgst -sha256 -hmac "$SIGNING_SECRET" -hex

Sign over the raw body bytes, before any JSON parsing or re-serialisation. A re-serialised body will not match.

The envelope around data

id
Matches unifyid-event-id.
type
The event type.
version2.0
The envelope version.
occurred_at
ISO 8601.
environment
sandbox or production.
application_id
Your application.
actor, subject
Who did it and who it is about, each { type, id } with a pairwise id.
correlation_id
Ties the event to the request that caused it: correlation_ and 43 base64url characters, your application's own. Another application told about the same request receives a different value.
data
The event-specific payload described above.

What this does not do

  • Webhooks are not required to go live. There is no webhook item among the nine go-live checks. Subscribe because you want to know, not because something is gating you.
  • A webhook is a notification, not a guarantee. Deliveries retry, and most 4xx responses from you are treated as permanent failures — 408, 409, 425 and 429 are the exceptions that keep retrying. Your authoritative signal that a consent has ended is still the 401 the next userinfo call returns.
  • UnifyID never reads your response body. Return a 2xx quickly and do your work afterwards.

Verified against API commit 234dba22 on 2026-10-18. The routes, scopes, checklist, events and limits on these pages are read from that export, not typed.