SCROLL
PassID Connect  ·  API Keys  ·  Code Verification  ·  Connection Requests  ·  Identity & Income APIs  ·  Webhooks PassID Connect  ·  API Keys  ·  Code Verification  ·  Connection Requests  ·  Identity & Income APIs  ·  Webhooks
One platform · isolated product permissions

Connect customer trust.
Use it in every payout.

Connect establishes the reusable customer relationship, Verify validates portable claims, and Pay binds eligible Connect evidence to transaction-specific authorization. Their keys remain isolated while their identifiers and events form one institutional workflow.

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.

POST /v1/verification-requests YOUR SERVER
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.

GET /v1/verifications/{id} YOUR SERVER
# 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.
Store your key as an environment variable YOUR SERVER
# .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
1
Go to passid.io/institution → log in with your institution account
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.

Every API request — sandbox YOUR BACKEND
# 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:

terminal TEST YOUR KEY
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:

1
Customer clicks Connect with PassID
The customer starts an application on your website or app and clicks Connect with PassID.
2
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.
3
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.
4
Customer reviews and approves
PassID shows your verified institution name, requested scopes, purpose, and duration. The customer approves or declines.
5
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.
6
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.
POST /sessions YOUR BACKEND
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

FieldTypeRequiredDescription
return_urlstringYesAbsolute URL PassID redirects to after approval or decline. Must be pre-registered — see note below.
application_referencestringNoYour internal application, applicant, or workflow reference
scopesstring[]YesData scopes to request. Scopes must be enabled by your institution package. See scope reference below.
purposestringNoShown to the customer on the approval screen. Be specific: "Rental application", "Personal loan", etc.
access_durationstringNo"90days", "1year", or "permanent". Defaults to 90 days.
statestringRecommendedYour own opaque value, echoed back unchanged on the callback. Verify it matches before trusting anything else in the callback.
code_challengestringYes (live)PKCE challenge derived from a code_verifier you generate and keep server-side. Required in live mode.
code_challenge_methodstringYes (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

201 Created PASSID 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 caseRecommended scopes
Personal loan / creditidentity.read accounts.read balances.read income.read liabilities.read risk_flags.read
Rental applicationidentity.read accounts.read balances.read income.read verification_status.read
Marketplace onboardingidentity.read accounts.read verification_status.read
Insurance underwritingidentity.read accounts.read income.read liabilities.read risk_flags.read
Employment verificationidentity.read income.read verification_status.read
Full accessidentity.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.

POST
/v1/connect/token
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.
Callback handler + POST /token in Node.js YOUR BACKEND
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

200 OK PASSID 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

GET /connections/:id/identity YOUR BACKEND
curl https://api.passid.io/v1/connect/connections/conn_live_x7y8z9/identity \
  -H "Authorization: Bearer live_institution_api_key"
200 OK — identity response PASSID RESPONSE
{
  "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.

passid-connect.js — full flow NODE.JS
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

POST https://yourapp.com/webhooks/passid PASSID → YOUR SERVER
// 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)

webhook-handler.js YOUR BACKEND
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.
Error envelope PASSID RESPONSE
{
  "success": false,
  "error": {
    "code": "INVALID_PASSID_CODE",
    "message": "This PassID Code is not recognised."
  },
  "requestId": "req_connect_abc123"
}
Error codeHTTPWhat happened & what to do
INVALID_API_KEY401Your Connect API key is wrong or revoked. Check it in PassID Connect → API Keys.
UNAUTHENTICATED401Authorization header missing or malformed. Format: Bearer <connect_api_key>
INVALID_PASSID_CODE404The fallback code was invalid or cancelled inside PassID-hosted auth. Ask the customer to relaunch PassID.
PASSID_CODE_ALREADY_USED409The fallback code was already consumed inside PassID-hosted auth. Ask the customer to relaunch PassID.
PASSID_CODE_EXPIRED410The fallback code expired inside PassID-hosted auth. Ask the customer to relaunch PassID.
INSUFFICIENT_SCOPE403The connection doesn't grant the requested scope. Check granted_scopes on the connection.
RATE_LIMITED429Too many requests. Back off and retry after the Retry-After header value.
INVALID_REQUEST400The 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_ENABLED403The 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.

ScopeEndpoint suffixData returned
identity.read/identityFull name, masked date of birth, country, city, verification status, verified_at
accounts.read/accountsAccount IDs, type (savings/current), institution, masked last4, currency, status
balances.read/balancesCurrent balance per account
transactions.read/transactions90-day transaction history
income.read/incomeMonthly income, source, currency, frequency, employer
liabilities.read/liabilitiesOutstanding loans, credit facilities, monthly obligations
risk_flags.read/risk-flagsRisk indicators and flags
verification_status.read/verification-statusVerification 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.

UserSandbox IDScenarioWhat to test
Amara Osei (KE)sbuser_success_001Full successHappy path — all scopes, clean data, approved quickly
Tobenna Eze (NG)sbuser_partial_002Partial consentCustomer grants only some requested scopes — handle INSUFFICIENT_SCOPE on denied ones
Priya Mehta (IN)sbuser_highliai_003High liabilityIncome and liabilities data present — test your risk assessment logic
Kwame Asante (GH)sbuser_discon_004DisconnectedConnection exists but account inactive — test stale connection handling
Fatima Al-Hassan (NG)sbuser_blocked_005Blocked userUser is flagged — test your blocked-user flow and user.blocked webhook

Run a hosted Connect sandbox flow

1
Open the sandbox dashboard → PassID Connect tab
Go to passid.io/institution and click PassID Connect in the left sidebar.
2
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.
3
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.
4
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.

EnvironmentCredential and providerAvailability
Sandboxpay_test_… · PassID simulator · synthetic dataAvailable · funds never move
Provider certificationProvider sandbox · test dataNot enabled
ProductionReserved pay_live_… · licensed providerProduction 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.
POST /v1/pay/payment-intents OFFICIAL
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.
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.
POST /v1/pay/payment-intents/{id}/execute OFFICIAL
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.
POST /v1/pay/credentials/{id}/verify OFFICIAL
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 ›
PASSID
Thank you - we'll be in touch.