> 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.

# Make your integration production-ready

> **Note**
>
> This is the runbook for going live; [Going live](/going-live/overview) is the one-page version. The code shows the Runledger side only: functions such as `greet()`, `deadLetter()` and `metrics.increment()` stand for your own.

The steps, in order:

| Step                                                                                 | Runs against                   | Done when                                                                          |
| ------------------------------------------------------------------------------------ | ------------------------------ | ---------------------------------------------------------------------------------- |
| [Make checks safe to depend on](#make-checks-safe-to-depend-on)                      | Your code                      | Your agents keep working when Runledger is slow or down                            |
| [Send events through your queue](#send-events-through-your-queue)                    | Your code                      | No event is lost or billed twice                                                   |
| [Wire up webhooks and disputes](#wire-up-webhooks-and-disputes)                      | Your code                      | Every dispute reaches someone before its deadline                                  |
| [Backtest on your history](/going-live/backtest-and-shadow#backtest-on-your-history) | The sandbox, on a test clock   | Runledger's invoices for past months match yours, or every difference is explained |
| [Run a shadow period](/going-live/backtest-and-shadow#run-a-shadow-period)           | The sandbox, with live traffic | A full billing period of real traffic matches your invoices                        |
| [Cut over](/going-live/cut-over#cut-over)                                            | Live                           | Your first Runledger invoices are paid                                             |

## Make checks safe to depend on

`check()` is the call your users wait for, because it runs before the work starts. Runledger is hosted in Australia, so from North America or Europe each call makes a long network round trip, and an outage at Runledger shouldn't become an outage in your product.

### Check once per run

Call `check()` when a conversation or run starts, and hold its whole budget: the price of one outcome, or an estimate for usage. Capture the hold with `track()` when the work finishes, [extend it](/api-reference/holds/extend) (in whole days) if a long run outlives it, and [release it](/api-reference/holds/release) if the work fails. A check before every step adds a round trip each time and reserves nothing the first hold didn't. [Checks and holds](/concepts/checks-and-holds) covers sizing and expiry.

### Fail open when Runledger doesn't answer

Decide what your product does when `check()` times out or Runledger is down:

|                  | Fail open                                                         | Fail closed                                |
| ---------------- | ----------------------------------------------------------------- | ------------------------------------------ |
| During an outage | The work runs, and `track()` records it when Runledger is back    | The work waits, or is refused              |
| What it costs    | A prepaid balance can go below zero by what ran during the outage | Your product is down while Runledger is    |
| Suits            | Conversations and other work a customer is waiting for            | Expensive batch work for prepaid customers |

Fail open only when Runledger didn't answer, or answered with an error marked `retryable`. A `400` or `401` means your request or key is wrong, and failing open on it would hide the bug until the invoices are wrong. Pick a timeout your product can afford: when the check runs alongside the first step of the work, as in the next section, a second or two costs your users nothing.

**`TypeScript`**

```typescript title="TypeScript"
// runledger.ts
import { Runledger, RunledgerClient, RunledgerError, RunledgerTimeoutError } from "@runledger/sdk";

export const runledger = new RunledgerClient(); // reads RUNLEDGER_API_KEY

// True when Runledger didn't answer, or answered that the request can be retried.
export function isRetryable(error: unknown): boolean {
  if (error instanceof RunledgerTimeoutError) return true;
  if (!(error instanceof RunledgerError)) return false;
  if (error.statusCode === undefined) return true; // no response: a network error
  const body = error.body as { retryable?: boolean } | undefined;
  return body?.retryable ?? (error.statusCode === 429 || error.statusCode >= 500);
}

export interface Gate {
  allowed: boolean;
  failedOpen: boolean;
  holdId?: string;
  reason?: Runledger.CheckDenialReason;
}

export async function checkOrFailOpen(request: Runledger.CheckRequest): Promise<Gate> {
  try {
    // Two seconds and no retries: after that, letting the work run beats waiting.
    const check = await runledger.check(request, { timeoutInSeconds: 2, maxRetries: 0 });
    return { allowed: check.allowed, failedOpen: false, holdId: check.hold?.id, reason: check.reason };
  } catch (error) {
    if (!isRetryable(error)) throw error; // a bad request or key is a bug: don't hide it
    metrics.increment("runledger.check.failed_open");
    return { allowed: true, failedOpen: true };
  }
}
```

**`Python`**

```python title="Python"
# runledger_client.py
from dataclasses import dataclass
from typing import Optional

import httpx

from runledger_sdk import AsyncRunledger
from runledger_sdk.core.api_error import ApiError

runledger = AsyncRunledger()  # reads RUNLEDGER_API_KEY


def is_retryable(error: Exception) -> bool:
    """True when Runledger didn't answer, or answered that the request can be retried."""
    if isinstance(error, httpx.TransportError):  # timeouts and network errors
        return True
    if not isinstance(error, ApiError):
        return False
    if isinstance(error.body, dict) and "retryable" in error.body:
        return bool(error.body["retryable"])
    return error.status_code is None or error.status_code == 429 or error.status_code >= 500


@dataclass
class Gate:
    allowed: bool
    failed_open: bool
    hold_id: Optional[str] = None
    reason: Optional[str] = None


async def check_or_fail_open(**request) -> Gate:
    try:
        # Two seconds and no retries: after that, letting the work run beats waiting.
        check = await runledger.check(**request, request_options={"timeout": 2.0, "max_retries": 0})
    except Exception as error:
        if not is_retryable(error):
            raise  # a bad request or key is a bug: don't hide it
        metrics.increment("runledger.check.failed_open")
        return Gate(allowed=True, failed_open=True)
    return Gate(
        allowed=check.allowed,
        failed_open=False,
        hold_id=check.hold.id if check.hold else None,
        reason=check.reason,
    )
```

The synchronous Python client, `Runledger`, takes the same `request_options`.

### Start the work and the check together

A caller shouldn't wait in silence while a check crosses an ocean. Start the check and the greeting together, and act on the result once both are done. A chat agent can do the same with its first model call.

**`TypeScript`**

```typescript title="TypeScript"
import { checkOrFailOpen } from "./runledger.js";

export async function answerCall(call: Call) {
  // The caller hears the greeting while the check runs.
  const [gate] = await Promise.all([
    checkOrFailOpen({
      external_customer_id: call.orgId,
      outcome_definition: "call_resolution",
      hold: true,
      hold_expires_in: "PT1H",
      run_id: call.id,
    }),
    greet(call),
  ]);
  if (!gate.allowed) return transferToHuman(call, gate.reason);
  return runVoiceAgent(call, gate.holdId);
}
```

**`Python`**

```python title="Python"
import asyncio

from runledger_client import check_or_fail_open


async def answer_call(call):
    # The caller hears the greeting while the check runs.
    gate, _ = await asyncio.gather(
        check_or_fail_open(
            external_customer_id=call.org_id,
            outcome_definition="call_resolution",
            hold=True,
            hold_expires_in="PT1H",
            run_id=call.id,
        ),
        greet(call),
    )
    if not gate.allowed:
        return await transfer_to_human(call, gate.reason)
    return await run_voice_agent(call, gate.hold_id)
```

## Send events through your queue

`track()` records work that already happened, so an event should never be lost and never count twice. Put the call on the job queue you already run, such as SQS, Sidekiq, Celery or BullMQ, and enqueue the event when it happens:

* Build `idempotency_key` from your own IDs, such as `conv_4821-resolved`, so every retry of the job sends the same key. Runledger keeps keys for 35 days. A new random key on each attempt would bill a retried event twice.
* Set `timestamp` to when the event happened. Events can be dated up to 35 days back, so a job that waits out an outage is billed in the right period, and one that arrives after its period's invoice is final goes on the next invoice.
* Let the queue retry errors marked `retryable`, and set the rest aside for a person: they won't succeed on retry.

**`TypeScript`**

```typescript title="TypeScript"
import { Runledger } from "@runledger/sdk";
import { isRetryable, runledger } from "./runledger.js";

// When the agent resolves a conversation: enqueue the event with your own key and the real time.
export async function onResolved(conversation: Conversation) {
  const event: Runledger.TrackRequest = {
    idempotency_key: `${conversation.id}-resolved`,
    name: "resolution.chat",
    external_customer_id: conversation.orgId,
    reference: conversation.id,
    timestamp: new Date().toISOString(),
    hold: conversation.holdId,
  };
  await queue.add("runledger.track", event);
}

// The queue's worker. Throwing makes the queue retry the job later, with the same key.
export async function trackJob(event: Runledger.TrackRequest) {
  let result: Runledger.Event;
  try {
    result = await runledger.track(event);
  } catch (error) {
    if (isRetryable(error)) throw error;
    return deadLetter(event, error); // won't succeed on retry: a person looks at it
  }
  for (const warning of result.warnings) await reportWarning(event, warning);
}
```

**`Python`**

```python title="Python"
from datetime import datetime, timezone

from runledger_client import is_retryable
from runledger_sdk import Runledger

runledger = Runledger()


# When the agent resolves a conversation: enqueue the event with your own key and the real time.
def on_resolved(conversation):
    queue.enqueue(
        "runledger.track",
        {
            "idempotency_key": f"{conversation.id}-resolved",
            "name": "resolution.chat",
            "external_customer_id": conversation.org_id,
            "reference": conversation.id,
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "hold": conversation.hold_id,
        },
    )


# The queue's worker. Raising makes the queue retry the job later, with the same key.
def track_job(job):
    event = {**job, "timestamp": datetime.fromisoformat(job["timestamp"])}
    try:
        result = runledger.track(**event)
    except Exception as error:
        if is_retryable(error):
            raise
        return dead_letter(job, error)  # won't succeed on retry: a person looks at it
    for warning in result.warnings:
        report_warning(job, warning)
```

### Act on warnings

`track()` succeeds even when an event doesn't do what you expected, and says so in `warnings`:

| Warning                           | What it means                                                                                                   | What to do                                                                                                                                                                     |
| --------------------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `no_rate_for_event`               | Nothing prices this event name, so it was stored unpriced                                                       | Check the name against your plans and outcome definitions                                                                                                                      |
| `no_active_subscription`          | The customer isn't on a plan                                                                                    | Check the customer's subscription                                                                                                                                              |
| `reference_not_found`             | A signal found no charge with this customer and `reference`                                                     | Check that the outcome and its signals send the same reference. Expected for work billed before your cutover                                                                   |
| `duplicate_outcome_for_reference` | The reference already has an active charge, so nothing new was charged                                          | Expected when a resolved conversation is resolved again                                                                                                                        |
| `reversal_horizon_passed`         | The charge is locked, so the signal had no effect                                                               | Expected for signals that arrive late                                                                                                                                          |
| `outcome_charge_already_reversed` | The charge was already reversed                                                                                 | Expected when two signals match the same charge                                                                                                                                |
| `capture_exceeded_hold`           | The event cost more than its hold, and the difference came from the balance                                     | Size holds closer to what the work costs                                                                                                                                       |
| `balance_overdrawn`               | The balance went past its overdraft limit                                                                       | Expect checks to be denied until the customer tops up                                                                                                                          |
| `allowance_exhausted`             | The work took a usage window past its cap. It is recorded, since it already happened                            | Expected now and then; if it is common, check before the work so `check()` can deny it                                                                                         |
| `spend_limit_exceeded`            | The work took a spend limit past its amount. It is recorded, and later checks are denied while the limit blocks | Tell the customer, or raise the limit                                                                                                                                          |
| `rate_not_found`                  | The plan's price rule could not price this event, so it was stored unpriced                                     | Check the event's properties against the price rule's rows and inputs                                                                                                          |
| `margin_below_guardrail`          | The event's margin is below a guardrail's floor                                                                 | Look at the price rule and the cost behind it. A guardrail whose action is `block` also denies a `check()` whose expected margin is below its floor, with `margin_below_floor` |

### Catch up after an outage

If your queue backs up, drain it with [`POST /v1/events/batch`](/api-reference/events/batch). It takes up to 100 events and reports each one as `created`, `duplicate` or `rejected`, so a batch that was partly sent is safe to send again. An event dated in a period whose invoice is already final goes on the customer's current draft invoice, with no warning.

### Use one reference per piece of work

Signals find their charge by customer and `reference`, so every event about the same conversation must carry the same reference. Use the ID of the thing you charge for. When a customer comes back through another channel, such as a call about yesterday's chat, your code makes the link and sends the chat's ID as the reference; Runledger can't make that link for you. [One charge per reference](/concepts/outcome-settlement#one-charge-per-reference) explains what happens when an outcome repeats.

## Wire up webhooks and disputes

Handle webhooks the way the [webhooks page](/api-reference/handle-webhooks) describes: verify the signature on the raw body, drop duplicates on `webhook-id`, and fetch the object before acting. An endpoint that fails for five days is disabled, and missed events aren't replayed for you, so alert on `failing_since` and redeliver by hand after an outage. Besides the events your product already uses, subscribe to these:

| Event                         | Why                                                                                                                  |
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `dispute.decision_due`        | A dispute's deadline is 24 hours away                                                                                |
| `reconciliation.breaks_found` | Runledger's records and one of your payment providers' disagree                                                      |
| `payment.disputed`            | A provider reported a chargeback on a top-up or invoice payment. Credits are not clawed back, so someone has to look |
| `subscription.past_due`       | An invoice went unpaid, and checks are denied until it's paid                                                        |
| `balance.depleted`            | A customer's balance ran out, and checks are denied until they top up                                                |
| `hold.expired`                | A hold lapsed without being captured or released, which can mean a `track()` call was lost                           |

Each outcome definition has a `decision_window`, which you set or take from a preset (seven days in `support_chat`), and a `default_decision`, which is `reverse` unless you change it. A dispute nobody decides in time is reversed. Before you go live:

* Send `dispute.decision_due` to the people who decide disputes, such as a support lead's queue, or add a [notification rule](/api-reference/how-configuration-works#notification-rules) to email them without code.
* Remember that a dispute still open when a charge's reversal horizon passes keeps the charge from locking until it's decided, so a slow queue delays when revenue becomes final.
* Give them the open disputes, soonest deadline first, with the evidence. [List disputes](/api-reference/disputes/list) filters by `status` and `decide_before`.
* Set `default_decision` to `uphold` if your contracts say a charge stands unless it's reversed.

## Next steps

* [Going live](/going-live/overview) is the one-page plan.
* [Idempotency and errors](/reference/idempotency-and-errors) lists every error code and which are safe to retry.