SCROLL
Bank Integration Guide  ·  API Keys  ·  SDK  ·  QR Flow  ·  Webhooks  ·  Outcome Reporting Bank Integration Guide  ·  API Keys  ·  SDK  ·  QR Flow  ·  Webhooks  ·  Outcome Reporting
Integration Guide

Everything your bank needs to go live.

From workspace setup to your first verified claim — API keys, the drop-in SDK, QR code flow, webhook verification, signature checking, outcome reporting, and sandbox testing. Complete.

Step 1

Workspace & API Keys

Create a workspace at passid.io/onboard. You receive two API keys immediately — one for sandbox testing, one for live production. All API calls require the X-Institution-Key header.

🔑
Keys are shown once at creation. Store them in your secrets manager (AWS Secrets Manager, Railway variables, etc.) — never in source code or version control.

Authentication header

Every API request YOUR BACKEND
// Sandbox
X-Institution-Key: sk_sandbox_xxxxxxxxxxxxxxxxxxxx

// Live (production)
X-Institution-Key: sk_live_xxxxxxxxxxxxxxxxxxxx

// Base URL
https://api.passid.io

Configure your webhook endpoint

In your institution dashboard → API & Webhooks, paste the URL on your server that will receive PASSID events. This is required before going live.

PATCH /api/institution/webhook YOUR BACKEND
await fetch("https://api.passid.io/api/institution/webhook", {
  method: "PATCH",
  headers: {
    "Content-Type": "application/json",
    "Authorization": "Bearer <institution-jwt>"
  },
  body: JSON.stringify({
    webhook_url: "https://yourbank.com/passid/webhook",
    webhook_secret: "whsec_your_32char_secret" // for HMAC sig verification
  })
});
Step 2

Install the SDK

The @passid/react package is a self-contained React component. It handles QR rendering, deep-link generation, polling, countdown timer, success/decline/error states, and manual token entry — all in one drop-in component. Zero backend changes required.

terminal INSTALL
npm install @passid/react

Drop it into your application page

LoanApplicationPage.jsx YOUR APP
import { PASSIDVerify } from "@passid/react";

export default function LoanApplicationPage() {
  return (
    <div>
      <h2>Step 3 — Verify your financial credentials</h2>

      <PASSIDVerify
        apiKey="sk_live_..."
        baseUrl="https://api.passid.io"
        institutionName="First National Bank"
        permissions={[
          "income_verified",
          "full_score",
          "sanctions_clear"
        ]}
        onSuccess={(claims) => {
          // claims arrive automatically — no polling needed
          handleVerified(claims);
        }}
        onDecline={(reason) => showDeclineMessage(reason)}
        onError={(err) => console.error(err)}
        theme={{ primary: "#0075eb" }} // optional brand colour
      />
    </div>
  );
}

Component props

PropTypeRequiredDescription
apiKeystringYesYour institution live or sandbox API key
baseUrlstringNoPASSID backend base URL. Defaults to https://api.passid.io
institutionNamestringNoDisplayed on the consent screen inside the PASSID mobile app and on the component header
permissionsstring[]NoClaims to request. Defaults to all. See Claims Reference below.
onSuccess(claims) => voidYesCalled automatically when the customer approves in the app. Receives the full claims object.
onDecline(reason) => voidNoCalled when the customer declines, or the request expires.
onError(Error) => voidNoCalled on network errors or unexpected API failures.
theme.primarystringNoHex colour for buttons and accent. Defaults to #00c27a.
Step 3

QR Verification Flow

This is the primary flow. The customer scans a QR code on your page with the PASSID mobile app. No token copying, no manual input — claims arrive in onSuccess automatically. The SDK handles everything internally.

