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

# How configuration works

Credit types, outcome definitions, plans, webhook endpoints, payment provider routing, notification rules and operator spend limits are all configuration, and so are [cost models, price rules and margin guardrails](/concepts/cost-reflective-pricing). You can set them from the [dashboard](/dashboard), from the API, and from a coding agent over [MCP](/agents/mcp). The three do the same thing: the same fields, the same validation and the same result. Agent-led setup is the main path, because an agent in your repo can configure the sandbox end to end.

Every object has its own endpoints, such as `POST /v1/plans`. For setting up a whole account, or for an agent, there is also a single document.

## The configuration document

`GET /v1/config` returns everything as one document, with a `hash` that identifies that exact state:

```json
{
  "object": "config",
  "hash": "cfg_8d3f1a52",
  "document": {
    "credit_types": [],
    "outcome_definitions": [
      {
        "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.",
        "preset": "support_chat",
        "confirmation_window": "PT24H",
        "reversal_horizon": "P30D"
      }
    ],
    "plans": [],
    "webhook_endpoints": [],
    "routing_rules": [],
    "notification_rules": []
  },
  "livemode": false
}
```

Each section has the shape of its create request. Leave a section out to leave it alone. Objects with a `key` are matched by key and webhook endpoints by `url`. The routing rules and notification rules in a document replace the current lists. `POST /v1/config/apply` never deletes an object because it is missing from the document. A replace does, as described below. A section holds at most 500 objects, and the notification rules at most 50.

Plans carry their [allowances](/guides/usage-windows), [pools](/guides/pools), `per_seat` flag, `default_spend_limit` and top-up settings. Two more sections complete the document: `spend_limits`, the operator's [spend limits](/guides/spend-limits), matched by `key`, and `account_settings`, which holds `grant_expiry_notice`, how long before credits expire the [warning](/guides/credit-expiry#warn-customers) goes out, and `customer_view_branding`, the default [look of the customer view](/guides/customer-view#brand-the-page). A limit your customer sets for themselves is data, not configuration, and isn't part of the document.

The [pricing from cost](/concepts/cost-reflective-pricing) objects have three more sections, `cost_models`, `price_rules` and `margin_guardrails`, with the same rules: objects are matched by key, a rate card inside a custom cost model adds a version only when its rates differ, and a live change is a proposal. A cost model can take its rates from the provider price catalog (`catalog_model`) and carry your own `overrides`; the catalog itself isn't configuration and can't be written.

