BillKit/DocsConsole
Usage guide

Metered billing, end to end

Charge for consumption. Create a per-unit price, post usage records, and let BillKit close the period and collect.

25 Sept 2026·Intermediate·12 min read

Metered billing charges for what a customer used rather than a fixed fee. In BillKit it is a property of the price, and it changes how a period is billed: a licensed subscription is charged its fixed price in advance when a period starts, while a metered one is closed at each period end, invoiced for the usage, and charged in arrears. BillKit charges both itself.

Create a metered price

Set usage_type: "metered". amount_cents stops meaning “the period fee” and starts meaning “the price of one unit”.

shell
curl https://api.billkit.eu/v1/prices \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
  "product_id": "prod_kQ2v8Zr4nT1yLpXaBc",
  "amount_cents": 2,
  "currency": "EUR",
  "interval": "month",
  "usage_type": "metered",
  "payment_methods": ["creditcard"]
}'

That is €0.02 per unit, billed monthly. Three constraints are enforced at create time:

  • interval must be month. A yearly meter is refused.
  • trial_days must be 0. A trial has no meaning when the first period’s charge is already zero until usage arrives.
  • refund_on_cancel must stay none. A cancellation refund ends access immediately, which on a meter would strand every record posted since the last close.

Rates below one cent

amount_cents is a whole minor unit, which is too coarse for most API-call pricing. Send unit_amount_decimal instead, as a string in minor units with up to 12 decimal places. It is metered-only, and it is mutually exclusive with amount_cents.

shell
curl https://api.billkit.eu/v1/prices \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
  "product_id": "prod_kQ2v8Zr4nT1yLpXaBc",
  "unit_amount_decimal": "0.02",
  "currency": "EUR",
  "interval": "month",
  "usage_type": "metered",
  "payment_methods": ["creditcard"]
}'

"0.02" is 0.02 cents, so €0.0002 per unit. It is a string in both directions because most JSON parsers read a bare number into a double, and 0.0002 has no exact binary form, so the rate would be corrupted before it was ever multiplied. Never parse it into a float.

Every price reads back a unit_amount_decimal, whether or not you declared one, so a client can read that one field and be right. amount_cents on a decimal price is its rounded projection and can legitimately be 0.

Tiered rates

Set billing_scheme: "tiered" and hand over a tier table instead of a rate. Tiered prices are metered-only, and tiers_mode is required rather than defaulted, because the same table read two ways is two different bills.

shell
curl https://api.billkit.eu/v1/prices \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
  "product_id": "prod_kQ2v8Zr4nT1yLpXaBc",
  "currency": "EUR",
  "interval": "month",
  "usage_type": "metered",
  "billing_scheme": "tiered",
  "tiers_mode": "graduated",
  "tiers": [
    {"up_to": 1000, "unit_amount_cents": 1},
    {"up_to": "inf", "unit_amount_decimal": "0.5"}
  ],
  "payment_methods": ["creditcard"]
}'
tiers_mode1,500 units against "first 1,000 at 1c, then 0.5c"
graduatedEach band prices the units inside it: 1,000 × 1c plus 500 × 0.5c, so €12.50.
volumeThe total picks one band and that band prices everything: 1,500 × 0.5c, so €7.50.

The rules on a table, each of them a 400 that names what is wrong: up_to strictly increasing, the last band open-ended ("inf"), at most 50 bands, and every band naming a price. Write a free band as unit_amount_cents: 0 rather than by leaving the rate out, so “free” is something the price says rather than something it forgot. A band may also carry flat_amount_cents, charged once for reaching the band, in whole minor units. Do not send amount_cents or unit_amount_decimal alongside a tier table.

There is one meter per subscription, not many. BillKit has no named meters, no aggregation choice and no per-event names: usage is a stream of integers against one subscription, summed. If you bill for two different things, sell two subscriptions.

Checkout

Identical to any other price. The customer goes through POST /v1/checkout/sessions as normal, with one difference in what gets charged: because the period’s cost is not knowable yet, only trial_verification_cents is taken (100 cents by default, with VAT added on a tax-exclusive price). Its only job is to mint the mandate BillKit will charge later. A coupon_code or trial_days_override on a metered checkout is a 400.

