WhatsApp API
Send WhatsApp template messages, receive your customers' replies, manage your templates, and top up credits — from your own backend, over a simple REST API.
Introduction
This API lets your application send WhatsApp template messages (order updates, OTPs, notifications) from your own WhatsApp Business number.
How it works: you keep your own WhatsApp Business Account and pay Meta directly for message delivery (their per-message rate, by category). On top of that, Geskap charges a small platform fee per message, deducted from your prepaid credit balance. You can read your balance and the exact fees at any time via the API.
- Base URL —
https://wa-api.geskap.com - Format — JSON over HTTPS. All responses are JSON.
- Auth — a secret API key (
sk_live_…) sent as a Bearer token.
Building a SaaS or reseller product on top of this API to serve multiple end customers is fine — it's usage-based: every number/key is billed for what it sends, with no separate reseller agreement or partner pricing to negotiate first.
Quickstart
From zero to a delivered message in three steps.
Connect your number, then create a key
Sign up on the developer console, connect your WhatsApp number there (the Meta sign-up runs in the browser), then create an API key. The key is shown only once — store it securely. Sending requires a connected number and a credit balance.
Send an approved template
curl -X POST https://wa-api.geskap.com/v1/messages \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "to": "+237699123456", "template": { "name": "order_update", "language": "en", "variables": ["Jean", "#8842"] }, "idempotency_key": "order-8842" }'Check delivery
Use the returned
idto poll delivery status.curl https://wa-api.geskap.com/v1/messages/6f1c... \ -H "Authorization: Bearer sk_live_..."
APPROVED.Authentication
Every request carries your API key as a Bearer token. Keep it secret — treat it like a password.
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxx
Each key carries scopes that limit what it can do:
| Scope | Grants |
|---|---|
messages:send | Send template messages, and reply with free-form text inside the 24h window |
messages:read | Read message delivery status |
templates:read | List templates |
templates:write | Create / delete templates |
balance:read | Read balance, pricing, connection status, buy credits |
connection:write | Finalize Meta Embedded Signup from your own app |
A request whose key lacks the required scope returns 403. New keys include all of the above. Keys issued before a scope existed do not gain it retroactively — if templates:write or connection:write returns 403 on an older key, create a new key.
Base URL & versioning
All endpoints live under the /v1 path on https://wa-api.geskap.com. The version is in the URL; breaking changes ship under a new version, so /v1 stays stable.
Recipient phone numbers use E.164 format, e.g. +237699123456. Recipients are always masked in stored logs.
Rate limits & quotas
Each key has a per-second burst limit and a daily message quota (defaults: 5 requests/second, 1000 messages/day). Exceeding either returns 429 with a Retry-After header (seconds) — back off and retry.
- A key may be restricted to an allow-list of templates; any other template returns
403. - UTILITY and MARKETING templates can both be created out of the box. Sending a MARKETING template still needs MARKETING send opt-in on your key (highest quality risk) — ask us to enable it.
- AUTHENTICATION templates (OTP codes) require your own Meta Business to be Verified (Meta Business Manager → Business Verification) — that applies to creation and sending, including the implicit OTP-button shape even without an explicit
category. Geskap does not currently offer to host your number inside its own Meta portfolio for AUTHENTICATION. Once Meta marks your business Verified, AUTHENTICATION works exactly like any other category — nothing to ask us for.
Errors
Errors use standard HTTP status codes and a JSON body: { "detail": "..." }.
| Code | Meaning |
|---|---|
400 | Bad request — unknown template, not APPROVED, bad number, missing example values |
401 | Missing / invalid / revoked API key |
402 | Insufficient credits — top up your balance |
403 | Missing scope · template not allowed for this key · MARKETING send not enabled · AUTHENTICATION rejected because your Meta Business isn't Verified yet (see Rate limits & quotas) |
404 | Resource not found |
429 | Rate limit exceeded — see the Retry-After header |
502 | Meta rejected the request (details in the body) |
Use an idempotency_key on sends so retries never double-send or double-charge.
Send a template
Send an APPROVED template to one recipient. The platform fee is charged on success and automatically refunded if Meta fails.
| Field | Type | Notes |
|---|---|---|
to | string | E.164, e.g. +237699123456 |
template.name | string | An APPROVED template name |
template.language | string | e.g. en, fr, en_US |
template.variables | string[] | Fills {{1}}..{{n}} in order (optional) |
template.header_image_url | string | Only if the template has an IMAGE header (optional) |
template.header_document_url | string | Only if the template has a DOCUMENT header, e.g. a PDF (optional). Never set alongside header_image_url. |
template.header_document_filename | string | Filename shown in the WhatsApp bubble, e.g. bulletin.pdf (optional) |
idempotency_key | string | Optional; reusing it returns the original result |
curl -X POST https://wa-api.geskap.com/v1/messages \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{"to":"+237699123456",
"template":{"name":"order_update","language":"en","variables":["Jean","#8842"]},
"idempotency_key":"order-8842"}'
Response (also sets an X-Credits-Remaining header):
{ "id": "6f1c...", "status": "sent", "wa_message_id": "wamid...",
"to": "2376****3456", "credits_charged": 2, "credits_remaining": 4198 }
Reply to a customer (free-form text)
Send a free-form text message to a customer who wrote to you within the previous 24 hours (Meta's service window). This is the conversational reply channel — free, no credits charged. Outside that window Meta rejects free-form text; use an approved template via POST /v1/messages instead.
| Field | Type | Notes |
|---|---|---|
to | string | E.164, e.g. +237699123456 — normally the from of the message.inbound you received |
text | string | The message text — no template variables |
from | string | Sending phone_number_id — use the number you received on the message.inbound so the reply goes out from the same number (optional) |
curl -X POST https://wa-api.geskap.com/v1/messages/reply \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{"to":"+237699123456","text":"Bonjour Jean, votre commande #8842 part demain matin."}'
{ "id": "9d3e...", "status": "sent", "wa_message_id": "wamid...", "to": "2376****3456" }
Reply buttons / list menu (non-template)
Same rule as the free-form reply above — free, only within the 24h service window — but with tappable UI instead of plain text: up to 3 reply buttons, or a list menu of up to 10 rows. The customer's tap arrives back as a normal message.inbound event carrying the id/title you set.
| Field | Type | Notes |
|---|---|---|
to | string | E.164, e.g. +237699123456 |
body | string | Message text shown above the buttons/list |
buttons | object[] | 1–3 {"id","title"} (title ≤20 chars). Exclusive with list. |
list | object | {"button":"…","sections":[{"title"?,"rows":[{"id","title","description"?}]}]} — up to 10 rows total (row title ≤24 chars). Exclusive with buttons. |
footer | string | Optional small footer line (optional) |
from | string | Sending phone_number_id (optional, same as the free-form reply) |
// reply buttons
curl -X POST https://wa-api.geskap.com/v1/messages/interactive \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{"to":"+237699123456","body":"Votre commande #8842 est prête. Confirmer la livraison ?",
"buttons":[{"id":"confirm","title":"Confirmer"},{"id":"reschedule","title":"Reporter"}]}'
// list menu
curl -X POST https://wa-api.geskap.com/v1/messages/interactive \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{"to":"+237699123456","body":"Que souhaitez-vous faire ?",
"list":{"button":"Choisir","sections":[{"rows":[
{"id":"track","title":"Suivre ma commande"},
{"id":"support","title":"Parler à un agent"}
]}]}}'
{ "id": "9d3e...", "status": "sent", "wa_message_id": "wamid...", "to": "2376****3456" }
Outside the 24h window this returns the same 400 as the free-form reply — send an approved template via POST /v1/messages instead (Meta's own template buttons/lists have a separate, more limited shape).
Message status
Delivery status of a message you sent. Advances sent → delivered → read, or failed.
{ "id": "6f1c...", "status": "delivered", "wa_message_id": "wamid...", "to": "2376****3456" }
Receive messages
Messages your customers send to your WhatsApp number(s) are relayed to you in real time via the message.inbound webhook, and stored on our side either way. Receiving is free — no credits are charged.
GET /v1/messages/inbound below, or register a webhook for real-time delivery.Reply within 24h of a message.inbound with free, free-form text via POST /v1/messages/reply. Outside that window, or for a message at your own initiative, use an approved template via POST /v1/messages.
Media (image, audio, document): the bytes are not re-hosted for API numbers — you get the type and Meta media_id; text and captions come through in full.
GET /v1/messages/inbound
Pull your stored messages — both received (direction: "in") and, for Coexistence numbers, messages your team sent from the WhatsApp Business App itself (direction: "echo"). Useful to backfill history, recover from a webhook outage, or skip building your own database entirely.
| Query param | Notes |
|---|---|
number | A phone_number_id of yours — restrict to that number (optional; default = all your numbers) |
from | A contact's MSISDN — restrict to one conversation (optional) |
direction | in or echo (optional; default = both) |
since | ISO timestamp cursor — returns only messages strictly after it, oldest-first, for catch-up polling (optional; without it, most-recent-first) |
limit | 1–200, default 50 |
curl "https://wa-api.geskap.com/v1/messages/inbound?limit=20" \ -H "Authorization: Bearer sk_live_..."
{ "messages": [
{ "id": "9a1f…", "direction": "in", "from": "+237699123456", "number": "1234567890",
"type": "text", "text": "Bonjour", "media_id": null, "media_mime": null, "media_url": null,
"wa_message_id": "wamid…", "timestamp": "2026-08-04T09:14:07+00:00",
"received_at": "2026-08-04T09:14:08+00:00" }
], "count": 1 }
Kept for a retention window (30 days by default) then purged — not permanent storage. See keeping your own copy if you need longer.
List templates
Your templates with their live Meta status. Only APPROVED templates can be sent.
[{ "name": "order_update", "language": "en", "status": "APPROVED",
"category": "UTILITY", "variables_count": 2 }]
Create a template
Submit a template for Meta review. Use {{1}}, {{2}}… in the body and provide one example per variable. Returns status: "PENDING" — poll GET /v1/templates until APPROVED.
| Field | Type | Notes |
|---|---|---|
name | string | lowercase_with_underscores |
language | string | e.g. en, fr |
category | string | UTILITY · MARKETING (both self-service) · AUTHENTICATION (requires your Meta Business to be Verified in Meta Business Manager — see Rate limits & quotas) |
body_text | string | Message body with {{n}} variables |
example_values | string[] | One example per variable (required by Meta) |
footer_text | string | Optional footer |
buttons | object[] | Max 2. {"kind":"url","text":"…","url":"https://…"} or {"kind":"quick_reply","text":"…"}. Don't mix kinds; Meta rejects wa.me links. |
header_media_base64 | string | Optional header sample, base64 — for an IMAGE, DOCUMENT (e.g. a PDF) or VIDEO header. Legacy name still accepted: header_image_base64. Requires header_media_mime. |
header_media_mime | string | MIME of the header sample. Required whenever header_media_base64 is given — it decides the Meta header format, and omitting it returns 422 rather than guessing. Legacy name accepted: header_image_mime.IMAGE (max 5 MB): image/jpeg, image/png.VIDEO (max 16 MB): video/mp4, video/3gpp.DOCUMENT (sample capped at 16 MB): application/pdf, text/plain, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document, application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-powerpoint, application/vnd.openxmlformats-officedocument.presentationml.presentation.WebP and GIF are rejected: WhatsApp accepts WebP only in stickers, never as a template header — convert to PNG or JPEG. |
curl -X POST https://wa-api.geskap.com/v1/templates \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{"name":"order_update","language":"en","category":"UTILITY",
"body_text":"Hi {{1}}, your order {{2}} is confirmed.",
"example_values":["Jean","#8842"],
"footer_text":"Geskap"}'
{ "name": "order_update", "language": "en", "status": "PENDING",
"category": "UTILITY", "variables_count": 2 }
Template variables
Almost every template rejection is about variables. Get these rules right and your template sails through Meta review. We validate them up front and return a precise 400 before ever calling Meta; if Meta still rejects, we pass its real reason back verbatim as (#code) message.
- Positional —
{{1}},{{2}},{{3}}… numbered, starting at 1 with no gaps ({{1}} {{3}}is invalid). - Named —
{{name}},{{order_id}}… letters, digits and_only (Meta lowercases them). - One example value per variable is REQUIRED —
example_valuesmust have at least as many entries as the body has variables, in the order the variables appear. - Counts must match — 2 variables → 2 example values. And at send time,
template.variablesmust supply one value per variable too. - No empty
{{}}; don't start/end the body with a variable or place two back-to-back ({{1}}{{2}}).
// ✅ positional
{ "body_text": "Bonjour {{1}}, votre commande {{2}} est confirmée.",
"example_values": ["Marc", "CMD-1043"] }
// ✅ named
{ "body_text": "Bonjour {{nom}}, votre commande {{order_id}} est confirmée.",
"example_values": ["Marc", "CMD-1043"] }
// ❌ mixed styles → 400 "Ne mélangez pas … numérotées … et nommées …"
// ❌ gap {{1}} {{3}} → 400 "… de {{1}} à {{2}} sans trou …"
// ❌ 2 vars, 1 value → 400 "Le corps a 2 variable(s) — fournissez une valeur d'exemple pour chacune"
Common Meta rejections and how to avoid them:
Meta says (as (#code) …) | Cause | Fix |
|---|---|---|
(#132000) parameter count mismatch | body variables ≠ values supplied | Match counts exactly (create-time examples & send-time variables). We now check this before billing you, so a mismatch returns a 400 and costs no credits. Named templates need no extra work: send variables in body order and we attach each parameter_name for you. |
(#132001) template does not exist | wrong name/language, or not yet APPROVED | Use the exact approved name + language code |
| example missing / invalid format | a variable has no example value | One example_values entry per variable |
| variable at edge / two adjacent | Meta forbids {{1}} at start/end or {{1}}{{2}} | Put text/space around every variable |
wa.me link rejected (2388081) | WhatsApp link in a URL button | Use a quick_reply button instead |
Delete a template
Delete a template on Meta (all languages) and locally.
{ "deleted": true, "name": "order_update" }
Check balance
{ "credit_balance": 4200, "low_balance_threshold": 50, "low_balance": false }
Pricing
Your per-message pricing — our fee only. platform_fee_credits is what a send costs you here; platform_fee_xaf is the same amount in currency. Meta's own delivery charge is billed to you by Meta directly and is not returned here — see Meta's official pricing.
{ "currency": "XAF", "base_price_per_credit": 10,
"categories": [
{ "category": "utility", "platform_fee_credits": 2, "platform_fee_xaf": 20 },
{ "category": "marketing", "platform_fee_credits": 3, "platform_fee_xaf": 30 }
] }
Credit packs
The credit packs you can buy. Price is fixed server-side.
[{ "code": "standard", "label": "Standard", "credits": 1000,
"price_xaf": 42000, "unit_price_xaf": 42, "discount_percent": 16 }]
Buy credits
Card only, via Stripe. The amount always comes from the pack, never from your request.
curl -X POST https://wa-api.geskap.com/v1/credits/checkout \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{"pack_code":"standard","use_saved_card":false}'
Two outcomes, distinguished by paid:
// paid:false → open checkout_url in a browser to pay.
// The first payment also saves the card for next time.
{ "order_id": "...", "pack_code": "standard", "credits": 1000,
"amount_xaf": 42000, "paid": false, "checkout_url": "https://checkout.stripe.com/..." }
// paid:true → use_saved_card:true and a card was on file: charged
// off-session, credits already granted, no redirect needed.
{ "order_id": "...", "pack_code": "standard", "credits": 1000,
"amount_xaf": 42000, "paid": true, "status": "succeeded", "credit_balance": 5200 }
checkout_url on the same order — always check paid rather than assuming the one-click path succeeded.Credits are granted by an idempotent operation, whether the payment is confirmed off-session or by Stripe webhook: you are never credited twice for one order.
Saved card
Inspect or forget the card kept for one-click re-charge. We never store card numbers — only Stripe's reference plus the brand and last four digits for display.
Order receipt
PDF receipt for a paid order (application/pdf). Returns 404 while the order is still unpaid.
Receive events (webhooks)
Instead of polling message status, register one HTTPS endpoint and we push every event to it in real time: delivery-status updates for your sends (message.status), inbound messages your customers send to your number (message.inbound — we run no bot on it, we relay it to you), and, for Coexistence numbers, messages your team sent from the WhatsApp Business App itself (message.echo).
Register it in the developer console: POST /v1/console/webhook (console session auth, body { "url": "https://..." } — HTTPS only, plain HTTP is rejected) pushes all event types to that one URL and returns 201 with { "url", "secret" } — the signing secret is shown only at that moment, store it right away. GET /v1/console/webhook returns { url, configured } (never the secret again); DELETE /v1/console/webhook removes it.
Every event is a POST with body { event, data, timestamp } (timestamp is ISO-8601 UTC, e.g. 2026-08-04T09:14:07+00:00) and a signature header computed over the raw body:
X-Camairetech-Signature: sha256=<hex hmac-sha256 of the raw request body, keyed by your secret>
POST, an 8s timeout, no automatic retry. Return 2xx fast, and de-duplicate on wa_message_id in case an endpoint ever gets called twice. Every delivery attempt (success or failure) is traced on the console's Logs page, so a missed push is never silent on our side.There's no synthetic "send me a test event" endpoint yet — the simplest way to test your endpoint is to send a real WhatsApp message to your connected number and watch message.inbound arrive (check the Logs page for the delivery attempt).
message.status
{ "event": "message.status",
"data": { "id": "6f1c…", "wa_message_id": "wamid…", "status": "delivered",
"recipient_masked": "2376****3456", "error": null },
"timestamp": "2026-08-04T09:14:07+00:00" }
status is one of sent, delivered, read, failed; error is non-null only when status is failed. id is our internal id — the same you'd get from GET /v1/messages/{id}.
message.inbound
{ "event": "message.inbound",
"data": { "from": "+237699123456", "contact_name": "Jean Mballa", "number": "1234567890",
"wa_message_id": "wamid…", "type": "text", "text": "Bonjour",
"media_id": null, "timestamp": "…" },
"timestamp": "2026-08-04T09:14:07+00:00" }
type is text, a media type (image, audio, video, document, sticker), or button for a tapped reply button/list row; text is "" when the message isn't plain text. media_id is Meta's media identifier, not the bytes (we don't host media for API numbers). number is the phone_number_id that received the message — pass it back as from on POST /v1/messages/reply. Every inbound message is also stored on our side — pull it back anytime with GET /v1/messages/inbound.
message.echo
Coexistence numbers only (the WhatsApp Business App keeps running on the phone alongside the API). Fired when a message is sent from the phone itself, outside this API — so your own systems stay in sync with what a human sent manually.
{ "event": "message.echo",
"data": { "to": "+237699123456", "number": "1234567890",
"wa_message_id": "wamid…", "type": "text", "text": "On vous rappelle dans 10 min",
"media_id": null, "timestamp": "…" },
"timestamp": "2026-08-04T09:14:07+00:00" }
event you don't recognize as a no-op (log it, return 2xx, move on) rather than failing.Verify the signature
import hmac, hashlib
def verify(raw_body: bytes, header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header or "")
Keep your own copy (optional)
We already store every inbound message on our side for a retention window, readable anytime via GET /v1/messages/inbound — you're not required to build a database just to avoid losing messages. Still, for retention beyond our window, or to feed your own CRM/dashboard, have your webhook endpoint write each message.inbound (and message.echo) event to a database of your own the moment it arrives.
In short: (1) verify the X-Camairetech-Signature HMAC on the raw body, (2) on a message.inbound event, insert a row/document with from, contact_name, number, text, type, media_id, wa_message_id, timestamp. Nothing here is provider-specific — Firebase/Firestore, Supabase/Postgres, or any queue works the same way.
Health check
Verify a key works and see whose it is.
{ "ok": true, "client": "Acme SARL", "client_id": "..." }
Test in Postman
Load every endpoint into Postman, set your key once, and run.
The download gives you a .postman_collection.json — in Postman: Import → drop the file → set the api_key collection variable to your sk_live_ key.
Prefer import-by-link? In Postman: Import → Link and paste either
https://wa-api.geskap.com/v1/postman.json (curated) or
https://wa-api.geskap.com/openapi.json (OpenAPI, auto-generated).
Geskap · WhatsApp Business API · API reference