Skip to content

Webhooks

Webhooks push events — a session completing, a customer’s status changing — to an HTTPS endpoint you register, each signed with a per-endpoint secret so you can prove the delivery came from PersonaBlocks before trusting it.

Endpoints are managed through /v1/webhook-endpoints (scope webhooks:manage):

  • CreatePOST /v1/webhook-endpoints with a url and an events array (a non-empty subset of the catalog). The response returns a signing secret (pbwh_...) exactly once — store it; you can’t retrieve it again. Creating an endpoint also auto-fires a ping event so you can confirm it’s reachable immediately.
  • List / deleteGET and DELETE /v1/webhook-endpoints/{id}.
  • Rotate the secretPOST /v1/webhook-endpoints/{id}/rotate-secret returns a fresh pbwh_... secret (again shown once). Overlap old and new briefly to rotate without dropping deliveries.
  • TestPOST /v1/webhook-endpoints/{id}/test fires a ping to an active endpoint any time.

Endpoints are mode-partitioned: a live endpoint receives only live events, a test endpoint only test events, and neither can even confirm the other’s existence.

Each delivery is an HTTP POST to your url with:

HeaderValue
X-PB-Signaturet=<unix-seconds>,v1=<hex hmac-sha256> — see below.
X-PB-Event-TypeThe event type, e.g. verification_session.completed.
X-PB-Event-IdThe event id (evt_...) — use it to dedupe.
User-AgentPersonaBlocks-Webhook/2.0

The request times out after 10 seconds — respond 2xx fast (before doing slow work) or the delivery is treated as failed and retried. The body is JSON:

{
"id": "evt_9tKf2mVxWyN4rLbJfHd3",
"type": "verification_session.completed",
"created": "2026-07-11T14:30:05.000Z",
"livemode": true,
"data": { /* event-specific payload */ }
}
Event typedata payload
verification_session.createdThe verification session object (just minted).
verification_session.completedThe verification session object — it finished and minted a VIC.
verification_session.failedThe verification session object — it ran but didn’t pass.
verification_session.expiredThe verification session object — its TTL elapsed before completion.
customer.associatedThe customer that just became associated with your merchant account.
customer.kyc_status.changedThe customer whose KYC status changed.
review.flaggedThe review that was raised for manual attention.
review.resolvedThe resolved review, with its final tier.
reverification.submittedThe reverification request a customer just completed.
pingA liveness test — { "message": "PersonaBlocks webhook test" }. Auto-fired on create and by the test button.

The X-PB-Signature header is t=<unix-seconds>,v1=<hex hmac-sha256(secret, "{t}.{body}")>. To verify:

  1. Parse t and v1 out of the header.
  2. Recompute hmac-sha256(secret, "{t}.{rawBody}") as hex — over the raw request body bytes, before any JSON parsing or re-encoding.
  3. Compare your value to v1 with a constant-time comparison.
  4. Reject the delivery if |now − t| exceeds 5 minutes (replay protection).
verify-webhook.mjs
// Verify a PersonaBlocks webhook signature (X-PB-Signature).
// Format: t=<unix-seconds>,v1=<hex hmac-sha256(secret, `${t}.${rawBody}`)>
import crypto from 'node:crypto';
export function verifyPersonaBlocksSignature({ payload, signatureHeader, secret, toleranceSeconds = 300, now = Math.floor(Date.now() / 1000) }) {
const parts = Object.fromEntries(String(signatureHeader || '').split(',').map((kv) => kv.split('=')));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(now - t) > toleranceSeconds) return false; // stale or malformed
const expected = crypto.createHmac('sha256', secret).update(`${t}.${payload}`).digest('hex');
const given = Buffer.from(parts.v1 || '', 'hex');
const want = Buffer.from(expected, 'hex');
return given.length === want.length && crypto.timingSafeEqual(given, want); // constant-time
}

A delivery that doesn’t get a 2xx (or times out) is retried on a fixed schedule — 5 min → 1 hour → 5 hours → 18 hours after the first attempt. That’s 5 attempts total; after the last one fails, the delivery is marked exhausted and never retried again.

Every event is also recorded in the durable event log for 90 days, queryable via GET /v1/events and re-deliverable via POST /v1/events/{id}/replay.

  • Verify every delivery with a constant-time compare, and reject anything older than 5 minutes.
  • Dedupe on X-PB-Event-Id. Retries and replays mean the same event id can arrive more than once; make your handler idempotent.
  • Treat a webhook as a signal, then re-query the API. Don’t trust the pushed payload as the authoritative state — on receipt, fetch the current object (e.g. GET /v1/verification-sessions/{id} or GET /v1/customers/{address}) and act on that. This closes the gap on out-of-order or missed deliveries.

Webhooks are the real-time path; polling is the catch-up path for downtime or missed deliveries. Two endpoints mirror the event stream:

  • GET /v1/customers?updated_since=<iso|unix-seconds> — only customers whose status changed since your last sync.
  • GET /v1/events?type=<event-type> — the event log, filterable by type, over the 90-day window.

Most integrations run the webhook for immediacy and one of these as a safety net.

  • Verification sessions — the lifecycle behind the verification_session.* events.
  • Customers & VICscustomer.kyc_status.changed and updated_since polling.
  • Errorsinvalid_url, invalid_events, endpoint_not_found, event_not_found.
  • Quickstart — register an endpoint and receive your first signed delivery.