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

# Handle webhooks

Runledger tells you when something happens that you didn't directly request: a confirmation window closed, a rule reversed a charge, a dispute deadline is near, an invoice finalized. Register an endpoint with `POST /v1/webhook_endpoints` and choose the event types you handle.

## The payload

Events are thin. They say what happened and to which object, and you fetch the object for its current state:

```json
{
  "id": "whe_0pL3kJ6hGf",
  "object": "webhook_event",
  "type": "outcome_charge.reversed",
  "api_version": "2026-10-18",
  "related_object": {
    "id": "oc_2xK9cL4vRt",
    "object": "outcome_charge",
    "url": "/v1/outcome_charges/oc_2xK9cL4vRt"
  },
  "reason": { "type": "rule" },
  "livemode": false,
  "created_at": "2026-11-04T13:12:09Z"
}
```

Events about a window, a threshold or a purchase also carry a `context` that names the customer, `member_id`, `team_id`, pool, allowance, spend limit and threshold they are about, so you can write a message without another call. Events that report a share of a window or limit point `related_object` at the customer's [usage summary](/guides/usage-summary).

Fetching the object means an out-of-date event can never overwrite newer state. When the cause was an API request, `reason` carries its `request_id` and `idempotency_key`, so you can match the event to your own call.

## Verify deliveries

