> This page is for version 2022-09-01.
> For other versions, use one of these documentation indexes:
> - 2026-09-29 (default): https://docs.withpersona.com/2026-09-29/llms.txt
> - 2025-12-08: https://docs.withpersona.com/2025-12-08/llms.txt
> - 2025-10-27: https://docs.withpersona.com/2025-10-27/llms.txt
> - 2023-01-05: https://docs.withpersona.com/2023-01-05/llms.txt
> - 2022-09-01: https://docs.withpersona.com/2022-09-01/llms.txt
> - 2021-07-05: https://docs.withpersona.com/2021-07-05/llms.txt
> - 2021-05-14: https://docs.withpersona.com/2021-05-14/llms.txt
> - 2020-05-18: https://docs.withpersona.com/2020-05-18/llms.txt

> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.withpersona.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.withpersona.com/_mcp/server.

# Webhook Best Practices

> Build reliable webhook handlers that verify signatures, handle duplicates, and recover from failures.

## Respond quickly and process events asynchronously

Persona expects your endpoint to return a successful response (`200`, `201`, `202` or `204`) within 5 seconds. If your endpoint responds with an error, or doesn't respond in time, the delivery counts as failed and Persona [retries](/2022-09-01/webhooks#retry-logic) the webhook event.

We recommend that your webhook handler does as little as possible before it responds:

