Create a webhook in Admin console → Integrations → Audit streaming (choose “HTTPS webhook”) or through the Organization API (scopes webhooks:read and webhooks:write). Subscribe to exact events from the catalog, such as document.signed, or to a prefix such as member.: a filter that names a catalog event matches that event only; anything else matches every event that starts with it.
Create a webhook
curl -s -X POST 'https://passkey-x.com/api/org/v1/webhooks' \
-H "Authorization: Bearer $PASSKEY_X_API_KEY" -H 'Content-Type: application/json' \
-d '{"name":"Contracts","url":"https://contracts.example.com/passkey-x","events":["document.signed","document.declined","document.sealed"]}'
# → the secret (pxwh_…) is shown once: store it
Payload
Each delivery is a JSON object of up to 200 events, in order. Deduplicate on events[].sequence. Test deliveries use "type": "test". Splunk, Datadog, Sentinel, Elastic, Slack and Teams destinations get events in their own format and authenticate with their own credential instead.
Unix seconds when the delivery was signed. Reject anything older than five minutes.
X-PasskeyX-Timestamp: 1792540800
X-PasskeyX-Signature
Webhooks
v1=hex HMAC-SHA256(secret, "<timestamp>.<raw body>"). While a secret rotates there are two, comma-separated: v1=<new>,v1=<old>. Accept a delivery when any one matches.
X-PasskeyX-Signature: v1=5257a8…,v1=9c1d0e…
X-PasskeyX-Event-Catalog
Webhooks
Version of the event catalog the delivery was built with.
X-PasskeyX-Event-Catalog: 2026-10-21
User-Agent
Webhooks
Always Passkey-X-Webhooks/1.
User-Agent: Passkey-X-Webhooks/1
Verify signatures
Read the raw body exactly as received (before any JSON parsing or re-encoding).
Reject the request when X-PasskeyX-Timestamp is more than five minutes from your clock (replays).
Compute hex(HMAC-SHA256(secret, timestamp + "." + raw body)) and compare it in constant time with every v1= value of X-PasskeyX-Signature.
Accept when one matches. Deduplicate on events[].sequence: delivery is at-least-once.
cURL / OpenSSL
# Recompute the signature of a saved delivery (body.json = the raw body, byte for byte)
TS='1792540800' # X-PasskeyX-Timestamp
SIG='v1=5257a8…' # X-PasskeyX-Signature (may hold two: v1=<new>,v1=<old>)
EXPECTED="v1=$({ printf '%s.' "$TS"; cat body.json; } | openssl dgst -sha256 -hmac "$PX_WEBHOOK_SECRET" | awk '{print $NF}')"
case ",$SIG," in *",$EXPECTED,"*) echo "valid" ;; *) echo "INVALID" ;; esac
# Send yourself a signed test delivery
curl -s -X POST 'https://passkey-x.com/api/org/v1/webhooks/$WEBHOOK_ID/test' -H "Authorization: Bearer $PASSKEY_X_API_KEY"
JavaScript (Node.js)
import { createHmac, timingSafeEqual } from "node:crypto";
/** true when any v1= signature matches one of your secrets (two during a rotation). */
export function verifyPasskeyX(rawBody, headers, secrets, toleranceSeconds = 300) {
const timestamp = headers["x-passkeyx-timestamp"];
if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
const offered = String(headers["x-passkeyx-signature"] ?? "").split(",")
.map((part) => part.trim()).filter((part) => part.startsWith("v1=")).map((part) => Buffer.from(part.slice(3), "hex"));
return [secrets].flat().some((secret) => {
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
return offered.some((signature) => signature.length === expected.length && timingSafeEqual(signature, expected));
});
}
// Express: keep the raw body for verification
app.post("/passkey-x", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyPasskeyX(req.body.toString("utf8"), req.headers, [process.env.PX_SECRET, process.env.PX_SECRET_OLD].filter(Boolean))) return res.sendStatus(401);
const delivery = JSON.parse(req.body); // deduplicate on events[].sequence
res.sendStatus(204);
});
Python
import hashlib, hmac, time
def verify_passkey_x(raw_body: bytes, headers: dict, secrets: list[str], tolerance: int = 300) -> bool:
timestamp = headers.get("X-PasskeyX-Timestamp", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance:
return False
offered = [part.strip()[3:] for part in headers.get("X-PasskeyX-Signature", "").split(",") if part.strip().startswith("v1=")]
signed = timestamp.encode() + b"." + raw_body
for secret in secrets: # the new and, during a rotation, the old secret
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
if any(hmac.compare_digest(expected, candidate) for candidate in offered):
return True
return False
# Flask
@app.post("/passkey-x")
def passkey_x():
if not verify_passkey_x(request.get_data(), request.headers, [os.environ["PX_SECRET"]]):
abort(401)
delivery = request.get_json() # deduplicate on events[].sequence
return "", 204
With the SDK
verifyWebhook from @passkey-x/sdk/webhooks does all of this and throws WebhookVerificationError (missing_headers, stale_timestamp or bad_signature). See the SDK.
Next.js route handler
import { verifyWebhook } from "@passkey-x/sdk/webhooks";
// While a secret rotates, deliveries carry v1=<new>,v1=<old>: keep both secrets until the overlap ends.
// Next.js route handler (app/api/passkey-x/route.ts); the same works in Express, Workers or Deno.
export async function POST(request: Request) {
const body = await request.text(); // verify the RAW body before parsing it
try {
await verifyWebhook({ body, headers: request.headers, secrets: [process.env.PX_WEBHOOK_SECRET!, process.env.PX_WEBHOOK_SECRET_OLD].filter(Boolean) as string[] });
} catch {
return new Response("invalid signature", { status: 401 });
}
const { events } = JSON.parse(body); // at-least-once and in order: deduplicate on event.sequence
return new Response(null, { status: 204 });
}
IP addresses. Deliveries come from Passkey-X’s relay, whose outgoing addresses are not fixed. Don’t rely on an IP allowlist: verify the signature of every request and reject the rest.
Retries, dead letters and replay
RetriesAfter a failure the next attempt waits 2, 4, 8, 16, 32 minutes, then every 60 minutes. Nothing is skipped: later events wait in order.
PausingA destination that fails 100 times in a row is paused.
Dead lettersSet dead_letter_after (3–50 attempts) with PATCH /v1/webhooks/{id} to park a batch that keeps failing and continue with newer events. List parked batches with GET /v1/webhooks/{id}/dead-letters and send one again with POST /v1/webhooks/{id}/dead-letters/{n}/replay.
Delivery logGET /v1/webhooks/{id}/deliveries?event=controller.&status=failed shows each attempt with the HTTP status, the events, the attempt number and the first 300 characters of your answer.
ReplayPOST /v1/webhooks/{id}/replay sends events of the last 30 days again from a sequence you choose.
Secret rotation
POST /v1/webhooks/{id}/rotate-secret with overlap_hours (0–72; 24 without a body) answers the new secret once. Until the overlap ends every delivery carries two signatures: deploy the new secret, keep both in your verifier, then drop the old one. overlap_hours: 0 stops the old secret at once (use it if the secret leaked).
Rotate without downtime
curl -s -X POST "https://passkey-x.com/api/org/v1/webhooks/$WEBHOOK_ID/rotate-secret" \
-H "Authorization: Bearer $PASSKEY_X_API_KEY" -H 'Content-Type: application/json' \
-d '{ "overlap_hours": 24 }'
# Until the overlap ends, deliveries carry X-PasskeyX-Signature: v1=<new>,v1=<old>
Catalog 2026-10-21 has 122 events (the same list as GET /v1/webhooks/events); its version is sent on every delivery as X-PasskeyX-Event-Catalog. Events added in it are marked New.
A participant's access changed between view and control
remote.participant_removed
Someone was removed from a remote session
remote.invite_revoked
A remote support invite link was revoked (in the app or with the API)
remote.seat_given
A Remote seat was given to a member
remote.seat_removed
A Remote seat was taken back
remote.recording_requested
A technician asked to record a session
remote.recording_allowed
The person on the device allowed recording
remote.recording_declined
The person on the device declined recording
remote.recording_started
Recording started (saved encrypted to the organization's recordings vault)
remote.recording_stopped
Recording stopped
remote.transcript_saved
A session's chat transcript was saved encrypted to the vault
remote.recording_expiredNew
A recording reached the end of its retention and was deleted
Organization
Event
When
organization.suspended_by_passkey_xNew
Passkey-X suspended the organization
organization.unsuspended_by_passkey_xNew
Passkey-X lifted the suspension
organization.blocked_by_passkey_xNew
Passkey-X blocked the organization
organization.unblocked_by_passkey_xNew
Passkey-X lifted the block
organization.closedNew
The organization was closed (it is deleted unless reopened)
organization.reopenedNew
A closed organization was reopened
domain.verifiedNew
A company domain was verified
domain.join_requestedNew
Someone with a company email address asked to join
Catalog versions
2026-10-21
26 more events: Controller quarantine, compliance, scheduled and cancelled actions; Remote recording retention; organization suspension, blocking, closing and domains; signer reminders; webhook pauses and dead letters; notification rule changes. Deliveries carry X-PasskeyX-Event-Catalog and two signatures while a secret rotates.
Org API 1.7
Controller and Remote events.
Org API 1.6
First catalog: documents, members, access, security, vaults, company devices, API keys and webhooks, with exact-event subscriptions.
Need help building on Passkey-X?
Questions about the API, a bug or a missing endpoint? Our team replies by email, usually within one business day.