Platform API reference

Send messages, run bots, receive replies. Last updated 14 October 2026.

Getting started

All Platform API requests go to:

https://api.gekychat.com/platform
  1. Sign in at /developers and turn on developer mode.
  2. Create an API key. Copy it right away; it is shown once.
  3. Send your first message:
curl -X POST https://api.gekychat.com/platform/messages/send \
  -H "Authorization: Bearer sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"conversation_id": 123, "body": "Hello from my app"}'

Responses are JSON. Successful calls return {"data": {...}}; errors return {"error": "..."} (see Requests, errors & limits).

Authentication

There are three kinds of credentials. Send each as Authorization: Bearer <secret>.

CredentialLooks likeUsed for
API keysk_…Users, conversations and messages (/platform/users, /platform/conversations/*, /platform/messages/*). Messages sent with a key appear as sent by the key's owner.
Access tokenlong tokenSame endpoints as an API key. Get one by trading your client id and secret (below).
Bot secretper botBusiness-run bot endpoints under /platform/bots/{botNumber}/…. Issued when your bot is registered.

Get an access token

POST /platform/oauth/token

FieldType
client_idstringrequired. Your developer client id (dev_…), shown on the portal.
client_secretstringrequired. One of your API keys.
grant_typestringrequired. Always client_credentials.
{ "access_token": "1|abc...", "token_type": "Bearer", "expires_in": null, "scope": ["messages.send"] }

Wrong credentials return 401 with "error": "invalid_client".

Requests, errors & limits

  • Request id. Every response includes an X-Request-Id header. Quote it when you contact support; the same id appears in your request logs.
  • Status codes. 200/201 success · 401 bad or missing credentials · 403 not allowed (for example the bot is not in that conversation) · 404 not found · 422 validation or rule failure · 429 rate limit · 5xx our side, safe to retry.
  • Validation errors return 422 with {"message": "...", "errors": {"field": ["..."]}}.
  • Rule errors include a stable code you can match on, for example {"error": "...", "code": "SESSION_EXPIRED"}. See Error codes.
Endpoint groupLimit
Bot actions (typing, read, react, templates list)240 requests / minute / bot
Bot reply120 requests / minute
Template send, opt-ins120 requests / minute
Media upload30 requests / minute
Payment requests (create)120 requests / minute
Payment refunds60 requests / minute
Template sends per bot1,000 per day
Incoming-webhook verification60 requests / minute

Users

Find a user by phone

GET /platform/users/by-phone?phone=233241234567

Returns {"data": {"id", "phone", "name", "normalized_phone"}}, or 404 with User not found. Phone number is not registered on GekyChat. Accounts with the special API creation privilege (granted by GekyChat on request) get the person created automatically instead of a 404 ("auto_created": true).

Create a user

POST /platform/users — returns the existing user for that phone ("created": false) or creates an unverified account ("created": true, status 201). The person verifies the phone the first time they sign in.

phonestring, required
namestring, optional (max 255)

Conversations

GET /platform/conversations/find-or-create?user_id=42 returns the direct conversation between a user and your bot or system user, creating it if needed.

user_idinteger, optionalThe person. Defaults to the authenticated user.
bot_user_idinteger, optionalThe other side. Defaults to the system bot.
{ "data": { "conversation_id": 123, "user_id": 42, "bot_user_id": 7 } }

Messages

Send a message

POST /platform/messages/send

FieldType
conversation_idintegerrequired
bodystringrequired. The message text.
external_refstring (max 255)optional. Your own reference; stored with the message.
metadataobjectoptional. Free-form data stored with the message.
buttonsarrayoptional. Link buttons.
interactiveobjectoptional. Interactive message.
{ "data": { "message_id": 9001, "conversation_id": 123, "status": "sent" } }   // 201

Send to a phone number

POST /platform/messages/send-to-phone — looks the person up by phone. Creating the user and conversation automatically needs the special API creation privilege; otherwise the user and conversation must already exist.

phonestring, required
bodystring, required
external_refstring, optional
metadataobject, optional
bot_user_idinteger, optional

Delivery status

GET /platform/messages/{id}/status returns the delivery state of a message you sent.

{ "data": { "message_id": 9001, "status": "read", "delivered_at": "2026-10-14T09:30:05+00:00", "read_at": "2026-10-14T09:31:40+00:00" } }

status is sent, delivered or read. Messages sent by someone else return 404.

Show up to two tappable buttons under a message instead of a long URL. Works on /messages/send and on bot replies.

{
  "conversation_id": 123,
  "body": "Your invoice is ready.",
  "buttons": [
    { "label": "Make Payment", "url": "https://pay.example.com/inv/123", "color": "#0F8A5F" },
    { "label": "Details",      "url": "https://example.com/inv/123" }
  ]
}
FieldRule
buttonsat most 2
buttons[].labelrequired, 1–12 characters
buttons[].urlrequired, https only, up to 2048 characters
buttons[].coloroptional, hex like #0F8A5F. The label turns dark or white automatically so it stays readable. Default is GekyChat green.

Interactive messages

Buttons, lists and call-to-action buttons, in the style of WhatsApp. The body is the main text. Three types:

// 1) Reply buttons: up to 3. A tap sends an interactive_reply to your webhook.
{
  "conversation_id": 123,
  "body": "Do you want to continue?",
  "interactive": {
    "type": "buttons",
    "header": { "type": "text", "text": "Order 1042" },
    "footer": "Reply within 24 hours",
    "action": { "buttons": [ { "id": "yes", "title": "Yes" }, { "id": "no", "title": "No" } ] }
  }
}

// 2) List: one menu button that opens sections of rows.
{
  "interactive": {
    "type": "list",
    "action": {
      "button": "Menu",
      "sections": [ { "title": "Shop", "rows": [ { "id": "search", "title": "Search", "description": "Find a product" } ] } ]
    }
  }
}

// 3) Call-to-action buttons: up to 3 stacked buttons.
{
  "interactive": {
    "type": "cta_url",
    "header": { "type": "image", "url": "https://example.com/banner.jpg" },
    "action": { "ctas": [
      { "type": "url",       "title": "View Product", "url": "https://example.com/p/1" },
      { "type": "call",      "title": "Call us",      "phone": "+233241234567" },
      { "type": "copy_code", "title": "Copy code",    "code": "SAVE10" }
    ] }
  }
}
FieldRule
interactive.typerequired: buttons, list or cta_url
interactive.headeroptional. type is text (needs text, max 60) or image, video, document (need an https url; upload with media upload)
interactive.footeroptional, max 60 characters
reply buttons1–3. Each has a unique id (max 200) and a title (max 20)
listbutton label max 20; 1–10 sections (title max 24); 1–10 rows each, with unique id, title (max 24) and optional description (max 72)
call-to-action1–3 buttons. title max 25. url needs an https url; call needs phone (6–15 digits, optional +); copy_code needs code (max 64)

When someone taps a reply button or list row you receive a message.received webhook with type: "interactive_reply" (see Events).

Bots

Business-run bots receive messages on your webhook and answer through these endpoints. Authenticate with the bot secret. Replace {botNumber} with the bot's number.

Reply

POST /platform/bots/{botNumber}/reply

conversation_idinteger, required. The bot must be in it.
bodystring, required, max 10,000
reply_to_message_idinteger, optional. Must be in the same conversation. Shows the message being answered.
buttons, interactiveoptional, as above

Free-form replies are allowed for 24 hours after the person's last message (the session window). After that you get 422 with "code": "SESSION_EXPIRED" and window_expires_at; send an approved template instead. The window time is also sent in every incoming webhook as session.expires_at.

{ "data": { "status": "sent", "message_id": 9002 } }   // 201

Typing indicator

POST /platform/bots/{botNumber}/typing

conversation_idinteger, required
is_typingboolean, required. true shows "typing…", false clears it.
{ "data": { "status": "ok" } }

Mark as read

POST /platform/bots/{botNumber}/read with {"message_id": 9001}. The person sees blue ticks. You can only mark messages sent by others (422 otherwise).

{ "data": { "status": "read" } }

Reactions

POST /platform/bots/{botNumber}/react

message_idinteger, required
emojistring (max 16). An empty value removes your reaction.
{ "data": { "status": "reacted" } }   // 201, or "removed"

Media upload

POST /platform/bots/{botNumber}/media (multipart, field file). Allowed: jpg, jpeg, png, webp, gif, mp4, pdf, up to 16 MB. Use the returned url as an interactive header url or template header_url.

{ "data": { "url": "https://...", "header_type": "image", "mime_type": "image/jpeg", "size": 48213 } }   // 201

Templates

Templates are pre-approved messages. They are the only way to message someone outside the 24-hour window and the only way to start a conversation with someone who has not written to you first. Templates are created for your bot and reviewed by GekyChat before they can be sent.

GET /platform/bots/{botNumber}/templates lists your approved templates (name, language, category, body, variable_count, header, footer, interactive).

POST /platform/bots/{botNumber}/templates/send

FieldType
conversation_id or tointeger / stringOne is required. to is a phone number.
templatestringrequired, template name
languagestringoptional, default en
variablesstring[]The values for {{1}}, {{2}}… in the body. The count must match variable_count. Up to 20, no new lines or tabs.
header_urlstringhttps URL. Required when the template has an image, video or document header.
button_variablesstring[]Values for dynamic URL buttons (up to 3).
{ "data": { "status": "sent", "message_id": 9003, "conversation_id": 123 } }   // 201

Templates only work in direct conversations. Business-initiated templates (outside the window) are billed by category.

  • Marketing templates need an explicit opt-in. Other templates need the person to have written to you first, or an opt-in from you.
  • If a person replies STOP, they are opted out and every further template is refused (USER_OPTED_OUT). START opts them back in. Only the person can undo STOP.
  • You are told about changes with the subscription.updated event and with subscription in incoming webhooks.

POST /platform/bots/{botNumber}/opt-ins records an opt-in you collected yourself (for example a sign-up form).

tostring, required. Phone number of a GekyChat user.
sourcestring, required (max 64). Where consent was collected, e.g. website_form. Kept as proof.
{ "data": { "status": "opted_in", "source": "website_form" } }   // 201

Sika coin payments

Charge customers in Sika coins from your bot, and fulfil the order only after the payment is confirmed. It works for any service (airtime and data, tickets, vouchers, subscriptions). All calls authenticate like bot replies: Authorization: Bearer <bot webhook secret>. The bot must be an external bot with a webhook URL.

How it flows

  1. Your service creates a payment request for one person.
  2. The person gets a chat message from your bot with Pay N coins and Cancel buttons and confirms in the app.
  3. We send your webhook a signed sika.payment.paid event. Check it (see Verify the signature), then deliver the order.
  4. If you cannot deliver, refund the request and the coins go back to the person.

POST /platform/bots/{botNumber}/payment-requests

FieldType
payer_user_id or payer_phoneinteger / stringOne is required. The person who pays.
coinsintegerrequired, 1 to 1,000,000
descriptionstringrequired (max 200). Shown to the person, e.g. Airtime GHS 5 for 0241234567.
client_referencestringrequired (max 100). Your own order id, unique per bot. Sending it again returns the same request (200) and never creates a second one. The same reference with a different payer or amount returns 409.
expires_in_minutesintegeroptional, 1 to 1440, default 30. An unpaid request expires after this.
{ "success": true, "data": {
    "id": 17, "client_reference": "ORD-1001", "status": "pending",   // pending | paid | refunded | expired | cancelled
    "coins": 30, "description": "Airtime GHS 5", "payer_user_id": 42,
    "expires_at": "2026-10-18T15:10:00+00:00", "paid_at": null, "transaction_id": null,
    "refunded_at": null, "refund_transaction_id": null, "refund_reason": null
} }   // 201

GET /platform/bots/{botNumber}/payment-requests/{client_reference} returns the same object. Use it to re-check a payment before you deliver, or if a webhook never arrived.

POST /platform/bots/{botNumber}/payment-requests/{client_reference}/refund with {"reason": "Delivery failed"} returns the coins from your bot's wallet to the payer. A request can be refunded once, and only after it was paid (otherwise 422). Your bot's wallet must hold enough coins.

Webhook events (signed like every other webhook; payment.client_reference and payment.id identify the order, so use them to de-duplicate):

EventWhen
sika.payment.paidThe person paid. payment.transaction_id is the wallet transaction.
sika.payment.refundedYou refunded the request.
sika.payment.expiredThe request was not paid in time.
sika.payment.cancelledThe person tapped Cancel.
sika.transfer.receivedSomeone sent coins straight to your bot's wallet without a request. The body has transfer.transaction_id, coins, from_user_id and note.
// sika.payment.paid
{ "event": "sika.payment.paid",
  "payment": { "id": 17, "client_reference": "ORD-1001", "status": "paid", "coins": 30,
               "description": "Airtime GHS 5", "payer_user_id": 42, "transaction_id": "0b7f...", "paid_at": "2026-10-18T14:45:12+00:00" } }

Coins sent without a request. GET /platform/bots/{botNumber}/wallet/transactions?since_id=0&limit=50 lists coins received by your bot, oldest first, with your current balance. Each row has an id, a unique transaction_id, coins, from_user_id and note. Poll with the last id you processed, and credit each transaction_id only once. Payment requests are preferred because they tie a payment to one order.

Webhooks

Set a webhook URL on an API key (portal → API keys) or on a bot. We POST a JSON body to it. Reply with any 2xx within 10 seconds. Failures are retried up to 5 times, waiting 10 seconds, 1 minute, 5 minutes, then 30 minutes.

Verify the signature

Each call has three headers:

  • X-Gekychat-Event — the event name
  • X-Gekychat-Timestamp — Unix seconds
  • X-Gekychat-Signature — hex HMAC-SHA256 of {timestamp}.{raw body} using your webhook secret

Compute the HMAC over the raw body (before parsing JSON), compare in constant time, and reject old timestamps (for example older than 5 minutes).

// Node.js (Express with raw body)
const crypto = require('crypto');
function valid(req, secret) {
  const ts = req.header('X-Gekychat-Timestamp');
  const sig = req.header('X-Gekychat-Signature');
  const expected = crypto.createHmac('sha256', secret).update(ts + '.' + req.rawBody).digest('hex');
  return Math.abs(Date.now() / 1000 - Number(ts)) < 300 &&
         crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}

// PHP
$ts  = $_SERVER['HTTP_X_GEKYCHAT_TIMESTAMP'];
$sig = $_SERVER['HTTP_X_GEKYCHAT_SIGNATURE'];
$expected = hash_hmac('sha256', $ts . '.' . file_get_contents('php://input'), $secret);
$ok = abs(time() - (int) $ts) < 300 && hash_equals($expected, $sig);

# Python
import hmac, hashlib, time
expected = hmac.new(secret.encode(), (ts + '.').encode() + raw_body, hashlib.sha256).hexdigest()
ok = abs(time.time() - int(ts)) < 300 and hmac.compare_digest(expected, sig)

Events

EventWhenSent to
message.receivedSomeone writes to your number or bot. Also for button and list taps (type: "interactive_reply").API keys, bots
message.delivered, message.readA message your bot sent was delivered or read.bots
message.reactionSomeone reacted to a message your bot sent.bots
subscription.updatedSomeone changed their subscription from the app (opt in or out).bots
sika.payment.paid, .refunded, .expired, .cancelled, sika.transfer.receivedSika coin payments to your bot. See Sika coin payments.bots
// message.received
{
  "event": "message.received",
  "message": {
    "id": 9100,
    "conversation_id": 123,
    "from": { "id": 42, "name": "Ama", "phone": "+233241234567" },
    "type": "text",                     // text | image | video | audio | document | interactive_reply ...
    "body": "Hi, is my order ready?",
    "context": { "message_id": 9003 },  // the message this one answers (always set for button/list taps)
    "interactive_reply": {              // only for taps: button_reply or list_reply
      "type": "button_reply", "id": "yes", "title": "Yes", "message_id": 9003
    },
    "attachments": [ { "url": "https://...", "mime_type": "image/jpeg", "original_name": "photo.jpg", "size": 48213 } ],
    "timestamp": "2026-10-14T09:30:00+00:00"
  },
  "bot":          { "bot_number": "0302000000", "name": "Shop bot" },          // bots only
  "subscription": { "status": "opted_in", "changed": false },                   // bots only
  "session":      { "expires_at": "2026-10-15T09:30:00+00:00" }                 // bots only: free-form replies allowed until then
}

// message.reaction (also message.delivered / message.read, with "status" instead of "emoji")
{
  "event": "message.reaction",
  "message": { "id": 9003, "conversation_id": 123, "from": { "id": 42, "name": "Ama", "phone": "+233241234567" },
               "timestamp": "2026-10-14T09:31:00+00:00", "emoji": "👍" },
  "bot": { "bot_number": "0302000000", "name": "Shop bot" }
}

// subscription.updated
{
  "event": "subscription.updated",
  "subscription": { "status": "opted_out" },
  "user": { "id": 42, "name": "Ama", "phone": "+233241234567" },
  "timestamp": "2026-10-14T09:32:00+00:00",
  "bot": { "bot_number": "0302000000", "name": "Shop bot" }
}

Use message.id to de-duplicate: retries can deliver the same event more than once.

Incoming webhook endpoint

If you integrate a system that pushes events to GekyChat, use POST /platform/webhooks/incoming (signed). For Meta-style verification, GET /platform/webhooks/incoming answers the hub.mode=subscribe handshake with your hub.challenge when hub.verify_token matches.

Billing

Almost everything on the Platform API is free. You are billed only for template messages sent outside the 24-hour window, by category (utility, marketing, authentication). Each account gets a monthly free allowance per category; usage beyond it is charged.

  • Invoices. On the 1st of each month we issue one invoice for the month before. It is due 14 days later. See them under Billing in the portal, where you can print or save each one as PDF.
  • Credit. You can top up credit at any time. Credit is applied to your invoices automatically: when an invoice is issued, and straight away when you top up while one is open. So if you use more than your monthly allowance, topping up covers it.
  • Paying. Pay an invoice or top up with the methods switched on for your account (for example Paystack: card, mobile money and bank transfer; Hubtel: mobile money and card; Priority Bank). Card details are entered on the provider's own page; GekyChat never sees or stores them.
  • Reminders. You get a message in GekyChat (and an email, if your account has one) 3 days before the due date, and after it passes: at 1, 7 and 14 days.
  • Overdue invoices. If an invoice is more than 7 days overdue, template messages outside the 24-hour window are paused and return 402 with "code": "BILLING_OVERDUE". Replies inside the 24-hour window keep working. Paying the invoice or topping up credit lifts the pause immediately.

Error codes

HTTPcodeMeaning
401error: "invalid_client"Wrong client id or secret on /oauth/token.
401—Missing or wrong API key, token or bot secret.
403—The bot is not part of that conversation, or the action is not allowed.
403USER_OPTED_OUTThe person sent STOP, or opted out in the app.
404USER_NOT_FOUNDThat phone number is not a GekyChat user.
404TEMPLATE_NOT_FOUNDNo template with that name and language.
402BILLING_OVERDUEAn invoice is more than 7 days overdue, so template messages outside the 24-hour window are paused. Pay it in Billing; sending resumes at once.
422SESSION_EXPIREDThe 24-hour window is closed. Send a template. Response includes window_expires_at.
422TEMPLATE_NOT_APPROVEDThe template is pending or rejected.
422TEMPLATE_VARIABLES_MISMATCHThe number of variables does not match variable_count.
422HEADER_URL_REQUIREDThe template has a media header; send header_url.
422BUTTON_VARIABLE_REQUIREDA dynamic URL button needs a value in button_variables.
422TEMPLATE_DIRECT_ONLYTemplates cannot be sent to groups.
422OPT_IN_REQUIREDNo consent on record. Record one with /opt-ins, or wait for the person to write first.
422MARKETING_OPT_IN_REQUIREDMarketing templates need an explicit opt-in.
422validationA field is missing or too long. See errors in the body.
429TEMPLATE_DAILY_LIMITYou reached the daily template limit.
429—Rate limit. Wait and retry.

Something missing or unclear? Tell us at gekychat.com/contact and include the X-Request-Id of the call if there is one.