Korat

Build a bot for your shop

This page is written so a shop owner who doesn't write code can follow it, with the technical detail for developers at the bottom — use the table of contents on the right to jump around.

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

  1. 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.
  2. 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).
  3. 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.
  4. 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 meansHow to fix it
not_invitedThe 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_staleWhoever 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_openThis 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_limitedThe 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.
blockedThe 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 silentThe 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.manage capability — 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 timebot_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

FieldTypeRequiredMeaning
kindstringNo (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_idinteger > 0Yes (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).
textstringYesMessage 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_keystringNoDedup 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.

HTTPreasonSourceMeaning / fix
401bad_tokenWorker / DBNo 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.
403not_invitedDBThe bot has never been invited into this room, or was kicked. Call bot_invite first.
403blockedDBThe user in the room has blocked this bot (rooms that are not a business inbox only).
403account_suspendedDBOne side of the chat is suspended — a permissions matter, not a bad request.
404no_such_chatDBchat_id does not exist.
404no_such_endpointWorkerWrong path — only POST /api/bot/send exists.
405method_not_allowedWorkerUsed a method other than POST.
409conversation_not_openDBThis is a business inbox and the customer has never messaged first — a business side (including its bot) cannot open the conversation.
409chat_closedDBA dating-match room that has already closed to new messages.
422empty_textDBtext is empty after trimming.
422too_longDBMessage exceeds the length cap — see the attached max value.
422bad_targetDBkind is not one of "chat"/"community_room"/"business_room".
404no_such_roomDBchat_id does not point to a real group/team room (the room was deleted).
409room_archivedDBThe room is closed — nobody, human or bot, can post.
403invite_staleDBThe 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.
422bad_jsonWorkerRequest body is not valid JSON.
422bad_chat_idWorkerchat_id/target_id must be a positive integer.
422bad_textWorkertext must be a string.
422idempotency_key_conflictWorkerBoth Idempotency-Key and client_key were sent and they disagree — send only one.
422bad_client_keyWorkerDedup key does not match the allowed shape (8–128 chars, A–Z a–z 0–9 _ . : -).
429rate_limitedWorker / DBOver the limit — check the Retry-After header and the window/retry_after_sec fields.
502database_errorWorkerThe database answered but not with 2xx — see the attached pg_code/detail, which is Postgres's own message.
503service_key_not_configuredWorkerThe server has no service key configured — a Korat-side problem, not the caller's.
504database_unreachableWorkerCould 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.