> ## Documentation Index
> Fetch the complete documentation index at: https://docs.custral.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Billing

> Read a customer's subscriptions, invoices and quotes live from Stripe, open their billing page, and set billing up.

These endpoints read billing back: what a customer pays, what they owe, and
what is coming. Invoices and subscriptions are read **live** from the
workspace's Stripe account, so they match what Stripe shows. The reads need the
`billing:read` scope.

The rest open a customer's billing page (`billing:portal`) and set billing up
(`billing:admin`). No endpoint here charges a card. Money moves only when a
customer acts on their billing page or a quote.

`customer` is **your app's own customer id** (the customer key picked in
**Settings → Payments**) or a Custral record id (`rec_…`). See
[Naming the customer](/dev/api-reference/quotes#naming-the-customer).

Every amount is in **minor** units (cents), the way Stripe returns them.
Totals are per currency and never summed across currencies.

| Endpoint | Scope | Returns |
| - | - | - |
| `GET /v1/customers/{customer}/billing` | `billing:read` | One customer's quotes, subscriptions, invoices and upcoming instalments |
| `GET /v1/invoices` | `billing:read` | One customer's invoices, or the workspace's for a period |
| `GET /v1/subscriptions` | `billing:read` | One customer's subscriptions |
| `GET /v1/revenue/metrics` | `billing:read` | The workspace's MRR, ARR, retention and billed revenue |
| `POST /v1/customers/{customer}/billing-sessions` | `billing:portal` | A 15-minute billing page for one customer |
| `POST /v1/customers/{customer}/subscriptions/cancel` | `billing:portal` | Cancels at the period end |
| `POST /v1/customers/{customer}/subscriptions/resume` | `billing:portal` | Keeps a plan that was set to end |
| `GET /v1/billing/setup` | `billing:admin` | Whether billing works yet, and what's missing |
| `POST /v1/billing/setup/connect-link` | `billing:admin` | Where an admin connects Stripe |
| `GET` / `PATCH /v1/billing/settings` | `billing:admin` | Payment settings, by object and property key |
| `GET` / `PATCH /v1/billing/page` | `billing:admin` | The billing page's sections and actions |

If the workspace has not connected Stripe, each response has
`stripeConnected: false` and empty invoice and subscription lists, rather than
an error.

## A customer's billing

`GET /v1/customers/{customer}/billing`

```ts theme={null}
const billing = await custral.billing.get({customer: "acct_123"});
```

<ResponseExample>
  ```json A customer's billing theme={null}
  {
    "stripeConnected": true,
    "quotes": [
      {"id": "quo_3Ab9xK2mQ7", "number": "Q-0007", "status": "accepted", "...": "…"}
    ],
    "subscriptions": [
      {
        "name": "Pro plan",
        "status": "active",
        "interval": "month",
        "mrr": 11880,
        "currency": "usd",
        "discountLabel": "20% off · 3 months",
        "collection": "autopay",
        "paymentMethod": "Visa ···· 4242",
        "paymentTermsDays": null,
        "trialEnd": null,
        "currentPeriodEnd": "2026-10-30T00:00:00.000Z",
        "cancelAtPeriodEnd": false
      }
    ],
    "invoices": [
      {
        "number": "INV-0042",
        "status": "paid",
        "overdue": false,
        "daysOverdue": null,
        "total": 11880,
        "amountDue": 0,
        "amountPaid": 11880,
        "currency": "usd",
        "customerName": "Kestrel Logistics",
        "dueDate": null,
        "paidAt": "2026-09-30T10:05:00.000Z",
        "createdAt": "2026-09-30T10:04:58.000Z",
        "hostedUrl": "https://invoice.stripe.com/i/example"
      }
    ],
    "upcomingInstalments": [
      {"quoteNumber": "Q-0007", "label": "Second half", "due": "2026-12-01", "amount": 250000, "currency": "usd"}
    ]
  }
  ```
</ResponseExample>

`quotes` are [Quote objects](/dev/api-reference/quotes). `upcomingInstalments`
are the dated parts of accepted quotes' one-time totals that have not been
billed yet.

## Invoices

`GET /v1/invoices`

<ParamField query="customer" type="string">
  One customer's invoices. Without it, the whole workspace's for `period`.
</ParamField>

<ParamField query="period" type="string">
  `30d`, `90d`, `365d` or `all`. Defaults to `90d`. Ignored with `customer`.
</ParamField>

<ParamField query="overdue" type="string">
  `true` for only invoices past their due date. The SDK takes a boolean.
</ParamField>

**With `customer`** the response is `{stripeConnected, invoices}`:

```ts theme={null}
const {invoices} = await custral.billing.invoices({customer: "acct_123", overdue: true});
```

**Without it** you get the workspace's receivables for the period, with totals
per currency:

```ts theme={null}
const receivables = await custral.billing.invoices({period: "30d"});
```

<ResponseExample>
  ```json Receivables theme={null}
  {
    "stripeConnected": true,
    "period": "30d",
    "truncated": false,
    "usageNotBilled": 0,
    "totals": [{"currency": "usd", "collected": 1250000, "outstanding": 320000, "overdue": 45000}],
    "invoices": [{"number": "INV-0042", "status": "open", "overdue": true, "daysOverdue": 6, "...": "…"}]
  }
  ```
</ResponseExample>

| Total | Counted as |
| - | - |
| `collected` | Paid within the period. |
| `outstanding` | Open now, due or not. |
| `overdue` | Open now and past due. |

`truncated` is `true` when the period holds more invoices than one read
returns. Narrow `period` to see them all. With `overdue=true`, `invoices` is
filtered but `totals` still covers the whole period.

`usageNotBilled` counts metered usage events Custral could not send to Stripe
because the customer had no Stripe customer yet, usually usage from before
they accepted a quote. It is a running count, not limited to `period`.

### Invoice attributes

<ResponseField name="number" type="string | null">
  Stripe's invoice number. `null` on a draft.
</ResponseField>

<ResponseField name="status" type="string">
  `draft`, `open`, `paid`, `void` or `uncollectible`.
</ResponseField>

<ResponseField name="overdue" type="boolean">
  Open and past its due date. `daysOverdue` says by how much.
</ResponseField>

<ResponseField name="total" type="integer">
  Minor units, like `amountDue` and `amountPaid`.
</ResponseField>

<ResponseField name="hostedUrl" type="string | null">
  Stripe's hosted page where the customer pays. Send it to a customer who owes.
</ResponseField>

## Subscriptions

`GET /v1/subscriptions`

<ParamField query="customer" type="string" required>
  Your app's customer id, or a record id.
</ParamField>

```ts theme={null}
const {subscriptions} = await custral.billing.subscriptions({customer: "acct_123"});
const isPro = subscriptions.some((s) => s.name === "Pro plan" && s.status === "active");
```

### Subscription attributes

<ResponseField name="status" type="string">
  Stripe's status: `active`, `trialing`, `past_due`, `canceled` and so on.
</ResponseField>

<ResponseField name="mrr" type="integer">
  Monthly recurring revenue after today's discounts, in minor units.
</ResponseField>

<ResponseField name="collection" type="string">
  `autopay` (charges the saved method) or `invoice` (sends a pay link).
</ResponseField>

<ResponseField name="paymentMethod" type="string | null">
  Such as `Visa ···· 4242`. `null` for invoice collection or when none is saved.
</ResponseField>

<ResponseField name="cancelAtPeriodEnd" type="boolean">
  Cancels when `currentPeriodEnd` is reached.
</ResponseField>

`name`, `interval`, `currency`, `discountLabel`, `paymentTermsDays`,
`trialEnd` and `currentPeriodEnd` are what they say.

To be **told** when a subscription starts, changes or ends, rather than
polling, listen for `subscription.started`, `subscription.updated`,
`subscription.canceled`, `payment.succeeded`, `payment.failed` and
`invoice.overdue` [webhooks](/dev/webhooks/overview).

## Revenue metrics

`GET /v1/revenue/metrics`

The workspace's recurring revenue, read live from Stripe. The key's creator must
be a workspace **admin**, as in the app.

```ts theme={null}
const {currencies, topCustomers} = await custral.billing.metrics();
const usd = currencies.find((c) => c.currency === "usd");
console.log(usd?.mrr, usd?.nrr); // 1250000, 1.08
```

<ResponseExample>
  ```json Revenue metrics theme={null}
  {
    "stripeConnected": true,
    "truncated": false,
    "currencies": [
      {
        "currency": "usd",
        "mrr": 1250000,
        "arr": 15000000,
        "activeSubscriptions": 42,
        "nrr": 1.08,
        "billed": [{"month": "2025-10", "amount": 980000}, {"month": "2026-09", "amount": 1310000}]
      }
    ],
    "topCustomers": [{"name": "Northwind", "mrr": 180000, "currency": "usd"}]
  }
  ```
</ResponseExample>

<ResponseField name="currencies" type="object[]">
  One entry per currency, the largest MRR first. `mrr` is monthly recurring
  revenue after today's discounts (metered usage is not included); `arr` is
  `mrr × 12`; `activeSubscriptions` counts the subscriptions that bill.
</ResponseField>

<ResponseField name="currencies[].nrr" type="number | null">
  Net revenue retention, trailing 12 months: what the customers who paid a year
  before the last complete month paid in that month, divided by what they paid
  then. `1.08` is 108%. Customers who joined since do not count; one who left
  counts as zero. `null` when nobody paid a year ago.
</ResponseField>

<ResponseField name="currencies[].billed" type="object[]">
  Invoiced per month (`YYYY-MM`) for the last 12 months, the current month last.
  Drafts and voided invoices are left out.
</ResponseField>

<ResponseField name="topCustomers" type="object[]">
  Up to five customers with the most MRR, by name.
</ResponseField>

`truncated` is `true` when Stripe holds more subscriptions or invoices than one
read covers, so the figures undercount.

## Billing sessions

`POST /v1/customers/{customer}/billing-sessions`

Opens one customer's billing page for **15 minutes**. Call it from your server
each time the customer opens Billing, then redirect to `url`, or pass `token` to
`CustralBilling.mount` ([`@custral/js`](/dev/sdks/browser)). The key's creator
needs Edit on the customer.

```ts theme={null}
const session = await custral.billing.session({
  customer: "acct_123",
  returnUrl: "https://app.example.com/settings",
});
```

<ParamField body="returnUrl" type="string">
  Where the page's Back link and Stripe's pages return. `https` only (`http`
  for `localhost`), on a host in `allowedReturnDomains`.
</ParamField>

<ResponseExample>
  ```json A billing session theme={null}
  {
    "url": "https://app.custral.com/billing-page/bps_…",
    "token": "bps_…",
    "expiresAt": "2026-10-08T15:45:00.000Z"
  }
  ```
</ResponseExample>

The token is a bearer credential for that one customer's page. Send it to their
browser only, and open a fresh session rather than storing one.

## Cancel and resume

`POST /v1/customers/{customer}/subscriptions/cancel`

Sets the customer's plan to end at the period end. Nothing is charged or
refunded. Unlike the billing page, this does not check the page's settings:
your server is acting, not the customer.

```ts theme={null}
const {endsAt} = await custral.billing.cancel({
  customer: "acct_123",
  reason: "too_expensive",
  comment: "Moving to the annual budget next quarter",
});
```

<ParamField body="reason" type="string">
  One of `too_expensive`, `missing_features`, `switched_service`, `unused`,
  `other`. Recorded in Stripe's cancellation details.
</ParamField>

<ParamField body="comment" type="string">
  Free text, recorded beside the reason.
</ParamField>

`POST /v1/customers/{customer}/subscriptions/resume` keeps a plan that was set
to end, and returns `{resumed: true}`.

## Billing setup

`GET /v1/billing/setup` says whether billing from your product works yet. It
needs `billing:admin`, and the key's creator must be a workspace admin, as in
**Settings → Payments**.

```ts theme={null}
const setup = await custral.billing.setup();
if (!setup.ready) console.log(setup.missing);
```

<ResponseExample>
  ```json Billing setup theme={null}
  {
    "stripe": {"connected": true, "accountName": "Acme Inc", "mode": "test"},
    "customersObject": {"key": "account", "name": "Accounts"},
    "plans": 3,
    "ready": true,
    "missing": ["Add your app's domain to allowedReturnDomains, so Back and Stripe return there."],
    "settingsUrl": "https://app.custral.com/settings/payments"
  }
  ```
</ResponseExample>

`stripe.mode` is `test` or `live`, read from the connected account. `missing`
lists what is left, in the order to do it.

Stripe can't be connected through the API, because Stripe asks a person for
consent. `POST /v1/billing/setup/connect-link` returns the settings page an
admin opens to press **Connect Stripe**.

## Payment settings

`GET /v1/billing/settings` and `PATCH /v1/billing/settings` read and change
what **Settings → Payments** holds. Objects and properties are named by their
**key** (`account`, `account_id`), never by id. Omitted fields stay as they are,
and `""` clears one.

```ts theme={null}
await custral.billing.settings.update({
  customerKey: "account_id",
  billingContact: "billing_email",
  allowedReturnDomains: ["app.example.com"],
  paymentTermsDays: 30,
});
```

<ParamField body="customerKey" type="string">
  The property on the customers object holding your app's own customer id.
</ParamField>

<ParamField body="billingContact" type="string">
  The email property on the customers object naming who gets invoices.
</ParamField>

<ParamField body="quoteFrom" type="object[]">
  Other objects a quote can start from, each `{object, link}`, where `link` is
  its relation to the customers object (a deal that bills its account).
</ParamField>

<ParamField body="payerParent" type="string">
  The relation on the customers object to a parent that sees its billing.
</ParamField>

<ParamField body="billingFields" type="object">
  Which billing fields Custral keeps on which object, by object key:
  `{"account": ["billing_status", "mrr"]}`.
</ParamField>

<ParamField body="paymentMethods" type="string[]">
  The ways to pay offered on Stripe's pages.
</ParamField>

<ParamField body="defaultCollectionMethod" type="string">
  `autopay` or `invoice`, for a quote that names neither.
</ParamField>

<ParamField body="paymentTermsDays" type="integer">
  Days to pay an invoice, 0 to 365.
</ParamField>

<ParamField body="allowedReturnDomains" type="string[]">
  Hosts a `returnUrl` may point at. Anything else is refused with
  `return_url_not_allowed`.
</ParamField>

<ParamField body="stripeMatchMetadataKey" type="string">
  The Stripe customer metadata key that holds your customer id, for matching
  existing Stripe customers to records.
</ParamField>

The customers object itself is picked in **Settings → Payments**, because it
carries a field mapping. The response names it as `customersObject`.

## Billing page settings

`GET /v1/billing/page` and `PATCH /v1/billing/page` read and change what a
customer's billing page shows. Omitted fields stay as they are.

```ts theme={null}
await custral.billing.page.update({
  sections: {usage: false},
  actions: {cancel: true},
  downgradeTiming: "period_end",
  askCancelReason: true,
});
```

<ParamField body="sections" type="object">
  `paymentMethod`, `usage`, `invoices`, `billingContact`: each `true` to show it.
</ParamField>

<ParamField body="actions" type="object">
  `updateCard`, `changePlan`, `cancel`: each `true` to offer it.
</ParamField>

<ParamField body="downgradeTiming" type="string">
  `period_end` (the default) or `immediately`. An upgrade is always charged now.
</ParamField>

<ParamField body="askCancelReason" type="boolean">
  Ask why when a customer cancels.
</ParamField>

The plans a customer can switch between are [Plans](/dev/api-reference/plans).

## Errors

| Code | When |
| - | - |
| `insufficient_scope` | The key lacks the scope the endpoint names. |
| `insufficient_permissions` | Revenue metrics only: the key's creator is not a workspace admin. |
| `payments_not_set_up` | The workspace has not set up payments in Settings → Payments. |
| `customer_not_found` | `customer` matches no customer. |
| `insufficient_permissions` | The member who created the key cannot see that customer. |
| `insufficient_permissions` | Setup, settings and page only: the key's creator is not a workspace admin. |
| `return_url_not_allowed` | A session's `returnUrl` is not on a domain in `allowedReturnDomains`. |
| `return_url_invalid` | A `returnUrl` that is not `https` (or `http` on `localhost`). |
| `record_not_a_customer` | A session for a record outside the customers object. |
| `plan_no_subscription` | Cancel or resume, for a customer with no subscription. |

See [Errors](/dev/errors/overview) for the HTTP status each code maps to.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.