Deliveries follow the [Standard Webhooks](https://www.standardwebhooks.com) specification, so its open-source libraries verify them. Each delivery has three headers:

* `webhook-id`: unique per event and the same on every retry. Use it to drop duplicates.
* `webhook-timestamp`: when this attempt was signed. Reject attempts more than five minutes old.
* `webhook-signature`: `v1,` followed by a base64 HMAC-SHA256 of `id.timestamp.body`, keyed with the endpoint's `whsec_` secret.

**`TypeScript`**

```typescript title="TypeScript"
import express from "express";
import { Webhook } from "standardwebhooks";

const app = express();
const wh = new Webhook(process.env.RUNLEDGER_WEBHOOK_SECRET!);

app.post("/webhooks/runledger", express.raw({ type: "application/json" }), (req, res) => {
  // Throws if the signature or timestamp is wrong.
  const event = wh.verify(req.body, req.headers as Record<string, string>) as {
    type: string;
    related_object: { id: string; object: string };
  };
  // Queue the event, then respond within 15 seconds.
  res.sendStatus(200);
});
```

**`Python`**

```python title="Python"
import os

from flask import Flask, request
from standardwebhooks.webhooks import Webhook

app = Flask(__name__)
wh = Webhook(os.environ["RUNLEDGER_WEBHOOK_SECRET"])

@app.post("/webhooks/runledger")
def runledger_webhook():
    # Raises if the signature or timestamp is wrong.
    event = wh.verify(request.get_data(), dict(request.headers))
    # Queue the event, then respond within 15 seconds.
    return "", 200
```

Verify against the raw body, before any JSON parsing changes it. While a secret is rotating, deliveries carry two signatures and either one passes.

## Delivery

* At least once, so deduplicate on `webhook-id`.
* In no guaranteed order. Fetch the object instead of relying on order.
* Any `2xx` within 15 seconds counts as delivered. Anything else is retried with exponential backoff for three days.
* An endpoint that fails every delivery for five days is disabled automatically, and `status` becomes `disabled`. Each endpoint reports `last_success_at`, `last_failure_at`, `failing_since` and `disables_at`, so you can see a failing endpoint before it is disabled. Set `status` back to `enabled` with `PATCH /v1/webhook_endpoints/{id}` once the endpoint works again.
* **Missed events are not replayed automatically.** An event that failed all its retries stays `failed`, and events that happened while an endpoint was disabled are not sent to it when you enable it again. Redeliver is manual and per event: find them with `GET /v1/webhook_events?delivery_status=failed` (or by `endpoint`), and send each one with `POST /v1/webhook_events/{id}/redeliver`. The list keeps the last 30 days, so redeliver within that window.
* `POST /v1/test_helpers/webhook_endpoints/{id}/send_event` sends a signed sample event to one endpoint in the sandbox, so you can test a handler and its signature check.
* `GET /v1/webhook_events` lists the last 30 days, so you can catch up after an outage, and `POST /v1/webhook_events/{id}/redeliver` sends one event again, to one endpoint or to every enabled endpoint subscribed to its type. Nothing redelivers for you. Each event has a `delivery_status` of `pending`, `succeeded` or `failed`, and the list filters by `delivery_status` and by `endpoint`.

## Event types

| Type                                           | Object           | Sent when                                                                                                                                                                                                                            |
| ---------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `outcome_charge.created`                       | Outcome charge   | `track()` reported an outcome                                                                                                                                                                                                        |
| `outcome_charge.confirmed`                     | Outcome charge   | The confirmation window closed, a dispute was upheld, or you confirmed early                                                                                                                                                         |
| `outcome_charge.disputed`                      | Outcome charge   | A dispute opened on it                                                                                                                                                                                                               |
| `outcome_charge.reversed`                      | Outcome charge   | A rule, a dispute or you reversed it                                                                                                                                                                                                 |
| `outcome_charge.locked`                        | Outcome charge   | Its reversal horizon closed, or a dispute that was holding the lock past the horizon was upheld                                                                                                                                      |
| `reversal.created`                             | Reversal         | Any reversal                                                                                                                                                                                                                         |
| `dispute.created`                              | Dispute          | You or a rule opened a dispute                                                                                                                                                                                                       |
| `dispute.decision_due`                         | Dispute          | 24 hours before `decide_by`                                                                                                                                                                                                          |
| `dispute.resolved`                             | Dispute          | You decided, or the default applied                                                                                                                                                                                                  |
| `hold.expired`                                 | Hold             | A hold lapsed and was released                                                                                                                                                                                                       |
| `balance.depleted`                             | Customer         | A balance's `available` reached zero                                                                                                                                                                                                 |
| `balance.low`                                  | Usage summary    | A pool's available balance fell to the plan's `top_up.low_balance_at`, or to the threshold of the customer's [auto top-up](/guides/auto-top-up-and-alerts)                                                                           |
| `usage.threshold_reached`                      | Usage summary    | A [usage window](/guides/usage-windows) reached one of its `alert_at` shares. Once per window per threshold                                                                                                                          |
| `spend_limit.threshold_reached`                | Usage summary    | Spend reached one of a [spend limit's](/guides/spend-limits) `alert_at` shares. Once per period per threshold                                                                                                                        |
| `spend_limit.updated`                          | Spend limit      | A person, your backend or the customer view changed a spend limit                                                                                                                                                                    |
| `auto_top_up.succeeded`, `auto_top_up.failed`  | Auto top-up      | An automatic purchase was paid, or its payment failed                                                                                                                                                                                |
| `auto_top_up.disabled`                         | Auto top-up      | A rule stopped after three failed payments in a row                                                                                                                                                                                  |
| `credit_grant.expiring`                        | Credit grant     | Credits with time left to run are within the account's `grant_expiry_notice` of expiring. See [Credit expiry](/guides/credit-expiry)                                                                                                 |
| `credit_grant.created`                         | Credit grant     | Credits were granted                                                                                                                                                                                                                 |
| `credit_grant.expired`                         | Credit grant     | Unused credits expired                                                                                                                                                                                                               |
| `credit_grant.voided`                          | Credit grant     | A grant was voided                                                                                                                                                                                                                   |
| `customer.created`, `customer.updated`         | Customer         | A customer changed                                                                                                                                                                                                                   |
| `subscription.created`, `subscription.updated` | Subscription     | A subscription started or changed                                                                                                                                                                                                    |
| `subscription.renewed`                         | Subscription     | A new period began                                                                                                                                                                                                                   |
| `subscription.past_due`                        | Subscription     | An invoice went unpaid                                                                                                                                                                                                               |
| `subscription.canceled`                        | Subscription     | A subscription ended                                                                                                                                                                                                                 |
| `invoice.created`                              | Invoice          | A new period's draft invoice opened                                                                                                                                                                                                  |
| `invoice.finalized`                            | Invoice          | An invoice was finalized                                                                                                                                                                                                             |
| `invoice.paid`                                 | Invoice          | The provider collected payment                                                                                                                                                                                                       |
| `invoice.payment_failed`                       | Invoice          | Collection failed                                                                                                                                                                                                                    |
| `checkout_session.completed`                   | Checkout session | The customer paid                                                                                                                                                                                                                    |
| `checkout_session.expired`                     | Checkout session | The page expired unpaid                                                                                                                                                                                                              |
| `export.ready`, `export.failed`                | Export           | An export finished                                                                                                                                                                                                                   |
| `payment.disputed`                             | Payment          | A payment provider reported a chargeback on a top-up or invoice payment. Separate from outcome disputes, and it changes no credits                                                                                                   |
| `reconciliation.completed`                     | Reconciliation   | The daily check finished                                                                                                                                                                                                             |
| `reconciliation.breaks_found`                  | Reconciliation   | The ledger and the provider disagree                                                                                                                                                                                                 |
| `test_clock.ready`                             | Test clock       | A test clock finished advancing                                                                                                                                                                                                      |
| `rate_card.activated`                          | Rate card        | A [rate card](/guides/price-changes-and-simulation#when-a-provider-changes-its-prices) became the active version of its cost model                                                                                                   |
| `catalog.price_changed`                        | Catalog price    | Runledger published a new [price version](/guides/price-changes-and-simulation#when-a-provider-changes-its-prices) for a catalog model one of your cost models uses. Fetch it to see the rates, the source and when they take effect |
| `margin_guardrail.breached`                    | Margin guardrail | Work fell below the guardrail's floor                                                                                                                                                                                                |
| `simulation.completed`                         | Simulation       | A [simulation](/guides/price-changes-and-simulation#simulation) finished                                                                                                                                                             |
| `proposal.pending`                             | Config proposal  | A [proposal](/api-reference/how-configuration-works#proposals-for-live) is waiting for a person to approve it                                                                                                                        |
| `topup_chargeback.threshold_exceeded`          | Payment          | The top-up chargeback rate, or the credits at risk, passed the threshold a [notification rule](/api-reference/how-configuration-works#notification-rules) set; `context` carries the figures and the rule                            |

New types may be added; subscribe only to the ones you handle.

## See also

* [Going live](/going-live/production-ready#wire-up-webhooks-and-disputes) shows how to wire up webhooks and disputes.
* [Outcome settlement](/concepts/outcome-settlement) explains the lifecycle the events report.