API reference
One API to drive everything programmatically: numbers, templates, messages, campaigns and your subscription.
Build your own product on top of it.
Authentication
All requests use a server-side API key. Generate one in your dashboard under API keys — it looks
like pk_live_… and is shown only once. Send it as a bearer token:
Authorization: Bearer pk_live_xxxxxxxxxxxxxxxxxxxxxxxxKeep keys server-side
A key can send messages from every number it covers. Never expose it in a browser or mobile app — call the API from your backend only.
Key scope
A key is either account-wide or tied to a single connected business. Pick the business when
generating the key in API keys. A scoped key resolves only that business: lists return its rows
only, ids belonging to another one answer 404, and GET /api/v1/subscription answers 403 — so you
can hand it to that business (or run their integration) without exposing the rest of your account.
Account-wide keys reach every number you have connected.
Conventions
Base URL https://wa.genuka.com/api/v1. Bodies and responses are JSON. List endpoints return
{ "data": [...] }. Everything is automatically scoped to your account. Errors use HTTP status
codes with { "error": "code", "message": "…" }.
Call wa.genuka.com over https and without a trailing slash: http:// returns a 301 to
https, /api/v1/messages/ a 308 to the slashless form, and some HTTP clients drop the
Authorization header when following a redirect. No other hostname serves this API.
Send a User-Agent
Our CDN refuses a few known bot signatures before they reach the API. The one our integrators
hit most is Python-urllib/3.x, the header urllib sends by default: it answers 403 with the
body error code: 1010, which does not come from us and therefore carries no JSON. Send a
User-Agent that identifies your integration — which is what will let you find yourself in your
own logs anyway:
request.add_header("User-Agent", "acme-crm/1.4 (+https://acme.co)")requests, curl, axios, node-fetch, and the Go and Java clients pass with no change.
Companies (connected businesses)
| Method | Endpoint | Description |
|---|---|---|
GET | /api/v1/companies | List connected businesses with their connections |
GET | /api/v1/companies/{id} | A single business + connections + counts |
curl https://wa.genuka.com/api/v1/companies -H "Authorization: Bearer pk_live_xxx"
{
"data": [
{
"id": "cmp_123",
"name": "Acme Coffee",
"onboardedAt": "2026-06-01T10:12:00.000Z",
"connections": [
{ "id": "con_1", "wabaId": "1029…", "phoneNumberId": "1065…",
"displayPhoneNumber": "+237 6 90 …", "qualityRating": "GREEN", "status": "connected" }
],
"_count": { "templates": 4 }
}
]
}Connections (numbers)
| Method | Endpoint | Description |
|---|---|---|
GET | /api/v1/connections | List WhatsApp connections (optional ?companyId=, ?externalRef=) |
A connection is a WABA + phone number. Its id is what you pass when creating templates, campaigns
or sending messages.
Templates
Templates are pre-approved message layouts. You need one to start a conversation (i.e. message a
customer outside the 24-hour window — see Messages). You create a template here, Meta
reviews it, and the approval / rejection lands automatically on GET /templates/{id} (and on your
webhooks).
| Method | Endpoint | Description |
|---|---|---|
GET | /api/v1/templates | List templates (optional ?companyId=) |
POST | /api/v1/templates | Create & submit a template to Meta |
GET | /api/v1/templates/{id} | A template + its status history |
POST | /api/v1/templates/sync | Re-read every status from Meta |
DELETE | /api/v1/templates/{id} | Delete on Meta and locally |
How creation works
You pass Meta's components array verbatim. That keeps this endpoint thin while letting you
build any template Meta supports — text, media headers, buttons, OTP, etc. The only required
fields are connectionId, name and components. category is one of MARKETING, UTILITY or
AUTHENTICATION (default MARKETING); language is a Meta locale such as en_US or fr (default
en).
Component reference
A template is an ordered list of components. Each has a type:
| Type | Shape | Description |
|---|---|---|
HEADER | format: TEXT | IMAGE | VIDEO | DOCUMENT | LOCATION | Optional. One per template. Media headers need an example handle. |
BODY | text + example | Required (except AUTHENTICATION). Holds the {{1}}… or {{name}} variables. |
FOOTER | text | Optional short footer. No variables. |
BUTTONS | QUICK_REPLY | URL | PHONE_NUMBER | COPY_CODE | OTP | Optional. Up to 10 buttons (rules vary by type). |
Examples are mandatory
Any component with variables (or a media header) must include an example so Meta can review it:
"example": { "body_text": [["Alice", "#1024"]] } for the body,
"example": { "header_handle": ["<id>"] } for a media header.
Utility / marketing template (header + body + buttons)
curl -X POST https://wa.genuka.com/api/v1/templates \
-H "Authorization: Bearer pk_live_xxx" -H "Content-Type: application/json" \
-d '{
"connectionId": "con_1",
"name": "order_shipped",
"language": "en_US",
"category": "UTILITY",
"components": [
{ "type": "HEADER", "format": "IMAGE",
"example": { "header_handle": ["4::aW1hZ2Uv..."] } },
{ "type": "BODY",
"text": "Hi {{1}}, order {{2}} just shipped. Track it any time.",
"example": { "body_text": [["Alice", "#1024"]] } },
{ "type": "FOOTER", "text": "Reply STOP to opt out" },
{ "type": "BUTTONS", "buttons": [
{ "type": "URL", "text": "Track order",
"url": "https://acme.co/track/{{1}}", "example": ["https://acme.co/track/1024"] },
{ "type": "QUICK_REPLY", "text": "Need help" }
] }
]
}'
{ "data": { "id": "tpl_123", "status": "pending", "providerId": "12534…" } }The media header example.header_handle is the resumable-upload handle returned by Meta's media
upload — at send time you supply the real image by URL or media id (see
Messages → template).
Named parameters
Prefer {{name}} over positional {{1}}? Set parameterFormat: "NAMED" and give each variable a
parameter_name in the example.
{
"connectionId": "con_1",
"name": "appointment_reminder",
"language": "en_US",
"category": "UTILITY",
"parameterFormat": "NAMED",
"components": [
{ "type": "BODY",
"text": "Hi {{customer_name}}, your appointment is on {{date}}.",
"example": { "body_text_named_params": [
{ "param_name": "customer_name", "example": "Alice" },
{ "param_name": "date", "example": "June 20" }
] } }
]
}Authentication template (OTP)
Authentication templates deliver one-time codes. The body and button text are fixed by WhatsApp — you don't write copy. You only choose the button type and a few options. No header, media, URLs or emojis are allowed.
| Type | Shape | Description |
|---|---|---|
COPY_CODE | otp_type: COPY_CODE | Customer taps to copy the code. Simplest, works everywhere. |
ONE_TAP | otp_type: ONE_TAP | Android auto-fill. Needs package_name + signature_hash. |
ZERO_TAP | otp_type: ZERO_TAP | Code delivered silently to the app. Needs the same app binding. |
{
"connectionId": "con_1",
"name": "verification_code",
"language": "en_US",
"category": "AUTHENTICATION",
"messageSendTtlSeconds": 600,
"components": [
{ "type": "BODY", "add_security_recommendation": true },
{ "type": "FOOTER", "code_expiration_minutes": 10 },
{ "type": "BUTTONS", "buttons": [
{ "type": "OTP", "otp_type": "COPY_CODE", "text": "Copy code" }
] }
]
}For ONE_TAP / ZERO_TAP, add the app binding to the OTP button: "autofill_text": "Autofill",
"supported_apps": [{ "package_name": "com.acme.app", "signature_hash": "K8a..." }].
Sending the code
Creating the template never sends anything. To deliver a code you send a template message with
the otp shorthand — see Messages → authentication template.
Refreshing statuses
A review outcome reaches you as a webhook — that is the path you should build
on. But a webhook that was never delivered (an endpoint that was down, an integration added after
the fact) leaves the template reading pending here long after Meta approved it, and a campaign
refusing to launch with no visible reason. POST /api/v1/templates/sync re-reads the truth from
Meta and writes it back.
curl -X POST https://wa.genuka.com/api/v1/templates/sync \
-H "Authorization: Bearer pk_live_xxx" -H "Content-Type: application/json" \
-d '{ "companyId": "cmp_1" }'The body is optional: with no field at all the refresh covers every WhatsApp account your key can
reach, companyId narrows it to one client, and connectionId to a single number's WABA. The
response names each WhatsApp account it touched — so one unreachable account is reported instead
of hidden — and returns the freshly written rows, which saves a follow-up GET.
{
"data": {
"wabas": [{ "wabaId": "1029…", "companyId": "cmp_1", "ok": true }],
"synced": 1,
"failed": 0,
"templates": [
{ "id": "tpl_1", "name": "order_shipped", "language": "fr", "status": "approved", "…": "…" }
]
}
}A refresh where every account refused answers 502 meta_rejected with the same results in the
body; a partial failure stays a 200 you are expected to read. GET /api/v1/templates?sync=true
does the same refresh inline before listing, and accepts the same companyId / connectionId
filters.
It is a repair path, not a polling loop
Each call spends one Meta read per WhatsApp account. Run it on a schedule if you like — hourly is plenty — but keep the webhook as the way statuses normally arrive.
Campaigns
| Method | Endpoint | Description |
|---|---|---|
GET | /api/v1/campaigns | List campaigns (optional ?companyId=) |
POST | /api/v1/campaigns | Create a campaign with recipients |
GET | /api/v1/campaigns/{id} | A campaign with delivery stats |
GET | /api/v1/campaigns/{id}/recipients | Per-recipient status (?status=, ?limit=) |
POST | /api/v1/campaigns/{id}/launch | Send to all pending recipients |
Create a campaign
Each recipient carries its own variables. The simple form is a positional array for the body
parameters {{1}}, {{2}}…. For media headers, buttons or OTP codes, pass a rich object instead —
{ "body": [...], "header": {...}, "buttons": [...] } — the same shape accepted by
Messages → template. The template must be approved before launching.
curl -X POST https://wa.genuka.com/api/v1/campaigns \
-H "Authorization: Bearer pk_live_xxx" -H "Content-Type: application/json" \
-d '{
"connectionId": "con_1",
"templateId": "tpl_123",
"name": "June promo",
"recipients": [
{ "to": "+237690000001", "variables": ["Alice", "#1024"] },
{ "to": "+237690000002", "variables": ["Bob", "#1025"] }
]
}'
{ "data": { "id": "cmp_9", "name": "June promo", "status": "draft", "_count": { "recipients": 2 } } }Launch
curl -X POST https://wa.genuka.com/api/v1/campaigns/cmp_9/launch \
-H "Authorization: Bearer pk_live_xxx"
{ "data": { "sent": 2, "failed": 0, "skipped": 0 } }Limits
We never charge for a message — Meta bills your WABA directly. Recipients do count against your
plan's message allowance for the period, and the whole list is checked before the first send, so a
launch that wouldn't fit is refused up front with 402 plan_limit_messages rather than stopping
half-way. Only the recipients Meta actually accepted are consumed. V1 sends synchronously — keep
lists modest; queued sending for large lists is on the roadmap.
Media
Upload a file once, send it as many times as you need. Storage is private: nothing put here is reachable from a public URL, and Meta never fetches the file — we hand it the bytes server to server at send time.
curl -X POST https://wa.genuka.com/api/v1/media \
-H "Authorization: Bearer pk_live_xxx" \
-F "companyId=cmp_1" \
-F "[email protected]"
{ "data": {
"id": "ast_1",
"url": "https://wa.genuka.com/api/v1/media/ast_1/content",
"expiresAt": "2026-09-18T09:12:00.000Z",
"mimeType": "application/pdf",
"kind": "document",
"sizeBytes": 481203,
"filename": "briefing.pdf"
},
"deduplicated": false }Two limits, stated up front
Retention — 30 days. Every file is deleted permanently 30 days after upload, and
expiresAt says exactly when. That is Meta's own window: past it, their copy is gone anyway. A
send referencing an expired file answers 404 media_not_found.
Storage — per plan. 1 GB on Starter, 3 GB on Growth, 5 GB on Scale, custom on Enterprise,
across every client. The cap is on what is held: deleting a file gives the room back
immediately. An upload that would exceed it answers 402 plan_limit_media before a single byte
is stored. GET /api/v1/media returns the current figure under storage.
url is an endpoint on this API, not a share link: it requires your key and refuses any call
outside its scope. Identical bytes already stored for this client come back as-is with a 200
and deduplicated: true, rather than being stored twice.
| Route | Description |
|---|---|
POST /api/v1/media | Upload. Multipart: file (required), companyId (required with a partner-wide key), filename (optional). |
GET /api/v1/media | List. Filters kind, companyId, cursor pagination, plus the storage figure. |
GET /api/v1/media/{id} | One file's metadata. |
GET /api/v1/media/{id}/content | The bytes, authenticated by your key. |
DELETE /api/v1/media/{id} | Delete now, without waiting for expiry. |
POST /api/v1/media/header-handle | Produce the header_handle for a template header. Multipart: file (required), connectionId or companyId (neither, with a client key). |
A template's media header: the header_handle
Creating a template whose header is an IMAGE, VIDEO or DOCUMENT requires an
example.header_handle, which Meta only issues through its Resumable Upload API. This is not
the same thing as an assetId: a handle is consumed once, at template creation, and a send
rejects it. Conversely an assetId is worthless at creation time.
curl -X POST https://wa.genuka.com/api/v1/media/header-handle \
-H "Authorization: Bearer pk_live_xxx" \
-F "connectionId=con_1" \
-F "[email protected]"
{ "data": {
"handle": "4::YXBwbGljYXRpb24vcGRm…",
"filename": "catalogue.pdf",
"mimeType": "application/pdf",
"sizeBytes": 481203
} }Name the client the way every other /media route expects (-F "companyId=cmp_1") or one
specific number (-F "connectionId=con_1"); a client key needs neither. The handle is the same
either way — the upload session belongs to the Meta app, not to a number.
Carry that handle into the template definition:
{
"connectionId": "con_1",
"name": "morning_brief",
"language": "en_US",
"category": "UTILITY",
"components": [
{ "type": "HEADER", "format": "DOCUMENT",
"example": { "header_handle": ["4::YXBwbGljYXRpb24vcGRm…"] } },
{ "type": "BODY", "text": "Hi {{1}}, your brief for {{2}} is attached.",
"example": { "body_text": [["Ana", "March 12"]] } }
]
}Accepted formats: image/jpeg, image/png, video/mp4, video/3gpp, application/pdf. Maximum
size 4 MB — above that the response is 413 media_too_large. Nothing is kept on our side: the
handle is single-use, so archiving it would serve no purpose.
Sending a stored file
Pass assetId anywhere a media object is expected, template headers included. We resolve the
Meta media_id for the right phone number and refresh it as it ages.
{
"connectionId": "con_1",
"to": "237600000000",
"document": { "assetId": "ast_1", "filename": "briefing.pdf" }
}id (a Meta media_id you minted yourself) and link (a public URL Meta fetches) are still
accepted. assetId is the one to prefer: it is the only one that exposes the file on no URL at
all, and the only one that does not expire under you after 30 days.
Messages
| Method | Endpoint | Description |
|---|---|---|
POST | /api/v1/messages | Send a single message of any type |
One endpoint sends every WhatsApp message type. Always pass connectionId and to, then exactly
one content field from the table below. Add replyTo (a received message's wamid) to
quote/reply to a message.
| Field | Window | Description |
|---|---|---|
template | billable | Pre-approved template. The only way to start a conversation outside the 24h window. |
text | free* | { "text": "Hi" } or { "text": { "body": "…", "previewUrl": true } } |
image / video / audio / document / sticker | free* | { "image": { "assetId": "ast_1" } } (recommended), { "link": "…" } or { "id": "<media-id>" }; caption/filename optional |
location | free* | { "location": { "latitude": …, "longitude": …, "name": "…", "address": "…" } } |
contacts | free* | { "contacts": [ … Meta contact objects … ] } |
reaction | free* | { "reaction": { "messageId": "wamid…", "emoji": "👍" } } |
buttons / list / cta | free* | Interactive reply buttons, a list menu, or a call-to-action URL button. |
locationRequest | free* | { "locationRequest": { "body": "Where should we deliver?" } } — shows a Send-location button. |
raw | free* | Escape hatch for anything else (Flows, address, catalog, voice-call): a full Cloud API type fragment. |
The 24-hour window
Only template sends can start a conversation. Everything marked free* is a free-form session
message: it only delivers if the customer messaged the business within the last 24 hours. Outside
that window, use a template. All sends return { "data": { "messageId": "wamid…" } }.
A 200 means Meta accepted the message, not that it was delivered. The final status
(sent → delivered → read, or failed) arrives asynchronously on your
webhooks. For to, always include the + and country code (e.g.
+237690000001) — omitting it can misroute the message. Media passed by link is cached by Meta
for ~10 minutes, so reuse the same URL for the same asset (or add a unique query string to bust the
cache).
Template message
The simple case is body variables only. variables is a positional array; you can also use
bodyNamed for named templates, header for a media/text header, and buttons for dynamic button
parameters.
curl -X POST https://wa.genuka.com/api/v1/messages \
-H "Authorization: Bearer pk_live_xxx" -H "Content-Type: application/json" \
-d '{
"connectionId": "con_1",
"to": "+237690000001",
"template": { "name": "order_shipped", "language": "en_US", "variables": ["Alice", "#1024"] }
}'
{ "data": { "messageId": "wamid.HBg…" } }{
"connectionId": "con_1",
"to": "+237690000001",
"template": {
"name": "order_shipped",
"language": "en_US",
"header": { "image": { "link": "https://acme.co/orders/1024.png" } },
"variables": ["Alice", "#1024"],
"buttons": [
{ "type": "url", "text": "1024" }
]
}
}The buttons[].text fills the dynamic part of a URL button (the {{1}} in
https://acme.co/track/{{1}}). For a quick-reply use { "type": "quick_reply", "payload": "…" };
for a coupon code use { "type": "copy_code", "code": "SAVE20" }. Named body params:
"bodyNamed": { "customer_name": "Alice" }.
Authentication template
Pass the code once via the otp shorthand — we fill both the body and the OTP button for you (the
format Meta requires).
{
"connectionId": "con_1",
"to": "+237690000001",
"template": { "name": "verification_code", "language": "en_US", "otp": "472913" }
}Free-form session messages
{ "connectionId": "con_1", "to": "+237690000001", "text": "Thanks, talk soon!" }{ "connectionId": "con_1", "to": "+237690000001",
"image": { "link": "https://acme.co/promo.jpg", "caption": "New arrivals 🎉" } }{ "connectionId": "con_1", "to": "+237690000001",
"document": { "link": "https://acme.co/invoice.pdf", "filename": "invoice-1024.pdf" } }{
"connectionId": "con_1",
"to": "+237690000001",
"buttons": {
"body": "Confirm your order?",
"footer": "Acme Coffee",
"buttons": [
{ "id": "yes", "title": "Confirm" },
{ "id": "no", "title": "Cancel" }
]
}
}{
"connectionId": "con_1",
"to": "+237690000001",
"list": {
"body": "Pick a delivery slot",
"button": "Choose",
"sections": [
{ "title": "Today", "rows": [
{ "id": "t1", "title": "12:00–14:00" },
{ "id": "t2", "title": "14:00–16:00", "description": "Most popular" }
] }
]
}
}{
"connectionId": "con_1",
"to": "+237690000001",
"cta": { "body": "Your receipt is ready.", "displayText": "View receipt", "url": "https://acme.co/r/1024" }
}{ "connectionId": "con_1", "to": "+237690000001", "replyTo": "wamid.HBg…", "text": "On its way!" }
{ "connectionId": "con_1", "to": "+237690000001",
"reaction": { "messageId": "wamid.HBg…", "emoji": "👍" } }Subscription
| Method | Endpoint | Description |
|---|---|---|
GET | /api/v1/subscription | Current plan, limits and usage |
{
"plan": { "code": "growth", "name": "Growth" },
"interval": "monthly",
"currency": "XAF",
"status": "active",
"currentPeriodEnd": "2026-07-18T00:00:00.000Z",
"billedNumbers": 9,
"usage": { "numbers": 9, "messages": 12480, "seats": 3 },
"limits": { "maxNumbers": 9, "monthlyMessages": 45000, "maxSeats": 5 }
}Errors
401 { "error": "missing_bearer_token" } // no Authorization header
401 { "error": "invalid_token" } // unknown / revoked key, or inactive account
403 { "error": "account_deactivated", "message": "Deactivated Account" }
// that business is suspended: its own key stops
// working, and any request naming it is refused
// — including with an account-wide key
400 { "error": "missing_fields", "message": "…" }
400 { "error": "missing_content", "message": "…" } // no content field on a message send
400 { "error": "invalid_media", "message": "…" } // media without an assetId, id or link
402 { "error": "plan_limit_media", "message": "…" } // plan storage cap reached
404 { "error": "media_not_found", "message": "…" } // unknown assetId, or past its 30 days
402 { "error": "plan_limit_numbers" } // every number the plan paid for is in use
402 { "error": "plan_limit_messages" } // the period's message allowance is exhausted
402 { "error": "subscription_past_due" } // trial or paid period lapsed without renewal
404 { "error": "connection_not_found" }
404 { "error": "template_not_found" }
409 { "error": "template_exists" }
413 { "error": "media_too_large", "message": "…" } // template header above 4 MB
422 { "error": "meta_rejected", "message": "…", "meta": { … } }
// Meta read the payload and refused it
502 { "error": "meta_rejected", "message": "…" } // Graph unreachable or failingWhat Meta refused returns 422, not 502
When Graph reads a payload and refuses it — a malformed template, a variable with no example, an
unprovisioned number — the response is a 422, and its body carries a meta object with Meta's
own error code, its traceId and its class:
{
"error": "meta_rejected",
"message": "Invalid parameter",
"meta": {
"errorClass": "template",
"retryable": false,
"code": 100,
"details": "body_text example count does not match the number of variables",
"traceId": "AbC…"
}
}traceId is what Meta support asks for: quote it verbatim.
A 502 is left for what the word describes — Graph unreachable, or Graph itself failing.
Returning a refused payload as a 502 made it undiagnosable: our CDN answers origin 5xx with its
own error page, replacing that JSON body with the single line error code: 502. The reason for
the refusal never reached you. 4xx bodies pass through untouched.
Request logs
Every response carries an x-request-id header. The same id identifies the call in your dashboard
under Logs, where each request is kept with its status, duration, body and — when it failed — the
response we returned. Webhook deliveries are listed next to it, with their exact signed payload and
a resend button.
How far back the log goes depends on your plan: 7 days on Starter, 30 on Growth, 90 on Scale.
Redacted fields
Bodies are stored to make a failure reproducible, minus anything that looks like a credential —
password, token, secret and api_key values are replaced before the row is written.