Skip to content

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
{
  "id": "evt_8c1f…",
  "type": "invoice.fulfilled",
  "mode": "live",
  "created": "2026-10-08T09:41:07Z",
  "data": {
    "object": {
      "id": "inv_3k9…",
      "status": "paid",
      "amount": 2400,
      "currency": "USD",
      "metadata": { "orderId": "1042" }
    }
  }
}

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.createdAn invoice was made.
invoice.rail_chosenThe buyer picked a coin and network; the address and amount are set.
invoice.openBack to waiting for money.
invoice.seenMoney is on its way, not yet confirmed.
invoice.underpaidLess than the amount arrived.
invoice.paidThe full amount arrived and is confirmed.
invoice.overpaidMore than the amount arrived.
invoice.reviewHeld for you to look at — a screening hit or late money.
invoice.expiredNothing arrived in time.
invoice.canceledCanceled before money arrived.
invoice.fulfilledGranted, once: ship now.
invoice.refundedRefunded, in full or in part.
invoice.sentA B2B invoice was emailed.
payment.receivedA transfer to an invoice's address was seen. Again, with `reorg: true`, if the chain took back its block.
payment.confirmedThat 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.droppedThat transfer left the chain.
receipt.createdA receipt was numbered and issued.
refund.requestedYou opened a refund.
refund.address_receivedThe buyer gave an address.
refund.sentYou marked the refund sent with its transaction id.
refund.confirmedThat transaction is confirmed on-chain.
refund.canceledThe refund was called off.
subscription.createdA subscription started.
subscription.activatedIts first payment arrived; it's active.
subscription.trial_endingThe trial ends in three days.
subscription.renewal_invoice_openedThe next period's invoice was opened.
subscription.renewedThe next period was paid.
subscription.past_dueA renewal is late.
subscription.unpaidGrace days are over.
subscription.pausedPaused: nothing renews or reminds.
subscription.resumedRunning again.
subscription.plan_changedMoved to another price, now or at the period's end.
subscription.canceledCanceled, now or at the period's end.
subscription.endedIt's over.
entitlement.grantedA customer got access, credits or a seat.
entitlement.revokedThat access was taken away.
checkout.completedA checkout session became a paid invoice.
license.createdA license key was issued.
license.revokedA license key was turned off.
customer.createdA new buyer.
webhook_endpoint.disabledAn endpoint failed for 72 hours and was turned off.
webhook.testA test you sent from the dashboard; only that endpoint gets it.
plan.changedYour 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.