Geskap · WhatsApp API
Postman API reference ↗

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.

  1. 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.

  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, and reply with free-form text inside the 24h window
messages:readRead message delivery status
templates:readList templates
templates:writeCreate / delete templates
balance:readRead balance, pricing, connection status, buy credits
connection:writeFinalize 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": "..." }.

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 rejected because your Meta Business isn't Verified yet (see Rate limits & quotas)
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 template

Template or reply — which one? Replying to a customer who wrote to you in the last 24h? Use free-form text via POST /v1/messages/reply — it's free. Sending a message at your initiative (a broadcast, a follow-up, anything outside that 24h window) requires a Meta-approved template, sent below.
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)
template.header_document_urlstringOnly if the template has a DOCUMENT header, e.g. a PDF (optional). Never set alongside header_image_url.
template.header_document_filenamestringFilename shown in the WhatsApp bubble, e.g. bulletin.pdf (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 }

Reply to a customer (free-form text)

POST/v1/messages/replymessages:send

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.

FieldTypeNotes
tostringE.164, e.g. +237699123456 — normally the from of the message.inbound you received
textstringThe message text — no template variables
fromstringSending 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)

POST/v1/messages/interactivemessages:send

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.

FieldTypeNotes
tostringE.164, e.g. +237699123456
bodystringMessage text shown above the buttons/list
buttonsobject[]1–3 {"id","title"} (title ≤20 chars). Exclusive with list.
listobject{"button":"…","sections":[{"title"?,"rows":[{"id","title","description"?}]}]} — up to 10 rows total (row title ≤24 chars). Exclusive with buttons.
footerstringOptional small footer line (optional)
fromstringSending 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

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" }

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.

No webhook, no real-time push — not no message. Without a webhook URL registered (developer console → Webhook), an inbound message is still stored; you just don't get it pushed the instant it arrives. Pull it anytime with 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

GET/v1/messages/inboundmessages:read

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 paramNotes
numberA phone_number_id of yours — restrict to that number (optional; default = all your numbers)
fromA contact's MSISDN — restrict to one conversation (optional)
directionin or echo (optional; default = both)
sinceISO timestamp cursor — returns only messages strictly after it, oldest-first, for catch-up polling (optional; without it, most-recent-first)
limit1–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

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 (requires your Meta Business to be Verified in Meta Business Manager — see Rate limits & quotas)
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_media_base64stringOptional 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_mimestringMIME 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 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 REQUIRED — example_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). 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 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 — 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

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/checkoutbalance:read

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 }
A saved card that is declined or needs 3-D Secure authentication falls back to 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

GET/v1/credits/payment-methodbalance:read
DELETE/v1/credits/payment-methodbalance:read

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

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

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>
Always verify the signature over the exact bytes you received (before JSON parsing) — re-serializing changes it. Delivery is best-effort: a single 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" }
Unknown event names may be added over time — treat any 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.

A full, step-by-step guide (Firestore collection, a signed HTTPS Cloud Function, security rules, sample Node.js code) is in the developer console docs: Webhooks → Store received messages (Firebase).

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).


Geskap · WhatsApp Business API · API reference