1
SDK mounts → creates a verification session
On component mount, the SDK calls POST /api/bridge/request with your API key and requested permissions. The backend creates a pending session and returns requestId, code (e.g. AB3X-7YQM), deepLink (passid://verify-request?r=…&i=…), and expiresAt. TTL defaults to 10 minutes.
2
QR code rendered on your page
The SDK renders the deep link as a QR code onto a <canvas> element using the qrcode library — no third-party image service. An 8-char fallback code (AB3X-7YQM) is shown alongside. A countdown timer starts. The SDK polls GET /api/bridge/request/:requestId every 2.5 seconds.
3
Customer opens PASSID app and scans
The customer opens the PASSID mobile wallet, taps Verify for Institution, and scans the QR. The app parses the deep link and opens a consent screen showing your institution name and each requested claim individually. Alternatively, they can point their phone camera at the QR — the OS deep-links directly into the PASSID app.
4
Customer reviews and approves
The consent screen fetches GET /api/bridge/request/:id/public (no auth required) to confirm the request is still pending and show the expiry countdown. The customer taps Share & Verify. The app generates a one-time share token from their verified claims and calls POST /api/bridge/request/:id/fulfill with their user JWT.
5
Claims arrive in your onSuccess callback
The backend validates the token, writes status = "completed", and the SDK poll detects this on its next tick. onSuccess(claims) fires with the full proven claims object. Simultaneously, a verification.completed webhook is sent to your registered endpoint. Your page never navigated away.
If the customer does not scan within the TTL window (default 10 min, max 60 min via ttlSeconds), the poll returns status: "expired" and onDecline("Request expired") fires. Show a "Generate new code" button.

What the SDK renders — states

StateTriggerWhat the user sees
idleInitial mountGenerate QR button + manual entry input
requestingQR button clickedSpinner — "Creating verification session…"
awaitingSession createdQR code + 8-char code + countdown + pulse dot
verifyingManual token submittedSpinner — "Verifying credential…"
successPoll resolves / token verifiedScore badge + tier + claim-by-claim summary
declinedCustomer declined / expired / invalidDecline message + friendly reason + try again
errorNetwork / API failureError message + try again
Step 4

Manual Token Entry

For in-branch use, accessibility, or when a customer's phone camera isn't available. The customer reads their PASSID token from the mobile app and gives it to a staff member who types it into the input field on the SDK component (or your own UI calling the verify API directly).

1
Customer opens PASSID app → Share → copies token
The token looks like PASSID-AB3X-7YQM-K9PQ-3MNX. It is a one-time-use credential tied to the specific permissions the customer selected.
2
Staff pastes token into the input on the SDK component
The SDK has a built-in manual entry input below the QR code. Staff pastes the token and presses Enter or clicks Verify.
3
SDK calls POST /api/bridge/verify directly
The token is verified in one call. Same onSuccess(claims) callback fires. Pass Idempotency-Key to make retries safe.

Calling verify directly from your backend

POST /api/bridge/verify YOUR BACKEND
const res = await fetch("https://api.passid.io/api/bridge/verify", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-Institution-Key": "sk_live_...",
    "Idempotency-Key": requestId // prevents double-verify on retry
  },
  body: JSON.stringify({
    token: "PASSID-AB3X-7YQM-K9PQ-3MNX",
    institutionName: "First National Bank"
  })
});

const { data } = await res.json();
// data.valid → true / false
// data.claims → VerificationClaims object (see below)
// data.analytics → cashflow analytics if permission granted
// data.denyReason → string if valid = false

Deny reasons

denyReasonMeaning
TOKEN_NOT_FOUNDToken does not exist — ask customer to generate a new share
TOKEN_EXPIREDToken TTL elapsed — ask customer to generate a fresh share
TOKEN_REVOKEDCustomer revoked this token from their wallet
TOKEN_ALREADY_USEDOne-time token was already consumed
INSTITUTION_MISMATCHToken was issued for a different institution
HARD_DENY_SANCTIONS_MATCHCustomer is on OFAC/EU/UN watchlist — do not proceed
Step 5

Claims Reference

The claims object returned in onSuccess and on the verify API response.

VerificationClaims — full example PASSID RESPONSE
{
  // ── FSI Score ─────────────────────────────────────────
  "fsi_score": 742, // 300–1000
  "fsi_low": 710, // confidence interval lower bound
  "fsi_high": 768, // confidence interval upper bound
  "tier": "A", // A / B / C / D
  "confidence": "high", // high / medium / low
  "p_stable": 0.91, // probability income stays stable 90 days

  // ── Income ────────────────────────────────────────────
  "income_verified": true,
  "income_band": "$3,000–$4,000/mo",

  // ── Identity & Compliance ─────────────────────────────
  "identity_verified": true,
  "sanctions_clear": true, // OFAC · EU · UN
  "fraud_risk": "low", // low / medium / high

  // ── Behavioural pillars ───────────────────────────────
  "payment_reliability": 0.88, // 0–1, on-time payment rate
  "savings_consistency": 81, // 0–100, savings balance regularity

  // ── Data context ──────────────────────────────────────
  "data_window_days": 180, // observation window used to compute score
  "linked_accounts": 3, // number of bank accounts in scope
  "freshness_hours": 48, // hours since last data refresh
  "corridor": "KE→GB" // optional, origin → destination
}

Permission values for the permissions[] array

