BillKit/Docs Console
Build & operate

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.

Updated Sep 17, 2026 · API version 2026-09-13

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.

shell
curl "https://api.billkit.eu/v1/subscriptions?expand=customer,price" \
-H "Authorization: Bearer $BILLKIT_API_KEY"
Response · 200 OK
{
"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

RouteExpandable
GET /v1/subscriptionscustomer, price
GET /v1/subscriptions/{id}customer, price
GET /v1/paymentscustomer, subscription
GET /v1/payments/{id}customer, subscription
GET /v1/invoicescustomer
GET /v1/invoices/{id}customer
GET /v1/customersstats
GET /v1/productsprices, stats
GET /v1/products/{id}prices, stats
GET /v1/eventscustomer

Asking for something a route cannot expand is a 400 that names what it can:

Response · 400 Bad Request
{
"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:

shell
curl "https://api.billkit.eu/v1/products?expand=prices,stats" \
-H "Authorization: Bearer $BILLKIT_API_KEY"
Response · 200 OK (product fields abridged)
{
"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 durationEffect on MRR
onceNone. The first invoice was cheaper; every one after it is not.
repeatingReduces it while the coupon still applies.
foreverReduces 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.