Webhooks
Get a signed POST the moment you're paid, when credits run low or when a top-up lands.
Add an endpoint under Developers in the dashboard and choose its events. Penni sends each event as a JSON POST and retries failed deliveries. Use Send test event to try your endpoint with a ping.
Events
| Event | Sent when |
|---|---|
settlement.succeeded | A payment was settled, live or test (see mode) |
credits.low | Your credits are running low or ran out |
credits.topped_up | Credits were added |
auto_topup.limit_warning | Auto top-up reached 50%, 75% or 90% of a limit |
auto_topup.paused | Auto top-up switched itself off |
{
"id": "evt_...",
"type": "settlement.succeeded",
"version": 1,
"created": "2026-10-12T09:30:00.000Z",
"account": "acc_...",
"mode": "live",
"data": {
"settlement": "stl_...",
"site": { "id": "site_...", "name": "Example API" },
"network": "eip155:8453",
"token": { "asset": "0x60a3...", "symbol": "EURC", "decimals": 6 },
"amount": "50000",
"amountFormatted": "0.05",
"payer": "0x...",
"payTo": "0x...",
"txHash": "0x...",
"explorerUrl": "https://basescan.org/tx/0x...",
"resource": "https://api.example.eu/premium/report",
"fee": { "amount": "2000", "amountFormatted": "0.002", "currency": "EUR" }
}
}Fields are only ever added within version: 1, so ignore fields you don't know. Each request also carries Penni-Event, Penni-Event-Id and Penni-Delivery headers; use the event ID to skip duplicates.
Verify the signature
Every delivery is signed with your endpoint's secret (whsec_…):
Penni-Signature: t=1791790000,v1=5f2b...
v1 is the hex HMAC-SHA256 of <t>.<raw body> with the secret. Reject a request when no v1 matches or when t is more than five minutes old. During a secret rotation there are two v1 values; accept either.
import { createHmac, timingSafeEqual } from "node:crypto"; // body: the raw request body as a string, before JSON.parse export function verifyPenni(secret, body, header, toleranceSeconds = 300) { const parts = header.split(",").map((part) => part.trim().split("=")); const t = Number(parts.find(([key]) => key === "t")?.[1]); if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false; const expected = Buffer.from(createHmac("sha256", secret).update(`${t}.${body}`).digest("hex")); return parts.some(([key, sig]) => key === "v1" && sig?.length === expected.length && timingSafeEqual(Buffer.from(sig), expected)); }
import hashlib, hmac, time # body: the raw request body as bytes, before json.loads def verify_penni(secret: str, body: bytes, header: str, tolerance: int = 300) -> bool: parts = [part.strip().partition("=") for part in header.split(",")] t = next((value for key, _, value in parts if key == "t"), "") if not t.isdigit() or abs(time.time() - int(t)) > tolerance: return False expected = hmac.new(secret.encode(), t.encode() + b"." + body, hashlib.sha256).hexdigest() return any(key == "v1" and hmac.compare_digest(value, expected) for key, _, value in parts)
<?php // $body: the raw request body, from file_get_contents('php://input') function penni_verify(string $secret, string $body, string $header, int $tolerance = 300): bool { $t = ''; $signatures = []; foreach (explode(',', $header) as $part) { [$key, $value] = array_pad(explode('=', trim($part), 2), 2, ''); if ($key === 't') { $t = $value; } elseif ($key === 'v1') { $signatures[] = $value; } } if (!ctype_digit($t) || abs(time() - (int) $t) > $tolerance) { return false; } $expected = hash_hmac('sha256', $t . '.' . $body, $secret); foreach ($signatures as $signature) { if (hash_equals($expected, $signature)) { return true; } } return false; }
Answer with any 2xx within ten seconds. Do slow work after you respond.