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"],
"payment_methods_from_account": false
}
}
]
}What each route can expand
| Route | Expandable |
|---|---|
GET /v1/subscriptions | customer, price, refund_eligibility |
GET /v1/subscriptions/{id} | customer, price, refund_eligibility |
GET /v1/payments | customer, subscription |
GET /v1/payments/{id} | customer, subscription, refund_eligibility |
GET /v1/invoices | customer |
GET /v1/invoices/{id} | customer |
GET /v1/customers | stats |
GET /v1/products | prices, stats, default_price |
GET /v1/products/{id} | prices, stats, default_price |
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.
Expanding needs the scope of what it embeds
An expansion puts another resource inside the response, so the key needs the
read scope of that resource as well as the route’s own. A key restricted to
payments:read can list payments, but asking it for expand=customer would
hand it the customer’s name, email and country, which GET /v1/customers/{id}
would refuse it. So the expansion is refused instead, with a 403 naming the
missing scope and param: "expand":
| Expansion | Also needs |
|---|---|
customer | customers:read |
subscription (on a payment) | subscriptions:read |
price (on a subscription) | prices:read and products:read, because the summary carries the product name |
refund_eligibility (on a subscription) | payments:read |
refund_eligibility (on a payment) | Nothing beyond the route’s own payments:read |
prices, default_price (on a product) | prices:read |
stats (on a product) | subscriptions:read |
stats (on a customer) | subscriptions:read and payments:read |
A full-access key has every scope, so this only ever affects a restricted key. Leave the expansion out, or give the key the scope.
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 resolved once for the whole page, so expanding both relations on a 25-row page adds two lookups, not fifty.
One level only. Expanded objects cannot be expanded further, and there is
no dotted syntax such as customer.subscriptions. Anything not in the table
above, including an empty expand=, is a 400.
A missing relation expands to null, not a 404. A payment whose customer
was deleted, or 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
default_price on a product is an ordinary relation: the price summary named by
default_price_id. prices and stats 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, applies recurring coupons (see MRR and discounts)
and is net of VAT. It is a reporting figure, not an accounting one. Reconcile
against /v1/payments and /v1/invoices, never against this.
currency is null when the subscriptions behind mrr_cents are in more than
one currency, or, with no such subscriptions, when the product’s prices are.
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),
lifetime_cents, which is net cash: charges that settled minus refunds that
settled, and currency, which is null when there is nothing to report or the
charges span more than one currency. 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, or a customer that has since been deleted or purged.
expand=refund_eligibility on a subscription answers “could I refund this
customer right now, and how much”, without your having to find the last charge
and work the window out yourself:
{
"object": "refund_eligibility",
"eligible": true,
"amount_cents": 2900,
"currency": "EUR",
"days_remaining": 5,
"reason": null,
"window_ends_at": 1789949412
}When eligible is false, reason says why: no_charge (no settled charge
that can be refunded, such as before the first payment settles), window_expired (the last charge is older than the price’s refund
window), fully_refunded, or disputed (an open dispute took back what was
left). window_ends_at is when the refund window closes, and is null when no
window applies. It is kept behind expand rather than always present because it
costs real work per row, which is the wrong price to pay on a list nobody is
rendering refund buttons on. See Refunds.
The same object on a payment (GET /v1/payments/{id}?expand=refund_eligibility)
answers for that payment rather than the last charge. Its reasons are
not_paid, unrefundable_type (a payment method verification, which sold
nothing), window_expired, fully_refunded, disputed,
operation_pending (another refund of this payment is still being confirmed;
ask again shortly), and plan_change_pending (a plan change is still settling,
and refunding the whole balance would end the subscription; a partial refund is
allowed meanwhile, so amount_cents is still set). Treat a reason you do not
recognise as “not refundable”; new ones may be added. It is not offered on the
payment list.
Both forms are a guide rather than a reservation: POST /v1/refunds makes the
final decision under a lock. The subscription form does not check for a plan
change or another refund in flight, so a refund it calls eligible can still be
refused with 409 operation_pending.
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 more
than the subscription’s discount shows: whether the bound coupon recurs,
and whether its window still covers the next charge. It is also net of VAT: on
a tax-inclusive price the VAT the customer’s charge carries is taken out first.
| 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, leaving VAT aside, 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