Tutorial: usage-based billing, end to end

Draft. Written from the repository's documents and code; not yet reviewed by the owner.

You will bill one customer for what it used, in the sandbox, from the first call to a paid invoice. It takes about ten minutes and uses only curl, so nothing here depends on a language. Every step names the page that explains it.

You need a sandbox key (qk_test_…, see Getting started) and your API address:

export BASE=https://YOUR-API-ADDRESS            # http://localhost:42800 on a local stack
export PAYRUDDER_API_KEY=qk_test_…
api() { curl -sS -H "Authorization: Bearer $PAYRUDDER_API_KEY" -H "Content-Type: application/json" "$@"; }

Nothing below touches real money: a sandbox key reaches the simulator (Test mode).

1. Say what you count

api $BASE/v1/meters -d '{"code":"api_calls","aggregation":"AGGREGATION_SUM","value_property":"n","quantity_scale":0,"unit_label":"calls"}'

A SUM meter adds up the quantities of its events; quantity_scale is how many decimal places a quantity may have, and cannot change later (Usage metering).

2. Say what it costs

A package (plan) is created once, then gets immutable versions that hold the prices:

api $BASE/v1/plans -d '{"code":"starter","name":"Starter"}'

api $BASE/v1/plans/starter/versions -d '{
  "base_fee_minor": "1000",
  "prices": [{ "meter_code": "api_calls", "per_unit": { "unit_price": "0.01" } }]
}'

base_fee_minor is the fixed fee in the currency's minor units (cents), as a string. per_unit prices each unit of the meter; graduated tiers, packages and an included allowance are in the PriceInput model. Publishing again makes version 2; customers stay on the version they subscribed to.

3. Add the customer and subscribe it

api $BASE/v1/customers -d '{"external_id":"cus_42","name":"Acme Ltd","email":"billing@acme.example","payment_method":"pm_sandbox_success"}'

api $BASE/v1/subscriptions -d '{"customer_external_id":"cus_42","plan_code":"starter","fee_timing":"FEE_TIMING_ARREARS"}'

external_id is your id for the customer; every later call uses it. The pm_sandbox_* payment method tells the simulator how the payment goes (this one succeeds; Test mode lists the others). FEE_TIMING_ARREARS bills the fixed fee together with the month's usage, so there is one invoice to look at.

4. Send usage

EVENT_TIME=$(date -u -v-1M +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d '1 minute ago' +%Y-%m-%dT%H:%M:%SZ)

api $BASE/v1/events -d '{"events":[{"customer_external_id":"cus_42","meter_code":"api_calls","quantity":"100","event_time":"'"$EVENT_TIME"'","idempotency_key":"tutorial-1"}]}'

The answer has one result per event: accepted, duplicate (send the same idempotency_key again and see) or rejected with an error_code (Errors). event_time is when the usage happened; it decides the billing period. The same thing from the command line: payrudder events send --customer cus_42 --meter api_calls --quantity 100.

Read it back (usage is counted a moment after it is accepted):

api $BASE/v1/customers/cus_42/usage

5. Close the month now

A real invoice waits for the month to end. In the sandbox you do not have to:

api -X POST $BASE/v1/sandbox/close-period -d '{}'

It answers 202 and runs the real billing flow: cutoff, rating, numbering (SBX-…) and the payment saga against the simulator. Poll for the invoice:

api "$BASE/v1/invoices?customer=cus_42"   # note the id of the invoice: INVOICE_ID=…

The invoice has a BASE_FEE line and a USAGE line for api_calls, and its status moves to paid when the simulated payment succeeds. Totals are integers in minor units, as strings. payrudder invoices list --customer cus_42 shows the same.

6. Try a declined payment

Repeat steps 3 to 5 with a second customer (cus_43) whose payment_method is pm_sandbox_declined; GET /v1/sandbox/scenarios lists every outcome the simulator knows. Its invoice is not paid, and Payrudder retries on the compressed sandbox schedule. To run the payment again under another outcome:

api -X POST $BASE/v1/sandbox/invoices/$INVOICE_ID/simulate -d '{"scenario":"success"}'

Your application learns about a payment, paid or not, from webhooks, not by polling.

Where next