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

# Webhooks

> Receive signed events when customers, subscriptions, invoices and balances change.

Ledger sends an HTTPS `POST` to your endpoint when something changes: a customer subscribes, a renewal is paid, credits are granted, a balance runs low. Use it to provision access, send emails or keep your own database in step without polling.

Each environment has its own endpoints: Sandbox events go to Sandbox endpoints, Live events to Live ones.

## Add an endpoint

In the dashboard, open **Settings › Webhooks**, choose **Add endpoint**, enter your URL and pick the events. Store the signing secret (`whsec_…`) with your other server secrets: it is shown once.

Subscribing to all events (`*`) includes ones Ledger adds later. Live endpoints must be public `https://` URLs. In the dashboard, **Send test event** sends a `webhook_endpoint.test` event to that endpoint alone.

## Verify the signature

Every request carries a `Ledger-Signature` header:

```
Ledger-Signature: t=1762539631,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

`t` is when Ledger signed it (Unix seconds) and `v1` is the hex HMAC-SHA256 of `{t}.{raw body}` with your signing secret. After you rotate the secret, Ledger signs with both the new and the old secret for 24 hours, so the header carries two `v1` values and either one verifies.

Verify the **raw** body: JSON that was parsed and serialized again won't match. Refuse signatures older than five minutes, so a captured request can't be replayed.

```typescript TypeScript SDK theme={"system"}
import { verifyWebhook, WebhookSignatureError } from "integrateledger/webhooks";

export async function POST(request: Request): Promise<Response> {
  let event;
  try {
    event = await verifyWebhook({
      payload: await request.text(),
      signature: request.headers.get("ledger-signature"),
      secret: process.env.LEDGER_WEBHOOK_SECRET ?? "",
    });
  } catch (error) {
    if (error instanceof WebhookSignatureError) {
      return new Response("Invalid signature", { status: 400 });
    }
    throw error;
  }
  await queueForProcessing(event); // answer fast; do the work afterwards
  return new Response(null, { status: 200 });
}
```

```python Python SDK theme={"system"}
import os
from flask import Flask, abort, request
from integrateledger import WebhookSignatureError, verify_webhook

app = Flask(__name__)

@app.post("/webhooks/ledger")
def ledger_webhook():
    try:
        event = verify_webhook(
            request.get_data(),
            request.headers.get("Ledger-Signature"),
            os.environ["LEDGER_WEBHOOK_SECRET"],
        )
    except WebhookSignatureError:
        abort(400)
    queue_for_processing(event)  # answer fast; do the work afterwards
    return "", 200
