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