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

# Quickstart

This walkthrough sets up a postpaid support agent that charges US\$0.95 per resolved chat, then runs one charge through its whole life on a [test clock](/api-reference/test-in-the-sandbox): pending, confirmed, then reversed when the customer comes back. It takes a few minutes and touches no real money.

## Before you start

* A Runledger account. A new account opens in the [sandbox](/api-reference/test-in-the-sandbox), where nothing moves money.
* Node.js for the TypeScript SDK, or Python for the Python SDK. Both samples show the same calls.
* Optional: the [concepts overview](/concepts/overview), if you want the terms first. Every term used here is in the [glossary](/reference/glossary).

> **Note**
>
> The TypeScript and Python SDKs are generated from the OpenAPI spec. `RunledgerClient` is the class the TypeScript SDK exports. Every field shown is in the [API Reference](/api-reference).

> **Prefer to let an agent do this?**
>
> A coding agent connected to the [developer MCP server](/agents/mcp) can run the same setup: it reads the sandbox, validates a configuration document, applies it and checks the result. You confirm the clocks and the prices. This page shows the API calls an agent would make.

### Get a sandbox key

Sign up at the [dashboard](/dashboard#create-an-account), which opens a sandbox with no email check, and press the button on **Get started** to create your key. It is a restricted sandbox key, starting `rl_rk_test_`, with every scope but `charges:write` and `disputes:resolve`, which covers every call on this page. Confirming or reversing a charge by hand and deciding a dispute need those two, from a secret key you create on **API keys**. The key has no expiry, and the dashboard shows it once, so copy it then. You can roll or revoke it later. Set it as `RUNLEDGER_API_KEY` and the client picks it up.

### Install an SDK

**`TypeScript`**

```bash title="TypeScript"
npm install @runledger/sdk
```

**`Python`**

```bash title="Python"
pip install runledger-sdk
```

### Define the outcome

An outcome definition names the event that reports the outcome, the criteria you agreed with your customers, and the two clocks: 24 hours pending, then 30 days in which it can still be reversed. The rules say what later events mean, and `dispute_settings.decision_window` is how long you have to decide a dispute the `low_csat` rule opens.

Those three values are the `support_chat` [preset](/concepts/outcome-settlement#two-clocks). The API has no silent default: you send `confirmation_window`, `reversal_horizon` and `dispute_settings.decision_window` yourself, or send `preset: "support_chat"` and the server fills in whichever you leave out and stores them as explicit values.

**`TypeScript`**

```typescript title="TypeScript"
import { RunledgerClient } from "@runledger/sdk";

const runledger = new RunledgerClient();

await runledger.outcomeDefinitions.create({
  key: "chat_resolution",
  name: "Chat resolution",
  event_name: "resolution.chat",
  criteria: "The customer did not reply within 24 hours and did not ask for a person.",
  confirmation_window: "PT24H",
  reversal_horizon: "P30D",
  dispute_settings: { decision_window: "P7D" },
  reversal_rules: [
    { key: "reopened", event_name: "conversation.reopened", action: "reverse", reason_code: "not_resolved" },
    {
      key: "low_csat",
      event_name: "csat.submitted",
      condition: { property: "score", operator: "lte", value: "2" },
      action: "open_dispute",
      reason_code: "customer_unhappy",
    },
  ],
});
```

**`Python`**

```python title="Python"
from runledger_sdk import Runledger

runledger = Runledger()

runledger.outcome_definitions.create(
    key="chat_resolution",
    name="Chat resolution",
    event_name="resolution.chat",
    criteria="The customer did not reply within 24 hours and did not ask for a person.",
    confirmation_window="PT24H",
    reversal_horizon="P30D",
    dispute_settings={"decision_window": "P7D"},
    reversal_rules=[
        {"key": "reopened", "event_name": "conversation.reopened", "action": "reverse", "reason_code": "not_resolved"},
        {
            "key": "low_csat",
            "event_name": "csat.submitted",
            "condition": {"property": "score", "operator": "lte", "value": "2"},
            "action": "open_dispute",
            "reason_code": "customer_unhappy",
        },
    ],
)
```

### Price it on a plan

A postpaid plan accrues charges in money and invoices them monthly. `unit_amount_decimal` is in minor units, so `"95"` is US\$0.95.

**`TypeScript`**

```typescript title="TypeScript"
await runledger.plans.create({
  key: "support_monthly",
  name: "Support, monthly",
  billing_mode: "postpaid",
  currency: "usd",
  interval: "month",
  rates: [{ outcome_definition: "chat_resolution", unit_amount_decimal: "95", description: "Chat resolutions" }],
});
```

**`Python`**

```python title="Python"
runledger.plans.create(
    key="support_monthly",
    name="Support, monthly",
    billing_mode="postpaid",
    currency="usd",
    interval="month",
    rates=[{"outcome_definition": "chat_resolution", "unit_amount_decimal": "95", "description": "Chat resolutions"}],
)
```

### Create a customer on a test clock

The customer lives on a frozen clock, so you can move time forward instead of waiting a day.

**`TypeScript`**

```typescript title="TypeScript"
const clock = await runledger.testClocks.create({ frozen_time: "2026-11-02T09:00:00Z" });

await runledger.customers.create({
  external_id: "org_123",
  name: "Acme Support",
  currency: "usd",
  test_clock: clock.id,
});

await runledger.subscriptions.create({
  external_customer_id: "org_123",
  plan: "support_monthly",
  collection_method: "send_invoice",
});
```

**`Python`**

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

clock = runledger.test_clocks.create(frozen_time=datetime(2026, 11, 2, 9, tzinfo=timezone.utc))

runledger.customers.create(
    external_id="org_123",
    name="Acme Support",
    currency="usd",
    test_clock=clock.id,
)

runledger.subscriptions.create(
    external_customer_id="org_123",
    plan="support_monthly",
    collection_method="send_invoice",
)
```

### Check, then track the resolution

`check()` asks whether the work can start and holds the price of one resolution. `track()` reports the outcome and turns the hold into a pending charge. The `reference` ties later events about the same conversation to this charge.

**`TypeScript`**

```typescript title="TypeScript"
const check = await runledger.check({
  external_customer_id: "org_123",
  outcome_definition: "chat_resolution",
  hold: true,
});

const event = await runledger.track({
  idempotency_key: "conv_4821-resolved",
  name: "resolution.chat",
  external_customer_id: "org_123",
  reference: "conv_4821",
  hold: check.hold?.id,
});
// event.kind === "outcome"; event.effects.outcome_charge is the pending charge
```

**`Python`**

```python title="Python"
check = runledger.check(
    external_customer_id="org_123",
    outcome_definition="chat_resolution",
    hold=True,
)

event = runledger.track(
    idempotency_key="conv_4821-resolved",
    name="resolution.chat",
    external_customer_id="org_123",
    reference="conv_4821",
    hold=check.hold.id if check.hold else None,
)
# event.kind == "outcome"; event.effects.outcome_charge is the pending charge
```

### Move time past the confirmation window

Advance the clock 25 hours. Nobody objected, so the charge confirms and a US\$0.95 line appears on the draft invoice. Its reversal horizon now runs for 30 days. The advance runs in the background, so wait until the clock is `ready` before you read what it did.

**`TypeScript`**

```typescript title="TypeScript"
await runledger.testClocks.advance({ test_clock_id: clock.id, frozen_time: "2026-11-03T10:00:00Z" });
while ((await runledger.testClocks.retrieve({ test_clock_id: clock.id })).status === "advancing") {
  await new Promise((resolve) => setTimeout(resolve, 1000));
}

const charges = await runledger.outcomeCharges.list({ reference: "conv_4821" });
// status: "confirmed", reversible_until: 2026-12-03T09:00:00Z
```

**`Python`**

```python title="Python"
runledger.test_clocks.advance(clock.id, frozen_time=datetime(2026, 11, 3, 10, tzinfo=timezone.utc))
while runledger.test_clocks.retrieve(clock.id).status == "advancing":
    time.sleep(1)

charges = runledger.outcome_charges.list(reference="conv_4821")
# status: "confirmed", reversible_until: 2026-12-03T09:00:00Z
```

### Report that the customer came back

Track the signal. The `reopened` rule matches it to the charge by customer and reference, reverses the charge, and takes it off the draft invoice. The reversal records the rule and the event that fired it.

**`TypeScript`**

```typescript title="TypeScript"
const signal = await runledger.track({
  idempotency_key: "conv_4821-reopened-1",
  name: "conversation.reopened",
  external_customer_id: "org_123",
  reference: "conv_4821",
});
// signal.kind === "signal"; signal.effects.reversals has one reversal,
// with source "rule", rule "reopened" and effect "accrual_removed"
```

**`Python`**

```python title="Python"
signal = runledger.track(
    idempotency_key="conv_4821-reopened-1",
    name="conversation.reopened",
    external_customer_id="org_123",
    reference="conv_4821",
)
# signal.kind == "signal"; signal.effects.reversals has one reversal,
# with source "rule", rule "reopened" and effect "accrual_removed"
```

Had the invoice already been finalized, the same reversal would appear as a credit on the next invoice. On a prepaid plan it would restore the credits instead.

## Next steps

* [Outcome settlement](/concepts/outcome-settlement) covers every case a charge can end in, and the [sandbox page](/api-reference/test-in-the-sandbox) shows the dispute path with the `low_csat` rule.
* [Checks and holds](/concepts/checks-and-holds) covers every reason `check()` can deny work.
* [Recipes](/guides/recipes) has plans for other billing models, such as credits that refill each month.
* [Going live](/going-live/overview) covers what to set up before Runledger carries real billing.