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

# Webhooks

> Receive events, and verify their signature.

PanoQore posts events to your HTTPS endpoints as they happen. Managing endpoints needs `webhooks:manage`.

## Add an endpoint

```bash theme={null}
curl -X POST "$PANOQORE_URL/v1/webhook-endpoints" \
  -H "Authorization: Bearer $PANOQORE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"ledger events","url":"https://api.example.com/hooks/panoqore","topics":["entry.*","hold.*"]}'
```

A signed test event is sent first, and the endpoint is saved only if it answers 2xx. Otherwise you get `WEBHOOK_TEST_FAILED`. The answer's `secret`, starting `whsec_`, is what you verify with. `POST /v1/webhook-endpoints/{id}/secret` shows it again, and each reveal goes on the audit log.

An endpoint receives what happens from then on, not earlier events. The URL must be `https`, and private and loopback addresses are refused. Redirects are not followed. A tenant can have 20 endpoints, each with up to 20 topics.

## Topics

A topic is an event type, a group such as `entry.*`, or `*`. The groups are `account`, `entry`, `hold`, `named_account`, `inbound_credit`, `approval`, `fx_rate`, `fx_quote` and `break`.

| Event | When |
| - | - |
| `account.created` | An account was opened. |
| `entry.pending`, `entry.posted`, `entry.voided` | An entry changed. |
| `hold.created`, `hold.captured`, `hold.released`, `hold.expired` | A hold changed. |
| `named_account.issued`, `named_account.frozen`, `named_account.active`, `named_account.closed` | A binding changed. |
| `approval.proposed`, `approval.partially_approved`, `approval.approved`, `approval.rejected`, `approval.withdrawn`, `approval.failed` | A request moved. |
| `break.opened`, `break.assigned`, `break.resolved` | A reconciliation break changed. |

An inbound credit arrives as `entry.posted`, with `kind: TRANSFER_IN`.

`*` also delivers other public events, such as `statement.ingested`. Sign-ins, team changes, API key changes and webhook changes are never sent.

## The delivery

Each delivery is a CloudEvents 1.0 JSON document, with `Content-Type: application/cloudevents+json`.

```text theme={null}
Qore-Signature: t=1793653664,v1=527f47ce028dc7a7ec8f4109fef2942272bffa3cfd5331c17dafcbc9073591d0
Qore-Event-Id: evt_01M6F1DTGJEEGTKHZTTZWXKA5F
Qore-Event-Type: entry.posted
User-Agent: PanoQore-Webhooks/1
```

```json theme={null}
{
  "specversion": "1.0",
  "id": "evt_01M6F1DTGJEEGTKHZTTZWXKA5F",
  "source": "panoqore",
  "type": "panoqore.entry.posted",
  "subject": "je_01M6F1DTGHF16S975MZ3FKDKM0",
  "time": "2026-11-02T21:07:44.274238Z",
  "datacontenttype": "application/json",
  "data": {
    "id": "je_01M6F1DTGHF16S975MZ3FKDKM0",
    "kind": "TRANSFER_IN",
    "status": "POSTED",
    "business_date": "2026-11-03",
    "postings": [
      { "id": "pst_01M6F1DTGHF1883VBQBJ793KM2", "seq": 0, "side": "DEBIT", "amount": "100", "currency": "NGN", "account_id": "acc_01M6F1BK2SFT5B28SF7TX09FHH", "account_code": "AST:SET:NGN:SHV", "account_name": "Safehaven settlement" }
    ]
  }
}
```

> [!WARNING]
>
> `data` is `snake_case`, unlike API responses. The `type` has a `panoqore.` prefix, and the `Qore-Event-Type` header and topics do not.

A test event has `type: panoqore.webhook.test`, and its `id` is not an `evt_` id.

## Verify the signature

`Qore-Signature` is `t=<unix seconds>,v1=<hex>`, where the hex is HMAC-SHA256 of `<t>.<body>`, keyed with the whole secret including `whsec_`. Use the raw bytes of the body, before any JSON parsing. Refuse a timestamp more than five minutes from now, so a captured delivery cannot be replayed.

```javascript Node theme={null}
import crypto from "node:crypto";

export function verify(secret, header, rawBody, now = Date.now()) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=")));
  const t = Number(parts.t);
  if (!t || !parts.v1 || Math.abs(now / 1000 - t) > 300) return false;
  const want = crypto.createHmac("sha256", secret).update(`${parts.t}.`).update(rawBody).digest();
  const got = Buffer.from(parts.v1, "hex");
  return got.length === want.length && crypto.timingSafeEqual(got, want);
}
```

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

def verify(secret: str, header: str, raw_body: bytes, now: float | None = None) -> bool:
    parts = dict(p.strip().split("=", 1) for p in header.split(","))
    t, sig = parts.get("t", ""), parts.get("v1", "")
    if not t.isdigit() or not sig or abs((now or time.time()) - int(t)) > 300:
        return False
    want = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(want, sig)
```

```go Go theme={null}
package hooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"strconv"
	"strings"
	"time"
)

func Verify(secret, header string, body []byte, now time.Time) bool {
	var t, sig string
	for _, part := range strings.Split(header, ",") {
		k, v, _ := strings.Cut(strings.TrimSpace(part), "=")
		switch k {
		case "t":
			t = v
		case "v1":
			sig = v
		}
	}
	unix, err := strconv.ParseInt(t, 10, 64)
	if err != nil || sig == "" {
		return false
	}
	if age := now.Sub(time.Unix(unix, 0)); age > 5*time.Minute || age < -5*time.Minute {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(t + "."))
	mac.Write(body)
	return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(sig))
}
```

## Answering

Answer 2xx within 10 seconds. Do slow work after answering.

Anything else is retried: eight tries over about 24 hours, after 1 minute, 4 minutes, 25 minutes, 90 minutes, 4 hours, 6 hours and 12 hours. Then the delivery waits for a replay.

Delivery is at least once, so drop duplicates by the event `id`.

## Replay

```bash theme={null}
# Every failed delivery for one endpoint
curl -X POST "$PANOQORE_URL/v1/webhook-endpoints/$ENDPOINT/replay" \
  -H "Authorization: Bearer $PANOQORE_API_KEY" -H "Content-Type: application/json" -d '{}'

# One event, at every endpoint it failed at
curl -X POST "$PANOQORE_URL/v1/events/$SEQ/replay" \
  -H "Authorization: Bearer $PANOQORE_API_KEY" -H "Content-Type: application/json" -d '{}'
```

`GET /v1/events?publicOnly=true` lists the event stream. It holds the same events, and each has a `seq`, its gap-free number on the tenant.


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