Skip to content
Talk to our solutions team

Strapi delivery

Strapi’s own paths, unprefixed. A site written against Strapi v5 needs its base URL changed and nothing else.

GET /api/:plural a page of entries
GET /api/:plural/:documentId one document
POST /api/:plural create
PUT /api/:plural/:documentId update
DELETE /api/:plural/:documentId delete

Strapi has no concept of a space, because one Strapi project is one space. A plural in the path is therefore looked up across every space the tenant declares.

  • One match is the answer.
  • No match is 404, and the message lists the spaces.
  • Several matches is 400, and the message lists the qualified names to choose from. Qualify the path segment as blog.post to select one.
ParameterDefaultMeaning
localeevery localeSelect one locale’s rows.
statuspublisheddraft shows unpublished entries. Anything else is 400.

status=draft shows the draft view and does not mix published entries into it, which is what an editor checking unfinished work wants.

Drafts require permission to edit the type, or a preview token.

Strapi’s bracket syntax, on the entity’s own fields and through declared relations.

?filters[title][$eq]=Hello world
?filters[price][$gt]=200
?filters[slug][$in]=hello,second
?filters[author][name][$eq]=Ada # through a relation
OperatorMeaning
$eqequal. The default when no operator is given
$nenot equal
$lt $lte $gt $gteordered comparison
$in $notInmembership. Comma-separated, or repeated keys
$contains $notContainssubstring
$startsWith $endsWithprefix and suffix
$null $notNullpresence. Takes a flag, not a value

Two filters are a conjunction. Alternatives use an indexed $or group, where the index identifies the branch:

?filters[$or][0][slug][$eq]=hello&filters[$or][1][slug][$eq]=second

Two keys sharing one index are a conjunction within that branch.

An operator outside the table is refused with 400 rather than ignored. A dropped predicate returns more rows than were asked for, which is the direction that exposes content.

A filter naming a field the type does not declare is 400. A value the column cannot hold is 400 and names the problem, rather than reaching the backend and returning an internal failure.

?sort=title:asc
?sort=title:desc,createdAt:asc

Repeated sort=, the sort[]= array form and indexed sort[0]= are all accepted, which covers both hand-written URLs and what a client library emits. A direction other than asc or desc is 400, as is an unknown field.

?fields=title,slug

id, documentId and locale are always projected, whether or not they were listed. A response whose documentId depended on the request would break every client that follows a link.

?populate=author
?populate=author.publisher # two levels, named
?populate[0]=author # indexed form
?populate[author][populate][publisher] # object form, same as author.publisher
?populate[author]=false # switched off

Relations are resolved one query per relation per level, batched across the level. The cost is depth × distinct relations and does not move with the number of rows returned. Twenty-five posts sharing three authors and two publishers is three queries.

The depth ceiling is 10. A deeper request is refused with the number.

Resolving a relation goes through the target type’s own read rule, so a caller sees only what they are entitled to.

A populated relation is rendered inline, as a nested entry.

ParameterDefaultNotes
pagination[page]1
pagination[pageSize]25Clamped to 100
pagination[start]0The offset form
pagination[limit]25Clamped to 100
pagination[withCount]truefalse omits pageCount and total, and saves a query

A page with no explicit sort is ordered by (documentId, locale), which is arbitrary but stable, so page two never repeats a row from page one.

The surface speaks Strapi’s vocabulary in both directions.

StrapiStored as
documentIddocumentid
createdAtcreatedon
updatedAtupdatedon
publishedAtpublishedon

Entries are flat, as in Strapi v5. The fields sit directly on the data object.

A page:

{
"data": [
{ "id": "01J9Z…", "documentId": "01J9Y…", "locale": "en", "title": "Hello world",
"createdAt": "2026-09-15T09:12:44Z", "updatedAt": "2026-09-15T09:14:02Z",
"publishedAt": "2026-09-15T09:14:02Z" }
],
"meta": { "pagination": { "page": 1, "pageSize": 25, "pageCount": 1, "total": 1 } }
}

One document:

{ "data": { "id": "01J9Z…", "documentId": "01J9Y…", "title": "Hello world" }, "meta": {} }

A document that does not exist in the requested locale is 404 with the error envelope, rather than a null body, so a client can tell it apart from an empty field.

An error:

{ "data": null, "error": { "status": 400, "name": "ValidationError", "message": "unknown field \"nope\" on blog.post" } }
StatusnameWhen
400ValidationErrorThe query was malformed or named something unknown
403ForbiddenErrorWell formed, and the caller is not entitled
404NotFoundErrorNo such type, or no such document in that locale
400ApplicationErrorThe request could not be served

Bookkeeping columns are never rendered: createdby, updatedby, deletedby, deletedon and status. status selects which entries are visible and is not itself content, so echoing it would invite a client to filter on the response instead of the query. This holds at every level, so a populated relation does not expose them either.

The body is {"data": {...}} and the reply is {"data": {...}, "meta": {}}, so a client’s own serialisation round-trips.

Authorization is the entity’s own access rules, carried through the same hook chain as any other write to the entity.

POST /api/posts?locale=en
Content-Type: application/json
{"data": {"title": "Hello world", "slug": "hello"}, "publish": false}

201 Created, with the row that landed.

A new entry is a draft. Publishing whatever was posted would mean one forgotten parameter puts an unfinished entry on the live site.

PUT /api/posts/01J9Y…?locale=en
Content-Type: application/json
{"data": {"title": "Hello, world"}, "publish": true}

200 OK. publish moves the publish state; omitting it leaves the state alone.

DELETE /api/posts/01J9Y…?locale=en

204 No Content. The row is gone, so there is nothing truthful to return about it.

The locale is part of the address. A document is one row per locale, so a write without a locale parameter writes the default locale rather than every translation.

Identity columns are stripped, not refused. A caller may not choose its own documentId, since it could collide with or impersonate another document. These arrive naturally when a client round-trips an entry it has just read, so they are removed in both spellings rather than rejected.

Fields marked localized: false are propagated across the document’s locale rows.

The body is bounded at 8 MiB. An entry is text somebody wrote; bytes belong in the asset plane, behind a field of type file. A body over the cap is 400 and says so, rather than failing as malformed JSON.

Only content types are addressable. A caller names a content type and reaches it through the same resolution the read surfaces use, so the schema’s other entities are not reachable here.

StatusnameWhen
400ValidationErrorThe body was not {"data": {...}}, was too large, or a value was rejected
403ForbiddenErrorThe entity’s rules refused this caller
404NotFoundErrorNo such type, or no such document in that locale