API conventions
Conventions on this page apply across every domain, not just one - they exist so a new domain's endpoints look and behave like the ones already shipped, without re-deriving the shape each time.
This page covers request/response shape. Domain meaning (Product vs Rate, Client vs
Customer, Cycle, etc.) is defined by
docs.bill.run Definitions and mapped on
BillRun definitions → core-api. The two data-level conventions -
UUID primary keys with descriptive foreign-key names, and soft deletion via deleted_at - live
on Identity and deletion, which every domain also follows.
Pagination
Every list endpoint (GET /api/v1/<resource>) takes the same request shape and returns the same
response envelope, built once in app/schemas/pagination.py and reused per resource.
Request query params:
| Param | Meaning | Default | Notes |
|---|---|---|---|
page | 1-indexed page number | 1 | |
limit | page size | 25 | max 200 |
q | free-text search term | - | only on resources where search applies |
| filters | resource-specific filter fields | - | only where applicable |
Unknown query params are rejected with 422 (PageParams sets extra="forbid"), so a typo'd
filter name fails loudly instead of being silently ignored.
Response shape:
{
"results": [{ "...": "..." }],
"meta": { "page": 1, "limit": 25, "total": 143 }
}
Implementing a new paginated endpoint
PaginatedResponse[ItemT, MetaT] is a Pydantic generic - used bare, FastAPI generates an OpenAPI
schema name from the type params (PaginatedResponse_WidgetRead_PaginationMeta_), which is ugly
in the docs UI and in generated clients. Always give it a real, named subclass instead:
# app/schemas/widget.py
from app.schemas.pagination import PaginatedResponse, PaginationMeta
class WidgetsPaginatedResponse(PaginatedResponse[WidgetRead, PaginationMeta]):
"""Named concrete alias so the OpenAPI schema is `WidgetsPaginatedResponse`, not the
generic-parameterization name Pydantic would otherwise generate."""
# app/api/v1/routes/widgets.py
from app.schemas.pagination import PageParams, PaginationMeta
from app.schemas.widget import WidgetRead, WidgetsPaginatedResponse
# No search/filters needed - use the base params directly:
@router.get("", response_model=WidgetsPaginatedResponse)
async def list_widgets(
db: DbSession, params: Annotated[PageParams, Query()]
) -> WidgetsPaginatedResponse:
widgets, total = await widgets_service.list_widgets(
db, offset=params.offset, limit=params.limit
)
return WidgetsPaginatedResponse(
results=[WidgetRead.model_validate(w) for w in widgets],
meta=PaginationMeta(page=params.page, limit=params.limit, total=total),
)
Extend PageParams/PaginationMeta when a resource needs search or filters - subclass rather
than modifying the base:
class WidgetListParams(PageParams):
q: str | None = None
status: WidgetStatus | None = None
class WidgetListMeta(PaginationMeta):
status: WidgetStatus | None = None # echoes the applied filter back to the caller
then use params: Annotated[WidgetListParams, Query()] and read params.q/params.status in the
route. The service layer takes plain offset/limit (and whatever filter values apply) rather
than the Pydantic params model itself, so it stays usable outside an HTTP request (scripts, other
services, tests).
:::caution Don't add filter params as siblings of the Query()-annotated params model
Annotated[PageParams, Query()] relies on FastAPI flattening the model's fields into individual
query params (page, limit, ...). Adding another Query()-annotated parameter next to it in
the same route signature breaks that flattening silently - FastAPI stops flattening and instead
expects a literal query param named params with the whole model as its value, which is not what
you want and isn't caught by tests unless you specifically hit the list endpoint with a filter.
Always add the
extra field to a PageParams subclass (as above) instead of a sibling parameter.
:::
First shipped for Accounts (GET /api/v1/accounts,
AccountsPaginatedResponse). Subscribers (SubscribersPaginatedResponse,
via SubscriberListParams for q, group_id, plan_id, status, sort, ...) and Customers
(CustomersPaginatedResponse, via CustomerListParams for q/sort) follow the same pattern, as do
Connectors, Events, and
Support Tickets.
:::caution Never paginate by a UUID id
Primary keys are UUIDs, which have no meaningful sort order - ordering a page by id shuffles rows
arbitrarily between pages. List service functions order by created_at, id (or another explicit sort) instead. See
Identity and deletion.
:::
Every list endpoint also excludes soft-deleted rows, and doesn't count them in meta.total -
again, see Identity and deletion.
When not to paginate: bounded sub-resource lists
This convention is for top-level resource collections (GET /api/v1/<resource>), where the row
count is unbounded and grows with usage. It doesn't apply to a bounded sub-resource of one
already-identified parent - a list that's naturally small and doesn't grow with overall system
usage, just with that one parent's own history.
Subscribers' revision history
(GET /api/v1/subscribers/{subscriber_id}/revisions) is the first example: it returns every revision of
one subscriber (a handful at most, not thousands), so it returns a plain list rather than
{"results": [...], "meta": {...}}. Reach for this exception only when the list is provably
bounded by something other than total resource count - don't use it as a shortcut to skip
pagination on what's actually a top-level collection.