Skip to main content

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:

ParamMeaningDefaultNotes
page1-indexed page number1
limitpage size25max 200
qfree-text search term-only on resources where search applies
filtersresource-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.