Skip to content
In this section

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/v1 on purpose: it is authenticated by payload signature rather than by your key, and every operation under /billing-api/v1 is key-gated.

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

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


1. Register a metric

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

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, 404 when it is not registered.

2. Price the metric on a contract

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

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 what GET /billing-api/v1/contracts/{contractId}/usage reports as capQuantity / capMode, and a hard policy rejects ingestion past the cap (see the failure table below). A soft one only alerts.

Per-seat pricing

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

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

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

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

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

Try a price before you commit to it

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

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. occurredAt may be arbitrarily far in the past — there is no backdating window to configure or to fall outside of. The only temporal rejection is an occurredAt ahead of now. Two edges worth knowing: an event older than the contract's first billing period is stored but stays unassigned until aggregation picks it up, and an event landing in a closed period follows the next rule down.
  • A late event never rewrites a closed period. Ingestion compares occurredAt against the metric's close watermark: an event older than the last closed period is accepted and flagged for the correction workflow instead of silently changing an amount that may already be on an issued invoice. Corrections are operator-reviewed, so systematically late feeds should be fixed at the source rather than relied on.

metered: false is the failure that looks like success

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

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