Skip to content
Talk to our solutions team

Preview

Delivery returns published content by default. Two things, and only two, open the draft view: being entitled to edit the content type, or presenting a signed grant from somebody who is.

The rule: you may preview what you may change

Section titled “The rule: you may preview what you may change”

A draft read is gated on the content type’s update rule.

access:
actions:
read: { language: expr, expression: 'true' }
update: { language: expr, expression: '"editor" in user.roles' }

With this type, an editor may read ?status=draft. A reader with no editorial privilege may not.

This keeps one authorization tier: the answer comes from the entity’s own access rules, evaluated with the same bindings and the same evaluator a write uses, so a rule means the same thing everywhere.

The gate fails closed. A type with no update rule is a type nobody is declared able to edit, so nobody can preview it either, including an administrator.

The site rendering a preview holds a delivery credential for published content and no editorial rights. Giving it editorial rights so that it can render drafts would hand every visitor the editor’s access.

A preview token is the narrow grant that closes the gap. Someone who can already preview mints one scoped to exactly what they want shown, and it travels in the link they share.

POST /app/:contentapp/preview-token
Content-Type: application/json
{
"type": "posts",
"space": "blog",
"documentId": "01J9Y…",
"locale": "en",
"ttlSeconds": 900
}
FieldRequiredMeaning
typeyesType name or plural. A grant must name a type
spacenoResolved from the type when omitted
documentIdnoNarrows to one document. Absent means the whole type
localenoNarrows to one locale. Absent means any
ttlSecondsnoDefaults to 1800. Clamped to 86400
{
"token": "kp1.eyJzcCI6…",
"expiresAt": "2026-09-15T09:30:00Z",
"type": "blog.post",
"space": "blog",
"documentId": "01J9Y…",
"locale": "en"
}

Minting runs the same gate a direct draft read runs, so a token conveys access its issuer already had and never more.

StatusWhen
200Minted
400No type in the body, or the body is not valid JSON
403The caller may not preview this type, so they may not delegate it
404No such type, or the plural is ambiguous across spaces
501The app has no signing secret configured

The 501 is deliberate. An app with no secret refuses rather than signing with an empty key: a token that looks like it is protecting something and is not is worse than no preview at all. See Operations.

Two spellings, both accepted on both delivery surfaces.

GET /api/posts/01J9Y…?locale=en&preview=kp1.eyJzcCI6…
GET /api/posts/01J9Y…?locale=en
X-Preview-Token: kp1.eyJzcCI6…

Prefer the header wherever the caller can set one. It stays out of logs, referrers and browser history, all of which a query string ends up in. The query parameter exists because a link an editor pastes into a browser cannot carry a header.

The token elevates a request; it does not authenticate one. The delivery routes still require the site’s ordinary credential, and a grant is never examined on a request that has not identified itself. The worst a leaked preview URL does is show its own narrow scope to somebody who could already read the published site.

Presenting a token is the request for drafts

Section titled “Presenting a token is the request for drafts”

A link an editor pastes carries the token and nothing else, so requiring a second parameter would make every such link silently show published content.

An explicit status still wins:

?preview=kp1.… the draft view
?preview=kp1.…&status=published the live version, same link

On the Contentful surface, presenting a token selects the preview view in the same way.

Every term is inside the signature, so the scope cannot be edited in the address bar. Widening a term invalidates the token rather than widening the grant.

TermEmpty in the grantEmpty in the request
typenot allowednot applicable
spacematches a spaceless request onlymust match
documentIdthe whole typedoes not satisfy a document-scoped grant
localeany localemust match a locale-scoped grant

The asymmetry in the last two rows is the point. A token minted for one document does not answer a request that names no document, because that request would return the whole type.

A locale-scoped grant does not authorise locale=*.

A token also names the app whose key signed it, and is held to it after verification, so it cannot be replayed against another app.

Default30 minutes
Maximum24 hours
Revocationnot available; a token is self-contained and lives out its term

The token is payload plus HMAC with no server-side store, so there is nothing to replicate between instances and nothing to clean up. The short default lifetime is what bounds a leaked link.

Mint a fresh token per preview session rather than holding one open.

  1. An editor opens the entry in your editing UI and clicks Preview.
  2. Your UI calls POST /app/blog/preview-token with the editor’s credential, naming the type, document and locale.
  3. Your UI builds the preview URL for the site, carrying ?preview=<token>.
  4. The site’s server-side render reads the token from its own query string and passes it on as X-Preview-Token alongside the site’s delivery credential.
  5. The delivery API returns the draft. When the token expires, the same URL returns the published version, or 404 if the document has never been published.