```

## Handle the event

```json theme={"system"}
{
  "id": "evt_3QeV8vK2mN7pL1xR9sT4uW6y",
  "object": "event",
  "type": "subscription.created",
  "apiVersion": "2026-11-07",
  "created": "2026-11-07T18:20:31.000Z",
  "livemode": false,
  "environment": "sandbox",
  "data": {
    "object": {
      "object": "subscription",
      "id": 42,
      "customerId": "user_123",
      "planId": "pro",
      "planName": "Pro",
      "monthlyPriceCents": 4900,
      "monthlyCredits": 1000,
      "status": "active",
      "currentPeriodStart": "2026-11-07T18:20:05.000Z",
      "currentPeriodEnd": "2026-12-07T18:20:05.000Z",
      "createdAt": "2026-11-07T18:19:12.000Z"
    }
  }
}
```

* **`id`** is the same on every retry of an event. Store it and skip events you've already handled.
* **`created`** is when the change happened, not when the request was sent.
* **`apiVersion`** names the shape. A breaking change ships as a new version; an endpoint keeps the version it was created with.
* **Order isn't guaranteed.** A retry can arrive after a later event. Compare `created`, or read the current state from the API, before acting on an old event.
* **Return a 2xx within 10 seconds.** Do the work after you answer: anything else (a redirect, a 4xx, a 5xx, a timeout) counts as a failure.

## Events

| Type | When | `data.object` |
| - | - | - |
| `customer.created` | A customer is created (not when an existing ID is returned) | `customer`: `customerId`, `createdAt` |
| `customer.deleted` | A customer is deleted. Their subscriptions and credits go with them, without separate events | `customer`: `customerId`, `deleted: true` |
| `subscription.created` | A plan becomes active: a free plan is attached, or a paid plan's first invoice is paid | `subscription` |
| `subscription.updated` | The plan changed (an upgrade was applied), a new period started (a renewal), or Stripe changed its status (`past_due`, `unpaid`, `paused`, or back to `active`). `data.previousAttributes` has the earlier `planId`, period or `status` | `subscription` |
| `subscription.canceled` | A subscription ended: it was canceled in Stripe, reached the end of a scheduled cancellation, or a paid plan replaced a free one | `subscription` |
| `invoice.paid` | Ledger verified a paid Stripe invoice: the first payment, a renewal or an upgrade | `invoice`: `id` (Stripe's), `customerId`, `subscriptionId`, `planId`, `billingReason`, `amountPaidCents`, `currency`, `periodStart`, `periodEnd`, `hostedInvoiceUrl` |
| `credits.granted` | Credits were added: a plan period, a renewal, an upgrade, a credit pack or a manual adjustment | `credit_grant`: `id`, `customerId`, `source` (`plan`, `add_on` or `adjustment`), `planId` or `addOnId`, `credits`, `periodStart`, `expiresAt` |
| `balance.low` | A tracked use took the customer's balance from above your threshold to at or below it | `balance`: `customerId`, `availableCredits`, `threshold`, `featureId` |
| `balance.depleted` | The first refused use in the customer's billing period | `balance`: `customerId`, `availableCredits`, `featureId`, `creditCost`, `reason` (`insufficient_credits` or `overage_cap_reached`), `periodStart` |
| `checkout.completed` | A plan or credit-pack Checkout was completed | `checkout`: `id` (Stripe's), `customerId`, `mode`, `planId` or `addOnId`, `subscriptionId` or `addOnPurchaseId` |
| `checkout.expired` | A Checkout expired without payment | `checkout`, as above |

A paid plan's `checkout.completed` arrives before Ledger has verified the invoice; act on `subscription.created` to give access. Ledger doesn't send failed-payment events yet: the subscription keeps its state until Stripe cancels it.

### Low and depleted balances

A customer's credits are one balance, whatever feature spends them, so both balance events are about the customer, with the feature that triggered them.

* **`balance.low`** is off until you set a threshold in credits under **Settings › Webhooks › Low balance alert** (one per environment). It fires when a use crosses the threshold, once; it fires again only after the balance goes back above it (a renewal or a credit pack) and crosses again.
* **`balance.depleted`** fires on the first refused request in the customer's billing period (the plan's period, or the calendar month in UTC for a customer without a plan). Later refusals in that period send nothing.

## Retries and turned-off endpoints

If a delivery fails, Ledger tries again after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours twice, and then every 12 hours: 12 attempts over about three days, each a little later at random so endpoints that failed together don't all retry together. The delivery log in **Settings › Webhooks** shows each delivery's attempts and lets you resend one.

When every delivery to an endpoint has failed for three days, Ledger turns the endpoint off and says so in **Settings › Webhooks**. No email is sent. Events that happen while it's off aren't sent to it. Fix the receiver, then turn it back on there or with [`disabled: false`](https://docs.integrateledger.com/api-reference/webhooks/update); deliveries that were still retrying resume.

Ledger keeps events and their delivery log for 30 days.

> [!NOTE]
>
> **Other ways.** To manage endpoints from code, use the [webhook endpoint API](https://docs.integrateledger.com/api-reference/webhooks/create.md). Without an SDK, verify the `v1` HMAC described above with your language's crypto library.


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