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

  1. Set your endpoint URL (one per account) in Dashboard → Webhooks.
  2. Copy the generated whsec_… signing secret.
  3. Every event type is delivered to that one address. There is no per-event selection; switch on type and 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).

verify.ts
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 typeWhen it fires
payment_intent.capturedA charge has been captured and funds are guaranteed. (payment_intent.authorized fires at authorization.)
payment_intent.failedA charge failed terminally.
checkout.session.completedThe customer finished the hosted checkout flow.
payment_intent.refundedA refund was applied to the payment intent.
dispute.createdThe card network opened a chargeback.
subscription.createdA recurring subscription was created.

Handling events

webhook.ts
// 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.