Skip to navigation

Quickstart

Charge for a resolution, confirm it, then reverse it, in the sandbox.

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: 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, 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, if you want the terms first. Every term used here is in the glossary.

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.

Prefer to let an agent do this?

A coding agent connected to the developer MCP server 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.

1

Get a sandbox key

Sign up at the dashboard, 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.

2

Install an SDK

npm install @runledger/sdk
3

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

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",
},
],
});
4

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.

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" }],
});
5

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.

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",
});
6

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.

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
7

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.

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
8

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.

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"

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 covers every case a charge can end in, and the sandbox page shows the dispute path with the low_csat rule.
  • Checks and holds covers every reason check() can deny work.
  • Recipes has plans for other billing models, such as credits that refill each month.
  • Going live covers what to set up before Runledger carries real billing.