UnifyIDDeveloper

Advanced integration

OAuth API

Construct the same secure authorization flow yourself when your application needs complete control over transaction creation and interface behavior.

Base URL and environments

Every API path in this guide is relative to the deployed Sandbox base URL. Sandbox and Production credentials are isolated and cannot be used across environments.

SurfaceSandbox URLPurpose
APIhttps://api.dev.unifyid.ioAuthorization, token exchange, UserInfo, capabilities, and webhooks.
Identity experiencehttps://dev.unifyid.ioHosted authentication, identity assurance, and consent.
Developer portalhttps://developer.dev.unifyid.ioApplications, credentials, scopes, redirect URIs, and webhook configuration.
ProductionProvided after approvalProduction domains and credentials are not available until the application passes Production review.

Create an application in the Developer portal, register every callback URI exactly, select the maximum scopes and document policy, and create a client secret only for a backend capable of protecting it. HTTPS is required outside localhost; wildcards and URL fragments are rejected.

Integration sequence

  1. 1Create a server-side OAuth transaction
  2. 2Generate state, nonce, and S256 PKCE
  3. 3Redirect to /v1/oauth/authorize
  4. 4Validate the callback state
  5. 5Exchange the one-time code at /v1/oauth/token
  6. 6Validate the ID token
  7. 7Call /v1/userinfo
  8. 8Listen for signed lifecycle webhooks

1. Create the transaction

Your backend should create and store a short-lived, single-use transaction before redirecting the browser. Bind it to the initiating browser session and store only the values required to validate the callback.

{
  "state": "{32_or_more_random_bytes_base64url}",
  "nonce": "{32_or_more_random_bytes_base64url}",
  "codeVerifier": "{43_to_128_character_pkce_verifier}",
  "redirectUri": "https://your-app.example/oauth/callback",
  "createdAt": "2026-07-31T12:00:00.000Z",
  "consumed": false
}

2. Authorization request

GET/v1/oauth/authorize

Starts Hosted UnifyID authentication, account-bound face assurance, and consent. It returns to the registered callback with either code and state, or an OAuth error and the original state.

ParameterRequiredDescription
response_typeYesMust be code.
client_idYesPublic Client ID issued to the application environment.
redirect_uriYesMust exactly match a registered callback URI.
scopeYesSpace-separated subset of scopes enabled for the application.
stateYesUnique unpredictable value bound to the initiating browser transaction.
nonceWith openidUnique unpredictable value that must match the ID token nonce.
code_challengePublic clients; recommended for allBase64url SHA-256 digest of the PKCE verifier.
code_challenge_methodWith PKCEMust be S256.
purposeRecommendedPlain-language reason shown during consent.
displayNoSet to popup only when authorization is opened in a popup window.
GET https://api.dev.unifyid.io/v1/oauth/authorize
  ?response_type=code
  &client_id={client_id}
  &redirect_uri={exact_registered_redirect_uri}
  &scope=openid%20profile%20email%20identity_verified
  &state={unpredictable_state}
  &nonce={unpredictable_nonce}
  &code_challenge={base64url_sha256_verifier}
  &code_challenge_method=S256
  &purpose={plain_language_purpose}

3. Validate the callback

Load the transaction from the same browser session, compare state using a timing-safe comparison, reject missing, expired, consumed, or mismatched transactions, and mark it consumed before exchanging the code. Authorization codes expire after five minutes and can be used only once.

// Successful callback
GET https://your-app.example/oauth/callback?code={one_time_code}&state={original_state}

// Denied or failed callback
GET https://your-app.example/oauth/callback?error=access_denied&error_description={description}&state={original_state}

4. Token exchange

POST/v1/oauth/token

Consumes the authorization code and returns a 15-minute access token. An ID token is included only when openid was approved. Refresh tokens are not issued.

Confidential clients should authenticate with HTTP Basic. Encode the Client ID and Client Secret as form components before joining them with a colon and Base64 encoding the result. client_secret_post is supported for compatibility. Public browser and mobile clients send no secret and must use S256 PKCE.

