> This page is for version v2 (default).
> For other versions, use one of these documentation indexes:
> - v2 (default): https://docs.paid.ai/v-2/llms.txt
> - v1: https://docs.paid.ai/v-1/llms.txt

> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.paid.ai/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.paid.ai/_mcp/server.

# Webhooks

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

Paid can send HTTP POST requests to your system whenever important billing events happen. You configure webhooks in the Paid app under [Settings > Webhooks](https://app.paid.ai/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](https://app.paid.ai/settings/webhooks) or a [Paid API key](https://app.paid.ai/settings/api-keys) 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 name                   | When it fires                                                         | Primary payload key   |
| ---------------------------- | --------------------------------------------------------------------- | --------------------- |
| `billing-invoice-created`    | A new invoice is created                                              | `invoice`             |
| `billing-invoice-paid`       | An invoice becomes fully paid                                         | `invoice`             |
| `billing-checkout-created`   | A checkout session is created                                         | `checkout`            |
| `billing-checkout-completed` | A checkout payment completes                                          | `checkout`            |
| `billing-checkout-expired`   | A checkout session expires                                            | `checkout`            |
| `billing-payment-succeeded`  | A payment succeeds                                                    | `payment`             |
| `billing-payment-failed`     | A payment attempt fails                                               | `payment`             |
| `billing-credits-depleted`   | A customer runs out of prepaid credits                                | `credits`             |
| `billing-overage-incurred`   | Usage exceeds the included threshold and creates an overage condition | `overage`             |
| `billing-credit-cap-reached` | A customer unit's credit spend in a period reaches the cap set on it  | `breachedUnit`, `cap` |

## Envelope format

Every webhook uses the same top-level envelope:

```json
{
  "event": "billing-payment-succeeded",
  "timestamp": "2026-04-16T14:05:00.000Z",
  "isTest": false,
  "data": {
    "payment": {
      "id": "pay_123",
      "invoiceId": "inv_123",
      "customerId": "cus_123"
    }
  }
}
```

* `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

```js
import crypto from "node:crypto";

const SIGNING_SECRET = process.env.PAID_WEBHOOK_SECRET;
const TOLERANCE_MS = 5 * 60 * 1000;

export function verifyPaidWebhook(rawBody, header) {
  if (!header) throw new Error("Missing signature");

  // Split each key=value on the FIRST `=` only - the signature is base64 and
  // its trailing `=` padding must not be lost.
  const parts = Object.fromEntries(
    header.split(",").map((kv) => {
      const i = kv.indexOf("=");
      return [kv.slice(0, i), kv.slice(i + 1)];
    }),
  );
  const timestamp = Number(parts.t);
  const provided = parts.s;
  if (!timestamp || !provided) throw new Error("Malformed signature");

  if (Math.abs(Date.now() - timestamp) > TOLERANCE_MS) {
    throw new Error("Stale signature");
  }

  const expected = crypto
    .createHmac("sha256", SIGNING_SECRET)
    .update(`${timestamp}.${rawBody}`)
    .digest("base64");

  const expectedBuf = Buffer.from(expected, "base64");
  const providedBuf = Buffer.from(provided, "base64");
  if (expectedBuf.length !== providedBuf.length) {
    throw new Error("Bad signature");
  }
  const ok = crypto.timingSafeEqual(expectedBuf, providedBuf);
  if (!ok) throw new Error("Bad signature");
}
```

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()`.

```js
app.post(
  "/paid-webhooks",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const rawBody = req.body.toString("utf8");
    verifyPaidWebhook(rawBody, req.headers["x-webhook-signature"]);
    res.json({ ok: true });
  },
);
```

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

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

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

```ts
// pages/api/paid-webhooks.ts
import getRawBody from "raw-body";

export const config = { api: { bodyParser: false } };

export default async function handler(req, res) {
  const rawBody = (await getRawBody(req)).toString("utf8");
  verifyPaidWebhook(rawBody, req.headers["x-webhook-signature"]);
  res.status(200).json({ ok: true });
}
```

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

```python
from flask import request

@app.post("/paid-webhooks")
def paid_webhooks():
    raw_body = request.get_data(as_text=True)
    sig = request.headers.get("x-webhook-signature")
    verify_paid_webhook(raw_body, sig)
    return {"ok": True}
```

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

```python
from fastapi import Request

@app.post("/paid-webhooks")
async def paid_webhooks(request: Request):
    raw_body = (await request.body()).decode("utf-8")
    sig = request.headers.get("x-webhook-signature")
    verify_paid_webhook(raw_body, sig)
    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.

```python
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt

@csrf_exempt
def paid_webhooks(request):
    raw_body = request.body.decode("utf-8")
    sig = request.headers.get("x-webhook-signature")
    verify_paid_webhook(raw_body, sig)
    return JsonResponse({"ok": True})
```

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

```go
import (
    "io"
    "net/http"
)

func paidWebhooks(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    sig := r.Header.Get("X-Webhook-Signature")
    if err := VerifyPaidWebhook(body, sig); err != nil {
        http.Error(w, "bad signature", http.StatusUnauthorized)
        return
    }
    w.WriteHeader(http.StatusOK)
}
```

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:

```bash
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

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

### Configure a webhook

```bash
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

```bash
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 '{}'
```

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

## Event payload examples

### Payment succeeded

```json
{
  "event": "billing-payment-succeeded",
  "timestamp": "2026-04-16T14:05:00.000Z",
  "isTest": false,
  "data": {
    "payment": {
      "id": "pay_123",
      "invoiceId": "inv_123",
      "invoiceNumber": "INV-000123",
      "customerId": "cus_123",
      "customerName": "Acme",
      "amount": 10800,
      "currency": "USD",
      "status": "posted",
      "paymentDate": "2026-04-16T14:05:00.000Z",
      "paymentType": "creditCard",
      "externalPaymentId": "pi_123"
    }
  }
}
```

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

```json
{
  "event": "billing-payment-failed",
  "timestamp": "2026-04-16T14:05:00.000Z",
  "isTest": false,
  "data": {
    "payment": {
      "id": "pay_456",
      "invoiceId": "inv_456",
      "invoiceNumber": "INV-000456",
      "customerId": "cus_456",
      "customerName": "Acme",
      "amount": 10800,
      "currency": "USD",
      "status": "failed",
      "failureReason": "Your card was declined.",
      "failureDate": "2026-04-16T14:05:00.000Z",
      "paymentType": "creditCard",
      "externalPaymentId": "pi_456"
    }
  }
}
```

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

```json
{
  "event": "billing-credits-depleted",
  "timestamp": "2026-04-16T14:05:00.000Z",
  "isTest": false,
  "data": {
    "credits": {
      "customerId": "cus_123",
      "customerName": "Acme",
      "orderLineAttributeId": "ola_123",
      "creditsCurrencyId": "cur_123",
      "totalCredits": 100.5,
      "remainingCredits": -0.5,
      "usedCredits": 1,
      "totalCreditsDecimal": "100.5",
      "remainingCreditsDecimal": "-0.5",
      "usedCreditsDecimal": "1",
      "eventName": "api_call.completed",
      "signalId": "sig_123",
      "depletedAt": "2026-04-16T14:05:00.000Z"
    }
  }
}
```

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

```json
{
  "event": "billing-overage-incurred",
  "timestamp": "2026-04-16T14:05:00.000Z",
  "isTest": false,
  "data": {
    "overage": {
      "customerId": "cus_123",
      "customerName": "Acme",
      "planId": "plan_123",
      "planName": "Growth",
      "orderLineAttributeId": "ola_123",
      "eventType": "OverageUsage",
      "threshold": 1000,
      "currentUsage": 1200,
      "occurredAt": "2026-04-16T14:05:00.000Z"
    }
  }
}
```

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

```json
{
  "event": "billing-credit-cap-reached",
  "timestamp": "2026-09-11T10:15:00.000Z",
  "isTest": false,
  "data": {
    "customer": { "id": "cus_123", "externalId": "acme" },
    "cap": {
      "creditsCurrencyId": "7f4f5d4c-55e9-4d5b-a3e7-c9eb3d2d01bf",
      "amount": 10000,
      "amountDecimal": "10000",
      "frequency": "MONTHLY",
      "effectiveFrom": "2026-03-01T00:00:00Z"
    },
    "breachedUnit": { "externalId": "tenant-a", "externalType": "tenant" },
    "used": 10240.5,
    "usedDecimal": "10240.5",
    "period": { "start": "2026-09-01T00:00:00Z", "end": "2026-10-01T00:00:00Z" },
    "detectedAt": "2026-09-11T10:14:58Z"
  }
}
```

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 your own `externalId`
  (the key every customer-unit route takes); `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