Passkey-X
Build

Webhooks

Signed, ordered, at-least-once deliveries: the event catalog, payload, signature verification, retries, dead letters, replay and secret rotation.

Catalog
2026-10-21
Events
122
Signature
HMAC-SHA256, v1=
Delivery
At-least-once, in order

Subscribe

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.

Delivery body
{
  "source": "passkey-x",
  "type": "audit_events",
  "webhook_id": "f1e2d3c4-b5a6-4978-8a9b-0c1d2e3f4a5b",
  "tenant_id": "8b6a3f1e-2c4d-4e5f-9a0b-1c2d3e4f5a6b",
  "sent_at": "2026-10-26T09:30:02Z",
  "first_sequence": 18341,
  "last_sequence": 18342,
  "events": [
    { "sequence": 18342, "occurred_at": "2026-10-26T09:30:00Z", "action": "remote.session_ended",
      "target_type": "remote_sessions", "target_id": "5a6b7c8d-9e0f-4a1b-8c3d-4e5f6a7b8c9d",
      "actor_identity_id": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9", "metadata": { "seconds": 412, "people": 2 },
      "hash_version": 2, "event_hash": "9a7f…" }
  ]
}

Delivery headers

HeaderAPIsWhat it meansExample
X-PasskeyX-WebhookWebhooksThe webhook (destination) id.X-PasskeyX-Webhook: f1e2d3c4-b5a6-4978-8a9b-0c1d2e3f4a5b
X-PasskeyX-TimestampWebhooksUnix seconds when the delivery was signed. Reject anything older than five minutes.X-PasskeyX-Timestamp: 1792540800
X-PasskeyX-SignatureWebhooksv1=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-CatalogWebhooksVersion of the event catalog the delivery was built with.X-PasskeyX-Event-Catalog: 2026-10-21
User-AgentWebhooksAlways Passkey-X-Webhooks/1.User-Agent: Passkey-X-Webhooks/1

Verify signatures

  1. Read the raw body exactly as received (before any JSON parsing or re-encoding).
  2. Reject the request when X-PasskeyX-Timestamp is more than five minutes from your clock (replays).
  3. Compute hex(HMAC-SHA256(secret, timestamp + "." + raw body)) and compare it in constant time with every v1= value of X-PasskeyX-Signature.
  4. 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>

Webhooks API

MethodPathWhat it doesScope
GET/v1/webhooks/eventsEvent catalogwebhooks:read
GET/v1/webhooksList webhookswebhooks:read
POST/v1/webhooksCreate a webhookwebhooks:write
GET/v1/webhooks/{webhook_id}One webhookwebhooks:read
PATCH/v1/webhooks/{webhook_id}Change a webhookwebhooks:write
DELETE/v1/webhooks/{webhook_id}Delete a webhookwebhooks:write
POST/v1/webhooks/{webhook_id}/rotate-secretRotate the signing secretwebhooks:write
POST/v1/webhooks/{webhook_id}/testSend a testwebhooks:write
GET/v1/webhooks/{webhook_id}/deliveriesDelivery logwebhooks:read
GET/v1/webhooks/{webhook_id}/dead-lettersDead letterswebhooks:read
POST/v1/webhooks/{webhook_id}/dead-letters/{dead_letter_id}/replaySend a dead letter againwebhooks:write
POST/v1/webhooks/{webhook_id}/replayReplay eventswebhooks:write

Event catalog

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.

Documents and signing

EventWhen
document.signature_requestedA signature request was sent
document.signedA signer signed a document (with the proof summary)
document.declinedA signer declined to sign
document.sealedEveryone signed: the document is sealed
document.request_cancelledA signature request was cancelled (by the sender, or the document was deleted)
document.revokedA sealed document was revoked
document.expiredA document or signature request expired
document.expiringA document expires soon
document.recordedA document signed elsewhere was recorded
document.signed_externalA document was signed with another provider
document.fields_changedThe fields of a pending request changed
document.request_emailedA signing link was emailed to an outside signer
document.signed_copy_storedThe signed copy was stored in the vault
document.signed_copy_emailedThe signed copy was emailed to the signers
document.signed_copy_resentThe signed copy was sent again
document.signer_remindedNewA signer was reminded to sign
document.signer_reassignedNewA signature request was moved to another signer

Members and directory

EventWhen
member.invitedA person was invited to the organization
member.invitation_cancelledAn invitation was cancelled
member.blockedA member was blocked
member.unblockedA member was unblocked
tenant_memberships.role_changedA member's role changed
tenant_membership.removedA member was removed
onboarding.step_doneAn onboarding step was completed
onboarding.overdueAn onboarding plan is overdue
offboarding.startedOffboarding started
offboarding.completedOffboarding finished
scim.user_provisionedThe directory (SCIM) added a person
scim.user_deactivatedThe directory (SCIM) deactivated a person

Access and sharing

EventWhen
access_request.createdSomeone asked for access to a vault
access_request.approvedAn access request was approved
access_request.deniedAn access request was denied
access.handoverAccess was handed over from one person to another
workspace.access_removedSomeone's access to a vault was removed
secure_send.createdA Secure Send link was created
secure_send.openedA Secure Send link was opened
secure_send.revokedA Secure Send link was revoked
emergency_access.requestedEmergency access was requested
emergency_access.activatedEmergency access was granted

