Webhooks

Paid can send HTTP POST requests to your system whenever important billing events happen. You configure webhooks in the Paid app under Settings > Webhooks, or manage them programmatically through the API.

This page covers:

  • Which webhook events Paid supports today
  • What the webhook payloads look like
  • How to configure and manage webhooks
  • How to test deliveries

Prerequisites

  • Admin access to Settings > Webhooks or a Paid API key for programmatic webhook management.
  • A publicly reachable HTTPS endpoint that can accept POST requests from Paid.
  • Access to the raw request body in your web server so you can verify x-webhook-signature before parsing JSON.
  • A place to store the webhook signing secret securely, such as an environment variable or secret manager.

Supported events

Event nameWhen it firesPrimary payload key
billing-invoice-createdA new invoice is createdinvoice
billing-invoice-paidAn invoice becomes fully paidinvoice
billing-checkout-createdA checkout session is createdcheckout
billing-checkout-completedA checkout payment completescheckout
billing-checkout-expiredA checkout session expirescheckout
billing-payment-succeededA payment succeedspayment
billing-payment-failedA payment attempt failspayment
billing-credits-depletedA customer runs out of prepaid creditscredits
billing-overage-incurredUsage exceeds the included threshold and creates an overage conditionoverage
billing-credit-cap-reachedA customer unit’s credit spend in a period reaches the cap set on itbreachedUnit, cap

Envelope format

Every webhook uses the same top-level envelope:

1{
2 "event": "billing-payment-succeeded",
3 "timestamp": "2026-04-16T14:05:00.000Z",
4 "isTest": false,
5 "data": {
6 "payment": {
7 "id": "pay_123",
8 "invoiceId": "inv_123",
9 "customerId": "cus_123"
10 }
11 }
12}
  • event identifies which webhook fired
  • timestamp is the time Paid created the delivery in RFC 3339 format
  • isTest is true for test deliveries sent from the UI
  • data contains the event-specific payload, keyed by the primary payload key listed above

Verifying webhook signatures

Every delivery includes an HMAC-SHA256 signature in the x-webhook-signature header so you can verify the request came from Paid. Your organization has one signing secret, used by every webhook delivery regardless of event type. Generate or rotate it from Settings > Webhooks.

Header format

x-webhook-signature: t=<unix_ms>,s=<base64_signature>
  • t is the delivery timestamp in milliseconds since the Unix epoch.
  • s is the base64-encoded HMAC-SHA256 of the signed payload using your webhook’s signing secret as the key. (The trailing = is base64 padding. Do not strip it.)

The signed payload is the timestamp, a literal ., and the raw JSON request body, joined as one byte string:

<t>.<raw_body>

Verify by recomputing the HMAC on your side and comparing in constant time. Reject deliveries where the timestamp is older than five minutes. This prevents replay of leaked deliveries.

Node.js example

1import crypto from "node:crypto";
2
3const SIGNING_SECRET = process.env.PAID_WEBHOOK_SECRET;
4const TOLERANCE_MS = 5 * 60 * 1000;
5
6export function verifyPaidWebhook(rawBody, header) {
7 if (!header) throw new Error("Missing signature");
8
9 // Split each key=value on the FIRST `=` only - the signature is base64 and
10 // its trailing `=` padding must not be lost.
11 const parts = Object.fromEntries(
12 header.split(",").map((kv) => {
13 const i = kv.indexOf("=");
14 return [kv.slice(0, i), kv.slice(i + 1)];
15 }),
16 );
17 const timestamp = Number(parts.t);
18 const provided = parts.s;
19 if (!timestamp || !provided) throw new Error("Malformed signature");
20
21 if (Math.abs(Date.now() - timestamp) > TOLERANCE_MS) {
22 throw new Error("Stale signature");
23 }
24
25 const expected = crypto
26 .createHmac("sha256", SIGNING_SECRET)
27 .update(`${timestamp}.${rawBody}`)
28 .digest("base64");
29
30 const expectedBuf = Buffer.from(expected, "base64");
31 const providedBuf = Buffer.from(provided, "base64");
32 if (expectedBuf.length !== providedBuf.length) {
33 throw new Error("Bad signature");
34 }
35 const ok = crypto.timingSafeEqual(expectedBuf, providedBuf);
36 if (!ok) throw new Error("Bad signature");
37}

