Webhooks
Pano can tell another system when something happens: a player registers, a ticket is opened, a post goes live, an order is paid. You give Pano an address; Pano sends a signed POST to it.
Create one in the panel under Settings -> Webhooks: name, address, events, a secret (shown once). Test sends a ping immediately.
Events
Names have the form source.subject.verb. The source is core or the plugin's short name.
| Event | When |
|---|---|
core.user.registered | A player registers |
core.user.deleted | A player is deleted in the panel |
core.ticket.created | A ticket is opened |
core.ticket.replied | A ticket gets a message (staff tells who wrote it) |
core.post.published | A post becomes published |
core.test.ping | You pressed Test |
Plugins add their own, for example market.order.paid, market.order.refunded, market.subscription.renewed, market.shipment.shipped. The panel lists every event with a sample.
Subscribe to exact names, to core.* or market.*, or to *. Wildcards never include core.test.ping.
Payloads carry no e-mail addresses, IP addresses or message texts.
What you receive
{
"id": "6c1f1b0e-0d4b-3a5e-9b0e-2f5c7e1a9d11",
"event": "core.post.published",
"source": "core",
"createdAt": 1790000000000,
"apiVersion": 1,
"site": { "name": "My server", "url": "https://example.com" },
"data": { "id": 7, "title": "Hello", "url": "/post/hello", "categoryId": 1, "publishedAt": 1790000000000 }
}New keys may be added later; never fail on one you do not know. id is stable for one event and one endpoint: use it to ignore repeats.
Headers: X-Pano-Event, X-Pano-Event-Id, X-Pano-Delivery, X-Pano-Attempt, X-Pano-Signature.
Check the signature
X-Pano-Signature is t=<unix seconds>,v1=<hex> where v1 is HMAC-SHA256 of <t>.<raw body> with your secret. Use the raw body, not parsed JSON. Reject a t more than 300 seconds old.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? '');
return a.length === b.length && timingSafeEqual(a, b);
}Signing can be switched to none for endpoints you trust by other means. The Discord format sends an unsigned embed.
Retries
Answer with any 2xx within 10 seconds. Otherwise Pano retries, up to 8 attempts by default (1 to 20):
- Delay grows from 30 seconds, doubling, up to 6 hours, with a little jitter.
Retry-Afteron429or503is respected (at most 1 hour).410 Gonestops at once. Redirects are not followed.- After 50 failures in a row the webhook is switched off.
Pano refuses addresses that point inside its own network. For a local test receiver set webhooks.allow-private-targets = true in config.conf (ignored on hosted Pano).
The log
Each delivery is kept with status (PENDING, SENDING, SUCCEEDED, FAILED, DEAD), attempts, answer and time. Open it in the panel to see the last response or to redeliver one. Finished entries are purged after 30 days. Up to 50 webhooks per site.