Security

EventWhen
security_alert.raisedA security alert was raised
security_incident.openedA security incident was opened
security_incident.updatedA security incident changed
session.revoked_by_adminAn administrator signed someone out
session.revoked_high_riskA risky sign-in was signed out
vault.unlock_failedA vault unlock failed
travel_mode.enabledTravel mode was turned on
travel_mode.disabledTravel mode was turned off
behavior_guard.hold_placedBehavior Guard paused an account
sso.configuredSingle sign-on was configured
policy.updatedA security policy changed

Vaults

EventWhen
workspace.createdA vault was created
workspace.deletedA vault was deleted
vault_item.purgedA vault item was deleted for good
attachment.deletedA file was deleted

Company devices

EventWhen
company_device.enrolledA company device was enrolled
company_device.removedA company device was removed

API keys and webhooks

EventWhen
api_key.createdAn API key was created
api_key.revokedAn API key was revoked
audit_webhook.createdA webhook was created
audit_webhook.updatedA webhook's name or events changed
audit_webhook.deletedA webhook was deleted
audit_webhook.secret_rotatedA webhook's signing secret was replaced
audit_webhook.replayedA webhook was set to send earlier events again
audit_webhook.enabledNewA webhook was resumed
audit_webhook.disabledNewA webhook was paused
audit_webhook.dead_letteredNewA webhook batch failed too often and was parked as a dead letter
notification_rule.createdNewA notification rule was created
notification_rule.updatedNewA notification rule was changed, paused or resumed
notification_rule.deletedNewA notification rule was deleted

Controller

EventWhen
controller.job_createdA Controller action was created
controller.job_approvedA second administrator approved a Controller action
controller.job_deniedA Controller action was denied
controller.job_finishedEvery computer finished a Controller action
controller.job_failedA computer could not carry out a Controller action
controller.updates_haltedApp updates stopped for a group (by an admin or because updates failed)
controller.updates_resumedApp updates resumed for a group
controller.group_changedA Controller group or its signed baseline changed
controller.keys_changedThe Controller signing or escrow key changed
controller.password_rotatedA computer changed its local admin password
controller.password_checkout_requestedSomeone asked to see a local admin password
controller.password_revealedA local admin password was opened
controller.elevation_requestedA member asked for admin rights on a computer
micro_agent.enrolledA computer joined the Controller
controller.job_scheduledNewA Controller action was scheduled for later
controller.job_cancelledNewA Controller action was cancelled
controller.computers_quarantinedNewComputers were quarantined (cut off from vaults until released)
controller.computers_releasedNewComputers were released from quarantine
controller.computers_removedNewComputers were removed from the Controller
controller.compliance_failedNewA computer stopped meeting its group's compliance rule
controller.compliance_restoredNewA computer meets its group's compliance rule again
controller.elevation_deniedNewA request for admin rights on a computer was denied
controller.bulk_requestedNewA change to many computers waits for a second administrator

Remote

EventWhen
remote.session_startedA remote support session or screen share started (code, connect or share)
remote.session_acceptedThe person on the device allowed viewing or control (or unattended access was used)
remote.session_declinedThe person on the device declined
remote.session_endedA remote session ended (with its length and number of people)
remote.control_givenSomeone was given control of a shared screen
remote.participant_joinedSomeone joined a remote session or asked to
remote.unattended_onA device turned on unattended access
remote.invite_createdA remote support invite link was made (in the app or with the API)
remote.settings_changedRemote settings changed (unattended access, relay, outside people)
remote.control_takenThe person on the device took control back
remote.access_changedA participant's access changed between view and control
remote.participant_removedSomeone was removed from a remote session
remote.invite_revokedA remote support invite link was revoked (in the app or with the API)
remote.seat_givenA Remote seat was given to a member
remote.seat_removedA Remote seat was taken back
remote.recording_requestedA technician asked to record a session
remote.recording_allowedThe person on the device allowed recording
remote.recording_declinedThe person on the device declined recording
remote.recording_startedRecording started (saved encrypted to the organization's recordings vault)
remote.recording_stoppedRecording stopped
remote.transcript_savedA session's chat transcript was saved encrypted to the vault
remote.recording_expiredNewA recording reached the end of its retention and was deleted

Organization

EventWhen
organization.suspended_by_passkey_xNewPasskey-X suspended the organization
organization.unsuspended_by_passkey_xNewPasskey-X lifted the suspension
organization.blocked_by_passkey_xNewPasskey-X blocked the organization
organization.unblocked_by_passkey_xNewPasskey-X lifted the block
organization.closedNewThe organization was closed (it is deleted unless reopened)
organization.reopenedNewA closed organization was reopened
domain.verifiedNewA company domain was verified
domain.join_requestedNewSomeone with a company email address asked to join

Catalog versions

  1. 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.
  2. Org API 1.7
    • Controller and Remote events.
  3. 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.