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
- Add an endpoint URL in Dashboard → Webhooks.
- Copy the generated
whsec_…signing secret. - Select which event types to receive.
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 (for idempotency) 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
- Non-2xx responses trigger exponential-backoff retries for up to 72 hours.
- After exhaustion, the event lands in your dead-letter queue. Replay from the dashboard.
- Every delivery attempt is logged with status, latency, and response body excerpt.
Always respond
200 before doing slow work. PAID retries on any non-2xx,
including timeouts. If your handler is slow, enqueue the work and ack immediately.