What a bot is, and what it's good for
A bot is another kind of "user account" on Korat with no person typing behind it — your own program sends a request, and a message shows up in whatever chat the bot has been invited into. Real uses:
- Announce new orders into a chat — your shop's ordering system gets a new order ⇒ have the bot post it straight into the customer chat or your staff room
- Low-stock alerts — inventory drops below a threshold you set ⇒ the bot posts a warning into your staff room
- Server/system status updates — your own health-check script finishes a run ⇒ have the bot report the result into a group your IT team watches
What a bot cannot do (real limits from the database, not temporary gaps):
- A bot cannot read messages in a room — it's outbound-only, unless you configure an inbound webhook yourself (see the technical reference below) — and even with a webhook configured, nothing is delivered today because the clock that has to drain the queue hasn't been scheduled in production yet.
- A bot cannot invite itself into a room — someone who is already in that room (or holds permission to manage it) always has to invite it.
- A bot can hold at most 2 live tokens at once, and one account can own at most 5 bots.
Getting started — 4 steps
- 1Create a bot. Give it a name and a username (the username must end in
bot, e.g.orderbot) — if you want it to belong to a shop rather than just you, pick the shop while creating it. - 2Copy the token. It's shown to you once, right after creation — copy it somewhere safe immediately. Once you close that screen there is no way to see the real value again (you'd have to issue a new one instead).
- 3Invite the bot into a room. Pick the chat, group room, or staff room you want the bot to post into — you have to already be in that room (or hold permission to manage it) to invite it.
- 4Send your first message. Take the token from step 2 and call the Bot API — a working example is in the next section.
Steps 1–3 happen inside the Korat app
The menu is at Settings › Account › "My bots" — from there you can create a bot, issue/rotate/revoke tokens, and invite it into a chat/group room/staff room, all without writing a line of code. This screen is coming in the next release of the Korat app — the version you can download today does not have it yet. If you open Settings and don't see "My bots," wait for an app update. In the meantime you can do steps 1–3 by calling Supabase RPCs directly (see the technical reference below).
🔒 A token is the bot's password
Whoever holds this token can send messages as the bot immediately — never send a token over chat, a screenshot, or unencrypted email. Keep it only in your own server or secrets store. No Korat staff member will ever ask you for a token, by chat, email, or phone — if someone claiming to be from the Korat team asks for one, assume it's a scam and report it via Contact right away. If a token leaks, revoke it in the app immediately (this cannot be undone — you'll need to issue a new one).
Example — sending your first message
There's a single endpoint, POST https://koratland.com/api/bot/send. Put the token in a header and say what to say into which room:
With curl (for anyone comfortable with a terminal)
curl -X POST "https://koratland.com/api/bot/send" \
-H "X-Bot-Token: kbot_12_xxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "kind": "chat", "chat_id": 9931, "text": "A new order just came in." }'
Replace kbot_12_xxxxxxxxxxxxxxxxxxxx with the bot's real token, and 9931 with the id of the room you invited the bot into (you can see the room id from the chat's link in the app, or from the room list in the "My bots" screen).
Without writing code (Google Apps Script)
If you don't have your own server, Google Apps Script is free with just a Google account — go to script.google.com → New project → paste this in → click "Run." The first run will ask for internet access permission (safe to grant — it's your own script):
function sendKoratMessage() {
var url = "https://koratland.com/api/bot/send";
var payload = {
kind: "chat",
chat_id: 9931, // replace with your room's id
text: "A new order just came in." // replace with your message
};
var options = {
method: "post",
contentType: "application/json",
headers: { "X-Bot-Token": "kbot_12_xxxxxxxxxxxxxxxxxxxx" }, // replace with your bot's token
payload: JSON.stringify(payload)
};
var res = UrlFetchApp.fetch(url, options);
Logger.log(res.getContentText()); // see the result in the Execution log
}
Click "Run" once to test it right away. To have it fire automatically — say, every time a new row is added to a Google Sheet where you log orders — use Apps Script's Triggers to call this function on onEdit, or on a timer every few minutes.
The same approach works with any automation tool that has an "HTTP request"/"Webhook" action already, such as n8n, Make, or Zapier — configure a POST to the same URL, with the X-Bot-Token header and the same JSON body shown above.
Common problems
You see this (reason) | What it means | How to fix it |
|---|---|---|
not_invited | The bot has never been invited into this room (or it was invited once and later kicked out). | Invite the bot into that room again from "My bots," or via bot_invite. |
invite_stale | Whoever invited the bot into this room has lost their permission there (they left the group, were demoted, lost permission to manage the staff room, or the room closed) ⇒ the bot goes silent on its own — nobody kicked it out. | Have someone who currently still has permission in that room invite the bot again — permission is always re-computed from the most recent inviter. |
conversation_not_open | This room is a shop's customer inbox, and the customer has never messaged first — the shop side (including its bot) cannot open the conversation. | Wait for the customer to message first, then have the bot reply. |
rate_limited | The bot is sending faster than its configured limit (per minute or per day). | Wait the number of seconds given in retry_after_sec, then retry — if you genuinely need a higher limit, contact the team via Contact. |
blocked | The user in that chat has blocked this bot. | Nothing to fix on your side — use a different chat/bot if needed. |
| A bot that used to post suddenly goes silent | The most common cause is invite_stale above — it's rarely the bot itself being broken. | Check whether whoever invited the bot into that room is still in it, and still has permission. |
The full error-code table is in the technical reference below.
Technical reference (for developers)
Everything below is written for someone building their own integration against the Bot API — every parameter, the full error-code table, and the inbound webhook.
Doing steps 1–3 via RPC directly (without the app)
If the app version you're on doesn't have the "My bots" screen yet, or you want to automate this, you call Supabase RPCs directly, authenticated as the logged-in user's own access token (not a bot token), through PostgREST — three steps:
1. Create a bot — bot_create
curl -X POST "https://<SUPABASE_PROJECT>.supabase.co/rest/v1/rpc/bot_create" \
-H "apikey: <SUPABASE_ANON_KEY>" \
-H "Authorization: Bearer <USER_ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"p_name": "Notify bot",
"p_username": "notifybot",
"p_business_id": null,
"p_about": "Order status updates"
}'
p_username must match a–z 0–9 _, 4–31 characters, and end in bot (regex ^[a-z][a-z0-9_]{2,29}bot$). Pass p_business_id to make it a business's bot — the caller must be a member of that business and hold the bot.manage capability. On success the response's token is the only time the real value is ever returned; the database keeps only its sha256 hash. Lose it and you have to rotate (bot_rotate_token) — there is no way to read it back.
{ "ok": true, "bot_id": 12, "user_id": 4821, "username": "notifybot", "token": "kbot_12_…" }
2. Invite the bot into a chat — bot_invite
The caller must actually be in that chat (or be business staff on that chat's business inbox), and must be able to manage this bot (can_manage_bot). p_target is chats.id.
curl -X POST "https://<SUPABASE_PROJECT>.supabase.co/rest/v1/rpc/bot_invite" \
-H "apikey: <SUPABASE_ANON_KEY>" \
-H "Authorization: Bearer <USER_ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-d '{ "p_bot": 12, "p_kind": "chat", "p_target": 9931 }'
2a. Inviting the bot into a group/team room — a stricter bar than a chat
Sending p_kind: "community_room" or "business_room" with p_target set to a community_rooms.id/business_rooms.id works for real now — but the inviter must clear a permission check that matches what that room actually means, not just membership:
- Community room — the inviter must be able to actually post in that room under the room's own rules (
read_role/post_role). A regular chat room: any member can invite. An announcement/back-office room restricted to admins: only the community's owner/admin can. - Business staff room — the inviter must clear the room's existing gates (read/post capability, audience rule) plus always hold the
teamroom.managecapability — bringing a bot into a company's internal room is treated as "managing the room," not just "posting in it."
Both cases also always require the caller to be able to manage that bot (can_manage_bot) — a shared room is not a place where anyone can drag someone else's bot in to talk.
🔴 invite_stale — the most important thing to know before using this
The inviter's permission is not checked only at invite time — bot_send re-checks the room's rules, in the original inviter's name, every single time a message is sent. If that inviter leaves the group / gets demoted / loses teamroom.manage / the room gets closed, the bot's very next message gets reason: "invite_stale" immediately — the bot goes silent on its own, with nobody having kicked it out. No background job, no trigger that has to fire — it's purely a permission re-computation.
Send a message — POST /api/bot/send
Two ways to send the token — pick one:
# Option 1 — Authorization: Bearer
curl -X POST "https://koratland.com/api/bot/send" \
-H "Authorization: Bearer kbot_12_xxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-9931-shipped" \
-d '{ "kind": "chat", "chat_id": 9931, "text": "Your order has shipped." }'
# Option 2 — X-Bot-Token
curl -X POST "https://koratland.com/api/bot/send" \
-H "X-Bot-Token: kbot_12_xxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "kind": "chat", "chat_id": 9931, "text": "Your order has shipped." }'
No token at all gets a 401:
{ "ok": false, "reason": "bad_token", "detail": "ส่งโทเคนใน Authorization: Bearer … หรือ X-Bot-Token เท่านั้น" }
(The Worker's error strings are Thai today, regardless of which language docs page sent you here — reason and the HTTP status are what to branch on programmatically, not detail.)
Body fields
| Field | Type | Required | Meaning |
|---|---|---|---|
kind | string | No (defaults to "chat") | Decides the target type. Three real values: "chat" · "community_room" · "business_room". Anything else gets reason: "bad_target" straight from the database (there is no silent fallback to "chat" — that was a real bug, since fixed). |
chat_id | integer > 0 | Yes (or target_id) | The id of the room the bot has already been invited into — chats.id for kind: "chat", community_rooms.id for "community_room", business_rooms.id for "business_room" (the body field is always named chat_id/target_id, regardless of kind). |
text | string | Yes | Message body. Cannot be empty. Length is capped by the text_max_message registry entry (a live, adjustable value, not a hardcoded number — see the max field on a too_long error). |
client_key | string | No | Dedup key, an alternative to the Idempotency-Key header. 8–128 characters, A–Z a–z 0–9 _ . : - only. |
Private chat vs. business chat — which field decides
kind decides the destination table type (chat / community room / business staff room). A private 1:1 chat and a business's customer inbox are not distinguished by kind at all — both are rows in the same chats table, and the difference is whether chats.business_id is set (set = a business inbox). That column is also what the invite-permission check, the block check, and the "a business can't message a customer first" rule (conversation_not_open) all read. The chat_id you pass in has to already be the right room — the API itself takes no separate "private vs. business" parameter.
Idempotency-Key and rate limits
Send either the standard Idempotency-Key header or a client_key body field — pick one; if you send both they must match, or you get a 422 idempotency_key_conflict. Re-sending the same request with the same key (same chat + same key) gets back the original 200 response with "duplicate": true — no new message is created.
Two limit layers: per IP (120 requests/minute, enforced by the Worker itself) and per bot (default 20/minute and 1000/day, per-bot, adjustable by an admin). Both answer 429 with a Retry-After header in seconds (per-IP is always 60 · per-bot per-minute is 60 · per-bot per-day is 3600).
Receiving messages — webhook
When someone sends a message into a chat the bot has already been invited into (and the sender is not the bot's own account — no echo back to itself), Korat POSTs it to the URL you configured. Set it via RPC, the same way you get a token — with a logged-in user's session that has can_manage_bot on the bot:
curl -X POST "https://<SUPABASE_PROJECT>.supabase.co/rest/v1/rpc/bot_set_webhook" \
-H "apikey: <SUPABASE_ANON_KEY>" \
-H "Authorization: Bearer <USER_ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-d '{ "p_bot": 12, "p_url": "https://your-server.example.com/korat-webhook" }'
{ "ok": true, "secret": "a1b2c3…", "note": "you see this secret once — keep it to verify signatures yourself" }
The URL must be https:// and cannot be an internal/loopback/link-local host (localhost, 127.0.0.1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 — which covers every cloud provider's metadata endpoint) — rejected at set time, and re-checked with a real DNS resolution every single time before delivery, in case DNS points somewhere else later. secret is shown once, at set/rotate time only — bot_rotate_webhook_secret(p_bot) rotates it any time, bot_disable_webhook(p_bot)/bot_enable_webhook(p_bot) turn it off/on by hand, and bot_webhook_status(p_bot) reads status (never the secret).
Nothing is delivered in production yet
The delivery queue (bot_webhook_deliveries) works correctly and is proved in the database, and the route's secret (BOT_WEBHOOK_DISPATCH_SECRET) has been set by the owner, but the piece that actually drains the queue (pg_cron calling the Worker every minute) has not been scheduled in production yet — you can set a webhook and messages will queue up, but your endpoint will not receive anything until the pg_cron clock is scheduled.
What your endpoint receives
POST /korat-webhook HTTP/1.1
Content-Type: application/json
X-Korat-Signature: sha256=<hex>
X-Korat-Timestamp: 1750000000000
{
"event": "message.created",
"chat_id": 9931,
"message_id": 88213,
"text": "Do you have this in red?",
"time": "14:02",
"sender": { "id": 4821, "username": "jamie_reed", "name": "Jamie Reed" }
}
That is the whole payload — no phone number, email, or anyone's token is ever included. The bot sees exactly as much of the room/message/sender as it needs to reply.
Verify the signature before you trust the request
X-Korat-Signature is HMAC-SHA256(secret, "<timestamp>." + rawBody), hex-encoded. Node.js:
const crypto = require("crypto");
function verify(rawBody, signatureHeader, timestampHeader, secret) {
const age = Date.now() - Number(timestampHeader);
if (!(age >= 0 && age < 5 * 60_000)) return false; // reject replays older than 5 minutes
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(`${timestampHeader}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}
Answer 2xx within 8 seconds to count as delivered. Anything else — including a 3xx redirect, which Korat never follows — or no answer at all counts as a failure and goes back into the retry queue.
Retries when your endpoint is down
A failure backs off exponentially (1, 2, 4, 8, 16, 32, 64, 128 minutes), capped at 8 attempts per message, then that one message is given up on. If the endpoint fails 15 times in a row (across messages), the webhook is disabled automatically and the bot's owner is notified in-app — it never fires forever at a dead endpoint. Call bot_enable_webhook to turn it back on once the destination is fixed.
Full error-code table
Every failure response has {"ok": false, "reason": "…"} — branch on reason, not just the HTTP status.
| HTTP | reason | Source | Meaning / fix |
|---|---|---|---|
| 401 | bad_token | Worker / DB | No token sent, or the token is wrong / revoked / the bot is disabled — deliberately one answer for all of these, to deny token-guessing any signal. |
| 403 | not_invited | DB | The bot has never been invited into this room, or was kicked. Call bot_invite first. |
| 403 | blocked | DB | The user in the room has blocked this bot (rooms that are not a business inbox only). |
| 403 | account_suspended | DB | One side of the chat is suspended — a permissions matter, not a bad request. |
| 404 | no_such_chat | DB | chat_id does not exist. |
| 404 | no_such_endpoint | Worker | Wrong path — only POST /api/bot/send exists. |
| 405 | method_not_allowed | Worker | Used a method other than POST. |
| 409 | conversation_not_open | DB | This is a business inbox and the customer has never messaged first — a business side (including its bot) cannot open the conversation. |
| 409 | chat_closed | DB | A dating-match room that has already closed to new messages. |
| 422 | empty_text | DB | text is empty after trimming. |
| 422 | too_long | DB | Message exceeds the length cap — see the attached max value. |
| 422 | bad_target | DB | kind is not one of "chat"/"community_room"/"business_room". |
| 404 | no_such_room | DB | chat_id does not point to a real group/team room (the room was deleted). |
| 409 | room_archived | DB | The room is closed — nobody, human or bot, can post. |
| 403 | invite_stale | DB | The inviter's permission was re-checked and no longer passes — they left the group, were demoted, lost teamroom.manage, or the room closed. See detail for the sub-reason (not_a_member/room_role/not_teamroom_manager) — fixed by having someone who still has permission invite the bot into that room again (bot_invite), not by waiting. |
| 422 | bad_json | Worker | Request body is not valid JSON. |
| 422 | bad_chat_id | Worker | chat_id/target_id must be a positive integer. |
| 422 | bad_text | Worker | text must be a string. |
| 422 | idempotency_key_conflict | Worker | Both Idempotency-Key and client_key were sent and they disagree — send only one. |
| 422 | bad_client_key | Worker | Dedup key does not match the allowed shape (8–128 chars, A–Z a–z 0–9 _ . : -). |
| 429 | rate_limited | Worker / DB | Over the limit — check the Retry-After header and the window/retry_after_sec fields. |
| 502 | database_error | Worker | The database answered but not with 2xx — see the attached pg_code/detail, which is Postgres's own message. |
| 503 | service_key_not_configured | Worker | The server has no service key configured — a Korat-side problem, not the caller's. |
| 504 | database_unreachable | Worker | Could not reach the database, or it timed out. Check safe_to_retry: it is only safe to retry if you sent a client_key/Idempotency-Key. |
Success response
{ "ok": true, "duplicate": false, "message_id": 88213 }
duplicate: true means this exact request was already sent before (deduplicated by client_key/Idempotency-Key) — no new message was created, but it is still a success (200) because the outcome the caller wanted already happened.