> ## Documentation Index
> Fetch the complete documentation index at: https://docs.bouclay.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Hear about every change, verify that it came from Bouclay, and handle it once

Bouclay sends an event to your server whenever something changes: a trial converts, an invoice is paid, a card is saved. Webhooks keep your app in step without polling.

## Register an endpoint

Add your URL in the dashboard under **Developers → Webhooks**, or with the API:

```bash theme={null}
curl https://bouclay.com/api/v1/webhook-endpoints \
  -H "Authorization: Bearer $BOUCLAY_KEY" \
  -H "Idempotency-Key: webhook-prod-1" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://yourapp.com/webhooks/bouclay"}'
```

The response carries the endpoint's **signing secret** (`whsec_…`). It is shown once, so store it right away. Every active endpoint receives every event. To replace a secret, call `POST /webhook-endpoints/{id}/rotate-secret`: the old secret stops working at once.

## What an event looks like

```json theme={null}
{
  "id": "evt_…",
  "type": "subscription.updated",
  "created": "2026-07-07T16:43:00+00:00",
  "data": {
    "object": { "id": "sub_…", "status": "past_due" }
  }
}
```

`data.object` is the full object, exactly as the REST API returns it.

## Verify the signature

Anyone can send a request to your URL, so check that each one came from Bouclay. Every delivery carries a header:

```http theme={null}
Bouclay-Signature: t=1751906580,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

`t` is when it was sent (Unix seconds). `v1` is an HMAC-SHA256 of `"<t>.<raw body>"` keyed with your signing secret, written in hex. To verify:

1. Read the **raw** request body, before any JSON parsing.
2. Compute the HMAC of `t + "." + body` with your secret.
3. Compare it with `v1` using a constant-time comparison.
4. Reject the request if `t` is more than five minutes old, so an old delivery can't be replayed.

```javascript Node.js theme={null}
import crypto from "node:crypto";

export function verifyBouclay(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");

  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  const valid =
    expected.length === parts.v1.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));

  return fresh && valid;
}
```

```php PHP theme={null}
function verifyBouclay(string $rawBody, string $header, string $secret): bool
{
    parse_str(str_replace(',', '&', $header), $parts);
    $expected = hash_hmac('sha256', $parts['t'].'.'.$rawBody, $secret);

    return abs(time() - (int) $parts['t']) < 300
        && hash_equals($expected, $parts['v1']);
}
```

```python Python theme={null}
import hashlib, hmac, time

def verify_bouclay(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    signed = f"{parts['t']}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    fresh = abs(time.time() - int(parts["t"])) < 300
    return fresh and hmac.compare_digest(expected, parts["v1"])
```

## Handle each event once

Your endpoint must answer with a `2xx` within 10 seconds. Redirects are not followed. A delivery that fails is tried up to 5 times in all, waiting 1 minute, 5 minutes, 30 minutes and 2 hours between tries. So the same event can arrive more than once, and a retried one can arrive after a newer one.

* **Answer fast.** Return `200` as soon as the signature checks out, then do the work in a background job.
* **Skip events you've seen.** Store each `id` you handle and ignore repeats.
* **Skip stale events.** Compare the event's `created` time with the last event you applied to that object, and ignore older ones.

## Read the state, not the event name

Each object sends exactly two events: `*.created` when it first exists, and `*.updated` for **every** change after that. There is no `invoice.paid` or `subscription.canceled`. Read `status` and the other fields off `data.object` instead:

```javascript theme={null}
if (event.type === "invoice.updated" && event.data.object.status === "paid") {
  // mark the order paid
}
```

This keeps the list short, and a new status never breaks your handler: it arrives on an event you already handle.

`subscription.*` events also carry `data.object.customer.entitlements`, the codes the customer holds right now, so you can update your paywall without calling back.

## Event list

| Event | Fires when |
| - | - |
| `customer.created` | A customer is created, including one created by a payment link. |
| `customer.updated` | A customer is edited, archived or restored. |
| `payment_method.created` | A card is saved. |
| `payment_method.updated` | A card is made the default, expires or is removed. |
| `product.created` / `product.updated` | A product is added, edited or archived. |
| `plan.created` / `plan.updated` | A plan is added, edited or archived. |
| `meter.created` / `meter.updated` | A meter is added, edited or archived. |
| `subscription.created` | A subscription starts. |
| `subscription.updated` | Any change: a trial converts, it renews, goes past due, pauses, is suspended, cancels, a cancellation is scheduled or undone, items or seats change, the minimum commitment or spending cap changes. |
| `invoice.created` | An invoice is issued. |
| `invoice.updated` | An invoice is paid, a payment fails, it is voided, or a credit note changes what it owes. A failed attempt rides along as `data.object.payment`. |
| `credit_note.created` | A credit note is issued, including the one every refund issues. |
| `credit_grant.created` | Promotional credit is given, or prepaid credit is sold (one per lot). |
| `credit_grant.updated` | A lot is adjusted, its top-up is paid or voided, a refund or chargeback takes credit back, a won dispute puts it back, or it expires. |
| `credit_auto_reload.created` / `credit_auto_reload.updated` | An auto-reload rule is set, changed, buys a top-up, or turns itself off. |
| `spend_alert.created` | A period's spend passes a warning point of a spending cap. |
| `credit_hold.updated` | A credit hold expired before you committed or released it. |

Prices, discounts, payments and refunds send no events of their own. A payment arrives on `invoice.updated` as `data.object.payment`.

## Missed something?

Every event is also kept in the API. List them with `GET /events`, newest first, to backfill after downtime.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.