Webhooks
PAID pushes signed JSON events to your endpoint whenever a state change happens. Verify the signature, react, and acknowledge with a 2xx response.
Subscribing
- Set your endpoint URL (one per account) in Dashboard → Webhooks.
- Copy the generated
whsec_…signing secret. - Every event type is delivered to that one address. There is no per-event selection; switch on
typeand ignore what you do not handle.
Verifying signatures
Every request includes a Webhook-Signature header of the form t=<timestamp>,v1=<hmac>. The HMAC is computed over "<timestamp>.<raw body>" using SHA-256 and your webhook secret —
a Stripe-compatible signature scheme. PAID also sends Standard Webhooks-style companion
headers: Webhook-ID (an id for the delivery, the same on every retry of it; today
it is the queue id whq_…, not the body's id) and Webhook-Timestamp (for replay rejection).
import crypto from "node:crypto";
export function verifyPaidSignature(
payload: string,
signature: string,
secret: string,
): boolean {
const [tPart, sPart] = signature.split(",");
const t = tPart.replace(/^t=/, "");
const s = sPart.replace(/^v1=/, "");
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${payload}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(s),
);
}Common events
| Event type | When it fires |
|---|---|
payment_intent.captured | A charge has been captured and funds are guaranteed. (payment_intent.authorized fires at authorization.) |
payment_intent.failed | A charge failed terminally. |
checkout.session.completed | The customer finished the hosted checkout flow. |
payment_intent.refunded | A refund was applied to the payment intent. |
dispute.created | The card network opened a chargeback. |
subscription.created | A recurring subscription was created. |
Handling events
// POST https://your-app.com/api/webhooks/paid
// PAID signs every event with your endpoint's whsec_ secret:
// Webhook-Signature: t=<unix_ts>,v1=<hmac_sha256_hex over "<ts>.<raw body>">
// Companion headers (Standard Webhooks-style names): Webhook-ID for
// idempotency, Webhook-Timestamp for replay rejection.
import crypto from "node:crypto";
function verifyPaidSignature(payload: string, header: string, secret: string): boolean {
const [tPart, sPart] = header.split(",");
const t = (tPart ?? "").replace(/^t=/, "");
const s = (sPart ?? "").replace(/^v1=/, "");
const expected = crypto
.createHmac("sha256", secret)
.update(t + "." + payload)
.digest("hex");
return (
s.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(s))
);
}
export async function POST(req: Request) {
const payload = await req.text();
const sig = req.headers.get("Webhook-Signature") ?? "";
if (!verifyPaidSignature(payload, sig, process.env.PAID_WEBHOOK_SECRET!)) {
return new Response("bad signature", { status: 400 });
}
const event = JSON.parse(payload);
switch (event.type) {
case "payment_intent.captured":
await grantEntitlement(event.data.customer_id);
break;
case "payment_intent.failed":
await notifyCustomer(event.data.customer_id, event.data.last_error);
break;
}
return new Response("ok");
}Retries and delivery
- A 5xx, 408 or 429 response, a timeout (10 s) or a connection failure is retried up to 3 more times over about a minute and a half (the retry poller runs every 30 seconds). Any other 4xx is not retried and goes straight to the dead-letter queue. So does an endpoint whose host name does not resolve: that is final on the first attempt.
- After exhaustion, the event lands in your dead-letter queue. Replay from the dashboard.
- Every delivery attempt is logged with its outcome (delivered, or the error) and its duration.
Always respond
200 before doing slow work. PAID retries 5xx, 408, 429, timeouts and
connection failures only; a host name that does not resolve is not retried. If your handler is
slow, enqueue the work and ack immediately; if it
cannot accept the event, answer 503, not a 4xx.