POST https://api.dev.unifyid.io/v1/oauth/token
Authorization: Basic {base64(urlencode(client_id) + ":" + urlencode(client_secret))}
Accept: application/json
Content-Type: application/json

{
  "grant_type": "authorization_code",
  "code": "{one_time_authorization_code}",
  "redirect_uri": "https://your-app.example/oauth/callback",
  "client_id": "{client_id}",
  "code_verifier": "{original_pkce_verifier}"
}
{
  "access_token": "{access_token}",
  "token_type": "Bearer",
  "expires_in": 900,
  "id_token": "{id_token_when_openid_was_granted}",
  "scope": "openid profile email identity_verified"
}

5. Validate the ID token

GET/.well-known/openid-configuration

Returns the issuer, endpoints, supported scopes, S256 requirement, token authentication methods, and the JWKS URI.

GET/.well-known/jwks.json

Returns the active RS256 public signing key. Cache keys according to response headers and refetch once when an unfamiliar kid is encountered.

Use a maintained OpenID Connect library. Require RS256; select the JWK matching the header kid; verify the signature; require the discovery issuer; require aud to equal your Client ID; validate exp, iat, and auth_time; compare nonce with the stored transaction; and use sub as the application account key. Allow no more than 60 seconds of clock skew.

6. Request approved information

GET/v1/userinfo

Send the OAuth access token as a Bearer token. The response contains the pairwise sub and only claims approved for this authorization. Missing claims are omitted and must not be interpreted as false.

GET https://api.dev.unifyid.io/v1/userinfo
Authorization: Bearer {access_token}
Accept: application/json

Supported scopes

openidprofileemailemailsphonephoneslegal_nameageage_over_18date_of_birthnationalityidentity_verifiedidentity_assurance_levelliveness_verifiedface_match_verifiedprofile_photoprofile_photo_verifiedidentity_documentsdocument_typedocument_issuing_countrydocument_expiry_datedocument_validitydocument_verifieddocument_verified_atdocument_number_maskeddocument_number_full

See the Scopes reference for disclosure semantics, dependencies, sensitivity, attestation support, and document policy.

7. Identity documents

GET/v1/userinfo/identity-documents

Requires identity_documents. Returns the data.documents collection of active, verified, share-enabled documents that match application policy and consent. The collection is not currently paginated.

{
  "data": {
    "documents": [{
      "documentReference": "docref_application_specific_value",
      "displayName": "NG Passport",
      "documentType": "passport",
      "issuingCountry": "NG",
      "validityStatus": "active",
      "verified": true
    }]
  }
}
GET/v1/userinfo/identity-documents/:documentReference

Returns the approved fields for one opaque reference. A reference that is unknown, no longer policy-eligible, disabled, or no longer shareable returns 404 IDENTITY_DOCUMENT_NOT_FOUND. Missing identity_documents returns 403 insufficient_scope.

{
  "data": {
    "documentReference": "docref_application_specific_value",
    "displayName": "NG Passport",
    "documentType": "passport",
    "issuingCountry": "NG",
    "expiryDate": "2031-05-17",
    "validityStatus": "active",
    "verified": true,
    "maskedDocumentNumber": "•••• 7778",
    "proof": {
      "type": "UnifyIDAttestation",
      "attestationId": "attestation_public_reference",
      "schemaVersion": "1.0",
      "status": "issued",
      "proofHash": "privacy_safe_audit_hash"
    }
  }
}

proof is currently an UnifyID audit and attestation reference. It is not a client-verifiable signature over the JSON response. Do not recompute or independently trust proofHash; use the API authorization, response, and lifecycle status as the authoritative boundary until a public proof-verification specification is published.

Popup transport

Open a blank window synchronously from the click before waiting for your backend to create the transaction. The transaction endpoint returns only an authorization URL and transaction identifier; secrets and tokens never enter the opener page.

// Application backend
POST /api/auth/unifyid/transactions
Cookie: application_session={session}

