Expanding responses
A response names its relations by id. Add expand and the ones you intend to render come back alongside them, resolved once for the whole page.
The problem it solves
A subscription carries a customer_id and a price_id. That is the right
shape for an API and the wrong shape for a table: a list of twenty-five
subscriptions rendered straight gives you twenty-five pairs of opaque ids,
and the only way to turn them into names is fifty more requests.
expand takes a comma-separated list of relations. Each one is resolved in a
single query across the whole page, and arrives as a nested object beside the
id it already had.
curl "https://api.billkit.eu/v1/subscriptions?expand=customer,price" \
-H "Authorization: Bearer $BILLKIT_API_KEY" {
"object": "list",
"has_more": false,
"data": [
{
"id": "sub_Pv9Kc3nX8mQ2rTyLbd",
"object": "subscription",
"customer_id": "cus_4Bd8Kq2mWn7xPz1Rfe",
"price_id": "price_7Hd3Wm9pQx2vRt5Kna",
"status": "active",
"customer": {
"id": "cus_4Bd8Kq2mWn7xPz1Rfe",
"object": "customer.summary",
"name": "Anna Hauer",
"email": "anna@example.com",
"country_code": "DE"
},
"price": {
"id": "price_7Hd3Wm9pQx2vRt5Kna",
"object": "price.summary",
"product_id": "prod_2Nk7Lv4xTc8mQw3Bhs",
"product_name": "Pro",
"amount_cents": 4900,
"currency": "EUR",
"interval": "month",
"usage_type": "licensed",
"active": true,
"trial_days": 14,
"payment_methods": ["creditcard", "ideal"]
}
}
]
} What each route can expand
| Route | Expandable |
|---|---|
GET /v1/subscriptions | customer, price |
GET /v1/subscriptions/{id} | customer, price |
GET /v1/payments | customer, subscription |
GET /v1/payments/{id} | customer, subscription |
GET /v1/invoices | customer |
GET /v1/invoices/{id} | customer |
GET /v1/customers | stats |
GET /v1/products | prices, stats |
GET /v1/products/{id} | prices, stats |
GET /v1/events | customer |
Asking for something a route cannot expand is a 400 that names what it can:
{
"error": {
"type": "invalid_request_error",
"code": "parameter_invalid",
"param": "expand",
"message": "Cannot expand 'customers'. Expandable here: customer, price, refund_eligibility."
}
} A typo failing loudly is deliberate. Silently ignoring it would be
indistinguishable from a relation that happened to be null, and you would
find out in production.
Rules worth knowing
It is opt-in. A relation is only ever resolved when you name it. A
request without the parameter still carries the expandable keys, set to
null, so customer and price appear on a subscription whether or not you
asked, and are populated only when you did. Nothing that was already in the
response changed name, type or value.
Expanded objects are summaries, not resources. customer.summary carries
what you render in a row, meaning who they are and where they are billed, not the
whole customer. GET /v1/customers/{id} is still how you read a customer.
Expanding never costs a request per row. Each relation is one
WHERE id IN (…) over the page. Expanding both relations on a
25-row page is two extra queries, not fifty.
A missing relation expands to null, not a 404. A payment whose customer
was purged under GDPR is still a real payment, and the page keeps working.
Summaries are versioned like any other part of the API: fields get added, not removed or repurposed. Read them the way you read any response, by the keys you need.
Products carry a second kind
prices and stats on a product are expansions in the same sense, but they
answer questions rather than naming a relation:
curl "https://api.billkit.eu/v1/products?expand=prices,stats" \
-H "Authorization: Bearer $BILLKIT_API_KEY" {
"object": "list",
"has_more": false,
"data": [
{
"id": "prod_2Nk7Lv4xTc8mQw3Bhs",
"object": "product",
"name": "Pro",
"stats": {
"object": "product.stats",
"subscriber_count": 7,
"mrr_cents": 28600,
"currency": "EUR",
"active_price_count": 2
}
}
]
} mrr_cents is normalised to a month: a yearly price contributes a twelfth of
its amount, and a metered price contributes nothing, because its amount is a
per-unit rate rather than a cycle charge. It counts active and trialing
subscriptions. It is a reporting figure, not an accounting one. Reconcile
against /v1/payments and /v1/invoices, never against this.
currency is null when a product’s prices disagree about it. BillKit
prices are per-currency rows, and labelling a mixed-currency total with one of
them would be worse than admitting the mix.
expand=stats on a customer is the same idea from the other side:
subscription_count (live subscriptions, the same set MRR counts) and
lifetime_cents, which is net cash: charges that settled minus refunds that
settled. It is backward-looking, unlike MRR.
expand=customer on an event resolves whatever customer the event’s
payload names. Events are resource snapshots rather than rows with foreign
keys, so this is a lookup by data.customer_id and comes back null for
events that name no customer.
MRR and discounts
Expanding price on a subscription also returns mrr_cents, its monthly
recurring contribution. It is computed server-side because it depends on
something none of the other fields expose: whether the subscription carries a
recurring discount.
| Coupon duration | Effect on MRR |
|---|---|
once | None. The first invoice was cheaper; every one after it is not. |
repeating | Reduces it while the coupon still applies. |
forever | Reduces it permanently. |
So a €79/year plan bought with a 99%-off once coupon still contributes
€6.58/month: the customer paid €0.79 for their first year and will pay €79 for
every year after, and MRR is what recurs. The same coupon set to forever
contributes 7 cents instead. If you want to know what was actually collected,
that is /v1/payments, where the charge carries discount_cents and the
coupon that produced it.
BillKit