That charge shows on the customer’s statement as “product name mandate verification”. It is a real charge and it is not automatically refunded, so either keep it small or tell the customer what it is. It gets no invoice, and refunding it does not end the subscription.

Once the payment settles, the subscription is active with a one-month period.

Post usage

occurred_at is left off here on purpose, so it defaults to now. Send it only when the work happened earlier than the call, and never earlier than the subscription itself: a timestamp before created is refused with a 400.

shell
curl https://api.billkit.eu/v1/subscriptions/sub_Pv9Kc3nX8mQ2rTyLbd/usage_records \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: job-4471-batch-19" \
-d '{
  "quantity": 4200,
  "metadata": {"job_id": "job_4471"}
}'
Response · 200 OK
{
"id": "ur_Bd6Xk9wQ2nR5tMvPzy",
"object": "usage_record",
"created": 1789344012,
"livemode": false,
"subscription_id": "sub_Pv9Kc3nX8mQ2rTyLbd",
"quantity": 4200,
"occurred_at": 1789344000,
"invoice_id": null,
"identifier": null,
"written_off_at": null,
"metadata": {"job_id": "job_4471"}
}
FieldRules
quantityintegerRequired. Between 1 and 1,000,000. Integers only, so pre-round fractional units to your billing granularity.
occurred_atintegerOptional, Unix seconds. Defaults to now. At most 5 minutes in the future, and never before the subscription was created.
metadataobjectOptional. Up to 50 keys.

Records are immutable and always additive. There is no set mode and no way to correct one after the fact, so post from the code path that actually did the work.

Nothing deduplicates on quantity and timestamp, because two API calls in the same second is a normal thing for a busy customer to do. There are two ways to dedupe, and they cover different failures:

Covers
Idempotency-KeyheaderA retry of this HTTP request: a timeout, a dropped connection, an SDK retry. The stored response is replayed. See Idempotency.
identifierbody fieldA retry of your own call: a job runner replaying a task, a queue delivering twice, your code re-invoking after its own timeout. Each of those arrives as a genuinely new request with a new key, so the header cannot see it.

identifier is unique within the subscription and is up to 255 characters. Report the same one twice and the first record comes back unchanged rather than a second being written, so the usage is billed once. Compare the returned id against the one you already have to tell a fresh write from a replay.

Send the id of the thing you are metering: the job id, the message id, the request id. If it is already unique in your system it is already the right identifier, and it is the only dedupe that survives your own retries.

Usage is only accepted while the subscription is active or past_due. A customer whose last invoice failed is still consuming, and dropping that usage would mean never billing for it. Anything else, including canceled, is refused with 409 state_conflict, as is a subscription on a price that is not metered.

What happens at period close

BillKit sweeps every few minutes. When current_period_end passes, for one subscription:

  1. Sum the quantity of every unbilled record whose occurred_at is before current_period_end.
  2. Price that total through the rate or tier table, rounding once, and compute VAT on it. Open an invoice with a single usage line. invoice.created fires.
  3. Stamp invoice_id on exactly those records, and roll the period forward one month.
  4. Charge the invoice total against the stored mandate.

On SEPA Direct Debit, for a customer with an email address, the debit waits for the customer to be told about it: at the close BillKit fires invoice.upcoming and emails the customer the amount and the date, and the invoice’s due_at is set 3 days out. The charge in step 4 runs once due_at has passed. A card is charged at the close.

On success the invoice becomes paid and invoice.paid, subscription.updated and payment.succeeded fire. On failure the invoice stays open, the subscription goes past_due, and invoice.payment_failed, subscription.past_due and payment.failed fire.

Two cases produce no invoice at all. Zero usage rolls the period and bills nothing. Usage worth less than 100 cents gross also bills nothing, but leaves the records unstamped so they roll into the next period rather than being lost. Payment providers refuse or overcharge on amounts that small.

A tier table applies once per billing period, and rolled usage keeps its own period. When an invoice bills usage from more than one period, each period’s records are priced through the tier table separately and the amounts are added, so a “first 1,000 free” allowance is granted once per month, not once per invoice. The usage line’s period then starts where the earliest billed usage did. A per-unit rate is linear, so it is priced on the combined total.

