# The inConcept site contract

inConcept writes texts; a website publishes them. For WordPress that works
out of the box. For any other website (your own site, a headless CMS,
anything that answers HTTPS) there is this contract: five routes on your
server that inConcept calls, signed with a shared secret. inConcept sends;
your site receives, stores and answers. There is no callback for you to
build: inConcept asks your site about an entry's status itself.

**This page is for the developer who builds the receiver.** It is the one
document the sending side is built against; the receiver on inconcept.ai
(`app/api/site/` in the inConcept repository) is a reference, not a
requirement. Version 1; see [Version and changes](#version-and-changes).

## How it works

```
inConcept                                      your site
    │  GET  {base}              (discover)         │  what collections and categories you have
    │  POST {base}/entries      (create)           │  a draft, a scheduled entry, or live at once
    │  GET  {base}/entries/{id} (read back)        │  every few minutes until you say "published"
    │  PUT  {base}/entries/{id} (update text)      │  the whole entry again
    │  PATCH/DELETE  …/{id}     (schedule, remove) │  back to draft, a new moment, or gone
```

1. **Connect.** Whoever manages the brand in inConcept enters the base
   address of your receiver (`https://your-site.nl/api/inconcept`, say), a
   key id of your choosing and a shared secret. inConcept asks the base
   address what the site offers (discovery, optional) and stores the
   collections and categories.
2. **Bind.** Each inConcept text type is bound to one of your collections.
   The category is chosen per text when it is published.
3. **Publish.** When a text goes to the site, inConcept `POST`s an entry.
   Depending on what the person chose it is a draft, scheduled for a moment,
   or live at once. Approving the text in inConcept is what allows it to go
   live; a text that is set back to concept is `PATCH`ed back to draft.
4. **Read back.** From the scheduled moment on, inConcept `GET`s the entry
   every couple of minutes until your site says `published`, then records
   the public address.
5. **Withdraw or delete.** Withdrawing a publication in inConcept `PATCH`es
   the entry back to draft, so it goes offline but stays. Deleting it from
   the site `DELETE`s the entry; a site that does not delete answers `405`
   and the person is told to withdraw instead.

## Quickstart

Ten minutes to a working receiver.

1. **Agree a key.** Pick a key id (any short word, it is sent in the clear)
   and generate a secret: `openssl rand -hex 32`. Keep both on your server
   as environment variables; the person who manages the brand in inConcept
   enters the same two values.
2. **Paste the minimal receiver.** One route handler that verifies the
   signature and stores the entry. This one is Next.js; the
   [reference](#reference) below has the signing code in PHP as well.

```ts
import { createHash, createHmac, timingSafeEqual } from "node:crypto"

function verify(request: Request, body: string, secret: string, keyId: string): boolean {
  const h = request.headers
  if (h.get("x-inconcept-key-id") !== keyId) return false
  const ts = Number(h.get("x-inconcept-timestamp"))
  if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false
  const url = new URL(request.url)
  const canonical = `${ts}.${request.method.toUpperCase()}.${url.pathname}${url.search}.${createHash("sha256").update(body).digest("hex")}`
  const expected = `v1=${createHmac("sha256", secret).update(canonical).digest("hex")}`
  const given = h.get("x-inconcept-signature") ?? ""
  return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected))
}

export async function POST(request: Request) {
  const body = await request.text()
  if (!verify(request, body, process.env.INCONCEPT_SECRET!, process.env.INCONCEPT_KEY_ID!)) {
    return Response.json({ error: "bad_signature" }, { status: 401 })
  }
  const entry = JSON.parse(body)
  // store entry, copy entry.image.url, …
  return Response.json({ id: "…", url: "https://your-site.nl/blog/…", status: entry.status, publishAt: entry.publishAt }, { status: 201 })
}
```

3. **Connect it in inConcept.** Under the brand's website, choose "Eigen
   website", enter the base address, the key id and the secret. inConcept
   asks your discovery route what the site offers; without discovery, the
   collections are entered by hand.
4. **Run the check, then send a test entry.** `site-check.mjs` (see
   [Testing and going live](#testing-and-going-live)) runs every call against
   your receiver from your own machine. Then the connection window's
   "Proefbericht sturen" sends one draft entry through the real path and
   shows what your site answered; repeating it updates the same entry.
5. **Bind and publish.** Bind each text type to a collection, choose a
   category when publishing, and the first real article goes out. From then
   on inConcept reads scheduled entries back until they are live.

## Reference

Base address: what the customer entered, `https://`, no trailing slash. All
bodies are JSON, UTF-8. Every body carries `"version": 1`.

### Requests

| Call | Request | Answer |
|---|---|---|
| Discover (optional) | `GET {base}` | `200` with the [site description](#the-site-description-discovery), or `404` when you prefer to configure collections by hand in inConcept |
| Create | `POST {base}/entries`, [entry body](#the-entry-body) | `201` with the [entry answer](#the-entry-answer); `200` with the existing entry when the idempotency key was seen before |
| Read | `GET {base}/entries/{id}` | `200` with the entry answer; `404 { "error": "not_found" }` when it is gone |
| Update | `PUT {base}/entries/{id}`, entry body | `200` with the entry answer |
| Schedule | `PATCH {base}/entries/{id}`, `{ "version": 1, "status": "draft" \| "scheduled" \| "published", "publishAt": ISO-8601 or null }` | `200` with the entry answer |
| Delete | `DELETE {base}/entries/{id}` | `204`, or `404 { "error": "not_found" }` when it is gone already; `405 { "error": "unsupported" }` when your site does not delete, and inConcept then tells the person to withdraw instead (the entry goes back to draft) |

### Authentication

Every request carries three headers:

- `X-InConcept-Key-Id`: the key id the customer entered.
- `X-InConcept-Timestamp`: Unix time in seconds. Refuse a request more than
  300 seconds off your clock (`stale_timestamp`).
- `X-InConcept-Signature`: `v1=` followed by the hex HMAC-SHA256, with the
  shared secret, of the canonical string
  `{timestamp}.{METHOD}.{path}.{sha256hex(body)}` where `path` is the request
  path including the query string (never the host), `METHOD` is upper case,
  and `body` is the raw request body as received (empty for a `GET` or a
  `DELETE`). Hash the bytes you received, not a re-serialised object.

Compare in constant time. A request that fails this answers `401` with
`bad_signature` (or `stale_timestamp`).

`POST {base}/entries` also carries `X-InConcept-Idempotency-Key`: the text's
id in inConcept. When you have an entry under that key already, answer `200`
with it instead of making a second one.

### Signing in two languages

The same canonical string, hashed with the same secret, on both sides. In
TypeScript (Node.js):

```ts
import { createHash, createHmac } from "node:crypto"

export function sign(secret: string, ts: number, method: string, pathWithQuery: string, body: string): string {
  const canonical = `${ts}.${method.toUpperCase()}.${pathWithQuery}.${createHash("sha256").update(body).digest("hex")}`
  return `v1=${createHmac("sha256", secret).update(canonical).digest("hex")}`
}
```

In PHP:

```php
<?php
function inconcept_signature(string $secret, int $ts, string $method, string $pathWithQuery, string $body): string {
    $canonical = $ts . '.' . strtoupper($method) . '.' . $pathWithQuery . '.' . hash('sha256', $body);
    return 'v1=' . hash_hmac('sha256', $canonical, $secret);
}

function inconcept_verify(string $secret, string $keyId): bool {
    if (($_SERVER['HTTP_X_INCONCEPT_KEY_ID'] ?? '') !== $keyId) return false;
    $ts = (int) ($_SERVER['HTTP_X_INCONCEPT_TIMESTAMP'] ?? 0);
    if (abs(time() - $ts) > 300) return false;
    $expected = inconcept_signature($secret, $ts, $_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'], file_get_contents('php://input'));
    return hash_equals($expected, $_SERVER['HTTP_X_INCONCEPT_SIGNATURE'] ?? '');
}
```

`REQUEST_URI` is the path with its query string, which is what the signature
covers; `hash_equals` compares in constant time.

### The entry body

```json
{
  "version": 1,
  "source": {
    "assignmentId": "9d2e…", "tenantId": "4f1a…",
    "brand": { "id": "…", "name": "inConcept" },
    "contentType": { "slug": "blog", "name": "Blog" },
    "form": "uitleg"
  },
  "collection": "blog",
  "category": { "id": 2, "key": "uitleg", "name": "Uitleg" },
  "locale": "nl",
  "group": "9d2e…",
  "title": "Zo leg je een merkstem vast",
  "slug": "zo-leg-je-een-merkstem-vast",
  "excerpt": "Twee tot drie zinnen die het artikel samenvatten.",
  "html": "<p>…</p><h2>…</h2>",
  "seo": { "title": "…", "description": "…", "focusKeyphrase": "merkstem" },
  "status": "scheduled",
  "publishAt": "2026-10-08T07:00:00.000Z",
  "author": { "name": "Thomas van Broekhoven", "title": "Oprichter", "photo": { "url": "https://…signed…", "alt": "Thomas van Broekhoven" } },
  "image": { "id": "m-31…", "url": "https://…signed…", "alt": "Een redactie aan het werk", "width": 2560, "height": 1440, "mimeType": "image/webp" },
  "fields": { "intro": "…" }
}
```

- `collection` is one of your collection keys; `category` is one of that
  collection's categories by its integer `id`, or `null`. Answer `422` with
  `unknown_collection` or `unknown_category` for one you do not have.
- `locale` is `nl` or `en`. `group` is shared by the Dutch and the English
  version of one text; use it to link translations. A text without a
  counterpart has its own id as its group.
- `html` is sanitised on your side. inConcept sends paragraphs, headings
  (`h2`, `h3`), lists, quotes, links, bold and italic. Nothing else is needed;
  strip what you do not want.
- `image` and `author.photo` are **signed addresses valid for one hour**.
  Fetch the file and store your own copy; the address will not work later.
  `image.id` is stable, so you can skip a copy you already hold. Answer `502`
  with `image_unreachable` when the fetch fails: inConcept then stops the
  publication, because nothing should go live without its picture.
- `status` is `draft`, `scheduled` (with `publishAt`) or `published`.
  A scheduled entry goes live on your side at `publishAt`; show it publicly
  only from that moment, and answer `published` from `GET` from then on.
  Refuse a `scheduled` without `publishAt`, or with a `publishAt` in the past,
  with `400` `invalid_payload`.
- `fields` holds the collection's own fields, as named in discovery, filled
  from the text. Empty when you declared none.
- `excerpt`, `slug`, `seo.*`, `author`, `image`, `category`, `source.form`,
  `source.brand.name` can be `null`. When `slug` is null, make one. When the
  slug is taken by another entry, answer `409` `slug_taken`.

### Fields

Every field of the entry body, generated from the
[JSON Schema](#the-entry-body-as-a-json-schema) at the end of this page.

<!-- fields -->

### The entry answer

```json
{ "id": "a1b2…", "url": "https://your-site.nl/blog/zo-leg-je-een-merkstem-vast", "editUrl": "https://your-site.nl/admin/…", "status": "scheduled", "publishAt": "2026-10-08T07:00:00.000Z" }
```

- `id`: your id, string or integer; inConcept sends it back as a string.
- `url`: the public address, or for a draft or scheduled entry the address it
  will get. inConcept fills this into other texts that link to this one, so
  make it the real one.
- `editUrl` is optional: where a person finishes a draft on your side.
- `status` as above, from your side's point of view.

### The site description (discovery)

```json
{
  "site": { "name": "inconcept.ai", "url": "https://inconcept.ai" },
  "collections": [
    {
      "key": "blog",
      "name": "Blog",
      "takesImage": true,
      "categories": [
        { "id": 1, "key": "praktijk", "name": "Praktijk" },
        { "id": 2, "key": "uitleg", "name": "Uitleg" }
      ],
      "fields": [{ "key": "intro", "label": "Intro", "multiline": true }]
    }
  ]
}
```

Category ids must be stable integers: inConcept stores them. Fields are
optional; declare only text fields you want filled from the text.

### Errors

`{ "error": "<code>", "message": "optional, for a developer" }` with:

| Status | Code | What inConcept does |
|---|---|---|
| 400 | `invalid_payload` | Stops and shows the person "the website does not understand the message"; check your parser against the schema |
| 401 | `bad_signature`, `stale_timestamp`, `unauthorized` | Stops; the person is told to check the key and the secret, or the server clock |
| 404 | `not_found` | On read: the record is marked missing. On delete: read as done |
| 405 | `unsupported` | On delete: the person is told to withdraw instead |
| 409 | `slug_taken` | Stops; the person adjusts the slug in inConcept |
| 422 | `unknown_collection`, `unknown_category` | Stops; the person reads the site again in the connection window |
| 502 | `image_unreachable` | Stops; nothing goes live without its picture |
| 500 | `server_error` | Stops and shows your `message` to the person |

Any other status is read as `server_error`. Do not redirect: a request that
carries a signature is not followed to another address.

## Testing and going live

- **Run the check.** One script, no dependencies, runs every call inConcept
  makes against your receiver and says per step what your site answered.
  It makes one draft entry, reads, schedules, updates and deletes it, and
  tries a wrong signature; nothing goes live.

```
curl -sO https://inconcept.ai/docs/site-check.mjs
node site-check.mjs https://your-site.nl/api/inconcept <key id> <secret>
```

  A site without discovery adds `--collection=blog` (and `--category=2`
  where the collection files by category). An assistant can run this and
  fix what fails. The entry body's schema is a file too:
  `https://inconcept.ai/docs/site-entry.schema.json`.
- **Signature first.** Verify against the raw bytes, before parsing. The
  commonest failure is a framework that parses JSON before your code runs
  and hands you a re-serialised body.
- **Use the test entry.** "Proefbericht sturen" in the connection window
  sends a draft the way a real text goes and shows your site's answer; it is
  idempotent, so send it as often as you like.
- **Scheduled means hidden.** A scheduled entry is stored but not shown
  until `publishAt`; from that moment `GET` answers `published`. inConcept
  polls, and records the public address when it sees that.
- **Withdraw and delete.** `PATCH` to `draft` takes an article offline and
  keeps it; `DELETE` removes it. Both come from a person in inConcept, so
  neither should ask for confirmation on your side.
- **Logs.** Log the key id, the timestamp and the reason you refused a
  request; "bad signature" without those is hard to debug on either side.
- **Previews and staging.** Give each environment its own key and secret, or
  the test entry from one lands on the other.

## Version and changes

The contract is versioned by the `version` field in every body; this page
describes version 1. Changes that keep version 1 only add optional fields or
answers; anything that changes meaning becomes version 2 and is announced
here first.

- **2026-10-06, version 1.** First release: discovery, create, read, update,
  schedule. Later the same day: `DELETE` with `405 unsupported`, and the
  category chosen per text rather than per text type.

## The entry body as a JSON Schema

Validate what arrives against this before storing it. Everything marked
nullable can be `null`; `fields` holds whatever you declared in discovery.
The [Fields](#fields) table above is generated from it.

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "inConcept site contract, entry body, version 1",
  "type": "object",
  "required": ["version", "source", "collection", "locale", "group", "title", "html", "status"],
  "properties": {
    "version": { "const": 1, "description": "The contract version; always 1." },
    "source": {
      "type": "object",
      "description": "Where the entry comes from in inConcept, for your own records.",
      "required": ["assignmentId", "tenantId"],
      "properties": {
        "assignmentId": { "type": "string", "description": "The text's id in inConcept; also the idempotency key of the create call." },
        "tenantId": { "type": "string", "description": "The organisation's id in inConcept." },
        "brand": { "type": ["object", "null"], "description": "The brand the text belongs to.", "properties": { "id": { "type": "string" }, "name": { "type": ["string", "null"] } } },
        "contentType": { "type": ["object", "null"], "description": "The inConcept text type (\"blog\", \"nieuwsbericht\").", "properties": { "slug": { "type": "string" }, "name": { "type": "string" } } },
        "form": { "type": ["string", "null"], "description": "The chosen form of the text type, when the type has forms (\"uitleg\")." }
      }
    },
    "collection": { "type": "string", "description": "The key of the collection the entry goes into, as declared in discovery." },
    "category": {
      "type": ["object", "null"],
      "description": "The category chosen for this text, one of the collection's; null when the collection has none.",
      "required": ["id"],
      "properties": { "id": { "type": "integer" }, "key": { "type": "string" }, "name": { "type": ["string", "null"] } }
    },
    "locale": { "enum": ["nl", "en"], "description": "The language of the text." },
    "group": { "type": "string", "format": "uuid", "description": "Shared by the Dutch and the English version of one text; the text's own id when it has no counterpart." },
    "title": { "type": "string", "minLength": 1, "maxLength": 200, "description": "The heading the text opens with, or the record's name." },
    "slug": { "type": ["string", "null"], "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", "maxLength": 120, "description": "The wanted address segment; make one when null, answer slug_taken when another entry holds it." },
    "excerpt": { "type": ["string", "null"], "maxLength": 500, "description": "Two or three sentences that summarise the text; the meta description and the teaser." },
    "html": { "type": "string", "description": "The body as HTML: p, h2, h3, ul, ol, li, blockquote, a, strong, em. Sanitise on your side." },
    "seo": {
      "type": ["object", "null"],
      "description": "Search engine fields, when the text has them.",
      "properties": { "title": { "type": ["string", "null"] }, "description": { "type": ["string", "null"] }, "focusKeyphrase": { "type": ["string", "null"] } }
    },
    "status": { "enum": ["draft", "scheduled", "published"], "description": "Hidden draft, live from publishAt, or live at once." },
    "publishAt": { "type": ["string", "null"], "format": "date-time", "description": "The moment a scheduled entry goes live, as an ISO 8601 instant; required with scheduled." },
    "author": {
      "type": ["object", "null"],
      "description": "The person the text is written as, for the byline.",
      "required": ["name"],
      "properties": {
        "name": { "type": "string" },
        "title": { "type": ["string", "null"] },
        "photo": { "type": ["object", "null"], "description": "A signed address valid for one hour; copy it.", "required": ["url"], "properties": { "url": { "type": "string", "format": "uri" }, "alt": { "type": "string" } } }
      }
    },
    "image": {
      "type": ["object", "null"],
      "description": "The featured image: a signed address valid for one hour, with a stable id to skip a copy you already hold.",
      "required": ["id", "url"],
      "properties": {
        "id": { "type": "string" },
        "url": { "type": "string", "format": "uri" },
        "alt": { "type": ["string", "null"] },
        "width": { "type": ["integer", "null"] },
        "height": { "type": ["integer", "null"] },
        "mimeType": { "type": ["string", "null"] }
      }
    },
    "fields": { "type": "object", "additionalProperties": { "type": "string" }, "description": "The collection's own fields as declared in discovery, filled from the text." }
  }
}
```

The entry answer is `{ "id": string | integer, "url": string, "editUrl"?: string, "status": "draft" | "scheduled" | "published", "publishAt": string | null }`.
