{"components":{"schemas":{"AllotmentDoc":{"additionalProperties":false,"properties":{"includedUnits":{"description":"The first N units of each period rate to zero. Decimal string.","examples":["1000"],"type":"string"},"rollover":{"description":"none, or rollover to carry an unused allowance into the next period.","enum":["none","rollover"],"examples":["none"],"type":"string"}},"required":["includedUnits"],"type":"object"},"AmendContractRequest":{"additionalProperties":false,"properties":{"customerCompanyCountry":{"description":"Send to replace, omit to keep; \"\" clears it. At most 56 characters.","type":"string"},"customerCompanyName":{"description":"Send to replace, omit to keep; \"\" clears it. At most 255 characters.","type":"string"},"customerName":{"description":"Send to replace, omit to keep; \"\" clears it. At most 128 characters.","type":"string"},"customerPhone":{"description":"Send to replace, omit to keep; \"\" clears it. At most 32 characters.","type":"string"},"endDate":{"description":"YYYY-MM-DD; omit to keep the current end. Not more than ten years ahead. An existing end date cannot yet be cleared.","type":"string"},"expectedBaseVersion":{"description":"REQUIRED. The version you last read. If the contract has moved on since, the amendment is refused with 422 instead of overwriting someone else's change.","examples":[1],"format":"int64","type":"integer"},"lineItems":{"description":"Replaces the whole component set — line items are snapshotted per version, so there is no partial edit. Omit to carry the current set forward; send [] to clear it. At most 100.","items":{"$ref":"#/components/schemas/ContractLineItemRequest"},"type":["array","null"]},"startDate":{"description":"YYYY-MM-DD; omit to keep the current start.","type":"string"},"status":{"description":"New contract status: draft, active, suspended or closed. Omit to keep the current one. Illegal transitions (anything out of closed, or draft straight to suspended) answer 422.","examples":["active"],"type":"string"}},"type":"object"},"BatchItemResponseDTO":{"additionalProperties":false,"properties":{"eventId":{"description":"The stored event's id, absent on a rejection.","examples":[91424],"format":"int64","type":"integer"},"index":{"description":"Position of the event in the submitted batch.","examples":[0],"format":"int64","type":"integer"},"metered":{"description":"Same meaning as on the single-event response.","type":"boolean"},"reason":{"description":"Why the item was rejected. A rejected item does not stop the others.","type":"string"},"status":{"description":"ingested, duplicate or rejected.","examples":["ingested"],"type":"string"}},"required":["index","status","metered"],"type":"object"},"BatchRequestDTO":{"additionalProperties":false,"properties":{"events":{"description":"Events to ingest, answered per item by index.","items":{"$ref":"#/components/schemas/UsageEventDTO"},"type":["array","null"]}},"required":["events"],"type":"object"},"BatchResponseDTO":{"additionalProperties":false,"properties":{"duplicates":{"description":"Events whose idempotency key was already seen.","examples":[1],"format":"int64","type":"integer"},"ingested":{"description":"Events recorded by this call.","examples":[2],"format":"int64","type":"integer"},"items":{"items":{"$ref":"#/components/schemas/BatchItemResponseDTO"},"type":["array","null"]},"rejected":{"description":"Events refused with a per-item reason.","examples":[0],"format":"int64","type":"integer"}},"required":["ingested","duplicates","rejected","items"],"type":"object"},"BillingSubscriptionWebhookEvent":{"additionalProperties":false,"properties":{"eventId":{"description":"Stable identity of this event. Deliveries are at-least-once — deduplicate on this, not on the payload.","examples":[918204],"format":"int64","type":"integer"},"eventType":{"description":"Which transition fired.","enum":["BillingSubscriptionStarted","BillingSubscriptionActivated","BillingSubscriptionRenewed","BillingSubscriptionPastDue","BillingSubscriptionRecovered","BillingSubscriptionPaused","BillingSubscriptionResumed","BillingSubscriptionCancelScheduled","BillingSubscriptionCanceled","BillingSubscriptionExpired"],"examples":["BillingSubscriptionPaused"],"type":"string"},"occurredAt":{"description":"When the state change happened, RFC 3339 UTC. A retried delivery repeats the original value; it is not the send time.","examples":["2026-08-20T14:02:11Z"],"format":"date-time","type":"string"},"payload":{"$ref":"#/components/schemas/SubscriptionEventPayload"},"sellerAccountUuid":{"description":"The Fynex seller account the event belongs to — you, not your customer.","examples":["6f2a1c1e-6a1e-4f10-9f2b-9c1d0b3a7e55"],"format":"uuid","type":"string"}},"required":["eventType","payload","eventId","sellerAccountUuid","occurredAt"],"type":"object"},"ChangePlanInputBody":{"additionalProperties":false,"properties":{"atTermEnd":{"description":"true schedules the change for the next term boundary (pendingPriceMinor until then) and is always honoured; false leaves the timing to the subscription's prorationPolicy — next_period defers it, anything else applies it now.","type":"boolean"},"currency":{"description":"ISO 4217; must equal the subscription's currency — it cannot change.","examples":["EUR"],"type":"string"},"priceMinor":{"description":"The new charge for one full billing period, integer minor units, 1 to 10^12.","examples":[12900],"format":"int64","type":"integer"}},"required":["priceMinor","currency"],"type":"object"},"CommitmentDoc":{"additionalProperties":false,"properties":{"minimumMinor":{"description":"Floor: the period bills at least this much, in minor units.","examples":[5000],"format":"int64","type":"integer"},"spendCapMinor":{"description":"Ceiling on the money the period can bill, in minor units.","examples":[500000],"format":"int64","type":"integer"},"usageCap":{"description":"Ceiling on the billable quantity, decimal string.","type":"string"}},"type":"object"},"ContractCreditsResponse":{"additionalProperties":false,"properties":{"balances":{"items":{"$ref":"#/components/schemas/CreditBalance"},"type":["array","null"]},"entries":{"items":{"$ref":"#/components/schemas/CreditLedgerEntry"},"type":["array","null"]},"hasMore":{"description":"True when older ledger entries exist beyond this page.","type":"boolean"},"nextBeforeId":{"description":"The original name of nextCursor, carrying the same value. Kept for existing integrations.","format":"int64","type":"integer"},"nextCursor":{"description":"Pass back as cursor to fetch the next page. Present only when hasMore.","format":"int64","type":"integer"}},"required":["balances","entries","hasMore"],"type":"object"},"ContractLineItemRequest":{"additionalProperties":false,"properties":{"componentConfig":{"description":"Settings for this component type — e.g. {\"interval\":\"month\",\"intervalCount\":1} for recurring, {\"metric\":\"api_calls\",\"aggregation\":\"sum\"} for usage."},"componentType":{"description":"What this line bills: recurring, usage, one_time, milestone, project, marketplace, credit, adjustment or hybrid.","examples":["recurring"],"type":"string"},"quantity":{"description":"How many of this component, a positive decimal string.","examples":["1"],"type":"string"}},"required":["componentType","quantity"],"type":"object"},"ContractSummary":{"additionalProperties":false,"properties":{"contractId":{"description":"Contract id — the key of every per-contract endpoint.","examples":[42],"format":"int64","type":"integer"},"contractNumber":{"description":"Contract document number; empty for contracts that predate numbering.","examples":["UK2607AA"],"type":"string"},"currency":{"description":"Contract currency (ISO 4217).","examples":["EUR"],"type":"string"},"customerCompanyCountry":{"description":"The customer company's registration country as agreed on the contract; absent when not stated.","examples":["NL"],"type":"string"},"customerCompanyName":{"description":"The customer's company as agreed on the contract; absent when not stated.","examples":["Harbour Group BV"],"type":"string"},"customerName":{"description":"The customer's name as agreed on the contract; absent when not stated.","examples":["Ada Lovelace"],"type":"string"},"customerPhone":{"description":"The customer's phone as agreed on the contract; absent when not stated.","examples":["+31 6 1234 5678"],"type":"string"},"endDate":{"description":"Contract end (YYYY-MM-DD), when fixed.","type":"string"},"sellerCustomerId":{"description":"Your customer on the contract.","examples":[7],"format":"int64","type":"integer"},"startDate":{"description":"Contract start (YYYY-MM-DD).","examples":["2026-01-01"],"type":"string"},"status":{"description":"Contract status.","enum":["draft","active","suspended","closed"],"examples":["active"],"type":"string"},"version":{"description":"Contract version.","examples":[1],"format":"int64","type":"integer"}},"required":["contractId","version","sellerCustomerId","currency","status","startDate"],"type":"object"},"ContractUsageResponse":{"additionalProperties":false,"properties":{"contractId":{"description":"Contract the snapshot belongs to.","examples":[42],"format":"int64","type":"integer"},"metrics":{"items":{"$ref":"#/components/schemas/MetricUsage"},"type":["array","null"]}},"required":["contractId","metrics"],"type":"object"},"CreateContractRequest":{"additionalProperties":false,"properties":{"currency":{"description":"REQUIRED. ISO 4217 the contract bills in: EUR, USD, GBP, DKK, NOK or SEK. Immutable — credit and invoices under it are never converted.","examples":["EUR"],"type":"string"},"customerCompanyCountry":{"description":"The customer company's registration country as agreed on this contract. At most 56 characters.","type":"string"},"customerCompanyName":{"description":"The customer's company as agreed on this contract. At most 255 characters.","type":"string"},"customerName":{"description":"The customer's name as agreed on this contract; defaults to the customer record's. At most 128 characters.","examples":["Ada Lovelace"],"type":"string"},"customerPhone":{"description":"The customer's phone as agreed on this contract. At most 32 characters.","type":"string"},"endDate":{"description":"YYYY-MM-DD the contract ends; omit for an open-ended contract. Must not be before startDate, and not more than ten years ahead.","type":"string"},"lineItems":{"description":"The contract's components, at most 100. Omit for a contract whose billing is defined by the subscriptions attached to it.","items":{"$ref":"#/components/schemas/ContractLineItemRequest"},"type":["array","null"]},"sellerCustomerId":{"description":"REQUIRED. One of your customers, from POST /customers or GET /customers. Must be yours.","examples":[7],"format":"int64","type":"integer"},"startDate":{"description":"REQUIRED. YYYY-MM-DD the contract starts. Up to a year in the past, up to ten years ahead.","examples":["2026-01-01"],"type":"string"}},"type":"object"},"CreateCustomerRequest":{"additionalProperties":false,"properties":{"companyCountry":{"description":"Company country, at most 56 characters.","type":"string"},"companyName":{"description":"Company name, at most 255 characters.","examples":["Analytical Engines Ltd"],"type":"string"},"currency":{"description":"Default billing currency (ISO 4217).","examples":["EUR"],"type":"string"},"customerRef":{"description":"Your own identifier for the customer, at most 128 characters. A ref already held by a DIFFERENT customer of yours answers 422.","examples":["crm-8842"],"type":"string"},"customerType":{"description":"Whether the customer is a person or a company: individual or company.","type":"string"},"email":{"description":"The customer's e-mail address. Required, and the key this call resolves on — the same address always resolves to the same customer.","examples":["ada@example.com"],"type":"string"},"name":{"description":"Contact name, at most 64 characters.","examples":["Ada Lovelace"],"type":"string"},"phone":{"description":"Contact phone, at most 32 characters.","type":"string"}},"required":["email"],"type":"object"},"CreateInvoiceRequest":{"additionalProperties":false,"properties":{"buyer":{"$ref":"#/components/schemas/InvoiceParty","description":"Who the document is addressed to; buyer.email is required. Frozen onto the document at issue — it is who the customer WAS, not who the directory says they are today."},"contractId":{"description":"Contract the document bills; must be yours.","examples":[42],"format":"int64","type":"integer"},"currency":{"description":"ISO 4217; must equal the contract's currency. A document is never issued in a currency the contract does not bill.","examples":["GBP"],"type":"string"},"dateOfSupply":{"description":"YYYY-MM-DD tax point, when it differs from the issue date.","examples":["2026-09-30"],"type":"string"},"dueDate":{"description":"YYYY-MM-DD the payment is due.","examples":["2026-10-31"],"type":"string"},"exemptionReason":{"description":"Machine-readable reason code when tax is not charged, at most 64 characters (intra_community_supply, export, vat_exempt_service, reverse_charge). On a US document it declares a buyer exemption and then requires the buyer's taxExemptionCertificateNumber.","examples":["vat_exempt_service"],"type":"string"},"invoiceType":{"description":"standard (the default), simplified (only below the jurisdiction's retail threshold) or modified (UK retail above it, VAT-inclusive line prices). credit_note is NOT issued here — a correction is raised against the document it corrects, not composed from scratch.","examples":["standard"],"type":"string"},"jurisdiction":{"description":"Tax rule block the document is issued under: UK, US or EU (case-insensitive). It decides which party fields are required and how tax is expressed.","examples":["UK"],"type":"string"},"legalNotice":{"description":"Statutory wording the jurisdiction requires on the document, at most 2000 characters.","type":"string"},"lineItems":{"description":"The document's lines, 1 to 200.","items":{"$ref":"#/components/schemas/NewInvoiceLine"},"type":["array","null"]},"notes":{"description":"Free text printed on the document, at most 2000 characters.","type":"string"},"paymentTerms":{"description":"Payment terms printed on the document, at most 256 characters.","examples":["Net 30"],"type":"string"},"poReference":{"description":"The buyer's purchase order reference, at most 128 characters.","examples":["PO-88213"],"type":"string"},"seller":{"$ref":"#/components/schemas/InvoiceParty","description":"The issuing party. Ignored when Fynex collects for you (the issuer is then Fynex's own entity, set server-side); used as the issuing identity only for merchant-issued sellers."},"sellerCustomerId":{"description":"Your customer the document is addressed to; must be yours. Mint one with POST /customers.","examples":[7],"format":"int64","type":"integer"},"send":{"description":"Whether to create the hosted collection link and e-mail it to the buyer straight after issue. Defaults to true. false issues the document and leaves delivery to POST /invoices/{invoiceId}/send.","type":"boolean"}},"type":"object"},"CreateInvoiceResponse":{"additionalProperties":false,"properties":{"deliveryStatus":{"description":"What THIS call's delivery attempt did. sent: the collection link was created and e-mailed, and paymentLinkUrl carries it. not_requested: none was attempted — send was false, or the document was already settled from stored credit and has nothing to collect. failed: the document IS issued and immutable, but delivery did not happen — retry it with POST /invoices/{invoiceId}/send, which answers the reason. Absent on an idempotent replay, which attempts no delivery of its own.","enum":["sent","not_requested","failed"],"type":"string"},"invoice":{"$ref":"#/components/schemas/Invoice"},"paymentLinkUrl":{"description":"Hosted payment page for the document. Absent when send was false, when the document was settled from stored credit at issue, when delivery failed, and on an idempotent replay — fetch or recreate it with POST /invoices/{invoiceId}/send.","type":"string"}},"required":["invoice"],"type":"object"},"CreateSubscriptionRequest":{"additionalProperties":false,"properties":{"anchorDate":{"description":"YYYY-MM-DD the billing cycle anchors on. Defaults to trialEnd for a trial, startDate otherwise.","examples":["2026-10-01"],"type":"string"},"autoRenew":{"description":"true rolls a new term every cycle (the default when omitted); false fixes the term at endDate, which is then required.","type":"boolean"},"currency":{"description":"ISO 4217; must match the contract's currency. Immutable for the subscription's lifetime.","examples":["EUR"],"type":"string"},"customEvery":{"description":"Multiplier of a custom cadence — every N units, 1–366.","examples":[3],"format":"int64","type":"integer"},"customUnit":{"description":"Unit of a custom cadence. Set exactly when frequency is custom.","enum":["day","week","month","year"],"type":"string"},"endDate":{"description":"YYYY-MM-DD fixed term end. Required when autoRenew is false, forbidden otherwise.","type":"string"},"frequency":{"description":"Billing cadence. annual, not yearly. custom needs customUnit and customEvery.","enum":["daily","weekly","bi_weekly","monthly","quarterly","semi_annual","annual","custom"],"examples":["monthly"],"type":"string"},"noticePeriodDays":{"description":"Days of notice a cancellation requires, 0–365; 0 means immediate at the current term's end.","examples":[30],"format":"int64","type":"integer"},"priceMinor":{"description":"Charge for one full billing period, integer minor units, 1 to 10^12.","examples":[9900],"format":"int64","type":"integer"},"prorationPolicy":{"description":"The proration term to record on the subscription. While the proration engine is enabled it selects what an immediate change-plan does: by_day puts the new price in force today and posts an adjustment for the unserved remainder of the period, full_period puts the new price in force today and bills the WHOLE current period at it — the days already elapsed included — with no adjustment posted, next_period keeps the old price for the rest of the current period and applies the new one from the next. With the engine off — the current default — it is inert and an immediate change behaves as full_period. Where billing.proration.invoice_binding is on, that adjustment is bound to the term's next invoice — a positive net as an extra line, a negative one as a discount on the recurring line plus a non-expiring proration credit lot for the remainder (#2793); otherwise it waits in the contract's adjustment ledger.","enum":["by_day","full_period","next_period"],"type":"string"},"startDate":{"description":"YYYY-MM-DD the subscription begins. May be up to a year in the past — the next lifecycle pass catches up.","examples":["2026-10-01"],"type":"string"},"trialEnd":{"description":"YYYY-MM-DD the trial ends; omit for no trial. A trial end on or before startDate is no trial.","type":"string"},"trialEndBehavior":{"description":"What happens when the trial ends: convert (default) becomes paying, cancel lapses.","enum":["convert","cancel"],"type":"string"},"trialRequiresPaymentMethod":{"description":"true makes a convert land in past_due when the customer has no usable payment method on file.","type":"boolean"}},"required":["frequency","priceMinor","currency","startDate"],"type":"object"},"CreateWebhookEndpointDTO":{"additionalProperties":false,"properties":{"description":{"description":"Your own label for this endpoint — which system receives it, which environment. Carried back on every listing.","examples":["billing events → ERP staging"],"type":"string"}},"type":"object"},"CreditBalance":{"additionalProperties":false,"properties":{"balanceMinor":{"description":"Balance in minor units.","examples":[250000],"format":"int64","type":"integer"},"creditType":{"description":"promotional, purchased, manual, gift, enterprise, ai_token, marketplace or proration.","examples":["purchased"],"type":"string"},"currency":{"description":"ISO 4217 currency of the balance.","examples":["EUR"],"type":"string"},"isLiability":{"description":"True for credit that was PAID FOR (purchased, enterprise, proration) — unearned revenue you owe as service. False for granted credit (promotional, manual, gift, ...).","type":"boolean"}},"required":["currency","creditType","balanceMinor","isLiability"],"type":"object"},"CreditEventPayload":{"additionalProperties":false,"properties":{"appliedMinor":{"description":"Credit consumed by this drawdown, in integer minor units.","examples":[250000],"format":"int64","type":"integer"},"contractId":{"description":"Contract whose credit balance moved.","examples":[42],"format":"int64","type":"integer"},"currency":{"description":"ISO 4217 currency; credit is held and drawn per currency.","examples":["EUR"],"type":"string"},"invoiceId":{"description":"The invoice that drew the credit down.","examples":[4181],"format":"int64","type":"integer"},"invoiceNumber":{"description":"That invoice's legal number.","examples":["UK2607AA-2608AAC"],"type":"string"},"occurredAt":{"description":"When the drawdown happened, RFC 3339 UTC.","format":"date-time","type":"string"},"remainingMinor":{"description":"Credit still available on the contract in this currency after the movement, in integer minor units. Zero on every CreditDepleted by definition.","examples":[0],"format":"int64","type":"integer"}},"required":["contractId","invoiceId","currency","appliedMinor","remainingMinor","occurredAt"],"type":"object"},"CreditLedgerEntry":{"additionalProperties":false,"properties":{"creditType":{"description":"Credit type the entry moved: promotional, purchased, manual, gift, enterprise, ai_token, marketplace or proration. A proration lot is minted by the engine when a mid-term subscription amendment credits more than the invoice it lands on can absorb; it is a liability and never expires.","examples":["purchased"],"type":"string"},"currency":{"description":"ISO 4217 currency of the entry.","examples":["EUR"],"type":"string"},"expiresAt":{"description":"When the granted credit expires.","format":"date-time","type":"string"},"id":{"description":"Ledger entry id — the keyset cursor for this history.","examples":[1201],"format":"int64","type":"integer"},"invoiceId":{"description":"The invoice a deduction funded.","format":"int64","type":"integer"},"kind":{"description":"topup (credit granted), deduction (consumed into an invoice), expiry (a lapsed lot removed) or reversal (a deduction returned to the customer when the invoice it funded was cancelled).","examples":["topup"],"type":"string"},"lotId":{"description":"The topup lot a deduction or expiry consumed.","format":"int64","type":"integer"},"occurredAt":{"description":"When the entry was recorded. The ledger is append-only, so this never changes.","format":"date-time","type":"string"},"reason":{"description":"Audited reason recorded with the entry.","examples":["annual prepayment"],"type":"string"},"sellerCustomerId":{"description":"Your customer the entry belongs to, when attributed.","format":"int64","type":"integer"},"signedDeltaMinor":{"description":"Signed movement in minor units: positive for topups and reversals, negative for deductions and expiries.","examples":[250000],"format":"int64","type":"integer"}},"required":["id","kind","creditType","signedDeltaMinor","currency","reason","occurredAt"],"type":"object"},"CreditTopUpRequest":{"additionalProperties":false,"properties":{"amountMinor":{"description":"Credit to grant, integer minor units, 1 to 10^12. Always positive — this is the lot, not a movement.","examples":[250000],"format":"int64","type":"integer"},"creditType":{"description":"What the credit is: promotional, purchased, manual, gift, enterprise, ai_token or marketplace. purchased and enterprise were PAID FOR — they are a liability you owe as service and may never carry an expiry; the rest are granted.","examples":["purchased"],"type":"string"},"currency":{"description":"ISO 4217; must equal the contract's current currency. Credit is never converted between currencies.","examples":["EUR"],"type":"string"},"expiresAt":{"description":"RFC 3339 instant this lot lapses; omit for credit that never expires. Must be in the future, and is refused outright on purchased and enterprise credit.","examples":["2027-01-01T00:00:00Z"],"type":"string"},"reason":{"description":"Why the credit was granted, at most 512 characters. Recorded on the ledger row and shown wherever the entry is.","examples":["annual prepayment"],"type":"string"},"sellerCustomerId":{"description":"One of your customers to attribute the lot to, for per-customer reporting. Must be yours. The contract is still the balance anchor.","format":"int64","type":"integer"}},"required":["creditType","amountMinor","currency","reason"],"type":"object"},"CreditWebhookEvent":{"additionalProperties":false,"properties":{"eventId":{"description":"Stable identity of this event. Deliveries are at-least-once — deduplicate on this, not on the payload.","examples":[918204],"format":"int64","type":"integer"},"eventType":{"description":"Which movement fired.","enum":["CreditApplied","CreditDepleted"],"examples":["CreditApplied"],"type":"string"},"occurredAt":{"description":"When the state change happened, RFC 3339 UTC. A retried delivery repeats the original value; it is not the send time.","examples":["2026-08-20T14:02:11Z"],"format":"date-time","type":"string"},"payload":{"$ref":"#/components/schemas/CreditEventPayload"},"sellerAccountUuid":{"description":"The Fynex seller account the event belongs to — you, not your customer.","examples":["6f2a1c1e-6a1e-4f10-9f2b-9c1d0b3a7e55"],"format":"uuid","type":"string"}},"required":["eventType","payload","eventId","sellerAccountUuid","occurredAt"],"type":"object"},"Customer":{"additionalProperties":false,"properties":{"companyCountry":{"description":"Company country.","type":"string"},"companyName":{"description":"Company the customer belongs to.","examples":["Analytical Engines Ltd"],"type":"string"},"createdAt":{"description":"RFC 3339 instant the customer record was created.","examples":["2026-09-01T10:15:00Z"],"type":"string"},"currency":{"description":"The customer's default billing currency (ISO 4217). A contract's own currency still wins.","examples":["EUR"],"type":"string"},"customerRef":{"description":"Your own identifier for the customer, if you set one.","examples":["crm-8842"],"type":"string"},"customerType":{"description":"Whether the customer is a person or a company; empty when never stated.","enum":["individual","company"],"type":"string"},"email":{"description":"The customer's e-mail. It is the natural key of this directory: one customer per address per seller account.","examples":["ada@example.com"],"type":"string"},"id":{"description":"The customer id — this is the sellerCustomerId contracts, credit top-ups and invoices refer to.","examples":[7],"format":"int64","type":"integer"},"name":{"description":"Contact name.","examples":["Ada Lovelace"],"type":"string"},"phone":{"description":"Contact phone.","type":"string"}},"required":["id","email","createdAt"],"type":"object"},"DeadLetterDTO":{"additionalProperties":false,"properties":{"attempts":{"description":"How many times a redrive has been tried.","examples":[0],"format":"int64","type":"integer"},"id":{"description":"The dead letter's id, used to redrive or discard it.","examples":[318],"format":"int64","type":"integer"},"payload":{"description":"The event as submitted, so it can be corrected and resent. Absent once discarded."},"reason":{"description":"Why the event was refused, in the words the intake used. This is the thing to fix before redriving.","examples":["unknown metric \"api_call\""],"type":"string"},"receivedAt":{"description":"RFC 3339 timestamp of when the intake refused it.","examples":["2026-09-14T10:22:31Z"],"type":"string"},"source":{"description":"Which intake produced it: events, batch, csv, feed or webhook.","examples":["csv"],"type":"string"},"status":{"description":"pending, redriven or discarded. Only a pending letter can be redriven or discarded.","examples":["pending"],"type":"string"}},"required":["id","source","reason","status","attempts","receivedAt"],"type":"object"},"DeadLettersListResponseDTO":{"additionalProperties":false,"properties":{"deadLetters":{"description":"Events this seller's intakes refused, newest first.","items":{"$ref":"#/components/schemas/DeadLetterDTO"},"type":["array","null"]}},"required":["deadLetters"],"type":"object"},"DiscardResponseDTO":{"additionalProperties":false,"properties":{"status":{"description":"discarded. The letter is closed and will not be metered; discarding is not reversible.","examples":["discarded"],"type":"string"}},"required":["status"],"type":"object"},"ErrorResponse":{"additionalProperties":false,"properties":{"error":{"type":"string"}},"required":["error"],"type":"object"},"EvaluatePricesRequest":{"additionalProperties":false,"properties":{"carriedOverUnits":{"description":"Optional rollover carry to seed the evaluation with, decimal string.","examples":["0"],"type":"string"},"config":{"$ref":"#/components/schemas/PriceConfigDoc"},"quantities":{"description":"Decimal strings; one result per entry, in order. At most 100 per call.","examples":[["12000"]],"items":{"type":"string"},"type":["array","null"]}},"required":["config","quantities"],"type":"object"},"EvaluatePricesResponse":{"additionalProperties":false,"properties":{"results":{"items":{"$ref":"#/components/schemas/EvaluatedPrice"},"type":["array","null"]}},"required":["results"],"type":"object"},"EvaluatedLine":{"additionalProperties":false,"properties":{"amount":{"description":"The line's exact amount, decimal string in major units.","examples":["40"],"type":"string"},"flatAmount":{"description":"The flat charge applied (tiered scheme), decimal string in major units.","examples":["0"],"type":"string"},"tierIndex":{"description":"Index of the band that produced the line (0 for unbanded schemes).","examples":[0],"format":"int64","type":"integer"},"unitPrice":{"description":"The rate applied, decimal string in major units.","examples":["0.004"],"type":"string"},"units":{"description":"Units the line charges, decimal string. For the package scheme this is the number of packages.","examples":["10000"],"type":"string"}},"required":["tierIndex","units","unitPrice","flatAmount","amount"],"type":"object"},"EvaluatedPrice":{"additionalProperties":false,"properties":{"amountMinor":{"description":"The money the period would bill, in minor units — after the usage cap, spend cap and minimum-commitment true-up.","examples":[11000],"format":"int64","type":"integer"},"billableUnits":{"description":"What the scheme actually rated: the quantity less the allotment, bounded by the usage cap. Decimal string.","examples":["11000"],"type":"string"},"carryOver":{"description":"The next period's rollover carry under the config's policy, decimal string.","examples":["0"],"type":"string"},"currency":{"description":"ISO 4217 currency of the amounts.","examples":["EUR"],"type":"string"},"includedUnitsApplied":{"description":"Units the allotment absorbed at zero charge, decimal string.","examples":["1000"],"type":"string"},"lines":{"items":{"$ref":"#/components/schemas/EvaluatedLine"},"type":["array","null"]},"minimumApplied":{"description":"True when the minimum commitment raised the amount.","type":"boolean"},"quantity":{"description":"The evaluated quantity, echoed back.","examples":["12000"],"type":"string"},"ratedAmountMinor":{"description":"The scheme's output before caps and floor, in minor units.","examples":[11000],"format":"int64","type":"integer"},"spendCapApplied":{"description":"True when the spend cap clamped the amount.","type":"boolean"},"usageCapApplied":{"description":"True when the usage cap clamped the billable quantity.","type":"boolean"}},"required":["quantity","amountMinor","ratedAmountMinor","currency","billableUnits","includedUnitsApplied","carryOver","usageCapApplied","spendCapApplied","minimumApplied"],"type":"object"},"EventResponseDTO":{"additionalProperties":false,"properties":{"eventId":{"description":"The stored event's id.","examples":[91422],"format":"int64","type":"integer"},"message":{"description":"Present when metered is false; says why.","type":"string"},"metered":{"description":"Whether the event was attributed to a billing period. false means stored but NOT on the meter — no active subscription schedule covers occurredAt. Check this flag: ignoring it is how a month of usage goes unbilled with every call answering 2xx.","type":"boolean"},"status":{"description":"ingested or duplicate.","examples":["ingested"],"type":"string"}},"required":["status","metered"],"type":"object"},"Invoice":{"additionalProperties":false,"properties":{"collectibleMinor":{"description":"What collection is asking the customer for, in minor units: the grand total less credit applied at issue. It does NOT drop to zero once paid — read status for that.","examples":[12000],"format":"int64","type":"integer"},"contractId":{"description":"Contract the document bills.","examples":[42],"format":"int64","type":"integer"},"creditAppliedMinor":{"description":"Stored credit drawn down into this document at issue, in minor units. Permanent attribution — never zeroed.","examples":[0],"format":"int64","type":"integer"},"creditSettledMinor":{"description":"How much of the document was discharged by stored credit rather than cash, in minor units (drawdown less any returned on cancellation). This is NOT a payment total: a card- or transfer-paid invoice reports 0 here — read status and paidVia to tell whether it was paid.","examples":[0],"format":"int64","type":"integer"},"currency":{"description":"ISO 4217 currency of every amount on the document.","examples":["EUR"],"type":"string"},"customerEmail":{"description":"Customer email as recorded on the document at issue. Returned on the single-invoice read and on send; omitted from list rows.","examples":["ap@acme.example"],"type":"string"},"customerName":{"description":"Customer name as recorded on the document at issue. Returned on the single-invoice read and on send; omitted from list rows, where reading it would mean parsing every document's payload.","examples":["Acme GmbH"],"type":"string"},"dateOfSupply":{"description":"Tax point / date of supply, when it differs from the issue date.","format":"date-time","type":"string"},"dueDate":{"description":"Payment due date, when set.","format":"date-time","type":"string"},"exemptionReason":{"description":"Tax exemption reason code, when exempt.","type":"string"},"grandTotalMinor":{"description":"Legal document total in minor units.","examples":[12000],"format":"int64","type":"integer"},"id":{"description":"Invoice id.","examples":[4180],"format":"int64","type":"integer"},"invoiceNumber":{"description":"Legal document number, unique and gapless per seller.","examples":["UK2607AA-2608AAB"],"type":"string"},"invoiceType":{"description":"standard, credit_note, simplified (below the retail threshold) or modified (UK retail, VAT-inclusive line prices).","examples":["standard"],"type":"string"},"issueDate":{"description":"Date the document was issued.","format":"date-time","type":"string"},"jurisdiction":{"description":"Tax jurisdiction the document was issued under (UK, US or EU).","examples":["UK"],"type":"string"},"legalNotice":{"description":"Jurisdiction-mandated notice printed on the document.","type":"string"},"origin":{"description":"Subsystem that produced the document: recurring, subscription, usage, milestone, project, one_time, adhoc or marketplace.","examples":["usage"],"type":"string"},"originalInvoiceNumber":{"description":"On a credit note: the invoice it corrects.","type":"string"},"outstandingMinor":{"description":"What is actually still owed on the document, in minor units: its total, plus every adjustment ledger entry against it (credit notes, write-offs, manual corrections), less what has been settled. A voided document reports 0. Unlike collectibleMinor — which is frozen at issue and therefore over-states a document that has since been credited or written off — this is the figure to age in an AR report. Omitted when the adjustment ledger is unavailable; treat its absence as unknown, never as zero.","examples":[12000],"format":"int64","type":"integer"},"paidVia":{"description":"How a paid document settled: payment_link (hosted checkout), bank_transfer (a matched deposit) or credit (funded entirely by stored credit).","examples":["payment_link"],"type":"string"},"paymentLinkId":{"description":"The collection payment link, once the invoice was sent. POST …/send returns its hosted URL.","format":"int64","type":"integer"},"reverseCharge":{"description":"True when the reverse-charge mechanism applies.","type":"boolean"},"sellerCustomerId":{"description":"Your customer the document is addressed to.","examples":[7],"format":"int64","type":"integer"},"status":{"description":"The document's lifecycle state.","enum":["draft","issued","sent","paid","overdue","voided","written_off"],"examples":["sent"],"type":"string"},"subtotalMinor":{"description":"Net total in minor units.","examples":[10000],"format":"int64","type":"integer"},"taxTotalMinor":{"description":"Tax total in minor units.","examples":[2000],"format":"int64","type":"integer"},"voidReason":{"description":"Audited reason for the void.","type":"string"},"voidedAt":{"description":"When the document was voided.","format":"date-time","type":"string"},"writeOffReason":{"description":"Audited reason for the write-off.","type":"string"},"writtenOffAt":{"description":"When collection was abandoned.","format":"date-time","type":"string"}},"required":["id","contractId","sellerCustomerId","origin","invoiceNumber","jurisdiction","invoiceType","status","currency","issueDate","subtotalMinor","taxTotalMinor","grandTotalMinor","creditAppliedMinor","collectibleMinor","creditSettledMinor","reverseCharge"],"type":"object"},"InvoiceDetailResponse":{"additionalProperties":false,"properties":{"invoice":{"$ref":"#/components/schemas/Invoice"},"lines":{"items":{"$ref":"#/components/schemas/InvoiceLine"},"type":["array","null"]}},"required":["invoice","lines"],"type":"object"},"InvoiceEventPayload":{"additionalProperties":false,"properties":{"collectibleMinor":{"description":"What collection asks the customer for, in integer minor units: the grand total less the credit drawn down at issue. Frozen at issue, so it is blind to later credit notes, write-offs and settlement — it is NOT a balance and must not be aged in an AR report. Read outstandingMinor on the invoice for the receivable.","examples":[12000],"format":"int64","type":"integer"},"contractId":{"description":"Contract the invoice belongs to.","examples":[42],"format":"int64","type":"integer"},"currency":{"description":"ISO 4217 currency of the amounts below.","examples":["EUR"],"type":"string"},"dueDate":{"description":"When payment is due, RFC 3339. Absent on a document with no due date.","format":"date-time","type":"string"},"grandTotalMinor":{"description":"The document total including tax, in integer minor units (1200 is €12.00).","examples":[12000],"format":"int64","type":"integer"},"invoiceId":{"description":"The invoice this event describes. GET /billing-api/v1/invoices/{invoiceId} is the source of truth for its current state.","examples":[4180],"format":"int64","type":"integer"},"invoiceNumber":{"description":"The document's legal number, assigned at issue and never reused.","examples":["UK2607AA-2608AAB"],"type":"string"},"occurredAt":{"description":"When the transition happened, RFC 3339 UTC. Not the delivery time — a retry carries the original.","format":"date-time","type":"string"},"origin":{"description":"What raised the document: recurring, usage, milestone, project, one_time, adhoc or marketplace.","examples":["usage"],"type":"string"},"paidVia":{"description":"On InvoicePaid, how it settled: payment_link, bank_transfer or credit. Absent otherwise.","examples":["payment_link"],"type":"string"},"sellerCustomerId":{"description":"The customer billed — your buyer, not your Fynex account.","examples":[7],"format":"int64","type":"integer"},"status":{"description":"Where the document landed: issued, sent, paid, overdue, voided or written_off. The event type says what happened; this says the resulting state.","examples":["paid"],"type":"string"}},"required":["invoiceId","invoiceNumber","contractId","sellerCustomerId","status","currency","grandTotalMinor","collectibleMinor","occurredAt"],"type":"object"},"InvoiceLine":{"additionalProperties":false,"properties":{"description":{"description":"Line description.","examples":["API calls over allowance"],"type":"string"},"discountMinor":{"description":"Discount subtracted from quantity × unit price, in minor units.","examples":[0],"format":"int64","type":"integer"},"netAmountMinor":{"description":"Net line amount in minor units.","examples":[10000],"format":"int64","type":"integer"},"position":{"description":"1-based position on the document.","examples":[1],"format":"int64","type":"integer"},"quantity":{"description":"Decimal quantity.","examples":["250"],"type":"string"},"sourceRef":{"description":"What produced the line (usage line, subscription period, charge id) for audit.","type":"string"},"taxAmountMinor":{"description":"Tax on this line in minor units.","examples":[2000],"format":"int64","type":"integer"},"taxCategory":{"description":"UK/EU tax category driving the rate.","examples":["standard"],"type":"string"},"taxRate":{"description":"Tax rate percent as a decimal string.","examples":["20"],"type":"string"},"taxable":{"description":"US jurisdiction taxable flag.","type":"boolean"},"unitPriceMinor":{"description":"Price per unit in minor units.","examples":[40],"format":"int64","type":"integer"}},"required":["position","description","quantity","unitPriceMinor","discountMinor","netAmountMinor","taxRate","taxAmountMinor"],"type":"object"},"InvoiceParty":{"additionalProperties":false,"properties":{"addressLine1":{"description":"Street address.","examples":["22 High Street"],"type":"string"},"addressLine2":{"description":"Second address line.","type":"string"},"businessType":{"description":"Whether the party trades as a business or a consumer, where the jurisdiction's rules distinguish them.","type":"string"},"city":{"description":"City.","examples":["Manchester"],"type":"string"},"companyRegistrationNumber":{"description":"Company registration number.","type":"string"},"country":{"description":"ISO 3166-1 alpha-2 country code.","examples":["GB"],"type":"string"},"ein":{"description":"US Employer Identification Number, NN-NNNNNNN. Validated against that shape before the document is frozen.","examples":["12-3456789"],"type":"string"},"email":{"description":"Where the invoice is e-mailed when send is true. Required on the buyer for delivery.","examples":["ap@acme.example"],"type":"string"},"name":{"description":"Legal name.","examples":["Acme Retail Ltd"],"type":"string"},"phone":{"description":"Contact phone.","type":"string"},"postalCode":{"description":"Postal or ZIP code.","examples":["M1 1AE"],"type":"string"},"region":{"description":"State, province or region. Required on a US party.","examples":["CA"],"type":"string"},"salesTaxId":{"description":"US sales tax identifier.","type":"string"},"taxExemptionCertificateNumber":{"description":"Buyer's exemption certificate. Required on a US tax-exempt sale — a sale that declares exemptionReason and charges no tax.","type":"string"},"vatNumber":{"description":"VAT registration number. Required on the buyer for an intra-Community supply or a reverse-charge document.","examples":["GB123456789"],"type":"string"}},"type":"object"},"InvoiceWebhookEvent":{"additionalProperties":false,"properties":{"eventId":{"description":"Stable identity of this event. Deliveries are at-least-once — deduplicate on this, not on the payload.","examples":[918204],"format":"int64","type":"integer"},"eventType":{"description":"Which transition fired.","enum":["InvoiceIssued","InvoiceSent","InvoicePaid","InvoiceOverdue","InvoiceVoided","InvoiceWrittenOff"],"examples":["InvoicePaid"],"type":"string"},"occurredAt":{"description":"When the state change happened, RFC 3339 UTC. A retried delivery repeats the original value; it is not the send time.","examples":["2026-08-20T14:02:11Z"],"format":"date-time","type":"string"},"payload":{"$ref":"#/components/schemas/InvoiceEventPayload"},"sellerAccountUuid":{"description":"The Fynex seller account the event belongs to — you, not your customer.","examples":["6f2a1c1e-6a1e-4f10-9f2b-9c1d0b3a7e55"],"format":"uuid","type":"string"}},"required":["eventType","payload","eventId","sellerAccountUuid","occurredAt"],"type":"object"},"LegacyAPIError":{"allOf":[{"$ref":"#/components/schemas/ErrorResponse"}],"deprecated":true,"description":"Deprecated: renamed to `ErrorResponse`, which has an identical shape. This name is kept for one version so clients generated before the rename still resolve; it will be removed. Nothing on the wire changed — only the name of the generated type."},"ListContractsResponse":{"additionalProperties":false,"properties":{"contracts":{"items":{"$ref":"#/components/schemas/ContractSummary"},"type":["array","null"]},"hasMore":{"description":"True when further contracts exist beyond this page.","type":"boolean"},"nextAfterId":{"description":"The original name of nextCursor, carrying the same value. Kept for existing integrations.","format":"int64","type":"integer"},"nextCursor":{"description":"Pass back as cursor to fetch the next page. Present only when hasMore.","format":"int64","type":"integer"}},"required":["contracts","hasMore"],"type":"object"},"ListInvoicesResponse":{"additionalProperties":false,"properties":{"hasMore":{"description":"True when older invoices exist beyond this page.","type":"boolean"},"invoices":{"items":{"$ref":"#/components/schemas/Invoice"},"type":["array","null"]},"nextBeforeId":{"description":"The original name of nextCursor, carrying the same value. Kept for existing integrations.","format":"int64","type":"integer"},"nextCursor":{"description":"Pass back as cursor to fetch the next page. Present only when hasMore.","format":"int64","type":"integer"}},"required":["invoices","hasMore"],"type":"object"},"ListSubscriptionsResponse":{"additionalProperties":false,"properties":{"hasMore":{"description":"True when older subscriptions exist beyond this page.","type":"boolean"},"nextBeforeId":{"description":"The original name of nextCursor, carrying the same value. Kept for existing integrations.","format":"int64","type":"integer"},"nextCursor":{"description":"Pass back as cursor to fetch the next page. Present only when hasMore.","format":"int64","type":"integer"},"subscriptions":{"items":{"$ref":"#/components/schemas/Subscription"},"type":["array","null"]}},"required":["subscriptions","hasMore"],"type":"object"},"MetricDTO":{"additionalProperties":false,"properties":{"aggregation":{"description":"How a period's events collapse into one billable quantity.","enum":["sum","max","min","last","count","count_unique"],"examples":["sum"],"type":"string"},"description":{"description":"Metric description.","type":"string"},"name":{"description":"Registry identifier.","examples":["api_calls"],"type":"string"},"uniqueKey":{"description":"count_unique only: the metadata key counted distinct.","type":"string"},"unit":{"description":"Unit of the metric.","examples":["call"],"type":"string"},"version":{"description":"Increments when a registration changes the definition; events are attributed to the version current when they arrive.","examples":[1],"format":"int64","type":"integer"}},"required":["name","version","unit","aggregation"],"type":"object"},"MetricUsage":{"additionalProperties":false,"properties":{"capMode":{"description":"How the cap is enforced: hard (blocks past the cap) or soft (alerts only).","examples":["hard"],"type":"string"},"capQuantity":{"description":"Configured usage cap, when a limit policy exists.","examples":["50000"],"type":"string"},"includedUnits":{"description":"Plan allowance for the period.","examples":["10000"],"type":"string"},"metricName":{"description":"Metric the usage is metered under.","examples":["api_calls"],"type":"string"},"percentOfCap":{"description":"used / capQuantity, percent.","examples":["20.5"],"type":"string"},"percentOfPlan":{"description":"used / includedUnits, percent.","examples":["102.5"],"type":"string"},"periodEnd":{"description":"Open billing period end (YYYY-MM-DD).","examples":["2026-08-31"],"type":"string"},"periodStart":{"description":"Open billing period start (YYYY-MM-DD).","examples":["2026-08-01"],"type":"string"},"used":{"description":"Quantity metered so far this period.","examples":["10250"],"type":"string"}},"required":["metricName","used","includedUnits"],"type":"object"},"MetricsListResponseDTO":{"additionalProperties":false,"properties":{"metrics":{"items":{"$ref":"#/components/schemas/MetricDTO"},"type":["array","null"]}},"required":["metrics"],"type":"object"},"NewInvoiceLine":{"additionalProperties":false,"properties":{"description":{"description":"What the line charges for. Printed on the document.","examples":["Consulting, August"],"type":"string"},"discountMinor":{"description":"Subtracted from quantity × unit price before tax, in minor units — a discount reduces the taxable amount, not just the price tag.","examples":[0],"format":"int64","type":"integer"},"quantity":{"description":"Decimal quantity, up to six decimal places; magnitude at most 10^9.","examples":["2"],"type":"string"},"sourceRef":{"description":"Your own reference for what produced the line, at most 128 characters, carried onto the document for audit. The collections: prefix is reserved for machine-issued lines and is refused.","examples":["erp:SO-4471"],"type":"string"},"taxCategory":{"description":"UK/EU tax category driving the rate, at most 32 characters (standard, reduced, zero, exempt).","examples":["standard"],"type":"string"},"taxRatePercent":{"description":"Tax rate as a decimal percentage, 0 to 100. Omit for 0.","examples":["20"],"type":"string"},"taxable":{"description":"US jurisdiction taxable flag. Meaningless on a UK/EU document, which uses taxCategory.","type":"boolean"},"unitPriceMinor":{"description":"Price per unit in integer minor units. Required, and explicitly 0 for a waived charge — the field is a pointer so an omitted price is a 400 rather than a free line.","examples":[5000],"format":"int64","type":["integer","null"]}},"type":"object"},"PauseSubscriptionInputBody":{"additionalProperties":false,"properties":{"pauseUntil":{"description":"YYYY-MM-DD to resume automatically. Omit to pause until you resume — subject to the seller's maximum pause policy.","examples":["2026-12-01"],"type":"string"}},"type":"object"},"PriceConfigDoc":{"additionalProperties":false,"properties":{"allotment":{"$ref":"#/components/schemas/AllotmentDoc"},"commitment":{"$ref":"#/components/schemas/CommitmentDoc"},"currency":{"description":"ISO 4217; must match the contract's currency.","examples":["EUR"],"type":"string"},"packageSize":{"description":"The package scheme's block size in units, decimal string. A partial package charges in full.","type":"string"},"percentBps":{"description":"The percentage scheme's rate in integer basis points (100 = 1%), applied to a quantity that is itself money in major units.","format":"int64","type":"integer"},"rounding":{"description":"Applied once, when the exact decimal amount collapses to minor units. No default — state it.","enum":["half_up","half_even","up","down"],"examples":["half_up"],"type":"string"},"scheme":{"description":"How the quantity is charged. graduated and volume are the classic trap — same bands, same quantity, different money.","enum":["per_unit","graduated","volume","tiered","package","percentage"],"examples":["graduated"],"type":"string"},"tiers":{"description":"Bands of the banded schemes: ordered, contiguous from zero, last band unbounded.","items":{"$ref":"#/components/schemas/PriceTierDoc"},"type":["array","null"]},"unitPrice":{"description":"The per_unit rate, decimal string in MAJOR units — the deliberate exception to the minor-units convention. Present but ignored by banded schemes.","examples":["0.004"],"type":"string"}},"required":["scheme","currency","rounding","unitPrice"],"type":"object"},"PriceConfigEnvelope":{"additionalProperties":false,"properties":{"config":{"$ref":"#/components/schemas/PriceConfigDoc"},"effectiveFrom":{"description":"YYYY-MM-DD date the configuration takes effect. Pricing is versioned by date — a new configuration never rewrites history.","examples":["2026-09-01"],"type":"string"}},"required":["effectiveFrom","config"],"type":"object"},"PriceConfigStored":{"additionalProperties":false,"properties":{"config":{"$ref":"#/components/schemas/PriceConfigDoc"},"contractId":{"description":"Contract the price belongs to.","examples":[42],"format":"int64","type":"integer"},"currency":{"description":"ISO 4217 currency of the configuration.","examples":["EUR"],"type":"string"},"effectiveFrom":{"description":"Date this version takes effect.","examples":["2026-09-01"],"type":"string"},"metricName":{"description":"The priced metric.","examples":["api_calls"],"type":"string"},"version":{"description":"Configuration version; increments per stored change.","examples":[1],"format":"int64","type":"integer"}},"required":["contractId","metricName","version","effectiveFrom","currency","config"],"type":"object"},"PriceTierDoc":{"additionalProperties":false,"properties":{"flatAmount":{"description":"The band's flat charge for tiered, decimal string in major units.","examples":["0"],"type":"string"},"unitPrice":{"description":"The band's rate for graduated and volume, decimal string in major units.","examples":["0.004"],"type":"string"},"upTo":{"description":"Inclusive upper bound in units, decimal string; null marks the last, unbounded band.","examples":["10000"],"type":["string","null"]}},"required":["upTo","unitPrice","flatAmount"],"type":"object"},"RedriveResponseDTO":{"additionalProperties":false,"properties":{"eventId":{"description":"The stored event's id when the redrive was accepted; absent when it failed again.","examples":[91424],"format":"int64","type":"integer"},"status":{"description":"ingested when the retry was accepted, or rejected when it failed again — the letter stays pending and the reason is updated.","examples":["ingested"],"type":"string"}},"required":["status"],"type":"object"},"RegisterMetricDTO":{"additionalProperties":false,"properties":{"aggregation":{"description":"How a period's events collapse into one billable quantity.","enum":["sum","max","min","last","count","count_unique"],"examples":["sum"],"type":"string"},"description":{"description":"Optional, at most 256 characters.","type":"string"},"name":{"description":"Registry identifier — what events reference.","examples":["api_calls"],"type":"string"},"uniqueKey":{"description":"count_unique only: the event-metadata key whose distinct values are counted (a user id, an endpoint). Required there, forbidden elsewhere. At most 1000 distinct values count per period; beyond that the balance is flagged as overflowed. Part of the versioned definition: registering the name again with a different key creates the next version rather than editing this one.","type":"string"},"unit":{"description":"Free text, at most 32 characters.","examples":["call"],"type":"string"}},"required":["name","unit","aggregation"],"type":"object"},"SellerCreditsResponse":{"additionalProperties":false,"properties":{"balances":{"items":{"$ref":"#/components/schemas/CreditBalance"},"type":["array","null"]}},"required":["balances"],"type":"object"},"SendInvoiceResponse":{"additionalProperties":false,"properties":{"invoice":{"$ref":"#/components/schemas/Invoice"},"paymentLinkUrl":{"description":"Hosted payment page for the invoice. Empty only if the existing link's URL could not be resolved.","type":"string"}},"required":["invoice"],"type":"object"},"SettlementPayment":{"additionalProperties":false,"properties":{"amountMinor":{"description":"The payment's amount in minor units.","examples":[12000],"format":"int64","type":"integer"},"createdAt":{"description":"RFC 3339 UTC time the attempt was made.","type":"string"},"currency":{"description":"ISO 4217 currency of the payment.","examples":["EUR"],"type":"string"},"paymentId":{"description":"The payment's id on the Payments API.","examples":[991],"format":"int64","type":"integer"},"payouts":{"description":"The payouts that have attributed this payment. Empty means the money has not been scheduled into a payout yet.","items":{"$ref":"#/components/schemas/SettlementPayout"},"type":["array","null"]},"status":{"description":"The payment's own status, as it stands. Failed attempts are reported too — they are part of the story, not noise.","examples":["settled"],"type":"string"}},"required":["paymentId","status","amountMinor","currency","createdAt"],"type":"object"},"SettlementPayout":{"additionalProperties":false,"properties":{"attributedMinor":{"description":"The slice of the payment this payout carries, in minor units. A payment can be split across payouts; the attributions sum to what left.","examples":[12000],"format":"int64","type":"integer"},"completedAt":{"description":"RFC 3339 UTC time the payout completed; empty while it is still moving.","type":"string"},"currency":{"description":"ISO 4217 currency of the attribution.","examples":["EUR"],"type":"string"},"payoutId":{"description":"The payout carrying (part of) this payment. Its full detail lives on the Payments API.","examples":[55],"format":"int64","type":"integer"},"state":{"description":"This attribution's state: reserved (selected for the payout), finalized (paid out) or released (returned to the pool).","examples":["finalized"],"type":"string"},"status":{"description":"The payout's own status: pending, dispatching, completed, …","examples":["completed"],"type":"string"}},"required":["payoutId","status","state","attributedMinor","currency"],"type":"object"},"SettlementTrailResponse":{"additionalProperties":false,"properties":{"invoiceId":{"description":"The invoice this trail belongs to.","examples":[4180],"format":"int64","type":"integer"},"invoiceNumber":{"description":"Its legal document number.","examples":["UK2607AA-2608AAB"],"type":"string"},"note":{"description":"Present when the trail is structurally empty: the invoice was never sent, settled by a matched bank deposit, or was discharged entirely by stored credit.","type":"string"},"paidVia":{"description":"How the document settled, when it has: payment_link, bank_transfer or credit.","examples":["payment_link"],"type":"string"},"paymentLinkId":{"description":"The collection link the trail hangs off; absent until the invoice was sent.","format":"int64","type":"integer"},"payments":{"description":"Payment attempts on the collection link, newest first, each with its payout attributions.","items":{"$ref":"#/components/schemas/SettlementPayment"},"type":["array","null"]}},"required":["invoiceId","invoiceNumber","payments"],"type":"object"},"Subscription":{"additionalProperties":false,"properties":{"anchorDate":{"description":"Billing cycle anchor every period boundary derives from.","format":"date-time","type":"string"},"autoRenew":{"description":"True rolls a new term every cycle; false expires at endDate.","type":"boolean"},"billingFrequency":{"description":"Billing cadence: daily, weekly, bi_weekly, monthly, quarterly, semi_annual, annual or custom. The annual cadence is named annual; there is no yearly value.","examples":["monthly"],"type":"string"},"cancelEffectiveAt":{"description":"When a requested cancellation takes effect.","format":"date-time","type":"string"},"cancelRequestedAt":{"description":"When cancellation was requested.","format":"date-time","type":"string"},"contractId":{"description":"Contract the subscription bills on.","examples":[42],"format":"int64","type":"integer"},"createdAt":{"description":"When the subscription was created.","format":"date-time","type":"string"},"currency":{"description":"ISO 4217 currency, immutable for the subscription's lifetime.","examples":["EUR"],"type":"string"},"currentPeriodEnd":{"description":"Current billing term end.","format":"date-time","type":"string"},"currentPeriodStart":{"description":"Current billing term start; absent until the subscription activates.","format":"date-time","type":"string"},"customEvery":{"description":"Multiplier of a custom cadence (every N units).","format":"int64","type":"integer"},"customUnit":{"description":"Unit of a custom cadence (day, week, month, year). Set exactly when billingFrequency is custom.","type":"string"},"endDate":{"description":"Fixed term end, when autoRenew is false.","format":"date-time","type":"string"},"id":{"description":"Subscription id.","examples":[310],"format":"int64","type":"integer"},"noticePeriodDays":{"description":"Days of notice a cancellation requires.","examples":[0],"format":"int64","type":"integer"},"pauseEndsAt":{"description":"Scheduled auto-resume date; absent for an open-ended pause.","format":"date-time","type":"string"},"pausedAt":{"description":"Set while the subscription is paused.","format":"date-time","type":"string"},"pendingPriceChangeAt":{"description":"When the pending price takes effect.","format":"date-time","type":"string"},"pendingPriceMinor":{"description":"A plan change scheduled to term end, in minor units, applied by the renewal pass.","format":"int64","type":"integer"},"priceMinor":{"description":"Charge for one full billing period, in minor units.","examples":[9900],"format":"int64","type":"integer"},"prorationPolicy":{"description":"The proration term recorded on the subscription: 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 this platform: by_day 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 — with no adjustment, next_period keeps the old price for the rest of the current period and applies the new one from the next. With the engine off — the current default — no adjustment is ever posted and the change lands immediately whatever this says. Where billing.proration.invoice_binding is on, that adjustment is bound to the term's next invoice — a positive net as an extra line, a negative one as a discount on the recurring line plus a non-expiring proration credit lot for the remainder (#2793); otherwise it waits in the contract's adjustment ledger.","examples":["by_day"],"type":"string"},"startDate":{"description":"When the subscription begins.","format":"date-time","type":"string"},"status":{"description":"The subscription's lifecycle state.","enum":["trial","active","past_due","paused","canceled","expired"],"examples":["active"],"type":"string"},"trialEnd":{"description":"Trial end date, when the subscription started with a trial.","format":"date-time","type":"string"},"trialEndBehavior":{"description":"What happens when the trial reaches its end: convert (becomes paying) or cancel (lapses).","examples":["convert"],"type":"string"},"trialRequiresPaymentMethod":{"description":"True when the trial needs a payment method on file before it can convert; without one a convert-behaviour trial cannot start billing.","type":"boolean"},"updatedAt":{"description":"When the subscription last changed — a renewal, a pause, a plan change or a cancellation request.","format":"date-time","type":"string"}},"required":["id","contractId","status","billingFrequency","priceMinor","currency","anchorDate","startDate","trialRequiresPaymentMethod","autoRenew","noticePeriodDays","createdAt","updatedAt"],"type":"object"},"SubscriptionEventPayload":{"additionalProperties":false,"properties":{"billingFrequency":{"description":"The cadence terms roll on: daily, weekly, bi_weekly, monthly, quarterly, semi_annual, annual or custom.","examples":["monthly"],"type":"string"},"contractId":{"description":"Contract the subscription runs on; the customer is looked up through it.","examples":[74],"format":"int64","type":"integer"},"currency":{"description":"ISO 4217 currency of the price below.","examples":["EUR"],"type":"string"},"currentPeriodEnd":{"description":"End of that term, RFC 3339. A pause does not move it — billing resumes on the original schedule.","format":"date-time","type":"string"},"currentPeriodStart":{"description":"Start of the term in force when the event fired, RFC 3339.","format":"date-time","type":"string"},"occurredAt":{"description":"When the transition happened, RFC 3339 UTC.","format":"date-time","type":"string"},"priceMinor":{"description":"The recurring price per term, in integer minor units.","examples":[820000],"format":"int64","type":"integer"},"status":{"description":"Where the subscription landed: trial, active, past_due, paused, canceled or expired. There is deliberately no previousStatus.","examples":["paused"],"type":"string"},"subscriptionId":{"description":"The subscription this event describes. GET /billing-api/v1/subscriptions/{subscriptionId} is the source of truth for its current state.","examples":[15],"format":"int64","type":"integer"}},"required":["subscriptionId","contractId","status","occurredAt"],"type":"object"},"TestClock":{"additionalProperties":false,"properties":{"contractId":{"description":"The contract this clock belongs to. A clock is per-contract: advancing one never moves another.","examples":[42],"format":"int64","type":"integer"},"frozenAt":{"description":"Real time when this clock was created (RFC 3339, UTC). Absent while simulated is false. Together with now it says how far the contract has been pushed from real time.","examples":["2026-01-01T09:15:00Z"],"type":"string"},"now":{"description":"The contract's current instant (RFC 3339, UTC) — what the billing engines treat as now for this contract. Equal to real time until the clock is first advanced.","examples":["2026-02-01T00:00:00Z"],"type":"string"},"simulated":{"description":"False while the contract still runs on real time; true once its clock has been advanced at least once.","examples":[true],"type":"boolean"},"updatedAt":{"description":"When the clock last moved (RFC 3339, UTC). Absent while simulated is false.","examples":["2026-01-01T09:16:12Z"],"type":"string"}},"required":["contractId","now","simulated"],"type":"object"},"TestClockAdvanceRequest":{"additionalProperties":false,"properties":{"to":{"description":"REQUIRED. RFC 3339 instant to advance this contract's clock to. Must be AFTER the contract's current clock — a clock never runs backwards — and at most 366 days after it. Re-sending the instant the clock already sits at is a no-op.","examples":["2026-02-01T00:00:00Z"],"type":"string"}},"type":"object"},"TestClockAdvanceResponse":{"additionalProperties":false,"properties":{"advanced":{"description":"False when the clock already sat at this instant, which is a no-op: nothing was renewed and nothing was issued a second time.","examples":[true],"type":"boolean"},"clock":{"$ref":"#/components/schemas/TestClock"},"invoices":{"$ref":"#/components/schemas/TestClockInvoices"},"lifecycle":{"$ref":"#/components/schemas/TestClockLifecycle"},"notAdvanced":{"description":"Parts of billing this clock does NOT move: they run on real time in their own scheduled passes.","examples":[["usage period close"]],"items":{"type":"string"},"type":["array","null"]}},"required":["clock","advanced","lifecycle","invoices","notAdvanced"],"type":"object"},"TestClockInvoices":{"additionalProperties":false,"properties":{"failed":{"description":"Terms whose issuance errored. Nothing was minted; retry the advance.","examples":[0],"format":"int64","type":"integer"},"issued":{"description":"Recurring invoices issued for this contract's term. At most one per advance.","examples":[1],"format":"int64","type":"integer"},"passDisabled":{"description":"True when billing.recurring.invoice_pass is switched off (or unwired) on this deployment, so no document was raised even though the term renewed.","examples":[false],"type":"boolean"},"sent":{"description":"Issued invoices whose payment link was created and e-mailed. A test-mode recipient the sandbox rule refuses counts as issued but not sent.","examples":[1],"format":"int64","type":"integer"},"skipped":{"description":"Terms not billed on this call: already claimed by an earlier advance or by the real cron, no prior invoice to continue from, no customer on the contract, or a currency disagreement.","examples":[0],"format":"int64","type":"integer"}},"required":["issued","sent","skipped","failed","passDisabled"],"type":"object"},"TestClockLifecycle":{"additionalProperties":false,"properties":{"canceled":{"description":"Subscriptions whose scheduled cancellation took effect.","examples":[0],"format":"int64","type":"integer"},"cronDisabled":{"description":"True when billing.subscription.lifecycle_cron is switched off on this deployment, so no transition was applied. Ask Fynex to enable it rather than debugging your integration.","examples":[false],"type":"boolean"},"expired":{"description":"Subscriptions that reached a fixed end date.","examples":[0],"format":"int64","type":"integer"},"renewed":{"description":"Subscriptions whose term rolled over, each emitting a subscription_renewed event on the contract's timeline.","examples":[1],"format":"int64","type":"integer"},"resumed":{"description":"Paused subscriptions whose pause window elapsed and resumed.","examples":[0],"format":"int64","type":"integer"},"skipped":{"description":"Subscriptions the real lifecycle cron transitioned concurrently. Benign: the work is already done.","examples":[0],"format":"int64","type":"integer"},"trialsActivated":{"description":"Trials that reached their end and converted to an active subscription.","examples":[0],"format":"int64","type":"integer"},"trialsCanceled":{"description":"Trials that reached their end with trialEndBehavior=cancel and ended.","examples":[0],"format":"int64","type":"integer"},"trialsPastDue":{"description":"Trials that converted but went straight to past_due because the customer has no usable payment method. Also counted in trialsActivated.","examples":[0],"format":"int64","type":"integer"}},"required":["trialsActivated","trialsCanceled","trialsPastDue","renewed","resumed","canceled","expired","skipped","cronDisabled"],"type":"object"},"UpcomingInvoiceLine":{"additionalProperties":false,"properties":{"amountMinor":{"description":"The line's amount in minor units, 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).","examples":[4800],"format":"int64","type":"integer"},"description":{"description":"The description the document line would carry.","examples":["api.requests usage — 2026-09-01 to 2026-10-01"],"type":"string"},"note":{"description":"What makes this particular line an estimate, or why it is zero — a term still in trial, a period still accruing, a credit that lands on another line.","examples":["the period is still open, so the quantity keeps accruing until it closes"],"type":"string"},"periodEnd":{"description":"Day the period the line covers ends (YYYY-MM-DD).","examples":["2026-10-01"],"type":"string"},"periodStart":{"description":"First day of the period the line covers (YYYY-MM-DD).","examples":["2026-09-01"],"type":"string"},"quantity":{"description":"What the line charges for, decimal string: 1 for a recurring term, the period's aggregated quantity for usage, the period-weighted seat count for seats.","examples":["12000"],"type":"string"},"source":{"description":"Which engine produced the line: subscription (a recurring term), proration (an unbilled mid-term amendment), usage (a metered period) or seat (a per-seat period).","examples":["usage"],"type":"string"},"unitPriceMinor":{"description":"The line's unit price in minor units. Present only when the line has a single one — absent for tiered, graduated, volume, package and percentage usage lines, whose price varies by band or is not per unit at all.","examples":[4],"format":"int64","type":"integer"}},"required":["source","description","quantity","amountMinor"],"type":"object"},"UpcomingInvoiceResponse":{"additionalProperties":false,"properties":{"asOf":{"description":"RFC 3339 instant the projection was taken. Metered usage accrues after it.","examples":["2026-09-20T12:00:00Z"],"type":"string"},"collectibleEstimateMinor":{"description":"What a payment rail would be asked for after credit, in minor units — the figure the buyer would see.","examples":[9800],"format":"int64","type":"integer"},"contractId":{"description":"The contract projected.","examples":[42],"format":"int64","type":"integer"},"creditAppliedEstimateMinor":{"description":"What the drawdown would consume in minor units: the available balance bounded by the document total.","examples":[5000],"format":"int64","type":"integer"},"creditAvailableMinor":{"description":"The contract's drawable stored value in minor units, in the projection's currency. Zero while the credits capability is off.","examples":[5000],"format":"int64","type":"integer"},"currency":{"description":"ISO 4217 currency the totals are in. A line in another currency is listed and EXCLUDED from the totals with a note; money is never converted.","examples":["EUR"],"type":"string"},"estimate":{"description":"Always true. Read it rather than inferring from the route's name.","type":"boolean"},"grandTotalMinor":{"description":"The projected document total in minor units. Equals subtotalMinor: tax is NOT projected — see notes.","examples":[14800],"format":"int64","type":"integer"},"lines":{"items":{"$ref":"#/components/schemas/UpcomingInvoiceLine"},"type":["array","null"]},"nextIssueDate":{"description":"The earliest period end that falls due (YYYY-MM-DD) — the day the next document would appear. Absent when nothing is due.","examples":["2026-10-01"],"type":"string"},"notes":{"description":"Every reason these figures are estimates and every exclusion made: the absent tax, an unpriced metric, a foreign-currency line, a capability switched off.","items":{"type":"string"},"type":["array","null"]},"subtotalMinor":{"description":"Sum of the included lines in minor units, pre-tax.","examples":[14800],"format":"int64","type":"integer"}},"required":["contractId","asOf","lines","subtotalMinor","grandTotalMinor","creditAvailableMinor","creditAppliedEstimateMinor","collectibleEstimateMinor","notes","estimate"],"type":"object"},"UsageAlertPayloadDoc":{"additionalProperties":false,"properties":{"alertId":{"description":"The fired alert. One event per rung per period — the alert id is what makes that true.","examples":[9142],"format":"int64","type":"integer"},"capQuantity":{"description":"The cap it is measured against, decimal string.","examples":["10000"],"type":"string"},"contractId":{"description":"Contract whose metered consumption crossed the line.","examples":[42],"format":"int64","type":"integer"},"kind":{"description":"threshold when a warning rung was crossed, cap_reached when the cap itself was.","examples":["threshold"],"type":"string"},"metricName":{"description":"The meter that crossed it.","examples":["api_calls"],"type":"string"},"periodStart":{"description":"Start of the billing period the crossing belongs to, RFC 3339.","format":"date-time","type":"string"},"sellerAccountUuid":{"description":"The seller the meter belongs to; the same value the envelope carries.","format":"uuid","type":"string"},"thresholdPercent":{"description":"Which rung fired, in whole percent of the cap.","examples":[80],"format":"int64","type":"integer"},"triggeredAt":{"description":"When the crossing was detected, RFC 3339 UTC.","format":"date-time","type":"string"},"usedQuantity":{"description":"Consumption at the moment of crossing, decimal string.","examples":["8000"],"type":"string"}},"required":["alertId","sellerAccountUuid","contractId","metricName","periodStart","kind","thresholdPercent","usedQuantity","capQuantity","triggeredAt"],"type":"object"},"UsageAlertWebhookEvent":{"additionalProperties":false,"properties":{"eventId":{"description":"Stable identity of this event. Deliveries are at-least-once — deduplicate on this, not on the payload.","examples":[918204],"format":"int64","type":"integer"},"eventType":{"description":"Always UsageAlertFired.","enum":["UsageAlertFired"],"examples":["UsageAlertFired"],"type":"string"},"occurredAt":{"description":"When the state change happened, RFC 3339 UTC. A retried delivery repeats the original value; it is not the send time.","examples":["2026-08-20T14:02:11Z"],"format":"date-time","type":"string"},"payload":{"$ref":"#/components/schemas/UsageAlertPayloadDoc"},"sellerAccountUuid":{"description":"The Fynex seller account the event belongs to — you, not your customer.","examples":["6f2a1c1e-6a1e-4f10-9f2b-9c1d0b3a7e55"],"format":"uuid","type":"string"}},"required":["eventType","payload","eventId","sellerAccountUuid","occurredAt"],"type":"object"},"UsageEventDTO":{"additionalProperties":false,"properties":{"contractId":{"description":"Contract the usage belongs to, as a string.","examples":["42"],"type":"string"},"idempotencyKey":{"description":"Caller-supplied key for this event. Resending the same key with the same payload is a no-op; the dedup window is unbounded.","examples":["req-2026-09-14-8f21"],"type":"string"},"metadata":{"description":"Optional JSON object carried for the caller's own audit."},"metric":{"description":"A registered metric name.","examples":["api_calls"],"type":"string"},"occurredAt":{"description":"RFC 3339. When the usage happened, not when it was sent: this places the event in a billing period.","examples":["2026-09-14T10:22:31Z"],"type":"string"},"quantity":{"description":"Decimal string — metered quantities must not pass through a float.","examples":["1250"],"type":"string"}},"required":["contractId","metric","quantity","occurredAt","idempotencyKey"],"type":"object"},"WebhookEndpointDTO":{"additionalProperties":false,"properties":{"createdAt":{"description":"RFC 3339 timestamp of creation.","examples":["2026-09-14T10:22:31Z"],"format":"date-time","type":"string"},"description":{"description":"The label given at creation.","type":"string"},"id":{"description":"The endpoint's id, used on rotate-secret and revoke.","examples":[12],"format":"int64","type":"integer"},"revokedAt":{"description":"RFC 3339 timestamp of revocation; absent while active.","format":"date-time","type":"string"},"secretRotatedAt":{"description":"RFC 3339 timestamp of the last secret rotation; absent if never rotated.","format":"date-time","type":"string"},"status":{"description":"active or revoked. A revoked endpoint refuses deliveries and cannot be reactivated; create a new one.","examples":["active"],"type":"string"},"token":{"description":"The path segment your sender posts to: /usage/v1/webhooks/{token}. Not a credential on its own — the HMAC signature is what authenticates a delivery.","examples":["whk_7f21c0"],"type":"string"}},"required":["id","token","status","createdAt"],"type":"object"},"WebhookEndpointListDTO":{"additionalProperties":false,"properties":{"endpoints":{"description":"Every endpoint registered by this seller, revoked ones included.","items":{"$ref":"#/components/schemas/WebhookEndpointDTO"},"type":["array","null"]}},"required":["endpoints"],"type":"object"},"WebhookEndpointSecretDTO":{"additionalProperties":false,"properties":{"endpoint":{"$ref":"#/components/schemas/WebhookEndpointDTO","description":"The endpoint this secret belongs to."},"secret":{"description":"The signing secret, shown ONCE. It is stored encrypted and no endpoint reads it back — copy it now or rotate to get a new one.","examples":["whsec_9c2f…"],"type":"string"},"secretNote":{"description":"States the display-once contract in words, so a client author does not discover it later by finding no read endpoint.","type":"string"}},"required":["endpoint","secret","secretNote"],"type":"object"}},"securitySchemes":{"SellerAuth":{"bearerFormat":"Fynex seller secret key (sk_test_… / sk_live_…)","description":"# Authentication\n\nEvery Billing API request must carry a seller secret key:\n\n```\nAuthorization: Bearer sk_live_4f6f...\n```\n\nKeys come in two environments, and the environment must match your account's\ncurrent mode: a Fynex seller account operates either in demo mode or live\nmode, never both at once. While the account is in demo mode only `sk_test_…`\nkeys authenticate; after go-live only `sk_live_…` keys do, and the old test\nkey answers `401` from then on. There is no second, parallel dataset behind\nthe two prefixes — the API always returns your account's own billing records.\n\nKeys are issued and rotated in the Fynex dashboard, on the **Integration**\npage, and\nare stored server-side only as SHA-256 hashes; a lost key cannot be recovered,\nonly regenerated.\n\nOnly `sk_test_…` and `sk_live_…` secret keys authenticate this public API.\nPublishable `pk_…` keys, dashboard sessions, legacy seller tokens, cookies and\nquery-string credentials are rejected. This is a server-to-server API: never\ncall it from a browser or mobile app, and rotate or revoke a compromised key\nin the dashboard immediately. No Billing-specific token store or session is\ncreated; authorization reuses the existing hashed API-key record and its\nactive/revoked and environment checks.\n\nFailure contract:\n\n- `401` — the header is missing, malformed, or the key is unknown or revoked.\n- `403` — the key is valid but the seller account is not active.\n\nThe seller account behind the key is the authoritative tenant: every object\nthis API returns belongs to that seller, and ids belonging to another tenant\nanswer `404`. Never embed secret keys in client-side code; calls must\noriginate from your servers.\n","scheme":"bearer","type":"http"}}},"info":{"description":"# Fynex Billing API\n\nThe Billing API is the public, versioned REST surface for billing data:\ninvoices, subscriptions, contracts, metered usage, and credit balances. Your\nsystems read the documents the billing engine issues, raise collection on\nthem, and write the metering that drives them — see **What v1 does, and what\nit does not** below for exactly where that line falls.\n\nAll endpoints live under the path prefix:\n\n```\n/billing-api/v1\n```\n\nThe host depends on the environment you were onboarded to — see **Base URL**\nin the Quickstart. Throughout these guides examples use `$FYNEX_API_BASE`,\nwhich you set once to your host plus that prefix.\n\nAuthenticate every request with your seller secret key (`sk_test_…` or\n`sk_live_…`) in the `Authorization` header.\n\n**New here? Read Concepts first** — five objects and the relationships\nbetween them, in two minutes. **Then the Quickstart**, which goes from issuing\na key to listing invoices and downloading a PDF. Then:\n\n| Guide | What it covers |\n| --- | --- |\n| Concepts | The five objects, how they relate, and the three questions their names do not answer. |\n| Quickstart | Your first calls, and what each failure status means. |\n| Authentication | Key format, environments, rotation, failure contract. |\n| Errors | Status contract, rate limits, what is safe to retry. |\n| Sandbox \u0026 testing | Hosts, keys, test cards, how to exercise date-driven flows without waiting, and the flags a sandbox needs on. |\n| Pagination \u0026 Amounts | The keyset paging loop; minor units and decimal strings. |\n| Endpoint reference | Every operation in detail: parameters, response fields, failure modes, worked examples. |\n| Workflows | Collecting on an invoice, polling for settlement, reconciling a period, watching usage and credit. |\n| Usage ingestion | The write half of metered billing: metrics, pricing schemes, events, inbound webhooks, and the price preview. |\n| Webhooks | The billing event catalog: invoice lifecycle and credit events, signing, retries. |\n| Code examples | Paging and retry loops in Python, Node, Go and shell; generating a typed client. |\n\n## What v1 does, and what it does not\n\nThe line runs between **documents** and **metering**, not between reads and\nwrites.\n\n**Every object this API lists can also be created through it.** The line\nthat remains is between *creating* a document and *editing* one: contracts\nare amended by appending a version, invoices are corrected by issuing a credit\nnote, and neither is ever changed in place.\n\n**Six surfaces are writable.**\n\n- **Contracts.** `POST /contracts` creates one in `draft` for a customer you\n  own (with a required `Idempotency-Key`), and\n  `POST /contracts/{contractId}/amendments` appends a version — a status move,\n  new dates, a replaced component set — guarded by the `expectedBaseVersion`\n  you last read. Closing a contract does not cancel its subscriptions: cancel\n  those first — see **Contracts**.\n- **Invoices.** `POST /invoices` composes, numbers and (by default) sends a\n  document in one call, with a required `Idempotency-Key`; it is immutable\n  from the moment it is issued — see **Invoices**. `POST /invoices/{invoiceId}/send`\n  raises or re-raises the collection link.\n- **Subscriptions.** `POST /contracts/{contractId}/subscriptions` creates one\n  on a contract you own (with a required `Idempotency-Key`), and\n  `/subscriptions/{subscriptionId}/cancel`, `/pause`, `/resume`, `/end-trial`\n  and `/change-plan` drive its lifecycle — see **Subscriptions**. Marking a\n  subscription past-due or recovered is not yours to do: that is the\n  collection loop's verdict.\n- **Credit top-ups.** `POST /contracts/{contractId}/credits/top-up` grants a\n  credit lot on a contract you own, with a required `Idempotency-Key` whose\n  body *is* compared — see **Credits**. It is the only way credit enters the\n  ledger; the deductions, expiries and reversals that take it out are the\n  engine's rows.\n- **Customers.** `POST /customers` resolves one of your customers by e-mail,\n  creating the record when the address is new — the `sellerCustomerId` every\n  contract, credit grant and invoice refers to. No `Idempotency-Key`: the\n  address is the key, so the call always answers `200`.\n- **Metering**, described in full two paragraphs down.\n\nRead-only does not mean static. On a seller who has them enabled, background\npasses issue invoices for due subscription terms and closed usage, and charge\nsent invoices against the customer's saved card. So a list can grow and a\ndocument can reach `paid` with no call of yours — see **Sandbox \u0026 testing**\nfor the switches that decide it.\n\n**Metering is fully writable.** Registering a metric, pricing it, sending\nevents and previewing what a configuration charges are all part of this API —\nsee the two paragraphs below and the **Usage ingestion** guide. Nothing about\nmetered billing requires the dashboard.\n\nIssued documents stay immutable either way: corrections are new credit notes,\nnever edits.\n\n**Billing emits webhooks** — `InvoiceIssued`/`Sent`/`Paid`/`Overdue`/`Voided`/\n`WrittenOff` and `CreditApplied`/`CreditDepleted` — through the same signed\ndelivery pipe as payment events: one receiver handles both. See the\n**Webhooks** guide. Polling `GET /invoices` remains the right tool for\nreconciliation.\n\n**Metered usage is reported under `/billing-api/v1/usage`**: register a\nmetric, price it, then send events singly, in batches or by CSV. That is what\n`GET /billing-api/v1/contracts/{contractId}/usage` reads back. The **Usage\ningestion** guide documents it end to end.\n\nTwo related surfaces are documented separately and are **not** duplicated\nhere:\n\n- **Payments API** (`/payments-api/v1`) — payments, payouts, wallets,\n  webhooks, and **top-up invoices**.\n- **Payment Links API** (`/api/v1/payment-links`) — standalone payment-link\n  management. Billing invoices create their own collection links via\n  `POST /billing-api/v1/invoices/{invoiceId}/send`; the resulting hosted\n  payment page URL is returned on the invoice itself.\n\n\n# Overview\n\n## Resources\n\n| Resource | What it is |\n| --- | --- |\n| Contract | The commercial agreement between you and one of your customers. Invoices, subscriptions, usage and credits all hang off a contract. |\n| Invoice | An immutable, numbered billing document. Once issued it is never edited — corrections are separate credit-note documents. |\n| Subscription | A recurring billing relationship on a contract: cadence, price, trial, pause and cancellation state. |\n| Usage | Metered consumption aggregated per contract and billing period, with plan allowances and caps. |\n| Credits | Stored-value balances (prepayments, promotional credit) and their append-only ledger. |\n\n## Versioning\n\nThe path prefix (`/billing-api/v1`) is the API version. Backwards-compatible\nadditions (new fields, new endpoints) happen within a version; breaking\nchanges get a new prefix. Unknown response fields must be ignored by clients.\n\n## Identifiers\n\nBilling resources use numeric `id`s (`int64`), unique per resource type.\nContract, invoice, and subscription ids are safe to store and to use in URLs.\nYour tenant identity is derived from the API key — it is never passed as a\nparameter, and objects belonging to other sellers are indistinguishable from\nmissing ones (`404`).\n\n## Immutability\n\nIssued invoices are append-only legal records. There is no update or delete:\na wrong invoice is voided or corrected by a credit note, and both documents\nremain visible in listings. Credit ledger entries are likewise append-only.\n\n\n# Concepts\n\nFive objects, and the relationships between them are not obvious from their\nnames. Read this once and the rest of the reference follows; skip it and the\nmost likely mistake is inventing a call that cannot exist.\n\n```\n                        ┌──────────────┐\n                        │   Customer   │  who you bill\n                        └──────┬───────┘\n                               │ one customer, many contracts\n                        ┌──────▼───────┐\n                        │   Contract   │  the agreement: currency, term\n                        └──────┬───────┘\n              ┌────────────────┼────────────────┐\n              │                │                │\n     ┌────────▼──────┐  ┌──────▼──────┐  ┌──────▼──────┐\n     │ Subscription  │  │    Usage    │  │   Credits   │\n     │ recurring fee │  │ metered qty │  │ stored value│\n     └────────┬──────┘  └──────┬──────┘  └──────┬──────┘\n              │                │                │\n              └───────┬────────┘                │ drawn down at issue\n                      │ billed by a run          │\n               ┌──────▼───────┐                  │\n               │   Invoice    │◄─────────────────┘\n               │  immutable   │\n               └──────────────┘\n```\n\n**Contract is the anchor.** Everything else hangs off one. A subscription, a\nusage meter, a credit balance and an invoice all name a contract, and the\ncontract carries the currency they must all agree on.\n\n**Invoice is a document, not a record you edit.** Once issued it has a number\nand is immutable. A correction is a new document — a credit note — and both\nstay visible in every listing. There is no `PATCH` and no `DELETE`, which\nmeans an agent cannot be told to \"fix\" an invoice: the only correct move is\nto issue a correcting one.\n\n## Three questions the object names do not answer\n\n### Can an invoice exist without a contract?\n\n**No.** Every invoice names a contract, and issuing one without it is refused\nbefore anything is written.\n\n**But you do not have to create the contract first.** An ad-hoc invoice — a\none-off with `origin: adhoc` — provisions what it needs from the bill-to you\ntype: the customer is found or created by email, and a live contract in the\nsame currency is reused if one exists, created if not. So a one-off stays one\ncall. Send the same recipient a second one-off and it lands on the same\ncustomer and the same contract rather than minting duplicates.\n\nThis provisioning is deliberately **only** for ad-hoc documents. A usage or\nrecurring invoice with no contract is a caller mistake, and inventing a\ncontract for it would detach that revenue from the agreement it belongs to.\n\n### Does a subscription generate invoices automatically?\n\n**No — not by itself.** Two different mechanisms are easy to confuse:\n\n- The **lifecycle pass** advances a subscription's *state* on its dates: a\n  trial activates, a cancellation takes effect after its notice period, a\n  fixed end expires it, and a term that has rolled over gets the term now in\n  force. It issues no documents at all.\n- The **billing run** raises the money. It bills each revenue model on a\n  contract — recurring, then usage, then project work, then any one-time line\n  — each through its own engine, each producing its own document so the\n  invoice's `origin` stays truthful.\n\nSo a subscription's period turning over does not, on its own, produce an\ninvoice. Something has to run the billing — and on a seller who has the\nissuance passes switched on (`billing.recurring.invoice_pass`,\n`billing.usage.invoice_pass`), that something is a schedule, not a person.\nBoth default to off. Write your integration so a document appearing without\nyou is normal, not an anomaly.\n\n**Double billing is prevented by claims, not by memory.** A subscription\nperiod lands on a unique `(subscription, period_start)` row, usage lines and\nproject work flip guarded status columns, and every claim's invoice carries\nits id. Re-running a billing run finishes only what is missing; an ambiguous\ncommit is repaired by looking the claim up, never by issuing again. A model\nthat fails does not roll back a sibling's document — a numbered invoice\ncannot be un-issued — and the run reports per model what happened.\n\n### Do credits apply before or after tax?\n\n**After.** Credit is a payment method, not a discount. It draws down the\ninvoice's grand total — the amount *including* tax — and never reduces the\ntaxable base. The tax the document reports is the tax on the full price,\nwhatever the customer's balance was.\n\nTwo consequences worth knowing before you model this:\n\n- A credit-funded invoice is neither *expected* nor *received* cash. It is its\n  own thing, and reporting treats it that way.\n- **An invoice collected by bank transfer draws no credit at all.** The buyer\n  was told to wire the grand total; shrinking the collectible underneath that\n  instruction would make every full-face wire arrive as an overpayment. The\n  balance stays on the ledger for the next link-collected document.\n\n## Three words that are one letter of confusion apart\n\nThe platform has three roles, and two of their field names differ by a single\nword while meaning entirely different things:\n\nThe direction of the money is what separates them:\n\n```\n   Customer  ──── pays ────►   Seller   ──── pays ────►   Payee\n sellerCustomerId            sellerAccountId             payeeId\n your buyer                  YOU, the key holder         who you disburse to\n Billing API                 both APIs                   Payments API\n```\n\n| Word | Who | Lives on |\n| --- | --- | --- |\n| **Seller** (`sellerAccountId`) | **You** — the account holder the API key belongs to. Every object in both APIs is scoped to one. | Both APIs |\n| **Customer** (`sellerCustomerId`) | Who **pays you** — the party your invoices are addressed to. | This API |\n| **Payee** (`payeeId`) | Who **you pay** — a counterparty receiving money through split payments and payouts. | Payments API |\n\n`sellerAccountId` and `sellerCustomerId` look like siblings and are not: the\nfirst is your own identity, the second is your customer's. A marketplace\noperator is all three sentences at once — they **are** a Fynex seller, they\n**have** customers who pay invoices, and they **have** payees who receive\nsplits and payouts.\n\nIf you think in the words *vendor*, *merchant* or *supplier*: the merchant\nrunning the account is the **seller**; a vendor or supplier you disburse money\nto is a **payee**; the buyer you bill is a **customer**. The settlement-trail\nendpoint (`GET /invoices/{invoiceId}/settlement`) is where the two APIs meet:\na customer's payment on this side becomes a payee's payout on the other.\n\n## Where money and quantities live\n\n| Thing | On the wire |\n| --- | --- |\n| An amount of money | Integer **minor units**, field ends `Minor` — `4999` is €49.99 |\n| A rate or percentage | Integer **basis points**, field ends `Bps` — `275` is 2.75% |\n| A metered quantity | Decimal **string** — `\"1250.5\"` |\n| A per-unit rate | Decimal **string** in MAJOR units — `\"0.004\"`, the one deliberate exception, because a rate is routinely finer than a minor unit |\n\nEverything on a contract shares that contract's currency. There is no\nconversion anywhere in this API: a mismatch is refused rather than converted.\n\n\n# Errors\n\nErrors are JSON with a single field:\n\n```json\n{\"error\": \"invoice not found\"}\n```\n\n(Authentication failures from the gateway layer may answer with a plain-text\nbody; treat any non-2xx as failed regardless of body shape.)\n\nEvery response, including `401`, `429`, and server errors, carries\n`X-Request-Id: req_\u003cuuid\u003e`. Include this value in a support request; it is the\nsafe correlation handle for a request, not your API key or customer data.\n\n| Status | Meaning |\n| --- | --- |\n| `400` | The request is malformed: an unparseable id, an unknown filter value, an invalid parameter. |\n| `401` | Missing or invalid API key. |\n| `403` | The key is valid but the seller account is not active. |\n| `404` | The object does not exist — or belongs to another seller. |\n| `422` | The object exists but the action is not applicable to it (for example, sending an invoice that is collected by bank transfer). |\n| `429` | Rate limit exceeded. Honour `Retry-After` and the `X-RateLimit-*` headers. |\n| `501` | The capability is not enabled for this deployment (for example, credits before stored value is switched on). |\n| `500` | Server fault. Safe to retry idempotent (GET) requests with backoff. |\n\n## Rate limiting\n\nRequests are rate-limited per seller, on a budget dedicated to this API —\nbilling traffic and payments-api traffic do not throttle each other. The\ndefault budget is **1000 requests per hour per seller**, with a burst of 100 so\na single client cannot spend the whole hour in one instant. The number is set\nper environment, so read `X-RateLimit-Limit` rather than hard-coding it.\n\nResponses normally carry `X-RateLimit-Limit`, `X-RateLimit-Remaining` and\n`X-RateLimit-Reset` (whole seconds until the budget refills), plus the current\nstandards-track structured fields `RateLimit-Policy` (`\"seller\";q=1000;qu=\"requests\";w=3600`)\nand `RateLimit` (`\"seller\";r=940;t=2100`). Both spellings carry the same numbers.\nNote these are not the `RateLimit-Limit`/`RateLimit-Remaining`/`RateLimit-Reset`\ntriple from an earlier revision of that draft, which is not sent. Read them rather than\nassuming a number: the budget is set per environment, and `X-RateLimit-Limit`\nis the authoritative value for yours. Treat them as advisory — during a limiter outage requests are allowed through without the\nheaders, so a client that requires them will break exactly when the platform\nis already degraded.\n\nThree write operations carry an **extra** per-seller quota on top of that\nbudget, each in its own bucket, because each accepted call spends something\nthat cannot be handed back:\n\n| Operation | Default quota | Why |\n| --- | --- | --- |\n| `POST /contracts` | 20 requests per rolling 24 hours (refusals and replays count) | Bounds how fast one key opens contracts; a create allocates a number from a series shared across sellers. |\n| `POST /contracts/{contractId}/amendments` | 200 requests per rolling 24 hours (a stale `expectedBaseVersion` counts) | Every accepted amendment appends a contract version — up to 100 line items — to a history that is append-only and has no delete. |\n| `POST /invoices` | 500 requests per rolling 24 hours (refusals and replays count) | Issuing allocates a number from your own gapless series, so a runaway loop burns your month of numbers. |\n\nAll three refuse with the same `429`, `Retry-After` and `X-RateLimit-*`\nheaders as the surface-wide limit.\n\nA rejected request answers `429` with `Retry-After` in seconds. Wait that\nlong — retrying sooner only deepens the overage.\n\nBulk work is what actually hits this. Reconciling a month walks pages of up\nto 100 invoices and may pull a PDF per document, so a few hundred requests in\none burst is normal. Pace bulk exports (a short sleep between pages costs far\nless than being throttled mid-walk), and if a legitimate workload cannot fit\nthe budget, ask Fynex to raise it rather than working around it with parallel\nkeys.\n\n### When the write quotas fail closed\n\nUnlike the surface-wide budget, those three operations — `POST /contracts`,\n`POST /contracts/{contractId}/amendments` and `POST /invoices` — **fail\nclosed**. While the limiter itself is unreachable they answer `503` with\n`Retry-After` instead of letting an uncounted burst of writes through. Nothing\nwas created, amended or issued, so the retry is safe: wait the header out and\nrepeat the call with the **same `Idempotency-Key`**, which is what stops the\nretry from opening a second contract or issuing a second document. Amendments\ncarry no `Idempotency-Key` — resend the same `expectedBaseVersion`, and a\nversion that did land answers `422` rather than appending a duplicate.\n\nTwo things this does *not* mean. It is the limiter FAILING, not the quota\nbeing absent: a deployment that switches a quota off (its request budget set\nto `0`) is simply unmetered on that operation, exactly as before, and never\nanswers `503` for this reason. And the quotas count **requests**, not\nsuccesses — a refusal or a replay spends one too, so a client retrying a `400`\nin a loop can exhaust its day without ever writing anything.\n\n\n# Pagination\n\nEvery list endpoint pages by keyset, with the same two parameters:\n\n- `limit` — page size, 1–100 (default 20).\n- `cursor` — where to continue from. Omit it (or pass `0`) for the first page.\n\nEach page reports how to continue:\n\n```json\n{\n  \"invoices\": [ ... ],\n  \"hasMore\": true,\n  \"nextCursor\": 4177\n}\n```\n\nPass `nextCursor` back as `cursor` until `hasMore` is `false`. That loop is\nthe same code on every list in this API.\n\nKeyset paging is stable under concurrent inserts: new rows appear on the first\npage of a fresh iteration and never shift the pages of an iteration already in\nflight.\n\n## Direction is a property of the list, not of the parameter\n\nThe lists do not all walk the same way, and that part is deliberate:\n\n| List | Order | A cursor means |\n| --- | --- | --- |\n| `/invoices`, `/subscriptions` | Newest first | ids **below** the cursor |\n| The credit ledger on `/contracts/{contractId}/credits` | Newest first | ids **below** the cursor |\n| `/contracts` | Ascending contract id | ids **above** the cursor |\n\nContracts walk forward because the id is a stable identity to iterate, not a\nrecency ranking. What used to differ as well was the parameter NAME —\n`beforeId` on some lists, `afterId` on others — so anyone who wrote a working\nloop for invoices wrote a broken one for contracts. `cursor` and `nextCursor`\nare the same on all of them; only the documented order changes.\n\nThe credit ledger pages the `entries` array only: the balances in the same\nresponse are always complete. Seller-wide credit balances are one row per\ncurrency and credit type, so that list is genuinely bounded and does not page.\n\n## The original parameter names\n\n`beforeId` / `nextBeforeId` and `afterId` / `nextAfterId` still work,\nunchanged, on the lists that had them, and every response still carries the\noriginal field beside `nextCursor` with the same value. Existing integrations\nneed no change. Sending both `cursor` and the original name with **different**\nvalues is a `400` rather than a silent choice between them.\n\n## Dates\n\n`issuedFrom` and `issuedTo` accept a calendar date (`2026-08-01`) or a full\nRFC 3339 timestamp. A bare date is read as **UTC midnight**, and the range is\nhalf-open — `issuedFrom` inclusive, `issuedTo` exclusive — so adjacent months\nnever double-count a document. If your books close in a non-UTC zone, send the\ntimestamp form with your offset rather than the bare date.\n\n## Amounts\n\nAll monetary amounts are **integers in the currency's minor units**\n(`grandTotalMinor: 12050` is €120.50 for a EUR document). Quantities and tax\nrates are decimal strings. Never parse amounts as floating point.\n\n\n## Retention and numbering\n\n**Stored events are retained indefinitely.** Nothing prunes them — there is no\nretention window on ingested usage and no job that deletes it, so an\nidempotency key you used a year ago is still recognised and resending that\nevent is still a no-op. Documents are the exception: invoices and credit notes\ncarry a statutory retention period per jurisdiction, which is a legal minimum\non how long they are kept.\n\nAn invoice number is `\u003cagreement\u003e-\u003cYYMM\u003e\u003corder\u003e`: your agreement number, a\ndash, the issue month as `YYMM`, and a three-letter order within that month\n(`AAA`, `AAB`, …) — for example `UK2607AA-2608AAB` is the second document\nissued in August 2026 under agreement `UK2607AA`. The series is allocated\ninside the issuing transaction, so it is gapless per month, and it is shared\nwith top-up invoices so the two document families can never collide.\n","title":"Fynex Billing API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/billing-api/v1/contracts":{"get":{"description":"Lists the seller's billing contracts — the entry point for every per-contract read. Paged by ascending contract id: while `hasMore` is true, pass the returned `nextCursor` back as `cursor` (`afterId`/`nextAfterId` are the same values under their original names). Keyset paging is stable under concurrent inserts: new rows appear on the first page of a fresh iteration and never shift the pages of one already in flight.","operationId":"list-contracts","parameters":[{"description":"Keyset cursor — the unified name on every list in this API. Pass back nextCursor from the previous page; omit for the first page. This list walks FORWARD, by ascending contract id: the direction differs from the invoice and subscription lists, the parameter name no longer does.","explode":false,"in":"query","name":"cursor","schema":{"description":"Keyset cursor — the unified name on every list in this API. Pass back nextCursor from the previous page; omit for the first page. This list walks FORWARD, by ascending contract id: the direction differs from the invoice and subscription lists, the parameter name no longer does.","examples":["42"],"type":"string"}},{"description":"The original name of the cursor on this list. Still accepted and identical in behaviour; sending both with different values is a 400.","explode":false,"in":"query","name":"afterId","schema":{"description":"The original name of the cursor on this list. Still accepted and identical in behaviour; sending both with different values is a 400.","examples":["42"],"type":"string"}},{"description":"Page size (1-100, default 20).","explode":false,"in":"query","name":"limit","schema":{"description":"Page size (1-100, default 20).","examples":["20"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListContractsResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal Server Error"}},"security":[{"SellerAuth":[]}],"summary":"List contracts","tags":["Usage"]},"post":{"description":"Creates the billing relationship every other object on this API hangs from: subscriptions bill on a contract, credit lands on a contract, invoices are issued under one. A new contract always starts in `draft` — activate it with an amendment.\n\n**`Idempotency-Key` is required.** A contract has no natural key of its own, so the header is the only thing that can tell a retry from a second contract. Retrying with the same key returns the contract the first call created — its CURRENT version, amendments included — and answers `200` instead of `201`. The body *is* compared. `sellerCustomerId` and `currency` are compared against the current version (neither is amendable). Everything an amendment CAN move is compared against the version this key originally created, so an amendment that legitimately re-dates or re-prices the contract does not turn later retries into refusals: `startDate` and `endDate` (present or absent), the `lineItems` — line by line, on `componentType`, `componentConfig` and `quantity`, in any order, not merely how many there are — and `customerName`, `customerPhone`, `customerCompanyName` and `customerCompanyCountry`, each compared only when your request states it (a blank one is inherited from the customer record, which is what the first call did too). A difference in any of them answers `422` naming the field, because that is a different intent, not a retry.\n\n`lineItems` are optional and are what a component-based contract bills on; a contract whose billing lives entirely in its subscriptions needs none.\n\nThis operation carries an **extra per-seller quota** on top of the surface-wide rate limit — creating a contract allocates a number from a sequence shared across sellers — so a burst of creates answers `429` with `Retry-After` well before the surface-wide budget is spent. That quota is enforced in Redis and fails closed: while the limiter is unreachable this operation answers `503` with `Retry-After` rather than accepting writes it cannot count.","operationId":"create-contract","parameters":[{"description":"REQUIRED. 1–128 characters from A–Z a–z 0–9 _ . : and -, unique per contract you intend to create — a UUID is the usual choice. Keys are scoped to your seller account. Keys under a prefix the billing engine uses for its own rows (proration:, proration-grant:, redeem:, subscription:, credit_note:, refund:, usage-adjustment:) answer 400: they are not yours to use. The same key returns the contract the first call created (200); presenting it with a request that differs in its sellerCustomerId or currency, or in the startDate, endDate, lineItems (compared line by line — component type, settings and quantity, not merely how many) or customer detail fields the key first created, answers 422 naming the field rather than creating a second contract.","in":"header","name":"Idempotency-Key","schema":{"description":"REQUIRED. 1–128 characters from A–Z a–z 0–9 _ . : and -, unique per contract you intend to create — a UUID is the usual choice. Keys are scoped to your seller account. Keys under a prefix the billing engine uses for its own rows (proration:, proration-grant:, redeem:, subscription:, credit_note:, refund:, usage-adjustment:) answer 400: they are not yours to use. The same key returns the contract the first call created (200); presenting it with a request that differs in its sellerCustomerId or currency, or in the startDate, endDate, lineItems (compared line by line — component type, settings and quantity, not merely how many) or customer detail fields the key first created, answers 422 naming the field rather than creating a second contract.","examples":["5c2f7c40-1a3e-4c9b-9d1f-2b6e5a0c7d31"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateContractRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContractSummary"}}},"description":"Created"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unprocessable Entity"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal Server Error"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Service Unavailable"}},"security":[{"SellerAuth":[]}],"summary":"Create a contract","tags":["Contracts"]}},"/billing-api/v1/contracts/{contractId}/amendments":{"post":{"description":"Records a new version of a contract. Contract history is append-only: nothing is ever rewritten, so a change to the status, the dates, the line items or the customer details is a new version, and the previous one stays readable exactly as it was.\n\n`expectedBaseVersion` is the version you last read. If the contract has moved on since — someone else amended it, or you retried a call that already applied — the amendment is refused with `422` rather than overwriting that change. Re-read the contract and decide again. That compare-and-set is why this operation needs no `Idempotency-Key`: a repeat cannot apply twice.\n\nOmitted fields carry forward. `lineItems` is the exception worth reading twice: omitting it keeps the current set, while sending `[]` clears it — line items are snapshotted per version, so a partial edit does not exist. A `closed` contract is terminal and answers `422`.\n\nThis operation carries an **extra per-seller quota** on top of the surface-wide rate limit — every accepted amendment appends a version that can never be deleted — so a loop of amendments answers `429` with `Retry-After` well before the surface-wide budget is spent. The quota is enforced in Redis and fails closed: while the limiter is unreachable this operation answers `503` with `Retry-After`.","operationId":"amend-contract","parameters":[{"description":"Contract to amend; must be yours.","in":"path","name":"contractId","required":true,"schema":{"description":"Contract to amend; must be yours.","examples":["42"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AmendContractRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContractSummary"}}},"description":"Created"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unprocessable Entity"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal Server Error"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Service Unavailable"}},"security":[{"SellerAuth":[]}],"summary":"Amend a contract","tags":["Contracts"]}},"/billing-api/v1/contracts/{contractId}/credits":{"get":{"description":"Returns one contract's credit balances (always complete) and a page of its append-only ledger history, newest first. Walk the history with `limit`, passing the returned `nextCursor` back as `cursor` while `hasMore` is true (`beforeId`/`nextBeforeId` are the same values under their original names). Keyset paging is stable under concurrent inserts: new rows appear on the first page of a fresh iteration and never shift the pages of one already in flight.","operationId":"get-contract-credits","parameters":[{"description":"Contract id.","in":"path","name":"contractId","required":true,"schema":{"description":"Contract id.","examples":["42"],"type":"string"}},{"description":"Keyset cursor — the unified name on every list in this API. Pass back nextCursor from the previous page; omit for the first page. This ledger walks BACKWARD (newest first).","explode":false,"in":"query","name":"cursor","schema":{"description":"Keyset cursor — the unified name on every list in this API. Pass back nextCursor from the previous page; omit for the first page. This ledger walks BACKWARD (newest first).","examples":["1201"],"type":"string"}},{"description":"The original name of the cursor on this list. Still accepted and identical in behaviour; sending both with different values is a 400.","explode":false,"in":"query","name":"beforeId","schema":{"description":"The original name of the cursor on this list. Still accepted and identical in behaviour; sending both with different values is a 400.","examples":["1201"],"type":"string"}},{"description":"Ledger page size (1-100, default 20). Balances are always complete.","explode":false,"in":"query","name":"limit","schema":{"description":"Ledger page size (1-100, default 20). Balances are always complete.","examples":["20"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContractCreditsResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal Server Error"},"501":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Implemented"}},"security":[{"SellerAuth":[]}],"summary":"Get contract credits","tags":["Credits"]}},"/billing-api/v1/contracts/{contractId}/credits/top-up":{"post":{"description":"Appends a credit lot to one of your contracts — prepaid balance the invoice engine draws down before any payment rail is asked for money. The ledger is append-only: this is the only way credit enters it, and the deductions, expiries and reversals that take it out are the engine's own rows.\n\n**`Idempotency-Key` is required.** Retrying with the same key returns the grant the first call created and answers `200` instead of `201`. Unlike a 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, because a top-up has a natural shape and a mismatched replay is a second grant that would otherwise vanish silently. Normalisation is not a difference: casing on `currency` and `creditType`, and surrounding whitespace on `reason`, replay as the same grant.\n\n`currency` must equal the contract's current currency — credit is never converted. `purchased` and `enterprise` credit was paid for: it is unearned revenue you owe as service, and an `expiresAt` on it is refused. Read the resulting balances from `GET /contracts/{contractId}/credits`.","operationId":"top-up-contract-credits","parameters":[{"description":"Contract whose balance the credit lands on; must be yours.","in":"path","name":"contractId","required":true,"schema":{"description":"Contract whose balance the credit lands on; must be yours.","examples":["42"],"type":"string"}},{"description":"REQUIRED. 1–128 characters from A–Z a–z 0–9 _ . : and -, unique per grant you intend to make — a UUID is the usual choice. Keys are scoped to your seller account. Keys under a prefix the billing engine uses for its own rows (proration:, proration-grant:, redeem:, subscription:, credit_note:, refund:, usage-adjustment:) answer 400: they are not yours to use. The same key returns the grant the first call created (200); presenting it with a request that differs in its contractId, amountMinor, currency, creditType, expiresAt, sellerCustomerId or reason answers 422 naming the field, rather than granting twice.","in":"header","name":"Idempotency-Key","schema":{"description":"REQUIRED. 1–128 characters from A–Z a–z 0–9 _ . : and -, unique per grant you intend to make — a UUID is the usual choice. Keys are scoped to your seller account. Keys under a prefix the billing engine uses for its own rows (proration:, proration-grant:, redeem:, subscription:, credit_note:, refund:, usage-adjustment:) answer 400: they are not yours to use. The same key returns the grant the first call created (200); presenting it with a request that differs in its contractId, amountMinor, currency, creditType, expiresAt, sellerCustomerId or reason answers 422 naming the field, rather than granting twice.","examples":["3f0b6a1e-9c1d-4f10-9f2b-6f2a1c1e6a1e"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreditTopUpRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreditLedgerEntry"}}},"description":"Created"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unprocessable Entity"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal Server Error"},"501":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Implemented"}},"security":[{"SellerAuth":[]}],"summary":"Grant credit on a contract","tags":["Credits"]}},"/billing-api/v1/contracts/{contractId}/invoices/upcoming":{"get":{"description":"Projects what the contract's next document would carry: the subscription terms that fall due, the unbilled mid-term amendments the engine would bind to them, each open metered period rated through the same 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.\n\n**Every figure is an estimate, and nothing is written.** `estimate` is always true and `notes` states each reason: metered usage keeps accruing after `asOf`, the period close resolves a rollover carry this projection assumes is zero, and an unbilled amendment can still move before the recurring pass binds it.\n\n**Amounts are PRE-TAX.** The recurring lane copies its tax treatment (category, rate, taxable flag) from a prior invoice on the same contract when it issues, so no tax is projected here and `grandTotalMinor` equals `subtotalMinor`. Read an issued document's tax from `GET /invoices/{invoiceId}`.\n\nMoney is never converted: a line in a currency other than the projection's is listed with a note and excluded from the totals.","operationId":"get-upcoming-invoice","parameters":[{"description":"Contract id.","in":"path","name":"contractId","required":true,"schema":{"description":"Contract id.","examples":["42"],"type":"string"}},{"description":"Project as of this instant — a date (YYYY-MM-DD) or an RFC 3339 timestamp. Omit for now.","explode":false,"in":"query","name":"at","schema":{"description":"Project as of this instant — a date (YYYY-MM-DD) or an RFC 3339 timestamp. Omit for now.","examples":["2026-09-20"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpcomingInvoiceResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal Server Error"},"501":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Implemented"}},"security":[{"SellerAuth":[]}],"summary":"Preview a contract's next invoice","tags":["Invoices"]}},"/billing-api/v1/contracts/{contractId}/subscriptions":{"post":{"description":"Starts a recurring billing relationship on one of your contracts. Optional fields must be omitted rather than sent empty: an explicit `\"\"` on `customUnit`, `trialEndBehavior` or `prorationPolicy` is not one of the listed values and answers 422. A subscription with a `trialEnd` in the future starts in `trial`; otherwise it is `active` from `startDate` with its first term computed immediately. The scheduled passes then issue invoices for due terms — see **Sandbox \u0026 testing** for the switches that decide whether they run.\n\n**`Idempotency-Key` is required.** Retrying with the same key returns the subscription the first call created and answers `200` instead of `201`; the body of a retry is not compared. Keys are scoped to your seller account — one key per subscription you intend to create, and a key replayed against a different contract answers `422` rather than silently returning the other contract's subscription.\n\nOne contract may carry several subscriptions. The price is the plan: there is no separate plan object.","operationId":"create-subscription","parameters":[{"description":"Contract the subscription bills on; must be yours.","in":"path","name":"contractId","required":true,"schema":{"description":"Contract the subscription bills on; must be yours.","examples":["42"],"type":"string"}},{"description":"REQUIRED. 1–128 characters from A–Z a–z 0–9 _ . : and -, unique per subscription you intend to create — a UUID is the usual choice. Keys are scoped to your seller account, not to the contract. Keys under a prefix the billing engine uses for its own rows (proration:, proration-grant:, redeem:, subscription:, credit_note:, refund:, usage-adjustment:) answer 400: they are not yours to use. The same key always returns the subscription the first call created (200), whatever the body, and presenting it on a different contract answers 422. A new key creates a new subscription (201).","in":"header","name":"Idempotency-Key","schema":{"description":"REQUIRED. 1–128 characters from A–Z a–z 0–9 _ . : and -, unique per subscription you intend to create — a UUID is the usual choice. Keys are scoped to your seller account, not to the contract. Keys under a prefix the billing engine uses for its own rows (proration:, proration-grant:, redeem:, subscription:, credit_note:, refund:, usage-adjustment:) answer 400: they are not yours to use. The same key always returns the subscription the first call created (200), whatever the body, and presenting it on a different contract answers 422. A new key creates a new subscription (201).","examples":["9c1d0b3a-7e55-4f10-9f2b-6f2a1c1e6a1e"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSubscriptionRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Subscription"}}},"description":"Created"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unprocessable Entity"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal Server Error"}},"security":[{"SellerAuth":[]}],"summary":"Create a subscription on a contract","tags":["Subscriptions"]}},"/billing-api/v1/contracts/{contractId}/test-clock":{"get":{"description":"Returns the instant the billing engines treat as *now* for this one contract.\n\nA contract that has never been advanced reports real time with `simulated: false` — the clock row is created by the first advance, not by reading. Available on **test-mode (demo) accounts only**; a live account answers `403`.","operationId":"get-contract-test-clock","parameters":[{"description":"Contract whose clock to read; must be yours.","in":"path","name":"contractId","required":true,"schema":{"description":"Contract whose clock to read; must be yours.","examples":["42"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestClock"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal Server Error"},"501":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Implemented"}},"security":[{"SellerAuth":[]}],"summary":"Read a contract's test clock","tags":["Sandbox \u0026 testing"]}},"/billing-api/v1/contracts/{contractId}/test-clock/advance":{"post":{"description":"Moves **one contract's** clock forward to `to`, then runs that contract's subscription lifecycle and recurring invoicing as of it — through the same engines the scheduled passes use. This is how a sandbox sees a monthly subscription renew, and its renewal invoice appear on `GET /invoices`, without waiting a month.\n\n**Test-mode (demo) accounts only.** A live account answers `403`: a live subscription's renewal is real revenue and a real document to a real customer.\n\n**Monotonic and capped.** `to` must be after the contract's current clock (read it from `GET /contracts/{contractId}/test-clock`) and at most 366 days after it. Sending the instant the clock already sits at is a no-op and answers `200` with `advanced: false` — safe to retry, and it will not issue a second document. A target in the past answers `400`.\n\n**Scope is the safety property.** Only the contract named in the path moves. Its sibling contracts — yours and everyone else's — keep real time, and no cron observes a changed clock.\n\n**What it does not advance:** usage period close and usage invoicing, dunning e-mail, and off-session auto-charge. Those run on real time in their own passes; the response repeats the list in `notAdvanced` so an integration never waits on a loop this call cannot drive.","operationId":"advance-contract-test-clock","parameters":[{"description":"Contract whose clock to advance; must be yours.","in":"path","name":"contractId","required":true,"schema":{"description":"Contract whose clock to advance; must be yours.","examples":["42"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestClockAdvanceRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestClockAdvanceResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal Server Error"},"501":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Implemented"}},"security":[{"SellerAuth":[]}],"summary":"Advance a contract's test clock","tags":["Sandbox \u0026 testing"]}},"/billing-api/v1/contracts/{contractId}/usage":{"get":{"description":"Returns the contract's current open billing periods for every metric with a limit policy or metered usage. An unknown contract id answers 404; a contract with no metered usage answers 200 with an empty `metrics` array.\n\n🚨 **A live reading, not the figure you will be invoiced.** The period is still open, so the number moves with every event that arrives — including events for work already done that reach us late. It is also pre-invoice: allowances, credits, discounts and rounding are applied when the period closes and the invoice is produced, and none of them are reflected here. Show it as \"usage so far\", never as an amount owed, and reconcile against the invoice the period produces — not against this endpoint.\n\nThis is a meter, not a history API: there is no date range and no paging, and a closed period is no longer in the response — read consumption over time from the invoices the periods produced. Stored events themselves are retained indefinitely; nothing prunes them, so an idempotency key stays recognised for the lifetime of your integration and metered history is corrected by a correction against the period, never by deleting events.","operationId":"get-contract-usage","parameters":[{"description":"Contract id.","in":"path","name":"contractId","required":true,"schema":{"description":"Contract id.","examples":["42"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContractUsageResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal Server Error"}},"security":[{"SellerAuth":[]}],"summary":"Get contract usage","tags":["Usage"]}},"/billing-api/v1/credits":{"get":{"description":"Returns the seller's stored-value balances across all contracts, one row per currency and credit type.\n\n**Unpaged, and safe to be.** The response is bounded by the seven credit types times the currencies you hold balances in — it does not grow with the number of contracts, customers or ledger entries behind it. There is no cursor because there is nothing to page through; read a single contract's ledger history from `GET /contracts/{contractId}/credits`, which is paged.\n\nBalances are a rollup of the ledger, not a separate record. `isLiability` separates credit the customer paid for from credit you granted — the two are not interchangeable on a balance sheet, so do not sum across it.","operationId":"list-credit-balances","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SellerCreditsResponse"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal Server Error"},"501":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Implemented"}},"security":[{"SellerAuth":[]}],"summary":"List credit balances","tags":["Credits"]}},"/billing-api/v1/customers":{"post":{"description":"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.\n\n**No `Idempotency-Key`: 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 — that is the guarantee a client key would otherwise have to provide.\n\nBecause of that the answer is always `200`, never `201`: the directory resolves the address without reporting whether this call is the one that minted the record, and inventing a `201` would mean guessing. Treat the response as \"this is your customer\", not as \"this customer is new\".\n\nDetails (`name`, `phone`, `companyName`, `companyCountry`, `currency`, `customerType`) are applied **fill-if-empty**: a value already recorded by another surface — checkout, or an earlier call — is never overwritten, and an omitted field changes nothing. Send `customerRef` to carry your own identifier; a ref already held by a different customer of yours answers `422`.","operationId":"create-customer","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCustomerRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Customer"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unprocessable Entity"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal Server Error"}},"security":[{"SellerAuth":[]}],"summary":"Create or resolve a customer","tags":["Contracts"]}},"/billing-api/v1/invoices":{"get":{"description":"Lists the seller's billing invoices newest first, with keyset paging: pass the returned `nextCursor` back as `cursor` while `hasMore` is true (`beforeId`/`nextBeforeId` are the same values under their original names). Keyset paging is stable under concurrent inserts: new rows appear on the first page of a fresh iteration and never shift the pages of one already in flight. Filter by `contractId`, `origin`, `status`, `invoiceType`, `issuedFrom`/`issuedTo` (a half-open date range), or `corrects` to find the credit notes against a given invoice number. `origin`, `status` and `invoiceType` reject an unrecognized value with 400 rather than returning an empty page; `contractId` and `corrects` are matched as given, so a wrong-but-well-formed value legitimately yields an empty page.","operationId":"list-invoices","parameters":[{"description":"Only invoices of this contract.","explode":false,"in":"query","name":"contractId","schema":{"description":"Only invoices of this contract.","examples":["42"],"type":"string"}},{"description":"Only invoices addressed to this customer of yours — the way to pull one customer's statement without walking the period.","explode":false,"in":"query","name":"sellerCustomerId","schema":{"description":"Only invoices addressed to this customer of yours — the way to pull one customer's statement without walking the period.","examples":["7"],"type":"string"}},{"description":"Only invoices produced by this subsystem: recurring, usage, milestone, project, one_time, adhoc or marketplace. (subscription is a deprecated alias of recurring, accepted but no longer produced.)","explode":false,"in":"query","name":"origin","schema":{"description":"Only invoices produced by this subsystem: recurring, usage, milestone, project, one_time, adhoc or marketplace. (subscription is a deprecated alias of recurring, accepted but no longer produced.)","examples":["usage"],"type":"string"}},{"description":"Only invoices in this status: draft, issued, sent, paid, overdue, voided or written_off.","explode":false,"in":"query","name":"status","schema":{"description":"Only invoices in this status: draft, issued, sent, paid, overdue, voided or written_off.","examples":["sent"],"type":"string"}},{"description":"standard, credit_note, simplified or modified.","explode":false,"in":"query","name":"invoiceType","schema":{"description":"standard, credit_note, simplified or modified.","examples":["credit_note"],"type":"string"}},{"description":"Only credit notes correcting this invoice number — how to find the corrections against a document without walking history.","explode":false,"in":"query","name":"corrects","schema":{"description":"Only credit notes correcting this invoice number — how to find the corrections against a document without walking history.","examples":["UK2607AA-2608AAB"],"type":"string"}},{"description":"Only invoices issued on or after this date (YYYY-MM-DD or RFC 3339).","explode":false,"in":"query","name":"issuedFrom","schema":{"description":"Only invoices issued on or after this date (YYYY-MM-DD or RFC 3339).","examples":["2026-08-01"],"type":"string"}},{"description":"Only invoices issued strictly before this date (YYYY-MM-DD or RFC 3339), so adjacent periods never double-count a document.","explode":false,"in":"query","name":"issuedTo","schema":{"description":"Only invoices issued strictly before this date (YYYY-MM-DD or RFC 3339), so adjacent periods never double-count a document.","examples":["2026-09-01"],"type":"string"}},{"description":"Keyset cursor — the unified name on every list in this API. Pass back nextCursor from the previous page; omit for the first page. This list walks BACKWARD (newest first), so the cursor means ids below this value.","explode":false,"in":"query","name":"cursor","schema":{"description":"Keyset cursor — the unified name on every list in this API. Pass back nextCursor from the previous page; omit for the first page. This list walks BACKWARD (newest first), so the cursor means ids below this value.","examples":["4180"],"type":"string"}},{"description":"The original name of the cursor on this list. Still accepted and identical in behaviour; sending both with different values is a 400.","explode":false,"in":"query","name":"beforeId","schema":{"description":"The original name of the cursor on this list. Still accepted and identical in behaviour; sending both with different values is a 400.","examples":["4180"],"type":"string"}},{"description":"Page size (1-100, default 20).","explode":false,"in":"query","name":"limit","schema":{"description":"Page size (1-100, default 20).","examples":["20"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListInvoicesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal Server Error"}},"security":[{"SellerAuth":[]}],"summary":"List invoices","tags":["Invoices"]},"post":{"description":"Composes and **issues** an invoice against one of your contracts in one call. There is no draft: the document is legally numbered from your gapless Fynex sequence, every derived amount (line net, tax, totals) is recomputed server-side from `quantity`, `unitPriceMinor` and `discountMinor`, and the whole document is validated against the jurisdiction's rules and frozen. What comes back is the record — it can be corrected by a credit note, never edited (in the dashboard — see below).\n\n**`Idempotency-Key` is required.** An invoice has no natural key — nothing in the request identifies the document it intends — and an issued invoice cannot be deleted, so a retry without a key is a permanent duplicate in your books. Retrying with the same key returns the invoice the first call issued and answers `200` instead of `201`. 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), the document's own text (`notes`, `poReference`, `paymentTerms`), the whole `buyer` block and the `seller` block as they were frozen onto the document, the net total of the lines before tax (`quantity × unitPriceMinor − discountMinor`, summed), how many lines there are, and each line's `description`, `quantity`, `unitPriceMinor`, `discountMinor`, `taxCategory`, `taxRatePercent` and `taxable` — the last four decide the TAX on a line whose net is identical, which the net total cannot see. A difference in any of them answers `422` naming the field.\n\nTwo fields are compared only when your request STATES them — `exemptionReason` and `legalNotice` — because the engine supplies its own on an exempt or reverse-charge document, so omitting one means \"whatever the engine decides\", which is what the first call got. The `seller` block is compared as the engine USED it rather than as you sent it: under Fynex-collection issuance the issuer is substituted, so a retry that states a different seller still replays. `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. A losing retry costs nothing from the number sequence: the key and the number are allocated in one transaction.\n\n`send` defaults to true: the hosted collection link is created and e-mailed to `buyer.email` straight after issue, and its URL comes back as `paymentLinkUrl`. Delivery is not part of issuance — if it fails the answer is still `201` with the issued document and no link, and you deliver it with `POST /invoices/{invoiceId}/send`. A document funded entirely by stored credit is already paid and gets no link. `deliveryStatus` says which of those happened: `sent` (link created and e-mailed), `not_requested` (`send` was false, or the document was already settled from stored credit) or `failed` (issued, delivery did not happen — retry it with `POST /invoices/{invoiceId}/send`). It is absent on a replay, which attempts no delivery of its own.\n\nThis operation carries an **extra per-seller quota** on top of the surface-wide rate limit — issuing allocates a gapless number from your own document series — so a runaway loop answers `429` with `Retry-After` well before it burns a month of your numbers.\n\n`currency` must equal the contract's currency. `contractId` and `sellerCustomerId` must already exist and be yours — mint them with `POST /contracts` and `POST /customers`. `buyer.email` is required: it is where the document is delivered. Charge catalog items are a dashboard affordance and are not resolved here: a line says what it charges.\n\n**Credit notes are not issued here.** A correction is raised *against* the document it corrects — mirroring its lines and retiring its collection link — which this request cannot express, so `invoiceType: credit_note` answers `400` and there is no `originalInvoiceNumber` field on the body. `invoiceType` accepts `standard` (the default), `simplified` and `modified`.","operationId":"create-invoice","parameters":[{"description":"REQUIRED. 1–128 characters from A–Z a–z 0–9 _ . : and -, unique per document you intend to issue — a UUID is the usual choice. Keys are scoped to your seller account. Keys under a prefix the billing engine uses for its own rows (proration:, proration-grant:, redeem:, subscription:, credit_note:, refund:, usage-adjustment:) answer 400: they are not yours to use. The same key returns the invoice the first call issued (200); presenting it with a request that differs in the document it describes — contractId, sellerCustomerId, currency, invoiceType, jurisdiction, dueDate, dateOfSupply, a stated exemptionReason or legalNotice, notes, poReference, paymentTerms, the buyer and seller blocks as they were frozen onto the document, or any line's description, quantity, unitPriceMinor, discountMinor, taxCategory, taxRatePercent or taxable — answers 422 naming the field rather than issuing a second document. send is NOT compared: it chooses delivery, not the document.","in":"header","name":"Idempotency-Key","schema":{"description":"REQUIRED. 1–128 characters from A–Z a–z 0–9 _ . : and -, unique per document you intend to issue — a UUID is the usual choice. Keys are scoped to your seller account. Keys under a prefix the billing engine uses for its own rows (proration:, proration-grant:, redeem:, subscription:, credit_note:, refund:, usage-adjustment:) answer 400: they are not yours to use. The same key returns the invoice the first call issued (200); presenting it with a request that differs in the document it describes — contractId, sellerCustomerId, currency, invoiceType, jurisdiction, dueDate, dateOfSupply, a stated exemptionReason or legalNotice, notes, poReference, paymentTerms, the buyer and seller blocks as they were frozen onto the document, or any line's description, quantity, unitPriceMinor, discountMinor, taxCategory, taxRatePercent or taxable — answers 422 naming the field rather than issuing a second document. send is NOT compared: it chooses delivery, not the document.","examples":["7b1f6a1e-9c1d-4f10-9f2b-6f2a1c1e6a1e"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateInvoiceRequest"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateInvoiceResponse"}}},"description":"Created"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unprocessable Entity"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal Server Error"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Service Unavailable"}},"security":[{"SellerAuth":[]}],"summary":"Issue an invoice","tags":["Invoices"]}},"/billing-api/v1/invoices/export":{"get":{"description":"Exports every invoice issued in the half-open `issuedFrom`/`issuedTo` window as one CSV or NDJSON file — the month-end reconciliation pull, without hand-rolling the paging loop. Both dates are required: there is deliberately no default window, because an exported file must never silently mean a window the server chose. Money columns are integer minor units, named so — including `outstandingMinor`, the receivable to age in an AR report, which is absent (an empty CSV cell) when the adjustment ledger is unavailable and must never be read as `0`. Rows come newest first, the same order as the list. A window wider than 100,000 documents or 64 MiB is refused with instructions to narrow it rather than silently truncated.","operationId":"export-invoices","parameters":[{"description":"Start of the period, inclusive (YYYY-MM-DD or RFC 3339). Required — there is deliberately no default window.","explode":false,"in":"query","name":"issuedFrom","schema":{"description":"Start of the period, inclusive (YYYY-MM-DD or RFC 3339). Required — there is deliberately no default window.","examples":["2026-08-01"],"type":"string"}},{"description":"End of the period, exclusive (YYYY-MM-DD or RFC 3339). Required. Half-open, so adjacent months never double-count a document.","explode":false,"in":"query","name":"issuedTo","schema":{"description":"End of the period, exclusive (YYYY-MM-DD or RFC 3339). Required. Half-open, so adjacent months never double-count a document.","examples":["2026-09-01"],"type":"string"}},{"description":"csv (default) or ndjson. NDJSON rows are exactly the list API's invoice objects, one per line — the same shape, so nothing new to parse.","explode":false,"in":"query","name":"format","schema":{"description":"csv (default) or ndjson. NDJSON rows are exactly the list API's invoice objects, one per line — the same shape, so nothing new to parse.","examples":["csv"],"type":"string"}},{"description":"Only invoices of this contract.","explode":false,"in":"query","name":"contractId","schema":{"description":"Only invoices of this contract.","examples":["42"],"type":"string"}},{"description":"Only invoices produced by this subsystem: recurring, usage, milestone, project, one_time, adhoc or marketplace.","explode":false,"in":"query","name":"origin","schema":{"description":"Only invoices produced by this subsystem: recurring, usage, milestone, project, one_time, adhoc or marketplace.","examples":["usage"],"type":"string"}},{"description":"Only invoices in this status: draft, issued, sent, paid, overdue, voided or written_off.","explode":false,"in":"query","name":"status","schema":{"description":"Only invoices in this status: draft, issued, sent, paid, overdue, voided or written_off.","examples":["paid"],"type":"string"}},{"description":"standard, credit_note, simplified or modified.","explode":false,"in":"query","name":"invoiceType","schema":{"description":"standard, credit_note, simplified or modified.","examples":["credit_note"],"type":"string"}}],"responses":{"200":{"description":"OK","headers":{"Content-Disposition":{"schema":{"type":"string"}},"Content-Type":{"schema":{"type":"string"}}}},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal Server Error"}},"security":[{"SellerAuth":[]}],"summary":"Export a period's invoices as one file","tags":["Invoices"]}},"/billing-api/v1/invoices/{invoiceId}":{"get":{"description":"Returns one invoice with its lines.","operationId":"get-invoice","parameters":[{"description":"Invoice id.","in":"path","name":"invoiceId","required":true,"schema":{"description":"Invoice id.","examples":["4180"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvoiceDetailResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal Server Error"}},"security":[{"SellerAuth":[]}],"summary":"Get an invoice","tags":["Invoices"]}},"/billing-api/v1/invoices/{invoiceId}/pdf":{"get":{"description":"Renders the invoice PDF from its frozen, schema-validated payload.","operationId":"get-invoice-pdf","parameters":[{"description":"Invoice id.","in":"path","name":"invoiceId","required":true,"schema":{"description":"Invoice id.","examples":["4180"],"type":"string"}}],"responses":{"200":{"content":{"application/pdf":{"schema":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}}},"description":"The rendered invoice PDF.","headers":{"Content-Disposition":{"schema":{"type":"string"}},"Content-Type":{"schema":{"type":"string"}}}},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal Server Error"}},"security":[{"SellerAuth":[]}],"summary":"Download the invoice PDF","tags":["Invoices"]}},"/billing-api/v1/invoices/{invoiceId}/send":{"post":{"description":"Materializes the invoice's collection instrument: creates its hosted payment link (emailing the customer when the document is configured for email delivery) and returns the link URL. Repeating the call on a still-collectible invoice returns the same link rather than creating a second. Answers 422 for documents collected by bank transfer, documents not in an issued/sent state (paid, void, written off), and documents with nothing left to collect.","operationId":"send-invoice","parameters":[{"description":"Invoice id.","in":"path","name":"invoiceId","required":true,"schema":{"description":"Invoice id.","examples":["4180"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendInvoiceResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unprocessable Entity"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal Server Error"}},"security":[{"SellerAuth":[]}],"summary":"Send an invoice","tags":["Invoices"]}},"/billing-api/v1/invoices/{invoiceId}/settlement":{"get":{"description":"Answers \"which payout covered this invoice?\" — the join between billing and money movement. Walks the invoice's collection link to its payment attempts (failed ones included) and each payment's payout attributions, newest first. Amounts are minor units. An invoice settled by bank transfer or by stored credit has no payment-link trail, and the response says so explicitly rather than answering an ambiguous empty list; payout and payment detail beyond the ids lives on the Payments API.","operationId":"get-invoice-settlement","parameters":[{"description":"Invoice id.","in":"path","name":"invoiceId","required":true,"schema":{"description":"Invoice id.","examples":["4180"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SettlementTrailResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal Server Error"}},"security":[{"SellerAuth":[]}],"summary":"Trace an invoice to the payouts that settled it","tags":["Invoices"]}},"/billing-api/v1/prices/evaluate":{"post":{"description":"Evaluates a pricing configuration at one or more quantities through the same engine that bills — pure, nothing stored, no contract required. Send several quantities to plot a price curve in one call. Amounts return in minor units; the per-band lines are exact decimals in major units and sum to the rated amount.","operationId":"evaluate-prices","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatePricesRequest"}},"application/octet-stream":{"schema":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EvaluatePricesResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unprocessable Entity"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal Server Error"}},"security":[{"SellerAuth":[]}],"summary":"Preview what a pricing configuration charges","tags":["Usage"]}},"/billing-api/v1/subscriptions":{"get":{"description":"Lists the seller's subscriptions newest first, with keyset paging: pass the returned `nextCursor` back as `cursor` while `hasMore` is true (`beforeId`/`nextBeforeId` are the same values under their original names), with an optional `contractId` filter. Keyset paging is stable under concurrent inserts: new rows appear on the first page of a fresh iteration and never shift the pages of one already in flight.","operationId":"list-subscriptions","parameters":[{"description":"Only subscriptions of this contract.","explode":false,"in":"query","name":"contractId","schema":{"description":"Only subscriptions of this contract.","examples":["42"],"type":"string"}},{"description":"Keyset cursor — the unified name on every list in this API. Pass back nextCursor from the previous page; omit for the first page. This list walks BACKWARD (newest first).","explode":false,"in":"query","name":"cursor","schema":{"description":"Keyset cursor — the unified name on every list in this API. Pass back nextCursor from the previous page; omit for the first page. This list walks BACKWARD (newest first).","examples":["310"],"type":"string"}},{"description":"The original name of the cursor on this list. Still accepted and identical in behaviour; sending both with different values is a 400.","explode":false,"in":"query","name":"beforeId","schema":{"description":"The original name of the cursor on this list. Still accepted and identical in behaviour; sending both with different values is a 400.","examples":["310"],"type":"string"}},{"description":"Page size (1-100, default 20).","explode":false,"in":"query","name":"limit","schema":{"description":"Page size (1-100, default 20).","examples":["20"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListSubscriptionsResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal Server Error"}},"security":[{"SellerAuth":[]}],"summary":"List subscriptions","tags":["Subscriptions"]}},"/billing-api/v1/subscriptions/{subscriptionId}":{"get":{"description":"Returns one subscription.","operationId":"get-subscription","parameters":[{"description":"Subscription id.","in":"path","name":"subscriptionId","required":true,"schema":{"description":"Subscription id.","examples":["310"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Subscription"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal Server Error"}},"security":[{"SellerAuth":[]}],"summary":"Get a subscription","tags":["Subscriptions"]}},"/billing-api/v1/subscriptions/{subscriptionId}/cancel":{"post":{"description":"Requests cancellation. With `noticePeriodDays` set, the subscription keeps serving until `cancelEffectiveAt` and reports `cancelRequestedAt` meanwhile; with none, it cancels at the end of the current term. Terminal — there is no un-cancel; a second request answers `422`.","operationId":"cancel-subscription","parameters":[{"description":"Subscription id.","in":"path","name":"subscriptionId","required":true,"schema":{"description":"Subscription id.","examples":["310"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Subscription"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unprocessable Entity"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal Server Error"}},"security":[{"SellerAuth":[]}],"summary":"Request cancellation","tags":["Subscriptions"]}},"/billing-api/v1/subscriptions/{subscriptionId}/change-plan":{"post":{"description":"Sets a new price for one full billing period. `atTermEnd: true` schedules it — the subscription shows `pendingPriceMinor` / `pendingPriceChangeAt` until the renewal pass applies it — and is always honoured. With `false` the subscription's recorded `prorationPolicy` decides, but only while the proration engine is enabled for this platform: `by_day` (or an unstated term) applies the change now and posts an adjustment for the unserved remainder, `full_period` applies it now and bills the WHOLE current period at the new price — the days already served included — posting no adjustment, `next_period` keeps the old price for the rest of this period and applies the new one from the next, exactly as `atTermEnd: true` would. With the engine off — the current default — `false` always applies the change now and no adjustment is posted. Where invoice binding (`billing.proration.invoice_binding`) is on, a posted adjustment reaches the term's next invoice as a line, a discount or a proration credit lot (#2793). The same price answers `422`, as does a second change while one is pending (a `next_period` policy schedules one, so the next change waits for the term to open). The currency cannot change.","operationId":"change-subscription-plan","parameters":[{"description":"Subscription id.","in":"path","name":"subscriptionId","required":true,"schema":{"description":"Subscription id.","examples":["310"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangePlanInputBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Subscription"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unprocessable Entity"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal Server Error"}},"security":[{"SellerAuth":[]}],"summary":"Change the recurring price","tags":["Subscriptions"]}},"/billing-api/v1/subscriptions/{subscriptionId}/end-trial":{"post":{"description":"Converts a `trial` to paying today, ahead of `trialEnd`, honouring `trialEndBehavior` and `trialRequiresPaymentMethod`. Only a `trial` can be ended (`422` otherwise).","operationId":"end-subscription-trial","parameters":[{"description":"Subscription id.","in":"path","name":"subscriptionId","required":true,"schema":{"description":"Subscription id.","examples":["310"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Subscription"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unprocessable Entity"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal Server Error"}},"security":[{"SellerAuth":[]}],"summary":"End a trial now","tags":["Subscriptions"]}},"/billing-api/v1/subscriptions/{subscriptionId}/pause":{"post":{"description":"Suspends billing on an `active` subscription. `pausedAt` is set; `pauseEndsAt` when `pauseUntil` was given. A pause longer than the seller's maximum pause policy answers `422`. Resume with `/resume`.","operationId":"pause-subscription","parameters":[{"description":"Subscription id.","in":"path","name":"subscriptionId","required":true,"schema":{"description":"Subscription id.","examples":["310"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PauseSubscriptionInputBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Subscription"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unprocessable Entity"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal Server Error"}},"security":[{"SellerAuth":[]}],"summary":"Pause a subscription","tags":["Subscriptions"]}},"/billing-api/v1/subscriptions/{subscriptionId}/resume":{"post":{"description":"Ends a pause early. A new term starts from today; the paused stretch is not billed. Only a `paused` subscription can be resumed (`422` otherwise).","operationId":"resume-subscription","parameters":[{"description":"Subscription id.","in":"path","name":"subscriptionId","required":true,"schema":{"description":"Subscription id.","examples":["310"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Subscription"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unprocessable Entity"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Internal Server Error"}},"security":[{"SellerAuth":[]}],"summary":"Resume a paused subscription","tags":["Subscriptions"]}},"/billing-api/v1/usage/contracts/{contractId}/metrics/{metricName}/price":{"get":{"description":"The configuration currently in force — the check to run before sending events. `404` with `this metric has no price configured` when none is.","operationId":"get-usage-price","parameters":[{"description":"Contract the price belongs to.","in":"path","name":"contractId","required":true,"schema":{"examples":["42"],"type":"string"}},{"description":"The metric being priced.","in":"path","name":"metricName","required":true,"schema":{"examples":["api_calls"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PriceConfigStored"}}},"description":"The current configuration."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Missing or invalid key. This surface answers only \"not authorized\", never the account's state."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Valid key, inactive seller account."},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unknown metric, or a contract that is not yours."},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Rate limited; honour Retry-After."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Infrastructure fault. Retry — idempotency keys make the retry safe."}},"security":[{"SellerAuth":[]}],"summary":"Get a metric's current price","tags":["Usage"]},"put":{"description":"Stores a pricing configuration effective from a stated date — versioned, never rewriting history. Schemes: `per_unit`, `graduated`, `volume`, `tiered`, `package`, `percentage`; `graduated` and `volume` are the classic trap — same bands, same quantity, different money. Rates are decimal strings in MAJOR units, the deliberate exception to the minor-units convention.","operationId":"set-usage-price","parameters":[{"description":"Contract the price belongs to.","in":"path","name":"contractId","required":true,"schema":{"examples":["42"],"type":"string"}},{"description":"The metric being priced.","in":"path","name":"metricName","required":true,"schema":{"examples":["api_calls"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PriceConfigEnvelope"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PriceConfigStored"}}},"description":"The stored configuration and its version."},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Malformed event, occurredAt in the future, or a hard usage cap already exceeded — the event is not recorded."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Missing or invalid key. This surface answers only \"not authorized\", never the account's state."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Valid key, inactive seller account."},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unknown metric, or a contract that is not yours."},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Rate limited; honour Retry-After."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Infrastructure fault. Retry — idempotency keys make the retry safe."}},"security":[{"SellerAuth":[]}],"summary":"Price a metric on a contract","tags":["Usage"]}},"/billing-api/v1/usage/csv":{"post":{"description":"Bulk intake for a periodic export. A row the pipeline refuses is dead-lettered rather than failing the file, so a partial success is normal and the counts say what happened. Re-uploading the same file is safe: row idempotency keys make it a no-op.","operationId":"upload-usage-csv","requestBody":{"content":{"multipart/form-data":{"schema":{"description":"Two parts: the file itself and the mapping that says which column carries which canonical field.","properties":{"file":{"contentMediaType":"application/octet-stream","description":"The CSV file. Without an idempotency-key column, keys derive from the file digest and line number, so re-uploading the same file is a no-op.","format":"binary","type":"string"},"mapping":{"description":"A CSVMapping JSON object. `quantityColumn` is required; contract, metric and occurredAt may each come from a column (`contractIdColumn`, `metricColumn`, `occurredAtColumn`) OR from a whole-file default (`defaultContractId`, `defaultMetric`, `defaultOccurredAt`) — one or the other, never both. `idempotencyKeyColumn` is optional.","examples":["{\"defaultContractId\":42,\"defaultMetric\":\"api_calls\",\"quantityColumn\":\"calls\",\"occurredAtColumn\":\"day\"}"],"type":"string"}},"required":["file","mapping"],"type":"object"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"description":"Per-file outcome. Rows that failed land in the dead-letter queue rather than failing the upload.","properties":{"deadLettered":{"description":"Rows refused and queued for inspection; read them with GET /usage/dead-letters.","examples":[2],"format":"int32","type":"integer"},"duplicates":{"description":"Rows whose idempotency key was already seen. Re-uploading the same file lands here in full.","examples":[0],"format":"int32","type":"integer"},"ingested":{"description":"Rows recorded by this upload.","examples":[498],"format":"int32","type":"integer"}},"type":"object"}}},"description":"Per-file counts."},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Malformed event, occurredAt in the future, or a hard usage cap already exceeded — the event is not recorded."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Missing or invalid key. This surface answers only \"not authorized\", never the account's state."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Valid key, inactive seller account."},"413":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The batch holds more than 500 events — split it."},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Rate limited; honour Retry-After."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Infrastructure fault. Retry — idempotency keys make the retry safe."}},"security":[{"SellerAuth":[]}],"summary":"Upload usage as a CSV file","tags":["Usage"]}},"/billing-api/v1/usage/dead-letters":{"get":{"description":"Events an intake refused, 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.","operationId":"list-usage-dead-letters","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeadLettersListResponseDTO"}}},"description":"The dead-letter queue."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Missing or invalid key. This surface answers only \"not authorized\", never the account's state."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Valid key, inactive seller account."},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Rate limited; honour Retry-After."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Infrastructure fault. Retry — idempotency keys make the retry safe."}},"security":[{"SellerAuth":[]}],"summary":"List refused usage events","tags":["Usage"]}},"/billing-api/v1/usage/dead-letters/{deadLetterId}/discard":{"post":{"description":"Closes the letter without metering it — for a row that should never have been sent. Not reversible: the usage it described will not be billed.","operationId":"discard-usage-dead-letter","parameters":[{"description":"The dead letter's id, from the listing.","in":"path","name":"deadLetterId","required":true,"schema":{"examples":["318"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DiscardResponseDTO"}}},"description":"The letter is closed."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Missing or invalid key. This surface answers only \"not authorized\", never the account's state."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Valid key, inactive seller account."},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unknown metric, or a contract that is not yours."},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Rate limited; honour Retry-After."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Infrastructure fault. Retry — idempotency keys make the retry safe."}},"security":[{"SellerAuth":[]}],"summary":"Discard a refused event","tags":["Usage"]}},"/billing-api/v1/usage/dead-letters/{deadLetterId}/redrive":{"post":{"description":"Resubmits the stored payload through the intake. Fix the cause first — a redrive with the same problem fails the same way, leaves the letter pending, and increments its attempt count.","operationId":"redrive-usage-dead-letter","parameters":[{"description":"The dead letter's id, from the listing.","in":"path","name":"deadLetterId","required":true,"schema":{"examples":["318"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RedriveResponseDTO"}}},"description":"The retry's outcome."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Missing or invalid key. This surface answers only \"not authorized\", never the account's state."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Valid key, inactive seller account."},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unknown metric, or a contract that is not yours."},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Rate limited; honour Retry-After."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Infrastructure fault. Retry — idempotency keys make the retry safe."}},"security":[{"SellerAuth":[]}],"summary":"Retry a refused event","tags":["Usage"]}},"/billing-api/v1/usage/events":{"post":{"description":"Records one metered-usage event. `201` when recorded, `200` with `\"status\": \"duplicate\"` when the idempotency key was already seen — retrying is always safe. Check `metered` on every response: `false` means the event is stored but NOT on the meter, because no active subscription schedule covers `occurredAt`.","operationId":"submit-usage-event","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UsageEventDTO"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventResponseDTO"}}},"description":"The idempotency key was already seen; the original outcome is reported."},"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventResponseDTO"}}},"description":"The event was recorded."},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Malformed event, occurredAt in the future, or a hard usage cap already exceeded — the event is not recorded."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Missing or invalid key. This surface answers only \"not authorized\", never the account's state."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Valid key, inactive seller account."},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unknown metric, or a contract that is not yours."},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The idempotencyKey was used before with a DIFFERENT payload. Same key, same payload is a duplicate (200), not a conflict."},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Rate limited; honour Retry-After."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Infrastructure fault. Retry — idempotency keys make the retry safe."}},"security":[{"SellerAuth":[]}],"summary":"Submit a usage event","tags":["Usage"]}},"/billing-api/v1/usage/events:batch":{"post":{"description":"Records up to 500 events in one call, answered per item by index. A rejected item does not stop the others; a larger batch is refused whole with `413`, and a `500` aborts the whole batch — the idempotency keys make the retry safe.","operationId":"submit-usage-events-batch","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchRequestDTO"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchResponseDTO"}}},"description":"Per-item outcomes."},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Malformed event, occurredAt in the future, or a hard usage cap already exceeded — the event is not recorded."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Missing or invalid key. This surface answers only \"not authorized\", never the account's state."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Valid key, inactive seller account."},"413":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The batch holds more than 500 events — split it."},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Rate limited; honour Retry-After."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Infrastructure fault. Retry — idempotency keys make the retry safe."}},"security":[{"SellerAuth":[]}],"summary":"Submit usage events in a batch","tags":["Usage"]}},"/billing-api/v1/usage/metrics":{"get":{"description":"Every metric the seller has registered.","operationId":"list-usage-metrics","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MetricsListResponseDTO"}}},"description":"The metric registry."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Missing or invalid key. This surface answers only \"not authorized\", never the account's state."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Valid key, inactive seller account."},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Rate limited; honour Retry-After."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Infrastructure fault. Retry — idempotency keys make the retry safe."}},"security":[{"SellerAuth":[]}],"summary":"List registered metrics","tags":["Usage"]},"post":{"description":"Declares a meter: what is counted, in what unit, and how a period's events collapse into one billable quantity. Registering an existing name appends the next version.","operationId":"register-usage-metric","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterMetricDTO"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MetricDTO"}}},"description":"The registered metric."},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Malformed event, occurredAt in the future, or a hard usage cap already exceeded — the event is not recorded."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Missing or invalid key. This surface answers only \"not authorized\", never the account's state."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Valid key, inactive seller account."},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Rate limited; honour Retry-After."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Infrastructure fault. Retry — idempotency keys make the retry safe."}},"security":[{"SellerAuth":[]}],"summary":"Register a metric","tags":["Usage"]}},"/billing-api/v1/usage/metrics/{metricName}":{"get":{"description":"The metric's current definition; `404` when it is not registered.","operationId":"get-usage-metric","parameters":[{"description":"Registered metric name.","in":"path","name":"metricName","required":true,"schema":{"examples":["api_calls"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MetricDTO"}}},"description":"The metric."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Missing or invalid key. This surface answers only \"not authorized\", never the account's state."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Valid key, inactive seller account."},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unknown metric, or a contract that is not yours."},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Rate limited; honour Retry-After."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Infrastructure fault. Retry — idempotency keys make the retry safe."}},"security":[{"SellerAuth":[]}],"summary":"Get one metric","tags":["Usage"]}},"/billing-api/v1/usage/webhook-endpoints":{"get":{"description":"Every endpoint this seller registered, revoked ones included. Secrets are never in a listing.","operationId":"list-usage-webhook-endpoints","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointListDTO"}}},"description":"The registered endpoints."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Missing or invalid key. This surface answers only \"not authorized\", never the account's state."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Valid key, inactive seller account."},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Rate limited; honour Retry-After."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Infrastructure fault. Retry — idempotency keys make the retry safe."}},"security":[{"SellerAuth":[]}],"summary":"List inbound webhook endpoints","tags":["Usage"]},"post":{"description":"Mints a token to post usage to and the secret that signs it. **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.","operationId":"create-usage-webhook-endpoint","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWebhookEndpointDTO"}}}},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointSecretDTO"}}},"description":"The endpoint and its secret, shown once."},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Malformed event, occurredAt in the future, or a hard usage cap already exceeded — the event is not recorded."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Missing or invalid key. This surface answers only \"not authorized\", never the account's state."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Valid key, inactive seller account."},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Rate limited; honour Retry-After."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Infrastructure fault. Retry — idempotency keys make the retry safe."}},"security":[{"SellerAuth":[]}],"summary":"Register an inbound webhook endpoint","tags":["Usage"]}},"/billing-api/v1/usage/webhook-endpoints/{endpointId}/revoke":{"post":{"description":"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.","operationId":"revoke-usage-webhook-endpoint","parameters":[{"description":"The endpoint's id, from the listing.","in":"path","name":"endpointId","required":true,"schema":{"examples":["12"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointDTO"}}},"description":"The endpoint, now revoked."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Missing or invalid key. This surface answers only \"not authorized\", never the account's state."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Valid key, inactive seller account."},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unknown metric, or a contract that is not yours."},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Rate limited; honour Retry-After."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Infrastructure fault. Retry — idempotency keys make the retry safe."}},"security":[{"SellerAuth":[]}],"summary":"Revoke an inbound webhook endpoint","tags":["Usage"]}},"/billing-api/v1/usage/webhook-endpoints/{endpointId}/rotate-secret":{"post":{"description":"Issues a new secret and returns it once, on the same display-once terms as creation. The old secret stops verifying immediately, so cut the sender over in the same change.","operationId":"rotate-usage-webhook-secret","parameters":[{"description":"The endpoint's id, from the listing.","in":"path","name":"endpointId","required":true,"schema":{"examples":["12"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpointSecretDTO"}}},"description":"The endpoint and its new secret, shown once."},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Missing or invalid key. This surface answers only \"not authorized\", never the account's state."},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Valid key, inactive seller account."},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unknown metric, or a contract that is not yours."},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Rate limited; honour Retry-After."},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Infrastructure fault. Retry — idempotency keys make the retry safe."}},"security":[{"SellerAuth":[]}],"summary":"Rotate an endpoint's signing secret","tags":["Usage"]}}},"servers":[{"description":"Fynex Billing API","url":"https://api.fynex.ai"},{"description":"Sandbox — test-mode accounts, test cards, no live money","url":"https://staging-api.fynex.ai"}],"tags":[{"description":"# Concepts\n\nFive objects, and the relationships between them are not obvious from their\nnames. Read this once and the rest of the reference follows; skip it and the\nmost likely mistake is inventing a call that cannot exist.\n\n```\n                        ┌──────────────┐\n                        │   Customer   │  who you bill\n                        └──────┬───────┘\n                               │ one customer, many contracts\n                        ┌──────▼───────┐\n                        │   Contract   │  the agreement: currency, term\n                        └──────┬───────┘\n              ┌────────────────┼────────────────┐\n              │                │                │\n     ┌────────▼──────┐  ┌──────▼──────┐  ┌──────▼──────┐\n     │ Subscription  │  │    Usage    │  │   Credits   │\n     │ recurring fee │  │ metered qty │  │ stored value│\n     └────────┬──────┘  └──────┬──────┘  └──────┬──────┘\n              │                │                │\n              └───────┬────────┘                │ drawn down at issue\n                      │ billed by a run          │\n               ┌──────▼───────┐                  │\n               │   Invoice    │◄─────────────────┘\n               │  immutable   │\n               └──────────────┘\n```\n\n**Contract is the anchor.** Everything else hangs off one. A subscription, a\nusage meter, a credit balance and an invoice all name a contract, and the\ncontract carries the currency they must all agree on.\n\n**Invoice is a document, not a record you edit.** Once issued it has a number\nand is immutable. A correction is a new document — a credit note — and both\nstay visible in every listing. There is no `PATCH` and no `DELETE`, which\nmeans an agent cannot be told to \"fix\" an invoice: the only correct move is\nto issue a correcting one.\n\n## Three questions the object names do not answer\n\n### Can an invoice exist without a contract?\n\n**No.** Every invoice names a contract, and issuing one without it is refused\nbefore anything is written.\n\n**But you do not have to create the contract first.** An ad-hoc invoice — a\none-off with `origin: adhoc` — provisions what it needs from the bill-to you\ntype: the customer is found or created by email, and a live contract in the\nsame currency is reused if one exists, created if not. So a one-off stays one\ncall. Send the same recipient a second one-off and it lands on the same\ncustomer and the same contract rather than minting duplicates.\n\nThis provisioning is deliberately **only** for ad-hoc documents. A usage or\nrecurring invoice with no contract is a caller mistake, and inventing a\ncontract for it would detach that revenue from the agreement it belongs to.\n\n### Does a subscription generate invoices automatically?\n\n**No — not by itself.** Two different mechanisms are easy to confuse:\n\n- The **lifecycle pass** advances a subscription's *state* on its dates: a\n  trial activates, a cancellation takes effect after its notice period, a\n  fixed end expires it, and a term that has rolled over gets the term now in\n  force. It issues no documents at all.\n- The **billing run** raises the money. It bills each revenue model on a\n  contract — recurring, then usage, then project work, then any one-time line\n  — each through its own engine, each producing its own document so the\n  invoice's `origin` stays truthful.\n\nSo a subscription's period turning over does not, on its own, produce an\ninvoice. Something has to run the billing — and on a seller who has the\nissuance passes switched on (`billing.recurring.invoice_pass`,\n`billing.usage.invoice_pass`), that something is a schedule, not a person.\nBoth default to off. Write your integration so a document appearing without\nyou is normal, not an anomaly.\n\n**Double billing is prevented by claims, not by memory.** A subscription\nperiod lands on a unique `(subscription, period_start)` row, usage lines and\nproject work flip guarded status columns, and every claim's invoice carries\nits id. Re-running a billing run finishes only what is missing; an ambiguous\ncommit is repaired by looking the claim up, never by issuing again. A model\nthat fails does not roll back a sibling's document — a numbered invoice\ncannot be un-issued — and the run reports per model what happened.\n\n### Do credits apply before or after tax?\n\n**After.** Credit is a payment method, not a discount. It draws down the\ninvoice's grand total — the amount *including* tax — and never reduces the\ntaxable base. The tax the document reports is the tax on the full price,\nwhatever the customer's balance was.\n\nTwo consequences worth knowing before you model this:\n\n- A credit-funded invoice is neither *expected* nor *received* cash. It is its\n  own thing, and reporting treats it that way.\n- **An invoice collected by bank transfer draws no credit at all.** The buyer\n  was told to wire the grand total; shrinking the collectible underneath that\n  instruction would make every full-face wire arrive as an overpayment. The\n  balance stays on the ledger for the next link-collected document.\n\n## Three words that are one letter of confusion apart\n\nThe platform has three roles, and two of their field names differ by a single\nword while meaning entirely different things:\n\nThe direction of the money is what separates them:\n\n```\n   Customer  ──── pays ────►   Seller   ──── pays ────►   Payee\n sellerCustomerId            sellerAccountId             payeeId\n your buyer                  YOU, the key holder         who you disburse to\n Billing API                 both APIs                   Payments API\n```\n\n| Word | Who | Lives on |\n| --- | --- | --- |\n| **Seller** (`sellerAccountId`) | **You** — the account holder the API key belongs to. Every object in both APIs is scoped to one. | Both APIs |\n| **Customer** (`sellerCustomerId`) | Who **pays you** — the party your invoices are addressed to. | This API |\n| **Payee** (`payeeId`) | Who **you pay** — a counterparty receiving money through split payments and payouts. | Payments API |\n\n`sellerAccountId` and `sellerCustomerId` look like siblings and are not: the\nfirst is your own identity, the second is your customer's. A marketplace\noperator is all three sentences at once — they **are** a Fynex seller, they\n**have** customers who pay invoices, and they **have** payees who receive\nsplits and payouts.\n\nIf you think in the words *vendor*, *merchant* or *supplier*: the merchant\nrunning the account is the **seller**; a vendor or supplier you disburse money\nto is a **payee**; the buyer you bill is a **customer**. The settlement-trail\nendpoint (`GET /invoices/{invoiceId}/settlement`) is where the two APIs meet:\na customer's payment on this side becomes a payee's payout on the other.\n\n## Where money and quantities live\n\n| Thing | On the wire |\n| --- | --- |\n| An amount of money | Integer **minor units**, field ends `Minor` — `4999` is €49.99 |\n| A rate or percentage | Integer **basis points**, field ends `Bps` — `275` is 2.75% |\n| A metered quantity | Decimal **string** — `\"1250.5\"` |\n| A per-unit rate | Decimal **string** in MAJOR units — `\"0.004\"`, the one deliberate exception, because a rate is routinely finer than a minor unit |\n\nEverything on a contract shares that contract's currency. There is no\nconversion anywhere in this API: a mismatch is refused rather than converted.\n","name":"Concepts"},{"description":"# Quickstart\n\nThis walks from a fresh API key to a working integration. Every example is\ncopy-pasteable; replace the key and ids with your own.\n\n## 1. Get a key\n\nIn the Fynex dashboard, open **Integration** in the left-hand menu and issue a\n**secret** key from the API keys card.\n\n- `sk_test_…` — authenticates while your account is in demo mode.\n- `sk_live_…` — authenticates once your account is live.\n\nAn account is in exactly one of those modes, so exactly one of the two key\nenvironments works at any time.\n\nKeys are stored only as hashes, so a key is shown once, at creation. If you\nlose it, regenerate — there is no recovery. Keep keys server-side: they carry\nfull read access to your billing data.\n\n## 2. Set your base URL\n\nEvery endpoint sits under the path `/billing-api/v1`. The host depends on the\nenvironment you were onboarded to, so the examples below use a variable:\n\n```bash\nexport FYNEX_API_BASE=\"https://staging-api.fynex.ai/billing-api/v1\"\nexport FYNEX_API_KEY=\"sk_test_4f6f...\"\n```\n\n| Environment | Host |\n| --- | --- |\n| Staging | `https://staging-api.fynex.ai` |\n| Production | Confirm your production host with Fynex before going live — it is assigned per deployment, and a `sk_live_` key is only accepted by the environment that issued it. |\n\nYou are reading these docs on the same host the API runs on, so\n`/billing-api/v1/openapi.json` and `/billing-api/v1/docs` are always\nreachable relative to wherever this page is served.\n\n## 3. Make your first call\n\n`GET /contracts` is the safest first request — it needs no ids and tells you\nwhether your key, environment, and network path all work:\n\n```bash\ncurl -s $FYNEX_API_BASE/contracts \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\"\n```\n\n```json\n{\n  \"contracts\": [\n    {\n      \"contractId\": 42,\n      \"contractNumber\": \"UK2607AA\",\n      \"version\": 1,\n      \"sellerCustomerId\": 7,\n      \"currency\": \"EUR\",\n      \"status\": \"active\",\n      \"startDate\": \"2026-01-01\",\n      \"customerName\": \"Ada Lovelace\",\n      \"customerCompanyName\": \"Harbour Group BV\"\n    }\n  ]\n}\n```\n\n`contractId` is the key to every per-contract endpoint that follows.\n\nIf instead you get:\n\n- `401` — the header is missing or the key is wrong. The format is\n  `Authorization: Bearer sk_live_…`; a bare key without `Bearer ` fails.\n- `403` — the key is valid but the seller account is not active. Check the\n  account status in the dashboard.\n- an empty `contracts` array — the key works, but this account has no\n  contracts yet.\n\n## 4. List invoices\n\n```bash\ncurl -s \"$FYNEX_API_BASE/invoices?limit=5\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\"\n```\n\n```json\n{\n  \"invoices\": [\n    {\n      \"id\": 4180,\n      \"contractId\": 42,\n      \"sellerCustomerId\": 7,\n      \"origin\": \"usage\",\n      \"invoiceNumber\": \"UK2607AA-2608AAB\",\n      \"jurisdiction\": \"UK\",\n      \"invoiceType\": \"standard\",\n      \"status\": \"sent\",\n      \"currency\": \"EUR\",\n      \"issueDate\": \"2026-08-01T00:00:00Z\",\n      \"dueDate\": \"2026-08-15T00:00:00Z\",\n      \"subtotalMinor\": 10000,\n      \"taxTotalMinor\": 2000,\n      \"grandTotalMinor\": 12000,\n      \"creditAppliedMinor\": 0,\n      \"collectibleMinor\": 12000,\n      \"creditSettledMinor\": 0,\n      \"reverseCharge\": false,\n      \"paymentLinkId\": 991\n    }\n  ],\n  \"hasMore\": true,\n  \"nextCursor\": 4180,\n  \"nextBeforeId\": 4180\n}\n```\n\nTwo things to internalize right now, because they cause most integration\nbugs:\n\n- **Amounts are integers in minor units.** `12000` in EUR is €120.00. Never\n  parse them as floats.\n- **`grandTotalMinor` is the document total, `collectibleMinor` is what\n  collection asks the customer for.** They differ when stored credit funded\n  part of the invoice. Neither drops to zero once the invoice is paid — to\n  decide \"does this customer still owe us money\", read `status`.\n- **`creditSettledMinor` is not a payment total.** It counts only stored\n  credit drawn down, so a card-paid invoice reports `0` there.\n\n## 5. Get one invoice with its lines\n\n```bash\ncurl -s $FYNEX_API_BASE/invoices/4180 \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\"\n```\n\n```json\n{\n  \"invoice\": { \"id\": 4180, \"invoiceNumber\": \"UK2607AA-2608AAB\", \"...\": \"...\" },\n  \"lines\": [\n    {\n      \"position\": 1,\n      \"description\": \"API calls over allowance\",\n      \"quantity\": \"250\",\n      \"unitPriceMinor\": 40,\n      \"discountMinor\": 0,\n      \"netAmountMinor\": 10000,\n      \"taxCategory\": \"standard\",\n      \"taxRate\": \"20\",\n      \"taxAmountMinor\": 2000,\n      \"sourceRef\": \"usage:api_calls:2026-07\"\n    }\n  ]\n}\n```\n\n`quantity` and `taxRate` are decimal **strings** — they carry fractional\nprecision that a JSON number would round. Feed them to a decimal type, not a\nfloat.\n\n## 6. Download the PDF\n\n```bash\ncurl -s $FYNEX_API_BASE/invoices/4180/pdf \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\" \\\n  -o invoice-UK2607AA-2608AAB.pdf\n```\n\nThe response is `application/pdf` bytes with a `Content-Disposition`\nfilename. It renders from the document's frozen payload, so a PDF fetched\ntoday and one fetched next year are identical.\n\n## Next\n\n- **Workflows** shows the end-to-end recipes: collecting on an invoice,\n  reconciling a month, watching usage against limits.\n- **Pagination \u0026 Amounts** covers the paging loop you will need past 20 rows.\n- **Errors** covers the status contract, rate limits, and retry policy.\n","name":"Quickstart"},{"description":"# Authentication\n\nEvery Billing API request must carry a seller secret key:\n\n```\nAuthorization: Bearer sk_live_4f6f...\n```\n\nKeys come in two environments, and the environment must match your account's\ncurrent mode: a Fynex seller account operates either in demo mode or live\nmode, never both at once. While the account is in demo mode only `sk_test_…`\nkeys authenticate; after go-live only `sk_live_…` keys do, and the old test\nkey answers `401` from then on. There is no second, parallel dataset behind\nthe two prefixes — the API always returns your account's own billing records.\n\nKeys are issued and rotated in the Fynex dashboard, on the **Integration**\npage, and\nare stored server-side only as SHA-256 hashes; a lost key cannot be recovered,\nonly regenerated.\n\nOnly `sk_test_…` and `sk_live_…` secret keys authenticate this public API.\nPublishable `pk_…` keys, dashboard sessions, legacy seller tokens, cookies and\nquery-string credentials are rejected. This is a server-to-server API: never\ncall it from a browser or mobile app, and rotate or revoke a compromised key\nin the dashboard immediately. No Billing-specific token store or session is\ncreated; authorization reuses the existing hashed API-key record and its\nactive/revoked and environment checks.\n\nFailure contract:\n\n- `401` — the header is missing, malformed, or the key is unknown or revoked.\n- `403` — the key is valid but the seller account is not active.\n\nThe seller account behind the key is the authoritative tenant: every object\nthis API returns belongs to that seller, and ids belonging to another tenant\nanswer `404`. Never embed secret keys in client-side code; calls must\noriginate from your servers.\n","name":"Authentication"},{"description":"# Errors\n\nErrors are JSON with a single field:\n\n```json\n{\"error\": \"invoice not found\"}\n```\n\n(Authentication failures from the gateway layer may answer with a plain-text\nbody; treat any non-2xx as failed regardless of body shape.)\n\nEvery response, including `401`, `429`, and server errors, carries\n`X-Request-Id: req_\u003cuuid\u003e`. Include this value in a support request; it is the\nsafe correlation handle for a request, not your API key or customer data.\n\n| Status | Meaning |\n| --- | --- |\n| `400` | The request is malformed: an unparseable id, an unknown filter value, an invalid parameter. |\n| `401` | Missing or invalid API key. |\n| `403` | The key is valid but the seller account is not active. |\n| `404` | The object does not exist — or belongs to another seller. |\n| `422` | The object exists but the action is not applicable to it (for example, sending an invoice that is collected by bank transfer). |\n| `429` | Rate limit exceeded. Honour `Retry-After` and the `X-RateLimit-*` headers. |\n| `501` | The capability is not enabled for this deployment (for example, credits before stored value is switched on). |\n| `500` | Server fault. Safe to retry idempotent (GET) requests with backoff. |\n\n## Rate limiting\n\nRequests are rate-limited per seller, on a budget dedicated to this API —\nbilling traffic and payments-api traffic do not throttle each other. The\ndefault budget is **1000 requests per hour per seller**, with a burst of 100 so\na single client cannot spend the whole hour in one instant. The number is set\nper environment, so read `X-RateLimit-Limit` rather than hard-coding it.\n\nResponses normally carry `X-RateLimit-Limit`, `X-RateLimit-Remaining` and\n`X-RateLimit-Reset` (whole seconds until the budget refills), plus the current\nstandards-track structured fields `RateLimit-Policy` (`\"seller\";q=1000;qu=\"requests\";w=3600`)\nand `RateLimit` (`\"seller\";r=940;t=2100`). Both spellings carry the same numbers.\nNote these are not the `RateLimit-Limit`/`RateLimit-Remaining`/`RateLimit-Reset`\ntriple from an earlier revision of that draft, which is not sent. Read them rather than\nassuming a number: the budget is set per environment, and `X-RateLimit-Limit`\nis the authoritative value for yours. Treat them as advisory — during a limiter outage requests are allowed through without the\nheaders, so a client that requires them will break exactly when the platform\nis already degraded.\n\nThree write operations carry an **extra** per-seller quota on top of that\nbudget, each in its own bucket, because each accepted call spends something\nthat cannot be handed back:\n\n| Operation | Default quota | Why |\n| --- | --- | --- |\n| `POST /contracts` | 20 requests per rolling 24 hours (refusals and replays count) | Bounds how fast one key opens contracts; a create allocates a number from a series shared across sellers. |\n| `POST /contracts/{contractId}/amendments` | 200 requests per rolling 24 hours (a stale `expectedBaseVersion` counts) | Every accepted amendment appends a contract version — up to 100 line items — to a history that is append-only and has no delete. |\n| `POST /invoices` | 500 requests per rolling 24 hours (refusals and replays count) | Issuing allocates a number from your own gapless series, so a runaway loop burns your month of numbers. |\n\nAll three refuse with the same `429`, `Retry-After` and `X-RateLimit-*`\nheaders as the surface-wide limit.\n\nA rejected request answers `429` with `Retry-After` in seconds. Wait that\nlong — retrying sooner only deepens the overage.\n\nBulk work is what actually hits this. Reconciling a month walks pages of up\nto 100 invoices and may pull a PDF per document, so a few hundred requests in\none burst is normal. Pace bulk exports (a short sleep between pages costs far\nless than being throttled mid-walk), and if a legitimate workload cannot fit\nthe budget, ask Fynex to raise it rather than working around it with parallel\nkeys.\n\n### When the write quotas fail closed\n\nUnlike the surface-wide budget, those three operations — `POST /contracts`,\n`POST /contracts/{contractId}/amendments` and `POST /invoices` — **fail\nclosed**. While the limiter itself is unreachable they answer `503` with\n`Retry-After` instead of letting an uncounted burst of writes through. Nothing\nwas created, amended or issued, so the retry is safe: wait the header out and\nrepeat the call with the **same `Idempotency-Key`**, which is what stops the\nretry from opening a second contract or issuing a second document. Amendments\ncarry no `Idempotency-Key` — resend the same `expectedBaseVersion`, and a\nversion that did land answers `422` rather than appending a duplicate.\n\nTwo things this does *not* mean. It is the limiter FAILING, not the quota\nbeing absent: a deployment that switches a quota off (its request budget set\nto `0`) is simply unmetered on that operation, exactly as before, and never\nanswers `503` for this reason. And the quotas count **requests**, not\nsuccesses — a refusal or a replay spends one too, so a client retrying a `400`\nin a loop can exhaust its day without ever writing anything.\n","name":"Errors"},{"description":"# Sandbox \u0026 testing\n\nEverything here works without touching live money. The goal: your first\nsuccessful call within minutes, and a full trial-to-renewal subscription\nexercised without waiting a month of wall-clock time.\n\n## Hosts\n\n| Environment | Host |\n| --- | --- |\n| Staging | `https://staging-api.fynex.ai` |\n| Production | Assigned per deployment — confirm yours with Fynex before go-live. |\n\nThe environment that matters is your **account's**, not the host's: a Fynex\naccount operates in demo mode or live mode, and test behaviour follows the\naccount. Demo accounts exist on both hosts.\n\nBoth hosts are also declared in the OpenAPI document itself, in its `servers`\nblock — production first, the sandbox second, each labelled. A generated client\nor an agent that reads only the spec can therefore pick the sandbox rather than\ndefaulting to the one host it happens to see. Each entry is the bare origin;\nthe `/billing-api/v1` prefix is already part of every path in the document, so\njoin a server with a path exactly as written and do not add the prefix twice.\n\n## Keys\n\n**No account yet? Mint a sandbox in one call.** `POST /sandbox/accounts` with\na JSON body creates an anonymous demo seller — wallets, a demo terminal, a\npre-set split rule, three test customers — and returns its `sk_test_` key:\n\n```bash\ncurl -X POST https://staging-api.fynex.ai/sandbox/accounts \\\n  -H \"Content-Type: application/json\" -d '{}'\n```\n\nThe door is open on the sandbox host (`staging-api.fynex.ai`); a host that has\nit switched off answers `503` to this call and shows no `/sandbox` page. The\nsecret is shown once; the keys are time-limited — `expiresAt` in the\nresponse says exactly when they stop working. An anonymous sandbox has no\ndashboard login — it is a key, not a user — and it is a test environment: never\nenter real personal or bank details. The `/sandbox` page on the API host has\nthe full contract.\n\nFor your own account, issue a **secret** key in the Fynex dashboard: **Integration** in the\nleft-hand menu, then the API keys card. While your account is in demo mode the\nkey is `sk_test_…`; after go-live only `sk_live_…` keys authenticate and the\ntest key answers `401` from then on. A key is shown once, at creation — store\nit server-side and never in a browser.\n\nThe same key authenticates every surface in these docs: the Billing API and\nusage ingestion under `/billing-api/v1`, and the Payments API.\n\n## Test cards\n\nOn a demo account, hosted payment pages (including the links\n`POST /invoices/{invoiceId}/send` creates) accept these Visa sandbox numbers:\n\n| Card number | Notes |\n| --- | --- |\n| `4111 1111 1111 1111` | Universal Visa test card |\n| `4530 9100 0001 2345` | Visa |\n| `4037 1122 3300 0001` | Visa |\n\nFor all of them: any future expiry, any 3-digit CVV, Latin cardholder name.\nPaying an invoice's link with one of these drives the document through\n`sent → paid` exactly as a live card would, so it is the way to test the\nsettlement-polling loop end to end.\n\n## Time travel\n\nSubscription flows — trial ending, first charge, renewal — run on billing\ndates, and a sandbox that makes you wait 30 real days for a renewal is not a\nsandbox. **Each contract can be given a test clock of its own and moved\nforward**, with a `sk_test_` key and no help from us:\n\n| Call | What it does |\n| --- | --- |\n| `GET /contracts/{contractId}/test-clock` | Reads what the billing engines treat as *now* for that one contract. |\n| `POST /contracts/{contractId}/test-clock/advance` | Moves it forward to `to`, then runs that contract's subscription lifecycle and recurring invoicing as of it. |\n\n**Test-mode (demo) accounts only.** A live account answers `403` on both: a\nlive subscription's renewal is real revenue and a real document to a real\ncustomer.\n\n**One contract at a time.** The clock belongs to the contract in the path.\nAdvancing it never moves a sibling contract — yours or another account's — and\nno scheduled pass sees a changed clock. That is why this is safe to publish.\n\n**Forward only, capped at 366 days.** `to` must be after the contract's\ncurrent clock. Re-sending the instant the clock already sits at is a **no-op**:\n`200` with `advanced: false`, nothing renewed and nothing issued twice, so the\ncall is safe to retry. A target in the past is `400` — the invoices an advance\nmints are numbered and immutable, so a clock cannot be wound back.\n\n### Worked example: a monthly subscription renewing in one call\n\nCreate the subscription today (`42` is a contract that already carries at\nleast one invoice — see the note below):\n\n```bash\ncurl -s -X POST \"$FYNEX_API_BASE/contracts/42/subscriptions\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -d '{\"frequency\":\"monthly\",\"priceMinor\":4999,\"currency\":\"EUR\",\"startDate\":\"2026-01-01\"}'\n```\n\nAsk where the contract's clock is:\n\n```bash\ncurl -s \"$FYNEX_API_BASE/contracts/42/test-clock\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\"\n# {\"contractId\":42,\"now\":\"2026-01-01T09:15:00Z\",\"simulated\":false}\n```\n\nAdvance it past the end of the first term — 31 days:\n\n```bash\ncurl -s -X POST \"$FYNEX_API_BASE/contracts/42/test-clock/advance\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"to\":\"2026-02-01T00:00:00Z\"}'\n```\n\n```json\n{\n  \"clock\": { \"contractId\": 42, \"now\": \"2026-02-01T00:00:00Z\", \"simulated\": true },\n  \"advanced\": true,\n  \"lifecycle\": { \"renewed\": 1, \"trialsActivated\": 0, \"cronDisabled\": false },\n  \"invoices\": { \"issued\": 1, \"sent\": 1, \"skipped\": 0, \"failed\": 0, \"passDisabled\": false }\n}\n```\n\nThe renewal is now visible on the two reads that matter — the subscription's\nnew term, and the document it earned:\n\n```bash\ncurl -s \"$FYNEX_API_BASE/subscriptions?contractId=42\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\"\n# currentPeriodStart moves to 2026-02-01, currentPeriodEnd to 2026-03-01\n\ncurl -s \"$FYNEX_API_BASE/invoices?contractId=42\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\"\n# one more invoice, origin \"recurring\", for the new term\n```\n\n`GET /contracts/{contractId}/invoices/upcoming` before the advance, and the\nissued document after it, should agree — which is the cheapest end-to-end\ncheck that your contract is configured the way you think it is.\n\n### What it does not advance\n\nThe clock moves the **subscription state machine** and the **recurring\ninvoice**. It does *not*:\n\n- close metered usage periods or mint usage invoices,\n- send dunning e-mail for a subscription that went `past_due`,\n- run off-session auto-charge on an issued invoice,\n- move the issue date: an invoice raised by an advance is dated at REAL time\n  (so its number stays in the real month's gapless sequence) while its period\n  is the simulated term — read the period, not the date, to check the renewal.\n\nThose stay on real time in their own scheduled passes, and every advance\nrepeats the list in its `notAdvanced` field. Do not write a test that waits on\none of them after an advance.\n\n### When an advance renews but issues nothing\n\nTwo operator switches decide whether the halves above run at all, and the\nresponse says which one stopped:\n\n| Response field | Switch | Symptom |\n| --- | --- | --- |\n| `lifecycle.cronDisabled` | `billing.subscription.lifecycle_cron` | Nothing renews, no matter how far the clock moves. |\n| `invoices.passDisabled` | `billing.recurring.invoice_pass` | The term renews, and no document appears on `GET /invoices`. |\n\nBoth default to **off** — see the table below. There is also one data\nprecondition the clock cannot supply: the recurring lane continues a\ncontract's existing invoicing pattern, so a contract with **no prior invoice**\nis reported in `invoices.skipped` rather than issued. Raise the first document\nonce with `POST /invoices` (or let a contract you already invoiced carry the\nsubscription), and every later advance issues by itself.\n\nThe staff-operated clock is a different thing and is not this. It shifts the\nlifecycle dates of test-mode *payment-link* subscriptions (the recurring\nhosted-checkout product), estate-wide, and never touches the billing\nsubscriptions this reference describes.\n\n## What a fresh sandbox has switched off\n\nSeveral billing capabilities sit behind operator feature flags that default to\n**off**, because each one moves money or issues documents on a schedule. A\nsandbox where they are off looks broken — subscriptions never charge, usage\nperiods never close into invoices, no invoice is ever raised, credits report\n`501` — when it is merely unconfigured. If a flow below does nothing, ask Fynex\nto confirm the flag before debugging your integration:\n\n| Flag | What it gates |\n| --- | --- |\n| `billing.subscription.lifecycle_cron` | The pass that ends trials, charges renewals and applies scheduled plan changes. |\n| `billing.usage.close_pass` | The pass that closes usage periods and mints usage invoice lines. |\n| `billing.recurring.invoice_pass` | The pass that turns due subscription terms into issued invoices. |\n| `billing.usage.invoice_pass` | The pass that composes closed usage lines into issued invoices. Separate from the recurring one on purpose — a seller may be ready to auto-bill terms and not metered usage, or the reverse. |\n| `billing.proration.invoice_binding` | Whether a subscription's unbilled mid-term proration is put on the invoice its term is billed on — a charge as an extra line, a credit as a discount on the recurring line, any remainder as a non-expiring `proration` credit lot. While off, a mid-term change is still recorded against the contract but never reaches a document. |\n| `billing.invoice.auto_charge` | Off-session collection: the loop that charges a customer's saved card for a sent or overdue invoice. Without it a document is only ever paid by someone visiting its link or making a transfer. |\n| `payment_links.recurring_billing` | Recurring charging through hosted payment links. |\n| `billing.credits` | Stored-value credits; while off, the credit endpoints answer `501` and invoices draw no credit down. |\n\n**Closing a period is not the same as billing it.** The close pass and the\ninvoice passes are different switches: with only `billing.usage.close_pass` on,\n`GET /contracts/{contractId}/usage` moves and periods close, and no invoice\never appears on `GET /invoices`. That pairing is the single most common reason\na sandbox looks like it has stopped halfway.\n\n## A sandbox session that proves the loop\n\nThe target this page is written against is the one in our own onboarding\nPRD: **a first transaction under 30 minutes, self-serve** — from a fresh\nsandbox key to an invoice on `GET /invoices` without talking to anyone at\nFynex. The steps below are that path; if one of them cannot be done inside\nthat budget, the gap is ours, not yours.\n\n1. `GET /contracts` — key, host and network path all work.\n2. Register a metric and price it (see **Usage ingestion**), send a few\n   events, and watch `GET /contracts/{contractId}/usage` move.\n3. Create a subscription in the dashboard with a trial that has already\n   ended, and let the lifecycle pass run — the renewal invoice appears on\n   `GET /invoices`.\n4. `POST /invoices/{invoiceId}/send`, pay the link with a test card, and poll\n   the invoice to `paid`.\n\nThat is the whole billing loop — metering, rating, issuance, collection,\nsettlement — without a real card or a real month.\n","name":"Sandbox \u0026 testing"},{"description":"# Pagination\n\nEvery list endpoint pages by keyset, with the same two parameters:\n\n- `limit` — page size, 1–100 (default 20).\n- `cursor` — where to continue from. Omit it (or pass `0`) for the first page.\n\nEach page reports how to continue:\n\n```json\n{\n  \"invoices\": [ ... ],\n  \"hasMore\": true,\n  \"nextCursor\": 4177\n}\n```\n\nPass `nextCursor` back as `cursor` until `hasMore` is `false`. That loop is\nthe same code on every list in this API.\n\nKeyset paging is stable under concurrent inserts: new rows appear on the first\npage of a fresh iteration and never shift the pages of an iteration already in\nflight.\n\n## Direction is a property of the list, not of the parameter\n\nThe lists do not all walk the same way, and that part is deliberate:\n\n| List | Order | A cursor means |\n| --- | --- | --- |\n| `/invoices`, `/subscriptions` | Newest first | ids **below** the cursor |\n| The credit ledger on `/contracts/{contractId}/credits` | Newest first | ids **below** the cursor |\n| `/contracts` | Ascending contract id | ids **above** the cursor |\n\nContracts walk forward because the id is a stable identity to iterate, not a\nrecency ranking. What used to differ as well was the parameter NAME —\n`beforeId` on some lists, `afterId` on others — so anyone who wrote a working\nloop for invoices wrote a broken one for contracts. `cursor` and `nextCursor`\nare the same on all of them; only the documented order changes.\n\nThe credit ledger pages the `entries` array only: the balances in the same\nresponse are always complete. Seller-wide credit balances are one row per\ncurrency and credit type, so that list is genuinely bounded and does not page.\n\n## The original parameter names\n\n`beforeId` / `nextBeforeId` and `afterId` / `nextAfterId` still work,\nunchanged, on the lists that had them, and every response still carries the\noriginal field beside `nextCursor` with the same value. Existing integrations\nneed no change. Sending both `cursor` and the original name with **different**\nvalues is a `400` rather than a silent choice between them.\n\n## Dates\n\n`issuedFrom` and `issuedTo` accept a calendar date (`2026-08-01`) or a full\nRFC 3339 timestamp. A bare date is read as **UTC midnight**, and the range is\nhalf-open — `issuedFrom` inclusive, `issuedTo` exclusive — so adjacent months\nnever double-count a document. If your books close in a non-UTC zone, send the\ntimestamp form with your offset rather than the bare date.\n\n## Amounts\n\nAll monetary amounts are **integers in the currency's minor units**\n(`grandTotalMinor: 12050` is €120.50 for a EUR document). Quantities and tax\nrates are decimal strings. Never parse amounts as floating point.\n","name":"Pagination \u0026 Amounts"},{"description":"# Endpoint reference\n\nEvery operation, with its exact parameters, response shape and failure modes.\nEvery one requires `Authorization: Bearer sk_live_…` (or `sk_test_…`); all\namounts are integers in minor units; all list responses page.\n\nBase: `$FYNEX_API_BASE` = `https://\u003cyour-host\u003e/billing-api/v1`\n\n## How numbers ride on the wire\n\nOne rule, four rows, and the field name tells you which row applies. Read the\nsuffix before you read the value.\n\n| Value | Type | Field name | Example |\n| --- | --- | --- | --- |\n| Money amount | Integer, **minor units** | ends `Minor` | `4999` is €49.99 |\n| Rate / percentage | Integer, **basis points** | ends `Bps` | `275` is 2.75% |\n| Quantity | Decimal **string** | — | `\"1250.5\"` |\n| Tax rate | Decimal **string**, percent | — | `\"20.0\"` |\n\nMoney is never a float and never a decimal string: a `…Minor` field is an\ninteger count of the currency's smallest unit, paired with the resource's ISO\n4217 `currency`. Reading `4999` as euros overstates the amount a hundredfold,\nand reading `275` as a percentage does the same to a rate — which is why the\nunit is repeated in every field's own description rather than stated once here.\n\nQuantities go the other way and travel as strings on purpose: metered\nconsumption is routinely finer than an integer, and JSON numbers are IEEE\nfloats. Per-unit **rates** are the one deliberate exception to minor units —\nthey are decimal strings in major units, because a rate is often smaller than\none minor unit (`\"0.004\"`).\n\nThe convention is enforced, not merely documented: a `…Minor` or `…Bps` field\nthat publishes as a float, or whose description never names its unit, fails the\nbuild.\n\n| | Operation | Purpose |\n| --- | --- | --- |\n| 1 | `GET /contracts` | The entry point — ids for every per-contract read |\n| 1a | `POST /contracts` | Create a contract (`Idempotency-Key` required; extra per-seller quota) |\n| 1b | `POST /contracts/{contractId}/amendments` | Append a contract version — status, dates, components, counterparty (extra per-seller quota) |\n| 2 | `GET /contracts/{contractId}/usage` | Metered consumption this period |\n| 3 | `GET /contracts/{contractId}/credits` | One contract's stored value + ledger |\n| 4 | `GET /credits` | Stored-value balances across all contracts |\n| 4a | `POST /contracts/{contractId}/credits/top-up` | Grant credit on a contract (`Idempotency-Key` required) |\n| 5 | `GET /invoices` | Invoice list, filtered and paged |\n| 5a | `POST /invoices` | Compose and issue an invoice (`Idempotency-Key` required; extra per-seller quota) |\n| 6 | `GET /invoices/{invoiceId}` | One invoice with its lines |\n| 7 | `GET /invoices/{invoiceId}/pdf` | The rendered document |\n| 8 | `POST /invoices/{invoiceId}/send` | Create the payment link (emails the customer) |\n| 9 | `GET /subscriptions` | Subscription list |\n| 10 | `GET /subscriptions/{subscriptionId}` | One subscription |\n| 10a | `POST /contracts/{contractId}/subscriptions` | Create a subscription (`Idempotency-Key` required) |\n| 10b | `POST /subscriptions/{subscriptionId}/cancel` · `/pause` · `/resume` · `/end-trial` · `/change-plan` | Drive a subscription's lifecycle |\n| 11 | `POST /usage/events` · `:batch` | Report metered consumption |\n| 12 | `GET`/`POST /usage/metrics` · `GET /usage/metrics/{metricName}` | The meter registry |\n| 13 | `PUT`/`GET /usage/contracts/{contractId}/metrics/{metricName}/price` | Price a metric on a contract |\n| 14 | `POST /prices/evaluate` | Preview what a config charges — the billing engine, pure |\n| 15 | `POST /usage/csv` | Bulk intake from a periodic export |\n| 18 | `GET /invoices/export` | A period's invoices as one CSV/NDJSON file — month-end, without the paging loop |\n| 19 | `GET /invoices/{invoiceId}/settlement` | Which payments and payouts settled this invoice |\n| 20 | `POST /customers` | Create or resolve a customer — the `sellerCustomerId` everything else needs |\n| 21 | `GET /contracts/{contractId}/invoices/upcoming` | What the contract's next invoice would carry — an estimate, nothing written |\n| 16 | `GET /usage/dead-letters` · `{deadLetterId}/redrive` · `{deadLetterId}/discard` | Events an intake refused, and what to do with them |\n| 17 | `GET`/`POST /usage/webhook-endpoints` · `{endpointId}/rotate-secret` · `{endpointId}/revoke` | Inbound webhook endpoints and their signing secrets |\n\n---\n\n## 1. `GET /contracts`\n\nLists the contracts your key can see. Every per-contract endpoint takes an id\nfrom here.\n\n| Parameter | In | Default | Notes |\n| --- | --- | --- | --- |\n| `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. |\n| `afterId` | query | — | The original name of `cursor` on this list. Still accepted and identical; sending both with different values is a `400`. |\n| `limit` | query | `20` | 1–100. |\n\nContracts page **forward** (ascending id) because the id is a stable identity\nto walk, not a recency ranking. Invoices, subscriptions and the credit ledger\npage **backward** (newest first) — see the Pagination reference\n(`billing-pagination.md`), which also states the keyset stability guarantee\nand the amounts convention.\n\n```bash\ncurl -s \"$FYNEX_API_BASE/contracts?limit=50\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\"\n```\n\n```json\n{\n  \"contracts\": [\n    {\n      \"contractId\": 42,\n      \"contractNumber\": \"UK2607AA\",\n      \"version\": 1,\n      \"sellerCustomerId\": 7,\n      \"currency\": \"EUR\",\n      \"status\": \"active\",\n      \"startDate\": \"2026-01-01\",\n      \"endDate\": \"2026-12-31\",\n      \"customerName\": \"Ada Lovelace\",\n      \"customerPhone\": \"+31 6 1234 5678\",\n      \"customerCompanyName\": \"Harbour Group BV\",\n      \"customerCompanyCountry\": \"NL\"\n    }\n  ],\n  \"hasMore\": true,\n  \"nextCursor\": 42,\n  \"nextAfterId\": 42\n}\n```\n\n| Field | Meaning |\n| --- | --- |\n| `contractId` | The key for endpoints 2 and 3. |\n| `contractNumber` | Document number (`UK2607AA`). Empty for contracts predating numbering. |\n| `version` | Contract version; you always get the current one. |\n| `sellerCustomerId` | Your customer on this contract. Also an invoice filter. |\n| `currency` | Contract currency (ISO 4217). |\n| `status` | Contract lifecycle state, e.g. `active`. |\n| `startDate` / `endDate` | `YYYY-MM-DD`. `endDate` absent on open-ended contracts. |\n| `customerName` / `customerPhone` | The customer's name and phone as agreed on the contract. Absent when not stated. |\n| `customerCompanyName` / `customerCompanyCountry` | The customer's company and its registration country as agreed on the contract. Absent when not stated. |\n\n**Failures:** `400` malformed `cursor`/`afterId`/`limit`, or both cursor names sent with different values · `401` bad key · `403`\ninactive seller.\n\n---\n\n## 1a. `POST /contracts`\n\nCreates a contract, in `draft`, for one of your customers. The contract is the\nanchor every other object refers to — subscriptions, credit, metered prices and\ninvoices — and it bills in one currency for its whole life.\n\n**`Idempotency-Key` is a required header**: 1–128 characters from\n`A–Z a–z 0–9 _ . : -`, one per contract you intend to create. Keys are scoped to\nyour 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\n(its *current* version) and answers `200` instead of `201`. **The body is\ncompared**, in two halves.\n\n`sellerCustomerId` and `currency` are compared against the contract's current\nversion — neither is amendable, so the two versions agree. Everything an\namendment *can* move is compared against **version 1**, the version this key\nactually created, so a contract that was legitimately re-dated or re-priced\ndoes not turn every later retry into a refusal:\n\n- `startDate` and `endDate`, present or absent;\n- `lineItems` **line by line** — `componentType`, `componentConfig` and\n  `quantity` — and in any order, not merely how many there are. Two components\n  of the same type at different quantities are two different contracts, and a\n  count alone answered the second with the first;\n- `customerName`, `customerPhone`, `customerCompanyName` and\n  `customerCompanyCountry`, each compared *only when your request states it* —\n  a blank one is inherited from the customer record, which is exactly what the\n  first call did.\n\nA difference in any of them answers `422` naming the field.\n\n```bash\ncurl -s -X POST \"$FYNEX_API_BASE/contracts\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\" \\\n  -H \"Idempotency-Key: 5c2f7c40-1a3e-4c9b-9d1f-2b6e5a0c7d31\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"sellerCustomerId\": 7,\n    \"currency\": \"EUR\",\n    \"startDate\": \"2026-10-01\"\n  }'\n```\n\n| Field | Notes |\n| --- | --- |\n| `sellerCustomerId` | Required; one of your customers (endpoint 20). Another seller's id is `400`. |\n| `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. |\n| `startDate` | Required. `YYYY-MM-DD`, up to a year in the past and ten years ahead; the window is measured in whole days. |\n| `endDate` | Optional `YYYY-MM-DD`; omit for open-ended. Not before `startDate`, and not more than ten years ahead. |\n| `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. |\n| `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. |\n\nAnswers the same contract shape endpoint 1 lists — `id`, `version` (`1`),\n`status` (`draft`), `currency`, `startDate`, `endDate`, `contractNumber` when\nnumbering is enabled.\n\n**Failures:** `400` (a missing `sellerCustomerId`, `currency` or `startDate`,\nunknown currency, malformed date, a date outside the window, over-long detail,\nan unavailable `componentConfig` reference, a `sellerCustomerId` that is not\nyours — the same answer whether it is another seller's or no customer at all —\nmissing or malformed `Idempotency-Key`) ·\n`422` (`Idempotency-Key` already used for a different contract) · `429` ·\n`503` · `401` / `403`.\n\n`429` here has **two** ceilings behind it. The surface-wide per-seller rate\nlimit applies as everywhere else, and creating a contract carries an *extra*\nper-seller quota of its own — **20 create requests per rolling 24 hours** by default — requests, not contracts: a refused or replayed call counts too.\nThat quota bounds how fast one key can open contracts; the budget above is\nsized for bulk reads. It is deliberately not a guarantee about the shared\n`CC+YYMM` number namespace, which 20 a day could not give: contract numbering\nis a rollout switch that is off by default, and where it is on the allocator\nfails the create rather than reusing a number. Both ceilings answer `429` with\n`Retry-After`; an integration that opens contracts as customers sign up will\nnever see either.\n\nThe quota fails closed — **`503`** with `Retry-After` while its limiter is\nunreachable, and nothing was created. See [Errors → rate limiting](/billing-api/v1/docs/errors#when-the-write-quotas-fail-closed).\n\n---\n\n## 1b. `POST /contracts/{contractId}/amendments`\n\nAppends a **version** to a contract you own. Nothing on a contract is edited\nin place: a status move, new dates, a replaced component set or a corrected\ncounterparty detail is a new version, and every earlier one stays readable.\n\n`expectedBaseVersion` is the concurrency guard — send the `version` you last\nread. If the contract has moved on since, the amendment answers `422`; re-read\nand decide again. There is no `Idempotency-Key`: a repeat of a successful\namendment fails the version check by construction.\n\n```bash\ncurl -s -X POST \"$FYNEX_API_BASE/contracts/42/amendments\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"expectedBaseVersion\": 1, \"status\": \"active\"}'\n```\n\n| Field | Notes |\n| --- | --- |\n| `expectedBaseVersion` | Required, positive: the version you last read. |\n| `status` | `draft`, `active`, `suspended` or `closed`; omit to keep. Illegal transitions (anything out of `closed`, `draft` straight to `suspended`) are `422`. |\n| `startDate` / `endDate` | `YYYY-MM-DD`; omit to keep. An existing end date cannot yet be cleared. |\n| `lineItems` | Omit to carry the current set forward; send the full new set to replace it; `[]` clears it. At most 100. |\n| `customerName` / `customerPhone` / `customerCompanyName` / `customerCompanyCountry` | Send to replace, omit to keep, `\"\"` to clear. |\n\nAnswers the new version (`version` is `expectedBaseVersion + 1`) in the same\ncontract shape as endpoint 1.\n\n**Failures:** `400` (malformed field, unknown `status`) · `404` (contract is\nnot yours) · `422` (stale `expectedBaseVersion`, closed contract, illegal\nstatus transition) · `429` · `503` · `401` / `403`.\n\n`429` here has **two** ceilings behind it, as on endpoint 1a. The surface-wide\nper-seller rate limit applies as everywhere else, and amending carries an\n*extra* per-seller quota of its own — **200 amend requests per rolling 24 hours** (a stale `expectedBaseVersion` counts too)\nby default — because every accepted amendment appends a version, with up to\n100 line items, to a history that is append-only and has no delete. Like the\ncreate quota it fails closed — **`503`** with `Retry-After` while its limiter\nis unreachable, and nothing was appended. See [Errors → rate limiting](/billing-api/v1/docs/errors#when-the-write-quotas-fail-closed).\n\n---\n\n## 2. `GET /contracts/{contractId}/usage`\n\nThe contract's **current open** billing periods, one entry per metric. This is\na live read, not a closed-period figure — reconcile money against invoices.\n\n```bash\ncurl -s \"$FYNEX_API_BASE/contracts/42/usage\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\"\n```\n\n```json\n{\n  \"contractId\": 42,\n  \"metrics\": [\n    {\n      \"metricName\": \"api_calls\",\n      \"used\": \"10250\",\n      \"includedUnits\": \"10000\",\n      \"capQuantity\": \"50000\",\n      \"capMode\": \"hard\",\n      \"percentOfCap\": \"20.5\",\n      \"percentOfPlan\": \"102.5\",\n      \"periodStart\": \"2026-08-01\",\n      \"periodEnd\": \"2026-08-31\"\n    }\n  ]\n}\n```\n\n| Field | Meaning |\n| --- | --- |\n| `used` | Metered so far this period. Decimal **string**. |\n| `includedUnits` | Plan allowance for the period. |\n| `capQuantity` / `capMode` | The ceiling and how it is enforced: `hard` blocks past it, `soft` only alerts. Absent when no limit policy exists. |\n| `percentOfPlan` | Above `100` means overage — what will be rated onto the next usage invoice. |\n| `percentOfCap` | Distance to the enforcement ceiling. Warn customers well before `100`. |\n| `periodStart` / `periodEnd` | Open period bounds. Absent when a metric has a policy but no usage yet. |\n\n**Failures:** `400` non-numeric id · `404` unknown contract, or one belonging\nto another seller (the two are indistinguishable by design).\n\nA real contract with nothing metered answers `200` with `\"metrics\": []` — a\nvalid state, not an error.\n\n---\n\n## 3. `GET /contracts/{contractId}/credits`\n\nOne contract's stored-value balances plus a page of its append-only ledger.\n\n| Parameter | In | Default | Notes |\n| --- | --- | --- | --- |\n| `contractId` | path | — | Required. |\n| `cursor` | query | — | Keyset cursor over the ledger: entries with an id **below** this value. Pass back the previous page's `nextCursor`. |\n| `beforeId` | query | — | The original name of `cursor` on this list. Still accepted and identical; sending both with different values is a `400`. |\n| `limit` | query | `20` | 1–100. Applies to `entries` only — `balances` is always complete. |\n\n```bash\ncurl -s \"$FYNEX_API_BASE/contracts/42/credits?limit=50\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\"\n```\n\n```json\n{\n  \"balances\": [\n    {\"currency\": \"EUR\", \"creditType\": \"purchased\", \"balanceMinor\": 250000, \"isLiability\": true}\n  ],\n  \"entries\": [\n    {\n      \"id\": 1201,\n      \"sellerCustomerId\": 7,\n      \"kind\": \"topup\",\n      \"creditType\": \"purchased\",\n      \"signedDeltaMinor\": 250000,\n      \"currency\": \"EUR\",\n      \"lotId\": 900,\n      \"invoiceId\": null,\n      \"expiresAt\": null,\n      \"reason\": \"annual prepayment\",\n      \"occurredAt\": \"2026-08-01T09:30:00Z\"\n    }\n  ],\n  \"hasMore\": true,\n  \"nextCursor\": 1201,\n  \"nextBeforeId\": 1201\n}\n```\n\n| Field | Meaning |\n| --- | --- |\n| `kind` | `topup` (granted or bought), `deduction` (consumed into an invoice), `expiry` (a lapsed lot removed). |\n| `signedDeltaMinor` | Positive for `topup`, negative for the other two. |\n| `lotId` | The grant a deduction or expiry consumed. |\n| `invoiceId` | The invoice a deduction funded — the link back to the document. |\n| `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. |\n\n**Failures:** `400` bad id or paging · `404` unknown/foreign contract ·\n`501` stored value is not enabled for this deployment.\n\nReplaying the ledger from the beginning always reproduces the balance:\ncorrections are new rows, never edits.\n\n---\n\n## 4. `GET /credits`\n\nYour stored-value position across every contract, one row per currency and\ncredit type. Bounded, so it does not page.\n\n```bash\ncurl -s \"$FYNEX_API_BASE/credits\" -H \"Authorization: Bearer $FYNEX_API_KEY\"\n```\n\n```json\n{\n  \"balances\": [\n    {\"currency\": \"EUR\", \"creditType\": \"purchased\", \"balanceMinor\": 250000, \"isLiability\": true},\n    {\"currency\": \"EUR\", \"creditType\": \"promotional\", \"balanceMinor\": 5000, \"isLiability\": false}\n  ]\n}\n```\n\nCredit types: `promotional`, `purchased`, `manual`, `gift`, `enterprise`,\n`ai_token`, `marketplace`, `proration`. `proration` is minted only by the\nbilling engine (unserved time returned on a mid-term downgrade) and cannot be\ngranted by hand. Finance usually wants liabilities separated from granted\ncredit — do not sum them blindly.\n\n**Failures:** `401` · `403` · `501` (capability off).\n\n---\n\n## 4a. `POST /contracts/{contractId}/credits/top-up`\n\nGrants a credit **lot** on a contract you own — prepaid balance the invoice\nengine draws down before any payment rail is asked for money. The ledger is\nappend-only, and this is the only way credit enters it: the deductions,\nexpiries and reversals that take it out are the engine's own rows and have no\npublic route.\n\n**`Idempotency-Key` is a required header**: 1–128 characters from\n`A–Z a–z 0–9 _ . : -`, one per grant you intend to make. Keys are scoped to\nyour seller account. The same key returns the grant the first call created and\nanswers `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\n`proration-grant:` lots live in the same index, and the recurring lane adopts\nthe lot it finds under that key as the term's grant — which is exactly why no\ncaller may put one there.\n\nUnlike the subscription create, **the body is compared**, and what is compared\nis everything about the lot you decide: `contractId`, `amountMinor`,\n`currency`, `creditType`, `expiresAt` (present or absent), `sellerCustomerId`\nand `reason`. A difference in any of them answers `422` naming the field rather\nthan handing back the earlier grant: a top-up has a natural shape, and silently\nreturning the first one would hide a second grant that never happened.\nNormalisation is not a difference — casing on `currency` and `creditType`, and\nsurrounding whitespace on `reason`, replay as the same grant.\n\n```bash\ncurl -s -X POST \"$FYNEX_API_BASE/contracts/42/credits/top-up\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\" \\\n  -H \"Idempotency-Key: 3f0b6a1e-9c1d-4f10-9f2b-6f2a1c1e6a1e\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"creditType\": \"purchased\",\n    \"amountMinor\": 250000,\n    \"currency\": \"EUR\",\n    \"reason\": \"annual prepayment\"\n  }'\n```\n\n| Field | Notes |\n| --- | --- |\n| `creditType` | `promotional`, `purchased`, `manual`, `gift`, `enterprise`, `ai_token` or `marketplace`. `purchased` and `enterprise` were PAID FOR — they are a liability you owe as service. |\n| `amountMinor` | The lot, in **minor units**, 1 to 10^12. Always positive: this is the grant, not a movement. |\n| `currency` | ISO 4217, and it must equal the contract's current currency — credit is never converted. A different one is `400`. |\n| `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. |\n| `reason` | Required, at most 512 characters. Recorded on the ledger row. |\n| `sellerCustomerId` | Optional attribution for per-customer reporting; must be one of your customers (endpoint 20). The contract is still the balance anchor. |\n\nAnswers the same `CreditLedgerEntry` shape endpoint 3 lists — `id`, `kind`\n(`topup`), `creditType`, `signedDeltaMinor`, `currency`, `lotId`, `expiresAt`,\n`reason`, `occurredAt`. A top-up **is** a lot, so `lotId` is absent on it; the\ndeductions that later consume it name this entry's `id`.\n\n**Failures:** `400` (unknown `creditType`, `amountMinor` outside the range, a\n`currency` other than the contract's, a `reason` over 512 characters, a\n`sellerCustomerId` that is not yours, missing or malformed `Idempotency-Key`) ·\n`404` (contract is not yours) · `422` (`Idempotency-Key` already used for a\ndifferent grant) · `429` · `401` / `403` · `501` (capability off).\n\n---\n\n## 5. `GET /invoices`\n\nThe workhorse. Newest first, keyset paged, with filters that let you fetch a\nbounded slice instead of walking history.\n\n| Parameter | In | Default | Notes |\n| --- | --- | --- | --- |\n| `contractId` | query | — | One contract. |\n| `sellerCustomerId` | query | — | One of your customers, across contracts. |\n| `origin` | query | — | `recurring`, `usage`, `milestone`, `project`, `one_time`, `adhoc`, `marketplace`. `subscription` is a deprecated alias of `recurring` and resolves to the same set. |\n| `status` | query | — | `draft`, `issued`, `sent`, `paid`, `overdue`, `voided`, `written_off`. |\n| `invoiceType` | query | — | `standard`, `credit_note`, `simplified`, `modified`. |\n| `corrects` | query | — | Credit notes issued against this invoice **number**. |\n| `issuedFrom` | query | — | Inclusive lower bound. `YYYY-MM-DD` (UTC midnight) or RFC 3339. |\n| `issuedTo` | query | — | **Exclusive** upper bound, so adjacent periods never double-count. |\n| `cursor` | query | — | Keyset cursor: rows with an id **below** this value — this list walks backward. Pass back the previous page's `nextCursor`. |\n| `beforeId` | query | — | The original name of `cursor` on this list. Still accepted and identical; sending both with different values is a `400`. |\n| `limit` | query | `20` | 1–100. |\n\n`origin`, `status` and `invoiceType` reject an unrecognized value with `400`,\nso a typo cannot masquerade as \"no such invoices\". `contractId`,\n`sellerCustomerId` and `corrects` are matched as given — a well-formed but\nwrong value legitimately returns an empty page.\n\n```bash\ncurl -s \"$FYNEX_API_BASE/invoices?issuedFrom=2026-08-01\u0026issuedTo=2026-09-01\u0026status=sent\u0026limit=100\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\"\n```\n\n```json\n{\n  \"invoices\": [\n    {\n      \"id\": 4180,\n      \"contractId\": 42,\n      \"sellerCustomerId\": 7,\n      \"origin\": \"usage\",\n      \"invoiceNumber\": \"UK2607AA-2608AAB\",\n      \"jurisdiction\": \"UK\",\n      \"invoiceType\": \"standard\",\n      \"status\": \"sent\",\n      \"currency\": \"EUR\",\n      \"issueDate\": \"2026-08-01T00:00:00Z\",\n      \"dueDate\": \"2026-08-15T00:00:00Z\",\n      \"subtotalMinor\": 10000,\n      \"taxTotalMinor\": 2000,\n      \"grandTotalMinor\": 12000,\n      \"creditAppliedMinor\": 0,\n      \"collectibleMinor\": 12000,\n      \"creditSettledMinor\": 0,\n      \"outstandingMinor\": 12000,\n      \"reverseCharge\": false,\n      \"paymentLinkId\": 991\n    }\n  ],\n  \"hasMore\": true,\n  \"nextCursor\": 4180,\n  \"nextBeforeId\": 4180\n}\n```\n\nList rows omit `customerName` / `customerEmail` — those live in the frozen\npayload and reading them per row would mean parsing every document. Use\n`sellerCustomerId` to group, or fetch the single invoice (endpoint 6).\n\n**Failures:** `400` unknown enum value, malformed date or paging · `401` ·\n`403`.\n\n---\n\n## 5a. `POST /invoices`\n\nComposes, numbers and — unless `send` is `false` — sends an invoice in one\ncall. The document is **issued the moment this answers `201`**: numbered\ngaplessly under your agreement, its totals recomputed server-side, its payload\nfrozen. There are no drafts on this API and no edits afterwards.\n\n**Credit notes are not issued here.** A correction is raised *against* the\ndocument it corrects — it mirrors that document's lines and retires its payment\nlink — none of which can be reconstructed from a hand-composed body, so\n`invoiceType: credit_note` is `400` and there is no `originalInvoiceNumber`\nfield on this request. Corrections are made in the dashboard.\n\n**`Idempotency-Key` is a required header**, same shape as endpoint 1a. The same\nkey returns the document the first call issued and answers `200` — with no\n`paymentLinkUrl` and no `deliveryStatus`, because delivery is not replayed.\n**The body is compared**, and what is compared is everything that decides\n*which* document this is:\n\n- `contractId`, `sellerCustomerId`, `currency`, `invoiceType`, `jurisdiction`;\n- `dueDate` and `dateOfSupply`, present or absent, and an `exemptionReason` or\n  `legalNotice` your request states (an omitted one is the engine's to decide —\n  it supplies its own on an exempt or reverse-charge document — and it decides\n  the same way twice);\n- the document's own text: `notes`, `poReference`, `paymentTerms`;\n- the whole `buyer` block as it was frozen onto the document — the same money\n  addressed to somebody else is a different legal record — and the `seller`\n  block *as the engine used it*: under Fynex-collection issuance the issuer is\n  substituted for the one you sent, so a retry that states a different seller\n  still replays;\n- the net total of the lines before tax (`quantity × unitPriceMinor −\n  discountMinor`, summed), how many lines there are, and **each line's\n  `description`, `quantity` and `unitPriceMinor`** — two lines whose net\n  happens to match (`2 × 5000` and `4 × 2500`) are still different lines;\n- **each line's `discountMinor`, `taxCategory`, `taxRatePercent` and\n  `taxable`** — these decide the TAX on a line whose net amount is identical,\n  which the net total above cannot see, so a retry that drops `taxRatePercent`\n  from `20` to `0` is a different document, not a replay.\n\nA difference in any of them answers `422` naming the field, and no second\ndocument is numbered.\n\n`send` is deliberately **not** compared: it chooses whether to deliver the\ndocument, not what the document says, so a retry that flips it still replays.\n\n```bash\ncurl -s -X POST \"$FYNEX_API_BASE/invoices\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\" \\\n  -H \"Idempotency-Key: 8d1e4b2a-6f3c-4e7a-9b0d-1c2e3f4a5b6c\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"contractId\": 42,\n    \"sellerCustomerId\": 7,\n    \"jurisdiction\": \"UK\",\n    \"currency\": \"EUR\",\n    \"dueDate\": \"2026-11-01\",\n    \"buyer\": {\"name\": \"Ada Lovelace\", \"email\": \"ada@example.com\", \"addressLine1\": \"1 Analytical Row\", \"city\": \"London\", \"postalCode\": \"E1 6AN\", \"country\": \"GB\"},\n    \"lineItems\": [{\"description\": \"Seats, October\", \"quantity\": \"10\", \"unitPriceMinor\": 4999, \"taxCategory\": \"standard\", \"taxRatePercent\": \"20\"}]\n  }'\n```\n\n| Field | Notes |\n| --- | --- |\n| `contractId` / `sellerCustomerId` | Both required and both yours (endpoints 1a and 20). Nothing is provisioned here: an unknown contract is `404`, a foreign customer `400`. |\n| `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`. |\n| `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`. |\n| `currency` | Must equal the contract's — a different one is `400`. |\n| `dueDate` / `dateOfSupply` | `YYYY-MM-DD`. |\n| `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. |\n| `seller` | Optional. Ignored under Fynex-collection issuance, where Fynex is the issuing party; used as the issuing identity only for a merchant-issued seller. |\n| `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. |\n| `poReference` / `notes` / `paymentTerms` / `exemptionReason` / `legalNotice` | Free text, 128 / 2000 / 256 / 64 / 2000 characters. |\n| `send` | Default `true`: create the hosted payment link and e-mail the buyer. `false` issues without delivering; `POST /invoices/{invoiceId}/send` delivers later. |\n\nAnswers `{\"invoice\": …, \"paymentLinkUrl\": …, \"deliveryStatus\": …}` — the same\ninvoice shape endpoint 6 returns, plus the link when one was created. **A\ndelivery failure does not undo issuance**: the answer is still `201` with the\ndocument and no link.\n\n`deliveryStatus` says what *this* call's delivery attempt did, because an\nabsent `paymentLinkUrl` alone cannot tell three different situations apart —\nand only one of them is yours to act on:\n\n| Value | Meaning |\n| --- | --- |\n| `sent` | The collection link was created and e-mailed; `paymentLinkUrl` carries it. |\n| `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. |\n| `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). |\n\nThe field is absent on an idempotent replay, which attempts no delivery of its\nown and must not restate the first call's outcome.\n\n**Failures:** `400` (input the engine refuses, `invoiceType: credit_note`, a\nmissing `buyer.email`, a `currency` other than the contract's, a foreign\n`sellerCustomerId`, missing or malformed `Idempotency-Key`)\n· `404` (contract is not yours) · `422` (`Idempotency-Key` already used for a\ndifferent document; a merchant-issued `seller` block missing the legal name,\naddress or tax details a compliant document needs — the detail names the\nfields; a UK/EU exemption without the issuer's VAT number) · `429` · `503` ·\n`401` / `403`. The incomplete-issuer case answered `400` before 2026-09; it is\na state of the seller's profile, not of the request, so it now sits with the\nother `422`s.\n\n`429` here has **two** ceilings behind it, as on endpoint 1a. The surface-wide\nper-seller rate limit applies as everywhere else, and issuing carries an\n*extra* per-seller quota of its own — **500 issue requests per rolling 24 hours** (refusals and replays count too) by\ndefault. It is far larger than the contract create's on purpose: issuing\nallocates a gapless number from *your own* `AGREEMENT-YYMM` series\n(the `AAA…ZZZ` order suffix within it), so a runaway integration burns a month\nof your numbers rather than a namespace shared with other sellers. Both answer\n`429` with `Retry-After` and the `X-RateLimit-*` headers.\n\nThe quota fails closed — **`503`** with `Retry-After` while its limiter is\nunreachable, and nothing was issued. See [Errors → rate limiting](/billing-api/v1/docs/errors#when-the-write-quotas-fail-closed).\n\n---\n\n## 6. `GET /invoices/{invoiceId}`\n\nOne invoice, its lines, and the customer identity as recorded at issue.\n\n```bash\ncurl -s \"$FYNEX_API_BASE/invoices/4180\" -H \"Authorization: Bearer $FYNEX_API_KEY\"\n```\n\n```json\n{\n  \"invoice\": {\n    \"id\": 4180,\n    \"customerName\": \"Acme GmbH\",\n    \"customerEmail\": \"ap@acme.example\",\n    \"invoiceNumber\": \"UK2607AA-2608AAB\",\n    \"status\": \"sent\",\n    \"grandTotalMinor\": 12000,\n    \"collectibleMinor\": 12000,\n    \"creditSettledMinor\": 0,\n    \"outstandingMinor\": 12000,\n    \"paidVia\": \"\",\n    \"…\": \"every field from the list shape\"\n  },\n  \"lines\": [\n    {\n      \"position\": 1,\n      \"description\": \"API calls over allowance\",\n      \"quantity\": \"250\",\n      \"unitPriceMinor\": 40,\n      \"discountMinor\": 0,\n      \"netAmountMinor\": 10000,\n      \"taxCategory\": \"standard\",\n      \"taxRate\": \"20\",\n      \"taxAmountMinor\": 2000,\n      \"sourceRef\": \"usage:api_calls:2026-07\"\n    }\n  ]\n}\n```\n\n### The four money fields, precisely\n\n| Field | Is | Is **not** |\n| --- | --- | --- |\n| `grandTotalMinor` | The legal document total | |\n| `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. |\n| `creditSettledMinor` | The part discharged by stored credit | A payment total. A card-paid invoice reports `0`. |\n| `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`. |\n\n\"Has this been paid?\" is answered by `status` (and `paidVia` for how), never\nby arithmetic on the amounts.\n\n`collectibleMinor` and `outstandingMinor` agree on a document nothing has\nhappened to. They diverge the moment one does: credit-note a `12000` invoice by\n`5000` and `collectibleMinor` still reads `12000` while `outstandingMinor` reads\n`7000`. Ageing `collectibleMinor` over-states receivables.\n\n`customerName` / `customerEmail` are who the document was addressed to **at\nissue** — a customer renaming themselves later does not rewrite an issued\nlegal record. Empty when the payload carries no buyer. (The contract list's\n`customerName` is different: it is the currently agreed value on the\ncontract, not a frozen document field.)\n\n### Correction and tax fields\n\nPresent only when they apply, so treat every one as optional.\n\n| Field | Meaning |\n| --- | --- |\n| `originalInvoiceNumber` | On a credit note: the invoice number it corrects. Find corrections the other way round with `?corrects=`. |\n| `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. |\n| `writtenOffAt` / `writeOffReason` | Collection was abandoned. Kept separate from the void fields so the two corrections stay distinguishable. |\n| `dateOfSupply` | The tax point, when it differs from `issueDate`. |\n| `reverseCharge` | True when the reverse-charge mechanism applies — the buyer accounts for the VAT. |\n| `exemptionReason` | Exemption code, when the document is exempt. |\n| `legalNotice` | Jurisdiction-mandated text printed on the document. |\n| `paidVia` | How a paid document settled: `payment_link`, `bank_transfer`, or `credit` (stored credit covered it in full). |\n| `paymentLinkId` | The collection link, once sent. Use the URL from `POST …/send`, not this id. |\n\nOn a line, `taxable` is the US-jurisdiction flag; UK/EU documents use\n`taxCategory` and `taxRate` instead.\n\n**Failures:** `400` non-numeric id · `404` unknown or foreign invoice.\n\n---\n\n## 7. `GET /invoices/{invoiceId}/pdf`\n\nThe rendered document, `application/pdf`, with a `Content-Disposition`\nfilename derived from the invoice number.\n\n```bash\ncurl -s \"$FYNEX_API_BASE/invoices/4180/pdf\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\" \\\n  -o UK2607AA-2608AAB.pdf\n```\n\nRendered from the frozen payload, so the same invoice produces the same\ndocument today and next year. Every issued document renders — including\nvoided ones and credit notes, which are part of the audit trail.\n\nRendering is the most expensive operation here; when bulk-downloading, pace\nthe loop (see Errors → rate limiting).\n\n**Failures:** `400` · `404` · `500` if the stored payload no longer validates\n(a server-side integrity fault; retrying will not fix it).\n\n---\n\n## 8. `POST /invoices/{invoiceId}/send`\n\nMaterializes the collection instrument: creates the invoice's hosted payment\nlink and returns its URL.\n\n\u003e **This emails your customer.** Whenever the document carries a buyer email,\n\u003e the first call delivers the payment invitation. There is no per-request\n\u003e suppression in v1, so do not call it merely to obtain a URL for internal\n\u003e use — the first call is customer-facing and cannot be undone.\n\n```bash\ncurl -s -X POST \"$FYNEX_API_BASE/invoices/4180/send\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\"\n```\n\n```json\n{\n  \"invoice\": { \"id\": 4180, \"status\": \"sent\", \"paymentLinkId\": 991, \"…\": \"…\" },\n  \"paymentLinkUrl\": \"https://pay.example-host/p/7bc1f2a9e4d5\"\n}\n```\n\n**Repeat calls are safe, including concurrently, and take no idempotency\nheader.** The invoice id is itself the collection anchor: a unique\npayment-link association makes concurrent calls converge on one link.\nRepeating a successful call returns that same link and does not send another\nemail.\n\nUse the returned URL — never construct one. The hosted host is per-deployment\nconfiguration.\n\n`422` cases, none retryable without a state change:\n\n| Message | Meaning |\n| --- | --- |\n| `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. |\n| `invoice has nothing left to collect` | Zero total, or stored credit covers it. |\n| `invoice is not in a sendable state` | `draft`, or already `paid`, `voided` or `written_off`. A settled invoice never hands out a payment link. |\n| `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. |\n\n**Failures:** `400` · `404` · `422` (above) · `401` / `403`.\n\n---\n\n## 9. `GET /subscriptions`\n\n| Parameter | In | Default | Notes |\n| --- | --- | --- | --- |\n| `contractId` | query | — | One contract. |\n| `cursor` | query | — | Keyset cursor, newest first: rows with an id **below** this value. Pass back the previous page's `nextCursor`. |\n| `beforeId` | query | — | The original name of `cursor` on this list. Still accepted and identical; sending both with different values is a `400`. |\n| `limit` | query | `20` | 1–100. |\n\n```bash\ncurl -s \"$FYNEX_API_BASE/subscriptions?contractId=42\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\"\n```\n\n```json\n{\n  \"subscriptions\": [\n    {\n      \"id\": 310,\n      \"contractId\": 42,\n      \"status\": \"active\",\n      \"billingFrequency\": \"monthly\",\n      \"priceMinor\": 9900,\n      \"currency\": \"EUR\",\n      \"anchorDate\": \"2026-01-01T00:00:00Z\",\n      \"startDate\": \"2026-01-01T00:00:00Z\",\n      \"currentPeriodStart\": \"2026-08-01T00:00:00Z\",\n      \"currentPeriodEnd\": \"2026-08-31T00:00:00Z\",\n      \"autoRenew\": true,\n      \"prorationPolicy\": \"by_day\",\n      \"noticePeriodDays\": 0,\n      \"trialRequiresPaymentMethod\": false,\n      \"createdAt\": \"2026-01-01T09:00:00Z\",\n      \"updatedAt\": \"2026-08-01T00:05:00Z\"\n    }\n  ],\n  \"hasMore\": false\n}\n```\n\n---\n\n## 10. `GET /subscriptions/{subscriptionId}`\n\nSame shape, one record. `subscriptionId` is the path parameter; ids come from\nendpoint 9.\n\n### Cadence fields\n\n`billingFrequency` is one of `daily`, `weekly`, `bi_weekly`, `monthly`,\n`quarterly`, `semi_annual`, `annual` or `custom`. The annual cadence is named\n`annual` — there is no yearly value, so a branch written against one never\nfires. On a custom cadence, `customUnit` (`day`, `week`, `month`, `year`) and\n`customEvery` (the multiplier) describe the repeat: `customUnit: \"week\"` with\n`customEvery: 2` bills fortnightly. Both are absent on the named frequencies.\n\n### Reading a subscription correctly\n\n| Question | Read |\n| --- | --- |\n| What are they paying? | `priceMinor` + `currency` + `billingFrequency`. There is no separate plan object — those three fields *are* the plan. |\n| Is a change coming? | `pendingPriceMinor` / `pendingPriceChangeAt`. Showing only `priceMinor` misinforms a customer who already requested a downgrade. |\n| Are they leaving? | `cancelRequestedAt` / `cancelEffectiveAt`. Service continues until the effective date, so `status` alone is not the answer. |\n| 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. |\n| Did a charge fail? | `status: \"past_due\"`. **Nothing retries it automatically** — this is a queue for you to work. |\n| Paused? | `pausedAt`, with `pauseEndsAt` for a scheduled auto-resume. |\n| 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. |\n\nStates: `trial` → `active` ⇄ `past_due` / `paused` → `canceled` | `expired`.\n\n**Failures:** `400` · `404`.\n\n---\n\n## 10a. `POST /contracts/{contractId}/subscriptions`\n\nCreates a subscription on a contract you own. `contractId` is the path\nparameter; the body is the plan and the term. **`Idempotency-Key` is a\nrequired header**: 1–128 characters from `A–Z a–z 0–9 _ . : -`, one per\nsubscription you intend to create — a UUID is the usual choice. Keys are\nscoped 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\nreturns the subscription the first call created and answers `200` instead of\n`201`; the body of a retry is not compared. A key replayed against a\n*different* contract answers `422` rather than returning the other contract's\nsubscription. Two concurrent first calls with one key produce one\nsubscription, which both callers receive.\n\n```bash\ncurl -s -X POST \"$FYNEX_API_BASE/contracts/42/subscriptions\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\" \\\n  -H \"Idempotency-Key: 9c1d0b3a-7e55-4f10-9f2b-6f2a1c1e6a1e\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"frequency\": \"monthly\",\n    \"priceMinor\": 9900,\n    \"currency\": \"EUR\",\n    \"startDate\": \"2026-10-01\",\n    \"trialEnd\": \"2026-10-15\",\n    \"trialEndBehavior\": \"convert\",\n    \"noticePeriodDays\": 30,\n    \"prorationPolicy\": \"by_day\"\n  }'\n```\n\n| Field | Notes |\n| --- | --- |\n| `frequency` | `daily`, `weekly`, `bi_weekly`, `monthly`, `quarterly`, `semi_annual`, `annual` or `custom`. `custom` needs `customUnit` (`day`/`week`/`month`/`year`) and `customEvery`. |\n| `priceMinor`, `currency` | The plan: one full period's charge in minor units (1 to 10^12), in the contract's currency. Immutable currency. |\n| `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. |\n| `trialEnd`, `trialEndBehavior`, `trialRequiresPaymentMethod` | A future `trialEnd` starts the subscription in `trial`; `convert` (default) or `cancel` decides how it ends. |\n| `autoRenew`, `endDate` | `autoRenew` defaults to `true`. `false` fixes the term and requires `endDate`; `endDate` with `autoRenew` true is refused. |\n| `noticePeriodDays` | Notice a cancellation requires, 0–365; `0` cancels at the current term's end. |\n| `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. |\n\nAnswers the same `Subscription` shape as endpoint 10. **Failures:** `400` (input\noutside the ranges above, a `currency` other than the contract's, missing or\nmalformed `Idempotency-Key`) · `404` (contract is not yours) · `422`\n(`Idempotency-Key` already used on another contract) · `429` · `401` / `403`.\n\n## 10b. Lifecycle actions\n\nAll `POST`, all on `/subscriptions/{subscriptionId}/…`, all returning the\nupdated subscription. A request the current state cannot take answers `422`\nwith the reason — pausing a trial, resuming an active subscription,\ncancelling twice — and so does a lost race against the lifecycle pass\n(`subscription changed concurrently; re-read it and retry`). `404` means the\nsubscription is not yours.\n\n| Path | Body | From | Effect |\n| --- | --- | --- | --- |\n| `/subscriptions/{subscriptionId}/cancel` | — | `trial`, `active`, `past_due`, `paused` | `cancelRequestedAt` set; serves until `cancelEffectiveAt` (notice) or term end. Terminal. |\n| `/subscriptions/{subscriptionId}/pause` | `{\"pauseUntil\": \"YYYY-MM-DD\"}` optional | `active` | Billing suspended; `pauseEndsAt` when a date was given. Beyond the seller's pause policy → `422`. |\n| `/subscriptions/{subscriptionId}/resume` | — | `paused` | New term from today; the pause is not billed. |\n| `/subscriptions/{subscriptionId}/end-trial` | — | `trial` | Converts now, per `trialEndBehavior` and `trialRequiresPaymentMethod`. |\n| `/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`. |\n\nMarking a subscription past-due or recovered has no public route: that is the\ncollection loop's verdict, not the integrator's.\n\n---\n\n## 11–13. The metered-usage write surface\n\nRegistering a metric (`name`, `unit`, `aggregation`), pricing it on a\ncontract (`contractId`, `metricName` path parameters; the config carries the\nscheme, tiers, allotment and commitment), and reporting events\n(`POST /usage/events`, one event; `POST /usage/events:batch`, many;\n`quantity` as a decimal string, `occurredAt`, `idempotencyKey`) are\ndocumented end to end in the **Usage ingestion** guide — parameters, the four\npricing schemes, the `metered: false` trap, and the failure table. The\noperations live in this spec; the guide is their reference.\n\n## 14. `POST /prices/evaluate`\n\nPreviews what a pricing configuration charges — through the same engine that\nbills, so the number matches the invoice to the cent. Pure: nothing is\nstored, no contract is referenced, and the config in the request is the same\ndocument `PUT /usage/contracts/{contractId}/metrics/{metricName}/price`\nstores.\n\n```bash\ncurl -s -X POST \"$FYNEX_API_BASE/prices/evaluate\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"config\": {\n      \"scheme\": \"graduated\", \"currency\": \"EUR\", \"rounding\": \"half_up\",\n      \"unitPrice\": \"0\",\n      \"tiers\": [\n        {\"upTo\": \"10000\", \"unitPrice\": \"0.01\", \"flatAmount\": \"0\"},\n        {\"upTo\": null,    \"unitPrice\": \"0.005\", \"flatAmount\": \"0\"}\n      ]\n    },\n    \"quantities\": [\"12000\"]\n  }'\n```\n\n```json\n{\n  \"results\": [{\n    \"quantity\": \"12000\",\n    \"amountMinor\": 11000,\n    \"ratedAmountMinor\": 11000,\n    \"currency\": \"EUR\",\n    \"billableUnits\": \"12000\",\n    \"includedUnitsApplied\": \"0\",\n    \"carryOver\": \"0\",\n    \"usageCapApplied\": false,\n    \"spendCapApplied\": false,\n    \"minimumApplied\": false,\n    \"lines\": [\n      {\"tierIndex\": 0, \"units\": \"10000\", \"unitPrice\": \"0.01\", \"flatAmount\": \"0\", \"amount\": \"100\"},\n      {\"tierIndex\": 1, \"units\": \"2000\", \"unitPrice\": \"0.005\", \"flatAmount\": \"0\", \"amount\": \"10\"}\n    ]\n  }]\n}\n```\n\nSend several `quantities` (up to 100) to plot a price curve in one call;\n`carriedOverUnits` seeds a rollover carry so the preview shows an existing\ncontract's next period. `amountMinor` is the money after the usage cap, spend\ncap and minimum — the flags say which of them acted. Lines are exact decimals\nin **major** units and always sum to the rated amount.\n\n**Failures:** `400` — a malformed config or quantity, with the engine's own\nvalidation message · `401` · `403`.\n\n## 15. `POST /usage/csv`\n\nBulk intake for a periodic export. `multipart/form-data` with two parts.\n\n| Part | Notes |\n| --- | --- |\n| `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. |\n| `mapping` | A `CSVMapping` JSON object naming which column carries which field. |\n\n`quantityColumn` is required. Contract, metric and occurred-at may each come\nfrom a column (`contractIdColumn`, `metricColumn`, `occurredAtColumn`) **or**\nfrom a whole-file default (`defaultContractId`, `defaultMetric`,\n`defaultOccurredAt`) — one or the other, never both for the same field.\n`idempotencyKeyColumn` is optional.\n\n```json\n{\"defaultContractId\":42,\"defaultMetric\":\"api_calls\",\"quantityColumn\":\"calls\",\"occurredAtColumn\":\"day\"}\n```\n\n**Returns** `{\"ingested\":498,\"duplicates\":0,\"deadLettered\":2}`.\n\nA row the pipeline refuses is dead-lettered rather than failing the upload, so\na partial success is normal — **read `deadLettered` on every response.** A file\nthat answers `200` with rows in the queue is not a file that billed.\n\n**Failures:** `400` — not multipart, a mapping that is not exactly one\n`CSVMapping` object, or a file-level rejection · `401` · `403` · `413` — the\nfile is over the per-call limit; split it.\n\n---\n\n## 16. `GET /usage/dead-letters`\n\nEvents an intake refused, newest first, each with the reason and the payload as\nsubmitted. This is the queue to watch: a `2xx` from an intake does not mean\nevery row inside it was accepted.\n\n| Field | Notes |\n| --- | --- |\n| `id` | Use it to redrive or discard. |\n| `source` | Which intake produced it: `events`, `batch`, `csv`, `feed`, `webhook`. |\n| `reason` | Why it was refused, in the intake's words. Fix this before redriving. |\n| `status` | `pending`, `redriven` or `discarded`. Only `pending` can be acted on. |\n| `attempts` | How many redrives have been tried. |\n| `receivedAt` | RFC 3339. |\n| `payload` | The event as submitted, so it can be corrected and resent. |\n\n**Failures:** `401` · `403`.\n\n### `POST /usage/dead-letters/{deadLetterId}/redrive`\n\n`deadLetterId` is the `id` from the listing above. Resubmits the stored payload. Returns `{\"status\":\"ingested\",\"eventId\":91424}`,\nor `{\"status\":\"rejected\"}` when it fails again — the letter stays `pending`,\nits `reason` is updated and `attempts` increments. Fix the cause first; a\nredrive with the same problem fails the same way.\n\n**Failures:** `401` · `403` · `404` — unknown id, or one belonging to another\nseller.\n\n### `POST /usage/dead-letters/{deadLetterId}/discard`\n\n`deadLetterId` is the `id` from the listing above. Closes the letter without metering it, for a row that should never have been\nsent. Returns `{\"status\":\"discarded\"}`. **Not reversible** — the usage it\ndescribed will not be billed.\n\n**Failures:** `401` · `403` · `404`.\n\n---\n\n## 17. `POST /usage/webhook-endpoints`\n\nRegisters an endpoint your own system posts usage to, and mints the secret that\nsigns those deliveries.\n\n**Body:** `{\"description\":\"billing events → ERP staging\"}` — optional, your own\nlabel.\n\n**Returns** `201` with the endpoint, the `secret`, and a `secretNote` restating\nthe terms:\n\n```json\n{\"endpoint\":{\"id\":12,\"token\":\"whk_7f21c0\",\"status\":\"active\",\"createdAt\":\"2026-09-14T10:22:31Z\"},\n \"secret\":\"whsec_9c2f…\",\"secretNote\":\"Copy this secret now: …\"}\n```\n\n**The secret is in this response and nowhere else.** It is stored encrypted and\nno endpoint reads it back. Copy it now; if it is lost, rotate.\n\nThe `token` is the path segment your sender posts to —\n`POST /usage/v1/webhooks/{token}`. It is not a credential on its own: the HMAC\nsignature is what authenticates a delivery. That receiver stays on the\n`/usage/v1` prefix deliberately and is not part of this key-authed surface,\nbecause its signature covers the body and the body must be read before the\nsender is known.\n\n**Failures:** `400` · `401` · `403`.\n\n### `GET /usage/webhook-endpoints`\n\nEvery endpoint you registered, revoked ones included. Secrets are never in a\nlisting.\n\n**Failures:** `401` · `403`.\n\n### `POST /usage/webhook-endpoints/{endpointId}/rotate-secret`\n\n`endpointId` is the endpoint's `id`. Issues a new secret and returns it once, on the same display-once terms as\ncreation. **The old secret stops verifying immediately** — cut the sender over\nin the same change, or deliveries fail in between.\n\n**Failures:** `401` · `403` · `404`.\n\n### `POST /usage/webhook-endpoints/{endpointId}/revoke`\n\n`endpointId` is the endpoint's `id`. Closes the endpoint: deliveries to its token are refused from here on. Not\nreversible — register a new endpoint instead. The record stays in the listing\nso the history remains readable.\n\n**Failures:** `401` · `403` · `404`.\n\n---\n\n## 18. `GET /invoices/export`\n\nThe month-end pull: every invoice issued in the window, as one file, so\nreconciliation does not start with writing a paging loop.\n\n| Parameter | In | Notes |\n| --- | --- | --- |\n| `issuedFrom` | query | **Required.** Start of the period, inclusive (`YYYY-MM-DD` or RFC 3339). |\n| `issuedTo` | query | **Required.** End of the period, exclusive — adjacent months never double-count a document. |\n| `format` | query | `csv` (default) or `ndjson`. |\n| `contractId`, `origin`, `status`, `invoiceType` | query | The list's filters, same names, same validation. |\n\nBoth dates are required on purpose: there is no default window, because \"the\nfile I exported\" must never silently mean a window the server chose. An\ninverted or empty range is rejected.\n\n**CSV** carries one row per invoice under this header, in this order:\n\n```\nid,invoiceNumber,contractId,sellerCustomerId,origin,invoiceType,status,\njurisdiction,currency,issueDate,dueDate,dateOfSupply,subtotalMinor,\ntaxTotalMinor,grandTotalMinor,creditAppliedMinor,collectibleMinor,\ncreditSettledMinor,corrects,reverseCharge,paidVia,voidedAt,writtenOffAt,\noutstandingMinor\n```\n\n(One line in the file; wrapped here to fit.)\n\nMoney columns are integer **minor units** and their names say so\n(`grandTotalMinor` — `4999` is €49.99); do not divide by 100 in the\nspreadsheet without also keeping the raw column. Timestamps are RFC 3339 UTC;\n`corrects` names the invoice a credit note corrects, empty otherwise.\n\n`outstandingMinor` is the receivable — the column to age in an AR report,\nrather than `collectibleMinor`, which is frozen at issue. An **empty cell**\nmeans the adjustment ledger was unavailable for that row: the balance is\nunknown, and it is never written as `0`. The rest of the row is unaffected,\nso a ledger outage costs you that one column and not the reconciliation.\n\n**New columns are appended, never inserted.** Reconciliation files are loaded\nby pipelines that address columns positionally, so reordering an existing\nexport would silently shift every one of them. Read by name if you can; if you\nread by position, a later Fynex release may make the row longer but will not\nmove a column you already read.\n\n**NDJSON** carries one list-API invoice object per line — exactly the shape\n`GET /invoices` serves, so a pipeline that parses the list parses the export\nwith no new code.\n\nRows come newest first, the same order as the list. A window holding more\nthan 100,000 documents answers `400` with instructions to narrow the range —\nrefused rather than silently truncated, because a reconciliation file that\nlooks complete and is not is how a document goes missing from someone's\nbooks.\n\n**Failures:** `400` — a missing or inverted date range, an unknown filter\nvalue, or a window over the row cap · `401` · `403`.\n\n---\n\n## 19. `GET /invoices/{invoiceId}/settlement`\n\nThe join between billing and money movement: which payment attempts hit this\ninvoice's collection link, and which payouts carried them out. Billing and\nsplit payments live on one platform — this is the endpoint that finally\nconnects them.\n\n**Returns**, newest payment first:\n\n```json\n{\n  \"invoiceId\": 4180,\n  \"invoiceNumber\": \"UK2607AA-2608AAB\",\n  \"paidVia\": \"payment_link\",\n  \"paymentLinkId\": 512,\n  \"payments\": [{\n    \"paymentId\": 991,\n    \"status\": \"settled\",\n    \"amountMinor\": 12000,\n    \"currency\": \"EUR\",\n    \"createdAt\": \"2026-08-17T09:26:00Z\",\n    \"payouts\": [{\n      \"payoutId\": 55,\n      \"status\": \"completed\",\n      \"state\": \"finalized\",\n      \"attributedMinor\": 12000,\n      \"currency\": \"EUR\",\n      \"completedAt\": \"2026-08-20T06:00:00Z\"\n    }]\n  }]\n}\n```\n\nFailed payment attempts are reported with their status — \"the customer tried\ntwice and the second one settled\" is part of the story. A payment with an\nempty `payouts` array has settled but not yet been scheduled into a payout. A\npayment can be split across payouts; `attributedMinor` is each payout's slice,\nand the attributions sum to what left. `state` is the attribution's own\nlifecycle: `reserved` (selected for a payout), `finalized` (paid out) or\n`released` (returned to the pool).\n\nThree invoices legitimately have an empty trail, and the response carries a\n`note` saying which case it is rather than an ambiguous empty list: an invoice\nthat was **never sent** (no collection link exists), one settled by a\n**matched bank deposit** (the money arrived outside the payment-link rail),\nand one **discharged entirely by stored credit** (no cash moved at all).\n\nPayout and payment detail beyond the ids lives on the Payments API — this\nendpoint gives you the ids to look them up with.\n\n**Failures:** `400` — a malformed id · `401` · `403` · `404` — unknown id, or\nan invoice belonging to another seller.\n\n---\n\n## 20. `POST /customers`\n\nResolves one of your customers by e-mail, creating the record when the address\nis new. The returned `id` is the `sellerCustomerId` every contract, credit\ntop-up and invoice refers to.\n\n**There is no `Idempotency-Key` here, and that is the rule rather than an\nomission: the e-mail address is the key.** Find-or-create on (your seller\naccount, e-mail) is enforced in the database, so calling this twice with the\nsame address returns the same customer and creates nothing the second time —\nwhich is exactly the guarantee a client key would otherwise have to supply.\n\nBecause of that the answer is always `200`, never `201`. The directory\nresolves the address without reporting whether *this* call minted the record,\nand a `201` would be a guess. Read the response as \"this is your customer\",\nnot as \"this customer is new\".\n\n```bash\ncurl -s -X POST \"$FYNEX_API_BASE/customers\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"email\": \"ada@example.com\",\n    \"customerRef\": \"crm-8842\",\n    \"name\": \"Ada Lovelace\",\n    \"companyName\": \"Analytical Engines Ltd\",\n    \"currency\": \"EUR\",\n    \"customerType\": \"company\"\n  }'\n```\n\n```json\n{\n  \"id\": 7,\n  \"email\": \"ada@example.com\",\n  \"customerRef\": \"crm-8842\",\n  \"name\": \"Ada Lovelace\",\n  \"companyName\": \"Analytical Engines Ltd\",\n  \"currency\": \"EUR\",\n  \"customerType\": \"company\",\n  \"createdAt\": \"2026-09-01T10:15:00Z\"\n}\n```\n\n| Field | Notes |\n| --- | --- |\n| `email` | Required, and the key this call resolves on. A plain address — `Ada \u003cada@example.com\u003e` is `400`. |\n| `customerRef` | Your own identifier, at most 128 characters. A ref already held by a **different** customer of yours answers `422`. |\n| `name`, `phone`, `companyName`, `companyCountry` | Optional contact details, at most 64 / 32 / 255 / 56 characters. |\n| `currency` | The customer's default billing currency (ISO 4217). A contract's own currency still wins. |\n| `customerType` | `individual` or `company`; omit when you do not know. |\n\nDetails are applied **fill-if-empty**: a value another surface already\nrecorded — checkout, or an earlier call — is never overwritten, and an omitted\nfield changes nothing. To correct a stored detail, use the dashboard.\n\n**Failures:** `400` (missing or malformed `email`, an over-length field, an\nunsupported `currency`, an unknown `customerType`) · `422` (`customerRef`\nalready belongs to a different customer) · `429` · `401` / `403`.\n\n## 21. `GET /contracts/{contractId}/invoices/upcoming`\n\nWhat the contract's next document would carry, projected through the **same\nengines that bill it**: the subscription terms that fall due, the unbilled\nmid-term amendments the engine would bind to them, each open metered period\nrated by the engine `POST /prices/evaluate` answers with, a per-seat period\nrated from the contract's seat schedule, and the stored credit that would be\ndrawn down.\n\nUntil this existed the answer had to be assembled in the client from four\nreads and its own arithmetic — the one implementation that is not\nauthoritative, which is the mistake `POST /prices/evaluate` was introduced to\nend for a single price. This is the same argument for a whole document.\n\n**Nothing is written.** No amendment pool is emptied, no credit lot is minted,\nno claim is taken: it is the read-only twin of the issuance pass.\n\n| Parameter | In | Default | Notes |\n| --- | --- | --- | --- |\n| `contractId` | path | — | The contract to project. |\n| `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\". |\n\n```bash\ncurl -s \"$FYNEX_API_BASE/contracts/42/invoices/upcoming\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\"\n```\n\n```json\n{\n  \"contractId\": 42,\n  \"asOf\": \"2026-09-20T12:00:00Z\",\n  \"nextIssueDate\": \"2026-10-01\",\n  \"currency\": \"EUR\",\n  \"lines\": [\n    {\n      \"source\": \"subscription\",\n      \"description\": \"Subscription — 2026-09-01 to 2026-10-01\",\n      \"quantity\": \"1\",\n      \"unitPriceMinor\": 10000,\n      \"amountMinor\": 10000,\n      \"periodStart\": \"2026-09-01\",\n      \"periodEnd\": \"2026-10-01\"\n    },\n    {\n      \"source\": \"usage\",\n      \"description\": \"api.requests usage — 2026-09-01 to 2026-10-01\",\n      \"quantity\": \"12000\",\n      \"amountMinor\": 4800,\n      \"periodStart\": \"2026-09-01\",\n      \"periodEnd\": \"2026-10-01\",\n      \"note\": \"the period is still open, so the quantity keeps accruing until it closes\"\n    }\n  ],\n  \"subtotalMinor\": 14800,\n  \"grandTotalMinor\": 14800,\n  \"creditAvailableMinor\": 5000,\n  \"creditAppliedEstimateMinor\": 5000,\n  \"collectibleEstimateMinor\": 9800,\n  \"notes\": [\n    \"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\",\n    \"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\"\n  ],\n  \"estimate\": true\n}\n```\n\n| Field | Notes |\n| --- | --- |\n| `asOf` | The instant projected. Metered usage accrues after it. |\n| `nextIssueDate` | The earliest period end that falls due — the day the next document would appear. Absent when nothing is due. |\n| `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. |\n| `source` | `subscription`, `proration`, `usage` or `seat` — which engine produced the line. |\n| `quantity` | Decimal string: `1` for a recurring term, the period's aggregated quantity for usage, the period-weighted seat count for seats. |\n| `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. |\n| `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. |\n| `periodStart`, `periodEnd` | The span the line covers. |\n| `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. |\n| `subtotalMinor` | Sum of the included lines, pre-tax. |\n| `grandTotalMinor` | The projected document total. It **equals** `subtotalMinor`: tax is not projected (see below). |\n| `creditAvailableMinor` | The contract's drawable stored value in this currency. Zero while the credits capability is off. |\n| `creditAppliedEstimateMinor` | What the drawdown would consume: the available balance bounded by the document total. |\n| `collectibleEstimateMinor` | What a payment rail would be asked for after credit — the figure your buyer would see. |\n| `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. |\n| `estimate` | Always `true`. |\n\n**Every figure is an estimate, and the response says so.** Metered usage keeps\naccruing after `asOf`; the period close resolves a rollover carry this\nprojection assumes is zero; an unbilled amendment can still move before the\nrecurring pass binds it. Reconcile against the issued document, never the\nother way round.\n\n**Amounts are pre-tax, deliberately.** The recurring lane does not compute tax\neither — it **copies** the treatment (category, rate, taxable flag) from a\nprior invoice on the same contract when it issues, and resolving that needs\nthe document history this read does not touch. Guessing a rate would put a\nnumber on your screen no jurisdiction agreed to, so no tax is projected and\n`grandTotalMinor` equals `subtotalMinor`. Read an issued document's tax from\n`GET /invoices/{invoiceId}`.\n\n**Failures:** `400` (a malformed `contractId` or `at`) · `404` (no such\ncontract, or not yours — the two are deliberately the same answer) · `429` ·\n`401` / `403` · `501` when the projection is not enabled on the deployment.\n\n## 22. `GET /contracts/{contractId}/test-clock` and `POST /contracts/{contractId}/test-clock/advance`\n\n**Test-mode (demo) accounts only.** A live account answers `403` on both: a\nlive subscription's renewal is real revenue and a real document sent to a real\ncustomer, and no key may simulate that.\n\nSubscription flows run on billing **dates**. A sandbox that makes you wait 31\nreal days for the first renewal is not a sandbox, so a contract can be given a\nclock of its own and moved forward.\n\n### `GET …/test-clock`\n\n```bash\ncurl -s \"$FYNEX_API_BASE/contracts/42/test-clock\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\"\n```\n\n```json\n{\n  \"contractId\": 42,\n  \"now\": \"2026-02-01T00:00:00Z\",\n  \"simulated\": true,\n  \"frozenAt\": \"2026-01-01T09:15:00Z\",\n  \"updatedAt\": \"2026-01-01T09:16:12Z\"\n}\n```\n\n| Field | Notes |\n| --- | --- |\n| `contractId` | The contract this clock belongs to. Clocks are **per contract**: advancing one never moves another, yours or anyone else's. |\n| `now` | What the billing engines treat as *now* for this contract. |\n| `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. |\n| `frozenAt` | Real time when the clock was created; absent while `simulated` is `false`. With `now` it says how far the contract has been pushed. |\n| `updatedAt` | When the clock last moved; absent while `simulated` is `false`. |\n\n### `POST …/test-clock/advance`\n\n```bash\ncurl -s -X POST \"$FYNEX_API_BASE/contracts/42/test-clock/advance\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"to\": \"2026-02-01T00:00:00Z\"}'\n```\n\n```json\n{\n  \"clock\": {\n    \"contractId\": 42,\n    \"now\": \"2026-02-01T00:00:00Z\",\n    \"simulated\": true,\n    \"frozenAt\": \"2026-01-01T09:15:00Z\",\n    \"updatedAt\": \"2026-01-01T09:16:12Z\"\n  },\n  \"advanced\": true,\n  \"lifecycle\": {\n    \"trialsActivated\": 0,\n    \"trialsCanceled\": 0,\n    \"trialsPastDue\": 0,\n    \"renewed\": 1,\n    \"resumed\": 0,\n    \"canceled\": 0,\n    \"expired\": 0,\n    \"skipped\": 0,\n    \"cronDisabled\": false\n  },\n  \"invoices\": {\n    \"issued\": 1,\n    \"sent\": 1,\n    \"skipped\": 0,\n    \"failed\": 0,\n    \"passDisabled\": false\n  },\n  \"notAdvanced\": [\n    \"usage period close (billing.usage.close_pass)\",\n    \"usage invoicing (billing.usage.invoice_pass)\",\n    \"dunning e-mail for a past-due subscription\",\n    \"off-session auto-charge of an issued invoice (billing.invoice.auto_charge)\"\n  ]\n}\n```\n\n`to` is an RFC 3339 instant. It must be **after** the contract's current clock\n— read it from `GET …/test-clock` — and at most **366 days** after it.\n\n**Re-sending the instant the clock already sits at is a no-op**: `200` with\n`advanced: false`, nothing renewed and nothing issued twice. That makes the\ncall safe to retry. A target in the past, or more than 366 days ahead, is\n`400`: a clock never runs backwards, because the documents it causes are\nimmutable and numbered.\n\nThe advance then runs, **for that one contract only**, the two engines the\nscheduled passes use:\n\n| Response block | What ran |\n| --- | --- |\n| `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. |\n| `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. |\n\n`lifecycle.cronDisabled` and `invoices.passDisabled` name an operator switch\nthat stopped half the loop — `billing.subscription.lifecycle_cron` and\n`billing.recurring.invoice_pass` respectively. If either is `true`, ask Fynex\nto enable the flag rather than debugging your integration; see\n[Sandbox \u0026 testing](/billing-api/v1/docs/sandbox).\n\n**`notAdvanced` is the honest part.** This clock moves the subscription state\nmachine and the recurring invoice. It does **not** close usage periods, mint\nusage invoices, send dunning e-mail, or run off-session auto-charge — those\nstay on real time in their own passes. Do not build a test that waits on one\nof them after an advance.\n\n**Failures:** `400` (a malformed `contractId`, a missing or unparseable `to`, a\ntarget at or before the current clock, or one more than 366 days ahead) ·\n`403` (a live-mode account, or an account whose mode cannot be confirmed) ·\n`404` (no such contract, or not yours — deliberately the same answer) · `429` ·\n`401` · `500`.\n\n---\n\n## Dashboard-only routes not on this surface\n\nThe platform's **cost model** (fynex-billing#277: `CostRateCard`, the cost\nengine and its dry run) is served on the dashboard surface `/billing/v1`\nonly — `POST|GET /billing/v1/cost-rate-cards`, `GET …/{id}`,\n`POST …/{id}/activate`, `POST /billing/v1/costs/evaluate` — behind the\nbackoffice `billing:read` / `billing:update` permissions. Nothing of it is\npublished here yet; the object's shape is still moving, and a public route is\na one-way door. Reference: `docs/billing-cost-model.md`.\n\n## Status codes, at a glance\n\n| Code | When |\n| --- | --- |\n| `200` | Success. Also a replayed write: the same `Idempotency-Key` again, or a customer that already existed. |\n| `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`). |\n| `400` | Malformed id, paging value, date, or an unrecognized `origin`/`status`/`invoiceType`. |\n| `401` | Missing or invalid key. Body may be plain text. |\n| `403` | Valid key, seller account not active. |\n| `404` | Unknown id — or one belonging to another seller. |\n| `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. |\n| `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. |\n| `500` | Server fault. Retry GETs with backoff. |\n| `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). |\n| `413` | The request body exceeds 1 MiB. |\n| `501` | Capability not enabled for this deployment (credits). |\n\nEvery error body is `{\"error\": \"…\"}` except the auth layer's, which may be\nplain text — treat any non-2xx as failed regardless of body shape.\n","name":"Endpoint reference"},{"description":"# Workflows\n\nEnd-to-end recipes for the things integrators actually build. Each one names\nthe endpoints, the order, and the failure modes worth handling.\n\n## Collect payment on an invoice\n\nInvoices are issued by the billing engine (subscription renewals, usage\nperiod close) or by your team in the dashboard. This API turns an issued\ninvoice into something a customer can pay.\n\n```bash\ncurl -s -X POST $FYNEX_API_BASE/invoices/4180/send \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\"\n```\n\n```json\n{\n  \"invoice\": { \"id\": 4180, \"status\": \"sent\", \"paymentLinkId\": 991, \"...\": \"...\" },\n  \"paymentLinkUrl\": \"https://pay.example-host/p/7bc1f2a9e4d5\"\n}\n```\n\nThe URL points at the Fynex hosted payment page for your deployment (the host is configured per environment, so do not hard-code it — always use the URL this response returns). Send it to your customer, embed it, or redirect to it. When the document is\nconfigured for email delivery, Fynex also emails the customer — calling this\nendpoint is what triggers that.\n\n**Retrying is safe, and needs no idempotency header.** The invoice's unique\npayment-link association is the durable safeguard: an invoice that already has\na link returns that same link rather than minting a second, even for\nsimultaneous calls, and does not re-send the email.\n\n**The payment carries the document.** Whichever rail collects an invoice — the\nhosted link or off-session auto-charge — the resulting payment is stamped with\nwhat it settled, so you never have to reconstruct it from an order reference:\n\n- The payment link's `metadata` holds `billingInvoiceId`, `invoiceNumber`,\n  `contractId`, `agreementNumber` and `sellerCustomerId`. The contract and its\n  agreement number reach the payments side nowhere else.\n- The payment row carries `billing_invoice_id`, and so does its checkout\n  session — a direct answer to \"which invoice did this payment collect\", with\n  no join through the link.\n- The acquirer's order description reads `Invoice \u003cnumber\u003e` (Paysafe\n  `description`, Solidgate `order_description`), which is what makes the\n  document visible in acquirer dashboards and dispute correspondence. The\n  merchant reference is deliberately unchanged: it is the duplicate-detection\n  and settlement-mapping key and stays machine-shaped.\n- `PaymentCompleted` carries `invoiceNumber` and `contractId` when the payment\n  collected an invoice, so it can be joined to `InvoicePaid` and to the\n  document itself.\n\nThree cases answer `422`, and they are not retryable — the state has to\nchange first:\n\n| Message | Meaning |\n| --- | --- |\n| `invoice is collected by bank transfer and cannot create a payment link` | The document instructs the buyer to wire money to a named account. A simultaneously payable card link would let a transfer and a card charge both succeed. |\n| `invoice has nothing left to collect` | Zero total, or stored credit already covers it. There is nothing to ask the customer for. |\n| `invoice is not in a sendable state` | Not issued yet (`draft`), or already `paid`, `voided` or `written_off`. A settled invoice never hands out a payment link. It is also the catch-all: if `status` reads `issued` and send still answers this, the document itself is faulty (a collection snapshot that is missing or unreadable) — nothing you send will fix it, so raise it with Fynex rather than retrying. |\n\n## Know when an invoice is paid\n\n`InvoicePaid` on the webhook pipe is the push signal (see **Webhooks**);\npolling remains right for reconciliation.\n\nDo not assume the settlement was something you or the buyer did. A seller can\nenable off-session collection (`billing.invoice.auto_charge`), and then a sent\nor overdue invoice is charged against the customer's saved card by a\nbackground loop — `paidVia` still reads `payment_link`, because it settles on\nthe same rail. Sellers can likewise have documents raised for them by the\nrecurring and usage invoice passes. Both are per-seller operator switches, off\nuntil turned on, and both mean `GET /invoices` can change with no call of\nyours: reconcile against the list for a period rather than against the\ndocuments you happen to have touched.\n\n```bash\ncurl -s $FYNEX_API_BASE/invoices/4180 \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\"\n```\n\nRead `invoice.status`:\n\n| Status | Meaning |\n| --- | --- |\n| `draft` | Not yet issued. Not a legal document; not collectible. |\n| `issued` | Numbered and legally real; no collection instrument yet. |\n| `sent` | A payment link exists; awaiting payment. |\n| `paid` | Settled. `paidVia` says how: `payment_link` (hosted checkout), `bank_transfer` (a matched deposit) or `credit` (stored credit covered it in full). |\n| `overdue` | Past its `dueDate` and still unpaid. |\n| `voided` | Cancelled before money moved; `voidedAt` and `voidReason` explain. |\n| `written_off` | Collection abandoned; `writtenOffAt` and `writeOffReason` explain. |\n\nTreat this list as open: a status you do not recognize should not crash your\nintegration. Branch on the ones you handle and fall through for the rest.\n\nPoll open invoices every few minutes at most; a fixed 15-minute sweep over\n`GET /invoices` is usually better than polling each document. Bank transfers\nsettle when the deposit is matched, which is hours-to-days — do not build a\ntight loop waiting for one.\n\nA credit note (`invoiceType: \"credit_note\"`) is a **separate document** that\ncorrects an earlier one, named in `originalInvoiceNumber`. It does not change\nthe original: to compute what a customer owes, sum the invoice and its credit\nnotes rather than expecting the original to shrink. Fetch them with\n`?corrects=\u003cinvoiceNumber\u003e`.\n\n## Reconcile an invoice against its payout\n\n\"The customer paid — where is the money?\" is one call per invoice:\n\n```bash\ncurl -s \"$FYNEX_API_BASE/invoices/4180/settlement\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\"\n```\n\nRead it in this order: each entry in `payments` is one attempt on the\ninvoice's collection link, failed ones included — the newest `settled` one is\nyour money. Inside it, each entry in `payouts` is a payout's claim on that\npayment: `state: \"finalized\"` with a `completedAt` means it left; `reserved`\nmeans it is scheduled; an empty `payouts` array means the payment settled but\nhas not been picked into a payout yet. `attributedMinor` is that payout's\nslice — a payment can be split across payouts, and the slices sum to what\nleft.\n\nAn invoice settled by bank transfer or stored credit has no link trail, and\nthe response's `note` says which case you are looking at — do not treat an\nempty `payments` list as an error. Payout detail beyond the id lives on the\nPayments API; this call gives you the id to look it up with.\n\n## Reconcile a period\n\n**For month-end, take the file.** One call returns every invoice of the\nwindow as CSV (or NDJSON — the list API's objects, one per line), with money\nin integer minor units and column names that say so. Both dates are required;\nthere is deliberately no default window:\n\n```bash\ncurl -s \"$FYNEX_API_BASE/invoices/export?issuedFrom=2026-08-01\u0026issuedTo=2026-09-01\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\" -o invoices_2026-08.csv\n```\n\nThe paged list below remains right when you are feeding an application rather\nthan closing books. Bound it by issue date rather than walking history until\nyou cross the boundary — the range is half-open (`issuedFrom` inclusive,\n`issuedTo` exclusive), so adjacent months never double-count a document:\n\n```bash\n# First page of August\ncurl -s \"$FYNEX_API_BASE/invoices?issuedFrom=2026-08-01\u0026issuedTo=2026-09-01\u0026limit=100\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\"\n\n# Next page: pass the previous response's nextCursor\ncurl -s \"$FYNEX_API_BASE/invoices?issuedFrom=2026-08-01\u0026issuedTo=2026-09-01\u0026limit=100\u0026cursor=4081\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\"\n```\n\nFilters worth combining:\n\n- `contractId=42` — one customer relationship.\n- `sellerCustomerId=7` — every document addressed to one of your customers,\n  across contracts. This is how you pull a customer statement without walking\n  the whole period.\n- `status=sent` — one lifecycle state (`draft`, `issued`, `sent`, `paid`,\n  `overdue`, `voided`, `written_off`).\n- `invoiceType=credit_note` — corrections only. The full set is `standard`,\n  `credit_note`, `simplified` (retail below the threshold) and `modified` (UK\n  retail above it, VAT-inclusive line prices).\n- `corrects=UK2607AA-2608AAB` — the credit notes issued against one invoice\n  number, without scanning history for them.\n- `origin=usage` — the subsystem that produced the document: `recurring`,\n  `usage`, `milestone`, `project`, `one_time`, `adhoc`, `marketplace`.\n  (`subscription` is a deprecated alias of `recurring`: still accepted, no\n  longer produced, and it resolves to the same set as `recurring` — see below.)\n\n`origin`, `status` and `invoiceType` reject an unrecognized value with `400`,\nso a typo cannot masquerade as \"no such invoices\". `contractId` and `corrects`\nare matched as given: a well-formed but wrong value legitimately returns an\nempty page, so check those two against `GET /contracts` and the invoice number\nyou meant.\n\n`origin=recurring` and `origin=subscription` return the same rows. The second\nis the pre-schema name, and documents issued before the rename still carry it\n— filtering either way includes both, so a monthly report cannot silently lose\nthe older half.\n\n`customerName` and `customerEmail` are returned on the single-invoice read and\non send, but **not** on list rows: they live in the document's frozen payload,\nand reading them for a whole page would mean parsing every document. To group\na report by customer, filter with `sellerCustomerId` rather than fetching each\ninvoice — that keeps a monthly reconciliation to a handful of requests instead\nof one per document.\n\nFor accounting, the figures per document are `grandTotalMinor` (what was\ninvoiced), `creditAppliedMinor` (funded by stored credit rather than cash),\nand `collectibleMinor` (the grand total less that credit — what collection\nasks the customer for).\n\nTwo traps worth stating plainly:\n\n- **`creditSettledMinor` is not a payment total.** It counts only the stored\n  credit drawn down; a card- or transfer-paid invoice reports `0` there.\n- **`collectibleMinor` does not drop to zero when an invoice is paid.** It\n  describes the ask, not the outcome.\n\nSo \"has this been paid?\" is answered by `status` (and `paidVia` for how), never\nby arithmetic on the amounts.\n\nBecause paging is keyset, not offset, documents issued while you are walking\nthe pages never shift rows into or out of a page you already fetched. Start a\nfresh walk to pick them up.\n\n## Watch usage against plan limits\n\nFor metered contracts, the snapshot answers \"where is this customer in the\ncurrent period\":\n\n```bash\ncurl -s $FYNEX_API_BASE/contracts/42/usage \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\"\n```\n\n```json\n{\n  \"contractId\": 42,\n  \"metrics\": [\n    {\n      \"metricName\": \"api_calls\",\n      \"used\": \"10250\",\n      \"includedUnits\": \"10000\",\n      \"capQuantity\": \"50000\",\n      \"capMode\": \"hard\",\n      \"percentOfCap\": \"20.5\",\n      \"percentOfPlan\": \"102.5\",\n      \"periodStart\": \"2026-08-01\",\n      \"periodEnd\": \"2026-08-31\"\n    }\n  ]\n}\n```\n\nUseful signals:\n\n- `percentOfPlan` above 100 means the customer is into overage — that is what\n  will be rated onto the next usage invoice.\n- `capQuantity` with `capMode: \"hard\"` is the ceiling enforcement uses. Warn\n  customers well before `percentOfCap` reaches 100 rather than at it.\n- Quantities are decimal strings; compare with a decimal type.\n\nAn unknown `contractId` answers `404`. A real contract with no metered usage\nanswers `200` with an empty `metrics` array — that is a valid state, not an\nerror.\n\nUsage aggregates continuously and closes at period end, so a snapshot is a\nlive read, not a final invoice figure. Reconcile money against invoices.\n\n## Reconcile stored credit\n\nBalances across all contracts:\n\n```bash\ncurl -s $FYNEX_API_BASE/credits \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\"\n```\n\n```json\n{\n  \"balances\": [\n    {\"currency\": \"EUR\", \"creditType\": \"purchased\", \"balanceMinor\": 250000, \"isLiability\": true}\n  ]\n}\n```\n\n`isLiability: true` marks credit that was **paid for** — `purchased`,\n`enterprise` and the engine-minted `proration` (unserved time returned on a\nmid-term downgrade) — which is unearned revenue you still owe service against.\nEverything you granted (`promotional`, `manual`, `gift`, `ai_token`,\n`marketplace`) is not a liability. Finance wants these separated, so do not\nsum them blindly. Paid-for credit also never expires; granted credit may.\n\nOne contract's balances plus its ledger:\n\n```bash\ncurl -s \"$FYNEX_API_BASE/contracts/42/credits?limit=50\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\"\n```\n\nThe ledger is append-only and newest-first. `kind` is `topup` (credit granted\nor bought), `deduction` (consumed into an invoice) or `expiry` (a lapsed lot\nremoved); `signedDeltaMinor` is positive for topups and negative for the other\ntwo, and `invoiceId` links a deduction to the invoice it funded. Corrections are new rows, never edits, so\nreplaying the ledger from the beginning always reproduces the current\nbalance.\n\nPage it the same way as invoices: `limit` sets the page, and while `hasMore`\nis true you pass `nextCursor` back as `cursor`. A long-lived contract's\nhistory exceeds one page, and a ledger you cannot read to the end cannot be\nreconciled.\n\n## See what the next invoice will carry\n\n`GET /contracts/{contractId}/invoices/upcoming` projects the contract's next\ndocument — before it exists, and without writing anything.\n\n```bash\ncurl -s \"$FYNEX_API_BASE/contracts/42/invoices/upcoming\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\"\n```\n\nThe `lines` array is the document as it would be composed, each line tagged\nwith the engine that produced it: `subscription` (a term falling due, priced\nat a scheduled plan change when one takes effect by the term's end),\n`proration` (an unbilled mid-term amendment the engine would bind to that\nterm — a charge as an extra line, a credit as a discount on the recurring\none), `usage` (an open metered period rated by the same engine\n`POST /prices/evaluate` answers with) and `seat` (a per-seat period rated from\nthe contract's seat schedule, to date). `nextIssueDate` is the day the\ndocument would appear, and `collectibleEstimateMinor` is what your buyer would\nbe asked for after stored credit is drawn down.\n\n**Every figure is an estimate, and `estimate: true` says so on every\nresponse.** Metered usage keeps accruing after `asOf`; the period close\nresolves a rollover carry this projection assumes is zero; an amendment can\nstill move before the recurring pass binds it. Amounts are also **pre-tax**:\nthe engine copies a document's tax treatment from a prior invoice on the same\ncontract when it issues, so no tax is projected here and `grandTotalMinor`\nequals `subtotalMinor`. Read the `notes` array — it names every reason the\nfigures are estimates and every exclusion made (an unpriced metric, a\nforeign-currency line, a capability switched off), which is what stops a\nnumber in this response from being read as a commitment.\n\nUse it to show a customer what is coming, to check a plan change landed\nbefore the term bills, or to catch an unpriced metric while there is still\ntime to price it. Reconcile against the **issued** document\n(`GET /invoices/{invoiceId}`), never the other way round.\n\n## Track subscription state\n\n```bash\ncurl -s \"$FYNEX_API_BASE/subscriptions?contractId=42\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\"\n```\n\nTo answer \"is this customer currently being billed, and for how much\", read\n`status`, `priceMinor` + `currency`, and `billingFrequency`. There is no\nseparate plan object — those three fields *are* the plan.\n\nWatch for:\n\n- `pendingPriceMinor` / `pendingPriceChangeAt` — a scheduled change (usually a\n  downgrade) that takes effect at term end. Showing only `priceMinor` will\n  misinform a customer who already requested a change.\n- `cancelRequestedAt` / `cancelEffectiveAt` — cancellation with notice. The\n  subscription keeps serving until the effective date, so `status` alone is\n  not \"are they leaving\".\n- `trialEnd` with `status: \"trial\"` — not yet paying.\n- `status: \"past_due\"` — collection failed. **What happens next depends on a\n  switch the seller controls**, so do not hard-code either answer:\n  - With off-session collection enabled (`billing.invoice.auto_charge`), a\n    failed charge is retried on a bounded schedule — a few attempts, spaced\n    apart — and the subscription is moved to `past_due` only once that budget\n    is spent. It can come back on its own when a later payment succeeds, which\n    is what `BillingSubscriptionRecovered` announces. Do not treat `past_due`\n    as terminal and do not start your own retry loop against it; you would be\n    charging the same card twice.\n  - Without it, nothing retries: `past_due` is a queue for you to work, and\n    the way out is the customer paying the outstanding invoice or a new card\n    being added.\n\n  In both cases the events to watch are `BillingSubscriptionPastDue` and\n  `BillingSubscriptionRecovered`, and the invoice's `outstandingMinor` is what\n  says how much is actually still owed.\n- `pauseEndsAt` — an auto-resume date for a paused subscription.\n\nLifecycle changes (cancel, pause, plan change) are dashboard operations in\nthis version.\n\n## Retries and rate limits\n\nReads are safe to retry. So is `POST .../send`: the invoice can hold only one\npayment link, so a retry returns the existing one.\n\n- `429` — you exceeded the per-seller budget. Wait the `Retry-After` seconds;\n  do not retry sooner. `X-RateLimit-Remaining`, when present, lets you slow\n  down before you hit it — the limiter fails open during an outage, and then\n  no rate-limit headers are sent at all.\n- `5xx` — retry with exponential backoff and jitter.\n- `4xx` other than `429` — do not retry; the request has to change.\n\nA retry loop that ignores `Retry-After` turns one throttled request into a\nsustained overage. If you are paging a large history, a small delay between\npages is cheaper than being throttled mid-walk.\n","name":"Workflows"},{"description":"# Code examples\n\nWorking snippets for the two pieces every integration needs — walking pages\nand retrying correctly — plus a typed-client shortcut.\n\n## Generate a client from the spec\n\nThe fastest path is not to hand-write a client at all. The spec is standard\nOpenAPI 3.1:\n\n```bash\ncurl -s \"$FYNEX_API_BASE/openapi.json\" -o fynex-billing.json\n\n# TypeScript types\nnpx openapi-typescript fynex-billing.json -o fynex-billing.d.ts\n\n# Python / Go / Java / … via openapi-generator\nopenapi-generator generate -i fynex-billing.json -g python -o ./fynex-billing-client\n```\n\nGenerated models keep minor-unit amounts as integers and decimal quantities as\nstrings, which is what you want. If your generator maps `quantity` or\n`taxRate` to a float, override it to a decimal type.\n\n## Python: page through invoices\n\n```python\nimport os\nimport time\n\nimport requests\n\nBASE = os.environ[\"FYNEX_API_BASE\"]  # e.g. https://api.fynex.ai/billing-api/v1\nSESSION = requests.Session()\nSESSION.headers[\"Authorization\"] = f'Bearer {os.environ[\"FYNEX_API_KEY\"]}'\n\n\ndef request(method, path, **kwargs):\n    \"\"\"One request with the retry policy: honour Retry-After on 429,\n    back off on 5xx, never retry other 4xx.\"\"\"\n    delay = 1.0\n    for attempt in range(6):\n        response = SESSION.request(method, f\"{BASE}{path}\", timeout=30, **kwargs)\n\n        if response.status_code == 429:\n            # Retry-After is authoritative; retrying sooner just deepens the overage.\n            time.sleep(int(response.headers.get(\"Retry-After\", \"1\")))\n            continue\n        if response.status_code \u003e= 500:\n            time.sleep(delay)\n            delay *= 2\n            continue\n        if not response.ok:\n            # 400/401/403/404/422 — the request must change, so retrying is pointless.\n            raise RuntimeError(f\"{response.status_code}: {response.json().get('error')}\")\n\n        return response\n    raise RuntimeError(\"giving up after repeated throttling or server errors\")\n\n\ndef iter_invoices(**filters):\n    \"\"\"Yield every invoice, newest first, following the keyset cursor.\"\"\"\n    before_id = None\n    while True:\n        params = {\"limit\": 100, **filters}\n        if before_id:\n            params[\"cursor\"] = before_id\n\n        page = request(\"GET\", \"/invoices\", params=params).json()\n        for invoice in page[\"invoices\"]:\n            yield invoice\n\n        if not page[\"hasMore\"]:\n            return\n        before_id = page[\"nextCursor\"]\n\n\noutstanding = [\n    inv for inv in iter_invoices(origin=\"usage\")\n    if inv[\"status\"] in (\"issued\", \"sent\") and inv[\"collectibleMinor\"] \u003e 0\n]\ntotal_minor = sum(inv[\"collectibleMinor\"] for inv in outstanding)\nprint(f\"{len(outstanding)} open invoices, {total_minor / 100:.2f} outstanding\")\n```\n\nThe last line divides by 100 only because the example is EUR. Currencies have\ndifferent minor-unit scales — divide by the scale of the invoice's own\n`currency`, or better, keep the integer and format at the edge.\n\n## Node: collect on an invoice\n\n```javascript\nconst BASE = process.env.FYNEX_API_BASE; // https://api.fynex.ai/billing-api/v1\n\nasync function call(method, path, options = {}) {\n  for (let attempt = 0; attempt \u003c 6; attempt++) {\n    const response = await fetch(`${BASE}${path}`, {\n      method,\n      ...options,\n      headers: {\n        Authorization: `Bearer ${process.env.FYNEX_API_KEY}`,\n        ...options.headers,\n      },\n    });\n\n    if (response.status === 429) {\n      const wait = Number(response.headers.get(\"Retry-After\") ?? 1);\n      await new Promise((r) =\u003e setTimeout(r, wait * 1000));\n      continue;\n    }\n    if (response.status \u003e= 500) {\n      await new Promise((r) =\u003e setTimeout(r, 2 ** attempt * 1000));\n      continue;\n    }\n    const body = await response.json();\n    if (!response.ok) throw new Error(`${response.status}: ${body.error}`);\n    return body;\n  }\n  throw new Error(\"giving up after repeated throttling or server errors\");\n}\n\n// Safe to retry as-is: the invoice's durable link association makes repeated\n// and simultaneous calls converge on the same payment link.\nconst { invoice, paymentLinkUrl } = await call(\"POST\", `/invoices/${invoiceId}/send`);\nconsole.log(`Invoice ${invoice.invoiceNumber}: ${paymentLinkUrl}`);\n```\n\n## Go: usage against plan limits\n\n```go\ntype MetricUsage struct {\n\tMetricName    string `json:\"metricName\"`\n\tUsed          string `json:\"used\"`          // decimal string\n\tIncludedUnits string `json:\"includedUnits\"` // decimal string\n\tCapQuantity   string `json:\"capQuantity,omitempty\"`\n\tCapMode       string `json:\"capMode,omitempty\"`\n\tPercentOfPlan string `json:\"percentOfPlan,omitempty\"`\n\tPeriodStart   string `json:\"periodStart,omitempty\"`\n\tPeriodEnd     string `json:\"periodEnd,omitempty\"`\n}\n\ntype ContractUsage struct {\n\tContractID int64         `json:\"contractId\"`\n\tMetrics    []MetricUsage `json:\"metrics\"`\n}\n\nfunc overageMetrics(ctx context.Context, client *http.Client, baseURL, key string, contractID int64) ([]MetricUsage, error) {\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet,\n\t\tfmt.Sprintf(\"%s/contracts/%d/usage\", baseURL, contractID), nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treq.Header.Set(\"Authorization\", \"Bearer \"+key)\n\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer resp.Body.Close()\n\tif resp.StatusCode != http.StatusOK {\n\t\tvar apiErr struct {\n\t\t\tError string `json:\"error\"`\n\t\t}\n\t\t_ = json.NewDecoder(resp.Body).Decode(\u0026apiErr)\n\t\treturn nil, fmt.Errorf(\"usage snapshot: %d: %s\", resp.StatusCode, apiErr.Error)\n\t}\n\n\tvar usage ContractUsage\n\tif err := json.NewDecoder(resp.Body).Decode(\u0026usage); err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar over []MetricUsage\n\tfor _, m := range usage.Metrics {\n\t\t// Decimal, not float: these quantities carry fractional precision.\n\t\tpct, err := decimal.NewFromString(m.PercentOfPlan)\n\t\tif err != nil || m.PercentOfPlan == \"\" {\n\t\t\tcontinue // no plan allowance configured for this metric\n\t\t}\n\t\tif pct.GreaterThan(decimal.NewFromInt(100)) {\n\t\t\tover = append(over, m)\n\t\t}\n\t}\n\treturn over, nil\n}\n```\n\n## Shell: download every PDF for a month\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n\nBASE=\"${FYNEX_API_BASE:?set FYNEX_API_BASE}\"\nAUTH=\"Authorization: Bearer ${FYNEX_API_KEY:?set FYNEX_API_KEY}\"\ncursor=\"\"\n\nwhile :; do\n  url=\"$BASE/invoices?limit=100\"\n  [ -n \"$cursor\" ] \u0026\u0026 url=\"$url\u0026cursor=$cursor\"\n  page=\"$(curl -sf -H \"$AUTH\" \"$url\")\"\n\n  # Every issued document renders, including void ones and credit notes —\n  # they are part of the audit trail. Filter here if you only want live ones.\n  echo \"$page\" | jq -r '.invoices[] | \"\\(.id) \\(.invoiceNumber)\"' |\n    while read -r id number; do\n      curl -sf -H \"$AUTH\" \"$BASE/invoices/$id/pdf\" -o \"invoices/$number.pdf\"\n    done\n\n  [ \"$(echo \"$page\" | jq -r '.hasMore')\" = \"true\" ] || break\n  cursor=\"$(echo \"$page\" | jq -r '.nextCursor')\"\n  sleep 1  # stay clear of the per-seller rate limit while bulk-downloading\ndone\n```\n\n## Testing your integration\n\nPoint everything at a `sk_test_…` key while your account is still in demo\nmode. Going live is two steps, not one: Fynex switches the account to live\nmode, and you swap in an `sk_live_…` key. From that moment the test key no\nlonger authenticates (`401`), so make the key a configuration value rather\nthan a constant. The billing records themselves belong to the account — a\nlive key does not reveal a second, hidden dataset.\n\nWorth exercising before you ship:\n\n- A `404` path (a contract id that is not yours) — confirm you surface it as\n  \"not found\" rather than crashing on a missing field.\n- A `429` path — force it by looping requests, and confirm your client waits\n  for `Retry-After` instead of hot-looping.\n- A multi-page walk — seed more rows than your page size, or set `limit=1`,\n  and confirm you follow `nextCursor` to the end.\n","name":"Code examples"},{"description":"# Usage ingestion\n\nMetered billing has two halves. This API reads the meter — `GET\n/billing-api/v1/contracts/{contractId}/usage` returns where a contract stands\nin its open period. **Writing to that meter happens on a different prefix**,\n`/billing-api/v1/usage`, documented here.\n\nSame API, same credential: `Authorization: Bearer sk_test_…` / `sk_live_…`,\nsame seller tenancy, same `{\"error\": \"…\"}` envelope.\n\n\u003e **A second, older path exists.** Metering shipped before this API did and\n\u003e took the top-level prefix `/usage/v1`. Every route below still answers there\n\u003e too, unchanged — but `/billing-api/v1/usage/…` is the documented one, and new\n\u003e integrations should use it. The single exception is the inbound webhook\n\u003e receiver in §5, which stays on `/usage/v1` on purpose: it is authenticated by\n\u003e payload signature rather than by your key, and every operation under\n\u003e `/billing-api/v1` is key-gated.\n\nThe loop is: **register a metric → price it → send events → read the meter →\nthe period closes into an invoice.**\n\n\u003e **One convention differs here, and it matters.** Everything under\n\u003e `/billing-api/v1` reports money in integer minor units. A *price\n\u003e configuration* is the exception: unit prices and tier amounts are decimal\n\u003e strings in **major** units (`\"0.0001\"` is one hundredth of a cent per unit),\n\u003e because per-unit rates are routinely finer than a minor unit. The rate is\n\u003e exact; the rounding to minor units happens once, at invoicing, by the policy\n\u003e the config names. Amounts you read back on an invoice are minor units as\n\u003e usual.\n\n---\n\n## 1. Register a metric\n\nA metric is the meter's identity: what is counted, in what unit, and how\nseveral events in one period combine.\n\n```bash\ncurl -s -X POST $FYNEX_API_BASE/usage/metrics \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"name\":\"api_calls\",\"unit\":\"call\",\"aggregation\":\"sum\",\"description\":\"Billable API requests\"}'\n```\n\n```json\n{\n  \"name\": \"api_calls\",\n  \"version\": 1,\n  \"unit\": \"call\",\n  \"aggregation\": \"sum\",\n  \"description\": \"Billable API requests\"\n}\n```\n\n| Field | Rules |\n| --- | --- |\n| `name` | Registry identifier — this is what events reference. |\n| `unit` | Free text, at most 32 characters (`call`, `GB`, `token`, `seat`). |\n| `aggregation` | `sum`, `max`, `min`, `last`, `count` or `count_unique`. How the period's events collapse into one billable quantity. |\n| `uniqueKey` | `count_unique` only: the event-metadata key whose distinct values are counted — a user id, an endpoint, a tenant. Required there, forbidden elsewhere. |\n| `description` | Optional, at most 256 characters. |\n\nAnswers `201` with the registered metric, or `400` naming the field that was\nwrong.\n\nA `count_unique` metric answers \"how many *different* users called\", where\n`count` answers \"how many calls\". Its events **must** carry the `uniqueKey`\nfield in `metadata` (`400` otherwise), and at most **1,000 distinct values**\ncount per contract and period: past that, new values stop counting and the\nbalance is flagged as overflowed — the figure becomes a floor. If your\ncardinality is legitimately higher, meter with `count` and dimension in your\nown analytics instead. Metrics are versioned: `version` increments when a registration\nchanges the definition, and events are attributed to the version current when\nthey arrive. `uniqueKey` is part of that definition, like `unit` and\n`aggregation`: it cannot be edited in place — registering the same name again\nwith a different `uniqueKey` creates version N+1, and events keep being\nattributed to whichever version is in force when they arrive.\n\nThere is no time-weighted aggregation — GB-hours, active-seat-days and the\nlike. Compute the weighted figure on your side and send it as the event\nquantity of a `sum` metric; a `max` or `last` metric over a gauge reading is\nthe other honest shape.\n\n- `GET /billing-api/v1/usage/metrics` — every metric you have registered.\n- `GET /billing-api/v1/usage/metrics/{name}` — one metric, `404` when it is not registered.\n\n## 2. Price the metric on a contract\n\nPricing is per contract and per metric, and it is versioned by date — a new\nconfiguration does not rewrite history, it takes effect from a date you state.\n\n```bash\ncurl -s -X PUT \"$FYNEX_API_BASE/usage/contracts/42/metrics/api_calls/price\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"effectiveFrom\": \"2026-09-01\",\n    \"config\": {\n      \"scheme\": \"graduated\",\n      \"currency\": \"EUR\",\n      \"rounding\": \"half_up\",\n      \"unitPrice\": \"0\",\n      \"tiers\": [\n        {\"upTo\": \"10000\", \"unitPrice\": \"0.004\", \"flatAmount\": \"0\"},\n        {\"upTo\": null,    \"unitPrice\": \"0.002\", \"flatAmount\": \"0\"}\n      ],\n      \"allotment\": {\"includedUnits\": \"1000\", \"rollover\": \"none\"},\n      \"commitment\": {\"minimumMinor\": 5000, \"spendCapMinor\": 500000}\n    }\n  }'\n```\n\n### The seven pricing schemes\n\n| `scheme` | How the quantity is charged |\n| --- | --- |\n| `per_unit` | `units × unitPrice`. One rate, no bands. |\n| `graduated` | Every band charges its own rate for the units inside it — progressive, like income tax. |\n| `volume` | The band the **total** quantity lands in prices **all** units at that band's rate. |\n| `tiered` | Stairstep: the band the total lands in charges its `flatAmount`, whatever the exact count. |\n| `package` | Per started block: `ceil(quantity / packageSize) × unitPrice`, where `unitPrice` is the price of one package. A partial package charges in full — 1,001 units at \"per 1,000\" is two packages. |\n| `percentage` | `percentBps` basis points (100 = 1%) of the aggregated quantity, which for this scheme is itself a monetary amount in major units — a GMV or transaction-value meter. |\n| `per_seat` | `unitPrice` per seat per FULL period, prorated to the day: `unitPrice × seatDays ÷ periodDays`. Rated from a recorded seat history rather than from ingested events — see [Per-seat pricing](#per-seat-pricing) below. |\n\n`graduated` and `volume` are the classic trap — same bands, same quantity,\ndifferent money. Read the two rows above before choosing. There is no `flat`\nscheme on purpose: a fixed per-period fee is what a subscription price is,\nand a usage floor is `commitment.minimumMinor` on any scheme.\n\n### The rest of the configuration\n\n| Field | Meaning |\n| --- | --- |\n| `currency` | ISO 4217. Must match the contract's currency. |\n| `rounding` | Applied once, when the exact decimal amount collapses to minor units: `half_up`, `half_even`, `up` or `down`. No default — state it. |\n| `unitPrice` | The `per_unit` rate, the `package` scheme's price per package, and the `per_seat` scheme's price per seat per full period. Decimal string in major units; ignored by banded schemes. |\n| `packageSize` | The `package` scheme's block size in units, decimal string. Required there, forbidden elsewhere. |\n| `percentBps` | The `percentage` scheme's rate in integer basis points (100 = 1%). Required there, forbidden elsewhere. |\n| `tiers[].upTo` | The band's inclusive upper bound in units. `null` marks the last, unbounded band. Bands are ordered and contiguous from zero. |\n| `tiers[].unitPrice` | The rate for `graduated` and `volume`. |\n| `tiers[].flatAmount` | The flat charge for `tiered`. |\n| `allotment.includedUnits` | The first N units of each period rate to zero. |\n| `allotment.rollover` | `none`, or `rollover` to carry an unused allowance into the next period. |\n| `commitment.minimumMinor` | Floor: the period bills at least this much, in minor units. |\n| `commitment.spendCapMinor` | Ceiling on the money the period can bill, in minor units. |\n| `commitment.usageCap` | Ceiling on the billable quantity, as a decimal string. |\n\nAnswers `200` with the stored configuration and its `version`. `GET` on the\nsame path returns the current one, or `404` with `this metric has no price\nconfigured` — which is the check to run before you start sending events.\n\n\u003e A **usage limit policy** is a separate control from `commitment.usageCap`:\n\u003e it is what `GET /billing-api/v1/contracts/{contractId}/usage` reports as\n\u003e `capQuantity` / `capMode`, and a `hard` policy **rejects ingestion past the\n\u003e cap** (see the failure table below). A `soft` one only alerts.\n\n### Per-seat pricing\n\n`per_seat` prices what a customer HOLDS rather than what it consumes, so it is\nthe one scheme that is not rated from ingested events. It reads a **seat\nhistory**: an append-only record of how many seats the contract holds and from\nwhen, so a period with mid-cycle changes can be priced day by day.\n\nConfigure it against the reserved metric **`platform.seat.active`**:\n\n```bash\ncurl -s -X PUT \"$FYNEX_API_BASE/usage/contracts/42/metrics/platform.seat.active/price\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"effectiveFrom\": \"2026-01-01\",\n    \"config\": {\n      \"scheme\": \"per_seat\",\n      \"currency\": \"EUR\",\n      \"rounding\": \"half_up\",\n      \"unitPrice\": \"10.00\"\n    }\n  }'\n```\n\n**The metric and the scheme are reserved for each other.** `per_seat` is\nconfigurable **only** on `platform.seat.active`, and `platform.seat.active`\naccepts **only** `per_seat` — either mismatch answers `400`. The pairing is not\nhousekeeping: a `per_seat` price on an ordinary metric is a period the close\npass can never rate (it has a quantity, and this scheme has no use for one), so\nit would simply never bill; and any other scheme on the seat metric would rate\nyour seats from whatever quantity happened to be ingested against it rather\nthan from the recorded seat history.\n\n`unitPrice` is the price of ONE seat for a FULL period. `allotment`,\n`allotment.rollover` and `commitment.usageCap` are **refused** on this scheme —\nthey are denominated in \"units\", and the unit here is a day-weighted seat\nintegral rather than a consumption counter, so the same number would not mean\nthe same thing. `commitment.minimumMinor` and `commitment.spendCapMinor` work\nexactly as they do everywhere else.\n\n**How a period is priced.** Seats are integrated over **UTC calendar days**,\nand each day is charged at the seat count that day ENDS with — the count in\neffect after every seat change effective on that day or earlier:\n\n```\namount = unitPrice × seatDays ÷ periodDays\n```\n\nTwo consequences worth stating, because they are what everyone asks:\n\n- A seat added on a day is charged for that day.\n- A seat added **and** removed on the same day is charged for nothing. The day\n  is not charged once per change; it is charged once, at the count the day ends\n  with — so a same-day add and remove cannot double count.\n\n**Worked example.** A contract on €10.00 per seat, January 2026 (31 days). It\nopens the month with 3 seats, goes to 5 on the 11th, and back to 4 on the 21st:\n\n| Days | Seats | Seat-days |\n| --- | --- | --- |\n| Jan 1–10 (10) | 3 | 30 |\n| Jan 11–20 (10) | 5 | 50 |\n| Jan 21–31 (11) | 4 | 44 |\n| **Total** | | **124** |\n\n124 seat-days ÷ 31 days = **4 seat-equivalents**, so the period bills\n€40.00 — not €50.00 (the peak) and not €30.00 (the opening). Re-rating the\nsame history always produces the same amount; a correction is a new seat\nchange, never an edit of a recorded one.\n\n**How a per-seat period is billed.** Exactly like every other metric's, and on\nthe same schedule: when the period has ended, it closes into one usage invoice\nline and the invoicing lane puts that line on a document. Two things are worth\nknowing because they are visible on the artifacts:\n\n- The line's `quantity` is the period's **seat-days** — 124 in the example\n  above, not 4. Seat-days is the exact integer the amount is derived from;\n  the divisor (`periodDays`) and the seat-equivalent figure\n  (`seatEquivalents`) are in the line's `ratingBreakdown` beside the day-run\n  trace. The amount on the line is already the prorated money, so a document\n  reads €40.00 whatever the quantity column says.\n- A period with **no** seats recorded still closes, at zero — the same way a\n  metric with no events does, so a `commitment.minimumMinor` trues up on\n  silence rather than being skipped.\n\nBecause seats are not ingested, this lane is driven by the price document\nrather than by arriving events: a contract is billed for seats from the first\nperiod its per-seat price is effective for, on the contract's own billing\ncycle. Periods that ended before the price existed are never billed\nretroactively.\n\n**Recording seat changes is not on this API yet.** Seats are recorded and read\non the dashboard surface — `POST` and `GET /billing/v1/contracts/{contractId}/seats`\n— which needs a dashboard session, not a seller key. A seller-key route for\nseat changes is a later slice; publishing one is a one-way door and the wire\nshape of a seat event is exactly what would move. Until then, a `per_seat`\nprice you configure here is priced from the history recorded there, and\n`GET /billing/v1/contracts/{contractId}/seats?periodStart=…\u0026periodEnd=…`\nreturns the day-by-day breakdown above.\n\n### Try a price before you commit to it\n\n`POST /billing-api/v1/prices/evaluate` runs a configuration through the same\nengine that bills, at any quantities you name — pure, nothing stored, no\ncontract required. It is how to see what `graduated` and `volume` do to the\nsame bands before agreeing a term, and it is what the number shown to a\ncustomer during negotiation should come from.\n\n```bash\ncurl -s -X POST \"$FYNEX_API_BASE/prices/evaluate\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"config\": { …the same config… }, \"quantities\": [\"0\", \"5000\", \"12000\"]}'\n```\n\nEach result carries `amountMinor` (what the period would bill, after the caps\nand the minimum), the flags saying which of those limits acted, and the exact\nper-band `lines`. Up to 100 quantities per call, so a whole price curve is one\nrequest.\n\nA `per_seat` configuration answers `422` here: that scheme is rated from the\ncontract's seat schedule and is not evaluable per quantity — there is no\nquantity that expresses *when* the seats changed. Read the period's amount and\nits day-by-day breakdown from\n`GET /billing/v1/contracts/{contractId}/seats?periodStart=…\u0026periodEnd=…`\ninstead.\n\n## 3. Send events\n\n```bash\ncurl -s -X POST $FYNEX_API_BASE/usage/events \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"contractId\": \"42\",\n    \"metric\": \"api_calls\",\n    \"quantity\": \"1250\",\n    \"occurredAt\": \"2026-09-14T10:22:31Z\",\n    \"idempotencyKey\": \"req-2026-09-14-8f21\",\n    \"metadata\": {\"region\": \"eu-west-1\"}\n  }'\n```\n\n```json\n{ \"status\": \"ingested\", \"eventId\": 91422, \"metered\": true }\n```\n\n| Field | Rules |\n| --- | --- |\n| `contractId` | The contract the usage belongs to, as a string. |\n| `metric` | A registered metric name. |\n| `quantity` | Decimal **string** — a JSON number is a float, and metered quantities must not pass through one. |\n| `occurredAt` | RFC 3339. When the usage happened, not when you sent it: this is what places the event in a billing period. |\n| `idempotencyKey` | Your own key for this event. Resending the same key with the same payload is a no-op. |\n| `metadata` | Optional JSON object, carried for your own audit. |\n\n`201` when the event was recorded, `200` with `\"status\": \"duplicate\"` when the\nidempotency key was already seen. Retrying is always safe.\n\nTwo policies worth knowing before you design your keys:\n\n- **The dedup window is unbounded.** Keys are unique per seller for the\n  lifetime of the data — not a rolling window — so a key seen once is a\n  duplicate forever. Derive keys from the event's identity (source record id,\n  timestamp), never from a counter you might reset.\n- **Backdating has no lower bound; the future is refused.** `occurredAt` may\n  be arbitrarily far in the past — there is no backdating window to configure\n  or to fall outside of. The only temporal rejection is an `occurredAt` ahead\n  of now. Two edges worth knowing: an event older than the contract's first\n  billing period is stored but stays unassigned until aggregation picks it\n  up, and an event landing in a **closed** period follows the next rule down.\n- **A late event never rewrites a closed period.** Ingestion compares\n  `occurredAt` against the metric's close watermark: an event older than the\n  last closed period is accepted and flagged for the correction workflow\n  instead of silently changing an amount that may already be on an issued\n  invoice. Corrections are operator-reviewed, so systematically late feeds\n  should be fixed at the source rather than relied on.\n\n### `metered: false` is the failure that looks like success\n\nAn accepted event is stored, but it only reaches the meter if the contract has\nan active subscription whose schedule covers `occurredAt`. When it does not,\nthe response is still `201`, `metered` is `false`, and `message` says so:\n\n```json\n{\n  \"status\": \"ingested\",\n  \"eventId\": 91423,\n  \"metered\": false,\n  \"message\": \"event stored but not attributed to a billing period; it will not appear on the usage meter until an active subscription's schedule covers occurredAt\"\n}\n```\n\n**Check this flag.** Ignoring it is how a month of usage goes unbilled with\nevery call answering 2xx.\n\n### Batches\n\n```bash\ncurl -s -X POST \"$FYNEX_API_BASE/usage/events:batch\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"events\":[ {...}, {...} ]}'\n```\n\n```json\n{\n  \"ingested\": 2,\n  \"duplicates\": 1,\n  \"rejected\": 1,\n  \"items\": [\n    {\"index\": 0, \"status\": \"ingested\",  \"eventId\": 91424, \"metered\": true},\n    {\"index\": 1, \"status\": \"duplicate\", \"eventId\": 91380, \"metered\": true},\n    {\"index\": 2, \"status\": \"rejected\",  \"reason\": \"unknown metric \\\"api_call\\\"\"},\n    {\"index\": 3, \"status\": \"ingested\",  \"eventId\": 91425, \"metered\": true}\n  ]\n}\n```\n\nAnswers `200` and reports each event by its `index`; a rejected item does not\nstop the others. Two whole-batch failures exist: `413` when the batch holds\nmore than **500 events** (split it — nothing in a refused batch is recorded),\nand `500` on an infrastructure fault — retry the entire batch, the idempotency\nkeys make that safe.\n\n### CSV\n\n`POST /billing-api/v1/usage/csv` takes `multipart/form-data` with two parts: `file` (the\nCSV) and `mapping` (one JSON object describing which column is which). It is\nthe path for backfills and ERP exports rather than live traffic.\n\n## 4. Read the meter back\n\nIngested and metered usage appears on this API:\n\n```bash\ncurl -s \"$FYNEX_API_BASE/contracts/42/usage\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\"\n```\n\nThe snapshot is a live read of the open period — it is not a closed figure.\nWhen the period closes, the rated usage becomes invoice lines, and those are\nwhat you reconcile money against.\n\n## 5. Receiving usage from a third party\n\nIf the system that produces the usage is not yours to change — an ERP, a\nvendor's platform — register an inbound endpoint and give them its URL instead\nof your API key:\n\n```bash\ncurl -s -X POST $FYNEX_API_BASE/usage/webhook-endpoints \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"description\":\"Acme ERP nightly meter\"}'\n```\n\nThe response carries the endpoint and **the signing secret, once** — it is not\nretrievable later. The sender then posts usage to\n`POST /usage/v1/webhooks/{token}`, signing the body with that secret. Manage\nthem with `GET /billing-api/v1/usage/webhook-endpoints`, `POST\n/billing-api/v1/usage/webhook-endpoints/{id}/rotate-secret` and `POST\n/billing-api/v1/usage/webhook-endpoints/{id}/revoke`.\n\n**These are inbound.** They are how usage gets *in*; they are not\nnotifications about your billing. There are no outbound Billing webhooks in\nv1 — for invoice settlement, poll.\n\n## 6. When an event cannot be processed\n\nEvents that arrive over asynchronous paths — inbound webhooks, CSV rows —\ncannot answer the sender, so a failure is parked instead of lost:\n\n- `GET /billing-api/v1/usage/dead-letters` — what is parked, with the reason and the\n  attempt count.\n- `POST /billing-api/v1/usage/dead-letters/{id}/redrive` — reprocess one after fixing the\n  cause (registering the missing metric, for instance).\n- `POST /billing-api/v1/usage/dead-letters/{id}/discard` — abandon one deliberately.\n\nSynchronous calls (`/events`, `/events:batch`) never dead-letter: the caller is\npresent, so the rejection comes back as an HTTP error.\n\n## Failure contract\n\n| Status | When |\n| --- | --- |\n| `400` | The event is malformed, `occurredAt` is in the future, or a **hard** usage cap is already exceeded — the event is not recorded. |\n| `401` | Missing or invalid key. This surface answers only \"not authorized\", never the account's state. |\n| `404` | Unknown metric, or a contract that is not yours. |\n| `409` | The `idempotencyKey` was used before with a **different** payload. Same key, same payload is a duplicate (`200`), not a conflict. |\n| `413` | The batch holds more than 500 events. Nothing in it is recorded — split it and resend. |\n| `500` | Infrastructure fault. Retry — idempotency keys make that safe. |\n\nError bodies are the same `{\"error\": \"…\"}` envelope as the rest of billing.\n","name":"Usage ingestion"},{"description":"# Webhooks\n\nBilling emits signed webhook events through the same delivery pipe as the\nPayments API: one endpoint registration, one signature scheme, one retry\npolicy — a receiver built for payment events handles billing events with no\nnew integration.\n\nEvents are recorded **in the same database transaction** as the state change\nthey describe. A delivered event can never describe a state that was rolled\nback, and each transition emits exactly once — retries of your endpoint\nreceive the same event, never a second one.\n\n## Event catalog\n\n| Event | Fires when |\n| --- | --- |\n| `InvoiceIssued` | A document is issued — numbered, legally real. |\n| `InvoiceSent` | The collection link was created (`POST /invoices/{invoiceId}/send`). |\n| `InvoicePaid` | The document settled — by hosted payment, matched bank transfer, or stored credit covering it in full. `paidVia` says which. |\n| `InvoiceOverdue` | A sent document passed its due date unpaid. |\n| `InvoiceVoided` | The document was cancelled before money moved. |\n| `InvoiceWrittenOff` | Collection was abandoned. |\n| `CreditApplied` | An issued invoice drew stored credit down. |\n| `CreditDepleted` | That drawdown consumed the contract's last available credit in the invoice's currency — top up, or the next invoice asks the customer for cash. |\n\n## Subscription lifecycle\n\n| Event | Fires when |\n| --- | --- |\n| `BillingSubscriptionStarted` | A subscription was created on a contract. |\n| `BillingSubscriptionActivated` | It became active — a trial converted, or a term began. |\n| `BillingSubscriptionRenewed` | A term rolled over into the next one. |\n| `BillingSubscriptionPastDue` | Collection failed, or a trial ended with no payment method. |\n| `BillingSubscriptionRecovered` | It came back from past due. |\n| `BillingSubscriptionPaused` | Billing is suspended; no invoices are raised while it is. |\n| `BillingSubscriptionResumed` | Billing continues **on the original schedule** — the period is not restarted. |\n| `BillingSubscriptionCancelScheduled` | A cancellation was requested and takes effect after its notice period. |\n| `BillingSubscriptionCanceled` | The cancellation took effect. |\n| `BillingSubscriptionExpired` | A fixed end date was reached. |\n\n**Why the `Billing` prefix.** The unprefixed `Subscription*` events on this\nsame pipe — `SubscriptionCreated`, `SubscriptionCharged`,\n`SubscriptionCancelled`, `SubscriptionPastDue` — describe **payment-link\nrecurring subscriptions**, a different object with its own lifecycle. Two\nproducts, one word. Subscribing to those will not tell you anything about a\nbilling subscription or its contract; subscribe to the prefixed family\ninstead.\n\n**One exception, and you have to know it: `SubscriptionTrialWillEnd`.** The\nprefixed family has no trial-ending event — a billing subscription's trial\nreminder is emitted under that unprefixed name instead, one day before the\ntrial ends, with a payload of its own:\n\n```json\n{\n  \"source\": \"billing\",\n  \"subscriptionId\": 15,\n  \"contractId\": 74,\n  \"trialEnd\": \"2026-09-17\",\n  \"amountMinor\": 820000,\n  \"currency\": \"EUR\"\n}\n```\n\n`source` is how you tell it apart: `billing` on the shape above, `payment_links`\non the larger shape a payment-link subscription's own trial reminder carries. It\nis the OpenAPI discriminator for the two, so a generated client picks the right\ntype from it. (`contractId` is present on the billing shape only and remains a\nvalid tell for a receiver written before `source` existed — a payment-link\nsubscription has no contract.) The Payments API's webhook catalog publishes both\nunder the one event name. **If you want a trial-ending warning for a billing subscription, you\nmust subscribe to the unprefixed event** — the prefixed family does not carry\none.\n\nMetered-usage threshold alerts (`UsageAlertFired`) were already delivered on\nthis pipe and are unchanged.\n\n## The delivery body\n\nEvery delivery — whatever fired — is an envelope with the event's payload\nnested inside it. The four envelope fields are the same for every family:\n\n```json\n{\n  \"eventId\": 918204,\n  \"eventType\": \"InvoicePaid\",\n  \"sellerAccountUuid\": \"6f2a1c1e-6a1e-4f10-9f2b-9c1d0b3a7e55\",\n  \"occurredAt\": \"2026-08-20T14:02:11Z\",\n  \"payload\": { }\n}\n```\n\n`eventId` is the delivery's stable identity: deduplicate on it. `occurredAt`\nis when the state changed, not when the delivery was attempted — a retry\nrepeats the original value. `sellerAccountUuid` is your Fynex account, never\nyour customer.\n\nEvery event is also published in the OpenAPI document under its top-level\n`webhooks` key, so a generated client carries the body type rather than\n`map[string]any`.\n\n## Payloads\n\nInvoice events carry the document's public identity and money facts — the\nsame fields the API serves, and nothing it does not:\n\n```json\n{\n  \"invoiceId\": 4180,\n  \"invoiceNumber\": \"UK2607AA-2608AAB\",\n  \"contractId\": 42,\n  \"sellerCustomerId\": 7,\n  \"origin\": \"usage\",\n  \"status\": \"paid\",\n  \"paidVia\": \"payment_link\",\n  \"currency\": \"EUR\",\n  \"grandTotalMinor\": 12000,\n  \"collectibleMinor\": 12000,\n  \"dueDate\": \"2026-08-15T00:00:00Z\",\n  \"occurredAt\": \"2026-08-20T14:02:11Z\"\n}\n```\n\nBuyer name and email are deliberately absent — webhook bodies land in the\nreceiver's logs, and the single-invoice read serves them to a caller who\nholds the key. Treat the event as the signal and `GET /invoices/{invoiceId}`\nas the source of truth.\n\nA subscription event carries the subscription's identity and where it landed:\n\n```json\n{\n  \"subscriptionId\": 15,\n  \"contractId\": 74,\n  \"status\": \"paused\",\n  \"billingFrequency\": \"monthly\",\n  \"currency\": \"EUR\",\n  \"priceMinor\": 820000,\n  \"currentPeriodStart\": \"2026-08-17T00:00:00Z\",\n  \"currentPeriodEnd\": \"2026-09-17T00:00:00Z\",\n  \"occurredAt\": \"2026-08-28T11:04:22Z\"\n}\n```\n\nThe event type says what happened and `status` says where it landed; there is\nno `previousStatus`. Nothing in the body identifies the customer — look them\nup through the contract if you need to.\n\nCredit events:\n\n```json\n{\n  \"contractId\": 42,\n  \"invoiceId\": 4181,\n  \"invoiceNumber\": \"UK2607AA-2608AAC\",\n  \"currency\": \"EUR\",\n  \"appliedMinor\": 250000,\n  \"remainingMinor\": 0,\n  \"occurredAt\": \"2026-09-01T00:05:00Z\"\n}\n```\n\n`remainingMinor` is the contract's available credit after the movement — it\nis `0` on every `CreditDepleted` by definition.\n\n## Delivery, signing, retries\n\nDeliveries are HTTP POSTs to the webhook endpoints configured for your\naccount, signed with your endpoint's secret:\n\n```\nX-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e\nX-Fynex-Timestamp: \u003cunix seconds at which the delivery was signed\u003e\n```\n\nVerify the signature against the **raw** request body before parsing, and\nreject a delivery whose timestamp is far from your own clock — that is what\nbounds replay of a body someone captured. Deliveries are retried with backoff\nuntil your endpoint answers 2xx; your handler must therefore be idempotent.\nDeduplicate on the envelope's `eventId`, which is stable across every retry of\nthe same event.\n\nEndpoint registration, secret rotation and the retry schedule are the\nPayments API's webhook machinery — see its **Webhooks** guide; nothing about\nit is billing-specific.\n\n## Polling still works\n\nThe polling guidance in **Workflows** remains valid and is the right tool for\nreconciliation: a month-end close should read `GET /invoices` for the period\nrather than reconstruct it from events. Webhooks are for the moment an\ninvoice being paid *unlocks something* — activating a seller, releasing a\nlisting, restoring access — where minutes of polling latency is real waiting.\n","name":"Webhooks"},{"description":"# Contracts\n\nA contract is the commercial agreement every other billing object hangs off:\nsubscriptions bill on it, credit is held against it, metered usage is priced\nunder it and every invoice names it. It belongs to one of your customers (a\n`sellerCustomerId` from `POST /customers`) and bills in one currency for its\nwhole life — credit and invoices under it are never converted.\n\n## Versions, not edits\n\nA contract is a stable id plus an append-only history of **versions**. Nothing\nis ever updated in place: every change — a status move, a new end date, a\ndifferent set of components — is a new version appended on top of the last,\nand every earlier version stays readable. `GET /contracts` shows the current\nversion of each contract you own, with `version` saying how many there are.\n\nThat is what an amendment is: **a compare-and-swap on the version number**.\nYou send the `expectedBaseVersion` you last read; if the contract has moved on\nsince, the amendment answers `422` and you re-read before deciding again.\nTwo integrations amending the same contract can therefore never silently\noverwrite each other.\n\n## Status\n\n`draft` → `active` → `suspended` ⇄ `active` → `closed`. A contract starts in\n`draft`. `closed` is terminal: nothing about a closed contract can be amended.\nA transition the state machine does not allow (anything out of `closed`, or\n`draft` straight to `suspended`) answers `422`.\n\n**Closing a contract does not cancel its subscriptions.** The status is a\nrecord of the commercial relationship; no billing engine reads it, so a\nsubscription on a closed contract keeps renewing and keeps issuing invoices.\nCancel each one with `POST /billing-api/v1/subscriptions/{subscriptionId}/cancel`\nFIRST, then close the contract.\n\n## Endpoints\n\n- `GET /billing-api/v1/contracts` — the current version of every contract you\n  own, keyset-paged by contract id.\n- `POST /billing-api/v1/contracts` — create one, in `draft`, for one of your\n  customers. **`Idempotency-Key` is required**: the same key returns the\n  contract the first call created (`200` instead of `201`); the same key\n  answers `422`, naming the field, when the request differs in its\n  `sellerCustomerId`, `currency` or the `lineItems` themselves — compared line by line on\n  `componentType`, `componentConfig` and `quantity`, order-insensitively — or in the\n  `startDate` or `endDate` as the key first created it. It also carries an EXTRA per-seller\n  quota of its own — 20 create requests a rolling day by default (a refused or replayed request counts too), answered as `429`\n  with `Retry-After` — bounding how fast one key can open contracts, each of\n  which allocates a number from a sequence shared across sellers.\n- `POST /billing-api/v1/contracts/{contractId}/amendments` — append a version:\n  a status move, new dates, a replaced component set, or the counterparty's\n  details as agreed. `expectedBaseVersion` is required and is the concurrency\n  guard; there is no `Idempotency-Key`, because a repeat of a successful\n  amendment fails the version check by construction. This one carries an EXTRA\n  per-seller quota too — 200 amend requests a rolling day by default (a stale `expectedBaseVersion` counts too) — because\n  every accepted amendment appends a version that can never be deleted.\n- `GET /billing-api/v1/contracts/{contractId}/usage` and\n  `GET /billing-api/v1/contracts/{contractId}/credits` — what has been metered\n  and what stored value is held under the contract.\n\nBoth write quotas, and the invoice issue quota, **fail closed** — while the\nlimiter is unreachable they answer `503` with `Retry-After` and the write does\nnot happen; see [Errors → rate limiting](/billing-api/v1/docs/errors#when-the-write-quotas-fail-closed).\n\n## Components\n\n`lineItems` on a contract are its **components**: what the agreement says is\nbeing sold, each with a `componentType`, a `quantity` (decimal string) and an\noptional `componentConfig` the engine reads for that type. They are\nsnapshotted per version — an amendment either carries the current set forward\n(omit `lineItems`) or replaces it whole (send the full new set; `[]` clears\nit). There is no partial edit, for the same reason there is no partial edit\nof anything else here: the version is the audit trail.\n\nA contract may have no components at all. Its billing is then defined by the\nsubscriptions attached to it (`POST /contracts/{contractId}/subscriptions`)\nand the metered prices set under it.\n\n## What the seller key may not do\n\nContracts created through this API record no dashboard user as their author\n(`createdBy` is `0`): the seller key is the actor, and the audit trail names\nthe key's seller account. Contract PDFs, tax profiles and the backoffice\nreview of a contract stay dashboard operations.\n","name":"Contracts"},{"description":"# Invoices\n\nA billing invoice is an immutable, numbered document. The relational fields\nyou see in responses (totals, dates, status) mirror a frozen, schema-valid\npayload snapshotted at issue time; the PDF renders from that snapshot.\n\n## Lifecycle\n\n`draft → issued → sent → paid`, with `overdue` for a sent document still\nunpaid past its due date, and three corrective exits:\n\n- `voided` — the document was cancelled before money moved (`voidedAt`,\n  `voidReason`).\n- written off — collection was abandoned (`writtenOffAt`, `writeOffReason`).\n- credit note — a separate document (`invoiceType: \"credit_note\"`) naming the\n  original in `originalInvoiceNumber`.\n\n`paidVia` records how a paid document settled: `payment_link` (the card rail —\neither the buyer visiting the hosted page, or an off-session charge on their\nsaved card when the seller has auto-collection enabled), `bank_transfer` (a\ndeposit on the collection account named on the document) or `credit` (stored\ncredit covered it in full at issue).\n\nA document can therefore reach `paid` without you doing anything, and can\nappear on `GET /invoices` without anyone creating it: whether terms and\nmetered usage are invoiced on a schedule, and whether saved cards are charged\noff-session, are per-seller operator switches. See **Sandbox \u0026 testing** for\nthe switches themselves.\n\n## Numbering\n\nEvery issued document carries TWO numbers, and they are not interchangeable.\n\n`invoiceNumber` (`UK2607AB-2607AAC`) is the document's identity: unique across\nevery Fynex invoice family, allocated from the shared per-(agreement, month)\nsequence, and **also the bank payment reference** — it is the only string a\nbuyer may quote on a transfer, and it is what `bankTransfer.paymentDescription`\nreturns.\n\n`customerDocumentNumber` (`0000042`) is the document's position in that\ncustomer's own series: cumulative for the life of the (seller, customer) pair,\nnever reset, and counted independently for each customer. Credit notes take the\nnext position like any other document, and a voided document keeps the one it\nhad. It is a convenience reference printed as \"Your document no.\" — unique only\nwithin the pair, so it is never a lookup key and never a payment reference.\nAbsent on documents issued before the series existed; those are not backfilled.\n\n## Numbering\n\nAn invoice number is `\u003cagreement\u003e-\u003cYYMM\u003e\u003corder\u003e`: your agreement number, a\ndash, the issue month as `YYMM`, and a three-letter order within that month\n(`AAA`, `AAB`, …) — for example `UK2607AA-2608AAB` is the second document\nissued in August 2026 under agreement `UK2607AA`. The series is allocated\ninside the issuing transaction, so it is gapless per month, and it is shared\nwith top-up invoices so the two document families can never collide. Numbers\nare what `corrects` and `originalInvoiceNumber` reference, so store them\nverbatim — do not parse meaning out of the order suffix.\n\n`customerName` and `customerEmail` carry who the document was addressed to at\nissue — taken from the frozen payload, so a customer later renaming themselves\ndoes not rewrite an issued legal record. They are returned by the\nsingle-invoice read and by `send`, and omitted from list rows.\n\n## Endpoints\n\n- `GET /billing-api/v1/invoices` — list, newest first, paged with\n  `cursor`/`limit`. Filters: `contractId`, `sellerCustomerId`, `status`,\n  `invoiceType` (`standard`, `credit_note`, `simplified`, `modified`),\n  `origin` (`recurring`, `usage`, `milestone`, `project`, `one_time`, `adhoc`,\n  `marketplace`; `subscription` is a deprecated alias of `recurring`),\n  `corrects` (credit notes against an invoice number), `issuedFrom`/`issuedTo`\n  (half-open date range).\n- `POST /billing-api/v1/invoices` — compose, number and (by default) send a\n  document in one call, behind a required `Idempotency-Key`. It is issued and\n  immutable the moment the call answers `201`; the same key replays the\n  document with `200` and no link, and a key whose request differs in its\n  contract, customer, currency, `invoiceType`, `dueDate`, net line total or\n  line count answers `422` naming the field. There are no drafts on this API:\n  send explicit lines with `unitPriceMinor`. `invoiceType` accepts `standard`\n  (the default), `simplified` and `modified`; **`credit_note` is refused with\n  `400`** — a correction is raised against the document it corrects, which this\n  request cannot express, and there is no `originalInvoiceNumber` field on it.\n  Correct a mistake in the dashboard.\n- `GET /billing-api/v1/invoices/{invoiceId}` — one invoice with its lines.\n- `GET /billing-api/v1/invoices/{invoiceId}/pdf` — the rendered PDF.\n- `POST /billing-api/v1/invoices/{invoiceId}/send` — materialize the\n  collection instrument: creates the invoice's hosted payment link and returns\n  the invoice together with `paymentLinkUrl`. **It emails your customer**\n  whenever the document carries a buyer email; there is no per-request\n  suppression in v1, so the first call is customer-facing. The invoice is\n  itself the collection anchor, so retries and simultaneous calls return the\n  one existing link and do not send another email — no idempotency header is\n  needed. Not applicable (`422`) to bank-transfer documents, to documents\n  outside `issued`/`sent`/`overdue` (a `draft`, or one already `paid`,\n  `voided` or `written_off`), or to documents with nothing left to collect.\n\n## Amounts\n\n`grandTotalMinor` is the legal document total. `collectibleMinor` is what\ncollection asks the customer for: the grand total less `creditAppliedMinor`\n(stored credit drawn down at issue).\n\n`creditSettledMinor` reports only the part discharged by that stored credit —\nit is **not** a payment total, and a card- or transfer-paid invoice reports\n`0`. Neither amount changes when the invoice is paid: `status` and `paidVia`\nare what tell you the outcome.\n\n`outstandingMinor` is the one to age in an AR report. `collectibleMinor` is\nfrozen at issue and stays there, so it keeps asking for the full amount after a\ncredit note, a write-off or any other adjustment; `outstandingMinor` is derived\nfrom the adjustment ledger on every read and reports what is genuinely still\nowed (`0` for a voided document). It is omitted when the adjustment ledger is\nunavailable — absence means **unknown**, not `0`.\n","name":"Invoices"},{"description":"# Subscriptions\n\nA subscription is one recurring billing relationship on a contract. All date\nfields are UTC calendar dates — billing runs on dates, not instants.\n\n## States\n\n`trial → active ⇄ past_due / paused → canceled | expired`\n\n- `trial` — running a free trial until `trialEnd`. `trialEndBehavior` says\n  which way it ends: `convert` (becomes a paying subscription) or `cancel`\n  (lapses). `trialRequiresPaymentMethod` tells you whether a payment method\n  must be on file first.\n- `active` — billing normally within `currentPeriodStart…currentPeriodEnd`.\n- `past_due` — a renewal charge failed. **No automatic retry runs**: recovery\n  is your action or a dashboard operation. Treat this status as \"act now\",\n  not as \"Fynex is handling it\".\n- `paused` — billing suspended (`pausedAt`, optional auto-resume at\n  `pauseEndsAt`).\n- `canceled` / `expired` — terminal. A cancellation with notice keeps serving\n  until `cancelEffectiveAt`.\n\n## Price and plan\n\nThere is no separate plan object: the plan reference is\n`priceMinor + currency + billingFrequency` (with `customUnit`/`customEvery`\nfor custom cadences). A scheduled downgrade appears as\n`pendingPriceMinor`/`pendingPriceChangeAt` until the renewal pass applies it.\n\n## Endpoints\n\nRead:\n\n- `GET /billing-api/v1/subscriptions` — list, newest first. Filter:\n  `contractId`. Pages with `cursor`/`limit`, newest first.\n- `GET /billing-api/v1/subscriptions/{subscriptionId}` — one subscription.\n\nCreate:\n\n- `POST /billing-api/v1/contracts/{contractId}/subscriptions` — a new\n  subscription on a contract you own. **`Idempotency-Key` is required** (1–128\n  characters, `A–Z a–z 0–9 _ . : -`): the same key always returns the\n  subscription the first call created (`200` instead of `201`), whatever the\n  body of the retry; use one key per subscription you intend to create. Keys\n  are scoped to your seller account — the same key on a different contract\n  answers `422`; a key under a billing-engine prefix (`proration:`, `redeem:`,\n  `subscription:`, …) answers `400`. Body: `frequency` (`daily`, `weekly`,\n  `bi_weekly`, `monthly`, `quarterly`, `semi_annual`, `annual`, `custom` with\n  `customUnit`/`customEvery`), `priceMinor` + `currency` (the contract's),\n  `startDate` (up to a year in the past), optional `anchorDate`, `trialEnd` +\n  `trialEndBehavior` (`convert`/`cancel`), `autoRenew` (default `true`; an\n  explicit `false` needs `endDate`, and `endDate` is refused otherwise),\n  `noticePeriodDays` (0–365), `prorationPolicy`\n  (`by_day`/`full_period`/`next_period` — selects what an immediate\n  `change-plan` does once the proration engine is enabled: prorate the\n  unserved remainder, bill the whole current period at the new price with the\n  elapsed days included and no adjustment, or keep the old price until the\n  next period. Inert while the engine is off, which is the default. Where\n  invoice binding is enabled for the environment, the recorded proration is\n  applied to the term's next invoice).\n\n```bash\ncurl -X POST \"$FYNEX_API_BASE/contracts/42/subscriptions\" \\\n  -H \"Authorization: Bearer $FYNEX_SECRET_KEY\" \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"frequency\":\"monthly\",\"priceMinor\":9900,\"currency\":\"EUR\",\"startDate\":\"2026-10-01\",\"noticePeriodDays\":30}'\n```\n\nLifecycle, all `POST /billing-api/v1/subscriptions/{subscriptionId}/…`:\n\n| Action | Body | From | Effect |\n| --- | --- | --- | --- |\n| `cancel` | — | `trial`, `active`, `past_due`, `paused` | Sets `cancelRequestedAt`; serves until `cancelEffectiveAt` (notice period) or term end. Terminal. |\n| `pause` | `{\"pauseUntil\": \"YYYY-MM-DD\"}` optional | `active` | Suspends billing; `pauseEndsAt` when a date was given. Longer than the seller's pause policy → `422`. |\n| `resume` | — | `paused` | New term from today; the pause is not billed. |\n| `end-trial` | — | `trial` | Converts now per `trialEndBehavior` / `trialRequiresPaymentMethod`. |\n| `change-plan` | `{\"priceMinor\": 12900, \"currency\": \"EUR\", \"atTermEnd\": true}` | `active` | Scheduled (`pendingPriceMinor`) or immediate. `atTermEnd: true` always schedules; with `false` the subscription's `prorationPolicy` decides while the proration engine is on — `next_period` keeps the old price for the rest of this period and applies the new one from the next, `by_day` applies it now and posts an adjustment for the unserved remainder, `full_period` applies it now and bills the whole current period at the new price, elapsed days included, with no adjustment. Same price, or a change already pending → `422`. Currency cannot change. |\n\nEvery action returns the updated subscription. A request the current state\ncannot take — pausing a trial, resuming an active subscription, cancelling\ntwice — answers `422` with the reason; so does a lost race against the\nlifecycle pass (`subscription changed concurrently; re-read it and retry`).\n`404` means the subscription is not yours. Marking past-due or recovered is\nthe collection loop's job and has no public route.\n","name":"Subscriptions"},{"description":"# Usage\n\nMetered usage is ingested by your systems (or Fynex connectors), aggregated\nper contract, metric, and billing period, and rated into usage invoices at\nperiod close. This API exposes the aggregated view.\n\n## Endpoints\n\n- `GET /billing-api/v1/contracts` — the contracts visible to your key, the\n  entry point for every per-contract read. Paged by ascending contract id:\n  while `hasMore` is true, pass the returned `nextCursor` back as `cursor`.\n\n```json\n{\n  \"contracts\": [\n    {\n      \"contractId\": 42,\n      \"contractNumber\": \"UK2607AA\",\n      \"version\": 1,\n      \"sellerCustomerId\": 7,\n      \"currency\": \"EUR\",\n      \"status\": \"active\",\n      \"startDate\": \"2026-01-01\",\n      \"customerName\": \"Ada Lovelace\",\n      \"customerCompanyName\": \"Harbour Group BV\"\n    }\n  ],\n  \"hasMore\": false\n}\n```\n\n- `GET /billing-api/v1/contracts/{contractId}/usage` — the current open\n  billing periods of every metric on the contract:\n\n```json\n{\n  \"contractId\": 42,\n  \"metrics\": [\n    {\n      \"metricName\": \"api_calls\",\n      \"used\": \"10250\",\n      \"includedUnits\": \"10000\",\n      \"capQuantity\": \"50000\",\n      \"capMode\": \"hard\",\n      \"percentOfCap\": \"20.5\",\n      \"percentOfPlan\": \"102.5\",\n      \"periodStart\": \"2026-08-01\",\n      \"periodEnd\": \"2026-08-31\"\n    }\n  ]\n}\n```\n\n`used` counts everything metered this period; `includedUnits` is the plan\nallowance; `capQuantity`/`capMode` describe the limit policy when one is\nconfigured. Quantities are decimal strings. An unknown contract id answers\n`404`; a real contract with nothing metered answers `200` with an empty\n`metrics` array.\n\n\u003e [!IMPORTANT]\n\u003e **This is a live reading, not the figure the customer will be invoiced.**\n\u003e The period is still open, so `used` moves with every event that arrives —\n\u003e including events for work already done that reach us late. It is also\n\u003e pre-invoice: allowances, credits, discounts and rounding are applied when the\n\u003e period closes and the invoice is produced, and none of them are reflected\n\u003e here.\n\u003e\n\u003e Show it to a customer as \"usage so far\", never as an amount owed, and\n\u003e reconcile against the invoice the period produced rather than against this\n\u003e endpoint. A dashboard that quotes this number as the bill will disagree with\n\u003e the bill.\n\nThis endpoint reads a meter; something has to fill it. Writing usage —\nregistering a metric, pricing it, and sending events — happens under\n`/billing-api/v1/usage`, with the same seller secret key, and is documented in\nfull under **Usage ingestion**. In short: `POST /billing-api/v1/usage/metrics`\nto declare the meter, `PUT\n/billing-api/v1/usage/contracts/{contractId}/metrics/{metricName}/price` to\nprice it, and `POST /billing-api/v1/usage/events` (or `:batch`, or `/csv`) to\nreport consumption.\n\n## What this endpoint keeps, and for how long\n\n**This is a meter, not a history API.** It answers with the contract's\n**currently open** billing periods — one entry per metric with a limit policy\nor metered usage. It has no date range and no paging, and a period that has\nclosed is no longer in the response. If you need consumption over time, record\nwhat you read while the period is open, or take it from the invoice the period\nproduced: the invoice is the durable record of what was billed.\n\n**Stored events are retained indefinitely.** Nothing prunes them — there is no\nretention window on ingested usage and no job that deletes it, so an\nidempotency key you used a year ago is still recognised and resending that\nevent is still a no-op. Two practical consequences:\n\n- Idempotency keys must stay unique for the lifetime of your integration, not\n  just for a period. Derive them from something durable — a row id, an export\n  digest — rather than from a timestamp that repeats.\n- Correcting metered history is done by issuing a correction against the period\n  (see **Usage corrections**), never by deleting events. There is no delete.\n\nDocuments are the exception: invoices and credit notes carry a statutory\nretention period per jurisdiction, which is a legal minimum on how long they\nare kept, not a window after which this endpoint stops answering.\n","name":"Usage"},{"description":"# Credits\n\nStored value is a capability your deployment switches on. While it is off,\nevery endpoint below answers `501` with `billing credits are not enabled` —\nand that is the honest answer, because with it off the billing engine does\nnot draw credit into invoices at all, so any balance shown would never be\nconsumed.\n\nCredits are stored value held against a contract — customer prepayments,\npromotional grants, goodwill. Balances are derived from an append-only\nledger; drawdown happens automatically when an invoice is issued against a\ncontract holding credit (visible on the invoice as `creditAppliedMinor`).\n\n## Endpoints\n\n- `GET /billing-api/v1/credits` — your balances across all contracts, one row\n  per currency and credit type:\n\n```json\n{\n  \"balances\": [\n    {\"currency\": \"EUR\", \"creditType\": \"purchased\", \"balanceMinor\": 250000, \"isLiability\": true}\n  ]\n}\n```\n\n`creditType` is one of `promotional`, `purchased`, `manual`, `gift`,\n`enterprise`, `ai_token`, `marketplace` or `proration`. `isLiability` marks\nthe paid-for types (`purchased`, `enterprise`, `proration`) — unearned revenue\nyou owe as service. Those never expire; granted credit may. `proration` is\nminted by the engine rather than granted by you: when a mid-term subscription\namendment credits your customer more than the invoice it lands on can absorb,\nthe remainder becomes a `proration` lot — money the customer already paid for\nservice not rendered — and the next document draws it down.\n\n- `GET /billing-api/v1/contracts/{contractId}/credits` — one contract's\n  balances (always complete) plus a page of its ledger history. `limit` sets\n  the page size (1–100, default 20); while `hasMore` is true, pass the\n  returned `nextCursor` back as `cursor` to walk older entries:\n\n```json\n{\n  \"balances\": [ ... ],\n  \"hasMore\": true,\n  \"nextCursor\": 1180,\n  \"nextBeforeId\": 1180,\n  \"entries\": [\n    {\n      \"id\": 1201,\n      \"kind\": \"topup\",\n      \"creditType\": \"purchased\",\n      \"signedDeltaMinor\": 250000,\n      \"currency\": \"EUR\",\n      \"invoiceId\": null,\n      \"reason\": \"annual prepayment\",\n      \"occurredAt\": \"2026-08-01T09:30:00Z\"\n    }\n  ]\n}\n```\n\nEntries are newest-first. `kind` is `topup`, `deduction`, `expiry` or\n`reversal` — the last one returns a deduction to the customer when the invoice\nit funded is cancelled. `signedDeltaMinor` is positive for `topup` and\n`reversal`, negative for `deduction` and `expiry`, and `invoiceId` links a\ndeduction (and the reversal that undoes it) to the document it funded.\n\n- `POST /billing-api/v1/contracts/{contractId}/credits/top-up` — grant a\n  credit lot. This is the only way credit enters the ledger; `deduction`,\n  `expiry` and `reversal` rows are the engine's and have no public route.\n\n```bash\ncurl -s -X POST \"$FYNEX_API_BASE/contracts/42/credits/top-up\" \\\n  -H \"Authorization: Bearer $FYNEX_API_KEY\" \\\n  -H \"Idempotency-Key: 3f0b6a1e-9c1d-4f10-9f2b-6f2a1c1e6a1e\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"creditType\":\"purchased\",\"amountMinor\":250000,\"currency\":\"EUR\",\"reason\":\"annual prepayment\"}'\n```\n\n`Idempotency-Key` is required, and here the body **is** compared: the same key\nwith a different contract, `amountMinor` or `currency` answers `422` instead of\nreturning the first grant, because a top-up that never happened must not look\nlike one that did. A matching replay answers `200` with the original entry; the\nfirst call answers `201`.\n\n`currency` must equal the contract's own — credit is never converted. An\n`expiresAt` is allowed only on granted credit: `purchased` and `enterprise`\nwere paid for, and money someone paid must not evaporate on a calendar date.\n`sellerCustomerId` is optional attribution and must name one of your customers\n(`POST /billing-api/v1/customers`); the contract remains the balance anchor.\n\nCorrecting a grant is not an edit — the ledger is append-only. Contact support\nfor a correcting entry.\n","name":"Credits"}],"webhooks":{"BillingSubscriptionActivated":{"post":{"description":"It became active — a trial converted, or a term began.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-BillingSubscriptionActivated","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingSubscriptionWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"BillingSubscriptionActivated","tags":["Webhooks"]}},"BillingSubscriptionCancelScheduled":{"post":{"description":"A cancellation was requested and takes effect after its notice period.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-BillingSubscriptionCancelScheduled","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingSubscriptionWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"BillingSubscriptionCancelScheduled","tags":["Webhooks"]}},"BillingSubscriptionCanceled":{"post":{"description":"The cancellation took effect.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-BillingSubscriptionCanceled","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingSubscriptionWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"BillingSubscriptionCanceled","tags":["Webhooks"]}},"BillingSubscriptionExpired":{"post":{"description":"A fixed end date was reached.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-BillingSubscriptionExpired","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingSubscriptionWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"BillingSubscriptionExpired","tags":["Webhooks"]}},"BillingSubscriptionPastDue":{"post":{"description":"Collection failed, or a trial ended with no payment method.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-BillingSubscriptionPastDue","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingSubscriptionWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"BillingSubscriptionPastDue","tags":["Webhooks"]}},"BillingSubscriptionPaused":{"post":{"description":"Billing is suspended; no invoices are raised while it is.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-BillingSubscriptionPaused","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingSubscriptionWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"BillingSubscriptionPaused","tags":["Webhooks"]}},"BillingSubscriptionRecovered":{"post":{"description":"It came back from past due.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-BillingSubscriptionRecovered","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingSubscriptionWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"BillingSubscriptionRecovered","tags":["Webhooks"]}},"BillingSubscriptionRenewed":{"post":{"description":"A term rolled over into the next one.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-BillingSubscriptionRenewed","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingSubscriptionWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"BillingSubscriptionRenewed","tags":["Webhooks"]}},"BillingSubscriptionResumed":{"post":{"description":"Billing continues on the original schedule — the period is not restarted.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-BillingSubscriptionResumed","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingSubscriptionWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"BillingSubscriptionResumed","tags":["Webhooks"]}},"BillingSubscriptionStarted":{"post":{"description":"A subscription was created on a contract.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-BillingSubscriptionStarted","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingSubscriptionWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"BillingSubscriptionStarted","tags":["Webhooks"]}},"CreditApplied":{"post":{"description":"An issued invoice drew stored credit down.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-CreditApplied","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreditWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"CreditApplied","tags":["Webhooks"]}},"CreditDepleted":{"post":{"description":"That drawdown consumed the contract's last available credit in the invoice's currency.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-CreditDepleted","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreditWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"CreditDepleted","tags":["Webhooks"]}},"InvoiceIssued":{"post":{"description":"A document is issued — numbered, legally real.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-InvoiceIssued","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvoiceWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"InvoiceIssued","tags":["Webhooks"]}},"InvoiceOverdue":{"post":{"description":"A sent document passed its due date unpaid.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-InvoiceOverdue","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvoiceWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"InvoiceOverdue","tags":["Webhooks"]}},"InvoicePaid":{"post":{"description":"The document settled — by hosted payment, matched bank transfer, or stored credit covering it in full. `paidVia` says which.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-InvoicePaid","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvoiceWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"InvoicePaid","tags":["Webhooks"]}},"InvoiceSent":{"post":{"description":"The collection link was created (`POST /invoices/{invoiceId}/send`).\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-InvoiceSent","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvoiceWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"InvoiceSent","tags":["Webhooks"]}},"InvoiceVoided":{"post":{"description":"The document was cancelled before money moved.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-InvoiceVoided","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvoiceWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"InvoiceVoided","tags":["Webhooks"]}},"InvoiceWrittenOff":{"post":{"description":"Collection was abandoned.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-InvoiceWrittenOff","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvoiceWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"InvoiceWrittenOff","tags":["Webhooks"]}},"UsageAlertFired":{"post":{"description":"Metered consumption crossed a configured threshold, or the cap itself. `kind` says which.\n\nDelivered as an HTTP POST to every webhook endpoint registered for the account, signed with that endpoint's secret: `X-Fynex-Signature: sha256=\u003chex HMAC-SHA256 of the raw body\u003e` and `X-Fynex-Timestamp: \u003cunix seconds\u003e`. Verify against the RAW body before parsing.\n\nDelivery is at-least-once and retried until your endpoint answers 2xx — deduplicate on `eventId`. Treat the event as the signal and the API read as the source of truth.","operationId":"webhook-UsageAlertFired","parameters":[{"description":"`sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with your endpoint's secret.","in":"header","name":"X-Fynex-Signature","required":true,"schema":{"examples":["sha256=8f2c…"],"type":"string"}},{"description":"Unix seconds at which the delivery was signed. Reject deliveries whose timestamp is far from your clock to bound replay.","in":"header","name":"X-Fynex-Timestamp","required":true,"schema":{"examples":["1787318531"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UsageAlertWebhookEvent"}}},"description":"The delivery envelope and the event's payload.","required":true},"responses":{"200":{"description":"Accepted. Any 2xx ends the retry schedule for this delivery; anything else is retried with backoff."}},"security":[],"summary":"UsageAlertFired","tags":["Webhooks"]}}},"x-tagGroups":[{"name":"Getting started","tags":["Concepts","Quickstart","Authentication","Sandbox \u0026 testing"]},{"name":"Guides","tags":["Endpoint reference","Workflows","Usage ingestion","Webhooks","Code examples"]},{"name":"Conventions","tags":["Errors","Pagination \u0026 Amounts"]},{"name":"Reference","tags":["Contracts","Invoices","Subscriptions","Usage","Credits"]}]}