Payment providers are not in the document. Connecting one has its own call, [`POST /v1/payment_providers`](/concepts/payment-providers#connect-a-provider), because a person finishes it at the provider; in the sandbox it connects test mode directly, and connecting in live isn't open yet.

## Validate, then apply

1. **Validate.** `POST /v1/config/validate` checks a document and writes nothing. It checks references between objects, clock windows against their bounds, and rates against credit types. A document with problems is a normal `200` with `valid: false` and the `errors`, so an agent can read them and try again. It also returns the `changes` the document would make, each with a `path`, the value `before` and the value `after`, and `warnings`.
2. **Apply in the sandbox.** `POST /v1/config/apply` creates or updates everything in the document, all or none, and returns the created and versioned IDs. It takes an `Idempotency-Key`, and applying the same document twice changes nothing the second time. It needs a sandbox key.
3. **Live changes are proposals.** With a live key, `POST /v1/config/apply` is refused with `proposal_required`. A live change goes through a proposal, below.

`PUT /v1/config` is the import: it **replaces** the sections the document carries, in one call. It takes the document and an `if_match` that must be the `hash` you read, so a change made in between is never overwritten. A stale `if_match`, one that no longer matches the current configuration, returns `409` with `invalid_state` and applies nothing: read `GET /v1/config` again, merge your change into what is there, and send the new `hash`. With a sandbox key it applies the change, and with a live key it creates a proposal.

**A replace removes what the document leaves out.** For every section the document carries, objects that are in the account but missing from that section are removed. A section the document doesn't carry is left alone. The `changes` of a dry run of the `PUT` and the diff of a proposal list each removal, as a change with `before` and no `after`, before anything is applied. Read them first. To add or update objects without removing any, use `POST /v1/config/apply`, which never deletes; the dashboard's import offers both, as **Add and update** (the default) and **Replace**. In live, an import is always a proposal: nothing changes until a signed-in Admin accepts it in the dashboard, and the approval replaces the sections as proposed unless the configuration changed since.

## Dry run

Every config write accepts the header `Runledger-Dry-Run: true`. It returns the normal response body with `dry_run: true`, `changes`, `warnings` and `errors` added, and writes nothing. Validation runs exactly as it would for a real write. The dashboard's "Preview changes" button and the MCP `validate_config` tool use the same checks, so a person and an agent see the same warnings. A dry run is kept in the [request logs](/api-reference/access-and-audit#request-logs), tagged **Dry run**, and the audit event of the real change that follows it has `dry_run_preceded: true`.

```bash
curl https://api.runledger.co/v1/plans \
  -H "Authorization: Bearer $RUNLEDGER_API_KEY" \
  -H "Runledger-Dry-Run: true" \
  -H "Content-Type: application/json" \
  -d '{"key": "support_monthly", "name": "Support, monthly", "billing_mode": "postpaid",
       "currency": "usd", "interval": "month",
       "rates": [{"outcome_definition": "chat_resolution", "unit_amount_decimal": "95"}]}'
```

## Warnings

Values outside the recommended ranges are accepted and come back as `warnings` with a `code`, a `message` and the `path` of the field. For example, a `confirmation_window` under one hour returns `confirmation_window_below_recommended`: the charge confirms almost at once, so only the reversal horizon protects you. Show warnings to the person who owns the decision. The ranges are on the [outcome settlement](/concepts/outcome-settlement#bounds-and-warnings) page.

## Proposals for live

A change to live configuration can be proposed by anyone with `config:propose`, which is what an agent holds in live mode, and decided only by a signed-in Admin. A Collaborator who changes live configuration in the dashboard makes a proposal the same way.

1. `POST /v1/config_proposals` takes a `document`, a `reason` and, optionally, the `if_match` hash it was based on. It returns a `pending` proposal with the diff, the warnings, and a `review_url` that opens the diff in the dashboard.
2. An Admin reviews it in the dashboard and approves or rejects it. An Admin can approve any proposal, including one for notification or routing rules. The approver and any note are recorded, and the audit events the change creates carry the approver and the time as `approved_by`. `POST /v1/config_proposals/{id}/approve` and `/reject` work only for a signed-in Admin: a key, including an agent key, gets `403`.
3. A proposal that nobody decides expires after 7 days.

Proposals show on the dashboard's Audit log screen as "Pending approval", and a notification rule on `proposal.pending` can tell the reviewers.

## Notification rules

`POST /v1/notification_rules` tells your team when something needs them. Webhooks still carry events to your systems; this is for the people on your team.

| Event                                 | Fires when                                                                                                                                                  | Settings                                                                                        |
| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `dispute.decision_due`                | A dispute's deadline is `lead_time` away                                                                                                                    | `lead_time`, default `PT24H`                                                                    |
| `reconciliation.breaks_found`         | The ledger and a provider disagree                                                                                                                          |                                                                                                 |
| `proposal.pending`                    | A config proposal needs approval                                                                                                                            |                                                                                                 |
| `topup_chargeback.threshold_exceeded` | The top-up chargeback rate or the credits at risk pass a threshold                                                                                          | `threshold.rate`, optionally `threshold.credits_at_risk`, and optionally one `payment_provider` |
| `margin_guardrail.breached`           | Work falls below a [margin guardrail's](/guides/price-rules-and-guardrails#margin-guardrails) floor                                                         |                                                                                                 |
| `catalog.price_changed`               | Runledger publishes a new [catalog price](/guides/price-changes-and-simulation#when-a-provider-changes-its-prices) for a model one of your cost models uses |                                                                                                 |
| `auto_top_up.disabled`                | A customer's [auto top-up](/guides/auto-top-up-and-alerts) stopped after three failed payments                                                              |                                                                                                 |

A rule's `channel` says how it is delivered. The set of channels is open, and destinations can be added. The built-in channels are:

| `channel` | Destination                                                     | Setting                               |
| --------- | --------------------------------------------------------------- | ------------------------------------- |
| `email`   | Up to 20 addresses                                              | `recipients`                          |
| `webhook` | One of your [webhook endpoints](/api-reference/handle-webhooks) | `webhook_endpoint`, the endpoint's ID |

Handle a channel you don't recognize by its name rather than failing on it.

A rule fires once per event: two rules on one event send two notifications, one rule never sends twice. The webhook channel delivers the event itself to the rule's endpoint, whether or not the endpoint subscribes to that type, so your handler sees the same body a subscription would; an event a rule's lead time or threshold made says so in `reason.notification_rule` and goes to that endpoint alone. A `dispute.decision_due` rule covers the disputes opened after it exists, and a dispute decided before its lead time sends nothing. `topup_chargeback.threshold_exceeded` is judged when a chargeback lands, over the trailing thirty days, and fires when the figure passes the line, not again while it stays above it.

Notification rules are for your team. Alerts about a customer's own balance or limits, such as `balance.low` and the usage and spend-limit thresholds, are [webhook events](/guides/auto-top-up-and-alerts#alerts) sent to your endpoints. Runledger never emails your end customers; you decide how to reach them.

A threshold has no default. `topup_chargeback.threshold_exceeded` fires only at the `threshold` you set.

## Outcome clock presets

`GET /v1/outcome_definition_presets` returns the recommended clocks for common outcomes, with their bounds and rationale, so the dashboard, the API and the MCP server all show the same values. [Outcome settlement](/concepts/outcome-settlement#presets-not-defaults) explains them.

## See also

* [Access, actors and audit](/api-reference/access-and-audit) covers who may apply or approve configuration.
* [Recipes](/guides/recipes) has complete documents for common billing models.
* [Sandbox and test clocks](/api-reference/test-in-the-sandbox) covers testing what you applied.