Skip to content

Response Contract

A frontend reads every v2 response with the same assumptions. These rules are those assumptions; a response schema that breaks one breaks a consumer that never looked at the module. The fields of each endpoint are in the CMS Admin API and Storefront API references.

A value that is inactive, empty or missing is null. The field is never omitted, never an empty string, and never 0 or false standing in for “no value”.

// Correct: the field is present, the value is null
{"price": null, "special_price": null, "description": null}
// Wrong: the field is omitted
{"description": "..."}
// Wrong: an empty string or a zero as "no value"
{"price": "", "description": ""}
{"price": "0.00"}

The frontend can write if (product.price) everywhere, and never has to guess whether a missing field means “not applicable” or “forgotten”. It follows that a response schema declares every field on every response: the set of fields is fixed, only the values change.

  • Arrays: [], never null, never omitted.
  • Objects used as maps (attributes by SKU): {}, never null.
  • Single objects (price): null when absent.
  • snake_case everywhere.
  • Booleans: is_ or has_ prefix (is_active, has_special_price).
  • References: _idx for business identifiers, _id for database primary keys. Do not expose a primary key when an idx exists.
  • Translations: _t9n for a JSON dict of translations (name_t9n).

One endpoint has one envelope. Pick it by the cost of counting.

Counted, for admin lists and small datasets. DRF’s page-number pagination, through the module’s AdminPageNumberPagination:

{
"count": 142,
"next": "https://.../?page=2",
"previous": null,
"results": []
}

Uncounted, for storefront lists where COUNT(*) is expensive (a large catalog, complex filters). The view fetches one row more than the page size to learn whether a next page exists:

{
"has_next_page": true,
"page": 2,
"page_size": 24,
"results": []
}

In both, results is always an array; an envelope may add keys of its own next to these. An endpoint with the uncounted envelope that needs a total offers it as a separate {resource}/count/ endpoint, cached on its own.

  • A monetary value is a decimal string ("349.00"), never a float.
  • A price carries both gross and net; the frontend’s configuration decides which one it shows first.
  • A rate is a decimal string too ("0.23"), never a display form ("23%").
  • No price means price: null, never an object of nulls.

A list of media is an array sorted by position, [] when empty. An image carries a source_set (one entry per rendered width, each with its width and height) and never a single hard-coded URL, so the frontend picks the size.

ISO 8601 with a timezone: "2025-03-24T14:30:00Z". Never a naive datetime, never a Unix timestamp.

Storefront endpoints that return localized or priced data take the same three query parameters:

ParamFormatExample
languageISO 639-1, two lettersen, pl
currencyISO 4217, three lettersEUR, PLN
countryISO 3166-1 alpha-2PL, DE

A missing or unknown value falls back to the channel’s default language, currency or country, when the channel has one. An unknown value alone is not a 400.

An error has one shape on every v2 endpoint, and it never carries SQL, file paths or a stack trace: Error Handling.

Error Handling