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. Add an endpoint URL in Dashboard → Webhooks.
  2. Copy the generated whsec_… signing secret.
  3. 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).

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

  • 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.