Environment Setup
Set up a development environment against the hosted API: the SDK, a sandbox key, a verified ingest call, and the errors you will meet first.
Overview
This guide sets up a development environment against the live platform. There is no local PasskeyBridge to run: the API is hosted, and you develop against https://api.passkeybridge.io with a sandbox key.
By the end you will have the SDK installed, a sandbox key that is never billed, a verified ingest call, and a clear rule for which key belongs in which environment.
Read Quickstart first if you have not sent a signal yet; this guide assumes you have an organization and can reach the dashboard.
System requirements
| Requirement | Version |
|---|---|
| Node.js | 20.19 or later, which the SDK declares in its engines field |
| Package manager | npm, pnpm or Bun |
| TypeScript | Optional. The SDK ships its own type declarations |
curl | Any recent version, for the checks below |
| Browser | One with WebAuthn support, only if you are building passkey flows |
Docker is not required and would not help: there is no self-hosted runtime. jq is useful for reading JSON responses in a terminal, and the dashboard's API playground covers exploratory calls without a REST client.
SDK installation
The wallet SDK. @passkeybridge/vc-wallet-sdk covers the credential surface: issuance, verification, presentation, OID4VCI offers, StatusList2021 lifecycle, and a local encrypted wallet.
npm install @passkeybridge/vc-wallet-sdkimport { PBWallet } from "@passkeybridge/vc-wallet-sdk";
const wallet = new PBWallet({
apiKey: process.env.PB_API_KEY!, // pb_live_... or pb_test_...
tenantId: process.env.PB_TENANT_ID!, // your Organization ID
baseUrl: "https://api.passkeybridge.io/v1", // this is the default
persist: true, // IndexedDB in a browser, memory elsewhere
});The current release is 0.6.0, Apache-2.0, with no runtime dependencies. baseUrl defaults to https://api.passkeybridge.io/v1, so the option only matters if you are pointed somewhere else. Releases are published from GitHub Actions through npm OIDC trusted publishing; the repository is private, so the package is published without a provenance attestation. Pin to the current minor (^0.6.0): the 0.x line may break between minors.
Scopes the SDK needs: vc_issue for issuance and offers, vc_verify for verification and presentation requests, vc_revoke for revoke, suspend, reinstate and status-list rebuilds. checkStatus, batchCheckStatus and getIssuerMetadata need no key at all.
Sensor capture. captureSensorFingerprint is a source file in the PasskeyBridge dashboard (src/lib/sensor-capture.ts) rather than a published package. To use it, copy the file into your own project from the SDK page. It hashes sensor readings on the device and sends digests.
Without an SDK. Every SDK method maps to one HTTP call. From Python, Go, Ruby or anything else, call the REST API directly with the same headers: x-pb-api-key and x-pb-tenant-id.
Recommended project structure
Nothing here is required by the platform. It is the layout that keeps keys out of the client bundle and makes the sandbox-to-production switch a configuration change.
your-app/
src/
lib/pb-client.ts # thin wrapper around the REST calls
config/pb-config.ts # environment-aware configuration
.env.local # sandbox key, never committed
.env.production # production key, injected by CI// src/config/pb-config.ts
export const PB_CONFIG = {
apiBase: process.env.PB_API_BASE ?? "https://api.passkeybridge.io/v1",
tenantId: process.env.PB_TENANT_ID ?? "",
apiKey: process.env.PB_API_KEY ?? "",
} as const;An API key is a bearer credential: it belongs on your server, in your platform's secret store, and never in a bundle a browser downloads. If you are using Vite, remember that a VITE_-prefixed variable is inlined into client code, which makes it the wrong place for a key. Add .env.local and .env.production to .gitignore.
Sandbox keys and scopes
One account has one organization, created when you sign up. There is no second sandbox organization to provision, and the wizard will not create one for an account that already has one. Sandbox is a property of the key, not of a separate environment.
Make a sandbox key. Go to Core > API keys, click Generate API key, tick Sandbox key before generating, and select the scopes you need. The key comes back prefixed pb_test_ and the API keys tab marks it with a Test badge.
Scopes. For development a broad key is convenient; for production, mint one key per job with only the scopes that job needs.
| Scope | Grants |
|---|---|
ingest | Post signals, and the BLAST tunnel lifecycle |
events | Read processed events and metrics |
metrics | Read organization metrics only |
vc_issue | Issue credentials and create OID4VCI offers |
vc_verify | Verify presentations and build OpenID4VP requests |
vc_revoke | Revoke, suspend, reinstate, rebuild the status list |
spatial | Atomic fingerprint binding |
scim | SCIM 2.0 user provisioning |
okta_hooks | Okta event hook consumer |
dpop | DPoP binding, verification and nonce issuance |
supply_chain | Supply-chain attestation (Enterprise) |
passkey_rp | Hosted passkey relying party |
passkey_rp_import | Bulk passkey credential import |
receipts | Entropy provenance receipts (Enterprise) |
admin and breakglass are deliberately not mintable: they exist only for console actions taken with a signed-in admin session. Four older names (playbooks, credentials, agents, shadow) were retired in September 2026; keys that still carry one keep working, and the name gates nothing.
The key is shown once. PasskeyBridge stores its SHA-256 digest and the first 16 characters. Copy it into your secret store straight away; a lost key is revoked and replaced, never recovered.
Sandbox vs. production keys
Every key carries a prefix that says which it is: pb_live_ (production) or pb_test_ (sandbox). There is one host and one data store. A sandbox key and a production key hit https://api.passkeybridge.io, write into the same organization, and are told apart by the key row.
| Dimension | Sandbox key (pb_test_) | Production key (pb_live_) |
|---|---|---|
| Host | api.passkeybridge.io | The same |
| Metering | Never metered, and never counted against your monthly allotment | Metered |
| Volume ceiling | 1,000 signals per rolling 30 days, shared with dashboard test mode. Past it: 429 with x-pb-reason: sandbox-cap | The plan allotment, then 429 or overage |
| Rate limit | The plan's per-minute limit, the same | The same |
| Outbound actions | Simulated. Webhook, Slack and email actions record skipped_sandbox and send nothing | Sent |
| Internal actions | Executed for real (delegates, tunnels, proofs, shadow identities) | The same |
| Stored event | test_mode = true, and a Test badge in the Events tab | test_mode = false |
| Credential issuance, BLAST tunnels | Real | Real |
Two consequences worth planning around. Sandbox traffic lands beside production traffic in the same Events tab, so filter by Test only or Live only when you are reading it. And because outbound actions are simulated, a sandbox run proves your playbook matched and would have fired; it does not prove your webhook receiver works. Send one live signal to a staging receiver for that.
Dashboard test mode is the other unmetered lane. Send test signal on the Overview tab posts a test_signal with your session token and the x-pb-test-mode: true header instead of a key, which requires you to be an admin of the organization and to have at least one active key. It shares the same 1,000-per-30-days ceiling. The Test mode switch beside it only shows a reminder banner; it does not change what the button sends.
Test mode does not gate everything. Parametric revocation still runs on a hard signal in test mode, because it touches only your own PasskeyBridge rows.
First local test
1. Check the host answers, with no credentials:
curl -s https://api.passkeybridge.io/v1/openapi-spec | jq '.info.title, .info.version'
# "PasskeyBridge™ API"
# "2.1.0"2. Send a signal with your sandbox key:
curl -X POST https://api.passkeybridge.io/v1/shield-ingest \
-H "Content-Type: application/json" \
-H "x-pb-api-key: $PB_API_KEY" \
-H "x-pb-tenant-id: $PB_TENANT_ID" \
-d '{
"event_type": "sim_swap",
"phone": "+15551234567",
"risk_score": 0.72
}'Expected (200):
{
"status": "ok",
"result": "no_matching_playbook",
"actions_count": 0,
"actions_failed_count": 0,
"latency_ms": 118,
"correlation_id": "pb-19a2f3c4d5e-7b3c9f02"
}result reads playbook_executed once an active playbook matches sim_swap. status turns to warning if an action failed, with the count in actions_failed_count.
3. Confirm it landed. Open Core > Events. Your signal is at the top of the first page, carrying the Test badge because the key is a sandbox key. Click the row: the drawer shows the decision and response times, the result, the matched playbook id, the correlation id from the response above, the stored metadata, and every action with its status. Any extra field you sent is forwarded to playbook actions and is not stored on the event.
4. The same call from Node:
// test-ingest.ts, run with: npx tsx test-ingest.ts
const res = await fetch("https://api.passkeybridge.io/v1/shield-ingest", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-pb-api-key": process.env.PB_API_KEY!,
"x-pb-tenant-id": process.env.PB_TENANT_ID!,
},
body: JSON.stringify({ event_type: "test_signal", risk_score: 0.5 }),
});
console.log(res.status, await res.json());Common setup errors
| Response | Cause | Fix |
|---|---|---|
401 Invalid API key | Unknown, revoked, or belonging to a different organization. The lookup matches key digest and organization together, so another organization's valid key reads as invalid | Check the key is active on Core > API keys and that x-pb-tenant-id is the organization it was minted in |
401 Missing x-pb-api-key header | The header did not arrive | Call /v1/_debug/echo and read api_key_present. If it is false, something between you and the edge dropped the header |
403 API key missing 'ingest' scope, x-pb-reason: missing-scope | The key names scopes and not the one this endpoint needs | Mint a key with the right scope. Scopes cannot be edited after minting |
403 Direct access denied. | The call went to the Supabase functions host instead of api.passkeybridge.io | Send it to api.passkeybridge.io under the /v1/ prefix |
404 Unknown tenant | The organization id is wrong | Copy it again from Developer > Settings, field Organization ID |
404 x-pb-reason: unknown-function | The function name is not on the public allowlist, or the /v1/ prefix is missing | Check the spelling against the API reference |
422 Payload validation failed | A field failed the schema. The details array names each one | Usually a phone_hash that is not 64 hex characters, or a missing event_type |
429 with limit and layer | Over the per-minute rate limit: Starter 60, Pro 300, Enterprise 600 | Back off and retry. X-RateLimit-Limit carries the limit that applied |
429 x-pb-reason: sandbox-cap | The 1,000-per-30-days ceiling on unmetered traffic | Use a live key for volume testing, or wait for the window to roll |
429 Starter plan monthly signal limit reached | The monthly allotment | Upgrade, or wait for the next month |
| CORS error in a browser | You are calling the API from client code | Call it from your server. An API key in a browser is a disclosed credential, whatever CORS says |
A 401 or 403 with no x-pb-reason header came from the edge rather than from the function.
CI/CD and deployment
Secrets. Keep keys in your platform's secret store (GitHub Actions secrets, Vercel environment variables, AWS Systems Manager). Never in source, never in a Dockerfile, never in a client bundle.
| Variable | Value |
|---|---|
PB_API_BASE | https://api.passkeybridge.io/v1 |
PB_TENANT_ID | Your Organization ID |
PB_API_KEY | The raw key, pb_live_ in production and pb_test_ everywhere else |
Before going live:
- Mint a production key with only the scopes that job needs, rather than reusing the broad development key.
- Store it in the secret store and confirm nothing reads it from a client-side variable.
- Set the Callback URL on Developer > Settings if any playbook uses
webhook_callback, and save the Callback signing secret in your receiver so you can verifyx-pb-signature. - Activate at least one playbook and exercise it with a sandbox signal, remembering that outbound actions are simulated for a sandbox key.
- Send one live signal to a staging receiver to prove the webhook path end to end.
- Configure alert rules in the Observability tab on the metrics you care about, such as
high_risk_countorerror_rate. - Decide how a data subject request will be handled, and read DSAR workflows.
Rotating a key. Generate the new key, update the secret, deploy, then revoke the old one. Both work during the overlap, which is the point; the old key's last used date on the API keys tab tells you when traffic has finished moving.
Rotating the signing secret is different: Rotate on Developer > Settings returns the new secret once and keeps the previous one valid for 24 hours, on both the inbound x-pb-signature check and your own verification of outbound deliveries.