Reference
Webhooks
Signed POSTs to your server when something happens. Verify, then act.
Add an endpoint in Dashboard → Developers → Webhooks, pick the events, copy the whsec_… secret. Test and live have their own endpoints.
A delivery
POST https://example.com/webhooks/perly
Answer with any 2xx within 10 seconds. Anything else is retried with backoff for three days. A delivery can arrive twice — use the event id to do things once.
Verify the signature
Every delivery has a Perly-Signature header: a timestamp and an HMAC-SHA256 of {timestamp}.{raw body} under your secret.
bash
Perly-Signature: t=1791446400,v1=5f2c8a…Use the raw body exactly as it arrived — parsed and re-serialised JSON won't match. Refuse timestamps more than five minutes away.
import crypto from "node:crypto";
export function verify(rawBody, header, secret, tolerance = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > tolerance) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Events
invoice.created | An invoice was made. |
invoice.rail_chosen | The buyer picked a coin and network; the address and amount are set. |
invoice.open | Back to waiting for money. |
invoice.seen | Money is on its way, not yet confirmed. |
invoice.underpaid | Less than the amount arrived. |
invoice.paid | The full amount arrived and is confirmed. |
invoice.overpaid | More than the amount arrived. |
invoice.review | Held for you to look at — a screening hit or late money. |
invoice.expired | Nothing arrived in time. |
invoice.canceled | Canceled before money arrived. |
invoice.fulfilled | Granted, once: ship now. |
invoice.refunded | Refunded, in full or in part. |
invoice.sent | A B2B invoice was emailed. |
payment.received | A transfer to an invoice's address was seen. Again, with `reorg: true`, if the chain took back its block. |
payment.confirmed | That transfer is deep enough to trust. Bitcoin: 1 block, 2 from $1,000, 3 from $10,000. Dollar tokens from $10,000: the invoice is paid only once the chain has also finalized that block (minutes later; about 15 on Ethereum, 20 on the rollups). |
payment.dropped | That transfer left the chain. |
receipt.created | A receipt was numbered and issued. |
refund.requested | You opened a refund. |
refund.address_received | The buyer gave an address. |
refund.sent | You marked the refund sent with its transaction id. |
refund.confirmed | That transaction is confirmed on-chain. |
refund.canceled | The refund was called off. |
subscription.created | A subscription started. |
subscription.activated | Its first payment arrived; it's active. |
subscription.trial_ending | The trial ends in three days. |
subscription.renewal_invoice_opened | The next period's invoice was opened. |
subscription.renewed | The next period was paid. |
subscription.past_due | A renewal is late. |
subscription.unpaid | Grace days are over. |
subscription.paused | Paused: nothing renews or reminds. |
subscription.resumed | Running again. |
subscription.plan_changed | Moved to another price, now or at the period's end. |
subscription.canceled | Canceled, now or at the period's end. |
subscription.ended | It's over. |
entitlement.granted | A customer got access, credits or a seat. |
entitlement.revoked | That access was taken away. |
checkout.completed | A checkout session became a paid invoice. |
license.created | A license key was issued. |
license.revoked | A license key was turned off. |
customer.created | A new buyer. |
webhook_endpoint.disabled | An endpoint failed for 72 hours and was turned off. |
webhook.test | A test you sent from the dashboard; only that endpoint gets it. |
plan.changed | Your Perly Pay plan changed. |
Ship on
invoice.fulfilled. It comes once per invoice, after the payment is confirmed and screened. invoice.paid can be followed by invoice.review if a sender is on a sanctions list.