Make sure your framework gives you the raw request body, not a re-serialized JSON object. Even whitespace differences break the signature.

Getting the raw body

Most web frameworks auto-parse JSON request bodies and discard the original bytes. The HMAC is computed over the literal bytes Paid sent, so a parsed-and-re-serialized object will not match. Below are minimal recipes for opting into raw-body access on the most common frameworks.

Express (Node): use express.raw() on the webhook route instead of express.json().

1app.post(
2 "/paid-webhooks",
3 express.raw({ type: "application/json" }),
4 (req, res) => {
5 const rawBody = req.body.toString("utf8");
6 verifyPaidWebhook(rawBody, req.headers["x-webhook-signature"]);
7 res.json({ ok: true });
8 },
9);

Next.js (App Router): use request.text(), not request.json().

1// app/api/paid-webhooks/route.ts
2export async function POST(request: Request) {
3 const rawBody = await request.text();
4 const sig = request.headers.get("x-webhook-signature");
5 verifyPaidWebhook(rawBody, sig);
6 return Response.json({ ok: true });
7}

Next.js (Pages Router): disable Next’s built-in body parser for the route.

1// pages/api/paid-webhooks.ts
2import getRawBody from "raw-body";
3
4export const config = { api: { bodyParser: false } };
5
6export default async function handler(req, res) {
7 const rawBody = (await getRawBody(req)).toString("utf8");
8 verifyPaidWebhook(rawBody, req.headers["x-webhook-signature"]);
9 res.status(200).json({ ok: true });
10}

Flask (Python): call request.get_data(), not request.get_json().

1from flask import request
2
3@app.post("/paid-webhooks")
4def paid_webhooks():
5 raw_body = request.get_data(as_text=True)
6 sig = request.headers.get("x-webhook-signature")
7 verify_paid_webhook(raw_body, sig)
8 return {"ok": True}

FastAPI (Python): await request.body() directly.

1from fastapi import Request
2
3@app.post("/paid-webhooks")
4async def paid_webhooks(request: Request):
5 raw_body = (await request.body()).decode("utf-8")
6 sig = request.headers.get("x-webhook-signature")
7 verify_paid_webhook(raw_body, sig)
8 return {"ok": True}

Django (Python): request.body is already the raw bytes. Exempt the route from CSRF since Paid is not the user’s browser.

1from django.http import JsonResponse
2from django.views.decorators.csrf import csrf_exempt
3
4@csrf_exempt
5def paid_webhooks(request):
6 raw_body = request.body.decode("utf-8")
7 sig = request.headers.get("x-webhook-signature")
8 verify_paid_webhook(raw_body, sig)
9 return JsonResponse({"ok": True})

Go (net/http): read the request body before decoding.

1import (
2 "io"
3 "net/http"
4)
5
6func paidWebhooks(w http.ResponseWriter, r *http.Request) {
7 body, err := io.ReadAll(r.Body)
8 if err != nil {
9 http.Error(w, err.Error(), http.StatusBadRequest)
10 return
11 }
12 sig := r.Header.Get("X-Webhook-Signature")
13 if err := VerifyPaidWebhook(body, sig); err != nil {
14 http.Error(w, "bad signature", http.StatusUnauthorized)
15 return
16 }
17 w.WriteHeader(http.StatusOK)
18}

The general rule: if your framework auto-parses JSON, find the option to disable it on this route, or read the body before any parser runs.

Rotating the secret

There is one signing secret per organization. Every webhook delivery from your account is signed with the same key. Rotate from the UI (Settings > Webhooks > Rotate signing secret) or the API:

$curl -X POST https://api.agentpaid.io/api/v2/webhooks/rotate-secret \
> -H "Authorization: Bearer YOUR_API_KEY"

The response includes signingSecret exactly once. Store it before closing the response. Paid does not store it in a way you can read back.

Rotation invalidates the old secret on the next delivery. Update your receiver before rotating in production, or accept a short verification gap.

How delivery works

Each webhook event is configured independently. In the Paid UI you can:

  1. Open Settings > Webhooks
  2. Choose the event you want to receive
  3. Enter the receiver URL in your system
  4. Enable the webhook
  5. Use the Test button to send a sample payload

Paid sends webhook requests as HTTP POST calls. Your endpoint should:

  • Accept JSON request bodies with Content-Type: application/json
  • Return a 2xx response quickly after validating and enqueueing work
  • Treat deliveries as retriable and idempotent on your side
  • Deduplicate using the business identifiers in the payload, not the delivery timestamp

