> ## Documentation Index
> Fetch the complete documentation index at: https://docs.perflo-api.proofof.tech/llms.txt
> Use this file to discover all available pages before exploring further.

# Receive operation webhooks

> Verify signed operation-transition notifications and handle retries safely.

Operation webhooks notify your HTTPS endpoint when a Perflo operation changes state. They report only gateway-owned operation transitions. They do not report upstream provider events or promise upstream event timeliness.

## Create a subscription

Call `POST /v1/webhook-subscriptions` with the `operations:read` scope, an HTTPS callback URL, and a fresh 256-bit `Idempotency-Key`. The response includes the subscription ID and a `{ kid, secret }` disclosure.

Store both values before accepting the response. If the response is lost, repeat the exact request with the same idempotency key. Perflo returns the same subscription, `kid`, and secret. Reusing that key for a different request returns an idempotency conflict. The binding is permanent.

This replay is the same logical disclosure, not a secret-recovery endpoint. Perflo stores only purpose- and context-bound Vault ciphertext and decrypts it only for an exact idempotency replay. `GET /v1/webhook-subscriptions` never returns secrets.

Perflo validates that every callback resolves only to public addresses. It repeats DNS and private-network validation for every delivery attempt and redirect. A subscription receives operation events created after its database-assigned creation boundary; it does not replay earlier events.

## Verify a delivery

Read the request body as raw bytes before JSON parsing. Build the signed bytes in this exact order, with a line feed (`\n`) between fields:

```text theme={null}
perflo-webhook-v1
<Perflo-Webhook-Timestamp>
<Perflo-Webhook-Subscription-Id>
<Perflo-Webhook-Event-Id>
<Perflo-Webhook-Operation-Id>
<Perflo-Webhook-Event-Version>
<Perflo-Webhook-Kid>
<raw request body>
```

Compute HMAC-SHA-256 with the secret selected by `Perflo-Webhook-Kid`, encode the digest as unpadded base64url, and compare it with the value after `v1=` in `Perflo-Webhook-Signature`. Use a constant-time comparison.

This Node.js example verifies a body that your framework has preserved as a `Buffer`:

```js theme={null}
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyPerfloWebhook({ body, headers, secret }) {
  const fields = [
    'perflo-webhook-v1',
    headers['perflo-webhook-timestamp'],
    headers['perflo-webhook-subscription-id'],
    headers['perflo-webhook-event-id'],
    headers['perflo-webhook-operation-id'],
    headers['perflo-webhook-event-version'],
    headers['perflo-webhook-kid'],
  ];
  if (fields.some((value) => typeof value !== 'string')) return false;

  const supplied = headers['perflo-webhook-signature'];
  if (typeof supplied !== 'string' || !supplied.startsWith('v1=')) return false;

  const prefix = Buffer.from(`${fields.join('\n')}\n`, 'utf8');
  const expected = createHmac('sha256', Buffer.from(secret, 'base64url'))
    .update(prefix)
    .update(body)
    .digest();
  const received = Buffer.from(supplied.slice(3), 'base64url');
  return received.length === expected.length && timingSafeEqual(received, expected);
}
```

Reject malformed headers, unknown `kid` values, stale timestamps according to your replay policy, and invalid signatures before processing the JSON payload.

## Process events idempotently

The body is canonical JSON with this shape:

```json theme={null}
{
  "id": "33333333-3333-4333-8333-333333333333",
  "occurred_at": "2030-01-01T00:00:00.123Z",
  "operation": {
    "id": "44444444-4444-4444-8444-444444444444",
    "kind": "card_freeze",
    "state": "succeeded"
  },
  "type": "operation.succeeded",
  "version": 4
}
```

Use the event `id` as your idempotency key. Delivery is at least once, so a successful event can arrive more than once after a lost acknowledgement or worker restart.

Perflo preserves contiguous ordering for one subscription and operation. It can deliver events for unrelated operations in parallel, so it does not provide global ordering. Return a `2xx` response only after durably recording the event.

## Rotate a secret

Call `POST /v1/webhook-subscriptions/{id}/secret-rotations` with a fresh 256-bit `Idempotency-Key` to create a new `{ kid, secret }`. If the response is lost, repeat the exact request with the same key to recover the same logical disclosure. An equal replay never rotates again; a different request with that key conflicts permanently.

New events pin the active `kid`. An event already queued before rotation keeps its earlier `kid` and secret for every retry. Keep both secret versions available while either `kid` may appear. Perflo retains a retired encrypted secret for at least 24 hours and longer when a nonterminal delivery still references it.

## Handle retries and dead letters

Perflo retries transient DNS, connection, TLS, timeout, `408`, `425`, `429`, and `5xx` failures. The first retry waits 10 seconds. Later intervals increase exponentially and are capped at one hour. After eight failed attempts, Perflo dead-letters the delivery and releases the next event for that operation.

Retries preserve the body, event identity, version, and `kid`. The timestamp and signature change on each attempt. Return another `2xx` response for a duplicate event after confirming that the original processing completed.

Delete a subscription with `DELETE /v1/webhook-subscriptions/{id}` when it should no longer receive events. Deletion stops new fanout and dead-letters its nonterminal deliveries.
