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.
Managing endpoints
Section titled “Managing endpoints”Endpoints are managed through /v1/webhook-endpoints (scope webhooks:manage):
- Create —
POST /v1/webhook-endpointswith aurland aneventsarray (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 apingevent so you can confirm it’s reachable immediately. - List / delete —
GETandDELETE /v1/webhook-endpoints/{id}. - Rotate the secret —
POST /v1/webhook-endpoints/{id}/rotate-secretreturns a freshpbwh_...secret (again shown once). Overlap old and new briefly to rotate without dropping deliveries. - Test —
POST /v1/webhook-endpoints/{id}/testfires apingto 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.
Delivery contract
Section titled “Delivery contract”Each delivery is an HTTP POST to your url with:
| Header | Value |
|---|---|
X-PB-Signature | t=<unix-seconds>,v1=<hex hmac-sha256> — see below. |
X-PB-Event-Type | The event type, e.g. verification_session.completed. |
X-PB-Event-Id | The event id (evt_...) — use it to dedupe. |
User-Agent | PersonaBlocks-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 catalog
Section titled “Event catalog”Event type | data payload |
|---|---|
verification_session.created | The verification session object (just minted). |
verification_session.completed | The verification session object — it finished and minted a VIC. |
verification_session.failed | The verification session object — it ran but didn’t pass. |
verification_session.expired | The verification session object — its TTL elapsed before completion. |
customer.associated | The customer that just became associated with your merchant account. |
customer.kyc_status.changed | The customer whose KYC status changed. |
review.flagged | The review that was raised for manual attention. |
review.resolved | The resolved review, with its final tier. |
reverification.submitted | The reverification request a customer just completed. |
ping | A liveness test — { "message": "PersonaBlocks webhook test" }. Auto-fired on create and by the test button. |
Verifying signatures
Section titled “Verifying signatures”The X-PB-Signature header is t=<unix-seconds>,v1=<hex hmac-sha256(secret, "{t}.{body}")>. To verify:
- Parse
tandv1out of the header. - Recompute
hmac-sha256(secret, "{t}.{rawBody}")as hex — over the raw request body bytes, before any JSON parsing or re-encoding. - Compare your value to
v1with a constant-time comparison. - Reject the delivery if
|now − t|exceeds 5 minutes (replay protection).
// 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}<?php// Verify a PersonaBlocks webhook signature (X-PB-Signature).// Format: t=<unix-seconds>,v1=<hex hmac-sha256(secret, "{$t}.{$rawBody}")>
/** * @param string $payload The raw request body, exactly as received (do not re-encode). * @param string $signatureHeader The X-PB-Signature header value. * @param string $secret Your endpoint's signing secret (pbwh_...). * @param int $toleranceSeconds Max clock skew to accept, in seconds (default 300 = 5 min). * @param int|null $now Unix seconds to compare against (defaults to time()). */function verify_personablocks_signature($payload, $signatureHeader, $secret, $toleranceSeconds = 300, $now = null) { if ($now === null) { $now = time(); } $parts = []; foreach (explode(',', (string) $signatureHeader) as $kv) { $pair = explode('=', $kv, 2); if (count($pair) === 2) { $parts[$pair[0]] = $pair[1]; } } if (!isset($parts['t']) || !is_numeric($parts['t'])) { return false; } $t = (int) $parts['t']; if (abs($now - $t) > $toleranceSeconds) { return false; } // stale or malformed $expected = hash_hmac('sha256', "{$t}.{$payload}", $secret); return isset($parts['v1']) && hash_equals($expected, $parts['v1']); // constant-time}# Verify a PersonaBlocks webhook signature (X-PB-Signature).# Format: t=<unix-seconds>,v1=<hex hmac-sha256(secret, f"{t}.{raw_body}")>import hashlibimport hmacimport time
def verify_personablocks_signature(payload, signature_header, secret, tolerance_seconds=300, now=None): """Return True iff signature_header is a fresh, valid signature over payload.
payload -- the raw request body, exactly as received (bytes or str). signature_header -- the X-PB-Signature header value. secret -- your endpoint's signing secret (pbwh_...). tolerance_seconds -- max clock skew to accept (default 300 = 5 min). now -- unix seconds to compare against (defaults to time.time()). """ if now is None: now = int(time.time()) if isinstance(payload, str): payload = payload.encode("utf-8") if isinstance(secret, str): secret = secret.encode("utf-8")
parts = dict( kv.split("=", 1) for kv in str(signature_header or "").split(",") if "=" in kv ) try: t = int(parts["t"]) except (KeyError, ValueError): return False # stale or malformed if abs(now - t) > tolerance_seconds: return False
signed = f"{t}.".encode("utf-8") + payload expected = hmac.new(secret, signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts.get("v1", "")) # constant-timeRetries and retention
Section titled “Retries and retention”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.
Best practices
Section titled “Best practices”- 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}orGET /v1/customers/{address}) and act on that. This closes the gap on out-of-order or missed deliveries.
Polling: the reconciliation twin
Section titled “Polling: the reconciliation twin”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 bytype, over the 90-day window.
Most integrations run the webhook for immediacy and one of these as a safety net.
Related
Section titled “Related”- Verification sessions — the lifecycle behind the
verification_session.*events. - Customers & VICs —
customer.kyc_status.changedandupdated_sincepolling. - Errors —
invalid_url,invalid_events,endpoint_not_found,event_not_found. - Quickstart — register an endpoint and receive your first signed delivery.