Late usage is never dropped

The sum has no lower bound on occurred_at. A record posted after its period closed, even one backdated into that period, lands on the next invoice instead. You bill one cycle late, but you bill. On a tiered price that record is priced as part of the period it occurred in, on top of what that period’s invoice already billed.

When a charge fails

BillKit retries the open invoice 24 hours after each failure, up to 4 attempts that count against the customer. Every retry charges the invoice as it was closed, so the amount never drifts. A decline that says the payment method cannot be charged again (an expired card, a closed bank account, a debit the customer refused, a bank asking for the customer to be present) stops the retries, and so does a chargeback on a metered charge for one of those reasons: next_payment_attempt is null, nothing is charged until the customer updates their payment method, and the charge follows on the new one. The decline table is the one in Recover failed payments. BillKit gives up when the attempts are spent, or 14 days after the invoice was due (30 days for SEPA Direct Debit), whichever comes first. At that point the invoice is marked uncollectible, the subscription is cancelled with cancellation_reason: payment_failed, and invoice.marked_uncollectible plus subscription.canceled fire.

An attempt that failed for a reason that is not your customer’s (your Mollie credentials were refused, or Mollie never confirmed the charge) is not counted, and your customer is not told about it. While your credentials are failing nothing is charged and nothing is cancelled.

A licensed subscription retries on a different schedule, set by the payment method and the decline. See Recover failed payments.

Reading usage back

shell
curl "https://api.billkit.eu/v1/subscriptions/sub_Pv9Kc3nX8mQ2rTyLbd/usage_records?invoice_id=pending&limit=100" \
-H "Authorization: Bearer $BILLKIT_API_KEY"

invoice_id=pending returns only the records not yet billed, which is the running total for the current period. Pass a real inv_... instead to see exactly what a given invoice was built from, which is the query to reach for when a customer disputes a line.

A record that will never be billed carries written_off_at. That happens when the subscription stopped billing before a close could pick the usage up. It is terminal, and such records are excluded from invoice_id=pending, because reporting them as about to be billed would be a lie about money.

What the next close will actually charge

Listing records tells you the quantity. usage_summary tells you the money, priced through the same rate or tier table the close uses and taxed the same way:

shell
curl https://api.billkit.eu/v1/subscriptions/sub_Pv9Kc3nX8mQ2rTyLbd/usage_summary \
-H "Authorization: Bearer $BILLKIT_API_KEY"
Response · 200 OK
{
"object": "usage_summary",
"livemode": false,
"subscription_id": "sub_Pv9Kc3nX8mQ2rTyLbd",
"period_start": 1789344000,
"period_end": 1791936000,
"currency": "EUR",
"pending_quantity": 4200,
"pending_record_count": 1,
"net_cents": 8400,
"tax_cents": 1764,
"gross_cents": 10164,
"will_charge": true,
"minimum_charge_cents": 100,
"open_invoice_id": null
}

Render this on an in-app “usage this month” panel. Two fields need handling. will_charge is false when the cycle would bill nothing, either because there is no usage or because the total is under minimum_charge_cents; that usage rolls into the next period. open_invoice_id names an earlier cycle that is invoiced and still unsettled, and while one is open this period cannot be charged.

What metered subscriptions cannot do

OperationResult
pause409 state_conflict. Usage would keep accruing with nothing to bill it against. With no pause there is nothing to resume: resume on a metered subscription changes nothing.
update (plan change)400. Proration is arithmetic on a fixed period fee, and a per-unit price has none. Cancel and start a new checkout.
CouponsRefused at checkout.
TrialsRefused at price creation and at checkout.
Portal plan switchingMetered prices are excluded from the switch list.

cancel and reactivate both work normally. Cancelling runs one final close at current_period_end that bills every outstanding record, including any posted up to the moment the close runs. If that final total is under the 100-cent minimum, the records are written off instead.

Testing it

Attach the customer to a test clock, post some usage, then advance the clock past current_period_end. The close, the invoice and the charge attempt all run before the advance call returns, so you can read the open invoice immediately afterwards. It becomes paid when the payment settles.