Skip to content
In this section

Endpoint reference

Every operation with its parameters, response shape and failure modes.

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
10c POST /subscriptions/{subscriptionId}/apply-coupon · /remove-coupon Put a coupon on a subscription, or stop it
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.

curl -s "$FYNEX_API_BASE/contracts?limit=50" \
  -H "Authorization: Bearer $FYNEX_API_KEY"
{
  "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.

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.


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.

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.


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.

curl -s "$FYNEX_API_BASE/contracts/42/usage" \
  -H "Authorization: Bearer $FYNEX_API_KEY"
{
  "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 Units of the period that rate to zero: allotment.includedUnits of the metric price effective for the period — the allowance the period close bills with. Units carried over by a rollover allotment are not included. A priced metric whose price includes nothing shows 0. For a metric with no price yet — or when its price cannot be read — the limit policy's included units (often 0).
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.
curl -s "$FYNEX_API_BASE/contracts/42/credits?limit=50" \
  -H "Authorization: Bearer $FYNEX_API_KEY"
{
  "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), reversal (a deduction handed back when the invoice it funded was voided).
signedDeltaMinor Positive for topup and reversal, negative for deduction and expiry.
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.

curl -s "$FYNEX_API_BASE/credits" -H "Authorization: Bearer $FYNEX_API_KEY"
{
  "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.

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 (midnight in the seller's timezone) 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.

curl -s "$FYNEX_API_BASE/invoices?issuedFrom=2026-08-01&issuedTo=2026-09-01&status=sent&limit=100" \
  -H "Authorization: Bearer $FYNEX_API_KEY"
{
  "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).

A recurring invoice a subscription coupon discounted also carries promotionDiscountMinor (the coupon's discount on the recurring line, already inside subtotalMinor) and couponCode; both are omitted otherwise, and once a void gave the coupon's term back. See 10c.

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.

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

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.


6. GET /invoices/{invoiceId}

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

curl -s "$FYNEX_API_BASE/invoices/4180" -H "Authorization: Bearer $FYNEX_API_KEY"
{
  "invoice": {
    "id": 4180,
    "customerName": "Acme GmbH",
    "customerEmail": "ap@acme.example",
    "invoiceNumber": "UK2607AA-2608AAB",
    "status": "sent",
    "grandTotalMinor": 12000,
    "collectibleMinor": 12000,
    "creditSettledMinor": 0,
    "outstandingMinor": 12000,
    "paidVia": "",
    "promotionDiscountMinor": 1980,
    "couponCode": "SPRING20",
    "…": "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.

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.

curl -s -X POST "$FYNEX_API_BASE/invoices/4180/send" \
  -H "Authorization: Bearer $FYNEX_API_KEY"
{
  "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.
curl -s "$FYNEX_API_BASE/subscriptions?contractId=42" \
  -H "Authorization: Bearer $FYNEX_API_KEY"
{
  "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". Where off-session collection is on, the loop has already spent its retry budget (3 attempts, at least 20 minutes apart); where it is off nothing retried at all. Either way this is a queue for you to work, and it clears itself when an invoice on the contract is paid.
Paused? pausedAt, with pauseEndsAt for a scheduled auto-resume.
Is a coupon on it? coupon — the active coupon, else the one that last ended, null when it never had one. periodsRemaining says how many more terms it discounts (null for forever); status is active, exhausted, stopped, replaced or released. See 10c.
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.

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.
couponCode Optional. Attaches the coupon in the same call, exactly as 10c would; a code that does not apply fails the create and no subscription is created (422, message starting coupon_invalid (<reason>) — whatever the reason, not_found and exhausted included).

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) · 409 (promotions_disabled: a couponCode while coupons on subscriptions are switched off) · 422 (Idempotency-Key already used on another contract, or a couponCode that does not apply) · 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 Billing continues on the original schedule — the period is not restarted; 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.

10c. Subscription coupons

Both POST, both returning the updated subscription with its coupon.

Path Body Effect
/subscriptions/{subscriptionId}/apply-coupon {"couponCode": "SPRING20"} Attaches the coupon. It discounts the next term the recurring pass invoices — a term already invoiced keeps its price — for the campaign's duration: once, repeating (N terms) or forever, on terms frozen now.
/subscriptions/{subscriptionId}/remove-coupon {"reason": "…"} — the reason is optional; send {} for none Stops the active coupon: terms invoiced from now on are billed in full. Terms it already discounted stay discounted. Repeating it once the coupon is stopped answers 200 with the subscription unchanged.

How a coupon reaches the invoice: its discount sits on the recurring line's discountMinor before tax and before any mid-term proration (a proration credit is capped by what the coupon left), and stored credit is drawn after both. The invoice names it in promotionDiscountMinor and couponCode. A term is counted when its invoice is issued; voiding that invoice before it is paid gives the term back (an exhausted coupon becomes active again). A credit note after payment does not. Cancelling the subscription, or reaching its fixed endDate, ends the coupon: released if no term used it, stopped (endedReason subscription_cancelled / subscription_expired) otherwise — and a stopped coupon whose only term is then voided unpaid becomes released. With change-plan the coupon carries over: a percentage applies to the new price. An immediate prorated change values the current term's unserved remainder at what that term was invoiced at — net of the percentage coupon that discounted it (even a once coupon the term used up), in full when no coupon discounted it (a coupon applied after the term was invoiced starts with the next one); a fixed amount leaves the remainder's value alone.

One coupon per subscription. Billing subscriptions accept seller-funded percent and fixed coupons in the subscription's currency; platform-funded and promotional-credit campaigns are refused. These calls take no Idempotency-Key: applying the code that is already attached, or removing a coupon already stopped, answers 200 with the subscription unchanged.

Every refusal is {"error": "<reason>: <text>"} — branch on the leading reason:

Status Reason When
400 — couponCode missing, or a malformed id.
404 not_found No such code; on remove-coupon, no coupon to stop. A subscription that is not yours is 404 too.
409 promotions_disabled Coupons on subscriptions are switched off for the platform, or not available on this deployment. An attached coupon keeps discounting regardless.
409 coupon_already_attached Another coupon is active — remove it first.
409 coupon_ended The coupon ended while you removed it.
409 exhausted · budget_exhausted The code or the campaign budget is used up.
422 ineligible · expired · not_started · disabled · currency_mismatch · first_time_only · region The code does not apply to this subscription.

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.

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"]
  }'
{
  "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.

{"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, a file over 20 MiB, or a file-level rejection · 401 · 403.


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:

{"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:

{
  "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, by design: a new address and one you have already sent get the same answer, so a retry reads exactly like the first call. Read the response as "this is your customer", not as "this customer is new".

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"
  }'
{
  "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".
curl -s "$FYNEX_API_BASE/contracts/42/invoices/upcoming" \
  -H "Authorization: Bearer $FYNEX_API_KEY"
{
  "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, seat or discount — which engine produced the line. discount is a subscription coupon's discount on the term before it: a negative line described Coupon <code>, which on the issued invoice is a discount on the recurring 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

curl -s "$FYNEX_API_BASE/contracts/42/test-clock" \
  -H "Authorization: Bearer $FYNEX_API_KEY"
{
  "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

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"}'
{
  "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
  },
  "usage": {
    "periodsClosed": 1,
    "unpriced": 0,
    "perSeat": 0,
    "deferred": 0,
    "failed": 0,
    "passDisabled": false
  },
  "notAdvanced": [
    "per-seat period close (the seat lane of 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 three 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.
usage The usage period close: every usage period of the contract that has ended by to (on your account's calendar) is rated and closed into a pending usage line, exactly as the hourly close pass would at that date. unpriced, perSeat and deferred are ended periods left open (no effective price, a per_seat price, or an earlier period still open under rollover); failed periods are retried by an advance to a later instant (re-sending the same instant is a no-op). A period already closed is not counted again. Usage events dated after real time are still refused. If the close failed as a whole, usage.error says so; the lifecycle and invoicing above still ran, the clock moved, and the answer is still 200. A deployment with no usage pricing wired answers usage.error: "usage pricing is not configured" with usage.passDisabled: false — a fault for Fynex to fix, not the close-pass switch.
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, a currency disagreement, or an account not eligible for recurring billing in the term's currency.

Closing a usage period early has a cost. The close runs at the simulated date, so a period that has not ended in real time can be closed before its real events have all arrived. A real usage event you send afterwards for that period is still accepted, but it can no longer join the closed period's usage line: it is recorded as a late event flagged requires_correction and lands in the correction backlog, where reopening the period is an explicit backoffice act. Send a period's events before you advance past its end. And one advance closes at most the periods that have ended by to — a period ending after to waits for a later advance (or for real time).

lifecycle.cronDisabled, invoices.passDisabled and usage.passDisabled name an operator switch that stopped part of the loop — billing.subscription.lifecycle_cron, billing.recurring.invoice_pass and billing.usage.close_pass respectively. If any of them is true, ask Fynex to enable the flag rather than debugging your integration; see Sandbox & testing.

notAdvanced is the honest part. This clock moves the subscription state machine, the recurring invoice and usage period close. It does not close per-seat 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.

The Billing → Overview summary, GET /billing/v1/overview?sellerAccountUuid=…[&at=…], is dashboard-only too. It returns this month's KPIs per currency (expected, billed, collected, outstanding and overdue, with last month's figures for the trend), six months of billed vs collected, and every open (not closed) contract's next document from the same projection as §21. Months are calendar months in the seller's timezone (its organization profile's); timezone names the zone they were read in, UTC when the seller has none that loads. Expected and upcoming amounts are pre-tax estimates. attention lists the "Needs your attention" cards as facts (kind, severity, amounts per currency, count, oldestDays), most severe first; the only kind so far is overdue_collections, and a client should skip a kind it does not know.

GET /billing/v1/calendar?sellerAccountUuid=… is dashboard-only as well: the seller's billing calendar, {"timezone": "America/New_York", "today": "2026-10-15"} — the zone its dates are kept in (UTC when the organization profile names none that loads) and the date it is there now.

The dashboard's invoice rows (GET /billing/v1/invoices and every dashboard answer that carries an invoice) also name invoiceType and paymentTerms (the document's stated terms, trimmed; omitted when it states none), so a list applies the document's due-date rule without opening each one: On receipt only for a standard invoice with no dueDate and no paymentTerms; a credit note, or an invoice on terms such as Net 30, shows no due date. The PDF, the hosted page, the invoice email and the payment-link description follow the same rule.

The dashboard billing run, POST /billing/v1/hybrid/run, answers two different ways when recurring terms cannot be billed. A contract whose subscription has an active coupon refuses the whole run with 409 and code: recurring_coupon_active when includeRecurring is set — the recurring invoice pass prices the coupon and the run does not, so nothing is billed; leave includeRecurring off. A recurring lane that is switched off (or an eligibility that is revoked, or a gate that is not configured) is a partial skip instead: 200, the recurring model reports skipped text plus skippedCode (recurring_lane_disabled, recurring_eligibility_revoked, recurring_gate_not_configured), and the run's other models are billed.

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: code missing_api_key or invalid_api_key.
403 Valid key, seller account not active.
404 Unknown id — or one belonging to another seller.
409 A subscription coupon refusal — promotions_disabled, coupon_already_attached, coupon_ended, exhausted (see 10c).
422 The object exists but the action does not apply — POST /send, the subscription writes, a coupon code that does not apply to the subscription, 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.
413 POST /usage/events:batch only: more than 500 events in one call. (A /usage/csv upload over 20 MiB answers 400.)
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.