Managing webhooks through the API

You can manage webhooks programmatically through the v2 API with an organization API key.

List all webhooks

$curl https://api.agentpaid.io/api/v2/webhooks \
> -H "Authorization: Bearer YOUR_API_KEY"

Configure a webhook

$curl -X PATCH https://api.agentpaid.io/api/v2/webhooks/billing-payment-succeeded \
> -H "Authorization: Bearer YOUR_API_KEY" \
> -H "Content-Type: application/json" \
> -d '{
> "url": "https://example.com/paid-webhooks",
> "enabled": true
> }'

Send a test delivery

$curl -X POST https://api.agentpaid.io/api/v2/webhooks/billing-payment-succeeded/test \
> -H "Authorization: Bearer YOUR_API_KEY" \
> -H "Content-Type: application/json" \
> -d '{}'

Test deliveries only work after the webhook has a valid URL configured and enabled is set to true.

Event payload examples

Payment succeeded

1{
2 "event": "billing-payment-succeeded",
3 "timestamp": "2026-04-16T14:05:00.000Z",
4 "isTest": false,
5 "data": {
6 "payment": {
7 "id": "pay_123",
8 "invoiceId": "inv_123",
9 "invoiceNumber": "INV-000123",
10 "customerId": "cus_123",
11 "customerName": "Acme",
12 "amount": 10800,
13 "currency": "USD",
14 "status": "posted",
15 "paymentDate": "2026-04-16T14:05:00.000Z",
16 "paymentType": "creditCard",
17 "externalPaymentId": "pi_123"
18 }
19 }
20}

Field notes:

  • payment.status is currently posted
  • payment.invoiceId and payment.invoiceNumber can be null when the payment is not linked to an invoice
  • payment.externalPaymentId can be null when there is no upstream processor reference
  • paymentDate is the effective payment timestamp in RFC 3339 format

Payment failed

1{
2 "event": "billing-payment-failed",
3 "timestamp": "2026-04-16T14:05:00.000Z",
4 "isTest": false,
5 "data": {
6 "payment": {
7 "id": "pay_456",
8 "invoiceId": "inv_456",
9 "invoiceNumber": "INV-000456",
10 "customerId": "cus_456",
11 "customerName": "Acme",
12 "amount": 10800,
13 "currency": "USD",
14 "status": "failed",
15 "failureReason": "Your card was declined.",
16 "failureDate": "2026-04-16T14:05:00.000Z",
17 "paymentType": "creditCard",
18 "externalPaymentId": "pi_456"
19 }
20 }
21}

Field notes:

  • payment.status is currently failed
  • failureReason is a human-readable failure message when one is available
  • failureDate is when Paid recorded the failed attempt in RFC 3339 format
  • invoiceId, invoiceNumber, and externalPaymentId can be null

Credits depleted

1{
2 "event": "billing-credits-depleted",
3 "timestamp": "2026-04-16T14:05:00.000Z",
4 "isTest": false,
5 "data": {
6 "credits": {
7 "customerId": "cus_123",
8 "customerName": "Acme",
9 "orderLineAttributeId": "ola_123",
10 "creditsCurrencyId": "cur_123",
11 "totalCredits": 100.5,
12 "remainingCredits": -0.5,
13 "usedCredits": 1,
14 "totalCreditsDecimal": "100.5",
15 "remainingCreditsDecimal": "-0.5",
16 "usedCreditsDecimal": "1",
17 "eventName": "api_call.completed",
18 "signalId": "sig_123",
19 "depletedAt": "2026-04-16T14:05:00.000Z"
20 }
21 }
22}

The sample above is a 100.5-credit pool with 0.5 credits left, and a 1-credit call that consumes them and overshoots by 0.5.

