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

# Receive and verify PanoFlow webhooks

> PanoFlow webhook event types, the event envelope and headers, HMAC-SHA256 signature verification in Node.js and Python, and delivery and retry behaviour.

PanoFlow sends an HTTPS `POST` to your webhook endpoint when something happens in a business, such as a payment settling or money arriving. Each request carries one signed event. Verify the signature, answer with a `2xx` quickly, and process the event idempotently.

## Set up an endpoint

Add endpoints in the web app (Developers → Webhooks) or with [`POST /v1/webhooks`](https://docs.panoflow.io/api-reference/webhooks/add-a-webhook-endpoint), choosing the event types to send. The response includes the endpoint's signing secret (`whsec_...`), shown once when the endpoint is created or its secret is rolled.

* The URL must be `https` on port 443 with a publicly trusted certificate, and publicly reachable. Otherwise you get `422 webhook_url_invalid`.
* Endpoints belong to one mode. Sandbox (`pk_test_`) and live endpoints are separate and have separate signing secrets.
* Send a test event with `POST /v1/webhooks/{webhook_id}/test` to check your endpoint.

## Event types

| Type | `data.object` | When |
| - | - | - |
| `payment.created` | `transfer` | A transfer was created and its amount and fee are held. Fires for each batch row, pay-run line, approved agent payment and scheduled run too. |
| `payment.completed` | `transfer` | The transfer settled (`status: completed`, "Settled" in the app). |
| `payment.failed` | `transfer` | The transfer failed. The held amount and fee are back in the source account. |
| `payment.reversed` | `transfer` | A completed transfer was reversed by a new linked transfer. |
| `collection.received` | `inbound_credit` | Money came in, was credited, and was matched to an invoice, payment link or virtual account. |
| `collection.unattached` | `inbound_credit` | Money came in and was credited to an account, but isn't tied to an invoice, payment link or virtual account. |
| `invoice.sent` | `invoice` | An invoice was sent to its customer. |
| `invoice.paid` | `invoice` | An invoice is fully paid. |
| `payrun.completed` | `pay_run` | Every line of a pay run finished. Some lines may have failed. |
| `agent.payment.held` | `agent_held_payment` | An agent's payment needs a person's approval (ask-first). |
| `agent.payment.blocked` | `agent_activity` | An agent's payment broke a guardrail and was refused. No transfer is created. |
| `business.approved` | `business` | Onboarding was approved; live accounts are opening. |
| `business.frozen` | `business` | The business was frozen; money can't move. |
| `account.opened` | `account` | A new account (Main, Payroll, sub-account or agent account) was opened. |

The catalogue is also available from [`GET /v1/event-types`](https://docs.panoflow.io/api-reference/events/list-event-types). New event types can be added; ignore types you don't handle.

## The request

PanoFlow sends `POST` with a `application/json; charset=utf-8` body containing one event, and these headers:

| Header | Meaning |
| - | - |
| `PanoFlow-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>`. See [Verify the signature](#verify-the-signature). |
| `PanoFlow-Event-Id` | The event id (`evt_...`), the same as the body's `id`. Deduplicate on it. |
| `PanoFlow-Event-Type` | The event type, for example `payment.completed`. |
| `PanoFlow-Delivery-Id` | The delivery (`wdl_...`) this attempt belongs to. A replay has a new one. Quote it to support. |
| `Content-Type` | `application/json; charset=utf-8` |
| `User-Agent` | `PanoFlow-Webhooks/1.0` |

### Envelope

```json theme={null}
{
  "id": "evt_01JC3E28JT97KB6CQ643DZVMXX",
  "object": "event",
  "type": "payment.created",
  "created_at": "2026-10-26T14:42:01Z",
  "livemode": true,
  "business_id": "biz_01JCY1JX4WHRDD459GQ8H7QEZZ",
  "data": {
    "object": {
      "id": "trf_01JCH3BV4H15G5E4G7X0NTH82F",
      "object": "transfer",
      "type": "payout",
      "status": "processing",
      "status_reason": "queued",
      "response_code": "09",
      "response_message": "Request processing in progress",
      "amount": { "amount": 48000000, "currency": "NGN" },
      "fee": { "amount": 5375, "currency": "NGN" },
      "total": { "amount": 48005375, "currency": "NGN" },
      "...": "..."
    }
  }
}
```

* `data.object` is the full API resource, the same JSON its `GET` endpoint returns, **as it was when the event happened**, not as it is when the request is sent.
* `previous_attributes` holds the old values of the fields that changed, on change events only.
* `livemode` is `false` for sandbox events.
* Money-movement objects carry `response_code` and `response_message` (see [Payment statuses](https://docs.panoflow.io/payment-statuses.md)). Invoices, businesses and accounts carry none.

## Verify the signature

Every request is signed with your endpoint's signing secret:

```text theme={null}
PanoFlow-Signature: t=1793176920,v1=908f3c5322dbf82cba58e324d090fdf4f3e5e1658f803d0fb02d4399314e0e05
```

**1. Parse the header**

Split the header on `,`, then split each part on the first `=`. `t` is the Unix time in seconds when this attempt was signed. Each `v1` is a lowercase hex HMAC-SHA256. There can be two `v1` values, one per active secret, while a rolled secret overlaps. Ignore schemes you don't know, such as a future `v2`.

**2. Build the signed payload**

Concatenate the decimal `t`, a literal `.`, and the **raw request body bytes exactly as received**. Don't parse and re-serialise the JSON first: whitespace and key order matter.

**3. Compute the HMAC**

Compute HMAC-SHA256 with the secret string's UTF-8 bytes as the key, including the `whsec_` prefix, over the signed payload. Hex-encode it in lowercase.

**4. Compare**

Compare your value with each `v1` using a constant-time comparison. Accept if any matches.

**5. Check the timestamp**

Reject the request if `t` is more than 300 seconds (5 minutes) away from your current time, even if the HMAC matches. This stops replays of a captured request. Keep your server clock in sync with NTP.

```javascript Node.js theme={null}
const crypto = require("node:crypto");
const express = require("express");

const TOLERANCE_SECONDS = 300;

function verifyPanoFlowSignature(rawBody, header, secret, now = Math.floor(Date.now() / 1000)) {
  let timestamp;
  const signatures = [];
  for (const part of header.split(",")) {
    const i = part.indexOf("=");
    if (i === -1) continue;
    const key = part.slice(0, i);
    const value = part.slice(i + 1);
    if (key === "t") timestamp = value;
    else if (key === "v1") signatures.push(value);
    // Ignore other schemes, such as a future v2.
  }
  if (!timestamp || !/^\d+$/.test(timestamp) || signatures.length === 0) return false;
  if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac("sha256", Buffer.from(secret, "utf8"))
    .update(`${timestamp}.`)
    .update(rawBody) // the raw body bytes, not re-serialised JSON
    .digest();

  return signatures.some(
    (sig) => /^[0-9a-f]{64}$/.test(sig) && crypto.timingSafeEqual(Buffer.from(sig, "hex"), expected)
  );
}

const app = express();

// express.raw keeps the body as a Buffer, exactly as received.
app.post("/webhooks/panoflow", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("PanoFlow-Signature") ?? "";
  if (!verifyPanoFlowSignature(req.body, header, process.env.PANOFLOW_WEBHOOK_SECRET)) {
    return res.status(400).end();
  }
  const event = JSON.parse(req.body.toString("utf8"));
  // Acknowledge first, then process; skip event.id values you've already handled.
  res.status(200).end();
  handleEvent(event);
});
```

```python Python theme={null}
import hashlib
import hmac
import os
import time

from flask import Flask, request

TOLERANCE_SECONDS = 300


def verify_panoflow_signature(raw_body, header, secret, now=None):
    timestamp = None
    signatures = []
    for part in header.split(","):
        key, sep, value = part.partition("=")
        if not sep:
            continue
        if key == "t":
            timestamp = value
        elif key == "v1":
            signatures.append(value)
        # Ignore other schemes, such as a future v2.
    if timestamp is None or not timestamp.isdigit() or not signatures:
        return False
    if now is None:
        now = int(time.time())
    if abs(now - int(timestamp)) > TOLERANCE_SECONDS:
        return False

    expected = hmac.new(
        secret.encode("utf-8"),
        timestamp.encode("ascii") + b"." + raw_body,  # the raw body bytes
        hashlib.sha256,
    ).hexdigest()
    return any(hmac.compare_digest(expected.encode(), sig.encode()) for sig in signatures)


app = Flask(__name__)


@app.post("/webhooks/panoflow")
def panoflow_webhook():
    raw_body = request.get_data()  # bytes, exactly as received
    header = request.headers.get("PanoFlow-Signature", "")
    if not verify_panoflow_signature(raw_body, header, os.environ["PANOFLOW_WEBHOOK_SECRET"]):
        return "", 400
    event = request.get_json()
    # Skip event["id"] values you've already handled, and keep slow work out of the request.
    enqueue(event)
    return "", 200
```

### Test your verification

Check your code against this reproducible example before you go live:

| Input | Value |
| - | - |
| Secret | `whsec_7fQ2mN8xLp4Rz0Tb3kQ` |
| `t` | `1793176920` (2026-10-28T08:42:00Z) |
| Raw body | `{"id":"evt_01JCM9S346Q3D25VT4F5V37E3S","object":"event","type":"payment.completed"}` |
| Expected `v1` | `908f3c5322dbf82cba58e324d090fdf4f3e5e1658f803d0fb02d4399314e0e05` |

Pass `now = 1793176920` when you test, or the timestamp check rejects it.

## Respond

* Answer with any `2xx` within **10 seconds**. The response body is ignored. Do slow work after you answer.
* Anything else is a failed attempt: a non-2xx status, a redirect, a TLS error, a timeout or a refused connection. Redirects are not followed.

## Delivery

* **At least once.** The same event can arrive more than once, through retries, replays or rare duplicates. Deduplicate on the event `id` (also in `PanoFlow-Event-Id`) and make your handler idempotent.
* **Ordered per endpoint.** Events for one endpoint are sent one at a time in the order they were created. While an event is being retried, later events for that endpoint wait behind it. Replays are the exception: a replayed event is delivered when it is requested, out of order. Use `created_at` and the resource's own `updated_at` or `status` to ignore stale updates.
* **Retries.** After a failed attempt, PanoFlow retries 5 minutes, 30 minutes and 2 hours after the first attempt, then doubles the gap, up to 12 hours between attempts, until the retry window ends. After that the delivery is marked `failed` and the business's developers are emailed.
* **Failing endpoints.** Three failed deliveries in a row flag the endpoint as failing and send a "webhook failing" email. A failing endpoint keeps receiving retries.
* **Re-signing.** Every retry and replay is signed again with a fresh `t`. The body bytes are identical across attempts.

## Replay and inspect

* Resend any event, delivered or failed, from the web app or with [`POST /v1/events/{event_id}/replay`](https://docs.panoflow.io/api-reference/events/replay-an-event). A replay is a new delivery of the same event: same `id`, same body, new signature.
* Events are kept for 30 days and are readable at `GET /v1/events` and `GET /v1/events/{event_id}`.
* Each delivery attempt is kept for 90 days with its response code, latency and the first 4 KB of your response body. List them with `GET /v1/webhooks/{webhook_id}/deliveries`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.