For AI agents: a documentation index is available at the root level at /llms.txt and /llms-full.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.
Log inGet Paid
DocumentationAPI ReferenceCLI
DocumentationAPI ReferenceCLI
  • Getting Started
    • Quickstart
    • First signals
    • Cost traces
    • Cost attributed signals
    • Delivered value
  • Credits
    • How credit balances work
    • Understanding the credit ledger
    • Examples of plans with included credits
  • Customers & Users
    • Checkout
    • User management
    • Multi-entity customers
  • Billing
    • Webhooks
    • Product lifecycle and archival semantics
    • Backdated order activation
  • Value Receipts
    • Overview
    • Integration guide
    • Authentication and embedding
  • Integrations
    • Datadog
    • Xero
LogoLogo
Log inGet Paid
On this page
  • Supported events
  • Envelope format
  • Verifying webhook signatures
  • Header format
  • Node.js example
  • Getting the raw body
  • Rotating the secret
  • How delivery works
  • Managing webhooks through the API
  • List all webhooks
  • Configure a webhook
  • Send a test delivery
  • Event payload examples
  • Payment succeeded
  • Payment failed
  • Credits depleted
  • Overage incurred
  • Implementation checklist
  • Testing from the Paid UI
Billing

Webhooks

Receive real-time HTTP notifications when billing events happen in Paid

Was this page helpful?
Previous

Product lifecycle and archival semantics

Next
Built with

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

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

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": 1000,
12 "remainingCredits": 0,
13 "usedCredits": 1000,
14 "eventName": "api_call.completed",
15 "signalId": "sig_123",
16 "depletedAt": "2026-04-16T14:05:00.000Z"
17 }
18 }
19}

Field notes:

  • remainingCredits is 0 when this event fires
  • creditsCurrencyId can be null if the depleted balance is not scoped to a credits currency
  • signalId is the Paid signal that pushed the balance to zero
  • 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

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_*, ola_test_*, or plan_test_*
  • Test payloads are intended to validate parsing and routing, not to represent real billing records in your system