ValueTypeClaims returned
full_scorenumberfsi_score, fsi_low, fsi_high, tier, confidence, p_stable
income_verifiedboolean + stringincome_verified, income_band
identity_verifiedbooleanidentity_verified
sanctions_clearbooleansanctions_clear
payment_reliabilitynumber 0–1payment_reliability
savings_consistencynumber 0–100savings_consistency
fraud_riskenumfraud_risk (low / medium / high)
Step 6

Direct Verify API

If you are not using the React SDK (e.g. server-side integration, mobile backend, non-React frontend), call POST /api/bridge/verify directly from your backend after receiving the token.

POST
/api/bridge/verify
Verifies a PASSID token and returns proven claims. One token = one verification. Pass Idempotency-Key: <unique-id> so retries on network failure never double-bill or double-verify. Response header: X-PASSID-API-Version: 1.
POST
/api/bridge/request
Creates a pending verification session (used by SDK automatically). Returns requestId, code, deepLink, expiresAt. Body: { institutionName?, permissions?, ttlSeconds? } — TTL default 600s, max 3600s.
GET
/api/bridge/request/:requestId
Poll for session completion. Returns status: "pending" | "completed" | "declined" | "expired". When completed, includes full claims and verifiedAt. Requires institution API key.
GET
/api/bridge/request/:id/public NO AUTH
Called by the PASSID mobile app after scanning the QR. Returns institution name, permissions, and expiry. No API key required. Returns 410 if expired, fulfilled, or declined.
GET
/api/bridge/request/by-code/:code/public NO AUTH
Resolves the 8-char human-readable fallback code (e.g. AB3X-7YQM or AB3X7YQM) to the same response as the public endpoint above. Used by the mobile app manual entry screen.
Step 7

Webhooks

PASSID sends signed HTTP POST events to your registered endpoint. Register your URL in the institution dashboard → API & Webhooks. All events use the same envelope format.

Event types

verification.completed
Fires immediately after a token is successfully verified — either via the SDK QR flow or a direct POST /api/bridge/verify call. Payload includes all proven claims, proof type, and the token. Use this on your backend to update your application state without polling.
verification.failed
Fires when a token is rejected. Includes deny_reason (e.g. TOKEN_EXPIRED, HARD_DENY_SANCTIONS_MATCH). Only fired when the request carries a valid institution API key.
outcome.reported
Fires when your institution submits a loan outcome via POST /api/bridge/outcome. Payload includes the token, outcome type, product type, amount, and FSI score at decision time. Use for audit trails and downstream integrations.
webhook.test
Manual test ping from the dashboard. Use to validate your signature verification logic before going live.

Event envelope

POST https://yourbank.com/passid/webhook PASSID → YOUR SERVER
// Headers
Content-Type: application/json
X-PASSID-Signature: sha256=a3f8c2d1e9b4... // HMAC-SHA256

// Body — verification.completed
{
  "event": "verification.completed",
  "institution_id": "42",
  "timestamp": "2026-03-29T10:22:14Z",
  "data": {
    "token": "PASSID-AB3X-7YQM-...",
    "verified_at": "2026-03-29T10:22:14Z",
    "proof_type": "groth16/bn254",
    "claims": {
      "tier": "A", "fsi_score": 742,
      "income_verified": true,
      "sanctions_clear": true
    }
  }
}

Retry policy

PASSID retries non-2xx responses automatically: immediately → 30 s → 5 min → 30 min → 2 h (5 attempts total). Pending deliveries survive server restarts. Your endpoint must return 200 within 10 seconds — do your async processing in the background.

Step 8

Signature Verification

Every webhook is signed with HMAC-SHA256 using the webhook secret you configured. Always verify the signature before processing — never trust the payload without it.

Node.js / Express webhook handler YOUR BACKEND
import crypto from "crypto";

const WEBHOOK_SECRET = process.env.PASSID_WEBHOOK_SECRET;

app.post("/passid/webhook", express.raw({ type: "application/json" }), (req, res) => {
  // 1. Verify signature — reject before touching the body
  const sigHeader = req.headers["x-passid-signature"];
  const expected = "sha256=" + crypto
    .createHmac("sha256", WEBHOOK_SECRET)
    .update(req.body) // raw Buffer — must use express.raw()
    .digest("hex");

  if (!crypto.timingSafeEqual(Buffer.from(sigHeader), Buffer.from(expected))) {
    return res.status(401).send("Invalid signature");
  }

  // 2. Parse and handle
  const event = JSON.parse(req.body.toString());

  if (event.event === "verification.completed") {
    const { token, claims } = event.data;
    updateApplicationStatus(token, claims); // your logic
  }

  // 3. Respond 200 quickly — do any heavy work in background
  res.sendStatus(200);
});
Always use express.raw() (not express.json()) when reading the body for HMAC verification. JSON parsing modifies whitespace and may alter the bytes used for the signature.
Step 9

