Verify API
Boolean claims. Your institution decides.
The Verify API is server-side. You create a verification request with the claims you need. The user permissions those claims. PASSID returns verified_claims booleans and credential status — never a score, rating, or recommendation. Use institution keys pk_sandbox_… / pk_live_… from the dashboard under API & Webhooks.
🔑
Separate from Connect and Pay. Verify keys do not work on Connect sessions. Connect keys do not work here. Header: Authorization: Bearer pk_sandbox_… or X-Institution-Key.
POST
/v1/verification-requests
Create a request. Body includes user_reference, claims, callback_url, and Idempotency-Key. Returns a request id and consent link.
GET
/v1/verifications/{id}
Read verified_claims booleans and credential status after consent. Apply your own policy.
Verify API · create
Create a verification request
Always create the request on your server. Never put the secret key in a browser or mobile app.
curl -X POST https://api.passid.io/v1/verification-requests \
-H "Authorization: Bearer $PASSID_SECRET_KEY" \
-H "Idempotency-Key: req-2026-09-01-0001" \
-d '{ "user_reference": "applicant_8842", "claims": ["identity_verified","income_verified"], "callback_url": "https://yourbank.com/passid/webhook" }'
Verify API · read
Read verified claims
When the webhook fires claim.verification.completed, fetch the result. PASSID does not approve or decline.
# verified_claims.identity_verified = true | false
# verification.institution_decision_required = true
# Apply YOUR policy. PASSID never returns a score.
Workspace: institution dashboard → Integration Guide → Verify API, and API & Webhooks for keys.
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 with a one-time code
PassID sends the customer to your return_url with a short-lived, single-use code and the state value you originally generated. The code is not usable on its own — verify state matches, then move to the next step server-side.
Your backend exchanges the code for evidence
Your backend calls POST /token server-side with the code and your PKCE code_verifier. This is the step that actually authorizes the connection — a connection_id or case_id alone, without this exchange, is never enough to read data.
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.
!
PKCE is mandatory in live mode. Generate a random code_verifier, derive code_challenge = BASE64URL(SHA256(code_verifier)), and generate your own opaque state value. Store both server-side (session store, signed cookie, or similar) keyed to this application attempt — you'll need the verifier again at the token-exchange step, and you must verify the returned state matches before trusting the callback.
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",' \
'"state": "<your opaque, unguessable value>",' \
'"code_challenge": "<BASE64URL(SHA256(code_verifier))>",' \
'"code_challenge_method": "S256"' \
'}'
Request body fields
| Field | Type | Required | Description |
| return_url | string | Yes | Absolute URL PassID redirects to after approval or decline. Must be pre-registered — see note below. |
| 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. |
| state | string | Recommended | Your own opaque value, echoed back unchanged on the callback. Verify it matches before trusting anything else in the callback. |
| code_challenge | string | Yes (live) | PKCE challenge derived from a code_verifier you generate and keep server-side. Required in live mode. |
| code_challenge_method | string | Yes (live) | Must be "S256". |
!
return_url must be registered first, and matching is exact. Register it via PUT /api/institution/redirect-urls (send {"redirectUrls": ["https://yourapp.example/passid/callback"]} with your dashboard session, up to 10 URLs — there's no dashboard UI for this yet, so call the API directly). The match is exact origin + path, not a prefix — a registered https://yourapp.example/passid will not accept https://yourapp.example/passid/123. The query string is ignored on both sides, so put per-request data there (?application=123), not in the path, if it varies per session.
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 the callback and exchange the code
After approval or decline, PassID redirects the customer to your return_url with code, state, and status in the query string. Never treat anything in that query string as authoritative on its own — including connection_id, which is present for convenience but proves nothing by itself. Verify state matches the value you generated, then immediately exchange code server-side at POST /token. The token response is what actually authorizes the connection and returns the verified evidence.
Server-to-server exchange of the one-time authorization code for the connection's evidence. Requires your sk_live_ secret key and the matching PKCE code_verifier. The code is single-use — a second exchange without a matching Idempotency-Key returns 409 code_already_used.
async function handleCallback(req) {
const { code, state, status } = req.query;
const saved = await loadSavedAttempt(req); // { state, codeVerifier, redirectUri }
if (!saved || state !== saved.state) throw new Error("state mismatch — possible CSRF, do not proceed");
if (status === "declined") return handleDecline();
// This call is what actually authorizes the connection.
// connection_id/case_id from the query string above are not usable until this succeeds.
const res = await fetch(`${BASE}/token`, {
method: "POST",
headers: { "Authorization": `Bearer ${SK}`, "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "authorization_code",
code,
redirect_uri: saved.redirectUri,
code_verifier: saved.codeVerifier,
}),
});
if (!res.ok) throw new Error("code exchange failed");
const { data } = await res.json();
// data.connection_id, data.evidence_result, data.granted_scopes, etc. are now trustworthy.
return data;
}
Token response
{
"data": {
"token_type": "Bearer",
"connection_id": "conn_live_x7y8z9",
"case_id": "case_live_a1b2c3",
"institution_subject_id": "isub_live_a1b2c3d4e5f6",
"evidence_result": "verified",
"granted_scopes": ["identity.read", "accounts.read", "income.read"],
"denied_scopes": [],
"result_url": "/v1/verification-cases/case_live_a1b2c3/result"
}
}
i
Store institution_subject_id against your own applicant record. It's stable per real, verified identity at your institution — the same person always resolves to the same value, even across separate applications with different internal IDs on your side. Use it as your own duplicate-account signal: if a new application's institution_subject_id matches one you've already seen, that's the same real person applying again, not a new one.
!
Production rule: never trust connection_id, case_id, or any other query-string value on your callback until POST /v1/connect/token has succeeded — the API enforces this server-side, so a skipped exchange fails closed with 403 CONNECTION_NOT_AUTHORIZED rather than silently working. In sandbox, use the hosted test flow to approve or decline as a customer.
Step 7
Fetch verified customer data
The POST /token response from the previous step already includes the evidence for every granted scope — you don't need to call anything else for the initial read. These endpoints exist for re-fetching later (status checks, a fresh poll after time has passed). Each call requires a connection_id from a connection that has completed the token exchange, and returns data for the scope that was granted. A 403 with code INSUFFICIENT_SCOPE means the customer did not grant that scope; CONNECTION_NOT_AUTHORIZED means /token was never called for this connection.
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.
i
Detecting repeat applicants: PassID recognizes when the same verified identity applies under a different applicant ID and merges it into one record rather than showing it as a second independent applicant. This is visible as is_repeat_applicant / case_count on GET /api/institution/applicants (filterable with ?repeat_only=true), and as a badge in the dashboard. PassID surfaces the signal — whether to trust an existing verification or require a fresh one per application is your own policy to implement. Note this endpoint uses your dashboard session, not your Connect API key.
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
Session creation and callback handling are naturally two separate requests (the customer's browser is in between) — shown here as two functions you'd wire into your own routes.
import { randomBytes, createHash } from "node:crypto";
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" };
const RETURN_URL = "https://institution.example/app/passid/callback";
// 1. Create a hosted session — call when the customer clicks "Connect with PassID".
// Save { state, codeVerifier } server-side, keyed to this application attempt.
async function startConnect(scopes, purpose, applicationReference) {
const state = randomBytes(16).toString("hex");
const codeVerifier = randomBytes(32).toString("base64url");
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
const req = await fetch(`${BASE}/sessions`, {
method: "POST", headers: h,
body: JSON.stringify({
scopes, purpose, access_duration: "90days", return_url: RETURN_URL,
application_reference: applicationReference, state,
code_challenge: codeChallenge, code_challenge_method: "S256",
}),
}).then(r => r.json());
await saveAttempt(applicationReference, { state, codeVerifier });
return req.data.hosted_url; // redirect the customer here
}
// 2. Handle the redirect back to RETURN_URL. connection_id/case_id in the query
// string are NOT authoritative until the exchange below succeeds.
async function handleCallback(query, applicationReference) {
if (query.status === "declined") throw new Error("Customer declined");
const saved = await loadAttempt(applicationReference);
if (!saved || query.state !== saved.state) throw new Error("state mismatch");
const exchanged = await fetch(`${BASE}/token`, {
method: "POST", headers: h,
body: JSON.stringify({
grant_type: "authorization_code", code: query.code,
redirect_uri: RETURN_URL, code_verifier: saved.codeVerifier,
}),
}).then(r => r.json());
// exchanged.data now holds the trustworthy connection_id, evidence_result,
// granted_scopes, and claims for every granted scope — no further calls needed.
return exchanged.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
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.
connection.created
Fires right after approval, once the connection itself exists and is ready for data retrieval. Payload includes connection_id, consent_id, institution_subject_id, and granted_scopes. Use this to trigger your underwriting or onboarding flow.
verification.result_ready
Fires once verification evidence has finished processing and is ready to fetch. Most integrations already have this data from the POST /token response — use this event instead if you'd rather be notified than poll.
connect.session.declined
Fires when the customer declines. No customer data is shared.
connection.revoked
Fires when access is revoked, by either the customer or the institution. Stop using the connection_id immediately.
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": "connection.created",
"event_type": "connection.created",
"institution_id": "901",
"timestamp": "2026-06-26T12:05:42Z",
"environment": "live",
"livemode": true,
"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 and names the specific field or reason.
i
Log the full response body, not just the status code. A generic code like INVALID_REQUEST covers several different causes — error.message is what actually tells you which one. This is the single most common thing that turns a five-minute fix into a long debugging session.
{
"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 — check error.message for the specific field. Common causes: missing code_challenge/code_challenge_method (PKCE is mandatory in live mode, no exceptions), 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 state, then exchanges the returned code at POST /token to get the authoritative connection_id and evidence.
PassID Pay API
Official sandbox API. Production access pending.
PassID Connect establishes the reusable customer relationship; PassID Pay references its connection_id and verified destination_id for a specific transaction. Connect sk_* and sandbox Pay pay_test_* keys remain separate so payment permissions can be isolated. Production Pay keys cannot be created. The sandbox never moves funds.
✓
Actors. The institution authorizes the simulated payout. The recipient consents to credential disclosure and confirms the payout destination. PassID binds those records and issues a sandbox credential. Only the PassID simulator runs today; licensed production execution remains disabled. The recipient does not authorize the merchant’s payout.
Authentication
Pay keys are a separate product
Mint a pay_test_… key in the dashboard under PassID Pay → API keys. The plaintext secret is shown once. Use the canonical base https://api.passid.io/v1/pay. The reserved pay_live_… prefix cannot be created or used until the licensed-provider, reconciliation, security, and operational gates pass.
| Environment | Credential and provider | Availability |
| Sandbox | pay_test_… · PassID simulator · synthetic data | Available · funds never move |
| Provider certification | Provider sandbox · test data | Not enabled |
| Production | Reserved pay_live_… · licensed provider | Production access pending |
Payment intents
Create an immutable payout
POST
/v1/pay/payment-intents
Create a payment intent from an active Connect relationship. Send connection_id, destination_id, policy_id, and Idempotency-Key. PassID resolves current consent and eligible evidence before authorization.
GET
/v1/pay/payment-intents/{id}
Read state, disclosure preview, and hosted consent URL.
POST
/v1/pay/payment-intents/{id}/merchant-authorize
A permitted institution principal explicitly authorizes the evaluated amount and destination. Recipient consent never substitutes for this step.
curl -X POST https://api.passid.io/v1/pay/payment-intents \
-H "Authorization: Bearer $PASSID_PAY_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: payout-2026-09-01-0001" \
-d '{ "amount": 120000, "currency": "USD", "purpose": "contractor_payout", "recipient": { "connection_id": "conn_01J...", "destination_id": "dst_01J..." }, "policy_id": "pol_contractor_payout_v1" }'
# → data.id data.hosted_url
# Send hosted_url to the recipient. They consent and confirm destination — they do not authorize the payout.
Recipient consent
Consent and destination are not merchant authorization
The hosted recipient page exchanges the single-use #invite=pht_sbx_… fragment through POST /v1/pay/recipient/session with {"invitation_token":"…"}. Its response supplies data.session_token for the recipient Bearer header. Invitations last up to 10 minutes; sessions up to 15 minutes. ID-only access is rejected. A new invitation revokes earlier links and sessions. This protects synthetic sandbox flows; it is not OTP-verified real-recipient identity.
Send the hosted request to the recipient. Institution users cannot consent to evidence reuse or confirm a destination on the recipient’s behalf. The institution-session consent and destination-confirmation endpoints return 403 RECIPIENT_ACTION_REQUIRED. The examples below exercise synthetic sandbox recipients only; real-recipient shadow-pilot access is not enabled.
POST
/v1/pay/payment-intents/{id}/consent
Recipient grants or declines credential disclosure. Body: {"approved": true}. This does not confirm the payout destination. Decline shares nothing and sends no payment instruction.
POST
/v1/pay/payment-intents/{id}/confirm-destination
Confirm the payout destination. Required again if the destination changes after consent.
Execute
Simulated provider execution
POST
/v1/pay/payment-intents/{id}/execute
Merchant-authenticated. Hands a purpose-bound instruction to the PassID simulator and issues a signed sandbox credential. Payment outcome is simulated_completed; credential status is separately active; funds_moved is always false.
GET
/v1/pay/payment-intents/{id}/events
Audit trail: merchant authorization, consent, destination, policy, adapter, credential.
curl -X POST https://api.passid.io/v1/pay/payment-intents/$INTENT_ID/merchant-authorize \
-H "Authorization: Bearer $PASSID_PAY_KEY"
curl -X POST https://api.passid.io/v1/pay/payment-intents/$INTENT_ID/consent \
-H "Authorization: Bearer $RECIPIENT_SESSION" \
-H "Content-Type: application/json" \
-d '{ "approved": true }'
curl -X POST https://api.passid.io/v1/pay/payment-intents/$INTENT_ID/confirm-destination \
-H "Authorization: Bearer $RECIPIENT_SESSION" \
-H "Content-Type: application/json" \
-d '{ "destination_token": "recipient-confirmed" }'
curl -X POST https://api.passid.io/v1/pay/payment-intents/$INTENT_ID/execute \
-H "Authorization: Bearer $PASSID_PAY_KEY"
# → data.credential_id data.jws data.intent.payment_outcome = simulated_completed
# → data.intent.credential_status = active data.intent.funds_moved = false
Credentials
Verify, status, and reuse
GET
/v1/pay/credentials/{id}
Read the signed PassID Payment Credential. Subject is pairwise only.
POST
/v1/pay/credentials/{id}/verify
Verify signature and status before reuse. Reversed or revoked credentials fail closed.
GET
/v1/pay/credentials/{id}/status
Status-aware. Credentials are never mutated in place.
POST
/v1/pay/credentials/{id}/present
Reuse eligible proof with a new audience (for example Atlas Rentals). Does not disclose the original marketplace identity, bank file, or exact amount.
curl -X POST https://api.passid.io/v1/pay/credentials/$CREDENTIAL_ID/verify \
-H "Authorization: Bearer $PASSID_PAY_KEY"
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 with PKCE, launched PassID, approved consent, verified
state on callback, exchanged the code at POST /token, and read the resulting identity and income evidence
- ✓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
- ✓If you are piloting PassID Pay: sandbox intent → merchant authorization → recipient consent and destination confirmation → execute → credential verify completed, and you have not treated a sandbox credential as a live-funds confirmation
Preparing for production?
Connect production access follows its own readiness review. PassID Pay production keys and execution remain unavailable; continue using pay_test_… and treat every Pay result as simulated with funds_moved: false.
Discuss production readiness ›