WhatsApp API
Send WhatsApp template messages, 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, CamaireTech 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.
Quickstart
From zero to a delivered message in three steps.
Get your API key
Your CamaireTech contact connects your WhatsApp number and issues a key. It is shown only once — store it securely.
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 |
messages:read | Read message delivery status |
templates:read | List templates |
templates:write | Create / delete templates |
balance:read | Read balance, pricing, buy credits |
A request whose key lacks the required scope returns 403. New keys include all of the above.
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 are on request, not self-service, for creation and sending: Meta requires the number to be hosted on a verified Business Portfolio. Any AUTHENTICATION template — including the implicit OTP-button shape, even without an explicit
category— returns403until we enable it for your key. Contact us to request it.
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 not enabled (on request only) |
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 message
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) |
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 }
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" }
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 (on request only — 403 unless enabled for your key) |
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_image_base64 | string | Optional IMAGE header (+ header_image_mime) |
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":"CamaireTech"}'
{ "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) |
(#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. platform_fee is what you pay CamaireTech (credits); meta_cost_xaf is what you pay Meta directly (estimate).
{ "currency": "XAF", "base_price_per_credit": 10,
"categories": [
{ "category": "utility", "platform_fee_credits": 2, "platform_fee_xaf": 20, "meta_cost_xaf": 4 },
{ "category": "marketing", "platform_fee_credits": 3, "platform_fee_xaf": 30, "meta_cost_xaf": 13 }
] }
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
Start a purchase. A Mobile Money prompt is pushed to phone. The amount comes from the pack, never from you.
curl -X POST https://wa-api.geskap.com/v1/credits/orders \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{"pack_code":"standard","phone":"+237699123456"}'
{ "order_id": "...", "status": "pending", "amount_xaf": 42000,
"reference": "...", "ussd_code": "*126#" }
Order status
Poll until status is success or failed. Credits are granted here when we confirm the payment — polling is idempotent, you are never credited twice.
{ "order_id": "...", "status": "success", "credits_added": 1000, "balance": 5200 }
Receive events (webhooks)
Instead of polling message status, register one HTTPS endpoint and we push events to it in real time: delivery-status updates for your sends, and inbound messages your customers send to your number (we run no bot on it — we relay it to you).
Register it in the developer console (POST /v1/console/webhook) — the signing secret is shown once. GET returns { url, configured } (never the secret); DELETE disables it.
Every event is a POST with body { event, data, timestamp } and a signature header over the raw body:
X-Camairetech-Signature: sha256=<hex hmac-sha256 of the raw request body, keyed by your secret>
2xx fast.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" }
message.inbound
{ "event": "message.inbound",
"data": { "from": "+237699123456", "wa_message_id": "wamid…", "type": "text",
"text": "Bonjour", "media_url": null, "timestamp": "…" },
"timestamp": "2026-08-04T09:14:07+00:00" }
type is text or a media type (image, audio, video, document, sticker); media_url is null (we don't host media for API numbers).
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 "")
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).
CamaireTech · WhatsApp Business API · API reference