REST response shapes
The exact JSON shapes a frontend consumer codes against: the content-entry envelope, the field-data object per content type, the asset object, the rich-text node tree, and the webhook payload. These are the authoritative shapes — the live OpenAPI 3.1 document at /v1/<workspace>/openapi.json is generated from your schema and always matches the running API.
OpenAPI 3.1
Every workspace exposes a generated spec:
curl -H "Authorization: Bearer $KEY" \
"https://api.estokad.com/v1/<workspace>/openapi.json"
A read-scope key is enough. The spec is regenerated from content_types on every request, so a defineType() push is reflected the same tick. It contains one components.schemas entry per content type (the field-data shape), one <Type>Entry envelope per type, the shared Asset, RichTextDocument, RichTextNode, Error schemas, and the per-type CRUD + publish paths. Point an OpenAPI client generator (openapi-typescript, orval, etc.) straight at it.
Content-entry envelope
GET /v1/<workspace>/content/<type>/<id> returns:
{
"entry": {
"id": "uuid",
"spaceId": "uuid",
"environmentId": "uuid",
"contentTypeId": "uuid",
"slug": "string | null", // denormalized from data.slug
"title": "string | null", // per-entry title, independent of fields
"data": { /* the type's field-data object — see below */ },
"publishedAt": "ISO8601 | null",
"publishedBy": "uuid | null",
"scheduledPublishAt": "ISO8601 | null",
"scheduledUnpublishAt": "ISO8601 | null",
"createdAt": "ISO8601",
"createdBy": "uuid | null",
"updatedAt": "ISO8601",
"updatedBy": "uuid | null",
"deletedAt": "ISO8601 | null"
},
"hasDraft": true, // present with read_draft+ scope
"locale": "fr-BE", // present when ?locale= used
"variantStatus": "published", // draft | review | published | null
"fallbackFrom": "en-US", // set when the locale fell back
"spaceLocales": { "default": "en-US", "supported": ["en-US", "fr-BE"] }
}
List endpoint GET /v1/<workspace>/content/<type>?first=N&offset=M:
{ "entries": [ /* envelopes as above */ ],
"meta": { "first": 20, "offset": 0, "count": 12 } }
Localization: pass ?locale=fr-BE. Only fields declared localized: true override from the locale variant; everything else comes from the default-locale data. fallbackFrom is set when the requested locale resolved through the fallback chain.
Field-data shapes by field type
entry.data is keyed by your field names. Each defineType() field type serializes as:
| Field type | JSON shape | Notes |
|---|---|---|
| text, slug | string | |
| markdown | string | raw markdown |
| richText | { type: "doc", content: [...] } | Tiptap JSON — see below |
| number | number (or integer) | |
| boolean | boolean | |
| datetime | string (ISO 8601) | |
| date | string (YYYY-MM-DD) | |
| enum | string | one of the declared options |
| geoPoint | { "lat": number, "lng": number } | |
| asset | string (asset id) | resolve via variant-url |
| assetList | string[] (asset ids) | |
| reference | string (entry id) | |
| referenceList | string[] (entry ids) | polymorphic when to is an array |
| embedded | nested object | shape = the embedded type |
| json | arbitrary JSON | |
Asset and reference fields carry ids, not embedded objects. Resolve them with a second call (the asset variant-url endpoint, or a content fetch by id). GraphQL resolves them inline if you prefer one round-trip.
Drafts may be incomplete: POST /content/<type> and PATCH /content/<type>/<id> accept a partial data. The full required shape is enforced only at POST /content/<type>/<id>/publish. Pass a top-level title on PATCH to set the per-entry title.
Rich text
A richText field is a Tiptap / ProseMirror JSON document — a node tree, not Portable Text and not HTML:
{
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Hello " },
{ "type": "text", "text": "world", "marks": [{ "type": "bold" }] }
]
},
{
"type": "heading",
"attrs": { "level": 2 },
"content": [{ "type": "text", "text": "Subheading" }]
}
]
}
Render it with any Tiptap-JSON renderer (e.g. @tiptap/html's generateHTML, or a React walker). The API can also return rendered HTML/Markdown on request.
Asset object
The asset metadata shape (from the asset list/upload endpoints):
{
"id": "uuid",
"filename": "hero.jpg",
"mimeType": "image/jpeg",
"sizeBytes": 482113,
"width": 1920,
"height": 1080,
"durationSeconds": null,
"storageKey": "<spaceId>/<assetId>.jpg",
"storageBucket": "scaleway-os",
"altText": "string | null",
"altTextByLocale": { "fr-BE": "…" },
"caption": "string | null",
"metadata": { "focal": { "x": 50, "y": 40 }, "tags": ["hero"] },
"isPublic": true,
"createdAt": "ISO8601",
"createdBy": "uuid | null",
"deletedAt": "ISO8601 | null"
}
There is no stable url property. Request a delivery URL:
curl -X POST -H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{ "width": 1200, "format": "webp", "quality": 80 }' \
"https://api.estokad.com/v1/<workspace>/content/_assets/<id>/variant-url"
{ "url": "https://cdn.estokad.com/<spaceId>/<assetId>.<ext>",
"fallbackUrl": "https://api.estokad.com/v1/<ws>/content/_assets/<id>",
"signed": false, "processed": false }
On production, public (isPublic) assets are served unauthenticated from https://cdn.estokad.com/<storageKey> — Scaleway Edge Services (CDN) in front of the assets bucket. Private assets keep a private object ACL and are only reachable via the bearer-authed fallbackUrl stream endpoint. signed/processed are false until on-the-fly image transforms (imgproxy) are enabled; the URL is the original then.
url is canonical — always take the host from it, never hand-build paths. For Next.js, use estokadImageLoader from @estokad/next instead of images.remotePatterns: a custom loader bypasses Next's host allowlist entirely and avoids double image processing (the CDN already serves the final asset).
Webhooks
Configure subscribers from /settings. The POST body:
{ "event": "entry.published",
"createdAt": "ISO8601",
"workspaceId": "uuid",
"spaceId": "uuid",
"data": { /* event-specific */ } }
Headers: X-Estokad-Event, X-Estokad-Signature: sha256=<hex>, X-Estokad-Delivery, X-Estokad-Attempt. The signature is HMAC-SHA256(subscriberSecret, rawBody) hex-encoded — verify it before acting. Delivery retries on non-2xx at 0s, 30s, 5m, 30m, 2h, 6h; 100 cumulative failures auto-disables the subscription; 5 s per-request timeout.
Event names: entry.created, entry.updated, entry.published, entry.unpublished, entry.deleted, asset.created, schema.pushed, audit.event.created.
entry.published data: { targetType, targetId, locale, promotedDraft }.
audit.event.created data: { sequence, hash, prevHash, action, targetType, targetId, actorType, actorId } — workspace-wide fan-out, carries the hash chain so a SIEM can detect dropped events.