Quickstart: ingest your first signal
Generate an API key, send a signal to the ingest endpoint, read the response, and make a playbook act on it.
Prerequisites
You need:
- A PasskeyBridge account. Sign up at app.passkeybridge.io/auth with an email address and a password, or with a passkey.
- A terminal with
curl.
No card is required. The Starter plan includes 100 signals a month; past that, ingest answers 429 until the next month or an upgrade.
Everything below happens against one host, https://api.passkeybridge.io, and inside one organization.
Step 1: Your organization
You do not create one. Signing up creates your organization: a database trigger runs on the new account, takes the Organization Name you typed on the sign-up form (or "My Organization" if you left it blank), and inserts you as its admin.
An organization owns everything else: API keys, playbooks, events, credentials, audit entries. One account has one organization, and the setup wizard offers to create one only if your account somehow has none.
Find your organization id. Open the dashboard, go to Developer > Settings, and copy the value shown under Organization ID. It is a UUID, and it goes in the x-pb-tenant-id header on every API call. The same screen holds the optional Callback URL that webhook_callback actions post to, the Callback signing secret, and the Require signed signals switch.
Step 2: Generate an API key
Go to Core > API keys and click Generate API key. The wizard asks for three things:
- Key Label, a name for your own use, 1 to 120 characters.
- Scopes, at least one. For signal ingest pick
ingest.eventsis a read scope and does not permit a write, so a key witheventsalone is refused by ingest with 403. Fourteen scopes can be minted:ingest,events,metrics,vc_issue,vc_verify,vc_revoke,spatial,scim,okta_hooks,dpop,supply_chain,passkey_rp,passkey_rp_import,receipts. - Sandbox key, a checkbox. Leave it off for this walkthrough, so the signal you send behaves exactly like production traffic.
The key is minted on the server and shown once. Copy it immediately: PasskeyBridge stores its SHA-256 digest and the first 16 characters, and nothing can recover the rest. If you lose it, revoke it on the same tab and generate another.
Prefixes tell the two apart. A production key begins pb_live_; a sandbox key begins pb_test_ and carries a Test badge on the API keys tab. Signals from a sandbox key are stored with test_mode = true, are never metered, have their outbound actions simulated rather than sent, and share a ceiling of 1,000 signals per rolling 30 days with dashboard test mode.
Send the key as x-pb-api-key and your organization id as x-pb-tenant-id on every request. SCIM and Okta event hooks are the exception: they take the key in an Authorization: Bearer header.
Optional: send each signal once. Add x-pb-idempotency-key with a value you mint per signal (a UUID is the recommended form; 1 to 128 printable ASCII characters, and Idempotency-Key is accepted as an alias). PasskeyBridge claims the key before any playbook action runs and keeps the outcome for 24 hours. A retry with the same key and the same body gets the stored answer back with x-pb-idempotent-replay: true; if the first attempt ran its actions and then failed to store the event (a 502), the retry stores the event without running the actions again, so a callback, a Slack post or an email is never sent twice for one key. The same key with a different body is refused with 422, and a retry that overlaps the first request answers 409 with a Retry-After header. Without the header, every request is processed as it arrives.
Step 3: Send your first signal
Post the signal to shield-ingest:
curl -X POST https://api.passkeybridge.io/v1/shield-ingest \
-H "Content-Type: application/json" \
-H "x-pb-api-key: pb_live_YOUR_KEY" \
-H "x-pb-tenant-id: YOUR_ORGANIZATION_ID" \
-d '{
"event_type": "sim_swap",
"phone": "+15551234567",
"risk_score": 0.85
}'Body fields. One of event_type or signal_type is required; everything else is optional and has no default.
| Field | Type | Notes |
|---|---|---|
event_type | string, 1 to 64 chars | Letters, digits, underscore and hyphen only. signal_type is an accepted alias |
phone | string, 1 to 32 chars | The raw number. Normalized toward E.164 and keyed-hashed server-side with HMAC-SHA-256 under a server-held pepper. Preferred |
phone_hash | string, exactly 64 hex chars | A digest you computed. Keyed again under the server pepper before storage and stamped hash_version 2; the callback envelope carries it as you sent it. Ignored when phone is also present |
risk_score | number, 0 to 1 | Stored on the event and shown in the dashboard |
source_id | UUID | Your own source identifier, stored on the event for correlation |
credential_sha1 | string, 40 hex chars | Runs a breach-corpus range check and adds the hibp_* fields to the response |
subject_ref | string, 1 to 128 printable ASCII | Your opaque handle for the subject. Stored in the clear and echoed to your callback, so it is refused if it looks like an email address, a phone number or a long digit run |
Any other field is accepted and forwarded to your playbook actions, and is not persisted on the event: the stored metadata holds risk_score, the correlation id and the breach-check fields only.
Neither phone nor phone_hash is required. Without one, the signal is still stored and playbooks still run, and the automatic revocation of agent delegates and A2A negotiations is skipped, because there is no identifier to act on.
The response (200):
{
"status": "ok",
"result": "no_matching_playbook",
"actions_count": 0,
"actions_failed_count": 0,
"latency_ms": 96,
"correlation_id": "pb-19a2f3c4d5e-7b3c9f02"
}statusisok, orwarningwhen at least one action reported an error.resultisplaybook_executedwhen an active playbook matched this event type,no_matching_playbookotherwise. On a fresh organization, expectno_matching_playbook; Step 5 fixes that.actions_countcounts everything that ran, including the automatic revocation entry and the breach check.correlation_idis worth logging: it appears in the stored event metadata and in the audit log.
Errors and refusals
Every failure comes back as JSON with an error string, and several carry an x-pb-reason header you can branch on.
| Status | Body or reason | Cause |
|---|---|---|
| 400 | Missing tenant ID in path or x-pb-tenant-id header | No organization id on the request |
| 400 | Invalid JSON body | The body did not parse |
| 400 | x-pb-reason: invalid-subject-ref | subject_ref was not a printable ASCII string of 1 to 128 chars, or looked like an email address or phone number |
| 400 | x-pb-reason: invalid-idempotency-key | x-pb-idempotency-key was present but not 1 to 128 printable ASCII characters |
| 401 | Missing x-pb-api-key header | No key presented |
| 401 | Invalid API key | Unknown key, revoked key, or a key belonging to another organization |
| 401 | x-pb-reason: signature-required or invalid-signature | The organization requires signed ingest and the header was absent or did not verify |
| 402 | x-pb-reason: payment-required | A paid plan whose subscription is unpaid, past the Starter allotment |
| 403 | API key missing 'ingest' scope, x-pb-reason: missing-scope | The key names scopes and ingest is not among them |
| 403 | Tenant ID header does not match path tenant | The header and the path segment disagree |
| 403 | Tenant is suspended. | The organization is quarantined |
| 403 | Direct access denied. Use https://api.passkeybridge.io/v1/{function}. | The request reached the functions host directly instead of the public host |
| 404 | Unknown tenant, x-pb-reason: unknown-tenant | No organization with that id |
| 409 | x-pb-reason: idempotency-in-flight, with Retry-After | A request with the same idempotency key is still being processed |
| 404 | x-pb-reason: unknown-function | The path is not on the public allowlist. Check the function name and the /v1/ prefix |
| 413 | Request body too large | Over the small-JSON cap |
| 422 | Payload validation failed with a details array | Schema failure, one string per problem, for example a phone_hash that is not 64 hex characters |
| 422 | x-pb-reason: idempotency-key-reused | The idempotency key was already used with a different request body; mint a new key for a new signal |
| 429 | Too many requests... with limit and layer | Over the per-minute rate limit for your plan |
| 429 | x-pb-reason: sandbox-cap | The 1,000-per-30-days ceiling on sandbox and test-mode traffic |
| 429 | Starter plan monthly signal limit reached (100). | The Starter monthly allotment |
| 502 | Failed to persist signal event | The event could not be written, so nothing was metered. With an idempotency key, retry with the same key: the actions already ran and are not run again |
| 503 | x-pb-reason: tenant-lookup-failed | The organization lookup itself failed. This is deliberately not a 404 |
| 503 | x-pb-reason: idempotency-unavailable | The idempotency store did not answer, so the request was not run; retry with the same key |
A 401 or 403 that carries no x-pb-reason at all came from the edge rather than the function, usually a Cloudflare rule. /v1/_debug/echo echoes back which headers arrived, which settles most "my key is not working" questions in one call.
Step 4: Verify in the dashboard
Return to the Dashboard and open the Events tab. The tab lists events newest first, 25 per page, with filters for signal type, playbook result, and live or test traffic. If a signal arrives while you are looking at the first page, a prompt at the top of the list says how many new events there are; click it to load them.
Each event row shows:
- Event type—The signal type you sent (e.g., sim_swap).
- Result—Executed when a playbook matched and ran, No match when none was configured for that signal type, Pending while the result is not yet recorded.
- Test—Present when the event was sent in test mode or with a sandbox key.
- Decision and response time—decision is PasskeyBridge's own path (authenticate, resolve, keyed-hash, match a playbook, decide); response is the whole request including outbound playbook actions.
- Timestamp—Server-side ingestion time.
Click a row to open the details drawer: decision path and full response time, the result, the matched playbook id, the correlation id, the stored metadata (risk_score and, when a credential-leak check ran, the HIBP fields), and every action that ran with its status and latency.
Alternative: Send a test signal from the dashboard
If you prefer not to use curl, click Send test signal on the Overview tab. The button needs at least one active API key on the organization; the signal itself is sent with your session (test mode), not with the key.
The dashboard sends a pre-configured signal with:
- event_type: test_signal
- A random test phone value, keyed-hashed server-side like any other identifier
- No risk_score
Test-mode signals use JWT authentication (your session token) instead of an API key. They appear in the Events tab like any other signal, carry the Test badge, and are never metered. The stored event metadata holds risk_score, the correlation id, and HIBP results only; a source field sent with the signal is forwarded to playbook actions but is not stored on the event.
The Test mode switch on the Overview tab shows a banner reminding you that dashboard-sent signals are tagged as test events; it does not change what the button sends.
Alternative: Replay an attack in test mode
The Overview tab also carries Replay an attack in test mode. An organization admin clicks Run replay and PasskeyBridge sends six credential_stuffing_attempt signals, one failed_verification, and one suspicious_login to the organization as test events, then reports which of the organization's own playbooks matched each signal type and the decision-path time (p50 and p95) across the run.
Nothing is metered, nothing is revoked, and nothing leaves PasskeyBridge: the replay uses only soft signal types, runs in test mode, and calls no carrier or customer endpoint. One replay is allowed per organization every ten minutes. Results are saved and the last five appear under the card, so a replay can be compared with the previous one after a playbook change. The events themselves appear in the Events tab with the Test badge.
Step 5: Create a playbook
A signal on its own is a record. A playbook is what makes it do something.
Go to Automation > Playbooks and click New playbook. A playbook has a name, one trigger event type, and one or more actions:
| Action | What it does |
|---|---|
webhook_callback | POSTs a JSON envelope to your callback URL, signed with x-pb-signature when a signing secret is set. This is the action that lets your own backend act on your own accounts |
slack_webhook | Posts a message to a Slack destination you registered in the dashboard. The playbook stores a reference; the URL itself is held encrypted |
email_alert | Emails a group on your organization (admins, or all members). Addresses are resolved when the alert fires |
log_event | Records the signal in the audit trail at a severity you choose |
suspend_account, suspend_agents | Deactivate this organization's agent delegates in PasskeyBridge |
revoke_tokens | Tears down this organization's open BLAST tunnels |
revoke_sessions | Revokes this organization's active A2A negotiations |
freeze_credentials | Invalidates this organization's cached proofs |
step_up_auth | Deactivates this organization's shadow identities |
quarantine | Revokes cross-references, invalidates cached proofs and deactivates shadow identities together |
The internal actions operate on PasskeyBridge's own rows for your organization. None of them reaches your users' accounts, sessions or passkeys; webhook_callback is how you drive those.
How they run. All actions of a matched playbook run in parallel, each raced against an 8 s timeout, and each records its own status and duration. A slow upstream cannot starve the others.
Confirm it. Re-send the same signal from Step 3. The response now reads "result": "playbook_executed" with actions_count at least 1, the Events tab row shows Executed, and the details drawer lists every action with its status. If an action reports an error, status is warning and actions_failed_count is above zero.