Assertion Flow
Check whether a user already has an Emblem pass that satisfies your policy.
What Assertion Is
The assertion flow checks whether a user already has an Emblem pass that satisfies your verification policy. If reusable proof exists, your server can exchange an authorization code for a slim assertion payload. If it does not, Emblem returns a normal "continue with your own verification" outcome.
When To Use It
Use assertion when you want an "Already verified with Emblem?" branch inside your product before running your own verification flow.
Typical pattern:
- Offer Emblem as an optional shortcut.
- Check for reusable proof first.
- Fall back to your normal verification when reusable proof is not available.
Prerequisites
You need:
- A provisioned
client_id - A registered
redirect_uri - A secret API key (
emb_sk_...)
Use redirect_uri consistently for both transaction creation and code exchange.
End-to-End Flow
Your server → POST /api/v1/assertions/transactions → receives authorize_url
Browser → redirects or opens popup to Emblem → user authenticates
Emblem → checks for reusable proof → evaluates policy
Browser → returns to your redirect_uri → with code or error
Your server → POST /api/v1/assertions/token → receives assertion dataBrowser Callback Contract
The browser callback to your redirect_uri has three meaningful outcomes:
| Outcome | Query params | Meaning |
|---|---|---|
| Success | code, state | Reusable proof was approved and you can exchange the code on your server |
| No reusable proof | error=requires_verification, error_code=NO_REUSABLE_RECORD, state | The user authenticated to Emblem but does not have reusable proof that satisfies your policy |
| Level upgrade needed | error=requires_verification, error_code=LEVEL_UPGRADE_REQUIRED, state | The user has proof, but not at the requested level |
requires_verification is not a transport failure. It is the normal branch
where you continue with your own verification flow.
Always validate state before taking action on the callback.
Redirect Pattern
Use full-page redirect when you are comfortable handing the whole browser window to Emblem during the check.
1. Create a transaction on your server
import crypto from 'node:crypto'
import { createClient } from '@emblemapp/sdk'
const emblem = createClient({
apiKey: process.env.EMBLEM_SECRET_KEY!,
})
const transaction = await emblem.createAssertionTransaction({
client_id: process.env.EMBLEM_ASSERTION_CLIENT_ID!,
redirect_uri: 'https://provider.example/emblem/callback',
state: crypto.randomUUID(),
nonce: crypto.randomUUID(),
required_level: 'L1',
})2. Redirect the browser
res.redirect(transaction.authorize_url)3. Handle the callback
app.get('/emblem/callback', async (req, res) => {
if (req.query.state !== req.session.assertionState) {
return res.status(403).send('Invalid state')
}
if (req.query.error === 'requires_verification') {
return res.redirect('/verify-with-provider')
}
const assertion = await emblem.exchangeAssertionCode({
grant_type: 'authorization_code',
code: req.query.code as string,
client_id: process.env.EMBLEM_ASSERTION_CLIENT_ID!,
redirect_uri: 'https://provider.example/emblem/callback',
})
// Use assertion.assertion.subject, level, and verified_at in your app.
res.json(assertion)
})Popup Pattern
Use popup mode when you want to keep your current page loaded and let Emblem run in a separate window.
Parent page
import { openAssertionPopup } from '@emblemapp/sdk'
const transaction = await emblem.createAssertionTransaction({
client_id: process.env.EMBLEM_ASSERTION_CLIENT_ID!,
redirect_uri: 'https://provider.example/emblem/popup-callback',
state: crypto.randomUUID(),
nonce: crypto.randomUUID(),
required_level: 'L1',
})
const result = await openAssertionPopup(transaction.authorize_url, {
expectedOrigin: window.location.origin,
timeoutMs: 120000,
})
if (result.status === 'success') {
await fetch('/api/emblem/assertion/exchange', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: result.code, state: result.state }),
})
} else if (result.status === 'error') {
if (result.error === 'requires_verification') {
window.location.href = '/verify-with-provider'
}
} else if (result.status === 'closed') {
// Popup closed before a callback reached the opener.
// Reconcile with the stored transaction_id or offer retry.
}Popup callback page
Host a small callback page on your own origin and relay the result back to the opener:
import { handleAssertionPopupCallback } from '@emblemapp/sdk'
handleAssertionPopupCallback({
targetOrigin: 'https://provider.example',
expectedState: sessionStorage.getItem('emblem_assertion_state') ?? undefined,
})Popup helper behavior
openAssertionPopup() resolves to one of:
{ status: 'success', code, state }{ status: 'error', error, errorCode, state }{ status: 'closed' }
handleAssertionPopupCallback() normalizes the query string and posts the
result to window.opener. If autoClose is not disabled, it closes the popup
after delivery.
Caller-owned callback page
Your popup callback page is hosted by you, not by Emblem. Emblem redirects the
popup back to your redirect_uri, and that page calls
handleAssertionPopupCallback().
Server-Side Code Exchange
Exchange approved authorization codes on your server with
POST /api/v1/assertions/token.
const result = await emblem.exchangeAssertionCode({
grant_type: 'authorization_code',
code: req.body.code,
client_id: process.env.EMBLEM_ASSERTION_CLIENT_ID!,
redirect_uri: 'https://provider.example/emblem/callback',
})Request parameters
| Parameter | Required | Description |
|---|---|---|
grant_type | Yes | Must be authorization_code |
code | Yes | The browser-returned authorization code |
client_id | Yes | Your provisioned authorization client |
redirect_uri | No | If provided, must exactly match the redirect_uri used on transaction creation |
Response shape
{
"transaction_id": "emb_atx_123",
"correlation_id": "corr_123",
"assertion": {
"client_id": "emb_cli_123",
"subject": "emb_sub_pairwise_value",
"transaction_id": "emb_atx_123",
"nonce": "nonce_123",
"level": "L1",
"verified_at": "2026-03-24T20:00:00.000Z",
"authenticated_at": "2026-03-24T20:01:00.000Z",
"source_provider": "SAFEPASSAGE"
}
}Assertion payload field guide
| Field | Meaning |
|---|---|
client_id | Your authorization client |
subject | Stable pairwise user identifier for this client. The same user gets the same subject for the same client_id, but a different one for a different client |
level | Verification level satisfied by the reusable proof: L1 or L2 |
verified_at | When the original verification happened |
authenticated_at | When the user authenticated to Emblem during this assertion flow |
source_provider | Provider/provenance enum for the reusable proof. EMBLEM_LEGACY represents imported legacy proof rather than a live provider verification |
nonce | Your original transaction nonce, echoed back |
Codes are single-use and expire after 2 minutes.
Reconciliation And Interrupted Flows
Use GET /api/v1/assertions/transactions/{transactionId} when the browser flow
did not cleanly complete on your side.
Typical cases:
- The user closes the popup.
- The browser is interrupted before your callback finishes.
- The transaction expires while the user is still on Emblem.
- Your app needs to distinguish pending from approved from
requires_verification.
Always store transaction_id when you create the transaction.
What code_available means
If reconciliation returns status: APPROVED and code_available: true, an
unconsumed authorization code still exists for that transaction.
It does not return the code itself. You still need the front-channel callback to obtain it.
Status action model
| Status | What it means | Recommended action |
|---|---|---|
PENDING | User has not completed the flow yet | Keep waiting briefly or let the user retry |
AUTHENTICATED | User authenticated to Emblem, but evaluation is not complete | Keep waiting briefly or let the user retry |
APPROVED | Reusable proof satisfied policy | If code_available=true, wait for callback or restart if your UX timed out |
REQUIRES_VERIFICATION | No reusable proof or level upgrade required | Continue your normal verification flow |
EXCHANGED | Code was already exchanged on some server path | Treat the flow as already completed |
FAILED or CANCELLED | The flow ended without approval | Show a recoverable error and offer retry |
EXPIRED | Transaction TTL elapsed | Start a fresh transaction |
Error Reference
Transaction creation
| Code | HTTP status | Meaning | What to do |
|---|---|---|---|
VALIDATION_ERROR | 400 | Missing or invalid request fields | Check client_id, redirect_uri, state, and enum values |
INVALID_REDIRECT_URI | 400 | redirect_uri is not registered for the client | Register the exact URI you send |
UNAUTHORIZED | 401 | Missing or invalid secret key | Check Authorization: Bearer emb_sk_... |
PUBLISHER_SUSPENDED | 403 | Tenant is suspended by Emblem | Contact Emblem support or your account owner |
CLIENT_INACTIVE | 403 | Authorization client exists but is inactive | Ask Emblem to activate the client |
NOT_FOUND | 404 | Authorization client not found | Verify client_id and environment alignment |
RATE_LIMITED | 429 | Too many requests | Back off and honor Retry-After |
INTERNAL_ERROR | 500 | Emblem-side failure | Retry if appropriate and log request_id / correlation_id |
Token exchange
| Code | HTTP status | Meaning | What to do |
|---|---|---|---|
VALIDATION_ERROR | 400 | Request body failed validation | Check grant_type, code, and client_id |
INVALID_GRANT | 400 | Exchange failed for an unmatched grant reason | Treat as a terminal exchange failure and restart |
EXPIRED_CODE | 400 | Authorization code expired | Start a fresh assertion transaction |
REUSED_CODE | 400 | Authorization code was already consumed | Do not retry the same code |
CODE_NOT_FOUND | 400 | Code does not exist | Confirm you are exchanging the latest callback code |
CLIENT_MISMATCH | 400 | Code does not belong to the provided client_id | Use the same client_id from transaction creation |
REDIRECT_URI_MISMATCH | 400 | Provided redirect_uri does not match the original transaction | Send the exact same redirect_uri, or omit it if you do not need the equality check |
UNAUTHORIZED | 401 | Missing or invalid secret key | Check the Authorization header |
CLIENT_INACTIVE | 403 | Authorization client is inactive | Ask Emblem to activate the client |
NOT_FOUND | 404 | Authorization client not found | Verify client_id and environment |
TRANSACTION_EXPIRED | 409 | Transaction expired before exchange | Restart the flow |
TRANSACTION_NOT_APPROVED | 409 | Transaction completed without reusable proof approval | Follow the browser callback outcome or reconciliation result |
TRANSACTION_NOT_READY | 409 | Transaction is not yet ready to exchange | Wait briefly or restart if the user abandoned the flow |
RATE_LIMITED | 429 | Too many requests | Back off and honor Retry-After |
INTERNAL_ERROR | 500 | Emblem-side failure | Retry if appropriate and log trace ids |
Reconciliation
| Code | HTTP status | Meaning | What to do |
|---|---|---|---|
VALIDATION_ERROR | 400 | Invalid transaction ID | Send an emb_atx_... id |
UNAUTHORIZED | 401 | Missing or invalid secret key | Check your Authorization header |
NOT_FOUND | 404 | Transaction not found for this publisher | Verify transaction_id and environment alignment |
RATE_LIMITED | 429 | Too many lookup requests | Back off and honor Retry-After |
INTERNAL_ERROR | 500 | Emblem-side failure | Retry if needed and log trace ids |
Constraints
- Redirect and popup are supported. Iframe embedding is not.
- Connect uses secret-key client auth only. PKCE and public clients are not supported.
- Assertion does not auto-escalate into hosted verification. If reusable proof is not available, your app must explicitly continue its own verification flow.