Developers
API
With the API you use your bots from your own software, such as an intranet, an app or a CRM. The bot answers just as in the chat on your website: with your own content, locations, opening hours, rules and choice flows.
- Base URL:
https://chatonpage.com/api/v1 - Requests and responses in JSON (UTF-8). Send
Accept: application/jsonalong. - Working with an AI assistant such as ChatGPT or Claude? Then use the MCP server.
- Want new leads in your CRM, Zapier or Make right away? Use webhooks.
Authentication
Create a key in the portal under Connections → API keys. Give it a name and choose whether it has access to all bots in your account (including bots you add later) or to one or more bots, and whether it may create bots. You only see the key once. You can create as many as you like and revoke each one separately.
Send the key in the header of every request:
Authorization: Bearer cop_…
Check key
/keyReturns the name of the key, the account and the bots it can access. Handy for testing whether everything works.
{
"name": "Intranet",
"account": "Kindercentrum",
"all_bots": false,
"bots": [{ "id": 24, "name": "Kindercentrum" }]
}
Retrieve bots
/botsAll bots this key has access to. Paused bots are not included. Use the id to select the bot in subsequent calls.
{
"data": [
{
"id": 24,
"name": "Kindercentrum",
"site_url": "https://www.kindercentrum.be",
"status": "active",
"default_locale": "nl",
"locales": ["nl", "fr"]
}
]
}
One bot
/bots/{id}The same details, plus the greeting, the start buttons and the bot's locations.
{
"data": {
"id": 24,
"name": "Kindercentrum",
…
"greeting": "Hallo! Ik ben de assistent van Kindercentrum. Waarmee kan ik u helpen?",
"start_choices": ["Inschrijven", "Openingsuren"],
"offices": [{ "id": 7, "name": "Biezebos", "city": "Gent" }],
"progress": { "percent": 100, "status": "Klaar", "ready": true, "pages": 84 }
}
}
Create bot
/botsCreates a new bot for a website, like New bot in the portal: the website is read right away, colours and icon are taken over, and whoever created the key gets an email as soon as the bot is ready to try. Follow the progress with GET /bots/{id} (progress).
Only with a key that has May create bots switched on. Every bot has its own plan: if the new bot costs extra, you first get 402 with the price, and you send the request again with "confirm_price": true. At most 10 new bots per hour per key.
| Field | Type | Meaning |
|---|---|---|
name | text | Name of the bot, max. 100 characters. Required. |
site_url | text | Website the bot reads, for example www.yourcompany.com. Required. |
default_locale | text | Main language: nl, en, fr, de or es. By default the language of your account. |
locales | list | Extra languages of the bot. Languages the bot finds on the website are added automatically. |
plan | text | lite, plus, pro or business. Without a plan we choose the smallest plan that fits the website. |
support_email | text | Address for questions the bot forwards; by default your account's support address. |
auto_approve | yes/no | Use new and changed pages right away (default yes) or have them approved in the portal first. |
for_customer | yes/no | Partners only: a bot for a customer, not covered by your own subscription. |
confirm_price | yes/no | Confirms the price if the bot costs extra. |
POST /bots
{ "name": "Webshop", "site_url": "www.webshop.be", "plan": "pro", "confirm_price": true }
201 Created
{
"data": {
"id": 31,
"name": "Webshop",
"site_url": "https://www.webshop.be/",
"status": "active",
…
"progress": { "percent": 10, "status": "Website wordt gelezen", "ready": false, "pages": 0 },
"embed_code": "<script src="https://chatonpage.com/widget.js" data-key="pk_…" async></script>",
"portal_url": "https://chatonpage.com/app/…/bots/31/edit"
}
}
Ask a question
/bots/{id}/messagesSends a message to the bot and returns the answer. Without conversation a new conversation is started. Send the conversation from the response along with the next question, and the bot will remember what the conversation is about.
| Field | Type | Meaning |
|---|---|---|
message | text | The user's question or answer, max. 4,000 characters. |
conversation optional | text | Id of a conversation started with this key. |
locale optional | text | Language of a new conversation (nl, fr, en, …). Defaults to the bot's language. If the user replies in a different language, the bot follows. |
office_id optional | number | Location a new conversation is about. |
user optional | text | Your own identifier for the user, max. 40 characters. This lets you find their conversations in the portal. No names or e-mail addresses. |
stream optional | boolean | true to receive the answer word by word. See Streaming. |
{
"conversation": "9f0c2a4e-6b1d-4c3e-9a57-2f1e8d7c6b5a",
"reply": {
"message_id": 18342,
"content": "Voor welk type opvang wil je inschrijven?",
"sources": [{ "title": "Inschrijven", "url": "https://www.kindercentrum.be/inschrijven" }],
"choices": ["Kinderdagverblijf", "Buitenschoolse opvang (BKO)", "Gezinsopvang"],
"office_options": [],
"escalated": false
}
}
content: the answer in Markdown (bold, lists, links).sources: the pages the answer is based on.choices: the bot asks the user to make a choice. See Choices.office_options: the bot asks which location is meant (id,name,city).escalated: the question was passed on to a staff member, as configured for the bot.
Streaming
With "stream": true you get a text/event-stream (Server-Sent Events), so you can show the answer while the bot is still writing:
event: start
data: {"conversation":"9f0c2a4e-…"}
event: delta
data: {"text":"Voor welk type "}
event: delta
data: {"text":"opvang wil je inschrijven?"}
event: done
data: {"conversation":"9f0c2a4e-…","reply":{ …zoals hierboven… }}
If something goes wrong along the way, an event: error follows with a message.
Retrieve conversation
/bots/{id}/conversations/{conversation}All messages of a conversation started with this key.
{
"data": {
"id": "9f0c2a4e-…",
"bot_id": 24,
"locale": "nl",
"office_id": null,
"started_at": "2026-10-17T10:12:03+02:00",
"messages": [
{ "id": 18341, "role": "user", "content": "Ik wil mijn kind inschrijven", "choices": [], "at": "…" },
{ "id": 18342, "role": "assistant", "content": "Voor welk type opvang …", "choices": ["Kinderdagverblijf", …], "at": "…" }
]
}
}
Forward leads
If a visitor asks to be contacted, the bot creates a lead with name, email address, phone number, question and summary. With webhooks, every new lead goes straight to your CRM, Zapier, Make or custom software. You can also fetch leads and events yourself.
Leads contain visitor contact details. Therefore, the calls below only work with a key where Access to leads is enabled, and only for the bots of that key. Without this access, you will get a 403.
Webhooks
Create a webhook in the portal under Connections → Webhooks, or via the API. Choose the URL, events and bots. For each event, we send a POST with JSON to that URL.
| Event | When |
|---|---|
lead.created | A visitor requested to be contacted. |
lead.updated | A lead’s follow-up status changed (new, contacted, closed), in the portal or via the API. Includes previous_status. |
report.created | The bot recorded a complaint or bug report. Five minutes later, so any follow-up from the visitor is included. |
POST https://jouw-crm.example.com/hooks/chatonpage
Content-Type: application/json
X-ChatOnPage-Event: lead.created
X-ChatOnPage-Event-Id: 5821
X-ChatOnPage-Delivery: 9310
X-ChatOnPage-Signature: t=1792923600,v1=5f2b…
{
"id": 5821,
"type": "lead.created",
"created_at": "2026-10-25T14:02:11+02:00",
"account": { "id": 3, "name": "Kindercentrum" },
"bot": { "id": 24, "name": "Kindercentrum" },
"data": {
"lead": {
"id": 1288,
"status": "new",
"name": "An Peeters",
"email": "an@example.com",
"phone": "+32 470 12 34 56",
"question": "Is er nog plaats in de BKO vanaf januari?",
"summary": "Ouder zoekt opvang voor twee kinderen vanaf januari.",
"language": "nl",
"office": { "id": 7, "name": "Biezebos" },
"conversation_id": "9f0c2a4e-6b1d-4c3e-9a57-2f1e8d7c6b5a",
"portal_url": "https://chatonpage.com/app/kindercentrum/leads",
"created_at": "2026-10-25T14:02:09+02:00",
"updated_at": "2026-10-25T14:02:09+02:00"
}
}
}
- Respond with a
2xxstatus within 10 seconds. Process the event in the background if possible. - If it fails, we retry after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours. Use
idto process an event that arrives twice only once. - If you respond with
410 Gone, we disable the webhook. If 5 events fail in a row, we do the same and notify the admins by email. After that, re-enable it and resend the failed events in the portal. - We do not follow redirects and only send to public
httpsaddresses. - With Test in the portal, you send a webhook.test event to try out your connection.
Verify signature
Each webhook has a secret (whsec_…), visible in the portal under Secret. The X-ChatOnPage-Signature header contains t (timestamp, Unix seconds) and v1: the HMAC-SHA256 of t, a dot and the raw body, using the secret as the key. Compare them, and reject messages where t is more than 5 minutes off.
// Node.js (Express): app.post('/hooks/chatonpage', express.raw({ type: 'application/json' }), …)
const crypto = require('crypto');
const { t, v1 } = Object.fromEntries(req.get('X-ChatOnPage-Signature').split(',').map((p) => p.split('=')));
const expected = crypto.createHmac('sha256', process.env.CHATONPAGE_WEBHOOK_SECRET).update(`${t}.${req.body}`).digest('hex');
const valid = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1)) && Math.abs(Date.now() / 1000 - t) < 300;
# PHP
[$t, $v1] = sscanf($_SERVER['HTTP_X_CHATONPAGE_SIGNATURE'], 't=%d,v1=%s');
$body = file_get_contents('php://input');
$valid = hash_equals(hash_hmac('sha256', $t.'.'.$body, getenv('CHATONPAGE_WEBHOOK_SECRET')), $v1) && abs(time() - $t) < 300;
Fetch events
/eventsSame events as webhooks, oldest first, for those who prefer to fetch themselves (e.g. every 5 minutes). Store next_after and send it as after: you’ll only get what’s new. Events are kept for 30 days.
| Parameter | Meaning |
|---|---|
after optional | Only events with a higher ID. |
type optional | One or more events, separated by a comma, for example lead.created. |
bot optional | Only events from this bot. |
limit optional | 1 to 100, default 50. |
GET /events?type=lead.created&after=5820
{
"data": [{ "id": 5821, "type": "lead.created", "created_at": "…", "account": { … }, "bot": { … }, "data": { "lead": { … } } }],
"next_after": 5821
}
Leads
/leadsLeads from the bots of the key, newest first. Filter by status (new, contacted, closed), bot and since (date or time, ISO 8601). If there is a next page, send next_before as before.
/leads/{id}One lead, with the same fields as in lead.created, plus bot.
/leads/{id}Updates the follow-up, e.g. {"status": "contacted"} once the lead is in your CRM. This is also visible in the portal and triggers a lead.updated event.
Webhooks via the API
This is how automation platforms like Zapier and Make connect and disconnect. A key only sees the webhooks it created itself. If the key is revoked, its webhooks are also removed.
/webhooks| Field | Type | Meaning |
|---|---|---|
url | text | Public HTTPS address. Required. |
events optional | list | All events by default. |
bots optional | list | Bot IDs. Defaults to the bots of the key. |
name optional | text | Name in the portal. Defaults to the key’s name. |
POST /webhooks
{ "url": "https://hooks.zapier.com/hooks/standard/123/abc", "events": ["lead.created"] }
201 Created
{
"data": {
"id": 12,
"name": "Zapier",
"url": "https://hooks.zapier.com/hooks/standard/123/abc",
"events": ["lead.created"],
"bots": "all",
"active": true,
"disabled_reason": null,
"created_at": "…",
"secret": "whsec_…"
}
}
The secret is only in this response. Next: GET /webhooks (list), DELETE /webhooks/{id} (unsubscribe) and POST /webhooks/{id}/test (test event). Maximum 25 webhooks per account.
Choices
In a choice flow (for example enrolling) the bot returns choices. Show them as buttons and send the chosen text back verbatim as the next message, with the same conversation. The user may also type something else: the bot will work out whether it is a choice or a new question.
Errors and limits
On an error you get an HTTP status with {"error": "…"}. For invalid fields (422) the details are in errors.
| Status | Meaning |
|---|---|
| 401 | Missing, invalid or revoked key. |
| 402 | The account is not active (for example, the trial has ended), or for POST /bots: the price must be confirmed ("code": "price_confirmation_required"). |
| 403 | This key cannot create bots or has no access to leads. |
| 404 | The bot, conversation, lead or webhook does not exist, or this key has no access to it. |
| 422 | A field is missing or invalid. |
| 429 | Too many questions: maximum 60 per minute per key, plus the daily limit per bot. |
Every answer counts towards the answers in your subscription, just like in the chat on your website. Conversations via the API are listed in the portal under Conversations.
Examples
curl
curl https://chatonpage.com/api/v1/bots \
-H "Authorization: Bearer $CHATONPAGE_KEY"
curl https://chatonpage.com/api/v1/bots/24/messages \
-H "Authorization: Bearer $CHATONPAGE_KEY" \
-H "Content-Type: application/json" \
-d '{"message": "Wat zijn de openingsuren in Gent?"}'
JavaScript (Node.js 18+)
const res = await fetch('https://chatonpage.com/api/v1/bots/24/messages', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CHATONPAGE_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: 'Wat zijn de openingsuren in Gent?', conversation }),
});
const { conversation: id, reply } = await res.json();
console.log(reply.content);
Python
import os, requests
r = requests.post(
"https://chatonpage.com/api/v1/bots/24/messages",
headers={"Authorization": f"Bearer {os.environ['CHATONPAGE_KEY']}"},
json={"message": "Wat zijn de openingsuren in Gent?"},
timeout=60,
)
data = r.json()
print(data["reply"]["content"])
conversation = data["conversation"] # meesturen bij de volgende vraag