Skip to content
Talk to our solutions team

Content quickstart

One content type from declaration to delivery, with the same content read back through both APIs.

Every request below carries a bearer token and the four tenancy headers. Set them once:

Terminal window
TOK=<your bearer token>
H=(-H "Authorization: Bearer $TOK" \
-H 'X-Customer: acme' -H 'X-Product: site' -H 'X-Env: prod' -H 'X-Tenant: acme')
BASE=https://content.example.com

A content type is an entity with a cms: block, in a file of its own in the product’s content/ folder. This walkthrough uses three files:

content/
types/blog.post.yaml # the content type
apps/blog/app.yaml # the app that signs the preview token in step 9
stores/stores.yaml # the store the image in step 8 is uploaded to

content/types/blog.post.yaml holds the entity and nothing around it:

entities:
- name: blog.post
status: active
default-order: documentid
cms:
kind: entry # entry | singleton
space: blog
access:
actions:
read: { language: expr, expression: 'true' }
create: { language: expr, expression: '"editor" in user.roles' }
update: { language: expr, expression: '"editor" in user.roles' }
delete: { language: expr, expression: '"admin" in user.roles' }
fields:
- name: id
type: ulid
defaultvalue: ulid()
validations:
- type: required
- type: final
- name: title
type: string
required: true
cms: { localized: true }
- name: slug
type: string
required: true
cms: { localized: true }
- name: body
type: text
nullable: true
- name: hero
type: file # a reference into the asset plane
nullable: true
- name: price
type: int
nullable: true
cms: { localized: false }
- name: authorid
type: ulid
nullable: true

content/apps/blog/app.yaml names the type as one of the app’s own. Naming it declares nothing; the file above does that:

name: blog
active: true
contentstore: default
contenttypes:
- blog.post
preview:
secret: ${CONTENT_PREVIEW_SECRET}

content/stores/stores.yaml declares the store. Where its bytes live is the tenant’s configuration, see Operations:

contentstores:
- name: default
type: native
active: true
access:
createasset: { language: expr, rule: 'true' }
getasset: { language: expr, rule: 'true' }
downloadasset: { language: expr, rule: 'true' }

No file names a datastore. The Data block declares datastores once, and the type lands in the one declared for the Content block.

Three things happen on the next engine build:

  1. documentid, locale, status and publishedon are added to the entity, along with the identity index over (documentid, locale).
  2. The table is created, or the additive part of the difference is applied to the existing one.
  3. The type becomes addressable as /api/posts and as /spaces/blog/environments/master/entries?content_type=post.
Terminal window
curl -s "${H[@]}" "$BASE/spaces/blog/environments/master/content_types" | jq '.items[].sys.id'
["post"]

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

Terminal window
curl -s "${H[@]}" -X POST "$BASE/api/posts?locale=en" \
-H 'Content-Type: application/json' \
-d '{"data": {"title": "Hello world", "slug": "hello", "body": "First post.", "price": 100}}'
{
"data": {
"id": "01J9Z…",
"documentId": "01J9Y…",
"locale": "en",
"title": "Hello world",
"slug": "hello",
"body": "First post.",
"price": 100,
"createdAt": "2026-09-15T09:12:44Z",
"updatedAt": "2026-09-15T09:12:44Z"
},
"meta": {}
}

A new entry is a draft. There is no publishedAt, and it does not appear on the delivery path until you publish it. Keep the documentId: it is the cross-locale identity, and it is what both APIs address the document by.

Terminal window
DOC=01J9Y…
curl -s "${H[@]}" -X PUT "$BASE/api/posts/$DOC?locale=en" \
-H 'Content-Type: application/json' \
-d '{"data": {}, "publish": true}'

publish is a state move, not a field. Omit it and an edit leaves the publish state alone, which is what you want when correcting a typo on a live page.

Terminal window
curl -s "${H[@]}" "$BASE/api/posts?locale=en"
{
"data": [
{
"id": "01J9Z…",
"documentId": "01J9Y…",
"locale": "en",
"title": "Hello world",
"slug": "hello",
"body": "First post.",
"price": 100,
"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 } }
}

Filters, sorting and pagination are Strapi’s own:

Terminal window
curl -s "${H[@]}" "$BASE/api/posts?locale=en&filters[price][\$gt]=50&sort=title:asc&pagination[pageSize]=10"

6. Read the same entry through the Contentful API

Section titled “6. Read the same entry through the Contentful API”
Terminal window
curl -s "${H[@]}" "$BASE/spaces/blog/environments/master/entries?content_type=post&locale=en"
{
"sys": { "type": "Array" },
"total": 1,
"skip": 0,
"limit": 100,
"items": [
{
"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", "body": "First post.", "price": 100 }
}
]
}

sys.id is the same value as documentId. One identity, two envelopes.

A document is one row per locale. Write the German row against the same documentId:

Terminal window
curl -s "${H[@]}" -X PUT "$BASE/api/posts/$DOC?locale=de" \
-H 'Content-Type: application/json' \
-d '{"data": {"title": "Hallo Welt", "slug": "hallo"}, "publish": true}'

price carries cms: { localized: false }, so its value is propagated across the document’s locale rows on write. A shared value cannot drift between translations.

Publish state is per locale row, so German can sit in draft while English is live.

Assets live in a content store. Upload first:

Terminal window
curl -s "${H[@]}" -X POST "$BASE/store/default/asset?path=/images&createfolders=true" \
-F "file=@./hero.jpg"
{ "id": "01J9X…", "name": "hero.jpg", "contenttype": "image/jpeg", "filesize": 184320 }

Then set the hero field to a reference to it:

Terminal window
curl -s "${H[@]}" -X PUT "$BASE/api/posts/$DOC?locale=en" \
-H 'Content-Type: application/json' \
-d '{"data": {"hero": "kisai-asset:default/01J9X…"}}'

On delivery, a file field comes back as both the reference and a URL, so a client can display it and re-reference it without a second lookup:

"hero": {
"ref": "kisai-asset:default/01J9X…",
"url": "/store/default/asset/id/01J9X…"
}

The URL is relative, because the service sits behind a gateway that may rewrite paths.

Append image parameters to it to get a derivative:

/store/default/asset/id/01J9X…?w=800&fm=webp

See Image transforms.

Your site holds a delivery credential for published content and no editorial rights. Someone who can edit the type mints a scoped grant and puts it in the link they share:

Terminal window
curl -s "${H[@]}" -X POST "$BASE/app/blog/preview-token" \
-H 'Content-Type: application/json' \
-d "{\"type\": \"posts\", \"documentId\": \"$DOC\", \"locale\": \"en\", \"ttlSeconds\": 900}"
{
"token": "kp1.eyJzcCI6…",
"expiresAt": "2026-09-15T09:30:00Z",
"type": "blog.post",
"space": "blog",
"documentId": "01J9Y…",
"locale": "en"
}

The site presents it alongside its ordinary credential:

Terminal window
curl -s "${H[@]}" "$BASE/api/posts/$DOC?locale=en&preview=kp1.eyJzcCI6…"

Presenting a token is itself the request for the draft view, so a pasted link needs nothing else. An explicit status=published still wins, which is how the same link checks the live version.

Server-rendered pages should use the X-Preview-Token header instead, which keeps the grant out of referrers and browser history.

Terminal window
curl -s "${H[@]}" -X DELETE "$BASE/api/posts/$DOC?locale=de"

204 No Content. The locale is part of the address, so this removes the German row and leaves English alone.