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.