# Guide to Persona Webhooks: Features and Best Practices

Persona webhooks notify your systems about identity verification activity: an inquiry completes, an inquiry is approved, a verification passes. If you're gating access, triggering review workflows, or updating records off Persona's KYC/identity flows, webhooks are how you react without polling.

This guide covers how Persona webhooks work, the events you'll handle, how to verify the `Persona-Signature`, the secret rotation and retry behavior, and the best practices for production.

## What are Persona webhooks?

Persona webhooks are HTTP POSTs sent to a URL you register, each carrying a JSON:API envelope. The event name is at `data.attributes.name`, and the full affected object is at `data.attributes.payload.data` (the same schema as Persona's API responses). Each webhook is pinned to a configurable API version, and Persona signs deliveries with a Stripe-style scheme in the `Persona-Signature` header.

## Persona webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard > Webhooks (per-event-type subscription) |
| Signature header | `Persona-Signature` (`t=<ts>,v1=<hex>`) |
| Signature scheme | HMAC-SHA256 hex over `{t}.{raw body}`, keyed with the `wbhsec_` secret |
| Rotation | Two space-separated `t=/v1=` pairs; accept any `v1` match |
| Replay protection | No documented timestamp tolerance (enforcement is up to you) |
| Payload | JSON:API; event name at `data.attributes.name` |
| Retries | 5s timeout, then up to 7 more attempts with exponential backoff |
| Success codes | 200, 201, 202, 204 |
| Redelivery | Events retained 30 days; manual redelivery in the dashboard |
| SDK | No official server-side SDK (npm `persona` is the frontend inquiry SDK) |

## Common events

Persona events are dotted names on the inquiry and verification resources (report events use a slash form, `report/<type>.<action>`):

| Event | Fires when |
| --- | --- |
| `inquiry.completed` | An inquiry is completed by the individual |
| `inquiry.approved` | An inquiry is approved |
| `inquiry.declined` | An inquiry is declined |
| `inquiry.failed` | An inquiry fails |
| `verification.passed` | A verification passes |
| `verification.failed` | A verification fails |

Persona adds event types over time, so handle unknown names defensively. Subscribe per event type, and consult Persona's events reference for the full, current catalog.

## Setting up Persona webhooks

Create webhooks in Dashboard > Webhooks, subscribing to the specific event types you need. Each webhook reveals a signing secret with a `wbhsec_` prefix and is pinned to an API version. IP allowlisting is available if you want to restrict the source.

## Securing Persona webhooks

Each delivery carries a `Persona-Signature` header in the form `t=<unix_ts>,v1=<hex>`. To verify, build the signed payload `{t}.{raw body}`, compute an HMAC-SHA256 with your `wbhsec_` secret, hex-encode it, and compare against the `v1` value. During secret rotation the header carries two space-separated `t=/v1=` pairs; accept the delivery if any `v1` matches. Use the raw body and a constant-time comparison.

```javascript
const crypto = require("crypto");

const SECRET = process.env.PERSONA_WEBHOOK_SECRET; // wbhsec_...

function verify(rawBody, signatureHeader) {
  // Header may carry multiple space-separated "t=...,v1=..." pairs (rotation)
  return (signatureHeader || "").split(" ").some((part) => {
    const params = Object.fromEntries(
      part.split(",").map((kv) => kv.split("=").map((s) => s.trim()))
    );
    if (!params.t || !params.v1) return false;
    const expected = crypto
      .createHmac("sha256", SECRET)
      .update(`${params.t}.${rawBody}`)
      .digest("hex");
    const a = Buffer.from(expected);
    const b = Buffer.from(params.v1);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });
}

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.body.toString("utf8"), req.headers["persona-signature"])) {
    return res.sendStatus(401);
  }

  res.sendStatus(200); // acknowledge fast (200/201/202/204 count as success)
  processQueue.add(JSON.parse(req.body)); // process asynchronously
});

```

The same check in Python:

```python
import hashlib
import hmac
import os

SECRET = os.environ["PERSONA_WEBHOOK_SECRET"].encode()  # wbhsec_...

def verify(raw_body: str, signature_header: str) -> bool:
    for part in (signature_header or "").split(" "):
        params = dict(kv.split("=", 1) for kv in part.split(",") if "=" in kv)
        t, v1 = params.get("t"), params.get("v1")
        if not t or not v1:
            continue
        expected = hmac.new(SECRET, f"{t}.{raw_body}".encode(), hashlib.sha256).hexdigest()
        if hmac.compare_digest(expected, v1):
            return True
    return False

```

## Persona webhook limitations and pain points

### No documented timestamp tolerance

The Problem: The signature includes a timestamp (`t`), but Persona doesn't document a tolerance window. If you don't enforce one yourself, a captured, validly signed payload can be replayed indefinitely.

Why It Happens: Persona leaves replay-window enforcement to the consumer.

Workarounds:

* Enforce your own freshness window: reject deliveries whose `t` is too far from now.
* Make processing idempotent so a replay has no additional effect.

How Hookdeck Can Help: Hookdeck deduplicates deliveries at the edge and can enforce checks consistently, filtering replays before they reach your app. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Rotation produces two signature pairs

The Problem: During secret rotation the `Persona-Signature` header carries two space-separated `t=/v1=` pairs. Code that parses only one rejects valid deliveries signed with the other secret.

Why It Happens: Overlapping signatures let you rotate the `wbhsec_` secret without dropping deliveries.

Workarounds:

* Split on spaces and accept the delivery if any pair's `v1` matches.
* Coordinate rotation so old and new secrets overlap.

How Hookdeck Can Help: Hookdeck handles the rotation case at the edge, so your app never has to parse multiple signature pairs.

### Ordering isn't guaranteed, and duplicates happen

The Problem: Events can arrive out of order and more than once. A state machine that assumes `inquiry.completed` precedes `inquiry.approved`, or that each event is unique, breaks.

Why It Happens: Parallel, at-least-once delivery doesn't preserve order.

Workarounds:

* Order by `data.attributes.created-at` rather than receipt order.
* Dedupe on the event ID and make side effects idempotent.

How Hookdeck Can Help: Hookdeck deduplicates and durably queues events, and its replay lets you re-process in a controlled order during recovery. See our [guide to webhook ordering](/webhooks/guides/webhook-ordering-why-its-hard-and-how-to-handle-it).

### Growing event catalog and 5-second timeout

The Problem: Persona adds event types over time, and it times out after 5 seconds (then retries up to 7 more times). A handler that assumes a fixed event list, or does slow synchronous work, will break or time out.

Why It Happens: The product evolves, and the delivery timeout is short.

Workarounds:

* Handle unknown event names gracefully.
* Acknowledge fast (any of 200/201/202/204) and process asynchronously.

How Hookdeck Can Help: Hookdeck acknowledges Persona within the timeout and durably queues events, so slow processing never causes a timeout, and it lets you route on event type as the catalog grows.

## Best practices

### Verify over `t.body` and accept any pair

Build `{t}.{raw body}`, HMAC-SHA256 hex with your `wbhsec_` secret, and accept the delivery if any `v1` pair matches, using constant-time comparison.

### Enforce your own replay window

Since Persona documents no tolerance, reject deliveries whose `t` is implausibly old and dedupe on the event ID.

### Acknowledge fast, process asynchronously

Return a success code (200/201/202/204) immediately and defer work to a queue, well inside the 5-second timeout. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Don't assume ordering

Order by `data.attributes.created-at` and design handlers to tolerate out-of-order and repeated events.

### Handle new event types

Persona adds events over time; branch defensively and don't fail on an unrecognized `data.attributes.name`.

## Conclusion

Persona webhooks deliver identity and verification events in JSON:API envelopes, signed with a Stripe-style `Persona-Signature` you verify over `{t}.{raw body}` with your `wbhsec_` secret, accepting multiple pairs during rotation. The things to plan around are what's left to you: no documented timestamp tolerance, unguaranteed ordering, duplicates, a 5-second timeout, and a growing event catalog.

That puts replay enforcement, rotation handling, ordering tolerance, fast acknowledgement, and idempotency on you. [Hookdeck](https://hookdeck.com) verifies the signature, handles rotation, deduplicates, and durably queues every event at the edge, so your systems only ever process verified, unique identity events.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Persona webhooks reliably in minutes.