CamaireTech · WhatsApp API
Postman API reference ↗

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 URLhttps://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.

  1. Get your API key

    Your CamaireTech contact connects your WhatsApp number and issues a key. It is shown only once — store it securely.

  2. 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"
      }'
  3. Check delivery

    Use the returned id to poll delivery status.

    curl https://wa-api.geskap.com/v1/messages/6f1c... \
      -H "Authorization: Bearer sk_live_..."
No template yet? Create one first via POST /v1/templates and wait until Meta marks it 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:

ScopeGrants
messages:sendSend template messages
messages:readRead message delivery status
templates:readList templates
templates:writeCreate / delete templates
balance:readRead 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 — returns 403 until we enable it for your key. Contact us to request it.

Errors

Errors use standard HTTP status codes and a JSON body: { "detail": "..." }.

CodeMeaning
400Bad request — unknown template, not APPROVED, bad number, missing example values
401Missing / invalid / revoked API key
402Insufficient credits — top up your balance
403Missing scope · template not allowed for this key · MARKETING send not enabled · AUTHENTICATION not enabled (on request only)
404Resource not found
429Rate limit exceeded — see the Retry-After header
502Meta rejected the request (details in the body)

Use an idempotency_key on sends so retries never double-send or double-charge.

Send a message

POST/v1/messagesmessages:send

Send an APPROVED template to one recipient. The platform fee is charged on success and automatically refunded if Meta fails.

FieldTypeNotes
tostringE.164, e.g. +237699123456
template.namestringAn APPROVED template name
template.languagestringe.g. en, fr, en_US
template.variablesstring[]Fills {{1}}..{{n}} in order (optional)
template.header_image_urlstringOnly if the template has an IMAGE header (optional)
idempotency_keystringOptional; 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

GET/v1/messages/{id}messages:read

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

GET/v1/templatestemplates:read

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

POST/v1/templatestemplates:write

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.

FieldTypeNotes
namestringlowercase_with_underscores
languagestringe.g. en, fr
categorystringUTILITY · MARKETING (both self-service) · AUTHENTICATION (on request only — 403 unless enabled for your key)
body_textstringMessage body with {{n}} variables
example_valuesstring[]One example per variable (required by Meta)
footer_textstringOptional footer
buttonsobject[]Max 2. {"kind":"url","text":"…","url":"https://…"} or {"kind":"quick_reply","text":"…"}. Don't mix kinds; Meta rejects wa.me links.
header_image_base64stringOptional 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 or named — never both. A template body uses one style, not a mix.
  • 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 REQUIREDexample_values must 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.variables must 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) …)CauseFix
(#132000) parameter count mismatchbody variables ≠ values suppliedMatch counts exactly (create-time examples & send-time variables)
(#132001) template does not existwrong name/language, or not yet APPROVEDUse the exact approved name + language code
example missing / invalid formata variable has no example valueOne example_values entry per variable
variable at edge / two adjacentMeta forbids {{1}} at start/end or {{1}}{{2}}Put text/space around every variable
wa.me link rejected (2388081)WhatsApp link in a URL buttonUse a quick_reply button instead

Delete a template

DELETE/v1/templates/{name}templates:write

Delete a template on Meta (all languages) and locally.

{ "deleted": true, "name": "order_update" }

Check balance

GET/v1/balancebalance:read
{ "credit_balance": 4200, "low_balance_threshold": 50, "low_balance": false }

Pricing

GET/v1/pricingbalance:read

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

GET/v1/credits/packsbalance:read

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

POST/v1/credits/ordersbalance:read

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

GET/v1/credits/orders/{id}balance:read

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>
Always verify the signature over the exact bytes you received (before JSON parsing) — re-serializing changes it. Delivery is best-effort (~8s timeout, no auto-retry yet): return 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

GET/v1/pingbalance:read

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.

Open interactive reference (/docs)

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