// Response after storing state, nonce, verifier, callback, expiry, and consumed=false
{
  "transactionId": "oauth_txn_opaque",
  "authorizationUrl": "https://api.dev.unifyid.io/v1/oauth/authorize?...&display=popup"
}
// Opener page
const popup = window.open(
  "",
  "unifyid_" + crypto.randomUUID(),
  "popup=yes,width=540,height=760,resizable=yes,scrollbars=yes"
);

if (!popup) {
  window.location.assign((await createTransaction({ display: "page" })).authorizationUrl);
} else {
  const transaction = await createTransaction({ display: "popup" });
  popup.location.replace(transaction.authorizationUrl);

  const listener = event => {
    if (event.origin !== "https://your-app.example") return;
    if (event.source !== popup) return;
    if (event.data?.type !== "unifyid:oauth:complete") return;
    if (event.data.transactionId !== transaction.transactionId) return;
    window.removeEventListener("message", listener);
    popup.close();
    window.location.assign("/dashboard");
  };
  window.addEventListener("message", listener);
}
// Callback page after the backend validates state, exchanges the code,
// validates the ID token, and creates an HTTP-only application session.
window.opener?.postMessage(
  {
    type: "unifyid:oauth:complete",
    transactionId: "{opaque_transaction_id}"
  },
  "https://your-app.example"
);
window.close();

Capability discovery

GET/v1/capabilities/scopes
GET/v1/capabilities/countries
GET/v1/capabilities/countries/:countryCode
GET/v1/capabilities/countries/:countryCode/document-types/:documentType

Signed lifecycle webhooks

Supported events are oauth.authorization.completed, claim_access.completed, consent.granted, consent.revoked, and webhook.test. Each body uses { id, type, createdAt, data }.

unifyid-event-id: {event_id}
unifyid-event-type: consent.revoked
unifyid-signature: t={unix_seconds},v1={hex_hmac_sha256}

signed_payload = timestamp + "." + raw_request_body
expected = HMAC_SHA256(webhook_secret, signed_payload)

Verify the signature against the raw body using a timing-safe comparison, reject timestamps outside five minutes, persist the event ID before side effects, ignore duplicates, and return a successful response only after durable acceptance. Delivery attempts occur immediately and then after 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours, and 24 hours. Requests time out after five seconds. See the Webhook reference.

Errors and rate limits

The token endpoint returns OAuth errors as { "error": "invalid_grant", "error_description": "..." }. Other API endpoints use { "error": { "code": "...", "message": "...", "requestId": "..." } }. Authorization callbacks carry error, optional error_description, and the original state.

ConditionResultIntegrator action
Invalid request, redirect, scope, PKCE, or reused code400 with invalid_request, invalid_scope, or invalid_grantCorrect the request; never retry unchanged.
Missing or invalid client authentication401 invalid_client and WWW-AuthenticateCheck the environment and rotate the secret if necessary.
User denial or missing consentaccess_denied or consent_requiredReturn the person to the application without creating a session.
Missing document scope403 insufficient_scopeStart a new authorization for required access.
Document reference no longer resolves404 IDENTITY_DOCUMENT_NOT_FOUNDRemove cached availability and enumerate documents again only if still authorized.
Rate limit exceeded429 RATE_LIMIT_EXCEEDEDHonor Retry-After; do not busy-retry.

OAuth endpoints currently allow 30 requests per IP within 10 minutes by default. Read X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset on every response. Limits may be adjusted by environment and plan.

Token lifecycle and revocation

Authorization codes expire after five minutes and are single-use. Access and ID tokens expire after 15 minutes. Refresh tokens are not issued. UnifyID does not currently expose a token revocation endpoint. When consent is revoked, expired, or disconnected, UserInfo and document access reject the authorization even if the signed token has not reached its cryptographic expiry. Stop processing immediately when an API rejects the token or a signed consent.revoked event arrives.

Sandbox completion

Before requesting Production access, test approval, denial, callback-state mismatch, code reuse, wrong PKCE verifier, missing claims, expired consent, document removal, duplicate webhooks, signature failure, rate limiting, popup blocking, and full-page redirect fallback. Sandbox does not permit document_number_full.

Was this page helpful?
UnifyID Developer Documentation · Version V.1 · Updated July 2026