Guides

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

EventSent when
settlement.succeededA payment was settled, live or test (see mode)
credits.lowYour credits are running low or ran out
credits.topped_upCredits were added
auto_topup.limit_warningAuto top-up reached 50%, 75% or 90% of a limit
auto_topup.pausedAuto top-up switched itself off
settlement.succeeded
{
  "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_…):

Header
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));
}

Answer with any 2xx within ten seconds. Do slow work after you respond.