Field notes:

  • Credit quantities are decimal, not whole numbers. totalCredits, remainingCredits, and usedCredits carry up to six decimal places, so a parser must accept values such as 10.5 or 0.000001. They are JSON numbers; ±2^53 is the exactness boundary for a consumer that reads them into a double, not an enforced payload limit — the publisher emits any storable balance. A parser typed to integers will break on this payload.
  • The *Decimal siblings are the lossless read. totalCreditsDecimal, remainingCreditsDecimal, and usedCreditsDecimal carry the same values as decimal strings, exact at any magnitude. Prefer them wherever a balance can exceed ±2^53; parse them with a decimal type, not a double.
  • remainingCredits can be negative. It is the real remainder in the pool at depletion: 0 for an exact depletion, and negative when the depleting spend overshot into overage — with sub-unit credit costs, a fraction such as -0.5.
  • totalCredits is the pool’s total, and usedCredits is what the depleting signal alone consumed — not the cumulative amount used. The two are equal only when a single signal consumes a whole pool.
  • creditsCurrencyId can be null if the depleted balance is not scoped to a credits currency
  • signalId is the Paid signal that took the balance to zero or below
  • eventName is the original signal event name that consumed the final credits

Overage incurred

1{
2 "event": "billing-overage-incurred",
3 "timestamp": "2026-04-16T14:05:00.000Z",
4 "isTest": false,
5 "data": {
6 "overage": {
7 "customerId": "cus_123",
8 "customerName": "Acme",
9 "planId": "plan_123",
10 "planName": "Growth",
11 "orderLineAttributeId": "ola_123",
12 "eventType": "OverageUsage",
13 "threshold": 1000,
14 "currentUsage": 1200,
15 "occurredAt": "2026-04-16T14:05:00.000Z"
16 }
17 }
18}

Field notes:

  • eventType is one of OverageUsage or OverageCredit
  • threshold is the included usage limit that was crossed
  • currentUsage is the usage value observed when the overage condition was detected
  • customerId, customerName, planName, threshold, and currentUsage can be null in edge cases

Credit cap reached

1{
2 "event": "billing-credit-cap-reached",
3 "timestamp": "2026-09-11T10:15:00.000Z",
4 "isTest": false,
5 "data": {
6 "customer": { "id": "cus_123", "externalId": "acme" },
7 "cap": {
8 "creditsCurrencyId": "7f4f5d4c-55e9-4d5b-a3e7-c9eb3d2d01bf",
9 "amount": 10000,
10 "amountDecimal": "10000",
11 "frequency": "MONTHLY",
12 "effectiveFrom": "2026-03-01T00:00:00Z"
13 },
14 "breachedUnit": { "id": "cu_456", "externalId": "tenant-a", "externalType": "tenant" },
15 "used": 10240.5,
16 "usedDecimal": "10240.5",
17 "period": { "start": "2026-09-01T00:00:00Z", "end": "2026-10-01T00:00:00Z" },
18 "detectedAt": "2026-09-11T10:14:58Z"
19 }
20}

Fires once per cap and period, when the credits of one currency spent by a customer unit and everything under it reach the cap set on that unit. Caps are advisory: spend is not blocked, and the event is your signal to act.

Field notes:

  • breachedUnit is the unit whose cap was reached, by its Paid id (cu_…) and your own externalId; externalType is your vocabulary for it and can be null. The spend may have come from that unit or from any unit beneath it.
  • cap identifies the cap: the credits currency it limits, the amount per period, the frequency, and the effectiveFrom its periods are anchored on. Caps have no separate id; address one by unit and credits currency.
  • used is the subtree’s spend of that currency in period at detection, a decimal that can exceed cap.amount. amountDecimal and usedDecimal are always present: the lossless string twins, exact at any magnitude. Prefer them wherever a total may exceed ±2^53.
  • period is the cap period the breach was found in (start inclusive, end exclusive, UTC).
  • customer.externalId can be null when the customer has no external id.

Implementation checklist

Before you enable a webhook in Paid, make sure your receiver:

  • Accepts unauthenticated HTTPS POST requests from Paid at a stable public URL
  • Parses the top-level envelope first, then branches on event
  • Handles null values for optional fields like invoiceId, invoiceNumber, externalPaymentId, customerName, planName, threshold, and currentUsage
  • Returns a 2xx after persisting or queueing the event
  • Ignores or separately labels isTest: true deliveries in your downstream systems
  • Safely ignores unknown event values for forward compatibility as new events are added

Testing from the Paid UI

Use the Test button in Settings > Webhooks to send a synthetic event to your receiver.

  • Test deliveries use the same envelope shape as production deliveries
  • isTest is set to true
  • IDs in the nested payload are synthetic test IDs such as pay_test_*, cust_test_*, cus_test_*, cu_test_*, ola_test_*, or plan_test_*
  • Test payloads are intended to validate parsing and routing, not to represent real billing records in your system