Expand
expand[] objects on retrieve and list responses.
Status: shipped. Affects every retrieve/list route in scope.
What it does
Pass ?expand[]=customer&expand[]=invoice on a wallet retrieve or list request and the response carries the hydrated customer / invoice object as a sibling of the flat customer_id / invoice_id fields. No expand → response is identical to today.
The flat ID stays present on every response. The sibling object appears only when the corresponding expand[] is in the query string. Nothing is removed; the addition is opt-in per request.
Default vs expanded shape
Examples use $WALLET for the API base —
{base_url}/api/v1/{app}/platform/wallet (see
Quick guide).
# Default — IDs only, byte-identical to pre-expand response
curl -u $KEY: "$WALLET/charges/chg_123"
{
"id": "chg_123",
"customer_id": "cus_abc",
"invoice_id": "inv_xyz",
...
}
# With expand — sibling objects appear
curl -u $KEY: "$WALLET/charges/chg_123?expand[]=customer&expand[]=invoice"
{
"id": "chg_123",
"customer_id": "cus_abc",
"customer": { "id": "cus_abc", "object": "customer", "name": "...", ... },
"invoice_id": "inv_xyz",
"invoice": { "id": "inv_xyz", "object": "invoice", "total": ..., ... },
...
}Grammar
- Repeat the parameter for each path:
?expand[]=customer&expand[]=invoice. - Dot-notation walks nested resources:
?expand[]=invoice.customerhydrates the charge's invoice and that invoice's customer in one request. - Maximum depth is 4 segments. Going deeper returns
400withexpand path "..." exceeds max depth 4. - Path segments must match
[a-z][a-z0-9_]*. Anything else returns400. - Duplicates and overlapping paths are deduped:
expand[]=customer&expand[]=customer.address&expand[]=customeris equivalent toexpand[]=customer.address. The orchestrator walks each unique sub-tree once per page.
What can be expanded
The registry is centralized at backend/wallet/app/bootstrap/expand_registry.go. Every entry is one line — adding a new expandable field on a new (or existing) route is a one-line change there plus the response-struct interface methods on the target domain.
| Resource | Fields |
|---|---|
balance | customer |
payment_method | customer |
payout | customer |
receipt | customer |
charge | customer, invoice, payment_method, balance |
invoice | customer, quote, subscription |
subscription | customer, payment_method |
transfer | from_customer, from_balance, to_customer, to_balance |
refund | charge, transfer |
credit_note | customer, invoice, subscription |
quote | customer, invoice |
balance_transaction | balance, transfer |
Requesting a field that isn't registered for the resource returns 400.
Performance
Each (field, page) is one batch DB round-trip with the deduped parent IDs. A page of 20 charges with ?expand[]=customer&expand[]=invoice is exactly two extra queries — never N+1.
Failure mode
If a resolver fails on a single path, the sibling field is omitted (omitempty keeps it absent), the orchestrator logs the failure, and the rest of the response renders as if expand wasn't requested for that field. Other expands on the same request are unaffected. The HTTP response is still 200.
Out of scope (today)
- Field-level partial selection (Stripe doesn't have it either; expand always returns the full target object).
- Expand on POST / PUT bodies — only retrieve and list routes apply.
- Expand on webhook payloads — webhooks have their own response shaping in
app/common/http/webhook.publisher.go.
How to add a new expandable field
- On the parent response struct: add a pointer field (
Customer *customer.CustomerResponse \json:"customer,omitempty"`) and the twoExpandableResponsemethods (GetExpandableIDs,SetExpanded). If the type doesn't already implementResource()`, add that too. - Make sure the target domain has
FindManyByIDs(ctx, ids, appCountryID)on its repository — copy the shape used bycustomer,charge,invoice, etc. - One line in
BuildExpandRegistry:r.Register("<resource>", "<field>", <targetResolver>(repos.<TargetRepo>)).
That's the whole thing. No handler change, no pagination change, no test change beyond a smoke test on the new field.