# Usage ingestion

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.

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

```bash
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"}'
```

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

```bash
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](#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`**:

```bash
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 **UTC calendar days**,
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.

```bash
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

```bash
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"}
  }'
```

```json
{ "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. |
| `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:

```json
{
  "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

```bash
curl -s -X POST "$FYNEX_API_BASE/usage/events:batch" \
  -H "Authorization: Bearer $FYNEX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"events":[ {...}, {...} ]}'
```

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

```bash
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:

```bash
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. There are no outbound Billing webhooks in
v1 — for invoice settlement, poll.

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