# Fynex Billing API — integration reference

Base URL: `https://api.fynex.ai/billing-api/v1` in production,
`https://staging-api.fynex.ai/billing-api/v1` for sandbox work. The examples
below use `$FYNEX_API_BASE` for whichever of the two you are on, as the rest of
these guides do. Your key decides which data you see: a `sk_test_…` key is test
mode wherever you send it, a `sk_live_…` key is live. This REST API is the only
supported external billing surface; internal GraphQL is never part of the
contract.

## Call convention

Send `Authorization: Bearer sk_test_…` or `Authorization: Bearer sk_live_…`
on every operation. Publishable keys, dashboard sessions, cookies, legacy
tokens and query-string credentials are rejected. The secret key defines the
seller tenant; another seller's id is indistinguishable from a missing id
(`404`). All responses include `X-Request-Id`; provide it to support. Clients
must ignore unknown response fields and tolerate unknown enum values.

Amounts are integer minor units with the resource's ISO 4217 `currency`.
Quantities, rates and percentages are decimal strings. Times are RFC 3339 UTC;
date-only invoice filters mean UTC midnight. Do not use floating-point money.

Lists use keyset paging: request `limit` (1–100, default 20), then pass
`nextCursor` back as `cursor` while `hasMore` is true — the same loop on every
list. Contracts walk forward by ascending id; every other list is newest
first. The original directional names (`beforeId`/`nextBeforeId`,
`afterId`/`nextAfterId`) still work and are still returned.

| Status | Meaning | Retry |
| --- | --- | --- |
| `400` | Invalid id, filter, paging or date parameter | Fix request |
| `401` / `403` | Invalid key / inactive seller | Fix credentials or account |
| `404` | Missing or foreign resource | No |
| `422` | Valid request cannot apply in current state | Wait for state change |
| `429` | Seller budget exhausted | Wait `Retry-After` |
| `5xx` | Fynex fault | Retry with exponential backoff and jitter |

Error bodies are `{"error":"message"}`. Rate-limit headers are advisory:
read `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `Retry-After` instead
of hard-coding a quota.

## Operations

| Method and path | Use |
| --- | --- |
| `GET /contracts` | Find contracts (`cursor`, `limit`; walks forward). |
| `GET /contracts/{contractId}/usage` | Current open-period metric usage. Empty metrics is valid. |
| `GET /contracts/{contractId}/credits` | Complete balances and paged immutable ledger. |
| `GET /credits` | Seller-wide credit balances. May return `501` when credits are disabled. |
| `GET /invoices` | Newest-first invoices; filter by contract, customer, origin, status, type, correction, and issue range. |
| `GET /invoices/{invoiceId}` | Invoice plus immutable lines and frozen buyer identity. |
| `GET /invoices/{invoiceId}/pdf` | Rendered legal document. |
| `POST /invoices/{invoiceId}/send` | Create or return the invoice's hosted collection link. |
| `GET /subscriptions` / `GET /subscriptions/{subscriptionId}` | Recurring billing state and price. |
| `POST /prices/evaluate` | What a pricing configuration charges at given quantities — the billing engine, pure. |

The generated [OpenAPI document](./openapi.json) is the typed source of truth
for every parameter and response field. The interactive version is
[`/billing-api/v1/docs`](./docs).

## Invoice collection

`POST /invoices/{invoiceId}/send` takes no idempotency header: the invoice id is
itself the collection anchor, so a transport retry — and any concurrent call —
converges on one payment link and does not send another invitation. The action is unavailable (`422`) for a
bank-transfer invoice, draft/paid/voided/written-off invoice, or one with no
collectible balance.

Invoices and credit-ledger rows are immutable. Corrections are separate credit
notes; never expect a historical invoice to be edited or deleted. To determine
whether an invoice is settled, use `status` and `paidVia`, not
`collectibleMinor` or `creditSettledMinor`.

Billing emits signed webhooks on the shared notify pipe: `InvoiceIssued`,
`InvoiceSent`, `InvoicePaid`, `InvoiceOverdue`, `InvoiceVoided`,
`InvoiceWrittenOff`, `CreditApplied`, `CreditDepleted`, and the ten
`BillingSubscription*` lifecycle events — staged in the same transaction as
the state change, delivered with `X-Fynex-Signature` (HMAC-SHA256 of the raw
body) and `X-Fynex-Timestamp`, and retried until 2xx. The body is an envelope
(`eventId`, `eventType`, `sellerAccountUuid`, `occurredAt`) with the event's
fields under `payload`; every event is typed in the OpenAPI document's
`webhooks` block. Polling `GET /invoices` with a bounded issue-date range
remains the right tool for reconciliation.

## Metered usage

This API reads the meter; the write half is `/billing-api/v1/usage`, same
secret key (it also answers on the older `/usage/v1` prefix). `POST
/billing-api/v1/usage/metrics` registers a meter (`name`,
`unit`, `aggregation`: sum/max/min/last/count/count_unique — the last
requires `uniqueKey`, the metadata key counted distinct, capped at 1000
values per period). `PUT
/billing-api/v1/usage/contracts/{contractId}/metrics/{metricName}/price` sets its
pricing —
scheme `per_unit`, `graduated`, `volume`, `tiered`, `package` or
`percentage`, with included units,
rollover and commitment floors/caps; rates there are decimal strings in MAJOR
units, unlike every amount this API returns. `POST /billing-api/v1/usage/events` reports one event (`contractId`, `metric`, `quantity` as a decimal string,
`occurredAt`, `idempotencyKey`), `:batch` reports many, `/billing-api/v1/usage/csv` takes a file. A `201` with `"metered": false` means the event was stored but no active
subscription schedule covers `occurredAt` — it will not be billed. There are
no write endpoints for contracts, subscriptions, invoices or credits.

## Compatibility and lifecycle

The `/v1` path is the major-version contract. Additive fields and endpoints
may appear without a version change; removing or renaming a field, changing a
type or meaning, adding a required request value, narrowing an enum, or
changing money semantics requires a new major version and a published
migration notice. Invoice statuses are `draft`, `issued`, `sent`, `paid`,
`overdue`, `voided`, and `written_off`; subscriptions expose their current
status and scheduled cancellation or price change fields.

---

# Fynex Billing API

The Billing API is the public, versioned REST surface for billing data:
invoices, subscriptions, contracts, metered usage, and credit balances. Your
systems read the documents the billing engine issues, raise collection on
them, and write the metering that drives them — see **What v1 does, and what
it does not** below for exactly where that line falls.

All endpoints live under the path prefix:

```
/billing-api/v1
```

The host depends on the environment you were onboarded to — see **Base URL**
in the Quickstart. Throughout these guides examples use `$FYNEX_API_BASE`,
which you set once to your host plus that prefix.

Authenticate every request with your seller secret key (`sk_test_…` or
`sk_live_…`) in the `Authorization` header.

**New here? Read Concepts first** — five objects and the relationships
between them, in two minutes. **Then the Quickstart**, which goes from issuing
a key to listing invoices and downloading a PDF. Then:

| Guide | What it covers |
| --- | --- |
| Concepts | The five objects, how they relate, and the three questions their names do not answer. |
| Quickstart | Your first calls, and what each failure status means. |
| Authentication | Key format, environments, rotation, failure contract. |
| Errors | Status contract, rate limits, what is safe to retry. |
| Sandbox & testing | Hosts, keys, test cards, how to exercise date-driven flows without waiting, and the flags a sandbox needs on. |
| Pagination & Amounts | The keyset paging loop; minor units and decimal strings. |
| Endpoint reference | Every operation in detail: parameters, response fields, failure modes, worked examples. |
| Workflows | Collecting on an invoice, polling for settlement, reconciling a period, watching usage and credit. |
| Usage ingestion | The write half of metered billing: metrics, pricing schemes, events, inbound webhooks, and the price preview. |
| Webhooks | The billing event catalog: invoice lifecycle and credit events, signing, retries. |
| Code examples | Paging and retry loops in Python, Node, Go and shell; generating a typed client. |

## What v1 does, and what it does not

The line runs between **documents** and **metering**, not between reads and
writes.

**Every object this API lists can also be created through it.** The line
that remains is between *creating* a document and *editing* one: contracts
are amended by appending a version, invoices are corrected by issuing a credit
note, and neither is ever changed in place.

**Six surfaces are writable.**

- **Contracts.** `POST /contracts` creates one in `draft` for a customer you
  own (with a required `Idempotency-Key`), and
  `POST /contracts/{contractId}/amendments` appends a version — a status move,
  new dates, a replaced component set — guarded by the `expectedBaseVersion`
  you last read. Closing a contract does not cancel its subscriptions: cancel
  those first — see **Contracts**.
- **Invoices.** `POST /invoices` composes, numbers and (by default) sends a
  document in one call, with a required `Idempotency-Key`; it is immutable
  from the moment it is issued — see **Invoices**. `POST /invoices/{invoiceId}/send`
  raises or re-raises the collection link.
- **Subscriptions.** `POST /contracts/{contractId}/subscriptions` creates one
  on a contract you own (with a required `Idempotency-Key`), and
  `/subscriptions/{subscriptionId}/cancel`, `/pause`, `/resume`, `/end-trial`
  and `/change-plan` drive its lifecycle — see **Subscriptions**. Marking a
  subscription past-due or recovered is not yours to do: that is the
  collection loop's verdict.
- **Credit top-ups.** `POST /contracts/{contractId}/credits/top-up` grants a
  credit lot on a contract you own, with a required `Idempotency-Key` whose
  body *is* compared — see **Credits**. It is the only way credit enters the
  ledger; the deductions, expiries and reversals that take it out are the
  engine's rows.
- **Customers.** `POST /customers` resolves one of your customers by e-mail,
  creating the record when the address is new — the `sellerCustomerId` every
  contract, credit grant and invoice refers to. No `Idempotency-Key`: the
  address is the key, so the call always answers `200`.
- **Metering**, described in full two paragraphs down.

Read-only does not mean static. On a seller who has them enabled, background
passes issue invoices for due subscription terms and closed usage, and charge
sent invoices against the customer's saved card. So a list can grow and a
document can reach `paid` with no call of yours — see **Sandbox & testing**
for the switches that decide it.

**Metering is fully writable.** Registering a metric, pricing it, sending
events and previewing what a configuration charges are all part of this API —
see the two paragraphs below and the **Usage ingestion** guide. Nothing about
metered billing requires the dashboard.

Issued documents stay immutable either way: corrections are new credit notes,
never edits.

**Billing emits webhooks** — `InvoiceIssued`/`Sent`/`Paid`/`Overdue`/`Voided`/
`WrittenOff` and `CreditApplied`/`CreditDepleted` — through the same signed
delivery pipe as payment events: one receiver handles both. See the
**Webhooks** guide. Polling `GET /invoices` remains the right tool for
reconciliation.

**Metered usage is reported under `/billing-api/v1/usage`**: register a
metric, price it, then send events singly, in batches or by CSV. That is what
`GET /billing-api/v1/contracts/{contractId}/usage` reads back. The **Usage
ingestion** guide documents it end to end.

Two related surfaces are documented separately and are **not** duplicated
here:

- **Payments API** (`/payments-api/v1`) — payments, payouts, wallets,
  webhooks, and **top-up invoices**.
- **Payment Links API** (`/api/v1/payment-links`) — standalone payment-link
  management. Billing invoices create their own collection links via
  `POST /billing-api/v1/invoices/{invoiceId}/send`; the resulting hosted
  payment page URL is returned on the invoice itself.

---

# Overview

## Resources

| Resource | What it is |
| --- | --- |
| Contract | The commercial agreement between you and one of your customers. Invoices, subscriptions, usage and credits all hang off a contract. |
| Invoice | An immutable, numbered billing document. Once issued it is never edited — corrections are separate credit-note documents. |
| Subscription | A recurring billing relationship on a contract: cadence, price, trial, pause and cancellation state. |
| Usage | Metered consumption aggregated per contract and billing period, with plan allowances and caps. |
| Credits | Stored-value balances (prepayments, promotional credit) and their append-only ledger. |

## Versioning

The path prefix (`/billing-api/v1`) is the API version. Backwards-compatible
additions (new fields, new endpoints) happen within a version; breaking
changes get a new prefix. Unknown response fields must be ignored by clients.

## Identifiers

Billing resources use numeric `id`s (`int64`), unique per resource type.
Contract, invoice, and subscription ids are safe to store and to use in URLs.
Your tenant identity is derived from the API key — it is never passed as a
parameter, and objects belonging to other sellers are indistinguishable from
missing ones (`404`).

## Immutability

Issued invoices are append-only legal records. There is no update or delete:
a wrong invoice is voided or corrected by a credit note, and both documents
remain visible in listings. Credit ledger entries are likewise append-only.

---

# Concepts

Five objects, and the relationships between them are not obvious from their
names. Read this once and the rest of the reference follows; skip it and the
most likely mistake is inventing a call that cannot exist.

```
                        ┌──────────────┐
                        │   Customer   │  who you bill
                        └──────┬───────┘
                               │ one customer, many contracts
                        ┌──────▼───────┐
                        │   Contract   │  the agreement: currency, term
                        └──────┬───────┘
              ┌────────────────┼────────────────┐
              │                │                │
     ┌────────▼──────┐  ┌──────▼──────┐  ┌──────▼──────┐
     │ Subscription  │  │    Usage    │  │   Credits   │
     │ recurring fee │  │ metered qty │  │ stored value│
     └────────┬──────┘  └──────┬──────┘  └──────┬──────┘
              │                │                │
              └───────┬────────┘                │ drawn down at issue
                      │ billed by a run          │
               ┌──────▼───────┐                  │
               │   Invoice    │◄─────────────────┘
               │  immutable   │
               └──────────────┘
```

**Contract is the anchor.** Everything else hangs off one. A subscription, a
usage meter, a credit balance and an invoice all name a contract, and the
contract carries the currency they must all agree on.

**Invoice is a document, not a record you edit.** Once issued it has a number
and is immutable. A correction is a new document — a credit note — and both
stay visible in every listing. There is no `PATCH` and no `DELETE`, which
means an agent cannot be told to "fix" an invoice: the only correct move is
to issue a correcting one.

## Three questions the object names do not answer

### Can an invoice exist without a contract?

**No.** Every invoice names a contract, and issuing one without it is refused
before anything is written.

**But you do not have to create the contract first.** An ad-hoc invoice — a
one-off with `origin: adhoc` — provisions what it needs from the bill-to you
type: the customer is found or created by email, and a live contract in the
same currency is reused if one exists, created if not. So a one-off stays one
call. Send the same recipient a second one-off and it lands on the same
customer and the same contract rather than minting duplicates.

This provisioning is deliberately **only** for ad-hoc documents. A usage or
recurring invoice with no contract is a caller mistake, and inventing a
contract for it would detach that revenue from the agreement it belongs to.

### Does a subscription generate invoices automatically?

**No — not by itself.** Two different mechanisms are easy to confuse:

- The **lifecycle pass** advances a subscription's *state* on its dates: a
  trial activates, a cancellation takes effect after its notice period, a
  fixed end expires it, and a term that has rolled over gets the term now in
  force. It issues no documents at all.
- The **billing run** raises the money. It bills each revenue model on a
  contract — recurring, then usage, then project work, then any one-time line
  — each through its own engine, each producing its own document so the
  invoice's `origin` stays truthful.

So a subscription's period turning over does not, on its own, produce an
invoice. Something has to run the billing — and on a seller who has the
issuance passes switched on (`billing.recurring.invoice_pass`,
`billing.usage.invoice_pass`), that something is a schedule, not a person.
Both default to off. Write your integration so a document appearing without
you is normal, not an anomaly.

**Double billing is prevented by claims, not by memory.** A subscription
period lands on a unique `(subscription, period_start)` row, usage lines and
project work flip guarded status columns, and every claim's invoice carries
its id. Re-running a billing run finishes only what is missing; an ambiguous
commit is repaired by looking the claim up, never by issuing again. A model
that fails does not roll back a sibling's document — a numbered invoice
cannot be un-issued — and the run reports per model what happened.

### Do credits apply before or after tax?

**After.** Credit is a payment method, not a discount. It draws down the
invoice's grand total — the amount *including* tax — and never reduces the
taxable base. The tax the document reports is the tax on the full price,
whatever the customer's balance was.

Two consequences worth knowing before you model this:

- A credit-funded invoice is neither *expected* nor *received* cash. It is its
  own thing, and reporting treats it that way.
- **An invoice collected by bank transfer draws no credit at all.** The buyer
  was told to wire the grand total; shrinking the collectible underneath that
  instruction would make every full-face wire arrive as an overpayment. The
  balance stays on the ledger for the next link-collected document.

## Three words that are one letter of confusion apart

The platform has three roles, and two of their field names differ by a single
word while meaning entirely different things:

The direction of the money is what separates them:

```
   Customer  ──── pays ────►   Seller   ──── pays ────►   Payee
 sellerCustomerId            sellerAccountId             payeeId
 your buyer                  YOU, the key holder         who you disburse to
 Billing API                 both APIs                   Payments API
```

| Word | Who | Lives on |
| --- | --- | --- |
| **Seller** (`sellerAccountId`) | **You** — the account holder the API key belongs to. Every object in both APIs is scoped to one. | Both APIs |
| **Customer** (`sellerCustomerId`) | Who **pays you** — the party your invoices are addressed to. | This API |
| **Payee** (`payeeId`) | Who **you pay** — a counterparty receiving money through split payments and payouts. | Payments API |

`sellerAccountId` and `sellerCustomerId` look like siblings and are not: the
first is your own identity, the second is your customer's. A marketplace
operator is all three sentences at once — they **are** a Fynex seller, they
**have** customers who pay invoices, and they **have** payees who receive
splits and payouts.

If you think in the words *vendor*, *merchant* or *supplier*: the merchant
running the account is the **seller**; a vendor or supplier you disburse money
to is a **payee**; the buyer you bill is a **customer**. The settlement-trail
endpoint (`GET /invoices/{invoiceId}/settlement`) is where the two APIs meet:
a customer's payment on this side becomes a payee's payout on the other.

## Where money and quantities live

| Thing | On the wire |
| --- | --- |
| An amount of money | Integer **minor units**, field ends `Minor` — `4999` is €49.99 |
| A rate or percentage | Integer **basis points**, field ends `Bps` — `275` is 2.75% |
| A metered quantity | Decimal **string** — `"1250.5"` |
| A per-unit rate | Decimal **string** in MAJOR units — `"0.004"`, the one deliberate exception, because a rate is routinely finer than a minor unit |

Everything on a contract shares that contract's currency. There is no
conversion anywhere in this API: a mismatch is refused rather than converted.

---

# Quickstart

This walks from a fresh API key to a working integration. Every example is
copy-pasteable; replace the key and ids with your own.

## 1. Get a key

In the Fynex dashboard, open **Integration** in the left-hand menu and issue a
**secret** key from the API keys card.

- `sk_test_…` — authenticates while your account is in demo mode.
- `sk_live_…` — authenticates once your account is live.

An account is in exactly one of those modes, so exactly one of the two key
environments works at any time.

Keys are stored only as hashes, so a key is shown once, at creation. If you
lose it, regenerate — there is no recovery. Keep keys server-side: they carry
full read access to your billing data.

## 2. Set your base URL

Every endpoint sits under the path `/billing-api/v1`. The host depends on the
environment you were onboarded to, so the examples below use a variable:

```bash
export FYNEX_API_BASE="https://staging-api.fynex.ai/billing-api/v1"
export FYNEX_API_KEY="sk_test_4f6f..."
```

| Environment | Host |
| --- | --- |
| Staging | `https://staging-api.fynex.ai` |
| Production | Confirm your production host with Fynex before going live — it is assigned per deployment, and a `sk_live_` key is only accepted by the environment that issued it. |

You are reading these docs on the same host the API runs on, so
`/billing-api/v1/openapi.json` and `/billing-api/v1/docs` are always
reachable relative to wherever this page is served.

## 3. Make your first call

`GET /contracts` is the safest first request — it needs no ids and tells you
whether your key, environment, and network path all work:

```bash
curl -s $FYNEX_API_BASE/contracts \
  -H "Authorization: Bearer $FYNEX_API_KEY"
```

```json
{
  "contracts": [
    {
      "contractId": 42,
      "contractNumber": "UK2607AA",
      "version": 1,
      "sellerCustomerId": 7,
      "currency": "EUR",
      "status": "active",
      "startDate": "2026-01-01",
      "customerName": "Ada Lovelace",
      "customerCompanyName": "Harbour Group BV"
    }
  ]
}
```

`contractId` is the key to every per-contract endpoint that follows.

If instead you get:

- `401` — the header is missing or the key is wrong. The format is
  `Authorization: Bearer sk_live_…`; a bare key without `Bearer ` fails.
- `403` — the key is valid but the seller account is not active. Check the
  account status in the dashboard.
- an empty `contracts` array — the key works, but this account has no
  contracts yet.

## 4. List invoices

```bash
curl -s "$FYNEX_API_BASE/invoices?limit=5" \
  -H "Authorization: Bearer $FYNEX_API_KEY"
```

```json
{
  "invoices": [
    {
      "id": 4180,
      "contractId": 42,
      "sellerCustomerId": 7,
      "origin": "usage",
      "invoiceNumber": "UK2607AA-2608AAB",
      "jurisdiction": "UK",
      "invoiceType": "standard",
      "status": "sent",
      "currency": "EUR",
      "issueDate": "2026-08-01T00:00:00Z",
      "dueDate": "2026-08-15T00:00:00Z",
      "subtotalMinor": 10000,
      "taxTotalMinor": 2000,
      "grandTotalMinor": 12000,
      "creditAppliedMinor": 0,
      "collectibleMinor": 12000,
      "creditSettledMinor": 0,
      "reverseCharge": false,
      "paymentLinkId": 991
    }
  ],
  "hasMore": true,
  "nextCursor": 4180,
  "nextBeforeId": 4180
}
```

Two things to internalize right now, because they cause most integration
bugs:

- **Amounts are integers in minor units.** `12000` in EUR is €120.00. Never
  parse them as floats.
- **`grandTotalMinor` is the document total, `collectibleMinor` is what
  collection asks the customer for.** They differ when stored credit funded
  part of the invoice. Neither drops to zero once the invoice is paid — to
  decide "does this customer still owe us money", read `status`.
- **`creditSettledMinor` is not a payment total.** It counts only stored
  credit drawn down, so a card-paid invoice reports `0` there.

## 5. Get one invoice with its lines

```bash
curl -s $FYNEX_API_BASE/invoices/4180 \
  -H "Authorization: Bearer $FYNEX_API_KEY"
```

```json
{
  "invoice": { "id": 4180, "invoiceNumber": "UK2607AA-2608AAB", "...": "..." },
  "lines": [
    {
      "position": 1,
      "description": "API calls over allowance",
      "quantity": "250",
      "unitPriceMinor": 40,
      "discountMinor": 0,
      "netAmountMinor": 10000,
      "taxCategory": "standard",
      "taxRate": "20",
      "taxAmountMinor": 2000,
      "sourceRef": "usage:api_calls:2026-07"
    }
  ]
}
```

`quantity` and `taxRate` are decimal **strings** — they carry fractional
precision that a JSON number would round. Feed them to a decimal type, not a
float.

## 6. Download the PDF

