Platform API reference
Getting started
All Platform API requests go to:
https://api.gekychat.com/platform
- Sign in at /developers and turn on developer mode.
- Create an API key. Copy it right away; it is shown once.
- 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>.
| Credential | Looks like | Used for |
|---|---|---|
| API key | sk_… | Users, conversations and messages (/platform/users, /platform/conversations/*, /platform/messages/*). Messages sent with a key appear as sent by the key's owner. |
| Access token | long token | Same endpoints as an API key. Get one by trading your client id and secret (below). |
| Bot secret | per bot | Business-run bot endpoints under /platform/bots/{botNumber}/…. Issued when your bot is registered. |
Get an access token
POST /platform/oauth/token
| Field | Type | |
|---|---|---|
client_id | string | required. Your developer client id (dev_…), shown on the portal. |
client_secret | string | required. One of your API keys. |
grant_type | string | required. 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-Idheader. Quote it when you contact support; the same id appears in your request logs. - Status codes.
200/201success ·401bad or missing credentials ·403not allowed (for example the bot is not in that conversation) ·404not found ·422validation or rule failure ·429rate limit ·5xxour side, safe to retry. - Validation errors return
422with{"message": "...", "errors": {"field": ["..."]}}. - Rule errors include a stable
codeyou can match on, for example{"error": "...", "code": "SESSION_EXPIRED"}. See Error codes.
| Endpoint group | Limit |
|---|---|
| Bot actions (typing, read, react, templates list) | 240 requests / minute / bot |
| Bot reply | 120 requests / minute |
| Template send, opt-ins | 120 requests / minute |
| Media upload | 30 requests / minute |
| Payment requests (create) | 120 requests / minute |
| Payment refunds | 60 requests / minute |
| Template sends per bot | 1,000 per day |
| Incoming-webhook verification | 60 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.
phone | string, required |
name | string, 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_id | integer, optional | The person. Defaults to the authenticated user. |
bot_user_id | integer, optional | The 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
| Field | Type | |
|---|---|---|
conversation_id | integer | required |
body | string | required. The message text. |
external_ref | string (max 255) | optional. Your own reference; stored with the message. |
metadata | object | optional. Free-form data stored with the message. |
buttons | array | optional. Link buttons. |
interactive | object | optional. 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.
phone | string, required |
body | string, required |
external_ref | string, optional |
metadata | object, optional |
bot_user_id | integer, 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.
Link buttons
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" }
]
}
| Field | Rule |
|---|---|
buttons | at most 2 |
buttons[].label | required, 1–12 characters |
buttons[].url | required, https only, up to 2048 characters |
buttons[].color | optional, 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" }
] }
}
}
| Field | Rule |
|---|---|
interactive.type | required: buttons, list or cta_url |
interactive.header | optional. type is text (needs text, max 60) or image, video, document (need an https url; upload with media upload) |
interactive.footer | optional, max 60 characters |
| reply buttons | 1–3. Each has a unique id (max 200) and a title (max 20) |
| list | button 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-action | 1–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_id | integer, required. The bot must be in it. |
body | string, required, max 10,000 |
reply_to_message_id | integer, optional. Must be in the same conversation. Shows the message being answered. |
buttons, interactive | optional, 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_id | integer, required |
is_typing | boolean, 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_id | integer, required |
emoji | string (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
| Field | Type | |
|---|---|---|
conversation_id or to | integer / string | One is required. to is a phone number. |
template | string | required, template name |
language | string | optional, default en |
variables | string[] | The values for {{1}}, {{2}}… in the body. The count must match variable_count. Up to 20, no new lines or tabs. |
header_url | string | https URL. Required when the template has an image, video or document header. |
button_variables | string[] | 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.
Consent: opt-in & STOP
- 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.updatedevent and withsubscriptionin incoming webhooks.
POST /platform/bots/{botNumber}/opt-ins records an opt-in you collected yourself (for example a sign-up form).
to | string, required. Phone number of a GekyChat user. |
source | string, 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
- Your service creates a payment request for one person.
- The person gets a chat message from your bot with Pay N coins and Cancel buttons and confirms in the app.
- We send your webhook a signed
sika.payment.paidevent. Check it (see Verify the signature), then deliver the order. - If you cannot deliver, refund the request and the coins go back to the person.
POST /platform/bots/{botNumber}/payment-requests
| Field | Type | |
|---|---|---|
payer_user_id or payer_phone | integer / string | One is required. The person who pays. |
coins | integer | required, 1 to 1,000,000 |
description | string | required (max 200). Shown to the person, e.g. Airtime GHS 5 for 0241234567. |
client_reference | string | required (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_minutes | integer | optional, 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):
| Event | When |
|---|---|
sika.payment.paid | The person paid. payment.transaction_id is the wallet transaction. |
sika.payment.refunded | You refunded the request. |
sika.payment.expired | The request was not paid in time. |
sika.payment.cancelled | The person tapped Cancel. |
sika.transfer.received | Someone 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 nameX-Gekychat-Timestamp— Unix secondsX-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
| Event | When | Sent to |
|---|---|---|
message.received | Someone writes to your number or bot. Also for button and list taps (type: "interactive_reply"). | API keys, bots |
message.delivered, message.read | A message your bot sent was delivered or read. | bots |
message.reaction | Someone reacted to a message your bot sent. | bots |
subscription.updated | Someone changed their subscription from the app (opt in or out). | bots |
sika.payment.paid, .refunded, .expired, .cancelled, sika.transfer.received | Sika 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
402with"code": "BILLING_OVERDUE". Replies inside the 24-hour window keep working. Paying the invoice or topping up credit lifts the pause immediately.
Error codes
| HTTP | code | Meaning |
|---|---|---|
| 401 | error: "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. |
| 403 | USER_OPTED_OUT | The person sent STOP, or opted out in the app. |
| 404 | USER_NOT_FOUND | That phone number is not a GekyChat user. |
| 404 | TEMPLATE_NOT_FOUND | No template with that name and language. |
| 402 | BILLING_OVERDUE | An 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. |
| 422 | SESSION_EXPIRED | The 24-hour window is closed. Send a template. Response includes window_expires_at. |
| 422 | TEMPLATE_NOT_APPROVED | The template is pending or rejected. |
| 422 | TEMPLATE_VARIABLES_MISMATCH | The number of variables does not match variable_count. |
| 422 | HEADER_URL_REQUIRED | The template has a media header; send header_url. |
| 422 | BUTTON_VARIABLE_REQUIRED | A dynamic URL button needs a value in button_variables. |
| 422 | TEMPLATE_DIRECT_ONLY | Templates cannot be sent to groups. |
| 422 | OPT_IN_REQUIRED | No consent on record. Record one with /opt-ins, or wait for the person to write first. |
| 422 | MARKETING_OPT_IN_REQUIRED | Marketing templates need an explicit opt-in. |
| 422 | validation | A field is missing or too long. See errors in the body. |
| 429 | TEMPLATE_DAILY_LIMIT | You 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.