1. [Check the signature](#checking-signatures) of the request.
2. Save the event, or add it to a queue for a background worker to process.
3. Return a `2xx` response right away.
4. Do any long-running work in the background worker, after you've responded.

A common example of long-running work is downloading files referenced in the payload, such as government ID and selfie images from `files.withpersona.com`. Other examples are calling other APIs, including the Persona API, or running heavy processing on the event. If your handler does this work before it responds, a slow download can make the request time out. Persona then retries the webhook, and you can end up processing the same event more than once. See [Handling duplicate events](#handling-duplicate-events).

> **Tip**
>
> File URLs in the payload contain an access token that expires: by default after 6 hours, configurable per webhook up to 3 days. Download files promptly in your background worker, or fetch new file URLs from the API if the token has expired. See [Downloading Files](/2022-09-01/downloading-files).

## Handling duplicate events

Your webhook endpoints may occasionally receive the same event more than once. This is due to the nature of network connectivity. We recommend making your event processing idempotent to handle duplicate events. One way of doing this is to log events that you've processed and to skip processing for already-logged events.

## Webhook event ordering

You are not guaranteed to receive webhook events in the order they were created. For example, a network blip may cause an event to be retried and received after a newer event. Use the `data.attributes.created-at` attribute to determine creation ordering.

## Checking signatures

Requests from webhooks will contain a `Persona-Signature` header with a hexadecimal-encoded HMAC. You should check that any request is authentic and safe to process by comparing this value with your own digest, computed from the request body and your webhook secret. Your webhook secret can be found in the [Webhooks section](https://withpersona.com/dashboard/webhooks) of the Dashboard.

The `Persona-Signature` header contains one or more space-separated signature sets. Each set has two comma-separated key-value pairs encoding information about the request. The first key-value pair will be in the form `t=<unix_timestamp>` and represents the unix time that the request was sent. The second key-value pair will be in the form `v1=<signature>`, where the signature is computed from your webhook secret and a dot-separated string composed of the unix timestamp joined with the request body.

A webhook usually has one secret, so the header usually has one set: `t=<unix_timestamp>,v1=<signature>`. While its secrets are rotating, the header contains one set per active secret (see [Rotating secrets safely](#rotating-secrets-safely)). Always check every `v1` signature in the header, and treat the request as authentic if any of them matches.

Sample code for checking signatures:

**`ruby`**

```ruby ruby
# The header holds one "t=<timestamp>,v1=<signature>" set per active secret,
# separated by spaces. Check every set and accept the request if any matches.
body = request.body.read
is_verified = request.headers['Persona-Signature'].split(' ').any? do |pair|
  t, v1 = pair.split(',').map { |value| value.split('=', 2).last }
  next false if t.nil? || v1.nil?

  computed_digest = OpenSSL::HMAC.hexdigest('SHA256', <YOUR_WEBHOOK_SECRET>, "#{t}.#{body}")
  Rack::Utils.secure_compare(v1, computed_digest)
end

if is_verified
  # Handle verified webhook event
end
```

**`python`**

```python python
# The header holds one "t=<timestamp>,v1=<signature>" set per active secret,
# separated by spaces. Check every set and accept the request if any matches.
body = request.data.decode('utf-8')
is_verified = False
for pair in request.headers['Persona-Signature'].split(' '):
  parts = dict(value.split('=', 1) for value in pair.split(',') if '=' in value)
  t, v1 = parts.get('t'), parts.get('v1')
  if t is None or v1 is None:
    continue

  computed_digest = hmac.new(<YOUR_WEBHOOK_SECRET>.encode(), (t + '.' + body).encode(), 'sha256').hexdigest()
  if hmac.compare_digest(v1, computed_digest):
    is_verified = True

if is_verified:
  # Handle verified webhook event
```

**`javascript`**

```javascript javascript
// For ExpressJS, you may need to encode the body to UTF8 first before generating the Hmac
// const rawBodySaver = (req, res, buf, encoding) => {
//  if (buf && buf.length) {
//    req.rawBody = buf.toString(encoding || 'utf8');
//  }
// }

// const options = {
//  verify: rawBodySaver
//};

let data = [];
request.on('data', chunk => {
    data.push(chunk);
});
request.on('end', () => {
    const body = Buffer.concat(data).toString();
    const t = request.headers['persona-signature'].split(',')[0].split('=')[1]
    const signatures = request.headers['persona-signature']
        .split(' ')
        .map(pair => pair.split('v1=')[1]);

    const hmac = crypto.createHmac('sha256', YOUR_WEBHOOK_SECRET)
        .update(t + '.' + body)
        .digest('hex');

  	// See if any of the signatures are valid
    const isVerified = signatures.some(signature => {
        return crypto.timingSafeEqual(Buffer.from(hmac), Buffer.from(signature));
    });

    if (isVerified) {
        // Handle verified webhook event
    }
});
```

**`Java Spring`**

```java Java Spring
@PostMapping("/webhook")
public ResponseEntity<Map<String, String>> handleWebhook(@RequestHeader("Persona-Signature") String personaSignature, @RequestBody String requestBody) {
    String secret = <YOUR_WEBHOOK_SECRET>;

    Mac hmacSha256 = null;
    try {
        hmacSha256 = Mac.getInstance("HmacSHA256");
    } catch (NoSuchAlgorithmException e) {
        throw new RuntimeException(e);
    }
    SecretKeySpec secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
    try {
        hmacSha256.init(secretKey);
    } catch (InvalidKeyException e) {
        throw new RuntimeException(e);
    }

    // While secrets are rotating, the header carries two space-separated
    // "t=<timestamp>,v1=<signature>" sets. Check every one of them.
    boolean isVerified = false;
    for (String pair : personaSignature.trim().split("\\s+")) {
        String[] parts = pair.split(",");
        if (parts.length < 2) {
            continue;
        }
        String timePart = parts[0].split("=", 2)[1];
        String signaturePart = parts[1].split("=", 2)[1];

        String toSign = timePart + "." + requestBody;
        byte[] signedBytes = hmacSha256.doFinal(toSign.getBytes(StandardCharsets.UTF_8));

        StringBuilder sb = new StringBuilder();
        for (byte b : signedBytes) {
            sb.append(String.format("%02x", b));
        }
        String calculatedSignature = sb.toString();

        // Compare in constant time. Do NOT use `==` here: in Java that
        // compares object references, not the strings themselves, so it
        // would never match and every webhook would be silently rejected.
        if (MessageDigest.isEqual(
                calculatedSignature.getBytes(StandardCharsets.UTF_8),
                signaturePart.getBytes(StandardCharsets.UTF_8))) {
            isVerified = true;
        }
    }

    if (isVerified) {
        // Handle verified webhook event
    }

    // ...
}
```

> **Warning**
>
> #### Parsing JSON when computing HMACs
>
> In some languages, parsing the JSON may result in something that's not equivalent to the request body. For example, JavaScript may round floats and reduce precision. We recommend using the raw request body when computing the HMAC.

## Rotating secrets safely

You can rotate a webhook's secret in the [Dashboard](https://withpersona.com/dashboard/webhooks) or with the [rotate secret API](/2022-09-01/api-reference/webhooks/rotate-a-webhook-secret). Rotating creates a new secret. With the API, the old secret stays active for the number of seconds you pass in `meta.expires-in-seconds`. If you omit it, the old secret expires immediately and there is no overlap.

While both secrets are active, Persona signs every delivery with each of them. The `Persona-Signature` header then contains one `t=<unix_timestamp>,v1=<signature>` set per active secret, separated by a space:

```text
t=1764686826,v1=<signature_from_old_secret> t=1764686826,v1=<signature_from_new_secret>
```

The set for the old secret typically comes first, but don't rely on the order. Once `expires-in-seconds` has passed, the old secret expires and the header contains a single set again.

To rotate without rejecting deliveries:

1. Deploy signature checking that accepts the request if **any** `v1` signature in the header matches, like the sample code above.
2. Rotate the secret with an `expires-in-seconds` long enough to cover your deployment, then save the new secret in your endpoint's configuration. The API response returns the new secret in `secret`; it's also the entry in `secrets` whose `expires-at` is `null`.
3. Let the old secret expire.

> **Warning**
>
> Don't check only the first signature in the header. That signature usually comes from the old secret, so every delivery fails verification as soon as you switch your endpoint to the new secret.

## CSRF protection

If you’re using Rails, Django, or another web framework, your site might automatically check that every POST request contains a CSRF token. This is an important security feature that helps protect you and your users from cross-site request forgery attempts. However, this security measure might also prevent your site from processing legitimate events. If so, you might need to exempt the webhooks route from CSRF protection.

**`Rails`**

```ruby Rails
class PersonaController < ApplicationController
  # If your controller accepts requests other than Persona webhooks,
  # you'll probably want to use `protect_from_forgery` to add CSRF
  # protection for your application. But don't forget to exempt
  # your webhook route!
  protect_from_forgery except: :webhook

  def webhook
    # Process webhook data in `params`
  end
end
```

**`Django`**

```python Django
import json

# Webhooks are always sent as HTTP POST requests, so ensure
# that only POST requests reach your webhook view by
# decorating `webhook()` with `require_POST`.
#
# To ensure that the webhook view can receive webhooks,
# also decorate `webhook()` with `csrf_exempt`.
@require_POST
@csrf_exempt
def webhook(request):
  # Process webhook data in `request.body`
```