```bash
curl -s $FYNEX_API_BASE/invoices/4180/pdf \
  -H "Authorization: Bearer $FYNEX_API_KEY" \
  -o invoice-UK2607AA-2608AAB.pdf
```

The response is `application/pdf` bytes with a `Content-Disposition`
filename. It renders from the document's frozen payload, so a PDF fetched
today and one fetched next year are identical.

## Next

- **Workflows** shows the end-to-end recipes: collecting on an invoice,
  reconciling a month, watching usage against limits.
- **Pagination & Amounts** covers the paging loop you will need past 20 rows.
- **Errors** covers the status contract, rate limits, and retry policy.

---

# Authentication

Every Billing API request must carry a seller secret key:

```
Authorization: Bearer sk_live_4f6f...
```

Keys come in two environments, and the environment must match your account's
current mode: a Fynex seller account operates either in demo mode or live
mode, never both at once. While the account is in demo mode only `sk_test_…`
keys authenticate; after go-live only `sk_live_…` keys do, and the old test
key answers `401` from then on. There is no second, parallel dataset behind
the two prefixes — the API always returns your account's own billing records.

Keys are issued and rotated in the Fynex dashboard, on the **Integration**
page, and
are stored server-side only as SHA-256 hashes; a lost key cannot be recovered,
only regenerated.

Only `sk_test_…` and `sk_live_…` secret keys authenticate this public API.
Publishable `pk_…` keys, dashboard sessions, legacy seller tokens, cookies and
query-string credentials are rejected. This is a server-to-server API: never
call it from a browser or mobile app, and rotate or revoke a compromised key
in the dashboard immediately. No Billing-specific token store or session is
created; authorization reuses the existing hashed API-key record and its
active/revoked and environment checks.

Failure contract:

- `401` — the header is missing, malformed, or the key is unknown or revoked.
- `403` — the key is valid but the seller account is not active.

The seller account behind the key is the authoritative tenant: every object
this API returns belongs to that seller, and ids belonging to another tenant
answer `404`. Never embed secret keys in client-side code; calls must
originate from your servers.

---

# Sandbox & testing

Everything here works without touching live money. The goal: your first
successful call within minutes, and a full trial-to-renewal subscription
exercised without waiting a month of wall-clock time.

## Hosts

| Environment | Host |
| --- | --- |
| Staging | `https://staging-api.fynex.ai` |
| Production | Assigned per deployment — confirm yours with Fynex before go-live. |

The environment that matters is your **account's**, not the host's: a Fynex
account operates in demo mode or live mode, and test behaviour follows the
account. Demo accounts exist on both hosts.

Both hosts are also declared in the OpenAPI document itself, in its `servers`
block — production first, the sandbox second, each labelled. A generated client
or an agent that reads only the spec can therefore pick the sandbox rather than
defaulting to the one host it happens to see. Each entry is the bare origin;
the `/billing-api/v1` prefix is already part of every path in the document, so
join a server with a path exactly as written and do not add the prefix twice.

## Keys

**No account yet? Mint a sandbox in one call.** `POST /sandbox/accounts` with
a JSON body creates an anonymous demo seller — wallets, a demo terminal, a
pre-set split rule, three test customers — and returns its `sk_test_` key:

```bash
curl -X POST https://staging-api.fynex.ai/sandbox/accounts \
  -H "Content-Type: application/json" -d '{}'
```

The door is open on the sandbox host (`staging-api.fynex.ai`); a host that has
it switched off answers `503` to this call and shows no `/sandbox` page. The
secret is shown once; the keys are time-limited — `expiresAt` in the
response says exactly when they stop working. An anonymous sandbox has no
dashboard login — it is a key, not a user — and it is a test environment: never
enter real personal or bank details. The `/sandbox` page on the API host has
the full contract.

For your own account, issue a **secret** key in the Fynex dashboard: **Integration** in the
left-hand menu, then the API keys card. While your account is in demo mode the
key is `sk_test_…`; after go-live only `sk_live_…` keys authenticate and the
test key answers `401` from then on. A key is shown once, at creation — store
it server-side and never in a browser.

The same key authenticates every surface in these docs: the Billing API and
usage ingestion under `/billing-api/v1`, and the Payments API.

## Test cards

On a demo account, hosted payment pages (including the links
`POST /invoices/{invoiceId}/send` creates) accept these Visa sandbox numbers:

| Card number | Notes |
| --- | --- |
| `4111 1111 1111 1111` | Universal Visa test card |
| `4530 9100 0001 2345` | Visa |
| `4037 1122 3300 0001` | Visa |

For all of them: any future expiry, any 3-digit CVV, Latin cardholder name.
Paying an invoice's link with one of these drives the document through
`sent → paid` exactly as a live card would, so it is the way to test the
settlement-polling loop end to end.

## Time travel

Subscription flows — trial ending, first charge, renewal — run on billing
dates, and a sandbox that makes you wait 30 real days for a renewal is not a
sandbox. **Each contract can be given a test clock of its own and moved
forward**, with a `sk_test_` key and no help from us:

| Call | What it does |
| --- | --- |
| `GET /contracts/{contractId}/test-clock` | Reads what the billing engines treat as *now* for that one contract. |
| `POST /contracts/{contractId}/test-clock/advance` | Moves it forward to `to`, then runs that contract's subscription lifecycle and recurring invoicing as of it. |

**Test-mode (demo) accounts only.** A live account answers `403` on both: a
live subscription's renewal is real revenue and a real document to a real
customer.

**One contract at a time.** The clock belongs to the contract in the path.
Advancing it never moves a sibling contract — yours or another account's — and
no scheduled pass sees a changed clock. That is why this is safe to publish.

**Forward only, capped at 366 days.** `to` must be after the contract's
current clock. Re-sending the instant the clock already sits at is a **no-op**:
`200` with `advanced: false`, nothing renewed and nothing issued twice, so the
call is safe to retry. A target in the past is `400` — the invoices an advance
mints are numbered and immutable, so a clock cannot be wound back.

### Worked example: a monthly subscription renewing in one call

Create the subscription today (`42` is a contract that already carries at
least one invoice — see the note below):

```bash
curl -s -X POST "$FYNEX_API_BASE/contracts/42/subscriptions" \
  -H "Authorization: Bearer $FYNEX_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"frequency":"monthly","priceMinor":4999,"currency":"EUR","startDate":"2026-01-01"}'
```

Ask where the contract's clock is:

```bash
curl -s "$FYNEX_API_BASE/contracts/42/test-clock" \
  -H "Authorization: Bearer $FYNEX_API_KEY"
# {"contractId":42,"now":"2026-01-01T09:15:00Z","simulated":false}
```

Advance it past the end of the first term — 31 days:

```bash
curl -s -X POST "$FYNEX_API_BASE/contracts/42/test-clock/advance" \
  -H "Authorization: Bearer $FYNEX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"to":"2026-02-01T00:00:00Z"}'
```

```json
{
  "clock": { "contractId": 42, "now": "2026-02-01T00:00:00Z", "simulated": true },
  "advanced": true,
  "lifecycle": { "renewed": 1, "trialsActivated": 0, "cronDisabled": false },
  "invoices": { "issued": 1, "sent": 1, "skipped": 0, "failed": 0, "passDisabled": false }
}
```

The renewal is now visible on the two reads that matter — the subscription's
new term, and the document it earned:

```bash
curl -s "$FYNEX_API_BASE/subscriptions?contractId=42" \
  -H "Authorization: Bearer $FYNEX_API_KEY"
# currentPeriodStart moves to 2026-02-01, currentPeriodEnd to 2026-03-01

curl -s "$FYNEX_API_BASE/invoices?contractId=42" \
  -H "Authorization: Bearer $FYNEX_API_KEY"
# one more invoice, origin "recurring", for the new term
```

`GET /contracts/{contractId}/invoices/upcoming` before the advance, and the
issued document after it, should agree — which is the cheapest end-to-end
check that your contract is configured the way you think it is.

### What it does not advance

The clock moves the **subscription state machine** and the **recurring
invoice**. It does *not*:

