Skip to content
Talk to our solutions team

Contentful delivery

Contentful’s own paths, over the same content the Strapi surface reads. A site written against the CDA needs its base URL changed and nothing else.

GET /spaces/:space/environments/:env/entries
GET /spaces/:space/environments/:env/entries/:id
GET /spaces/:space/environments/:env/content_types

The environment-scoped spellings are served too, for clients configured with a base URL that already carries the environment:

GET /spaces/:space/entries
GET /spaces/:space/entries/:id
GET /spaces/:space/content_types

The :space segment selects a content space. An unknown space is 404, and the message lists the spaces the tenant declares.

The :env segment is accepted and is not a selector. A Contentful environment is a copy of a space’s content, which on this platform is a tenant, and the tenant is already established by the request’s CEPT headers. Accepting the segment keeps every client URL working; treating it as a second tenancy axis would let a token choose its own data.

GET /spaces/blog/environments/master/entries?content_type=post

A type-less query is 400. There is no single table to read, and fanning out across every content type would turn one request into one query per type. The refusal says so rather than answering with an empty array.

A by-id fetch does not need one, because the id is a document identity:

GET /spaces/blog/environments/master/entries/01J9Y…

The type is found by asking each of the space’s types for that document, which is what the CDA’s own /entries/{id} does.

ParameterDefaultNotes
content_typerequired on /entriesType name or plural
localethe row’s own* pivots every locale into one entry
limit100Clamped to 1000
skip0
include1Link resolution depth. Above 10 is 400
order(documentId, locale)- prefixes a descending term
selectevery fieldComma-separated

Field equality and bracketed operators, in the CDA’s spelling:

?fields.slug=hello
?fields.price[gte]=300
?fields.slug[in]=hello,second
?sys.id=01J9Y…
OperatorMeaning
nenot equal
in ninmembership
lt lte gt gteordered comparison
existspresence
matchsubstring

A field the type does not declare is 400.

?order=fields.title
?order=-sys.createdAt,fields.title

A leading minus is descending.

?select=fields.title,fields.slug

sys is always rendered, so naming it does not restrict the projection and is not an error. The columns sys is assembled from are projected whether or not they were listed.

Every entry is sys, which the platform owns, plus fields, which the author owns.

{
"sys": {
"type": "Entry",
"id": "01J9Y…",
"space": { "sys": { "type": "Link", "linkType": "Space", "id": "blog" } },
"contentType": { "sys": { "type": "Link", "linkType": "ContentType", "id": "post" } },
"locale": "en",
"createdAt": "2026-09-15T09:12:44Z",
"updatedAt": "2026-09-15T09:14:02Z",
"firstPublishedAt": "2026-09-15T09:14:02Z",
"publishedAt": "2026-09-15T09:14:02Z",
"revision": 1
},
"fields": { "title": "Hello world", "slug": "hello", "price": 100 }
}

sys.id is the document identity, and it is the same value as the Strapi surface’s documentId. An entry resolved through a link by one client is findable by the other under the same name.

A published entry carries revision, firstPublishedAt and publishedAt. A draft carries none of them, which is how a client tells them apart without a status field.

{
"sys": { "type": "Array" },
"total": 2,
"skip": 0,
"limit": 100,
"items": [ /* entries */ ],
"includes": { "Entry": [ /* linked entries, once each */ ] }
}

includes is present only when links were resolved.

The CDA does not inline a linked entry. The field holds a Link, and the entries themselves arrive once in a top-level includes.Entry[] however many fields point at them.

"fields": {
"title": "Hello world",
"author": { "sys": { "type": "Link", "linkType": "Entry", "id": "01J9A…" } }
}

That shape is why include is a depth rather than a list of names: the client walks links against the includes block.

Four properties are worth knowing.

The block is flat at every depth. A post’s author and that author’s publisher are siblings in includes.Entry[], joined by the Link the author carries. Nesting the publisher inside the author would put it out of reach of a client that follows links by id.

An entry appears once. A page of twenty-five posts by the same author carries one author.

Resolution is batched across each level. Every parent sharing a relation at level n contributes its keys to one query, so the cost is depth × distinct relations and never a function of the number of rows. Twenty-five posts, three authors and two publishers is three queries.

A cycle terminates. A model where a page’s parent is a page resolves to exactly the depth asked for.

Each link is resolved through the target type’s own read rule, so include never becomes a way to read a type the caller is not entitled to.

locale=* collapses a document’s locale rows into one entry whose localized fields are maps of locale to value, which is how Contentful stores content natively.

{
"sys": { "type": "Entry", "id": "01J9Y…" },
"fields": {
"title": { "en": "Hello world", "de": "Hallo Welt" },
"price": 100
}
}

Fields marked localized: false are rendered as plain values, since they are the same across every locale by construction. Wrapping them would tell a client they vary.

sys.locale is omitted on this view, because a pivoted entry is not in one locale.

The pivot gathers a document’s rows and is the call to reach for in management and migration tooling. ?locale=en is the delivery path, and is a single-row read.

The CDA models preview as a separate host with its own token. One instance serves both here, so the draft view is selected by any of:

SignalFor
X-Contentful-Preview: trueA client that can set a header
A /preview/ path prefixA client pointed at a preview base URL
A preview tokenA front end with no editorial credential

Drafts require permission to edit the type, or a token minted by somebody who has it.

GET /spaces/blog/environments/master/content_types
{
"sys": { "type": "Array" },
"total": 1, "skip": 0, "limit": 1,
"items": [
{
"sys": { "type": "ContentType", "id": "post",
"space": { "sys": { "type": "Link", "linkType": "Space", "id": "blog" } } },
"name": "post",
"displayField": "title",
"fields": [
{ "id": "title", "name": "title", "type": "Symbol", "required": true, "localized": true },
{ "id": "body", "name": "body", "type": "Text", "required": false, "localized": false },
{ "id": "price", "name": "price", "type": "Integer","required": false, "localized": false },
{ "id": "hero", "name": "hero", "type": "Link", "required": false, "localized": false }
]
}
]
}

Field types are mapped into Contentful’s vocabulary, which is what a client’s codegen reads:

DeclaredRendered
stringSymbol
textText
int, bigintInteger
float, decimal, moneyNumber
boolBoolean
datetime, date, timeDate
jsonObject
arrayArray
fileLink

The CMS columns are not listed as fields, since they belong to sys.

{
"sys": { "type": "Error", "id": "NotFound" },
"message": "The resource could not be found.",
"requestId": ""
}
StatusidWhen
400BadRequestcontent_type missing, or a malformed query string
400InvalidQueryAn unknown field, or include above the ceiling
403AccessDeniedWell formed, and the caller is not entitled
404NotFoundUnknown space, unknown type, or no such entry