Technical documentation

Own website (API)

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.

Building with an AI assistant

  1. 1Working with Claude Code, Cursor or Codex? Copy the build prompt below and paste it into your project. The assistant reads the documentation itself through the address in it and builds the five routes in your stack.
  2. 2Working with a chat assistant without internet access? Choose : that puts the whole documentation on your clipboard to paste into the conversation.
  3. 3Set the key id and the secret as environment variables and have the assistant run the check script (see "Testing and going live" below): it runs every call against your receiver and says what fails. Then connect the site in inConcept and send a test entry.

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.

On this page

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.

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 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.
  4. Read back. From the scheduled moment on, inConcept GETs 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 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.

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 below has the signing code in PHP as well.
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 })
}
  1. 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.
  2. Run the check, then send a test entry. 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.
  3. 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

CallRequestAnswer
Discover (optional)GET {base}200 with the site description, or 404 when you prefer to configure collections by hand in inConcept
CreatePOST {base}/entries, entry body201 with the entry answer; 200 with the existing entry when the idempotency key was seen before
ReadGET {base}/entries/{id}200 with the entry answer; 404 { "error": "not_found" } when it is gone
UpdatePUT {base}/entries/{id}, entry body200 with the entry answer
SchedulePATCH {base}/entries/{id}, { "version": 1, "status": "draft" | "scheduled" | "published", "publishAt": ISO-8601 or null }200 with the entry answer
DeleteDELETE {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):

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.

The entry body

{
  "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 at the end of this page.

FieldTypeRequiredWhat it is
version1yesThe contract version; always 1.
sourceobjectyesWhere the entry comes from in inConcept, for your own records.
source.assignmentIdstringyesThe text's id in inConcept; also the idempotency key of the create call.
source.tenantIdstringyesThe organisation's id in inConcept.
source.brandobject, nullablenoThe brand the text belongs to.
source.brand.idstringno
source.brand.namestring, nullableno
source.contentTypeobject, nullablenoThe inConcept text type ("blog", "nieuwsbericht").
source.contentType.slugstringno
source.contentType.namestringno
source.formstring, nullablenoThe chosen form of the text type, when the type has forms ("uitleg").
collectionstringyesThe key of the collection the entry goes into, as declared in discovery.
categoryobject, nullablenoThe category chosen for this text, one of the collection's; null when the collection has none.
category.idintegeryes
category.keystringno
category.namestring, nullableno
locale"nl" | "en"yesThe language of the text.
groupstring (uuid)yesShared by the Dutch and the English version of one text; the text's own id when it has no counterpart.
titlestringyesThe heading the text opens with, or the record's name.
slugstring, nullablenoThe wanted address segment; make one when null, answer slug_taken when another entry holds it.
excerptstring, nullablenoTwo or three sentences that summarise the text; the meta description and the teaser.
htmlstringyesThe body as HTML: p, h2, h3, ul, ol, li, blockquote, a, strong, em. Sanitise on your side.
seoobject, nullablenoSearch engine fields, when the text has them.
seo.titlestring, nullableno
seo.descriptionstring, nullableno
seo.focusKeyphrasestring, nullableno
status"draft" | "scheduled" | "published"yesHidden draft, live from publishAt, or live at once.
publishAtstring (date-time), nullablenoThe moment a scheduled entry goes live, as an ISO 8601 instant; required with scheduled.
authorobject, nullablenoThe person the text is written as, for the byline.
author.namestringyes
author.titlestring, nullableno
author.photoobject, nullablenoA signed address valid for one hour; copy it.
author.photo.urlstring (uri)yes
author.photo.altstringno
imageobject, nullablenoThe featured image: a signed address valid for one hour, with a stable id to skip a copy you already hold.
image.idstringyes
image.urlstring (uri)yes
image.altstring, nullableno
image.widthinteger, nullableno
image.heightinteger, nullableno
image.mimeTypestring, nullableno
fieldsobject of stringsnoThe collection's own fields as declared in discovery, filled from the text.

The entry answer

{ "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)

{
  "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:

StatusCodeWhat inConcept does
400invalid_payloadStops and shows the person "the website does not understand the message"; check your parser against the schema
401bad_signature, stale_timestamp, unauthorizedStops; the person is told to check the key and the secret, or the server clock
404not_foundOn read: the record is marked missing. On delete: read as done
405unsupportedOn delete: the person is told to withdraw instead
409slug_takenStops; the person adjusts the slug in inConcept
422unknown_collection, unknown_categoryStops; the person reads the site again in the connection window
502image_unreachableStops; nothing goes live without its picture
500server_errorStops 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 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 }.

Questions while building?

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 touch

Cookies 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.