Step 1
Get your API keys
Sign up at passid.io/onboard. Once approved, log in to the institution dashboard, go to PassID Connect → API Keys, and create your first sandbox key. Use sandbox while building, then switch to your live institution API key after KYB and pilot approval.
🔑
Copy Connect keys immediately — secret keys are shown once and never stored in plaintext. Put them in server-side environment variables only. Never expose them in frontend code, mobile apps, logs, or version control.
# .env (never commit this file)
PASSID_CONNECT_KEY=sk_test_your_key_here
PASSID_CONNECT_BASE=https://api.passid.io/api/sandbox/connect
Where to find your keys
2
Open PassID Connect and use the API Keys / Live Access tabs
3
Create a sandbox key for testing. After production approval, use your live institution API key with the production base URL.
Step 2
Authentication & base URL
All server-side PassID Connect requests use Bearer token authentication. Pass your sandbox or live institution API key in the Authorization header. The institution is derived from the key, never from a caller-supplied institution ID.
# Sandbox base URL
https://api.passid.io/api/sandbox/connect
# Production base URL for approved institutions
https://api.passid.io/v1/connect
# Required header on every server-side request
Authorization: Bearer $PASSID_CONNECT_KEY
# Optional — prevents double-execution on retry
Idempotency-Key: unique-id-per-request
Quick smoke test
Run this to confirm your key is working before writing any code:
curl https://api.passid.io/api/sandbox/connect/keys \
-H "Authorization: Bearer sk_test_your_key_here"
# 200 → your key is active. 401 → check the key value.
i
For production, replace the base URL with https://api.passid.io/v1/connect and use your live institution API key. Requested scopes are checked against your institution package before PassID creates a session.
Step 3
The PassID Connect flow
The scalable integration starts inside your application and moves the customer into PassID-hosted authorization. Institution staff never ask for, copy, or enter the customer's PassID Code:
Customer clicks Connect with PassID
The customer starts an application on your website or app and clicks Connect with PassID.
Your backend creates a scoped session
Your backend calls POST /sessions with the approved package scopes, purpose, access duration, return URL, and your internal application reference.
PassID-hosted UI opens
Your frontend opens the hosted URL or SDK modal. The customer signs in or, if needed, enters a temporary PassID Code inside PassID.
Customer reviews and approves
PassID shows your verified institution name, requested scopes, purpose, and duration. The customer approves or declines.
PassID redirects back
PassID sends the customer to your return_url. Your backend verifies GET /sessions/:session_id before trusting the callback.
You read approved customer data
Call data endpoints with the connection_id and your secret key. Each endpoint enforces approved scopes independently.
Step 4
Create a hosted session
This is the main call. Your backend creates a hosted Connect session with requested scopes, purpose, access duration, return URL, and your internal application reference. PassID returns a client_secret for server-side retrieval and hosted_url for the customer-facing authorization step.
POST
/v1/connect/sessions
Creates a hosted Connect session. Returns session_id, server-side client_secret, hosted_url, and backing request_id. Supports idempotency via the Idempotency-Key header.
curl -X POST https://api.passid.io/v1/connect/sessions \
-H "Authorization: Bearer live_institution_api_key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: req-$(uuidgen)" \
-d '{' \
'"return_url": "https://institution.example/app/passid/callback",' \
'"application_reference": "app_123",' \
'"scopes": ["identity.read", "accounts.read", "income.read"],' \
'"purpose": "Personal loan application",' \
'"access_duration": "90days"' \
'}'
Request body fields
| Field | Type | Required | Description |
| return_url | string | Yes | Absolute URL PassID redirects to after approval or decline |
| application_reference | string | No | Your internal application, applicant, or workflow reference |
| scopes | string[] | Yes | Data scopes to request. Scopes must be enabled by your institution package. See scope reference below. |
| purpose | string | No | Shown to the customer on the approval screen. Be specific: "Rental application", "Personal loan", etc. |
| access_duration | string | No | "90days", "1year", or "permanent". Defaults to 90 days. |
Response
{
"data": {
"session_id": "pcs_live_a1b2c3d4",
"client_secret": "pcs_live_secret_...",
"hosted_url": "https://app.passid.io/connect/authorize?env=live&session=pcs_live_...",
"status": "pending_customer",
"requested_scopes": "identity.read accounts.read income.read",
"expires_at": "2026-06-26T12:20:00Z"
}
}
Scope bundles by use case
| Use case | Recommended scopes |
| Personal loan / credit | identity.read accounts.read balances.read income.read liabilities.read risk_flags.read |
| Rental application | identity.read accounts.read balances.read income.read verification_status.read |
| Marketplace onboarding | identity.read accounts.read verification_status.read |
| Insurance underwriting | identity.read accounts.read income.read liabilities.read risk_flags.read |
| Employment verification | identity.read income.read verification_status.read |
| Full access | identity.read accounts.read balances.read transactions.read income.read liabilities.read risk_flags.read verification_status.read when enabled by the institution package |
Step 5
Handle hosted callback
After approval or decline, PassID redirects the customer to your return_url. Your backend verifies the hosted session status and stores the approved connection_id.
GET
/v1/connect/sessions/:session_id
Returns the current status of a hosted session. Verify this after the return_url callback. When status is "approved", the connection_id is included.
async function waitForApproval(sessionId, timeoutMs = 300_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await fetch(`${BASE}/sessions/${sessionId}`, {
headers: { "Authorization": `Bearer ${SK}` },
});
const { data } = await res.json();
if (data.status === "approved") return data.connection_id;
if (data.status === "declined") throw new Error("Customer declined");
if (data.status === "expired") throw new Error("Session expired");
await new Promise(r => setTimeout(r, 2500)); // poll every 2.5s
}
throw new Error("Timeout waiting for approval");
}
Status values
| Status | Meaning | Action |
| pending_customer | Waiting for customer to approve or decline | Keep polling |
| approved | Customer approved — connection_id is in the response | Use connection_id to fetch data |
| declined | Customer declined the request | Tell the customer and allow them to retry with a new code |
| expired | Customer did not respond within the TTL | Ask the customer to relaunch PassID authorization |
Approved response
{
"data": {
"session_id": "pcs_live_a1b2c3d4",
"client_secret": "pcs_live_secret_...",
"hosted_url": "https://app.passid.io/connect/authorize?env=live&session=pcs_live_...",
"status": "approved",
"connection_id": "conn_live_x7y8z9",
"granted_scopes": ["identity.read", "accounts.read", "income.read"],
"approved_at": "2026-06-26T12:05:42Z"
}
}
!
Production rule: never trust only the query string on your callback. Always verify the session server-side with GET /v1/connect/sessions/:session_id. In sandbox, use the hosted test flow to approve or decline as a customer.
Step 7
Fetch verified customer data
With a connection_id in hand, call any data endpoint. Each call returns data for the scope that was granted. A 403 with code INSUFFICIENT_SCOPE means the customer did not grant that scope.
GET
/v1/connect/connections/:id/identity
Returns the customer's verified identity: full name, masked date of birth, country, city, verification status, and verified_at timestamp. Requires identity.read scope.
GET
/v1/connect/connections/:id/accounts
Returns the customer's linked financial accounts: account IDs, type (savings/current), institution name, masked last 4 digits, currency, status. Requires accounts.read.
GET
/v1/connect/connections/:id/balances
Returns current balances per account. Requires balances.read.
GET
/v1/connect/connections/:id/income
Returns verified income data: monthly income, income source, currency, frequency, and employer (if employment-based). Requires income.read.
GET
/v1/connect/connections/:id/liabilities
Returns outstanding liabilities: loans, credit facilities, monthly obligations. Requires liabilities.read.
GET
/v1/connect/connections/:id/risk-flags
Returns risk indicators and flags. Requires risk_flags.read.
GET
/v1/connect/connections/:id/verification-status
Returns identity verification status and date. Requires verification_status.read.
Example: fetch identity
curl https://api.passid.io/v1/connect/connections/conn_live_x7y8z9/identity \
-H "Authorization: Bearer live_institution_api_key"
{
"data": {
"connection_id": "conn_live_x7y8z9",
"environment": "live",
"identity": {
"full_name": "Amara Osei",
"date_of_birth": "****-**-14", // masked
"country": "KE",
"city": "Nairobi",
"verification_status": "verified",
"verified_at": "2026-01-15T09:20:00Z"
}
}
}
Complete integration — 25 lines
const SK = process.env.PASSID_CONNECT_KEY;
const BASE = process.env.PASSID_CONNECT_BASE ?? "https://api.passid.io/v1/connect";
const h = { "Authorization": `Bearer ${SK}`, "Content-Type": "application/json" };
async function connectCustomer(scopes, purpose, applicationReference) {
// 1. Create a hosted session
const req = await fetch(`${BASE}/sessions`, {
method: "POST", headers: h,
body: JSON.stringify({ scopes, purpose, access_duration: "90days", return_url: "https://institution.example/app/passid/callback", application_reference: applicationReference }),
}).then(r => r.json());
const sessionId = req.data.session_id;
// 2. Poll until customer approves (or times out)
let connectionId;
for (let i = 0; i < 120; i++) {
await new Promise(r => setTimeout(r, 2500));
const s = await fetch(`${BASE}/sessions/${sessionId}`, { headers: h }).then(r => r.json());
if (s.data.status === "approved") { connectionId = s.data.connection_id; break; }
if (["declined", "expired"].includes(s.data.status)) throw new Error(s.data.status);
}
if (!connectionId) throw new Error("timeout");
// 3. Fetch verified data
const [identity, income] = await Promise.all([
fetch(`${BASE}/connections/${connectionId}/identity`, { headers: h }).then(r => r.json()),
fetch(`${BASE}/connections/${connectionId}/income`, { headers: h }).then(r => r.json()),
]);
return { connectionId, identity: identity.data, income: income.data };
}
Step 8
Webhooks
PassID sends signed HTTP POST events to your registered webhook URL when connection state changes. Configure your URL in the institution dashboard. Treat webhooks as notifications and verify final state through the Connect API before making decisions.
Event types
connection.created
Fires when a customer approves a hosted Connect session. Payload includes connection_id, request_id, and granted_scopes. Use this to trigger your underwriting or onboarding flow.
connect.session.created
Fires when your backend creates a hosted Connect session. Payload includes session_id, request_id, and requested scopes.
connect.session.approved
Fires when the customer approves. Payload includes connection_id, consent_id, and granted_scopes.
connect.session.declined
Fires when the customer declines. No customer data is shared.
connection.created
Fires when an approved connection is created and ready for approved data retrieval.
connection.revoked
Fires when access is revoked. Stop using the connection_id immediately.
connection.expired
Fires when the access duration expires. Create a new hosted session if the customer needs to reauthorize.
Webhook payload
// Headers sent by PassID
X-PassID-Event: connection.created
X-PassID-Signature: sha256=abc123...
Content-Type: application/json
// Body
{
"event_id": "evt_live_9a8b7c6d",
"event_type": "connection.created",
"created_at": "2026-06-26T12:05:42Z",
"data": {
"connection_id": "conn_live_x7y8z9",
"session_id": "pcs_live_a1b2c3d4",
"granted_scopes": ["identity.read", "income.read"]
}
}
Verify the signature (Node.js)
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyWebhook(rawBody, sigHeader, sk) {
const expected = createHmac("sha256", sk)
.update(rawBody)
.digest("hex");
const received = (sigHeader ?? "").replace(/^sha256=/, "");
const a = Buffer.from(expected);
const b = Buffer.from(received);
if (a.length !== b.length || !timingSafeEqual(a, b)) throw new Error("Invalid signature");
}
// Express handler
app.post("/webhooks/passid", express.raw({ type: "application/json" }), (req, res) => {
verifyWebhook(req.body, req.headers["x-passid-signature"], process.env.PASSID_WEBHOOK_SECRET);
const event = JSON.parse(req.body);
// handle event.event_type
res.sendStatus(200); // respond fast — process async
});
i
Use express.raw() (not express.json()) so the body is the exact bytes PassID sent. Parsing before HMAC validation will break the signature check.
Step 9
Error reference
All error responses follow the same envelope. The error.code field is machine-readable; error.message is human-readable.
{
"success": false,
"error": {
"code": "INVALID_PASSID_CODE",
"message": "This PassID Code is not recognised."
},
"requestId": "req_connect_abc123"
}
| Error code | HTTP | What happened & what to do |
| INVALID_API_KEY | 401 | Your Connect API key is wrong or revoked. Check it in PassID Connect → API Keys. |
| UNAUTHENTICATED | 401 | Authorization header missing or malformed. Format: Bearer <connect_api_key> |
| INVALID_PASSID_CODE | 404 | The fallback code was invalid or cancelled inside PassID-hosted auth. Ask the customer to relaunch PassID. |
| PASSID_CODE_ALREADY_USED | 409 | The fallback code was already consumed inside PassID-hosted auth. Ask the customer to relaunch PassID. |
| PASSID_CODE_EXPIRED | 410 | The fallback code expired inside PassID-hosted auth. Ask the customer to relaunch PassID. |
| INSUFFICIENT_SCOPE | 403 | The connection doesn't grant the requested scope. Check granted_scopes on the connection. |
| RATE_LIMITED | 429 | Too many requests. Back off and retry after the Retry-After header value. |
| INVALID_REQUEST | 400 | The request is malformed, the redirect URL is not registered, or a requested scope is not enabled for the institution package. |
| LIVE_MODE_NOT_ENABLED | 403 | The institution is not approved for live Connect access. Complete KYB and live-access review. |
Step 10
Scope reference
Request only the scopes your use case needs. Customers see each scope on the approval screen — fewer scopes means higher approval rates.
| Scope | Endpoint suffix | Data returned |
| identity.read | /identity | Full name, masked date of birth, country, city, verification status, verified_at |
| accounts.read | /accounts | Account IDs, type (savings/current), institution, masked last4, currency, status |
| balances.read | /balances | Current balance per account |
| transactions.read | /transactions | 90-day transaction history |
| income.read | /income | Monthly income, source, currency, frequency, employer |
| liabilities.read | /liabilities | Outstanding loans, credit facilities, monthly obligations |
| risk_flags.read | /risk-flags | Risk indicators and flags |
| verification_status.read | /verification-status | Verification status and date |
Step 11
Sandbox test users
The sandbox has five pre-seeded users covering different scenarios. Use the sandbox dashboard or hosted sandbox authorization flow to run the full session, callback, and data retrieval lifecycle.
| User | Sandbox ID | Scenario | What to test |
| Amara Osei (KE) | sbuser_success_001 | Full success | Happy path — all scopes, clean data, approved quickly |
| Tobenna Eze (NG) | sbuser_partial_002 | Partial consent | Customer grants only some requested scopes — handle INSUFFICIENT_SCOPE on denied ones |
| Priya Mehta (IN) | sbuser_highliai_003 | High liability | Income and liabilities data present — test your risk assessment logic |
| Kwame Asante (GH) | sbuser_discon_004 | Disconnected | Connection exists but account inactive — test stale connection handling |
| Fatima Al-Hassan (NG) | sbuser_blocked_005 | Blocked user | User is flagged — test your blocked-user flow and user.blocked webhook |
Run a hosted Connect sandbox flow
Open the sandbox dashboard → PassID Connect tab
Select a sandbox user in hosted PassID
Choose any of the 5 test users from the PassID-hosted screen. If a code fallback is shown, it stays inside PassID customer authentication.
Create the hosted session from your backend
Your server creates the scoped session and passes the hosted URL or client secret back to the frontend.
Approve in hosted PassID
The customer approves in PassID-hosted UI. Your callback handler verifies the session and stores the connection_id.
Step 12
Go-live checklist
Complete all items before switching to your live API key.
- ✓Sandbox and live Connect keys are stored in a secrets manager (AWS Secrets Manager, Railway variables, Vault) — never in source code or
.env committed to version control
- ✓End-to-end sandbox flow complete: created a hosted session, launched PassID, approved consent, handled callback, stored
connection_id, and fetched identity and income data
- ✓Webhook URL configured in sandbox dashboard → Settings → tested with the Send test event button in the Webhooks tab
- ✓Webhook signature verification implemented using HMAC-SHA256 with
timingSafeEqual — and tested against the test event
- ✓Webhook endpoint responds
200 within 5 seconds — async processing happens in background (queue, worker, etc.)
- ✓
Idempotency-Key header sent on every POST /sessions to prevent duplicate hosted sessions on network retry
- ✓Hosted authorization errors handled, including customer cancellation, expired session, and PassID Code fallback failures inside PassID UI
- ✓Partial consent handled — check
granted_scopes before calling data endpoints; don't assume all requested scopes were granted
- ✓All five sandbox users tested — especially partial_consent and blocked scenarios
- ✓Institution package is assigned correctly so Connect only allows services approved for the institution's use case
Ready to go live?
Our team activates live keys after a short review. We check that your webhook is reachable, your signature verification works, and your error handling is correct. Typical review takes 1 business day.
Request live key review ›