- close metered usage periods or mint usage invoices,
- send dunning e-mail for a subscription that went `past_due`,
- run off-session auto-charge on an issued invoice,
- move the issue date: an invoice raised by an advance is dated at REAL time
  (so its number stays in the real month's gapless sequence) while its period
  is the simulated term — read the period, not the date, to check the renewal.

Those stay on real time in their own scheduled passes, and every advance
repeats the list in its `notAdvanced` field. Do not write a test that waits on
one of them after an advance.

### When an advance renews but issues nothing

Two operator switches decide whether the halves above run at all, and the
response says which one stopped:

| Response field | Switch | Symptom |
| --- | --- | --- |
| `lifecycle.cronDisabled` | `billing.subscription.lifecycle_cron` | Nothing renews, no matter how far the clock moves. |
| `invoices.passDisabled` | `billing.recurring.invoice_pass` | The term renews, and no document appears on `GET /invoices`. |

Both default to **off** — see the table below. There is also one data
precondition the clock cannot supply: the recurring lane continues a
contract's existing invoicing pattern, so a contract with **no prior invoice**
is reported in `invoices.skipped` rather than issued. Raise the first document
once with `POST /invoices` (or let a contract you already invoiced carry the
subscription), and every later advance issues by itself.

The staff-operated clock is a different thing and is not this. It shifts the
lifecycle dates of test-mode *payment-link* subscriptions (the recurring
hosted-checkout product), estate-wide, and never touches the billing
subscriptions this reference describes.

## What a fresh sandbox has switched off

Several billing capabilities sit behind operator feature flags that default to
**off**, because each one moves money or issues documents on a schedule. A
sandbox where they are off looks broken — subscriptions never charge, usage
periods never close into invoices, no invoice is ever raised, credits report
`501` — when it is merely unconfigured. If a flow below does nothing, ask Fynex
to confirm the flag before debugging your integration:

| Flag | What it gates |
| --- | --- |
| `billing.subscription.lifecycle_cron` | The pass that ends trials, charges renewals and applies scheduled plan changes. |
| `billing.usage.close_pass` | The pass that closes usage periods and mints usage invoice lines. |
| `billing.recurring.invoice_pass` | The pass that turns due subscription terms into issued invoices. |
| `billing.usage.invoice_pass` | The pass that composes closed usage lines into issued invoices. Separate from the recurring one on purpose — a seller may be ready to auto-bill terms and not metered usage, or the reverse. |
| `billing.proration.invoice_binding` | Whether a subscription's unbilled mid-term proration is put on the invoice its term is billed on — a charge as an extra line, a credit as a discount on the recurring line, any remainder as a non-expiring `proration` credit lot. While off, a mid-term change is still recorded against the contract but never reaches a document. |
| `billing.invoice.auto_charge` | Off-session collection: the loop that charges a customer's saved card for a sent or overdue invoice. Without it a document is only ever paid by someone visiting its link or making a transfer. |
| `payment_links.recurring_billing` | Recurring charging through hosted payment links. |
| `billing.credits` | Stored-value credits; while off, the credit endpoints answer `501` and invoices draw no credit down. |

**Closing a period is not the same as billing it.** The close pass and the
invoice passes are different switches: with only `billing.usage.close_pass` on,
`GET /contracts/{contractId}/usage` moves and periods close, and no invoice
ever appears on `GET /invoices`. That pairing is the single most common reason
a sandbox looks like it has stopped halfway.

## A sandbox session that proves the loop

The target this page is written against is the one in our own onboarding
PRD: **a first transaction under 30 minutes, self-serve** — from a fresh
sandbox key to an invoice on `GET /invoices` without talking to anyone at
Fynex. The steps below are that path; if one of them cannot be done inside
that budget, the gap is ours, not yours.

1. `GET /contracts` — key, host and network path all work.
2. Register a metric and price it (see **Usage ingestion**), send a few
   events, and watch `GET /contracts/{contractId}/usage` move.
3. Create a subscription in the dashboard with a trial that has already
   ended, and let the lifecycle pass run — the renewal invoice appears on
   `GET /invoices`.
4. `POST /invoices/{invoiceId}/send`, pay the link with a test card, and poll
   the invoice to `paid`.

That is the whole billing loop — metering, rating, issuance, collection,
settlement — without a real card or a real month.

---

# Errors

Errors are JSON with a single field:

```json
{"error": "invoice not found"}
```

(Authentication failures from the gateway layer may answer with a plain-text
body; treat any non-2xx as failed regardless of body shape.)

Every response, including `401`, `429`, and server errors, carries
`X-Request-Id: req_<uuid>`. Include this value in a support request; it is the
safe correlation handle for a request, not your API key or customer data.

| Status | Meaning |
| --- | --- |
| `400` | The request is malformed: an unparseable id, an unknown filter value, an invalid parameter. |
| `401` | Missing or invalid API key. |
| `403` | The key is valid but the seller account is not active. |
| `404` | The object does not exist — or belongs to another seller. |
| `422` | The object exists but the action is not applicable to it (for example, sending an invoice that is collected by bank transfer). |
| `429` | Rate limit exceeded. Honour `Retry-After` and the `X-RateLimit-*` headers. |
| `501` | The capability is not enabled for this deployment (for example, credits before stored value is switched on). |
| `500` | Server fault. Safe to retry idempotent (GET) requests with backoff. |

## Rate limiting

Requests are rate-limited per seller, on a budget dedicated to this API —
billing traffic and payments-api traffic do not throttle each other. The
default budget is **1000 requests per hour per seller**, with a burst of 100 so
a single client cannot spend the whole hour in one instant. The number is set
per environment, so read `X-RateLimit-Limit` rather than hard-coding it.

Responses normally carry `X-RateLimit-Limit`, `X-RateLimit-Remaining` and
`X-RateLimit-Reset` (whole seconds until the budget refills), plus the current
standards-track structured fields `RateLimit-Policy` (`"seller";q=1000;qu="requests";w=3600`)
and `RateLimit` (`"seller";r=940;t=2100`). Both spellings carry the same numbers.
Note these are not the `RateLimit-Limit`/`RateLimit-Remaining`/`RateLimit-Reset`
triple from an earlier revision of that draft, which is not sent. Read them rather than
assuming a number: the budget is set per environment, and `X-RateLimit-Limit`
is the authoritative value for yours. Treat them as advisory — during a limiter outage requests are allowed through without the
headers, so a client that requires them will break exactly when the platform
is already degraded.

Three write operations carry an **extra** per-seller quota on top of that
budget, each in its own bucket, because each accepted call spends something
that cannot be handed back:

| Operation | Default quota | Why |
| --- | --- | --- |
| `POST /contracts` | 20 requests per rolling 24 hours (refusals and replays count) | Bounds how fast one key opens contracts; a create allocates a number from a series shared across sellers. |
| `POST /contracts/{contractId}/amendments` | 200 requests per rolling 24 hours (a stale `expectedBaseVersion` counts) | Every accepted amendment appends a contract version — up to 100 line items — to a history that is append-only and has no delete. |
| `POST /invoices` | 500 requests per rolling 24 hours (refusals and replays count) | Issuing allocates a number from your own gapless series, so a runaway loop burns your month of numbers. |

All three refuse with the same `429`, `Retry-After` and `X-RateLimit-*`
headers as the surface-wide limit.

A rejected request answers `429` with `Retry-After` in seconds. Wait that
long — retrying sooner only deepens the overage.

Bulk work is what actually hits this. Reconciling a month walks pages of up
to 100 invoices and may pull a PDF per document, so a few hundred requests in
one burst is normal. Pace bulk exports (a short sleep between pages costs far
less than being throttled mid-walk), and if a legitimate workload cannot fit
the budget, ask Fynex to raise it rather than working around it with parallel
keys.

### When the write quotas fail closed

Unlike the surface-wide budget, those three operations — `POST /contracts`,
`POST /contracts/{contractId}/amendments` and `POST /invoices` — **fail
closed**. While the limiter itself is unreachable they answer `503` with
`Retry-After` instead of letting an uncounted burst of writes through. Nothing
was created, amended or issued, so the retry is safe: wait the header out and
repeat the call with the **same `Idempotency-Key`**, which is what stops the
retry from opening a second contract or issuing a second document. Amendments
carry no `Idempotency-Key` — resend the same `expectedBaseVersion`, and a
version that did land answers `422` rather than appending a duplicate.

Two things this does *not* mean. It is the limiter FAILING, not the quota
being absent: a deployment that switches a quota off (its request budget set
to `0`) is simply unmetered on that operation, exactly as before, and never
answers `503` for this reason. And the quotas count **requests**, not
successes — a refusal or a replay spends one too, so a client retrying a `400`
in a loop can exhaust its day without ever writing anything.

---

# Pagination

Every list endpoint pages by keyset, with the same two parameters:

- `limit` — page size, 1–100 (default 20).
- `cursor` — where to continue from. Omit it (or pass `0`) for the first page.

Each page reports how to continue:

```json
{
  "invoices": [ ... ],
  "hasMore": true,
  "nextCursor": 4177
}
```

Pass `nextCursor` back as `cursor` until `hasMore` is `false`. That loop is
the same code on every list in this API.

Keyset paging is stable under concurrent inserts: new rows appear on the first
page of a fresh iteration and never shift the pages of an iteration already in
flight.

## Direction is a property of the list, not of the parameter

The lists do not all walk the same way, and that part is deliberate:

| List | Order | A cursor means |
| --- | --- | --- |
| `/invoices`, `/subscriptions` | Newest first | ids **below** the cursor |
| The credit ledger on `/contracts/{contractId}/credits` | Newest first | ids **below** the cursor |
| `/contracts` | Ascending contract id | ids **above** the cursor |

Contracts walk forward because the id is a stable identity to iterate, not a
recency ranking. What used to differ as well was the parameter NAME —
`beforeId` on some lists, `afterId` on others — so anyone who wrote a working
loop for invoices wrote a broken one for contracts. `cursor` and `nextCursor`
are the same on all of them; only the documented order changes.

The credit ledger pages the `entries` array only: the balances in the same
response are always complete. Seller-wide credit balances are one row per
currency and credit type, so that list is genuinely bounded and does not page.

## The original parameter names

`beforeId` / `nextBeforeId` and `afterId` / `nextAfterId` still work,
unchanged, on the lists that had them, and every response still carries the
original field beside `nextCursor` with the same value. Existing integrations
need no change. Sending both `cursor` and the original name with **different**
values is a `400` rather than a silent choice between them.

## Dates

`issuedFrom` and `issuedTo` accept a calendar date (`2026-08-01`) or a full
RFC 3339 timestamp. A bare date is read as **UTC midnight**, and the range is
half-open — `issuedFrom` inclusive, `issuedTo` exclusive — so adjacent months
never double-count a document. If your books close in a non-UTC zone, send the
timestamp form with your offset rather than the bare date.

## Amounts

All monetary amounts are **integers in the currency's minor units**
(`grandTotalMinor: 12050` is €120.50 for a EUR document). Quantities and tax
rates are decimal strings. Never parse amounts as floating point.

---

# Endpoint reference

Every operation, with its exact parameters, response shape and failure modes.
Every one requires `Authorization: Bearer sk_live_…` (or `sk_test_…`); all
amounts are integers in minor units; all list responses page.

Base: `$FYNEX_API_BASE` = `https://<your-host>/billing-api/v1`

## How numbers ride on the wire

One rule, four rows, and the field name tells you which row applies. Read the
suffix before you read the value.

| Value | Type | Field name | Example |
| --- | --- | --- | --- |
| Money amount | Integer, **minor units** | ends `Minor` | `4999` is €49.99 |
| Rate / percentage | Integer, **basis points** | ends `Bps` | `275` is 2.75% |
| Quantity | Decimal **string** | — | `"1250.5"` |
| Tax rate | Decimal **string**, percent | — | `"20.0"` |

Money is never a float and never a decimal string: a `…Minor` field is an
integer count of the currency's smallest unit, paired with the resource's ISO
4217 `currency`. Reading `4999` as euros overstates the amount a hundredfold,
and reading `275` as a percentage does the same to a rate — which is why the
unit is repeated in every field's own description rather than stated once here.

Quantities go the other way and travel as strings on purpose: metered
consumption is routinely finer than an integer, and JSON numbers are IEEE
floats. Per-unit **rates** are the one deliberate exception to minor units —
they are decimal strings in major units, because a rate is often smaller than
one minor unit (`"0.004"`).

The convention is enforced, not merely documented: a `…Minor` or `…Bps` field
that publishes as a float, or whose description never names its unit, fails the
build.

| | Operation | Purpose |
| --- | --- | --- |
| 1 | `GET /contracts` | The entry point — ids for every per-contract read |
| 1a | `POST /contracts` | Create a contract (`Idempotency-Key` required; extra per-seller quota) |
| 1b | `POST /contracts/{contractId}/amendments` | Append a contract version — status, dates, components, counterparty (extra per-seller quota) |
| 2 | `GET /contracts/{contractId}/usage` | Metered consumption this period |
| 3 | `GET /contracts/{contractId}/credits` | One contract's stored value + ledger |
| 4 | `GET /credits` | Stored-value balances across all contracts |
| 4a | `POST /contracts/{contractId}/credits/top-up` | Grant credit on a contract (`Idempotency-Key` required) |
| 5 | `GET /invoices` | Invoice list, filtered and paged |
| 5a | `POST /invoices` | Compose and issue an invoice (`Idempotency-Key` required; extra per-seller quota) |
| 6 | `GET /invoices/{invoiceId}` | One invoice with its lines |
| 7 | `GET /invoices/{invoiceId}/pdf` | The rendered document |
| 8 | `POST /invoices/{invoiceId}/send` | Create the payment link (emails the customer) |
| 9 | `GET /subscriptions` | Subscription list |
| 10 | `GET /subscriptions/{subscriptionId}` | One subscription |
| 10a | `POST /contracts/{contractId}/subscriptions` | Create a subscription (`Idempotency-Key` required) |
| 10b | `POST /subscriptions/{subscriptionId}/cancel` · `/pause` · `/resume` · `/end-trial` · `/change-plan` | Drive a subscription's lifecycle |
| 11 | `POST /usage/events` · `:batch` | Report metered consumption |
| 12 | `GET`/`POST /usage/metrics` · `GET /usage/metrics/{metricName}` | The meter registry |
| 13 | `PUT`/`GET /usage/contracts/{contractId}/metrics/{metricName}/price` | Price a metric on a contract |
| 14 | `POST /prices/evaluate` | Preview what a config charges — the billing engine, pure |
| 15 | `POST /usage/csv` | Bulk intake from a periodic export |
| 18 | `GET /invoices/export` | A period's invoices as one CSV/NDJSON file — month-end, without the paging loop |
| 19 | `GET /invoices/{invoiceId}/settlement` | Which payments and payouts settled this invoice |
| 20 | `POST /customers` | Create or resolve a customer — the `sellerCustomerId` everything else needs |
| 21 | `GET /contracts/{contractId}/invoices/upcoming` | What the contract's next invoice would carry — an estimate, nothing written |
| 16 | `GET /usage/dead-letters` · `{deadLetterId}/redrive` · `{deadLetterId}/discard` | Events an intake refused, and what to do with them |
| 17 | `GET`/`POST /usage/webhook-endpoints` · `{endpointId}/rotate-secret` · `{endpointId}/revoke` | Inbound webhook endpoints and their signing secrets |

---

## 1. `GET /contracts`

Lists the contracts your key can see. Every per-contract endpoint takes an id
from here.

| Parameter | In | Default | Notes |
| --- | --- | --- | --- |
| `cursor` | query | — | Keyset cursor. Only contracts with an id **above** this value — this list walks forward. Omit for the first page; pass back the `nextCursor` of the previous page. |
| `afterId` | query | — | The original name of `cursor` on this list. Still accepted and identical; sending both with different values is a `400`. |
| `limit` | query | `20` | 1–100. |

Contracts page **forward** (ascending id) because the id is a stable identity
to walk, not a recency ranking. Invoices, subscriptions and the credit ledger
page **backward** (newest first) — see the Pagination reference
(`billing-pagination.md`), which also states the keyset stability guarantee
and the amounts convention.

```bash
curl -s "$FYNEX_API_BASE/contracts?limit=50" \
  -H "Authorization: Bearer $FYNEX_API_KEY"
```

```json
{
  "contracts": [
    {
      "contractId": 42,
      "contractNumber": "UK2607AA",
      "version": 1,
      "sellerCustomerId": 7,
      "currency": "EUR",
      "status": "active",
      "startDate": "2026-01-01",
      "endDate": "2026-12-31",
      "customerName": "Ada Lovelace",
      "customerPhone": "+31 6 1234 5678",
      "customerCompanyName": "Harbour Group BV",
      "customerCompanyCountry": "NL"
    }
  ],
  "hasMore": true,
  "nextCursor": 42,
  "nextAfterId": 42
}
```

| Field | Meaning |
| --- | --- |
| `contractId` | The key for endpoints 2 and 3. |
| `contractNumber` | Document number (`UK2607AA`). Empty for contracts predating numbering. |
| `version` | Contract version; you always get the current one. |
| `sellerCustomerId` | Your customer on this contract. Also an invoice filter. |
| `currency` | Contract currency (ISO 4217). |
| `status` | Contract lifecycle state, e.g. `active`. |
| `startDate` / `endDate` | `YYYY-MM-DD`. `endDate` absent on open-ended contracts. |
| `customerName` / `customerPhone` | The customer's name and phone as agreed on the contract. Absent when not stated. |
| `customerCompanyName` / `customerCompanyCountry` | The customer's company and its registration country as agreed on the contract. Absent when not stated. |

**Failures:** `400` malformed `cursor`/`afterId`/`limit`, or both cursor names sent with different values · `401` bad key · `403`
inactive seller.

---

## 1a. `POST /contracts`

Creates a contract, in `draft`, for one of your customers. The contract is the
anchor every other object refers to — subscriptions, credit, metered prices and
invoices — and it bills in one currency for its whole life.

**`Idempotency-Key` is a required header**: 1–128 characters from
`A–Z a–z 0–9 _ . : -`, one per contract you intend to create. Keys are scoped to
your seller account. Keys beginning with a prefix the billing engine uses for its own ledger rows (`proration:`, `proration-grant:`, `redeem:`, `subscription:`, `credit_note:`, `refund:`, `usage-adjustment:`) are refused with `400` — they are not yours to use. The same key returns the contract the first call created
(its *current* version) and answers `200` instead of `201`. **The body is
compared**, in two halves.

`sellerCustomerId` and `currency` are compared against the contract's current
version — neither is amendable, so the two versions agree. Everything an
amendment *can* move is compared against **version 1**, the version this key
actually created, so a contract that was legitimately re-dated or re-priced
does not turn every later retry into a refusal:

- `startDate` and `endDate`, present or absent;
- `lineItems` **line by line** — `componentType`, `componentConfig` and
  `quantity` — and in any order, not merely how many there are. Two components
  of the same type at different quantities are two different contracts, and a
  count alone answered the second with the first;
- `customerName`, `customerPhone`, `customerCompanyName` and
  `customerCompanyCountry`, each compared *only when your request states it* —
  a blank one is inherited from the customer record, which is exactly what the
  first call did.

A difference in any of them answers `422` naming the field.

```bash
curl -s -X POST "$FYNEX_API_BASE/contracts" \
  -H "Authorization: Bearer $FYNEX_API_KEY" \
  -H "Idempotency-Key: 5c2f7c40-1a3e-4c9b-9d1f-2b6e5a0c7d31" \
  -H "Content-Type: application/json" \
  -d '{
    "sellerCustomerId": 7,
    "currency": "EUR",
    "startDate": "2026-10-01"
  }'
```

| Field | Notes |
| --- | --- |
| `sellerCustomerId` | Required; one of your customers (endpoint 20). Another seller's id is `400`. |
| `currency` | Required. ISO 4217, one of `EUR`, `USD`, `GBP`, `DKK`, `NOK`, `SEK`. Immutable: every subscription, credit lot and invoice under the contract must use it. |
| `startDate` | Required. `YYYY-MM-DD`, up to a year in the past and ten years ahead; the window is measured in whole days. |
| `endDate` | Optional `YYYY-MM-DD`; omit for open-ended. Not before `startDate`, and not more than ten years ahead. |
| `lineItems` | Optional components, at most 100: `componentType` (`recurring`, `usage`, `one_time`, `milestone`, `project`, `marketplace`, `credit`, `adjustment`, `hybrid`), `quantity` (decimal string), `componentConfig` (JSON the engine reads for that type, at most 16 KiB). There is no `priceRef`, and a `componentConfig` carrying `milestoneDefinitionRef`, `projectRef` or `splitRuleRef` is `400` — those name objects this API does not publish. Omit for a contract billed only by its subscriptions and metered prices. |
| `customerName` / `customerPhone` / `customerCompanyName` / `customerCompanyCountry` | The counterparty's details *as agreed on this contract*; blank ones default from the customer record. 128 / 32 / 255 / 56 characters. |

Answers the same contract shape endpoint 1 lists — `id`, `version` (`1`),
`status` (`draft`), `currency`, `startDate`, `endDate`, `contractNumber` when
numbering is enabled.

**Failures:** `400` (a missing `sellerCustomerId`, `currency` or `startDate`,
unknown currency, malformed date, a date outside the window, over-long detail,
an unavailable `componentConfig` reference, a `sellerCustomerId` that is not
yours — the same answer whether it is another seller's or no customer at all —
missing or malformed `Idempotency-Key`) ·
`422` (`Idempotency-Key` already used for a different contract) · `429` ·
`503` · `401` / `403`.

`429` here has **two** ceilings behind it. The surface-wide per-seller rate
limit applies as everywhere else, and creating a contract carries an *extra*
per-seller quota of its own — **20 create requests per rolling 24 hours** by default — requests, not contracts: a refused or replayed call counts too.
That quota bounds how fast one key can open contracts; the budget above is
sized for bulk reads. It is deliberately not a guarantee about the shared
`CC+YYMM` number namespace, which 20 a day could not give: contract numbering
is a rollout switch that is off by default, and where it is on the allocator
fails the create rather than reusing a number. Both ceilings answer `429` with
`Retry-After`; an integration that opens contracts as customers sign up will
never see either.

The quota fails closed — **`503`** with `Retry-After` while its limiter is
unreachable, and nothing was created. See [Errors → rate limiting](/billing-api/v1/docs/errors#when-the-write-quotas-fail-closed).

---

## 1b. `POST /contracts/{contractId}/amendments`

Appends a **version** to a contract you own. Nothing on a contract is edited
in place: a status move, new dates, a replaced component set or a corrected
counterparty detail is a new version, and every earlier one stays readable.

`expectedBaseVersion` is the concurrency guard — send the `version` you last
read. If the contract has moved on since, the amendment answers `422`; re-read
and decide again. There is no `Idempotency-Key`: a repeat of a successful
amendment fails the version check by construction.

```bash
curl -s -X POST "$FYNEX_API_BASE/contracts/42/amendments" \
  -H "Authorization: Bearer $FYNEX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"expectedBaseVersion": 1, "status": "active"}'
```

| Field | Notes |
| --- | --- |
| `expectedBaseVersion` | Required, positive: the version you last read. |
| `status` | `draft`, `active`, `suspended` or `closed`; omit to keep. Illegal transitions (anything out of `closed`, `draft` straight to `suspended`) are `422`. |
| `startDate` / `endDate` | `YYYY-MM-DD`; omit to keep. An existing end date cannot yet be cleared. |
| `lineItems` | Omit to carry the current set forward; send the full new set to replace it; `[]` clears it. At most 100. |
| `customerName` / `customerPhone` / `customerCompanyName` / `customerCompanyCountry` | Send to replace, omit to keep, `""` to clear. |

Answers the new version (`version` is `expectedBaseVersion + 1`) in the same
contract shape as endpoint 1.

**Failures:** `400` (malformed field, unknown `status`) · `404` (contract is
not yours) · `422` (stale `expectedBaseVersion`, closed contract, illegal
status transition) · `429` · `503` · `401` / `403`.

`429` here has **two** ceilings behind it, as on endpoint 1a. The surface-wide
per-seller rate limit applies as everywhere else, and amending carries an
*extra* per-seller quota of its own — **200 amend requests per rolling 24 hours** (a stale `expectedBaseVersion` counts too)
by default — because every accepted amendment appends a version, with up to
100 line items, to a history that is append-only and has no delete. Like the
create quota it fails closed — **`503`** with `Retry-After` while its limiter
is unreachable, and nothing was appended. See [Errors → rate limiting](/billing-api/v1/docs/errors#when-the-write-quotas-fail-closed).

---

## 2. `GET /contracts/{contractId}/usage`

The contract's **current open** billing periods, one entry per metric. This is
a live read, not a closed-period figure — reconcile money against invoices.

```bash
curl -s "$FYNEX_API_BASE/contracts/42/usage" \
  -H "Authorization: Bearer $FYNEX_API_KEY"
```

```json
{
  "contractId": 42,
  "metrics": [
    {
      "metricName": "api_calls",
      "used": "10250",
      "includedUnits": "10000",
      "capQuantity": "50000",
      "capMode": "hard",
      "percentOfCap": "20.5",
      "percentOfPlan": "102.5",
      "periodStart": "2026-08-01",
      "periodEnd": "2026-08-31"
    }
  ]
}
```

| Field | Meaning |
| --- | --- |
| `used` | Metered so far this period. Decimal **string**. |
| `includedUnits` | Plan allowance for the period. |
| `capQuantity` / `capMode` | The ceiling and how it is enforced: `hard` blocks past it, `soft` only alerts. Absent when no limit policy exists. |
| `percentOfPlan` | Above `100` means overage — what will be rated onto the next usage invoice. |
| `percentOfCap` | Distance to the enforcement ceiling. Warn customers well before `100`. |
| `periodStart` / `periodEnd` | Open period bounds. Absent when a metric has a policy but no usage yet. |

**Failures:** `400` non-numeric id · `404` unknown contract, or one belonging
to another seller (the two are indistinguishable by design).

A real contract with nothing metered answers `200` with `"metrics": []` — a
valid state, not an error.

---

## 3. `GET /contracts/{contractId}/credits`

One contract's stored-value balances plus a page of its append-only ledger.

| Parameter | In | Default | Notes |
| --- | --- | --- | --- |
| `contractId` | path | — | Required. |
| `cursor` | query | — | Keyset cursor over the ledger: entries with an id **below** this value. Pass back the previous page's `nextCursor`. |
| `beforeId` | query | — | The original name of `cursor` on this list. Still accepted and identical; sending both with different values is a `400`. |
| `limit` | query | `20` | 1–100. Applies to `entries` only — `balances` is always complete. |

```bash
curl -s "$FYNEX_API_BASE/contracts/42/credits?limit=50" \
  -H "Authorization: Bearer $FYNEX_API_KEY"
```

```json
{
  "balances": [
    {"currency": "EUR", "creditType": "purchased", "balanceMinor": 250000, "isLiability": true}
  ],
  "entries": [
    {
      "id": 1201,
      "sellerCustomerId": 7,
      "kind": "topup",
      "creditType": "purchased",
      "signedDeltaMinor": 250000,
      "currency": "EUR",
      "lotId": 900,
      "invoiceId": null,
      "expiresAt": null,
      "reason": "annual prepayment",
      "occurredAt": "2026-08-01T09:30:00Z"
    }
  ],
  "hasMore": true,
  "nextCursor": 1201,
  "nextBeforeId": 1201
}
```

| Field | Meaning |
| --- | --- |
| `kind` | `topup` (granted or bought), `deduction` (consumed into an invoice), `expiry` (a lapsed lot removed). |
| `signedDeltaMinor` | Positive for `topup`, negative for the other two. |
| `lotId` | The grant a deduction or expiry consumed. |
| `invoiceId` | The invoice a deduction funded — the link back to the document. |
| `isLiability` | True for **paid-for** types (`purchased`, `enterprise`, `proration`): unearned revenue you owe as service. Granted credit is not a liability, and only granted credit can expire. |

**Failures:** `400` bad id or paging · `404` unknown/foreign contract ·
`501` stored value is not enabled for this deployment.

Replaying the ledger from the beginning always reproduces the balance:
corrections are new rows, never edits.

---

## 4. `GET /credits`

Your stored-value position across every contract, one row per currency and
credit type. Bounded, so it does not page.

```bash
curl -s "$FYNEX_API_BASE/credits" -H "Authorization: Bearer $FYNEX_API_KEY"
```

```json
{
  "balances": [
    {"currency": "EUR", "creditType": "purchased", "balanceMinor": 250000, "isLiability": true},
    {"currency": "EUR", "creditType": "promotional", "balanceMinor": 5000, "isLiability": false}
  ]
}
```

Credit types: `promotional`, `purchased`, `manual`, `gift`, `enterprise`,
`ai_token`, `marketplace`, `proration`. `proration` is minted only by the
billing engine (unserved time returned on a mid-term downgrade) and cannot be
granted by hand. Finance usually wants liabilities separated from granted
credit — do not sum them blindly.

**Failures:** `401` · `403` · `501` (capability off).

---

## 4a. `POST /contracts/{contractId}/credits/top-up`

Grants a credit **lot** on a contract you own — prepaid balance the invoice
engine draws down before any payment rail is asked for money. The ledger is
append-only, and this is the only way credit enters it: the deductions,
expiries and reversals that take it out are the engine's own rows and have no
public route.

**`Idempotency-Key` is a required header**: 1–128 characters from
`A–Z a–z 0–9 _ . : -`, one per grant you intend to make. Keys are scoped to
your seller account. The same key returns the grant the first call created and
answers `200` instead of `201`. Keys beginning with a prefix the billing engine uses for its own ledger rows (`proration:`, `proration-grant:`, `redeem:`, `subscription:`, `credit_note:`, `refund:`, `usage-adjustment:`) are refused with `400` — they are not yours to use. The engine's own
`proration-grant:` lots live in the same index, and the recurring lane adopts
the lot it finds under that key as the term's grant — which is exactly why no
caller may put one there.

Unlike the subscription create, **the body is compared**, and what is compared
is everything about the lot you decide: `contractId`, `amountMinor`,
`currency`, `creditType`, `expiresAt` (present or absent), `sellerCustomerId`
and `reason`. A difference in any of them answers `422` naming the field rather
than handing back the earlier grant: a top-up has a natural shape, and silently
returning the first one would hide a second grant that never happened.
Normalisation is not a difference — casing on `currency` and `creditType`, and
surrounding whitespace on `reason`, replay as the same grant.

```bash
curl -s -X POST "$FYNEX_API_BASE/contracts/42/credits/top-up" \
  -H "Authorization: Bearer $FYNEX_API_KEY" \
  -H "Idempotency-Key: 3f0b6a1e-9c1d-4f10-9f2b-6f2a1c1e6a1e" \
  -H "Content-Type: application/json" \
  -d '{
    "creditType": "purchased",
    "amountMinor": 250000,
    "currency": "EUR",
    "reason": "annual prepayment"
  }'
```

| Field | Notes |
| --- | --- |
| `creditType` | `promotional`, `purchased`, `manual`, `gift`, `enterprise`, `ai_token` or `marketplace`. `purchased` and `enterprise` were PAID FOR — they are a liability you owe as service. |
| `amountMinor` | The lot, in **minor units**, 1 to 10^12. Always positive: this is the grant, not a movement. |
| `currency` | ISO 4217, and it must equal the contract's current currency — credit is never converted. A different one is `400`. |
| `expiresAt` | RFC 3339 instant the lot lapses; omit for credit that never expires. Must be in the future, and is refused outright on `purchased` and `enterprise` credit — money someone paid must not evaporate on a calendar date. |
| `reason` | Required, at most 512 characters. Recorded on the ledger row. |
| `sellerCustomerId` | Optional attribution for per-customer reporting; must be one of your customers (endpoint 20). The contract is still the balance anchor. |

Answers the same `CreditLedgerEntry` shape endpoint 3 lists — `id`, `kind`
(`topup`), `creditType`, `signedDeltaMinor`, `currency`, `lotId`, `expiresAt`,
`reason`, `occurredAt`. A top-up **is** a lot, so `lotId` is absent on it; the
deductions that later consume it name this entry's `id`.

**Failures:** `400` (unknown `creditType`, `amountMinor` outside the range, a
`currency` other than the contract's, a `reason` over 512 characters, a
`sellerCustomerId` that is not yours, missing or malformed `Idempotency-Key`) ·
`404` (contract is not yours) · `422` (`Idempotency-Key` already used for a
different grant) · `429` · `401` / `403` · `501` (capability off).

---

## 5. `GET /invoices`

The workhorse. Newest first, keyset paged, with filters that let you fetch a
bounded slice instead of walking history.

| Parameter | In | Default | Notes |
| --- | --- | --- | --- |
| `contractId` | query | — | One contract. |
| `sellerCustomerId` | query | — | One of your customers, across contracts. |
| `origin` | query | — | `recurring`, `usage`, `milestone`, `project`, `one_time`, `adhoc`, `marketplace`. `subscription` is a deprecated alias of `recurring` and resolves to the same set. |
| `status` | query | — | `draft`, `issued`, `sent`, `paid`, `overdue`, `voided`, `written_off`. |
| `invoiceType` | query | — | `standard`, `credit_note`, `simplified`, `modified`. |
| `corrects` | query | — | Credit notes issued against this invoice **number**. |
| `issuedFrom` | query | — | Inclusive lower bound. `YYYY-MM-DD` (UTC midnight) or RFC 3339. |
| `issuedTo` | query | — | **Exclusive** upper bound, so adjacent periods never double-count. |
| `cursor` | query | — | Keyset cursor: rows with an id **below** this value — this list walks backward. Pass back the previous page's `nextCursor`. |
| `beforeId` | query | — | The original name of `cursor` on this list. Still accepted and identical; sending both with different values is a `400`. |
| `limit` | query | `20` | 1–100. |

`origin`, `status` and `invoiceType` reject an unrecognized value with `400`,
so a typo cannot masquerade as "no such invoices". `contractId`,
`sellerCustomerId` and `corrects` are matched as given — a well-formed but
wrong value legitimately returns an empty page.

```bash
curl -s "$FYNEX_API_BASE/invoices?issuedFrom=2026-08-01&issuedTo=2026-09-01&status=sent&limit=100" \
  -H "Authorization: Bearer $FYNEX_API_KEY"
```

```json
{
  "invoices": [
    {
      "id": 4180,
      "contractId": 42,
      "sellerCustomerId": 7,
      "origin": "usage",
      "invoiceNumber": "UK2607AA-2608AAB",
      "jurisdiction": "UK",
      "invoiceType": "standard",
      "status": "sent",
      "currency": "EUR",
      "issueDate": "2026-08-01T00:00:00Z",
      "dueDate": "2026-08-15T00:00:00Z",
      "subtotalMinor": 10000,
      "taxTotalMinor": 2000,
      "grandTotalMinor": 12000,
      "creditAppliedMinor": 0,
      "collectibleMinor": 12000,
      "creditSettledMinor": 0,
      "outstandingMinor": 12000,
      "reverseCharge": false,
      "paymentLinkId": 991
    }
  ],
  "hasMore": true,
  "nextCursor": 4180,
  "nextBeforeId": 4180
}
```

List rows omit `customerName` / `customerEmail` — those live in the frozen
payload and reading them per row would mean parsing every document. Use
`sellerCustomerId` to group, or fetch the single invoice (endpoint 6).

**Failures:** `400` unknown enum value, malformed date or paging · `401` ·
`403`.

---

## 5a. `POST /invoices`

Composes, numbers and — unless `send` is `false` — sends an invoice in one
call. The document is **issued the moment this answers `201`**: numbered
gaplessly under your agreement, its totals recomputed server-side, its payload
frozen. There are no drafts on this API and no edits afterwards.

**Credit notes are not issued here.** A correction is raised *against* the
document it corrects — it mirrors that document's lines and retires its payment
link — none of which can be reconstructed from a hand-composed body, so
`invoiceType: credit_note` is `400` and there is no `originalInvoiceNumber`
field on this request. Corrections are made in the dashboard.

**`Idempotency-Key` is a required header**, same shape as endpoint 1a. The same
key returns the document the first call issued and answers `200` — with no
`paymentLinkUrl` and no `deliveryStatus`, because delivery is not replayed.
**The body is compared**, and what is compared is everything that decides
*which* document this is:

- `contractId`, `sellerCustomerId`, `currency`, `invoiceType`, `jurisdiction`;
- `dueDate` and `dateOfSupply`, present or absent, and an `exemptionReason` or
  `legalNotice` your request states (an omitted one is the engine's to decide —
  it supplies its own on an exempt or reverse-charge document — and it decides
  the same way twice);
- the document's own text: `notes`, `poReference`, `paymentTerms`;
- the whole `buyer` block as it was frozen onto the document — the same money
  addressed to somebody else is a different legal record — and the `seller`
  block *as the engine used it*: under Fynex-collection issuance the issuer is
  substituted for the one you sent, so a retry that states a different seller
  still replays;
- the net total of the lines before tax (`quantity × unitPriceMinor −
  discountMinor`, summed), how many lines there are, and **each line's
  `description`, `quantity` and `unitPriceMinor`** — two lines whose net
  happens to match (`2 × 5000` and `4 × 2500`) are still different lines;
- **each line's `discountMinor`, `taxCategory`, `taxRatePercent` and
  `taxable`** — these decide the TAX on a line whose net amount is identical,
  which the net total above cannot see, so a retry that drops `taxRatePercent`
  from `20` to `0` is a different document, not a replay.

A difference in any of them answers `422` naming the field, and no second
document is numbered.

`send` is deliberately **not** compared: it chooses whether to deliver the
document, not what the document says, so a retry that flips it still replays.

```bash
curl -s -X POST "$FYNEX_API_BASE/invoices" \
  -H "Authorization: Bearer $FYNEX_API_KEY" \
  -H "Idempotency-Key: 8d1e4b2a-6f3c-4e7a-9b0d-1c2e3f4a5b6c" \
  -H "Content-Type: application/json" \
  -d '{
    "contractId": 42,
    "sellerCustomerId": 7,
    "jurisdiction": "UK",
    "currency": "EUR",
    "dueDate": "2026-11-01",
    "buyer": {"name": "Ada Lovelace", "email": "ada@example.com", "addressLine1": "1 Analytical Row", "city": "London", "postalCode": "E1 6AN", "country": "GB"},
    "lineItems": [{"description": "Seats, October", "quantity": "10", "unitPriceMinor": 4999, "taxCategory": "standard", "taxRatePercent": "20"}]
  }'
```

| Field | Notes |
| --- | --- |
| `contractId` / `sellerCustomerId` | Both required and both yours (endpoints 1a and 20). Nothing is provisioned here: an unknown contract is `404`, a foreign customer `400`. |
| `jurisdiction` | Required; which tax regime the document is composed under. Exactly `UK`, `US` or `EU` — the input is upper-cased first, so `uk` is accepted and stored as `UK`. |
| `invoiceType` | `standard` (default), `simplified` (only below the jurisdiction's retail threshold) or `modified` (UK retail above it, VAT-inclusive line prices). `credit_note` is refused with `400`. |
| `currency` | Must equal the contract's — a different one is `400`. |
| `dueDate` / `dateOfSupply` | `YYYY-MM-DD`. |
| `buyer` | The addressee: `name`, `email`, `addressLine1`, `city`, `postalCode`, `country` required — `email` is where the document is delivered, and a request without one is `400`; `phone`, `addressLine2`, `region`, `businessType`, `vatNumber`, `companyRegistrationNumber`, `taxExemptionCertificateNumber`, `ein`, `salesTaxId` optional. |
| `seller` | Optional. Ignored under Fynex-collection issuance, where Fynex is the issuing party; used as the issuing identity only for a merchant-issued seller. |
| `lineItems` | 1–200 lines: `description`, `quantity` (decimal string), `unitPriceMinor` (required, integer minor units), `discountMinor`, `taxCategory`, `taxRatePercent` (decimal string, 0–100), `taxable`, `sourceRef`. A UK/EU line must carry a `taxCategory` (`standard`, `reduced`, `zero`, `exempt`) and its rate must agree with it; a US line carries `taxable` and no category. No `catalogItemId` — send explicit prices. |
| `poReference` / `notes` / `paymentTerms` / `exemptionReason` / `legalNotice` | Free text, 128 / 2000 / 256 / 64 / 2000 characters. |
| `send` | Default `true`: create the hosted payment link and e-mail the buyer. `false` issues without delivering; `POST /invoices/{invoiceId}/send` delivers later. |

Answers `{"invoice": …, "paymentLinkUrl": …, "deliveryStatus": …}` — the same
invoice shape endpoint 6 returns, plus the link when one was created. **A
delivery failure does not undo issuance**: the answer is still `201` with the
document and no link.

`deliveryStatus` says what *this* call's delivery attempt did, because an
absent `paymentLinkUrl` alone cannot tell three different situations apart —
and only one of them is yours to act on:

| Value | Meaning |
| --- | --- |
| `sent` | The collection link was created and e-mailed; `paymentLinkUrl` carries it. |
| `not_requested` | No delivery was attempted: `send` was `false`, or the document was settled from stored credit at issue and has nothing left to collect. Nothing is wrong. |
| `failed` | The document **is** issued and immutable, and delivery did not happen. Retry it with `POST /invoices/{invoiceId}/send`, which tells you why (a test-mode recipient the sandbox rule refuses is the usual reason). |

The field is absent on an idempotent replay, which attempts no delivery of its
own and must not restate the first call's outcome.

**Failures:** `400` (input the engine refuses, `invoiceType: credit_note`, a
missing `buyer.email`, a `currency` other than the contract's, a foreign
`sellerCustomerId`, missing or malformed `Idempotency-Key`)
· `404` (contract is not yours) · `422` (`Idempotency-Key` already used for a
different document; a merchant-issued `seller` block missing the legal name,
address or tax details a compliant document needs — the detail names the
fields; a UK/EU exemption without the issuer's VAT number) · `429` · `503` ·
`401` / `403`. The incomplete-issuer case answered `400` before 2026-09; it is
a state of the seller's profile, not of the request, so it now sits with the
other `422`s.

`429` here has **two** ceilings behind it, as on endpoint 1a. The surface-wide
per-seller rate limit applies as everywhere else, and issuing carries an
*extra* per-seller quota of its own — **500 issue requests per rolling 24 hours** (refusals and replays count too) by
default. It is far larger than the contract create's on purpose: issuing
allocates a gapless number from *your own* `AGREEMENT-YYMM` series
(the `AAA…ZZZ` order suffix within it), so a runaway integration burns a month
of your numbers rather than a namespace shared with other sellers. Both answer
`429` with `Retry-After` and the `X-RateLimit-*` headers.

The quota fails closed — **`503`** with `Retry-After` while its limiter is
unreachable, and nothing was issued. See [Errors → rate limiting](/billing-api/v1/docs/errors#when-the-write-quotas-fail-closed).

---

## 6. `GET /invoices/{invoiceId}`

One invoice, its lines, and the customer identity as recorded at issue.

```bash
curl -s "$FYNEX_API_BASE/invoices/4180" -H "Authorization: Bearer $FYNEX_API_KEY"
```

```json
{
  "invoice": {
    "id": 4180,
    "customerName": "Acme GmbH",
    "customerEmail": "ap@acme.example",
    "invoiceNumber": "UK2607AA-2608AAB",
    "status": "sent",
    "grandTotalMinor": 12000,
    "collectibleMinor": 12000,
    "creditSettledMinor": 0,
    "outstandingMinor": 12000,
    "paidVia": "",
    "…": "every field from the list shape"
  },
  "lines": [
    {
      "position": 1,
      "description": "API calls over allowance",
      "quantity": "250",
      "unitPriceMinor": 40,
      "discountMinor": 0,
      "netAmountMinor": 10000,
      "taxCategory": "standard",
      "taxRate": "20",
      "taxAmountMinor": 2000,
      "sourceRef": "usage:api_calls:2026-07"
    }
  ]
}
```

### The four money fields, precisely

| Field | Is | Is **not** |
| --- | --- | --- |
| `grandTotalMinor` | The legal document total | |
| `collectibleMinor` | What collection asks the customer for — grand total less credit applied at issue | A balance. It does **not** drop to zero when paid, and it is blind to later credit notes, write-offs and adjustments. |
| `creditSettledMinor` | The part discharged by stored credit | A payment total. A card-paid invoice reports `0`. |
| `outstandingMinor` | The receivable: the document total plus every adjustment ledger entry against it, less what has been settled. A voided document reports `0`. This is the figure to age in an AR report. | Guaranteed present. It is omitted when the adjustment ledger is unavailable — treat its absence as **unknown**, never as `0`. |

"Has this been paid?" is answered by `status` (and `paidVia` for how), never
by arithmetic on the amounts.

`collectibleMinor` and `outstandingMinor` agree on a document nothing has
happened to. They diverge the moment one does: credit-note a `12000` invoice by
`5000` and `collectibleMinor` still reads `12000` while `outstandingMinor` reads
`7000`. Ageing `collectibleMinor` over-states receivables.

`customerName` / `customerEmail` are who the document was addressed to **at
issue** — a customer renaming themselves later does not rewrite an issued
legal record. Empty when the payload carries no buyer. (The contract list's
`customerName` is different: it is the currently agreed value on the
contract, not a frozen document field.)

### Correction and tax fields

Present only when they apply, so treat every one as optional.

| Field | Meaning |
| --- | --- |
| `originalInvoiceNumber` | On a credit note: the invoice number it corrects. Find corrections the other way round with `?corrects=`. |
| `voidedAt` / `voidReason` | The document was cancelled before money moved. The invoice is not deleted — an issued invoice is a legal record — so this is a status plus an audited reason. |
| `writtenOffAt` / `writeOffReason` | Collection was abandoned. Kept separate from the void fields so the two corrections stay distinguishable. |
| `dateOfSupply` | The tax point, when it differs from `issueDate`. |
| `reverseCharge` | True when the reverse-charge mechanism applies — the buyer accounts for the VAT. |
| `exemptionReason` | Exemption code, when the document is exempt. |
| `legalNotice` | Jurisdiction-mandated text printed on the document. |
| `paidVia` | How a paid document settled: `payment_link`, `bank_transfer`, or `credit` (stored credit covered it in full). |
| `paymentLinkId` | The collection link, once sent. Use the URL from `POST …/send`, not this id. |

On a line, `taxable` is the US-jurisdiction flag; UK/EU documents use
`taxCategory` and `taxRate` instead.

**Failures:** `400` non-numeric id · `404` unknown or foreign invoice.

---

## 7. `GET /invoices/{invoiceId}/pdf`

The rendered document, `application/pdf`, with a `Content-Disposition`
filename derived from the invoice number.

```bash
curl -s "$FYNEX_API_BASE/invoices/4180/pdf" \
  -H "Authorization: Bearer $FYNEX_API_KEY" \
  -o UK2607AA-2608AAB.pdf
```

Rendered from the frozen payload, so the same invoice produces the same
document today and next year. Every issued document renders — including
voided ones and credit notes, which are part of the audit trail.

Rendering is the most expensive operation here; when bulk-downloading, pace
the loop (see Errors → rate limiting).

**Failures:** `400` · `404` · `500` if the stored payload no longer validates
(a server-side integrity fault; retrying will not fix it).

---

## 8. `POST /invoices/{invoiceId}/send`

Materializes the collection instrument: creates the invoice's hosted payment
link and returns its URL.

> **This emails your customer.** Whenever the document carries a buyer email,
> the first call delivers the payment invitation. There is no per-request
> suppression in v1, so do not call it merely to obtain a URL for internal
> use — the first call is customer-facing and cannot be undone.

```bash
curl -s -X POST "$FYNEX_API_BASE/invoices/4180/send" \
  -H "Authorization: Bearer $FYNEX_API_KEY"
```

```json
{
  "invoice": { "id": 4180, "status": "sent", "paymentLinkId": 991, "…": "…" },
  "paymentLinkUrl": "https://pay.example-host/p/7bc1f2a9e4d5"
}
```

**Repeat calls are safe, including concurrently, and take no idempotency
header.** The invoice id is itself the collection anchor: a unique
payment-link association makes concurrent calls converge on one link.
Repeating a successful call returns that same link and does not send another
email.

Use the returned URL — never construct one. The hosted host is per-deployment
configuration.

`422` cases, none retryable without a state change:

| Message | Meaning |
| --- | --- |
| `invoice is collected by bank transfer and cannot create a payment link` | The document instructs a wire to a named account; a simultaneously payable card link could let both succeed. |
| `invoice has nothing left to collect` | Zero total, or stored credit covers it. |
| `invoice is not in a sendable state` | `draft`, or already `paid`, `voided` or `written_off`. A settled invoice never hands out a payment link. |
| `payment-link: sandbox emails can only go to *@sandbox.fynex.ai, *@example.test, or your own verified email` | The seller is in test mode and the invoice's customer address is neither a reserved test domain nor an address the seller itself owns. Test mode never e-mails a real customer. |

**Failures:** `400` · `404` · `422` (above) · `401` / `403`.

---

## 9. `GET /subscriptions`

| Parameter | In | Default | Notes |
| --- | --- | --- | --- |
| `contractId` | query | — | One contract. |
| `cursor` | query | — | Keyset cursor, newest first: rows with an id **below** this value. Pass back the previous page's `nextCursor`. |
| `beforeId` | query | — | The original name of `cursor` on this list. Still accepted and identical; sending both with different values is a `400`. |
| `limit` | query | `20` | 1–100. |

```bash
curl -s "$FYNEX_API_BASE/subscriptions?contractId=42" \
  -H "Authorization: Bearer $FYNEX_API_KEY"
```

```json
{
  "subscriptions": [
    {
      "id": 310,
      "contractId": 42,
      "status": "active",
      "billingFrequency": "monthly",
      "priceMinor": 9900,
      "currency": "EUR",
      "anchorDate": "2026-01-01T00:00:00Z",
      "startDate": "2026-01-01T00:00:00Z",
      "currentPeriodStart": "2026-08-01T00:00:00Z",
      "currentPeriodEnd": "2026-08-31T00:00:00Z",
      "autoRenew": true,
      "prorationPolicy": "by_day",
      "noticePeriodDays": 0,
      "trialRequiresPaymentMethod": false,
      "createdAt": "2026-01-01T09:00:00Z",
      "updatedAt": "2026-08-01T00:05:00Z"
    }
  ],
  "hasMore": false
}
```

---

## 10. `GET /subscriptions/{subscriptionId}`

Same shape, one record. `subscriptionId` is the path parameter; ids come from
endpoint 9.

### Cadence fields

`billingFrequency` is one of `daily`, `weekly`, `bi_weekly`, `monthly`,
`quarterly`, `semi_annual`, `annual` or `custom`. The annual cadence is named
`annual` — there is no yearly value, so a branch written against one never
fires. On a custom cadence, `customUnit` (`day`, `week`, `month`, `year`) and
`customEvery` (the multiplier) describe the repeat: `customUnit: "week"` with
`customEvery: 2` bills fortnightly. Both are absent on the named frequencies.

### Reading a subscription correctly

| Question | Read |
| --- | --- |
| What are they paying? | `priceMinor` + `currency` + `billingFrequency`. There is no separate plan object — those three fields *are* the plan. |
| Is a change coming? | `pendingPriceMinor` / `pendingPriceChangeAt`. Showing only `priceMinor` misinforms a customer who already requested a downgrade. |
| Are they leaving? | `cancelRequestedAt` / `cancelEffectiveAt`. Service continues until the effective date, so `status` alone is not the answer. |
| Are they paying yet? | `status: "trial"` plus `trialEnd`. `trialEndBehavior` says which way it ends — `convert` or `cancel` — and `trialRequiresPaymentMethod` whether a method must be on file first. |
| Did a charge fail? | `status: "past_due"`. **Nothing retries it automatically** — this is a queue for you to work. |
| Paused? | `pausedAt`, with `pauseEndsAt` for a scheduled auto-resume. |
| What proration was agreed? | `prorationPolicy` — `by_day`, `full_period` or `next_period`; absent when never stated. It **selects what an immediate `change-plan` does while the proration engine is enabled** for the platform: `by_day` (and an unstated term) puts the new price in force today and posts an adjustment for the unserved remainder, `full_period` puts the new price in force today and bills the **whole** current period at it — the days already elapsed included — posting no adjustment, `next_period` keeps the old price for the rest of the current period and applies the new one from the next. The engine is **off by default**, and while it is off no adjustment is posted and a mid-cycle change lands immediately whatever this says — so read it as the contract's written intent until an operator turns proration on. Where invoice binding is enabled for the environment as well, the posted adjustment reaches the term's next invoice as a line, a discount, or a `proration` credit lot. |

States: `trial` → `active` ⇄ `past_due` / `paused` → `canceled` | `expired`.

**Failures:** `400` · `404`.

---

## 10a. `POST /contracts/{contractId}/subscriptions`

Creates a subscription on a contract you own. `contractId` is the path
parameter; the body is the plan and the term. **`Idempotency-Key` is a
required header**: 1–128 characters from `A–Z a–z 0–9 _ . : -`, one per
subscription you intend to create — a UUID is the usual choice. Keys are
scoped to your seller account, not to the contract. Keys beginning with a prefix the billing engine uses for its own ledger rows (`proration:`, `proration-grant:`, `redeem:`, `subscription:`, `credit_note:`, `refund:`, `usage-adjustment:`) are refused with `400` — they are not yours to use. The same key always
returns the subscription the first call created and answers `200` instead of
`201`; the body of a retry is not compared. A key replayed against a
*different* contract answers `422` rather than returning the other contract's
subscription. Two concurrent first calls with one key produce one
subscription, which both callers receive.

```bash
curl -s -X POST "$FYNEX_API_BASE/contracts/42/subscriptions" \
  -H "Authorization: Bearer $FYNEX_API_KEY" \
  -H "Idempotency-Key: 9c1d0b3a-7e55-4f10-9f2b-6f2a1c1e6a1e" \
  -H "Content-Type: application/json" \
  -d '{
    "frequency": "monthly",
    "priceMinor": 9900,
    "currency": "EUR",
    "startDate": "2026-10-01",
    "trialEnd": "2026-10-15",
    "trialEndBehavior": "convert",
    "noticePeriodDays": 30,
    "prorationPolicy": "by_day"
  }'
```

| Field | Notes |
| --- | --- |
| `frequency` | `daily`, `weekly`, `bi_weekly`, `monthly`, `quarterly`, `semi_annual`, `annual` or `custom`. `custom` needs `customUnit` (`day`/`week`/`month`/`year`) and `customEvery`. |
| `priceMinor`, `currency` | The plan: one full period's charge in minor units (1 to 10^12), in the contract's currency. Immutable currency. |
| `startDate`, `anchorDate` | `YYYY-MM-DD`. `anchorDate` defaults to `trialEnd` for a trial, `startDate` otherwise. A past `startDate` — up to a year back — is caught up by the next lifecycle pass. |
| `trialEnd`, `trialEndBehavior`, `trialRequiresPaymentMethod` | A future `trialEnd` starts the subscription in `trial`; `convert` (default) or `cancel` decides how it ends. |
| `autoRenew`, `endDate` | `autoRenew` defaults to `true`. `false` fixes the term and requires `endDate`; `endDate` with `autoRenew` true is refused. |
| `noticePeriodDays` | Notice a cancellation requires, 0–365; `0` cancels at the current term's end. |
| `prorationPolicy` | `by_day`, `full_period` or `next_period`. Selects what an immediate `change-plan` does once the proration engine is enabled — prorate the remainder, bill the whole current period at the new price (elapsed days included, no adjustment), or hold the old price until the next period. Inert while the engine is off (the default); where invoice binding is enabled as well, the posted adjustment is applied to the term's next invoice. |

Answers the same `Subscription` shape as endpoint 10. **Failures:** `400` (input
outside the ranges above, a `currency` other than the contract's, missing or
malformed `Idempotency-Key`) · `404` (contract is not yours) · `422`
(`Idempotency-Key` already used on another contract) · `429` · `401` / `403`.

## 10b. Lifecycle actions

All `POST`, all on `/subscriptions/{subscriptionId}/…`, all returning the
updated subscription. A request the current state cannot take answers `422`
with the reason — pausing a trial, resuming an active subscription,
cancelling twice — and so does a lost race against the lifecycle pass
(`subscription changed concurrently; re-read it and retry`). `404` means the
subscription is not yours.

| Path | Body | From | Effect |
| --- | --- | --- | --- |
| `/subscriptions/{subscriptionId}/cancel` | — | `trial`, `active`, `past_due`, `paused` | `cancelRequestedAt` set; serves until `cancelEffectiveAt` (notice) or term end. Terminal. |
| `/subscriptions/{subscriptionId}/pause` | `{"pauseUntil": "YYYY-MM-DD"}` optional | `active` | Billing suspended; `pauseEndsAt` when a date was given. Beyond the seller's pause policy → `422`. |
| `/subscriptions/{subscriptionId}/resume` | — | `paused` | New term from today; the pause is not billed. |
| `/subscriptions/{subscriptionId}/end-trial` | — | `trial` | Converts now, per `trialEndBehavior` and `trialRequiresPaymentMethod`. |
| `/subscriptions/{subscriptionId}/change-plan` | `{"priceMinor": 12900, "currency": "EUR", "atTermEnd": true}` | `active` | `atTermEnd: true` schedules (`pendingPriceMinor`) and is always honoured; `false` leaves the timing to the subscription's `prorationPolicy` (`next_period` holds the old price until the next period, anything else applies the new one now — see the policy table above). Same price, or a change already pending → `422`. The currency cannot change — a different one is `400`. |

Marking a subscription past-due or recovered has no public route: that is the
collection loop's verdict, not the integrator's.

---

## 11–13. The metered-usage write surface

Registering a metric (`name`, `unit`, `aggregation`), pricing it on a
contract (`contractId`, `metricName` path parameters; the config carries the
scheme, tiers, allotment and commitment), and reporting events
(`POST /usage/events`, one event; `POST /usage/events:batch`, many;
`quantity` as a decimal string, `occurredAt`, `idempotencyKey`) are
documented end to end in the **Usage ingestion** guide — parameters, the four
pricing schemes, the `metered: false` trap, and the failure table. The
operations live in this spec; the guide is their reference.

## 14. `POST /prices/evaluate`

Previews what a pricing configuration charges — through the same engine that
bills, so the number matches the invoice to the cent. Pure: nothing is
stored, no contract is referenced, and the config in the request is the same
document `PUT /usage/contracts/{contractId}/metrics/{metricName}/price`
stores.

```bash
curl -s -X POST "$FYNEX_API_BASE/prices/evaluate" \
  -H "Authorization: Bearer $FYNEX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "config": {
      "scheme": "graduated", "currency": "EUR", "rounding": "half_up",
      "unitPrice": "0",
      "tiers": [
        {"upTo": "10000", "unitPrice": "0.01", "flatAmount": "0"},
        {"upTo": null,    "unitPrice": "0.005", "flatAmount": "0"}
      ]
    },
    "quantities": ["12000"]
  }'
```

```json
{
  "results": [{
    "quantity": "12000",
    "amountMinor": 11000,
    "ratedAmountMinor": 11000,
    "currency": "EUR",
    "billableUnits": "12000",
    "includedUnitsApplied": "0",
    "carryOver": "0",
    "usageCapApplied": false,
    "spendCapApplied": false,
    "minimumApplied": false,
    "lines": [
      {"tierIndex": 0, "units": "10000", "unitPrice": "0.01", "flatAmount": "0", "amount": "100"},
      {"tierIndex": 1, "units": "2000", "unitPrice": "0.005", "flatAmount": "0", "amount": "10"}
    ]
  }]
}
```

Send several `quantities` (up to 100) to plot a price curve in one call;
`carriedOverUnits` seeds a rollover carry so the preview shows an existing
contract's next period. `amountMinor` is the money after the usage cap, spend
cap and minimum — the flags say which of them acted. Lines are exact decimals
in **major** units and always sum to the rated amount.

**Failures:** `400` — a malformed config or quantity, with the engine's own
validation message · `401` · `403`.

## 15. `POST /usage/csv`

Bulk intake for a periodic export. `multipart/form-data` with two parts.

| Part | Notes |
| --- | --- |
| `file` | The CSV itself. Without an idempotency-key column, keys derive from the file digest and line number — so re-uploading the same file is a no-op, not a double charge. |
| `mapping` | A `CSVMapping` JSON object naming which column carries which field. |

`quantityColumn` is required. Contract, metric and occurred-at may each come
from a column (`contractIdColumn`, `metricColumn`, `occurredAtColumn`) **or**
from a whole-file default (`defaultContractId`, `defaultMetric`,
`defaultOccurredAt`) — one or the other, never both for the same field.
`idempotencyKeyColumn` is optional.

```json
{"defaultContractId":42,"defaultMetric":"api_calls","quantityColumn":"calls","occurredAtColumn":"day"}
```

**Returns** `{"ingested":498,"duplicates":0,"deadLettered":2}`.

A row the pipeline refuses is dead-lettered rather than failing the upload, so
a partial success is normal — **read `deadLettered` on every response.** A file
that answers `200` with rows in the queue is not a file that billed.

**Failures:** `400` — not multipart, a mapping that is not exactly one
`CSVMapping` object, or a file-level rejection · `401` · `403` · `413` — the
file is over the per-call limit; split it.

---

## 16. `GET /usage/dead-letters`

Events an intake refused, newest first, each with the reason and the payload as
submitted. This is the queue to watch: a `2xx` from an intake does not mean
every row inside it was accepted.

| Field | Notes |
| --- | --- |
| `id` | Use it to redrive or discard. |
| `source` | Which intake produced it: `events`, `batch`, `csv`, `feed`, `webhook`. |
| `reason` | Why it was refused, in the intake's words. Fix this before redriving. |
| `status` | `pending`, `redriven` or `discarded`. Only `pending` can be acted on. |
| `attempts` | How many redrives have been tried. |
| `receivedAt` | RFC 3339. |
| `payload` | The event as submitted, so it can be corrected and resent. |

**Failures:** `401` · `403`.

### `POST /usage/dead-letters/{deadLetterId}/redrive`

`deadLetterId` is the `id` from the listing above. Resubmits the stored payload. Returns `{"status":"ingested","eventId":91424}`,
or `{"status":"rejected"}` when it fails again — the letter stays `pending`,
its `reason` is updated and `attempts` increments. Fix the cause first; a
redrive with the same problem fails the same way.

**Failures:** `401` · `403` · `404` — unknown id, or one belonging to another
seller.

### `POST /usage/dead-letters/{deadLetterId}/discard`

`deadLetterId` is the `id` from the listing above. Closes the letter without metering it, for a row that should never have been
sent. Returns `{"status":"discarded"}`. **Not reversible** — the usage it
described will not be billed.

**Failures:** `401` · `403` · `404`.

---

## 17. `POST /usage/webhook-endpoints`

Registers an endpoint your own system posts usage to, and mints the secret that
signs those deliveries.

**Body:** `{"description":"billing events → ERP staging"}` — optional, your own
label.

**Returns** `201` with the endpoint, the `secret`, and a `secretNote` restating
the terms:

```json
{"endpoint":{"id":12,"token":"whk_7f21c0","status":"active","createdAt":"2026-09-14T10:22:31Z"},
 "secret":"whsec_9c2f…","secretNote":"Copy this secret now: …"}
```

**The secret is in this response and nowhere else.** It is stored encrypted and
no endpoint reads it back. Copy it now; if it is lost, rotate.

The `token` is the path segment your sender posts to —
`POST /usage/v1/webhooks/{token}`. It is not a credential on its own: the HMAC
signature is what authenticates a delivery. That receiver stays on the
`/usage/v1` prefix deliberately and is not part of this key-authed surface,
because its signature covers the body and the body must be read before the
sender is known.

**Failures:** `400` · `401` · `403`.

### `GET /usage/webhook-endpoints`

Every endpoint you registered, revoked ones included. Secrets are never in a
listing.

**Failures:** `401` · `403`.

### `POST /usage/webhook-endpoints/{endpointId}/rotate-secret`

`endpointId` is the endpoint's `id`. Issues a new secret and returns it once, on the same display-once terms as
creation. **The old secret stops verifying immediately** — cut the sender over
in the same change, or deliveries fail in between.

**Failures:** `401` · `403` · `404`.

### `POST /usage/webhook-endpoints/{endpointId}/revoke`

`endpointId` is the endpoint's `id`. Closes the endpoint: deliveries to its token are refused from here on. Not
reversible — register a new endpoint instead. The record stays in the listing
so the history remains readable.

**Failures:** `401` · `403` · `404`.

---

## 18. `GET /invoices/export`

The month-end pull: every invoice issued in the window, as one file, so
reconciliation does not start with writing a paging loop.

| Parameter | In | Notes |
| --- | --- | --- |
| `issuedFrom` | query | **Required.** Start of the period, inclusive (`YYYY-MM-DD` or RFC 3339). |
| `issuedTo` | query | **Required.** End of the period, exclusive — adjacent months never double-count a document. |
| `format` | query | `csv` (default) or `ndjson`. |
| `contractId`, `origin`, `status`, `invoiceType` | query | The list's filters, same names, same validation. |

Both dates are required on purpose: there is no default window, because "the
file I exported" must never silently mean a window the server chose. An
inverted or empty range is rejected.

**CSV** carries one row per invoice under this header, in this order:

```
id,invoiceNumber,contractId,sellerCustomerId,origin,invoiceType,status,
jurisdiction,currency,issueDate,dueDate,dateOfSupply,subtotalMinor,
taxTotalMinor,grandTotalMinor,creditAppliedMinor,collectibleMinor,
creditSettledMinor,corrects,reverseCharge,paidVia,voidedAt,writtenOffAt,
outstandingMinor
```

(One line in the file; wrapped here to fit.)

Money columns are integer **minor units** and their names say so
(`grandTotalMinor` — `4999` is €49.99); do not divide by 100 in the
spreadsheet without also keeping the raw column. Timestamps are RFC 3339 UTC;
`corrects` names the invoice a credit note corrects, empty otherwise.

`outstandingMinor` is the receivable — the column to age in an AR report,
rather than `collectibleMinor`, which is frozen at issue. An **empty cell**
means the adjustment ledger was unavailable for that row: the balance is
unknown, and it is never written as `0`. The rest of the row is unaffected,
so a ledger outage costs you that one column and not the reconciliation.

**New columns are appended, never inserted.** Reconciliation files are loaded
by pipelines that address columns positionally, so reordering an existing
export would silently shift every one of them. Read by name if you can; if you
read by position, a later Fynex release may make the row longer but will not
move a column you already read.

**NDJSON** carries one list-API invoice object per line — exactly the shape
`GET /invoices` serves, so a pipeline that parses the list parses the export
with no new code.

Rows come newest first, the same order as the list. A window holding more
than 100,000 documents answers `400` with instructions to narrow the range —
refused rather than silently truncated, because a reconciliation file that
looks complete and is not is how a document goes missing from someone's
books.

**Failures:** `400` — a missing or inverted date range, an unknown filter
value, or a window over the row cap · `401` · `403`.

---

## 19. `GET /invoices/{invoiceId}/settlement`

The join between billing and money movement: which payment attempts hit this
invoice's collection link, and which payouts carried them out. Billing and
split payments live on one platform — this is the endpoint that finally
connects them.

**Returns**, newest payment first:

```json
{
  "invoiceId": 4180,
  "invoiceNumber": "UK2607AA-2608AAB",
  "paidVia": "payment_link",
  "paymentLinkId": 512,
  "payments": [{
    "paymentId": 991,
    "status": "settled",
    "amountMinor": 12000,
    "currency": "EUR",
    "createdAt": "2026-08-17T09:26:00Z",
    "payouts": [{
      "payoutId": 55,
      "status": "completed",
      "state": "finalized",
      "attributedMinor": 12000,
      "currency": "EUR",
      "completedAt": "2026-08-20T06:00:00Z"
    }]
  }]
}
```

Failed payment attempts are reported with their status — "the customer tried
twice and the second one settled" is part of the story. A payment with an
empty `payouts` array has settled but not yet been scheduled into a payout. A
payment can be split across payouts; `attributedMinor` is each payout's slice,
and the attributions sum to what left. `state` is the attribution's own
lifecycle: `reserved` (selected for a payout), `finalized` (paid out) or
`released` (returned to the pool).

Three invoices legitimately have an empty trail, and the response carries a
`note` saying which case it is rather than an ambiguous empty list: an invoice
that was **never sent** (no collection link exists), one settled by a
**matched bank deposit** (the money arrived outside the payment-link rail),
and one **discharged entirely by stored credit** (no cash moved at all).

Payout and payment detail beyond the ids lives on the Payments API — this
endpoint gives you the ids to look them up with.

**Failures:** `400` — a malformed id · `401` · `403` · `404` — unknown id, or
an invoice belonging to another seller.

---

## 20. `POST /customers`

Resolves one of your customers by e-mail, creating the record when the address
is new. The returned `id` is the `sellerCustomerId` every contract, credit
top-up and invoice refers to.

**There is no `Idempotency-Key` here, and that is the rule rather than an
omission: the e-mail address is the key.** Find-or-create on (your seller
account, e-mail) is enforced in the database, so calling this twice with the
same address returns the same customer and creates nothing the second time —
which is exactly the guarantee a client key would otherwise have to supply.

Because of that the answer is always `200`, never `201`. The directory
resolves the address without reporting whether *this* call minted the record,
and a `201` would be a guess. Read the response as "this is your customer",
not as "this customer is new".

```bash
curl -s -X POST "$FYNEX_API_BASE/customers" \
  -H "Authorization: Bearer $FYNEX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "ada@example.com",
    "customerRef": "crm-8842",
    "name": "Ada Lovelace",
    "companyName": "Analytical Engines Ltd",
    "currency": "EUR",
    "customerType": "company"
  }'
```

```json
{
  "id": 7,
  "email": "ada@example.com",
  "customerRef": "crm-8842",
  "name": "Ada Lovelace",
  "companyName": "Analytical Engines Ltd",
  "currency": "EUR",
  "customerType": "company",
  "createdAt": "2026-09-01T10:15:00Z"
}
```

| Field | Notes |
| --- | --- |
| `email` | Required, and the key this call resolves on. A plain address — `Ada <ada@example.com>` is `400`. |
| `customerRef` | Your own identifier, at most 128 characters. A ref already held by a **different** customer of yours answers `422`. |
| `name`, `phone`, `companyName`, `companyCountry` | Optional contact details, at most 64 / 32 / 255 / 56 characters. |
| `currency` | The customer's default billing currency (ISO 4217). A contract's own currency still wins. |
| `customerType` | `individual` or `company`; omit when you do not know. |

Details are applied **fill-if-empty**: a value another surface already
recorded — checkout, or an earlier call — is never overwritten, and an omitted
field changes nothing. To correct a stored detail, use the dashboard.

**Failures:** `400` (missing or malformed `email`, an over-length field, an
unsupported `currency`, an unknown `customerType`) · `422` (`customerRef`
already belongs to a different customer) · `429` · `401` / `403`.

## 21. `GET /contracts/{contractId}/invoices/upcoming`

What the contract's next document would carry, projected through the **same
engines that bill it**: the subscription terms that fall due, the unbilled
mid-term amendments the engine would bind to them, each open metered period
rated by the engine `POST /prices/evaluate` answers with, a per-seat period
rated from the contract's seat schedule, and the stored credit that would be
drawn down.

Until this existed the answer had to be assembled in the client from four
reads and its own arithmetic — the one implementation that is not
authoritative, which is the mistake `POST /prices/evaluate` was introduced to
end for a single price. This is the same argument for a whole document.

**Nothing is written.** No amendment pool is emptied, no credit lot is minted,
no claim is taken: it is the read-only twin of the issuance pass.

| Parameter | In | Default | Notes |
| --- | --- | --- | --- |
| `contractId` | path | — | The contract to project. |
| `at` | query | now | Project as of this instant — a date (`YYYY-MM-DD`) or an RFC 3339 timestamp. A malformed value is a `400`, never a silently substituted "now". |

```bash
curl -s "$FYNEX_API_BASE/contracts/42/invoices/upcoming" \
  -H "Authorization: Bearer $FYNEX_API_KEY"
```

```json
{
  "contractId": 42,
  "asOf": "2026-09-20T12:00:00Z",
  "nextIssueDate": "2026-10-01",
  "currency": "EUR",
  "lines": [
    {
      "source": "subscription",
      "description": "Subscription — 2026-09-01 to 2026-10-01",
      "quantity": "1",
      "unitPriceMinor": 10000,
      "amountMinor": 10000,
      "periodStart": "2026-09-01",
      "periodEnd": "2026-10-01"
    },
    {
      "source": "usage",
      "description": "api.requests usage — 2026-09-01 to 2026-10-01",
      "quantity": "12000",
      "amountMinor": 4800,
      "periodStart": "2026-09-01",
      "periodEnd": "2026-10-01",
      "note": "the period is still open, so the quantity keeps accruing until it closes"
    }
  ],
  "subtotalMinor": 14800,
  "grandTotalMinor": 14800,
  "creditAvailableMinor": 5000,
  "creditAppliedEstimateMinor": 5000,
  "collectibleEstimateMinor": 9800,
  "notes": [
    "every amount is pre-tax: the recurring lane copies its tax treatment from a prior invoice on this contract at issue time, and this projection does not resolve it",
    "metered lines are rated with a zero rollover carry, as the finance summary is; the close pass resolves the real carry chain when the period closes"
  ],
  "estimate": true
}
```

| Field | Notes |
| --- | --- |
| `asOf` | The instant projected. Metered usage accrues after it. |
| `nextIssueDate` | The earliest period end that falls due — the day the next document would appear. Absent when nothing is due. |
| `currency` | The one currency the totals are in. A line in another currency is listed and **excluded** from the totals with a note; money is never converted to produce a nicer figure. |
| `source` | `subscription`, `proration`, `usage` or `seat` — which engine produced the line. |
| `quantity` | Decimal string: `1` for a recurring term, the period's aggregated quantity for usage, the period-weighted seat count for seats. |
| `unitPriceMinor` | Present only when the line **has** a single unit price. Absent for tiered, graduated, volume, package and percentage usage lines: their price varies by band or is not per unit at all, and an average here would let you recompute a different amount. |
| `amountMinor` | Pre-tax and **signed**. A mid-term downgrade credit is negative because that is what it does to the document's face value; on the issued invoice it rides as a discount on the recurring line. |
| `periodStart`, `periodEnd` | The span the line covers. |
| `note` | Why this particular line is an estimate, or why it is zero — a term still in trial, a period still accruing, a credit that lands on another line. |
| `subtotalMinor` | Sum of the included lines, pre-tax. |
| `grandTotalMinor` | The projected document total. It **equals** `subtotalMinor`: tax is not projected (see below). |
| `creditAvailableMinor` | The contract's drawable stored value in this currency. Zero while the credits capability is off. |
| `creditAppliedEstimateMinor` | What the drawdown would consume: the available balance bounded by the document total. |
| `collectibleEstimateMinor` | What a payment rail would be asked for after credit — the figure your buyer would see. |
| `notes` | Every reason the figures are estimates and every exclusion made: the absent tax, an unpriced metric, a foreign-currency line, a capability switched off. Read them; they are not decoration. |
| `estimate` | Always `true`. |

**Every figure is an estimate, and the response says so.** Metered usage keeps
accruing after `asOf`; the period close resolves a rollover carry this
projection assumes is zero; an unbilled amendment can still move before the
recurring pass binds it. Reconcile against the issued document, never the
other way round.

**Amounts are pre-tax, deliberately.** The recurring lane does not compute tax
either — it **copies** the treatment (category, rate, taxable flag) from a
prior invoice on the same contract when it issues, and resolving that needs
the document history this read does not touch. Guessing a rate would put a
number on your screen no jurisdiction agreed to, so no tax is projected and
`grandTotalMinor` equals `subtotalMinor`. Read an issued document's tax from
`GET /invoices/{invoiceId}`.

**Failures:** `400` (a malformed `contractId` or `at`) · `404` (no such
contract, or not yours — the two are deliberately the same answer) · `429` ·
`401` / `403` · `501` when the projection is not enabled on the deployment.

## 22. `GET /contracts/{contractId}/test-clock` and `POST /contracts/{contractId}/test-clock/advance`

**Test-mode (demo) accounts only.** A live account answers `403` on both: a
live subscription's renewal is real revenue and a real document sent to a real
customer, and no key may simulate that.

Subscription flows run on billing **dates**. A sandbox that makes you wait 31
real days for the first renewal is not a sandbox, so a contract can be given a
clock of its own and moved forward.

### `GET …/test-clock`

```bash
curl -s "$FYNEX_API_BASE/contracts/42/test-clock" \
  -H "Authorization: Bearer $FYNEX_API_KEY"
```

```json
{
  "contractId": 42,
  "now": "2026-02-01T00:00:00Z",
  "simulated": true,
  "frozenAt": "2026-01-01T09:15:00Z",
  "updatedAt": "2026-01-01T09:16:12Z"
}
```

| Field | Notes |
| --- | --- |
| `contractId` | The contract this clock belongs to. Clocks are **per contract**: advancing one never moves another, yours or anyone else's. |
| `now` | What the billing engines treat as *now* for this contract. |
| `simulated` | `false` while the contract still runs on real time — a contract that has never been advanced has no clock row, and reading does not create one. |
| `frozenAt` | Real time when the clock was created; absent while `simulated` is `false`. With `now` it says how far the contract has been pushed. |
| `updatedAt` | When the clock last moved; absent while `simulated` is `false`. |

### `POST …/test-clock/advance`

```bash
curl -s -X POST "$FYNEX_API_BASE/contracts/42/test-clock/advance" \
  -H "Authorization: Bearer $FYNEX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"to": "2026-02-01T00:00:00Z"}'
```

```json
{
  "clock": {
    "contractId": 42,
    "now": "2026-02-01T00:00:00Z",
    "simulated": true,
    "frozenAt": "2026-01-01T09:15:00Z",
    "updatedAt": "2026-01-01T09:16:12Z"
  },
  "advanced": true,
  "lifecycle": {
    "trialsActivated": 0,
    "trialsCanceled": 0,
    "trialsPastDue": 0,
    "renewed": 1,
    "resumed": 0,
    "canceled": 0,
    "expired": 0,
    "skipped": 0,
    "cronDisabled": false
  },
  "invoices": {
    "issued": 1,
    "sent": 1,
    "skipped": 0,
    "failed": 0,
    "passDisabled": false
  },
  "notAdvanced": [
    "usage period close (billing.usage.close_pass)",
    "usage invoicing (billing.usage.invoice_pass)",
    "dunning e-mail for a past-due subscription",
    "off-session auto-charge of an issued invoice (billing.invoice.auto_charge)"
  ]
}
```

`to` is an RFC 3339 instant. It must be **after** the contract's current clock
— read it from `GET …/test-clock` — and at most **366 days** after it.

**Re-sending the instant the clock already sits at is a no-op**: `200` with
`advanced: false`, nothing renewed and nothing issued twice. That makes the
call safe to retry. A target in the past, or more than 366 days ahead, is
`400`: a clock never runs backwards, because the documents it causes are
immutable and numbered.

The advance then runs, **for that one contract only**, the two engines the
scheduled passes use:

| Response block | What ran |
| --- | --- |
| `lifecycle` | The subscription state machine: trials ending, pauses elapsing, scheduled cancellations and fixed ends taking effect, and terms rolling over. Each renewal emits a `subscription_renewed` event on the contract's revenue timeline, exactly as the cron's would. Counts are subscriptions, not amounts. |
| `invoices` | The recurring invoice lane: the renewed term's document, composed from the contract's previous invoice, numbered, and sent. At most one per advance. `skipped` covers a term already claimed by an earlier advance or by the real cron, a contract with no prior invoice to continue from, no customer on the contract, or a currency disagreement. |

`lifecycle.cronDisabled` and `invoices.passDisabled` name an operator switch
that stopped half the loop — `billing.subscription.lifecycle_cron` and
`billing.recurring.invoice_pass` respectively. If either is `true`, ask Fynex
to enable the flag rather than debugging your integration; see
[Sandbox & testing](/billing-api/v1/docs/sandbox).

**`notAdvanced` is the honest part.** This clock moves the subscription state
machine and the recurring invoice. It does **not** close usage periods, mint
usage invoices, send dunning e-mail, or run off-session auto-charge — those
stay on real time in their own passes. Do not build a test that waits on one
of them after an advance.

**Failures:** `400` (a malformed `contractId`, a missing or unparseable `to`, a
target at or before the current clock, or one more than 366 days ahead) ·
`403` (a live-mode account, or an account whose mode cannot be confirmed) ·
`404` (no such contract, or not yours — deliberately the same answer) · `429` ·
`401` · `500`.

---

## Dashboard-only routes not on this surface

The platform's **cost model** (fynex-billing#277: `CostRateCard`, the cost
engine and its dry run) is served on the dashboard surface `/billing/v1`
only — `POST|GET /billing/v1/cost-rate-cards`, `GET …/{id}`,
`POST …/{id}/activate`, `POST /billing/v1/costs/evaluate` — behind the
backoffice `billing:read` / `billing:update` permissions. Nothing of it is
published here yet; the object's shape is still moving, and a public route is
a one-way door. Reference: `docs/billing-cost-model.md`.

## Status codes, at a glance

| Code | When |
| --- | --- |
| `200` | Success. Also a replayed write: the same `Idempotency-Key` again, or a customer that already existed. |
| `201` | Created — the first call of a write that minted something (`POST /contracts`, `POST /contracts/{contractId}/amendments`, `POST /invoices`, `POST /contracts/{contractId}/subscriptions`, `POST /contracts/{contractId}/credits/top-up`). |
| `400` | Malformed id, paging value, date, or an unrecognized `origin`/`status`/`invoiceType`. |
| `401` | Missing or invalid key. Body may be plain text. |
| `403` | Valid key, seller account not active. |
| `404` | Unknown id — or one belonging to another seller. |
| `422` | The object exists but the action does not apply — `POST /send`, the subscription writes, an `Idempotency-Key` already spent on a different credit grant, contract or invoice, a `customerRef` already held by another customer, an amendment whose `expectedBaseVersion` is stale or whose status transition is illegal. |
| `429` | Rate limited. Honour `Retry-After`. `POST /contracts`, `POST /contracts/{contractId}/amendments` and `POST /invoices` each carry an extra per-seller quota on top of the surface-wide budget. |
| `500` | Server fault. Retry GETs with backoff. |
| `503` | The per-seller write quota's limiter is unreachable, so those three writes fail closed. Honour `Retry-After` and retry — see [Errors → rate limiting](/billing-api/v1/docs/errors#when-the-write-quotas-fail-closed). |
| `413` | The request body exceeds 1 MiB. |
| `501` | Capability not enabled for this deployment (credits). |

Every error body is `{"error": "…"}` except the auth layer's, which may be
plain text — treat any non-2xx as failed regardless of body shape.

---

# Workflows

End-to-end recipes for the things integrators actually build. Each one names
the endpoints, the order, and the failure modes worth handling.

## Collect payment on an invoice

Invoices are issued by the billing engine (subscription renewals, usage
period close) or by your team in the dashboard. This API turns an issued
invoice into something a customer can pay.

```bash
curl -s -X POST $FYNEX_API_BASE/invoices/4180/send \
  -H "Authorization: Bearer $FYNEX_API_KEY"
```

```json
{
  "invoice": { "id": 4180, "status": "sent", "paymentLinkId": 991, "...": "..." },
  "paymentLinkUrl": "https://pay.example-host/p/7bc1f2a9e4d5"
}
```

The URL points at the Fynex hosted payment page for your deployment (the host is configured per environment, so do not hard-code it — always use the URL this response returns). Send it to your customer, embed it, or redirect to it. When the document is
configured for email delivery, Fynex also emails the customer — calling this
endpoint is what triggers that.

**Retrying is safe, and needs no idempotency header.** The invoice's unique
payment-link association is the durable safeguard: an invoice that already has
a link returns that same link rather than minting a second, even for
simultaneous calls, and does not re-send the email.

**The payment carries the document.** Whichever rail collects an invoice — the
hosted link or off-session auto-charge — the resulting payment is stamped with
what it settled, so you never have to reconstruct it from an order reference:

- The payment link's `metadata` holds `billingInvoiceId`, `invoiceNumber`,
  `contractId`, `agreementNumber` and `sellerCustomerId`. The contract and its
  agreement number reach the payments side nowhere else.
- The payment row carries `billing_invoice_id`, and so does its checkout
  session — a direct answer to "which invoice did this payment collect", with
  no join through the link.
- The acquirer's order description reads `Invoice <number>` (Paysafe
  `description`, Solidgate `order_description`), which is what makes the
  document visible in acquirer dashboards and dispute correspondence. The
  merchant reference is deliberately unchanged: it is the duplicate-detection
  and settlement-mapping key and stays machine-shaped.
- `PaymentCompleted` carries `invoiceNumber` and `contractId` when the payment
  collected an invoice, so it can be joined to `InvoicePaid` and to the
  document itself.

Three cases answer `422`, and they are not retryable — the state has to
change first:

| Message | Meaning |
| --- | --- |
| `invoice is collected by bank transfer and cannot create a payment link` | The document instructs the buyer to wire money to a named account. A simultaneously payable card link would let a transfer and a card charge both succeed. |
| `invoice has nothing left to collect` | Zero total, or stored credit already covers it. There is nothing to ask the customer for. |
| `invoice is not in a sendable state` | Not issued yet (`draft`), or already `paid`, `voided` or `written_off`. A settled invoice never hands out a payment link. It is also the catch-all: if `status` reads `issued` and send still answers this, the document itself is faulty (a collection snapshot that is missing or unreadable) — nothing you send will fix it, so raise it with Fynex rather than retrying. |

## Know when an invoice is paid

`InvoicePaid` on the webhook pipe is the push signal (see **Webhooks**);
polling remains right for reconciliation.

Do not assume the settlement was something you or the buyer did. A seller can
enable off-session collection (`billing.invoice.auto_charge`), and then a sent
or overdue invoice is charged against the customer's saved card by a
background loop — `paidVia` still reads `payment_link`, because it settles on
the same rail. Sellers can likewise have documents raised for them by the
recurring and usage invoice passes. Both are per-seller operator switches, off
until turned on, and both mean `GET /invoices` can change with no call of
yours: reconcile against the list for a period rather than against the
documents you happen to have touched.

```bash
curl -s $FYNEX_API_BASE/invoices/4180 \
  -H "Authorization: Bearer $FYNEX_API_KEY"
```

Read `invoice.status`:

| Status | Meaning |
| --- | --- |
| `draft` | Not yet issued. Not a legal document; not collectible. |
| `issued` | Numbered and legally real; no collection instrument yet. |
| `sent` | A payment link exists; awaiting payment. |
| `paid` | Settled. `paidVia` says how: `payment_link` (hosted checkout), `bank_transfer` (a matched deposit) or `credit` (stored credit covered it in full). |
| `overdue` | Past its `dueDate` and still unpaid. |
| `voided` | Cancelled before money moved; `voidedAt` and `voidReason` explain. |
| `written_off` | Collection abandoned; `writtenOffAt` and `writeOffReason` explain. |

Treat this list as open: a status you do not recognize should not crash your
integration. Branch on the ones you handle and fall through for the rest.

Poll open invoices every few minutes at most; a fixed 15-minute sweep over
`GET /invoices` is usually better than polling each document. Bank transfers
settle when the deposit is matched, which is hours-to-days — do not build a
tight loop waiting for one.

A credit note (`invoiceType: "credit_note"`) is a **separate document** that
corrects an earlier one, named in `originalInvoiceNumber`. It does not change
the original: to compute what a customer owes, sum the invoice and its credit
notes rather than expecting the original to shrink. Fetch them with
`?corrects=<invoiceNumber>`.

## Reconcile an invoice against its payout

"The customer paid — where is the money?" is one call per invoice:

```bash
curl -s "$FYNEX_API_BASE/invoices/4180/settlement" \
  -H "Authorization: Bearer $FYNEX_API_KEY"
```

Read it in this order: each entry in `payments` is one attempt on the
invoice's collection link, failed ones included — the newest `settled` one is
your money. Inside it, each entry in `payouts` is a payout's claim on that
payment: `state: "finalized"` with a `completedAt` means it left; `reserved`
means it is scheduled; an empty `payouts` array means the payment settled but
has not been picked into a payout yet. `attributedMinor` is that payout's
slice — a payment can be split across payouts, and the slices sum to what
left.

An invoice settled by bank transfer or stored credit has no link trail, and
the response's `note` says which case you are looking at — do not treat an
empty `payments` list as an error. Payout detail beyond the id lives on the
Payments API; this call gives you the id to look it up with.

## Reconcile a period

**For month-end, take the file.** One call returns every invoice of the
window as CSV (or NDJSON — the list API's objects, one per line), with money
in integer minor units and column names that say so. Both dates are required;
there is deliberately no default window:

```bash
curl -s "$FYNEX_API_BASE/invoices/export?issuedFrom=2026-08-01&issuedTo=2026-09-01" \
  -H "Authorization: Bearer $FYNEX_API_KEY" -o invoices_2026-08.csv
```

The paged list below remains right when you are feeding an application rather
than closing books. Bound it by issue date rather than walking history until
you cross the boundary — the range is half-open (`issuedFrom` inclusive,
`issuedTo` exclusive), so adjacent months never double-count a document:

```bash
# First page of August
curl -s "$FYNEX_API_BASE/invoices?issuedFrom=2026-08-01&issuedTo=2026-09-01&limit=100" \
  -H "Authorization: Bearer $FYNEX_API_KEY"

# Next page: pass the previous response's nextCursor
curl -s "$FYNEX_API_BASE/invoices?issuedFrom=2026-08-01&issuedTo=2026-09-01&limit=100&cursor=4081" \
  -H "Authorization: Bearer $FYNEX_API_KEY"
```

Filters worth combining:

- `contractId=42` — one customer relationship.
- `sellerCustomerId=7` — every document addressed to one of your customers,
  across contracts. This is how you pull a customer statement without walking
  the whole period.
- `status=sent` — one lifecycle state (`draft`, `issued`, `sent`, `paid`,
  `overdue`, `voided`, `written_off`).
- `invoiceType=credit_note` — corrections only. The full set is `standard`,
  `credit_note`, `simplified` (retail below the threshold) and `modified` (UK
  retail above it, VAT-inclusive line prices).
- `corrects=UK2607AA-2608AAB` — the credit notes issued against one invoice
  number, without scanning history for them.
- `origin=usage` — the subsystem that produced the document: `recurring`,
  `usage`, `milestone`, `project`, `one_time`, `adhoc`, `marketplace`.
  (`subscription` is a deprecated alias of `recurring`: still accepted, no
  longer produced, and it resolves to the same set as `recurring` — see below.)

`origin`, `status` and `invoiceType` reject an unrecognized value with `400`,
so a typo cannot masquerade as "no such invoices". `contractId` and `corrects`
are matched as given: a well-formed but wrong value legitimately returns an
empty page, so check those two against `GET /contracts` and the invoice number
you meant.

`origin=recurring` and `origin=subscription` return the same rows. The second
is the pre-schema name, and documents issued before the rename still carry it
— filtering either way includes both, so a monthly report cannot silently lose
the older half.

`customerName` and `customerEmail` are returned on the single-invoice read and
on send, but **not** on list rows: they live in the document's frozen payload,
and reading them for a whole page would mean parsing every document. To group
a report by customer, filter with `sellerCustomerId` rather than fetching each
invoice — that keeps a monthly reconciliation to a handful of requests instead
of one per document.

For accounting, the figures per document are `grandTotalMinor` (what was
invoiced), `creditAppliedMinor` (funded by stored credit rather than cash),
and `collectibleMinor` (the grand total less that credit — what collection
asks the customer for).

Two traps worth stating plainly:

- **`creditSettledMinor` is not a payment total.** It counts only the stored
  credit drawn down; a card- or transfer-paid invoice reports `0` there.
- **`collectibleMinor` does not drop to zero when an invoice is paid.** It
  describes the ask, not the outcome.

So "has this been paid?" is answered by `status` (and `paidVia` for how), never
by arithmetic on the amounts.

Because paging is keyset, not offset, documents issued while you are walking
the pages never shift rows into or out of a page you already fetched. Start a
fresh walk to pick them up.

## Watch usage against plan limits

For metered contracts, the snapshot answers "where is this customer in the
current period":

```bash
curl -s $FYNEX_API_BASE/contracts/42/usage \
  -H "Authorization: Bearer $FYNEX_API_KEY"
```

```json
{
  "contractId": 42,
  "metrics": [
    {
      "metricName": "api_calls",
      "used": "10250",
      "includedUnits": "10000",
      "capQuantity": "50000",
      "capMode": "hard",
      "percentOfCap": "20.5",
      "percentOfPlan": "102.5",
      "periodStart": "2026-08-01",
      "periodEnd": "2026-08-31"
    }
  ]
}
```

Useful signals:

- `percentOfPlan` above 100 means the customer is into overage — that is what
  will be rated onto the next usage invoice.
- `capQuantity` with `capMode: "hard"` is the ceiling enforcement uses. Warn
  customers well before `percentOfCap` reaches 100 rather than at it.
- Quantities are decimal strings; compare with a decimal type.

An unknown `contractId` answers `404`. A real contract with no metered usage
answers `200` with an empty `metrics` array — that is a valid state, not an
error.

Usage aggregates continuously and closes at period end, so a snapshot is a
live read, not a final invoice figure. Reconcile money against invoices.

## Reconcile stored credit

Balances across all contracts:

```bash
curl -s $FYNEX_API_BASE/credits \
  -H "Authorization: Bearer $FYNEX_API_KEY"
```

```json
{
  "balances": [
    {"currency": "EUR", "creditType": "purchased", "balanceMinor": 250000, "isLiability": true}
  ]
}
```

`isLiability: true` marks credit that was **paid for** — `purchased`,
`enterprise` and the engine-minted `proration` (unserved time returned on a
mid-term downgrade) — which is unearned revenue you still owe service against.
Everything you granted (`promotional`, `manual`, `gift`, `ai_token`,
`marketplace`) is not a liability. Finance wants these separated, so do not
sum them blindly. Paid-for credit also never expires; granted credit may.

One contract's balances plus its ledger:

```bash
curl -s "$FYNEX_API_BASE/contracts/42/credits?limit=50" \
  -H "Authorization: Bearer $FYNEX_API_KEY"
```

The ledger is append-only and newest-first. `kind` is `topup` (credit granted
or bought), `deduction` (consumed into an invoice) or `expiry` (a lapsed lot
removed); `signedDeltaMinor` is positive for topups and negative for the other
two, and `invoiceId` links a deduction to the invoice it funded. Corrections are new rows, never edits, so
replaying the ledger from the beginning always reproduces the current
balance.

Page it the same way as invoices: `limit` sets the page, and while `hasMore`
is true you pass `nextCursor` back as `cursor`. A long-lived contract's
history exceeds one page, and a ledger you cannot read to the end cannot be
reconciled.

## See what the next invoice will carry

`GET /contracts/{contractId}/invoices/upcoming` projects the contract's next
document — before it exists, and without writing anything.

```bash
curl -s "$FYNEX_API_BASE/contracts/42/invoices/upcoming" \
  -H "Authorization: Bearer $FYNEX_API_KEY"
```

The `lines` array is the document as it would be composed, each line tagged
with the engine that produced it: `subscription` (a term falling due, priced
at a scheduled plan change when one takes effect by the term's end),
`proration` (an unbilled mid-term amendment the engine would bind to that
term — a charge as an extra line, a credit as a discount on the recurring
one), `usage` (an open metered period rated by the same engine
`POST /prices/evaluate` answers with) and `seat` (a per-seat period rated from
the contract's seat schedule, to date). `nextIssueDate` is the day the
document would appear, and `collectibleEstimateMinor` is what your buyer would
be asked for after stored credit is drawn down.

**Every figure is an estimate, and `estimate: true` says so on every
response.** Metered usage keeps accruing after `asOf`; the period close
resolves a rollover carry this projection assumes is zero; an amendment can
still move before the recurring pass binds it. Amounts are also **pre-tax**:
the engine copies a document's tax treatment from a prior invoice on the same
contract when it issues, so no tax is projected here and `grandTotalMinor`
equals `subtotalMinor`. Read the `notes` array — it names every reason the
figures are estimates and every exclusion made (an unpriced metric, a
foreign-currency line, a capability switched off), which is what stops a
number in this response from being read as a commitment.

Use it to show a customer what is coming, to check a plan change landed
before the term bills, or to catch an unpriced metric while there is still
time to price it. Reconcile against the **issued** document
(`GET /invoices/{invoiceId}`), never the other way round.

## Track subscription state

```bash
curl -s "$FYNEX_API_BASE/subscriptions?contractId=42" \
  -H "Authorization: Bearer $FYNEX_API_KEY"
```

To answer "is this customer currently being billed, and for how much", read
`status`, `priceMinor` + `currency`, and `billingFrequency`. There is no
separate plan object — those three fields *are* the plan.

Watch for:

- `pendingPriceMinor` / `pendingPriceChangeAt` — a scheduled change (usually a
  downgrade) that takes effect at term end. Showing only `priceMinor` will
  misinform a customer who already requested a change.
- `cancelRequestedAt` / `cancelEffectiveAt` — cancellation with notice. The
  subscription keeps serving until the effective date, so `status` alone is
  not "are they leaving".
- `trialEnd` with `status: "trial"` — not yet paying.
- `status: "past_due"` — collection failed. **What happens next depends on a
  switch the seller controls**, so do not hard-code either answer:
  - With off-session collection enabled (`billing.invoice.auto_charge`), a
    failed charge is retried on a bounded schedule — a few attempts, spaced
    apart — and the subscription is moved to `past_due` only once that budget
    is spent. It can come back on its own when a later payment succeeds, which
    is what `BillingSubscriptionRecovered` announces. Do not treat `past_due`
    as terminal and do not start your own retry loop against it; you would be
    charging the same card twice.
  - Without it, nothing retries: `past_due` is a queue for you to work, and
    the way out is the customer paying the outstanding invoice or a new card
    being added.

  In both cases the events to watch are `BillingSubscriptionPastDue` and
  `BillingSubscriptionRecovered`, and the invoice's `outstandingMinor` is what
  says how much is actually still owed.
- `pauseEndsAt` — an auto-resume date for a paused subscription.

Lifecycle changes (cancel, pause, plan change) are dashboard operations in
this version.

## Retries and rate limits

Reads are safe to retry. So is `POST .../send`: the invoice can hold only one
payment link, so a retry returns the existing one.

- `429` — you exceeded the per-seller budget. Wait the `Retry-After` seconds;
  do not retry sooner. `X-RateLimit-Remaining`, when present, lets you slow
  down before you hit it — the limiter fails open during an outage, and then
  no rate-limit headers are sent at all.
- `5xx` — retry with exponential backoff and jitter.
- `4xx` other than `429` — do not retry; the request has to change.

A retry loop that ignores `Retry-After` turns one throttled request into a
sustained overage. If you are paging a large history, a small delay between
pages is cheaper than being throttled mid-walk.

---

# Usage ingestion

Metered billing has two halves. This API reads the meter — `GET
/billing-api/v1/contracts/{contractId}/usage` returns where a contract stands
in its open period. **Writing to that meter happens on a different prefix**,
`/billing-api/v1/usage`, documented here.

Same API, same credential: `Authorization: Bearer sk_test_…` / `sk_live_…`,
same seller tenancy, same `{"error": "…"}` envelope.

> **A second, older path exists.** Metering shipped before this API did and
> took the top-level prefix `/usage/v1`. Every route below still answers there
> too, unchanged — but `/billing-api/v1/usage/…` is the documented one, and new
> integrations should use it. The single exception is the inbound webhook
> receiver in §5, which stays on `/usage/v1` on purpose: it is authenticated by
> payload signature rather than by your key, and every operation under
> `/billing-api/v1` is key-gated.

The loop is: **register a metric → price it → send events → read the meter →
the period closes into an invoice.**

> **One convention differs here, and it matters.** Everything under
> `/billing-api/v1` reports money in integer minor units. A *price
> configuration* is the exception: unit prices and tier amounts are decimal
> strings in **major** units (`"0.0001"` is one hundredth of a cent per unit),
> because per-unit rates are routinely finer than a minor unit. The rate is
> exact; the rounding to minor units happens once, at invoicing, by the policy
> the config names. Amounts you read back on an invoice are minor units as
> usual.

---

## 1. Register a metric

A metric is the meter's identity: what is counted, in what unit, and how
several events in one period combine.

```bash
curl -s -X POST $FYNEX_API_BASE/usage/metrics \
  -H "Authorization: Bearer $FYNEX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"api_calls","unit":"call","aggregation":"sum","description":"Billable API requests"}'
```

```json
{
  "name": "api_calls",
  "version": 1,
  "unit": "call",
  "aggregation": "sum",
  "description": "Billable API requests"
}
```

| Field | Rules |
| --- | --- |
| `name` | Registry identifier — this is what events reference. |
| `unit` | Free text, at most 32 characters (`call`, `GB`, `token`, `seat`). |
| `aggregation` | `sum`, `max`, `min`, `last`, `count` or `count_unique`. How the period's events collapse into one billable quantity. |
| `uniqueKey` | `count_unique` only: the event-metadata key whose distinct values are counted — a user id, an endpoint, a tenant. Required there, forbidden elsewhere. |
| `description` | Optional, at most 256 characters. |

Answers `201` with the registered metric, or `400` naming the field that was
wrong.

A `count_unique` metric answers "how many *different* users called", where
`count` answers "how many calls". Its events **must** carry the `uniqueKey`
field in `metadata` (`400` otherwise), and at most **1,000 distinct values**
count per contract and period: past that, new values stop counting and the
balance is flagged as overflowed — the figure becomes a floor. If your
cardinality is legitimately higher, meter with `count` and dimension in your
own analytics instead. Metrics are versioned: `version` increments when a registration
changes the definition, and events are attributed to the version current when
they arrive. `uniqueKey` is part of that definition, like `unit` and
`aggregation`: it cannot be edited in place — registering the same name again
with a different `uniqueKey` creates version N+1, and events keep being
attributed to whichever version is in force when they arrive.

There is no time-weighted aggregation — GB-hours, active-seat-days and the
like. Compute the weighted figure on your side and send it as the event
quantity of a `sum` metric; a `max` or `last` metric over a gauge reading is
the other honest shape.

- `GET /billing-api/v1/usage/metrics` — every metric you have registered.
- `GET /billing-api/v1/usage/metrics/{name}` — one metric, `404` when it is not registered.

## 2. Price the metric on a contract

Pricing is per contract and per metric, and it is versioned by date — a new
configuration does not rewrite history, it takes effect from a date you state.

```bash
curl -s -X PUT "$FYNEX_API_BASE/usage/contracts/42/metrics/api_calls/price" \
  -H "Authorization: Bearer $FYNEX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "effectiveFrom": "2026-09-01",
    "config": {
      "scheme": "graduated",
      "currency": "EUR",
      "rounding": "half_up",
      "unitPrice": "0",
      "tiers": [
        {"upTo": "10000", "unitPrice": "0.004", "flatAmount": "0"},
        {"upTo": null,    "unitPrice": "0.002", "flatAmount": "0"}
      ],
      "allotment": {"includedUnits": "1000", "rollover": "none"},
      "commitment": {"minimumMinor": 5000, "spendCapMinor": 500000}
    }
  }'
```

### The seven pricing schemes

| `scheme` | How the quantity is charged |
| --- | --- |
| `per_unit` | `units × unitPrice`. One rate, no bands. |
| `graduated` | Every band charges its own rate for the units inside it — progressive, like income tax. |
| `volume` | The band the **total** quantity lands in prices **all** units at that band's rate. |
| `tiered` | Stairstep: the band the total lands in charges its `flatAmount`, whatever the exact count. |
| `package` | Per started block: `ceil(quantity / packageSize) × unitPrice`, where `unitPrice` is the price of one package. A partial package charges in full — 1,001 units at "per 1,000" is two packages. |
| `percentage` | `percentBps` basis points (100 = 1%) of the aggregated quantity, which for this scheme is itself a monetary amount in major units — a GMV or transaction-value meter. |
| `per_seat` | `unitPrice` per seat per FULL period, prorated to the day: `unitPrice × seatDays ÷ periodDays`. Rated from a recorded seat history rather than from ingested events — see [Per-seat pricing](#per-seat-pricing) below. |

`graduated` and `volume` are the classic trap — same bands, same quantity,
different money. Read the two rows above before choosing. There is no `flat`
scheme on purpose: a fixed per-period fee is what a subscription price is,
and a usage floor is `commitment.minimumMinor` on any scheme.

### The rest of the configuration

| Field | Meaning |
| --- | --- |
| `currency` | ISO 4217. Must match the contract's currency. |
| `rounding` | Applied once, when the exact decimal amount collapses to minor units: `half_up`, `half_even`, `up` or `down`. No default — state it. |
| `unitPrice` | The `per_unit` rate, the `package` scheme's price per package, and the `per_seat` scheme's price per seat per full period. Decimal string in major units; ignored by banded schemes. |
| `packageSize` | The `package` scheme's block size in units, decimal string. Required there, forbidden elsewhere. |
| `percentBps` | The `percentage` scheme's rate in integer basis points (100 = 1%). Required there, forbidden elsewhere. |
| `tiers[].upTo` | The band's inclusive upper bound in units. `null` marks the last, unbounded band. Bands are ordered and contiguous from zero. |
| `tiers[].unitPrice` | The rate for `graduated` and `volume`. |
| `tiers[].flatAmount` | The flat charge for `tiered`. |
| `allotment.includedUnits` | The first N units of each period rate to zero. |
| `allotment.rollover` | `none`, or `rollover` to carry an unused allowance into the next period. |
| `commitment.minimumMinor` | Floor: the period bills at least this much, in minor units. |
| `commitment.spendCapMinor` | Ceiling on the money the period can bill, in minor units. |
| `commitment.usageCap` | Ceiling on the billable quantity, as a decimal string. |

Answers `200` with the stored configuration and its `version`. `GET` on the
same path returns the current one, or `404` with `this metric has no price
configured` — which is the check to run before you start sending events.

> A **usage limit policy** is a separate control from `commitment.usageCap`:
> it is what `GET /billing-api/v1/contracts/{contractId}/usage` reports as
> `capQuantity` / `capMode`, and a `hard` policy **rejects ingestion past the
> cap** (see the failure table below). A `soft` one only alerts.

### Per-seat pricing

`per_seat` prices what a customer HOLDS rather than what it consumes, so it is
the one scheme that is not rated from ingested events. It reads a **seat
history**: an append-only record of how many seats the contract holds and from
when, so a period with mid-cycle changes can be priced day by day.

Configure it against the reserved metric **`platform.seat.active`**:

```bash
curl -s -X PUT "$FYNEX_API_BASE/usage/contracts/42/metrics/platform.seat.active/price" \
  -H "Authorization: Bearer $FYNEX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "effectiveFrom": "2026-01-01",
    "config": {
      "scheme": "per_seat",
      "currency": "EUR",
      "rounding": "half_up",
      "unitPrice": "10.00"
    }
  }'
```

**The metric and the scheme are reserved for each other.** `per_seat` is
configurable **only** on `platform.seat.active`, and `platform.seat.active`
accepts **only** `per_seat` — either mismatch answers `400`. The pairing is not
housekeeping: a `per_seat` price on an ordinary metric is a period the close
pass can never rate (it has a quantity, and this scheme has no use for one), so
it would simply never bill; and any other scheme on the seat metric would rate
your seats from whatever quantity happened to be ingested against it rather
than from the recorded seat history.

`unitPrice` is the price of ONE seat for a FULL period. `allotment`,
`allotment.rollover` and `commitment.usageCap` are **refused** on this scheme —
they are denominated in "units", and the unit here is a day-weighted seat
integral rather than a consumption counter, so the same number would not mean
the same thing. `commitment.minimumMinor` and `commitment.spendCapMinor` work
exactly as they do everywhere else.

**How a period is priced.** Seats are integrated over **UTC calendar days**,
and each day is charged at the seat count that day ENDS with — the count in
effect after every seat change effective on that day or earlier:

```
amount = unitPrice × seatDays ÷ periodDays
```

Two consequences worth stating, because they are what everyone asks:

- A seat added on a day is charged for that day.
- A seat added **and** removed on the same day is charged for nothing. The day
  is not charged once per change; it is charged once, at the count the day ends
  with — so a same-day add and remove cannot double count.

**Worked example.** A contract on €10.00 per seat, January 2026 (31 days). It
opens the month with 3 seats, goes to 5 on the 11th, and back to 4 on the 21st:

| Days | Seats | Seat-days |
| --- | --- | --- |
| Jan 1–10 (10) | 3 | 30 |
| Jan 11–20 (10) | 5 | 50 |
| Jan 21–31 (11) | 4 | 44 |
| **Total** | | **124** |

124 seat-days ÷ 31 days = **4 seat-equivalents**, so the period bills
€40.00 — not €50.00 (the peak) and not €30.00 (the opening). Re-rating the
same history always produces the same amount; a correction is a new seat
change, never an edit of a recorded one.

**How a per-seat period is billed.** Exactly like every other metric's, and on
the same schedule: when the period has ended, it closes into one usage invoice
line and the invoicing lane puts that line on a document. Two things are worth
knowing because they are visible on the artifacts:

- The line's `quantity` is the period's **seat-days** — 124 in the example
  above, not 4. Seat-days is the exact integer the amount is derived from;
  the divisor (`periodDays`) and the seat-equivalent figure
  (`seatEquivalents`) are in the line's `ratingBreakdown` beside the day-run
  trace. The amount on the line is already the prorated money, so a document
  reads €40.00 whatever the quantity column says.
- A period with **no** seats recorded still closes, at zero — the same way a
  metric with no events does, so a `commitment.minimumMinor` trues up on
  silence rather than being skipped.

Because seats are not ingested, this lane is driven by the price document
rather than by arriving events: a contract is billed for seats from the first
period its per-seat price is effective for, on the contract's own billing
cycle. Periods that ended before the price existed are never billed
retroactively.

**Recording seat changes is not on this API yet.** Seats are recorded and read
on the dashboard surface — `POST` and `GET /billing/v1/contracts/{contractId}/seats`
— which needs a dashboard session, not a seller key. A seller-key route for
seat changes is a later slice; publishing one is a one-way door and the wire
shape of a seat event is exactly what would move. Until then, a `per_seat`
price you configure here is priced from the history recorded there, and
`GET /billing/v1/contracts/{contractId}/seats?periodStart=…&periodEnd=…`
returns the day-by-day breakdown above.

### Try a price before you commit to it

`POST /billing-api/v1/prices/evaluate` runs a configuration through the same
engine that bills, at any quantities you name — pure, nothing stored, no
contract required. It is how to see what `graduated` and `volume` do to the
same bands before agreeing a term, and it is what the number shown to a
customer during negotiation should come from.

```bash
curl -s -X POST "$FYNEX_API_BASE/prices/evaluate" \
  -H "Authorization: Bearer $FYNEX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"config": { …the same config… }, "quantities": ["0", "5000", "12000"]}'
```

Each result carries `amountMinor` (what the period would bill, after the caps
and the minimum), the flags saying which of those limits acted, and the exact
per-band `lines`. Up to 100 quantities per call, so a whole price curve is one
request.

A `per_seat` configuration answers `422` here: that scheme is rated from the
contract's seat schedule and is not evaluable per quantity — there is no
quantity that expresses *when* the seats changed. Read the period's amount and
its day-by-day breakdown from
`GET /billing/v1/contracts/{contractId}/seats?periodStart=…&periodEnd=…`
instead.

## 3. Send events

```bash
curl -s -X POST $FYNEX_API_BASE/usage/events \
  -H "Authorization: Bearer $FYNEX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contractId": "42",
    "metric": "api_calls",
    "quantity": "1250",
    "occurredAt": "2026-09-14T10:22:31Z",
    "idempotencyKey": "req-2026-09-14-8f21",
    "metadata": {"region": "eu-west-1"}
  }'
```

```json
{ "status": "ingested", "eventId": 91422, "metered": true }
```

| Field | Rules |
| --- | --- |
| `contractId` | The contract the usage belongs to, as a string. |
| `metric` | A registered metric name. |
| `quantity` | Decimal **string** — a JSON number is a float, and metered quantities must not pass through one. |
| `occurredAt` | RFC 3339. When the usage happened, not when you sent it: this is what places the event in a billing period. |
| `idempotencyKey` | Your own key for this event. Resending the same key with the same payload is a no-op. |
| `metadata` | Optional JSON object, carried for your own audit. |

`201` when the event was recorded, `200` with `"status": "duplicate"` when the
idempotency key was already seen. Retrying is always safe.

Two policies worth knowing before you design your keys:

- **The dedup window is unbounded.** Keys are unique per seller for the
  lifetime of the data — not a rolling window — so a key seen once is a
  duplicate forever. Derive keys from the event's identity (source record id,
  timestamp), never from a counter you might reset.
- **Backdating has no lower bound; the future is refused.** `occurredAt` may
  be arbitrarily far in the past — there is no backdating window to configure
  or to fall outside of. The only temporal rejection is an `occurredAt` ahead
  of now. Two edges worth knowing: an event older than the contract's first
  billing period is stored but stays unassigned until aggregation picks it
  up, and an event landing in a **closed** period follows the next rule down.
- **A late event never rewrites a closed period.** Ingestion compares
  `occurredAt` against the metric's close watermark: an event older than the
  last closed period is accepted and flagged for the correction workflow
  instead of silently changing an amount that may already be on an issued
  invoice. Corrections are operator-reviewed, so systematically late feeds
  should be fixed at the source rather than relied on.

### `metered: false` is the failure that looks like success

An accepted event is stored, but it only reaches the meter if the contract has
an active subscription whose schedule covers `occurredAt`. When it does not,
the response is still `201`, `metered` is `false`, and `message` says so:

```json
{
  "status": "ingested",
  "eventId": 91423,
  "metered": false,
  "message": "event stored but not attributed to a billing period; it will not appear on the usage meter until an active subscription's schedule covers occurredAt"
}
```

**Check this flag.** Ignoring it is how a month of usage goes unbilled with
every call answering 2xx.

### Batches

```bash
curl -s -X POST "$FYNEX_API_BASE/usage/events:batch" \
  -H "Authorization: Bearer $FYNEX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"events":[ {...}, {...} ]}'
```

```json
{
  "ingested": 2,
  "duplicates": 1,
  "rejected": 1,
  "items": [
    {"index": 0, "status": "ingested",  "eventId": 91424, "metered": true},
    {"index": 1, "status": "duplicate", "eventId": 91380, "metered": true},
    {"index": 2, "status": "rejected",  "reason": "unknown metric \"api_call\""},
    {"index": 3, "status": "ingested",  "eventId": 91425, "metered": true}
  ]
}
```

Answers `200` and reports each event by its `index`; a rejected item does not
stop the others. Two whole-batch failures exist: `413` when the batch holds
more than **500 events** (split it — nothing in a refused batch is recorded),
and `500` on an infrastructure fault — retry the entire batch, the idempotency
keys make that safe.

### CSV

`POST /billing-api/v1/usage/csv` takes `multipart/form-data` with two parts: `file` (the
CSV) and `mapping` (one JSON object describing which column is which). It is
the path for backfills and ERP exports rather than live traffic.

## 4. Read the meter back

Ingested and metered usage appears on this API:

```bash
curl -s "$FYNEX_API_BASE/contracts/42/usage" \
  -H "Authorization: Bearer $FYNEX_API_KEY"
```

The snapshot is a live read of the open period — it is not a closed figure.
When the period closes, the rated usage becomes invoice lines, and those are
what you reconcile money against.

## 5. Receiving usage from a third party

If the system that produces the usage is not yours to change — an ERP, a
vendor's platform — register an inbound endpoint and give them its URL instead
of your API key:

```bash
curl -s -X POST $FYNEX_API_BASE/usage/webhook-endpoints \
  -H "Authorization: Bearer $FYNEX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"description":"Acme ERP nightly meter"}'
```

The response carries the endpoint and **the signing secret, once** — it is not
retrievable later. The sender then posts usage to
`POST /usage/v1/webhooks/{token}`, signing the body with that secret. Manage
them with `GET /billing-api/v1/usage/webhook-endpoints`, `POST
/billing-api/v1/usage/webhook-endpoints/{id}/rotate-secret` and `POST
/billing-api/v1/usage/webhook-endpoints/{id}/revoke`.

**These are inbound.** They are how usage gets *in*; they are not
notifications about your billing. There are no outbound Billing webhooks in
v1 — for invoice settlement, poll.

## 6. When an event cannot be processed

Events that arrive over asynchronous paths — inbound webhooks, CSV rows —
cannot answer the sender, so a failure is parked instead of lost:

- `GET /billing-api/v1/usage/dead-letters` — what is parked, with the reason and the
  attempt count.
- `POST /billing-api/v1/usage/dead-letters/{id}/redrive` — reprocess one after fixing the
  cause (registering the missing metric, for instance).
- `POST /billing-api/v1/usage/dead-letters/{id}/discard` — abandon one deliberately.

Synchronous calls (`/events`, `/events:batch`) never dead-letter: the caller is
present, so the rejection comes back as an HTTP error.

## Failure contract

| Status | When |
| --- | --- |
| `400` | The event is malformed, `occurredAt` is in the future, or a **hard** usage cap is already exceeded — the event is not recorded. |
| `401` | Missing or invalid key. This surface answers only "not authorized", never the account's state. |
| `404` | Unknown metric, or a contract that is not yours. |
| `409` | The `idempotencyKey` was used before with a **different** payload. Same key, same payload is a duplicate (`200`), not a conflict. |
| `413` | The batch holds more than 500 events. Nothing in it is recorded — split it and resend. |
| `500` | Infrastructure fault. Retry — idempotency keys make that safe. |

Error bodies are the same `{"error": "…"}` envelope as the rest of billing.

---

# Webhooks

Billing emits signed webhook events through the same delivery pipe as the
Payments API: one endpoint registration, one signature scheme, one retry
policy — a receiver built for payment events handles billing events with no
new integration.

Events are recorded **in the same database transaction** as the state change
they describe. A delivered event can never describe a state that was rolled
back, and each transition emits exactly once — retries of your endpoint
receive the same event, never a second one.

## Event catalog

| Event | Fires when |
| --- | --- |
| `InvoiceIssued` | A document is issued — numbered, legally real. |
| `InvoiceSent` | The collection link was created (`POST /invoices/{invoiceId}/send`). |
| `InvoicePaid` | The document settled — by hosted payment, matched bank transfer, or stored credit covering it in full. `paidVia` says which. |
| `InvoiceOverdue` | A sent document passed its due date unpaid. |
| `InvoiceVoided` | The document was cancelled before money moved. |
| `InvoiceWrittenOff` | Collection was abandoned. |
| `CreditApplied` | An issued invoice drew stored credit down. |
| `CreditDepleted` | That drawdown consumed the contract's last available credit in the invoice's currency — top up, or the next invoice asks the customer for cash. |

## Subscription lifecycle

| Event | Fires when |
| --- | --- |
| `BillingSubscriptionStarted` | A subscription was created on a contract. |
| `BillingSubscriptionActivated` | It became active — a trial converted, or a term began. |
| `BillingSubscriptionRenewed` | A term rolled over into the next one. |
| `BillingSubscriptionPastDue` | Collection failed, or a trial ended with no payment method. |
| `BillingSubscriptionRecovered` | It came back from past due. |
| `BillingSubscriptionPaused` | Billing is suspended; no invoices are raised while it is. |
| `BillingSubscriptionResumed` | Billing continues **on the original schedule** — the period is not restarted. |
| `BillingSubscriptionCancelScheduled` | A cancellation was requested and takes effect after its notice period. |
| `BillingSubscriptionCanceled` | The cancellation took effect. |
| `BillingSubscriptionExpired` | A fixed end date was reached. |

**Why the `Billing` prefix.** The unprefixed `Subscription*` events on this
same pipe — `SubscriptionCreated`, `SubscriptionCharged`,
`SubscriptionCancelled`, `SubscriptionPastDue` — describe **payment-link
recurring subscriptions**, a different object with its own lifecycle. Two
products, one word. Subscribing to those will not tell you anything about a
billing subscription or its contract; subscribe to the prefixed family
instead.

**One exception, and you have to know it: `SubscriptionTrialWillEnd`.** The
prefixed family has no trial-ending event — a billing subscription's trial
reminder is emitted under that unprefixed name instead, one day before the
trial ends, with a payload of its own:

```json
{
  "source": "billing",
  "subscriptionId": 15,
  "contractId": 74,
  "trialEnd": "2026-09-17",
  "amountMinor": 820000,
  "currency": "EUR"
}
```

`source` is how you tell it apart: `billing` on the shape above, `payment_links`
on the larger shape a payment-link subscription's own trial reminder carries. It
is the OpenAPI discriminator for the two, so a generated client picks the right
type from it. (`contractId` is present on the billing shape only and remains a
valid tell for a receiver written before `source` existed — a payment-link
subscription has no contract.) The Payments API's webhook catalog publishes both
under the one event name. **If you want a trial-ending warning for a billing subscription, you
must subscribe to the unprefixed event** — the prefixed family does not carry
one.

Metered-usage threshold alerts (`UsageAlertFired`) were already delivered on
this pipe and are unchanged.

## The delivery body

Every delivery — whatever fired — is an envelope with the event's payload
nested inside it. The four envelope fields are the same for every family:

```json
{
  "eventId": 918204,
  "eventType": "InvoicePaid",
  "sellerAccountUuid": "6f2a1c1e-6a1e-4f10-9f2b-9c1d0b3a7e55",
  "occurredAt": "2026-08-20T14:02:11Z",
  "payload": { }
}
```

`eventId` is the delivery's stable identity: deduplicate on it. `occurredAt`
is when the state changed, not when the delivery was attempted — a retry
repeats the original value. `sellerAccountUuid` is your Fynex account, never
your customer.

Every event is also published in the OpenAPI document under its top-level
`webhooks` key, so a generated client carries the body type rather than
`map[string]any`.

## Payloads

Invoice events carry the document's public identity and money facts — the
same fields the API serves, and nothing it does not:

```json
{
  "invoiceId": 4180,
  "invoiceNumber": "UK2607AA-2608AAB",
  "contractId": 42,
  "sellerCustomerId": 7,
  "origin": "usage",
  "status": "paid",
  "paidVia": "payment_link",
  "currency": "EUR",
  "grandTotalMinor": 12000,
  "collectibleMinor": 12000,
  "dueDate": "2026-08-15T00:00:00Z",
  "occurredAt": "2026-08-20T14:02:11Z"
}
```

Buyer name and email are deliberately absent — webhook bodies land in the
receiver's logs, and the single-invoice read serves them to a caller who
holds the key. Treat the event as the signal and `GET /invoices/{invoiceId}`
as the source of truth.

A subscription event carries the subscription's identity and where it landed:

```json
{
  "subscriptionId": 15,
  "contractId": 74,
  "status": "paused",
  "billingFrequency": "monthly",
  "currency": "EUR",
  "priceMinor": 820000,
  "currentPeriodStart": "2026-08-17T00:00:00Z",
  "currentPeriodEnd": "2026-09-17T00:00:00Z",
  "occurredAt": "2026-08-28T11:04:22Z"
}
```

The event type says what happened and `status` says where it landed; there is
no `previousStatus`. Nothing in the body identifies the customer — look them
up through the contract if you need to.

Credit events:

```json
{
  "contractId": 42,
  "invoiceId": 4181,
  "invoiceNumber": "UK2607AA-2608AAC",
  "currency": "EUR",
  "appliedMinor": 250000,
  "remainingMinor": 0,
  "occurredAt": "2026-09-01T00:05:00Z"
}
```

`remainingMinor` is the contract's available credit after the movement — it
is `0` on every `CreditDepleted` by definition.

## Delivery, signing, retries

Deliveries are HTTP POSTs to the webhook endpoints configured for your
account, signed with your endpoint's secret:

```
X-Fynex-Signature: sha256=<hex HMAC-SHA256 of the raw body>
X-Fynex-Timestamp: <unix seconds at which the delivery was signed>
```

Verify the signature against the **raw** request body before parsing, and
reject a delivery whose timestamp is far from your own clock — that is what
bounds replay of a body someone captured. Deliveries are retried with backoff
until your endpoint answers 2xx; your handler must therefore be idempotent.
Deduplicate on the envelope's `eventId`, which is stable across every retry of
the same event.

Endpoint registration, secret rotation and the retry schedule are the
Payments API's webhook machinery — see its **Webhooks** guide; nothing about
it is billing-specific.

## Polling still works

The polling guidance in **Workflows** remains valid and is the right tool for
reconciliation: a month-end close should read `GET /invoices` for the period
rather than reconstruct it from events. Webhooks are for the moment an
invoice being paid *unlocks something* — activating a seller, releasing a
listing, restoring access — where minutes of polling latency is real waiting.

---

# Code examples

Working snippets for the two pieces every integration needs — walking pages
and retrying correctly — plus a typed-client shortcut.

## Generate a client from the spec

The fastest path is not to hand-write a client at all. The spec is standard
OpenAPI 3.1:

```bash
curl -s "$FYNEX_API_BASE/openapi.json" -o fynex-billing.json

# TypeScript types
npx openapi-typescript fynex-billing.json -o fynex-billing.d.ts

# Python / Go / Java / … via openapi-generator
openapi-generator generate -i fynex-billing.json -g python -o ./fynex-billing-client
```

Generated models keep minor-unit amounts as integers and decimal quantities as
strings, which is what you want. If your generator maps `quantity` or
`taxRate` to a float, override it to a decimal type.

## Python: page through invoices

```python
import os
import time

import requests

BASE = os.environ["FYNEX_API_BASE"]  # e.g. https://api.fynex.ai/billing-api/v1
SESSION = requests.Session()
SESSION.headers["Authorization"] = f'Bearer {os.environ["FYNEX_API_KEY"]}'


def request(method, path, **kwargs):
    """One request with the retry policy: honour Retry-After on 429,
    back off on 5xx, never retry other 4xx."""
    delay = 1.0
    for attempt in range(6):
        response = SESSION.request(method, f"{BASE}{path}", timeout=30, **kwargs)

        if response.status_code == 429:
            # Retry-After is authoritative; retrying sooner just deepens the overage.
            time.sleep(int(response.headers.get("Retry-After", "1")))
            continue
        if response.status_code >= 500:
            time.sleep(delay)
            delay *= 2
            continue
        if not response.ok:
            # 400/401/403/404/422 — the request must change, so retrying is pointless.
            raise RuntimeError(f"{response.status_code}: {response.json().get('error')}")

        return response
    raise RuntimeError("giving up after repeated throttling or server errors")


def iter_invoices(**filters):
    """Yield every invoice, newest first, following the keyset cursor."""
    before_id = None
    while True:
        params = {"limit": 100, **filters}
        if before_id:
            params["cursor"] = before_id

        page = request("GET", "/invoices", params=params).json()
        for invoice in page["invoices"]:
            yield invoice

        if not page["hasMore"]:
            return
        before_id = page["nextCursor"]


outstanding = [
    inv for inv in iter_invoices(origin="usage")
    if inv["status"] in ("issued", "sent") and inv["collectibleMinor"] > 0
]
total_minor = sum(inv["collectibleMinor"] for inv in outstanding)
print(f"{len(outstanding)} open invoices, {total_minor / 100:.2f} outstanding")
```

The last line divides by 100 only because the example is EUR. Currencies have
different minor-unit scales — divide by the scale of the invoice's own
`currency`, or better, keep the integer and format at the edge.

## Node: collect on an invoice

```javascript
const BASE = process.env.FYNEX_API_BASE; // https://api.fynex.ai/billing-api/v1

async function call(method, path, options = {}) {
  for (let attempt = 0; attempt < 6; attempt++) {
    const response = await fetch(`${BASE}${path}`, {
      method,
      ...options,
      headers: {
        Authorization: `Bearer ${process.env.FYNEX_API_KEY}`,
        ...options.headers,
      },
    });

    if (response.status === 429) {
      const wait = Number(response.headers.get("Retry-After") ?? 1);
      await new Promise((r) => setTimeout(r, wait * 1000));
      continue;
    }
    if (response.status >= 500) {
      await new Promise((r) => setTimeout(r, 2 ** attempt * 1000));
      continue;
    }
    const body = await response.json();
    if (!response.ok) throw new Error(`${response.status}: ${body.error}`);
    return body;
  }
  throw new Error("giving up after repeated throttling or server errors");
}

// Safe to retry as-is: the invoice's durable link association makes repeated
// and simultaneous calls converge on the same payment link.
const { invoice, paymentLinkUrl } = await call("POST", `/invoices/${invoiceId}/send`);
console.log(`Invoice ${invoice.invoiceNumber}: ${paymentLinkUrl}`);
```

## Go: usage against plan limits

```go
type MetricUsage struct {
	MetricName    string `json:"metricName"`
	Used          string `json:"used"`          // decimal string
	IncludedUnits string `json:"includedUnits"` // decimal string
	CapQuantity   string `json:"capQuantity,omitempty"`
	CapMode       string `json:"capMode,omitempty"`
	PercentOfPlan string `json:"percentOfPlan,omitempty"`
	PeriodStart   string `json:"periodStart,omitempty"`
	PeriodEnd     string `json:"periodEnd,omitempty"`
}

type ContractUsage struct {
	ContractID int64         `json:"contractId"`
	Metrics    []MetricUsage `json:"metrics"`
}

func overageMetrics(ctx context.Context, client *http.Client, baseURL, key string, contractID int64) ([]MetricUsage, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet,
		fmt.Sprintf("%s/contracts/%d/usage", baseURL, contractID), nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Authorization", "Bearer "+key)

	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		var apiErr struct {
			Error string `json:"error"`
		}
		_ = json.NewDecoder(resp.Body).Decode(&apiErr)
		return nil, fmt.Errorf("usage snapshot: %d: %s", resp.StatusCode, apiErr.Error)
	}

	var usage ContractUsage
	if err := json.NewDecoder(resp.Body).Decode(&usage); err != nil {
		return nil, err
	}

	var over []MetricUsage
	for _, m := range usage.Metrics {
		// Decimal, not float: these quantities carry fractional precision.
		pct, err := decimal.NewFromString(m.PercentOfPlan)
		if err != nil || m.PercentOfPlan == "" {
			continue // no plan allowance configured for this metric
		}
		if pct.GreaterThan(decimal.NewFromInt(100)) {
			over = append(over, m)
		}
	}
	return over, nil
}
```

## Shell: download every PDF for a month

```bash
#!/usr/bin/env bash
set -euo pipefail

BASE="${FYNEX_API_BASE:?set FYNEX_API_BASE}"
AUTH="Authorization: Bearer ${FYNEX_API_KEY:?set FYNEX_API_KEY}"
cursor=""

while :; do
  url="$BASE/invoices?limit=100"
  [ -n "$cursor" ] && url="$url&cursor=$cursor"
  page="$(curl -sf -H "$AUTH" "$url")"

  # Every issued document renders, including void ones and credit notes —
  # they are part of the audit trail. Filter here if you only want live ones.
  echo "$page" | jq -r '.invoices[] | "\(.id) \(.invoiceNumber)"' |
    while read -r id number; do
      curl -sf -H "$AUTH" "$BASE/invoices/$id/pdf" -o "invoices/$number.pdf"
    done

  [ "$(echo "$page" | jq -r '.hasMore')" = "true" ] || break
  cursor="$(echo "$page" | jq -r '.nextCursor')"
  sleep 1  # stay clear of the per-seller rate limit while bulk-downloading
done
```

## Testing your integration

Point everything at a `sk_test_…` key while your account is still in demo
mode. Going live is two steps, not one: Fynex switches the account to live
mode, and you swap in an `sk_live_…` key. From that moment the test key no
longer authenticates (`401`), so make the key a configuration value rather
than a constant. The billing records themselves belong to the account — a
live key does not reveal a second, hidden dataset.

Worth exercising before you ship:

- A `404` path (a contract id that is not yours) — confirm you surface it as
  "not found" rather than crashing on a missing field.
- A `429` path — force it by looping requests, and confirm your client waits
  for `Retry-After` instead of hot-looping.
- A multi-page walk — seed more rows than your page size, or set `limit=1`,
  and confirm you follow `nextCursor` to the end.

---

# Contracts

A contract is the commercial agreement every other billing object hangs off:
subscriptions bill on it, credit is held against it, metered usage is priced
under it and every invoice names it. It belongs to one of your customers (a
`sellerCustomerId` from `POST /customers`) and bills in one currency for its
whole life — credit and invoices under it are never converted.

## Versions, not edits

A contract is a stable id plus an append-only history of **versions**. Nothing
is ever updated in place: every change — a status move, a new end date, a
different set of components — is a new version appended on top of the last,
and every earlier version stays readable. `GET /contracts` shows the current
version of each contract you own, with `version` saying how many there are.

That is what an amendment is: **a compare-and-swap on the version number**.
You send the `expectedBaseVersion` you last read; if the contract has moved on
since, the amendment answers `422` and you re-read before deciding again.
Two integrations amending the same contract can therefore never silently
overwrite each other.

## Status

`draft` → `active` → `suspended` ⇄ `active` → `closed`. A contract starts in
`draft`. `closed` is terminal: nothing about a closed contract can be amended.
A transition the state machine does not allow (anything out of `closed`, or
`draft` straight to `suspended`) answers `422`.

**Closing a contract does not cancel its subscriptions.** The status is a
record of the commercial relationship; no billing engine reads it, so a
subscription on a closed contract keeps renewing and keeps issuing invoices.
Cancel each one with `POST /billing-api/v1/subscriptions/{subscriptionId}/cancel`
FIRST, then close the contract.

## Endpoints

- `GET /billing-api/v1/contracts` — the current version of every contract you
  own, keyset-paged by contract id.
- `POST /billing-api/v1/contracts` — create one, in `draft`, for one of your
  customers. **`Idempotency-Key` is required**: the same key returns the
  contract the first call created (`200` instead of `201`); the same key
  answers `422`, naming the field, when the request differs in its
  `sellerCustomerId`, `currency` or the `lineItems` themselves — compared line by line on
  `componentType`, `componentConfig` and `quantity`, order-insensitively — or in the
  `startDate` or `endDate` as the key first created it. It also carries an EXTRA per-seller
  quota of its own — 20 create requests a rolling day by default (a refused or replayed request counts too), answered as `429`
  with `Retry-After` — bounding how fast one key can open contracts, each of
  which allocates a number from a sequence shared across sellers.
- `POST /billing-api/v1/contracts/{contractId}/amendments` — append a version:
  a status move, new dates, a replaced component set, or the counterparty's
  details as agreed. `expectedBaseVersion` is required and is the concurrency
  guard; there is no `Idempotency-Key`, because a repeat of a successful
  amendment fails the version check by construction. This one carries an EXTRA
  per-seller quota too — 200 amend requests a rolling day by default (a stale `expectedBaseVersion` counts too) — because
  every accepted amendment appends a version that can never be deleted.
- `GET /billing-api/v1/contracts/{contractId}/usage` and
  `GET /billing-api/v1/contracts/{contractId}/credits` — what has been metered
  and what stored value is held under the contract.

Both write quotas, and the invoice issue quota, **fail closed** — while the
limiter is unreachable they answer `503` with `Retry-After` and the write does
not happen; see [Errors → rate limiting](/billing-api/v1/docs/errors#when-the-write-quotas-fail-closed).

## Components

`lineItems` on a contract are its **components**: what the agreement says is
being sold, each with a `componentType`, a `quantity` (decimal string) and an
optional `componentConfig` the engine reads for that type. They are
snapshotted per version — an amendment either carries the current set forward
(omit `lineItems`) or replaces it whole (send the full new set; `[]` clears
it). There is no partial edit, for the same reason there is no partial edit
of anything else here: the version is the audit trail.

A contract may have no components at all. Its billing is then defined by the
subscriptions attached to it (`POST /contracts/{contractId}/subscriptions`)
and the metered prices set under it.

## What the seller key may not do

Contracts created through this API record no dashboard user as their author
(`createdBy` is `0`): the seller key is the actor, and the audit trail names
the key's seller account. Contract PDFs, tax profiles and the backoffice
review of a contract stay dashboard operations.

---

# Invoices

A billing invoice is an immutable, numbered document. The relational fields
you see in responses (totals, dates, status) mirror a frozen, schema-valid
payload snapshotted at issue time; the PDF renders from that snapshot.

## Lifecycle

`draft → issued → sent → paid`, with `overdue` for a sent document still
unpaid past its due date, and three corrective exits:

- `voided` — the document was cancelled before money moved (`voidedAt`,
  `voidReason`).
- written off — collection was abandoned (`writtenOffAt`, `writeOffReason`).
- credit note — a separate document (`invoiceType: "credit_note"`) naming the
  original in `originalInvoiceNumber`.

`paidVia` records how a paid document settled: `payment_link` (the card rail —
either the buyer visiting the hosted page, or an off-session charge on their
saved card when the seller has auto-collection enabled), `bank_transfer` (a
deposit on the collection account named on the document) or `credit` (stored
credit covered it in full at issue).

A document can therefore reach `paid` without you doing anything, and can
appear on `GET /invoices` without anyone creating it: whether terms and
metered usage are invoiced on a schedule, and whether saved cards are charged
off-session, are per-seller operator switches. See **Sandbox & testing** for
the switches themselves.

## Numbering

Every issued document carries TWO numbers, and they are not interchangeable.

`invoiceNumber` (`UK2607AB-2607AAC`) is the document's identity: unique across
every Fynex invoice family, allocated from the shared per-(agreement, month)
sequence, and **also the bank payment reference** — it is the only string a
buyer may quote on a transfer, and it is what `bankTransfer.paymentDescription`
returns.

`customerDocumentNumber` (`0000042`) is the document's position in that
customer's own series: cumulative for the life of the (seller, customer) pair,
never reset, and counted independently for each customer. Credit notes take the
next position like any other document, and a voided document keeps the one it
had. It is a convenience reference printed as "Your document no." — unique only
within the pair, so it is never a lookup key and never a payment reference.
Absent on documents issued before the series existed; those are not backfilled.

## Numbering

An invoice number is `<agreement>-<YYMM><order>`: your agreement number, a
dash, the issue month as `YYMM`, and a three-letter order within that month
(`AAA`, `AAB`, …) — for example `UK2607AA-2608AAB` is the second document
issued in August 2026 under agreement `UK2607AA`. The series is allocated
inside the issuing transaction, so it is gapless per month, and it is shared
with top-up invoices so the two document families can never collide. Numbers
are what `corrects` and `originalInvoiceNumber` reference, so store them
verbatim — do not parse meaning out of the order suffix.

`customerName` and `customerEmail` carry who the document was addressed to at
issue — taken from the frozen payload, so a customer later renaming themselves
does not rewrite an issued legal record. They are returned by the
single-invoice read and by `send`, and omitted from list rows.

## Endpoints

- `GET /billing-api/v1/invoices` — list, newest first, paged with
  `cursor`/`limit`. Filters: `contractId`, `sellerCustomerId`, `status`,
  `invoiceType` (`standard`, `credit_note`, `simplified`, `modified`),
  `origin` (`recurring`, `usage`, `milestone`, `project`, `one_time`, `adhoc`,
  `marketplace`; `subscription` is a deprecated alias of `recurring`),
  `corrects` (credit notes against an invoice number), `issuedFrom`/`issuedTo`
  (half-open date range).
- `POST /billing-api/v1/invoices` — compose, number and (by default) send a
  document in one call, behind a required `Idempotency-Key`. It is issued and
  immutable the moment the call answers `201`; the same key replays the
  document with `200` and no link, and a key whose request differs in its
  contract, customer, currency, `invoiceType`, `dueDate`, net line total or
  line count answers `422` naming the field. There are no drafts on this API:
  send explicit lines with `unitPriceMinor`. `invoiceType` accepts `standard`
  (the default), `simplified` and `modified`; **`credit_note` is refused with
  `400`** — a correction is raised against the document it corrects, which this
  request cannot express, and there is no `originalInvoiceNumber` field on it.
  Correct a mistake in the dashboard.
- `GET /billing-api/v1/invoices/{invoiceId}` — one invoice with its lines.
- `GET /billing-api/v1/invoices/{invoiceId}/pdf` — the rendered PDF.
- `POST /billing-api/v1/invoices/{invoiceId}/send` — materialize the
  collection instrument: creates the invoice's hosted payment link and returns
  the invoice together with `paymentLinkUrl`. **It emails your customer**
  whenever the document carries a buyer email; there is no per-request
  suppression in v1, so the first call is customer-facing. The invoice is
  itself the collection anchor, so retries and simultaneous calls return the
  one existing link and do not send another email — no idempotency header is
  needed. Not applicable (`422`) to bank-transfer documents, to documents
  outside `issued`/`sent`/`overdue` (a `draft`, or one already `paid`,
  `voided` or `written_off`), or to documents with nothing left to collect.

## Amounts

`grandTotalMinor` is the legal document total. `collectibleMinor` is what
collection asks the customer for: the grand total less `creditAppliedMinor`
(stored credit drawn down at issue).

`creditSettledMinor` reports only the part discharged by that stored credit —
it is **not** a payment total, and a card- or transfer-paid invoice reports
`0`. Neither amount changes when the invoice is paid: `status` and `paidVia`
are what tell you the outcome.

`outstandingMinor` is the one to age in an AR report. `collectibleMinor` is
frozen at issue and stays there, so it keeps asking for the full amount after a
credit note, a write-off or any other adjustment; `outstandingMinor` is derived
from the adjustment ledger on every read and reports what is genuinely still
owed (`0` for a voided document). It is omitted when the adjustment ledger is
unavailable — absence means **unknown**, not `0`.

---

# Subscriptions

A subscription is one recurring billing relationship on a contract. All date
fields are UTC calendar dates — billing runs on dates, not instants.

## States

`trial → active ⇄ past_due / paused → canceled | expired`

- `trial` — running a free trial until `trialEnd`. `trialEndBehavior` says
  which way it ends: `convert` (becomes a paying subscription) or `cancel`
  (lapses). `trialRequiresPaymentMethod` tells you whether a payment method
  must be on file first.
- `active` — billing normally within `currentPeriodStart…currentPeriodEnd`.
- `past_due` — a renewal charge failed. **No automatic retry runs**: recovery
  is your action or a dashboard operation. Treat this status as "act now",
  not as "Fynex is handling it".
- `paused` — billing suspended (`pausedAt`, optional auto-resume at
  `pauseEndsAt`).
- `canceled` / `expired` — terminal. A cancellation with notice keeps serving
  until `cancelEffectiveAt`.

## Price and plan

There is no separate plan object: the plan reference is
`priceMinor + currency + billingFrequency` (with `customUnit`/`customEvery`
for custom cadences). A scheduled downgrade appears as
`pendingPriceMinor`/`pendingPriceChangeAt` until the renewal pass applies it.

## Endpoints

Read:

- `GET /billing-api/v1/subscriptions` — list, newest first. Filter:
  `contractId`. Pages with `cursor`/`limit`, newest first.
- `GET /billing-api/v1/subscriptions/{subscriptionId}` — one subscription.

Create:

- `POST /billing-api/v1/contracts/{contractId}/subscriptions` — a new
  subscription on a contract you own. **`Idempotency-Key` is required** (1–128
  characters, `A–Z a–z 0–9 _ . : -`): the same key always returns the
  subscription the first call created (`200` instead of `201`), whatever the
  body of the retry; use one key per subscription you intend to create. Keys
  are scoped to your seller account — the same key on a different contract
  answers `422`; a key under a billing-engine prefix (`proration:`, `redeem:`,
  `subscription:`, …) answers `400`. Body: `frequency` (`daily`, `weekly`,
  `bi_weekly`, `monthly`, `quarterly`, `semi_annual`, `annual`, `custom` with
  `customUnit`/`customEvery`), `priceMinor` + `currency` (the contract's),
  `startDate` (up to a year in the past), optional `anchorDate`, `trialEnd` +
  `trialEndBehavior` (`convert`/`cancel`), `autoRenew` (default `true`; an
  explicit `false` needs `endDate`, and `endDate` is refused otherwise),
  `noticePeriodDays` (0–365), `prorationPolicy`
  (`by_day`/`full_period`/`next_period` — selects what an immediate
  `change-plan` does once the proration engine is enabled: prorate the
  unserved remainder, bill the whole current period at the new price with the
  elapsed days included and no adjustment, or keep the old price until the
  next period. Inert while the engine is off, which is the default. Where
  invoice binding is enabled for the environment, the recorded proration is
  applied to the term's next invoice).

```bash
curl -X POST "$FYNEX_API_BASE/contracts/42/subscriptions" \
  -H "Authorization: Bearer $FYNEX_SECRET_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"frequency":"monthly","priceMinor":9900,"currency":"EUR","startDate":"2026-10-01","noticePeriodDays":30}'
```

Lifecycle, all `POST /billing-api/v1/subscriptions/{subscriptionId}/…`:

| Action | Body | From | Effect |
| --- | --- | --- | --- |
| `cancel` | — | `trial`, `active`, `past_due`, `paused` | Sets `cancelRequestedAt`; serves until `cancelEffectiveAt` (notice period) or term end. Terminal. |
| `pause` | `{"pauseUntil": "YYYY-MM-DD"}` optional | `active` | Suspends billing; `pauseEndsAt` when a date was given. Longer than the seller's pause policy → `422`. |
| `resume` | — | `paused` | New term from today; the pause is not billed. |
| `end-trial` | — | `trial` | Converts now per `trialEndBehavior` / `trialRequiresPaymentMethod`. |
| `change-plan` | `{"priceMinor": 12900, "currency": "EUR", "atTermEnd": true}` | `active` | Scheduled (`pendingPriceMinor`) or immediate. `atTermEnd: true` always schedules; with `false` the subscription's `prorationPolicy` decides while the proration engine is on — `next_period` keeps the old price for the rest of this period and applies the new one from the next, `by_day` applies it now and posts an adjustment for the unserved remainder, `full_period` applies it now and bills the whole current period at the new price, elapsed days included, with no adjustment. Same price, or a change already pending → `422`. Currency cannot change. |

Every action returns the updated subscription. A request the current state
cannot take — pausing a trial, resuming an active subscription, cancelling
twice — answers `422` with the reason; so does a lost race against the
lifecycle pass (`subscription changed concurrently; re-read it and retry`).
`404` means the subscription is not yours. Marking past-due or recovered is
the collection loop's job and has no public route.

---

# Usage

Metered usage is ingested by your systems (or Fynex connectors), aggregated
per contract, metric, and billing period, and rated into usage invoices at
period close. This API exposes the aggregated view.

## Endpoints

- `GET /billing-api/v1/contracts` — the contracts visible to your key, the
  entry point for every per-contract read. Paged by ascending contract id:
  while `hasMore` is true, pass the returned `nextCursor` back as `cursor`.

```json
{
  "contracts": [
    {
      "contractId": 42,
      "contractNumber": "UK2607AA",
      "version": 1,
      "sellerCustomerId": 7,
      "currency": "EUR",
      "status": "active",
      "startDate": "2026-01-01",
      "customerName": "Ada Lovelace",
      "customerCompanyName": "Harbour Group BV"
    }
  ],
  "hasMore": false
}
```

- `GET /billing-api/v1/contracts/{contractId}/usage` — the current open
  billing periods of every metric on the contract:

```json
{
  "contractId": 42,
  "metrics": [
    {
      "metricName": "api_calls",
      "used": "10250",
      "includedUnits": "10000",
      "capQuantity": "50000",
      "capMode": "hard",
      "percentOfCap": "20.5",
      "percentOfPlan": "102.5",
      "periodStart": "2026-08-01",
      "periodEnd": "2026-08-31"
    }
  ]
}
```

`used` counts everything metered this period; `includedUnits` is the plan
allowance; `capQuantity`/`capMode` describe the limit policy when one is
configured. Quantities are decimal strings. An unknown contract id answers
`404`; a real contract with nothing metered answers `200` with an empty
`metrics` array.

> [!IMPORTANT]
> **This is a live reading, not the figure the customer will be invoiced.**
> The period is still open, so `used` moves with every event that arrives —
> including events for work already done that reach us late. It is also
> pre-invoice: allowances, credits, discounts and rounding are applied when the
> period closes and the invoice is produced, and none of them are reflected
> here.
>
> Show it to a customer as "usage so far", never as an amount owed, and
> reconcile against the invoice the period produced rather than against this
> endpoint. A dashboard that quotes this number as the bill will disagree with
> the bill.

This endpoint reads a meter; something has to fill it. Writing usage —
registering a metric, pricing it, and sending events — happens under
`/billing-api/v1/usage`, with the same seller secret key, and is documented in
full under **Usage ingestion**. In short: `POST /billing-api/v1/usage/metrics`
to declare the meter, `PUT
/billing-api/v1/usage/contracts/{contractId}/metrics/{metricName}/price` to
price it, and `POST /billing-api/v1/usage/events` (or `:batch`, or `/csv`) to
report consumption.

## What this endpoint keeps, and for how long

**This is a meter, not a history API.** It answers with the contract's
**currently open** billing periods — one entry per metric with a limit policy
or metered usage. It has no date range and no paging, and a period that has
closed is no longer in the response. If you need consumption over time, record
what you read while the period is open, or take it from the invoice the period
produced: the invoice is the durable record of what was billed.

**Stored events are retained indefinitely.** Nothing prunes them — there is no
retention window on ingested usage and no job that deletes it, so an
idempotency key you used a year ago is still recognised and resending that
event is still a no-op. Two practical consequences:

- Idempotency keys must stay unique for the lifetime of your integration, not
  just for a period. Derive them from something durable — a row id, an export
  digest — rather than from a timestamp that repeats.
- Correcting metered history is done by issuing a correction against the period
  (see **Usage corrections**), never by deleting events. There is no delete.

Documents are the exception: invoices and credit notes carry a statutory
retention period per jurisdiction, which is a legal minimum on how long they
are kept, not a window after which this endpoint stops answering.

---

# Credits

Stored value is a capability your deployment switches on. While it is off,
every endpoint below answers `501` with `billing credits are not enabled` —
and that is the honest answer, because with it off the billing engine does
not draw credit into invoices at all, so any balance shown would never be
consumed.

Credits are stored value held against a contract — customer prepayments,
promotional grants, goodwill. Balances are derived from an append-only
ledger; drawdown happens automatically when an invoice is issued against a
contract holding credit (visible on the invoice as `creditAppliedMinor`).

## Endpoints

- `GET /billing-api/v1/credits` — your balances across all contracts, one row
  per currency and credit type:

```json
{
  "balances": [
    {"currency": "EUR", "creditType": "purchased", "balanceMinor": 250000, "isLiability": true}
  ]
}
```

`creditType` is one of `promotional`, `purchased`, `manual`, `gift`,
`enterprise`, `ai_token`, `marketplace` or `proration`. `isLiability` marks
the paid-for types (`purchased`, `enterprise`, `proration`) — unearned revenue
you owe as service. Those never expire; granted credit may. `proration` is
minted by the engine rather than granted by you: when a mid-term subscription
amendment credits your customer more than the invoice it lands on can absorb,
the remainder becomes a `proration` lot — money the customer already paid for
service not rendered — and the next document draws it down.

- `GET /billing-api/v1/contracts/{contractId}/credits` — one contract's
  balances (always complete) plus a page of its ledger history. `limit` sets
  the page size (1–100, default 20); while `hasMore` is true, pass the
  returned `nextCursor` back as `cursor` to walk older entries:

```json
{
  "balances": [ ... ],
  "hasMore": true,
  "nextCursor": 1180,
  "nextBeforeId": 1180,
  "entries": [
    {
      "id": 1201,
      "kind": "topup",
      "creditType": "purchased",
      "signedDeltaMinor": 250000,
      "currency": "EUR",
      "invoiceId": null,
      "reason": "annual prepayment",
      "occurredAt": "2026-08-01T09:30:00Z"
    }
  ]
}
```

Entries are newest-first. `kind` is `topup`, `deduction`, `expiry` or
`reversal` — the last one returns a deduction to the customer when the invoice
it funded is cancelled. `signedDeltaMinor` is positive for `topup` and
`reversal`, negative for `deduction` and `expiry`, and `invoiceId` links a
deduction (and the reversal that undoes it) to the document it funded.

- `POST /billing-api/v1/contracts/{contractId}/credits/top-up` — grant a
  credit lot. This is the only way credit enters the ledger; `deduction`,
  `expiry` and `reversal` rows are the engine's and have no public route.

```bash
curl -s -X POST "$FYNEX_API_BASE/contracts/42/credits/top-up" \
  -H "Authorization: Bearer $FYNEX_API_KEY" \
  -H "Idempotency-Key: 3f0b6a1e-9c1d-4f10-9f2b-6f2a1c1e6a1e" \
  -H "Content-Type: application/json" \
  -d '{"creditType":"purchased","amountMinor":250000,"currency":"EUR","reason":"annual prepayment"}'
```

`Idempotency-Key` is required, and here the body **is** compared: the same key
with a different contract, `amountMinor` or `currency` answers `422` instead of
returning the first grant, because a top-up that never happened must not look
like one that did. A matching replay answers `200` with the original entry; the
first call answers `201`.

`currency` must equal the contract's own — credit is never converted. An
`expiresAt` is allowed only on granted credit: `purchased` and `enterprise`
were paid for, and money someone paid must not evaporate on a calendar date.
`sellerCustomerId` is optional attribution and must name one of your customers
(`POST /billing-api/v1/customers`); the contract remains the balance anchor.

Correcting a grant is not an edit — the ledger is append-only. Contact support
for a correcting entry.