Outcome Reporting

Weeks or months after a PASSID-verified decision, report what actually happened. This closes the model calibration loop — PASSID uses your outcomes to verify that FSI scores predict default rates correctly across tiers. It also gives you cohort analytics in your institution dashboard.

📊
Outcome reporting is optional but strongly encouraged. The more institutions report outcomes, the better calibrated the model becomes for your specific borrower population and corridor.
POST
/api/bridge/outcome
Report a loan outcome. Requires X-Institution-Key. One outcome per token per institution — duplicate reports return 409. The FSI score at time of verification is captured automatically from your verification history — you never send it.
POST /api/bridge/outcome YOUR BACKEND
await fetch("https://api.passid.io/api/bridge/outcome", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-Institution-Key": "sk_live_..."
  },
  body: JSON.stringify({
    token: "PASSID-AB3X-7YQM-...", // token from the original verify
    outcome: "repaid", // see valid outcomes below
    productType: "personal_loan", // optional
    amountUsd: 5000, // optional, loan amount
    observationDays: 90, // optional, days since disbursement
    notes: "Early repayment" // optional, internal notes
  })
});

Valid outcome values

ValueMeaning
repaidLoan repaid in full, on schedule
no_defaultStill active, no missed payments to date
delinquent30+ days past due, not yet defaulted
defaulted90+ days past due or written off
fraud_confirmedIdentity or application fraud confirmed
account_closedAccount closed by institution (not default)
otherOutcome not covered by above — use notes

Viewing your cohort data

After reporting outcomes, view calibration data in your institution dashboard → Performance tab. The cohort API is also available directly:

GET
/api/bridge/outcome/cohort
Returns default rate and repay rate broken down by FSI tier (A/B/C/D) for all outcomes your institution has reported. Requires institution JWT (not API key). Used by the Performance page in your dashboard.
Step 10

Sandbox & Testing

Use your sandbox API key (sk_sandbox_...) with the same endpoints. All sandbox responses are deterministic — same token always returns the same claims. Isolated from live data.

Test credentials

Paste these into the token field of POST /api/bridge/verify or into the SDK manual entry input.

TokenTier / ScoreCorridorResult
KE8V-3TXY-Z72LA · 812KE→GBincome ✓ · identity ✓ · sanctions clear · fraud low · approved
NG4M-XQRS-T88WB · 743NG→USincome ✓ · identity ✓ · sanctions clear · fraud low · approved
GH2P-ABCD-56KLC · 671GH→EUincome ✓ · identity ⚠ · fraud medium · review

Test the QR flow end-to-end in sandbox

1
Switch your institution dashboard to Sandbox mode
Top-left environment toggle in the dashboard. Sandbox API key is used automatically.
2
Render your page with <PASSIDVerify apiKey="sk_sandbox_..." />
A QR and 8-char code appear. The session is real — it lives in the sandbox DB and will resolve when fulfilled.
3
Open the PASSID mobile app in sandbox mode and scan the QR
Approve the consent screen. Claims resolve automatically in onSuccess on your page.
4
Test webhooks using the dashboard simulator
Dashboard → API & Webhooks → Send test event. Sends a real signed verification.completed payload to your endpoint. Use to validate your signature verification before going live.
Step 11

Go-Live Checklist

Complete all items before switching to your live API key.

  • Live API key stored in secrets manager — never in code or .env committed to version control
  • Webhook URL registered in institution dashboard and tested with simulator
  • Webhook signature verification implemented using X-PASSID-Signature: sha256=... + HMAC-SHA256 + timingSafeEqual
  • Webhook endpoint responds 200 within 10 seconds — async processing done in background
  • Idempotency-Key header sent on every POST /api/bridge/verify call to prevent double-verification on retry
  • HARD_DENY_SANCTIONS_MATCH deny reason handled — do not approve applications with this reason under any circumstances
  • QR session expiry handled — onDecline("Request expired") shows a "Generate new code" button, not an error
  • Sandbox end-to-end test completed — QR scanned from mobile, onSuccess received, webhook received and signature verified
  • Outcome reporting endpoint integrated — plan to POST outcomes after loan completion or default observation period
  • Institution dashboard configured — team members invited, corridors enabled, webhook secret stored securely
Ready to go live?
Our integration team reviews every institution before the live key is activated. Typical review takes 1 business day. We check webhook reachability, signature verification, and sanctions handling.
Request live key review ›