Usage ingestion
The write half of metered billing.
Metered billing has two halves. This API reads the meter — GET
/billing-api/v1/contracts/{contractId}/usage returns where a contract stands
in its open period. Writing to that meter happens on a different prefix,
/billing-api/v1/usage, documented here.
Same API, same credential: Authorization: Bearer sk_test_… / sk_live_…,
same seller tenancy, same {"error": "…"} envelope. Two differences from the
document routes: these routes return no X-RateLimit-* headers (batch your
events and bound your own concurrency), and idempotency is the idempotencyKey
field in the event body rather than an Idempotency-Key header (§3).
A second, older path exists. Metering shipped before this API did and took the top-level prefix
/usage/v1. Every route below still answers there too, unchanged — but/billing-api/v1/usage/…is the documented one, and new integrations should use it. The single exception is the inbound webhook receiver in §5, which stays on/usage/v1on purpose: it is authenticated by payload signature rather than by your key, and every operation under/billing-api/v1is key-gated.
The loop is: register a metric → price it → send events → read the meter → the period closes into an invoice.
One convention differs here, and it matters. Everything under
/billing-api/v1reports money in integer minor units. A price configuration is the exception: unit prices and tier amounts are decimal strings in major units ("0.0001"is one hundredth of a cent per unit), because per-unit rates are routinely finer than a minor unit. The rate is exact; the rounding to minor units happens once, at invoicing, by the policy the config names. Amounts you read back on an invoice are minor units as usual.
1. Register a metric
A metric is the meter's identity: what is counted, in what unit, and how several events in one period combine.
curl -s -X POST $FYNEX_API_BASE/usage/metrics \
-H "Authorization: Bearer $FYNEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"api_calls","unit":"call","aggregation":"sum","description":"Billable API requests"}'
{
"name": "api_calls",
"version": 1,
"unit": "call",
"aggregation": "sum",
"description": "Billable API requests"
}
| Field | Rules |
|---|---|
name |
Registry identifier — this is what events reference. |
unit |
Free text, at most 32 characters (call, GB, token, seat). |
aggregation |
sum, max, min, last, count or count_unique. How the period's events collapse into one billable quantity. |
uniqueKey |
count_unique only: the event-metadata key whose distinct values are counted — a user id, an endpoint, a tenant. Required there, forbidden elsewhere. |
description |
Optional, at most 256 characters. |
Answers 201 with the registered metric, or 400 naming the field that was
wrong.
A count_unique metric answers "how many different users called", where
count answers "how many calls". Its events must carry the uniqueKey
field in metadata (400 otherwise), and at most 1,000 distinct values
count per contract and period: past that, new values stop counting and the
balance is flagged as overflowed — the figure becomes a floor. If your
cardinality is legitimately higher, meter with count and dimension in your
own analytics instead. Metrics are versioned: version increments when a registration
changes the definition, and events are attributed to the version current when
they arrive. uniqueKey is part of that definition, like unit and
aggregation: it cannot be edited in place — registering the same name again
with a different uniqueKey creates version N+1, and events keep being
attributed to whichever version is in force when they arrive.
There is no time-weighted aggregation — GB-hours, active-seat-days and the
like. Compute the weighted figure on your side and send it as the event
quantity of a sum metric; a max or last metric over a gauge reading is
the other honest shape.
GET /billing-api/v1/usage/metrics— every metric you have registered.GET /billing-api/v1/usage/metrics/{name}— one metric,404when it is not registered.
2. Price the metric on a contract
Pricing is per contract and per metric, and it is versioned by date — a new configuration does not rewrite history, it takes effect from a date you state.
curl -s -X PUT "$FYNEX_API_BASE/usage/contracts/42/metrics/api_calls/price" \
-H "Authorization: Bearer $FYNEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"effectiveFrom": "2026-09-01",
"config": {
"scheme": "graduated",
"currency": "EUR",
"rounding": "half_up",
"unitPrice": "0",
"tiers": [
{"upTo": "10000", "unitPrice": "0.004", "flatAmount": "0"},
{"upTo": null, "unitPrice": "0.002", "flatAmount": "0"}
],
"allotment": {"includedUnits": "1000", "rollover": "none"},
"commitment": {"minimumMinor": 5000, "spendCapMinor": 500000}
}
}'
The seven pricing schemes
scheme |
How the quantity is charged |
|---|---|
per_unit |
units × unitPrice. One rate, no bands. |
graduated |
Every band charges its own rate for the units inside it — progressive, like income tax. |
volume |
The band the total quantity lands in prices all units at that band's rate. |
tiered |
Stairstep: the band the total lands in charges its flatAmount, whatever the exact count. |
package |
Per started block: ceil(quantity / packageSize) × unitPrice, where unitPrice is the price of one package. A partial package charges in full — 1,001 units at "per 1,000" is two packages. |
percentage |
percentBps basis points (100 = 1%) of the aggregated quantity, which for this scheme is itself a monetary amount in major units — a GMV or transaction-value meter. |
per_seat |
unitPrice per seat per FULL period, prorated to the day: unitPrice × seatDays ÷ periodDays. Rated from a recorded seat history rather than from ingested events — see Per-seat pricing below. |
graduated and volume are the classic trap — same bands, same quantity,
different money. Read the two rows above before choosing. There is no flat
scheme on purpose: a fixed per-period fee is what a subscription price is,
and a usage floor is commitment.minimumMinor on any scheme.
The rest of the configuration
| Field | Meaning |
|---|---|
currency |
ISO 4217. Must match the contract's currency. |
rounding |
Applied once, when the exact decimal amount collapses to minor units: half_up, half_even, up or down. No default — state it. |
unitPrice |
The per_unit rate, the package scheme's price per package, and the per_seat scheme's price per seat per full period. Decimal string in major units; ignored by banded schemes. |
packageSize |
The package scheme's block size in units, decimal string. Required there, forbidden elsewhere. |
percentBps |
The percentage scheme's rate in integer basis points (100 = 1%). Required there, forbidden elsewhere. |
tiers[].upTo |
The band's inclusive upper bound in units. null marks the last, unbounded band. Bands are ordered and contiguous from zero. |
tiers[].unitPrice |
The rate for graduated and volume. |
tiers[].flatAmount |
The flat charge for tiered. |
allotment.includedUnits |
The first N units of each period rate to zero. |
allotment.rollover |
none, or rollover to carry an unused allowance into the next period. |
commitment.minimumMinor |
Floor: the period bills at least this much, in minor units. |
commitment.spendCapMinor |
Ceiling on the money the period can bill, in minor units. |
commitment.usageCap |
Ceiling on the billable quantity, as a decimal string. |
Answers 200 with the stored configuration and its version. GET on the
same path returns the current one, or 404 with this metric has no price
configured — which is the check to run before you start sending events.
A usage limit policy is a separate control from
commitment.usageCap: it is whatGET /billing-api/v1/contracts/{contractId}/usagereports ascapQuantity/capMode, and ahardpolicy rejects ingestion past the cap (see the failure table below). Asoftone only alerts.
Per-seat pricing
per_seat prices what a customer HOLDS rather than what it consumes, so it is
the one scheme that is not rated from ingested events. It reads a seat
history: an append-only record of how many seats the contract holds and from
when, so a period with mid-cycle changes can be priced day by day.
Configure it against the reserved metric platform.seat.active:
curl -s -X PUT "$FYNEX_API_BASE/usage/contracts/42/metrics/platform.seat.active/price" \
-H "Authorization: Bearer $FYNEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"effectiveFrom": "2026-01-01",
"config": {
"scheme": "per_seat",
"currency": "EUR",
"rounding": "half_up",
"unitPrice": "10.00"
}
}'
The metric and the scheme are reserved for each other. per_seat is
configurable only on platform.seat.active, and platform.seat.active
accepts only per_seat — either mismatch answers 400. The pairing is not
housekeeping: a per_seat price on an ordinary metric is a period the close
pass can never rate (it has a quantity, and this scheme has no use for one), so
it would simply never bill; and any other scheme on the seat metric would rate
your seats from whatever quantity happened to be ingested against it rather
than from the recorded seat history.
unitPrice is the price of ONE seat for a FULL period. allotment,
allotment.rollover and commitment.usageCap are refused on this scheme —
they are denominated in "units", and the unit here is a day-weighted seat
integral rather than a consumption counter, so the same number would not mean
the same thing. commitment.minimumMinor and commitment.spendCapMinor work
exactly as they do everywhere else.
How a period is priced. Seats are integrated over the seller's calendar days (in the timezone on the organization profile, UTC when none is set), and each day is charged at the seat count that day ENDS with — the count in effect after every seat change effective on that day or earlier:
amount = unitPrice × seatDays ÷ periodDays
Two consequences worth stating, because they are what everyone asks:
- A seat added on a day is charged for that day.
- A seat added and removed on the same day is charged for nothing. The day is not charged once per change; it is charged once, at the count the day ends with — so a same-day add and remove cannot double count.
Worked example. A contract on €10.00 per seat, January 2026 (31 days). It opens the month with 3 seats, goes to 5 on the 11th, and back to 4 on the 21st:
| Days | Seats | Seat-days |
|---|---|---|
| Jan 1–10 (10) | 3 | 30 |
| Jan 11–20 (10) | 5 | 50 |
| Jan 21–31 (11) | 4 | 44 |
| Total | 124 |
124 seat-days ÷ 31 days = 4 seat-equivalents, so the period bills €40.00 — not €50.00 (the peak) and not €30.00 (the opening). Re-rating the same history always produces the same amount; a correction is a new seat change, never an edit of a recorded one.
How a per-seat period is billed. Exactly like every other metric's, and on the same schedule: when the period has ended, it closes into one usage invoice line and the invoicing lane puts that line on a document. Two things are worth knowing because they are visible on the artifacts:
- The line's
quantityis the period's seat-days — 124 in the example above, not 4. Seat-days is the exact integer the amount is derived from; the divisor (periodDays) and the seat-equivalent figure (seatEquivalents) are in the line'sratingBreakdownbeside the day-run trace. The amount on the line is already the prorated money, so a document reads €40.00 whatever the quantity column says. - A period with no seats recorded still closes, at zero — the same way a
metric with no events does, so a
commitment.minimumMinortrues up on silence rather than being skipped.
Because seats are not ingested, this lane is driven by the price document rather than by arriving events: a contract is billed for seats from the first period its per-seat price is effective for, on the contract's own billing cycle. Periods that ended before the price existed are never billed retroactively.
Recording seat changes is not on this API yet. Seats are recorded and read
on the dashboard surface — POST and GET /billing/v1/contracts/{contractId}/seats
— which needs a dashboard session, not a seller key. A seller-key route for
seat changes is a later slice; publishing one is a one-way door and the wire
shape of a seat event is exactly what would move. Until then, a per_seat
price you configure here is priced from the history recorded there, and
GET /billing/v1/contracts/{contractId}/seats?periodStart=…&periodEnd=…
returns the day-by-day breakdown above.
Try a price before you commit to it
POST /billing-api/v1/prices/evaluate runs a configuration through the same
engine that bills, at any quantities you name — pure, nothing stored, no
contract required. It is how to see what graduated and volume do to the
same bands before agreeing a term, and it is what the number shown to a
customer during negotiation should come from.
curl -s -X POST "$FYNEX_API_BASE/prices/evaluate" \
-H "Authorization: Bearer $FYNEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"config": { …the same config… }, "quantities": ["0", "5000", "12000"]}'
Each result carries amountMinor (what the period would bill, after the caps
and the minimum), the flags saying which of those limits acted, and the exact
per-band lines. Up to 100 quantities per call, so a whole price curve is one
request.
A per_seat configuration answers 422 here: that scheme is rated from the
contract's seat schedule and is not evaluable per quantity — there is no
quantity that expresses when the seats changed. Read the period's amount and
its day-by-day breakdown from
GET /billing/v1/contracts/{contractId}/seats?periodStart=…&periodEnd=…
instead.
3. Send events
curl -s -X POST $FYNEX_API_BASE/usage/events \
-H "Authorization: Bearer $FYNEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"contractId": "42",
"metric": "api_calls",
"quantity": "1250",
"occurredAt": "2026-09-14T10:22:31Z",
"idempotencyKey": "req-2026-09-14-8f21",
"metadata": {"region": "eu-west-1"}
}'
{ "status": "ingested", "eventId": 91422, "metered": true }
| Field | Rules |
|---|---|
contractId |
The contract the usage belongs to, as a string. |
metric |
A registered metric name. |
quantity |
Decimal string — a JSON number is a float, and metered quantities must not pass through one. |
occurredAt |
RFC 3339. When the usage happened, not when you sent it: this is what places the event in a billing period — the period of the day it falls on in the seller's timezone. |
idempotencyKey |
Your own key for this event. Resending the same key with the same payload is a no-op. |
metadata |
Optional JSON object, carried for your own audit. |
201 when the event was recorded, 200 with "status": "duplicate" when the
idempotency key was already seen. Retrying is always safe.
Two policies worth knowing before you design your keys:
- The dedup window is unbounded. Keys are unique per seller for the lifetime of the data — not a rolling window — so a key seen once is a duplicate forever. Derive keys from the event's identity (source record id, timestamp), never from a counter you might reset.
- Backdating has no lower bound; the future is refused.
occurredAtmay be arbitrarily far in the past — there is no backdating window to configure or to fall outside of. The only temporal rejection is anoccurredAtahead of now. Two edges worth knowing: an event older than the contract's first billing period is stored but stays unassigned until aggregation picks it up, and an event landing in a closed period follows the next rule down. - A late event never rewrites a closed period. Ingestion compares
occurredAtagainst the metric's close watermark: an event older than the last closed period is accepted and flagged for the correction workflow instead of silently changing an amount that may already be on an issued invoice. Corrections are operator-reviewed, so systematically late feeds should be fixed at the source rather than relied on.
metered: false is the failure that looks like success
An accepted event is stored, but it only reaches the meter if the contract has
an active subscription whose schedule covers occurredAt. When it does not,
the response is still 201, metered is false, and message says so:
{
"status": "ingested",
"eventId": 91423,
"metered": false,
"message": "event stored but not attributed to a billing period; it will not appear on the usage meter until an active subscription's schedule covers occurredAt"
}
Check this flag. Ignoring it is how a month of usage goes unbilled with every call answering 2xx.
Batches
curl -s -X POST "$FYNEX_API_BASE/usage/events:batch" \
-H "Authorization: Bearer $FYNEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"events":[ {...}, {...} ]}'
{
"ingested": 2,
"duplicates": 1,
"rejected": 1,
"items": [
{"index": 0, "status": "ingested", "eventId": 91424, "metered": true},
{"index": 1, "status": "duplicate", "eventId": 91380, "metered": true},
{"index": 2, "status": "rejected", "reason": "unknown metric \"api_call\""},
{"index": 3, "status": "ingested", "eventId": 91425, "metered": true}
]
}
Answers 200 and reports each event by its index; a rejected item does not
stop the others. Two whole-batch failures exist: 413 when the batch holds
more than 500 events (split it — nothing in a refused batch is recorded),
and 500 on an infrastructure fault — retry the entire batch, the idempotency
keys make that safe.
CSV
POST /billing-api/v1/usage/csv takes multipart/form-data with two parts: file (the
CSV) and mapping (one JSON object describing which column is which). It is
the path for backfills and ERP exports rather than live traffic.
4. Read the meter back
Ingested and metered usage appears on this API:
curl -s "$FYNEX_API_BASE/contracts/42/usage" \
-H "Authorization: Bearer $FYNEX_API_KEY"
The snapshot is a live read of the open period — it is not a closed figure. When the period closes, the rated usage becomes invoice lines, and those are what you reconcile money against.
5. Receiving usage from a third party
If the system that produces the usage is not yours to change — an ERP, a vendor's platform — register an inbound endpoint and give them its URL instead of your API key:
curl -s -X POST $FYNEX_API_BASE/usage/webhook-endpoints \
-H "Authorization: Bearer $FYNEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"description":"Acme ERP nightly meter"}'
The response carries the endpoint and the signing secret, once — it is not
retrievable later. The sender then posts usage to
POST /usage/v1/webhooks/{token}, signing the body with that secret. Manage
them with GET /billing-api/v1/usage/webhook-endpoints, POST
/billing-api/v1/usage/webhook-endpoints/{id}/rotate-secret and POST
/billing-api/v1/usage/webhook-endpoints/{id}/revoke.
These are inbound. They are how usage gets in; they are not
notifications about your billing. Outbound notifications — InvoicePaid,
InvoiceOverdue, the credit, subscription and usage-alert lifecycle events —
are the Webhooks guide, delivered through the same signed pipe as payment
events. Polling GET /invoices remains the right tool for reconciling a
period, not for learning that a document settled.
6. When an event cannot be processed
Events that arrive over asynchronous paths — inbound webhooks, CSV rows — cannot answer the sender, so a failure is parked instead of lost:
GET /billing-api/v1/usage/dead-letters— what is parked, with the reason and the attempt count.POST /billing-api/v1/usage/dead-letters/{id}/redrive— reprocess one after fixing the cause (registering the missing metric, for instance).POST /billing-api/v1/usage/dead-letters/{id}/discard— abandon one deliberately.
Synchronous calls (/events, /events:batch) never dead-letter: the caller is
present, so the rejection comes back as an HTTP error.
Failure contract
| Status | When |
|---|---|
400 |
The event is malformed, occurredAt is in the future, or a hard usage cap is already exceeded — the event is not recorded. A CSV upload over 20 MiB is also 400. |
401 |
Missing or invalid key. This surface answers only "not authorized", never the account's state. |
404 |
Unknown metric, or a contract that is not yours. |
409 |
The idempotencyKey was used before with a different payload. Same key, same payload is a duplicate (200), not a conflict. |
413 |
The batch holds more than 500 events. Nothing in it is recorded — split it and resend. |
500 |
Infrastructure fault. Retry — idempotency keys make that safe. |
Error bodies are the same {"error": "…"} envelope as the rest of billing.