Technical documentation
Five routes on your own server, signed with a shared secret. Below is everything a developer needs to build the receiver in an afternoon, alone or with an AI assistant.
This is the manual for whoever builds the receiver. What the connection does for your team is in the overview.
The technical documentation is in English, the language of the code.
Build prompt
Read https://inconcept.ai/en/docs/publishing/connect-website/api.md and build a receiver for the inConcept site contract in my project: the five routes under one base address, signature verification on the raw body before parsing, storage of the entry, a copy of the image, and the answers exactly as the document describes them. Use this project's stack. Ask me for the key id and the secret as environment variables. Then run the check script from the document (site-check.mjs) against the running receiver and fix what fails.
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.
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
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.POSTs 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 PATCHed back to draft.GETs the entry every couple of minutes until your site says published, then records the public address.PATCHes the entry back to draft, so it goes offline but stays. Deleting it from the site DELETEs the entry; a site that does not delete answers 405 and the person is told to withdraw instead.Ten minutes to a working receiver.
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.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 })
}
site-check.mjs (see 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.Base address: what the customer entered, https://, no trailing slash. All bodies are JSON, UTF-8. Every body carries "version": 1.
| Call | Request | Answer |
|---|---|---|
| Discover (optional) | GET {base} | 200 with the site description, or 404 when you prefer to configure collections by hand in inConcept |
| Create | POST {base}/entries, entry body | 201 with 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) |
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.
The same canonical string, hashed with the same secret, on both sides. In TypeScript (Node.js):
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
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.
{
"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.Every field of the entry body, generated from the JSON Schema at the end of this page.
| Field | Type | Required | What it is |
|---|---|---|---|
version | 1 | yes | The contract version; always 1. |
source | object | yes | Where the entry comes from in inConcept, for your own records. |
source.assignmentId | string | yes | The text's id in inConcept; also the idempotency key of the create call. |
source.tenantId | string | yes | The organisation's id in inConcept. |
source.brand | object, nullable | no | The brand the text belongs to. |
source.brand.id | string | no | |
source.brand.name | string, nullable | no | |
source.contentType | object, nullable | no | The inConcept text type ("blog", "nieuwsbericht"). |
source.contentType.slug | string | no | |
source.contentType.name | string | no | |
source.form | string, nullable | no | The chosen form of the text type, when the type has forms ("uitleg"). |
collection | string | yes | The key of the collection the entry goes into, as declared in discovery. |
category | object, nullable | no | The category chosen for this text, one of the collection's; null when the collection has none. |
category.id | integer | yes | |
category.key | string | no | |
category.name | string, nullable | no | |
locale | "nl" | "en" | yes | The language of the text. |
group | string (uuid) | yes | Shared by the Dutch and the English version of one text; the text's own id when it has no counterpart. |
title | string | yes | The heading the text opens with, or the record's name. |
slug | string, nullable | no | The wanted address segment; make one when null, answer slug_taken when another entry holds it. |
excerpt | string, nullable | no | Two or three sentences that summarise the text; the meta description and the teaser. |
html | string | yes | The body as HTML: p, h2, h3, ul, ol, li, blockquote, a, strong, em. Sanitise on your side. |
seo | object, nullable | no | Search engine fields, when the text has them. |
seo.title | string, nullable | no | |
seo.description | string, nullable | no | |
seo.focusKeyphrase | string, nullable | no | |
status | "draft" | "scheduled" | "published" | yes | Hidden draft, live from publishAt, or live at once. |
publishAt | string (date-time), nullable | no | The moment a scheduled entry goes live, as an ISO 8601 instant; required with scheduled. |
author | object, nullable | no | The person the text is written as, for the byline. |
author.name | string | yes | |
author.title | string, nullable | no | |
author.photo | object, nullable | no | A signed address valid for one hour; copy it. |
author.photo.url | string (uri) | yes | |
author.photo.alt | string | no | |
image | object, nullable | no | The featured image: a signed address valid for one hour, with a stable id to skip a copy you already hold. |
image.id | string | yes | |
image.url | string (uri) | yes | |
image.alt | string, nullable | no | |
image.width | integer, nullable | no | |
image.height | integer, nullable | no | |
image.mimeType | string, nullable | no | |
fields | object of strings | no | The collection's own fields as declared in discovery, filled from the text. |
{ "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.{
"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.
{ "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.
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.
publishAt; from that moment GET answers published. inConcept polls, and records the public address when it sees that.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.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.
DELETE with 405 unsupported, and the category chosen per text rather than per text type.Validate what arrives against this before storing it. Everything marked nullable can be null; fields holds whatever you declared in discovery. The Fields table above is generated from it.
{
"$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 }.
Send us what your site answers and we will look with you. A test entry can be sent from inConcept itself, in the brand's connection window.
Get in touchCookies for visitor statistics
inConcept uses Google Analytics to see which pages of the website are visited. That needs cookies. Without your consent we measure nothing. Read the privacy statement.