BillKit/DocsConsole
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 Oct 19, 2026·OpenAPI v1.1.0

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 (subscription and price fields abridged)
{
"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

RouteExpandable
GET /v1/subscriptionscustomer, price, refund_eligibility
GET /v1/subscriptions/{id}customer, price, refund_eligibility
GET /v1/paymentscustomer, subscription
GET /v1/payments/{id}customer, subscription, refund_eligibility
GET /v1/invoicescustomer
GET /v1/invoices/{id}customer
GET /v1/customersstats
GET /v1/productsprices, stats, default_price
GET /v1/products/{id}prices, stats, default_price
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.

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":

ExpansionAlso needs
customercustomers: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:

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, 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:

refund_eligibility
{
"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 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, 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.