Metered billing, end to end
Charge for consumption. Create a per-unit price, post usage records, and let BillKit close the period and collect.
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”.
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:
intervalmust bemonth. A yearly meter is refused.trial_daysmust be 0. A trial has no meaning when the first period’s charge is already zero until usage arrives.refund_on_cancelmust staynone. 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.
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.
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_mode | 1,500 units against "first 1,000 at 1c, then 0.5c" |
|---|---|
| graduated | Each band prices the units inside it: 1,000 × 1c plus 500 × 0.5c, so €12.50. |
| volume | The 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.
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"}
}'{
"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"}
}| Field | Rules |
|---|---|
| quantityinteger | Required. Between 1 and 1,000,000. Integers only, so pre-round fractional units to your billing granularity. |
| occurred_atinteger | Optional, Unix seconds. Defaults to now. At most 5 minutes in the future, and never before the subscription was created. |
| metadataobject | Optional. 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-Keyheader | A retry of this HTTP request: a timeout, a dropped connection, an SDK retry. The stored response is replayed. See Idempotency. |
| identifierbody field | A 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:
- Sum the
quantityof every unbilled record whoseoccurred_atis beforecurrent_period_end. - 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.createdfires. - Stamp
invoice_idon exactly those records, and roll the period forward one month. - 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
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:
curl https://api.billkit.eu/v1/subscriptions/sub_Pv9Kc3nX8mQ2rTyLbd/usage_summary \
-H "Authorization: Bearer $BILLKIT_API_KEY"{
"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
| Operation | Result |
|---|---|
| pause | 409 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. |
| Coupons | Refused at checkout. |
| Trials | Refused at price creation and at checkout. |
| Portal plan switching | Metered 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.
BillKit