Genuka WA docs

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:

Header
Authorization: Bearer pk_live_xxxxxxxxxxxxxxxxxxxxxxxx

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

MethodEndpointDescription
GET/api/v1/companiesList connected businesses with their connections
GET/api/v1/companies/{id}A single business + connections + counts
GET /api/v1/companies
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)

MethodEndpointDescription
GET/api/v1/connectionsList 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).

MethodEndpointDescription
GET/api/v1/templatesList templates (optional ?companyId=)
POST/api/v1/templatesCreate & submit a template to Meta
GET/api/v1/templates/{id}A template + its status history
POST/api/v1/templates/syncRe-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:

TypeShapeDescription
HEADERformat: TEXT | IMAGE | VIDEO | DOCUMENT | LOCATIONOptional. One per template. Media headers need an example handle.
BODYtext + exampleRequired (except AUTHENTICATION). Holds the {{1}}… or {{name}} variables.
FOOTERtextOptional short footer. No variables.
BUTTONSQUICK_REPLY | URL | PHONE_NUMBER | COPY_CODE | OTPOptional. 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)

POST /api/v1/templates
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.

POST /api/v1/templates (named)
{
  "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.

TypeShapeDescription
COPY_CODEotp_type: COPY_CODECustomer taps to copy the code. Simplest, works everywhere.
ONE_TAPotp_type: ONE_TAPAndroid auto-fill. Needs package_name + signature_hash.
ZERO_TAPotp_type: ZERO_TAPCode delivered silently to the app. Needs the same app binding.
POST /api/v1/templates (authentication)
{
  "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.

POST /api/v1/templates/sync
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.

200 OK
{
  "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

MethodEndpointDescription
GET/api/v1/campaignsList campaigns (optional ?companyId=)
POST/api/v1/campaignsCreate a campaign with recipients
GET/api/v1/campaigns/{id}A campaign with delivery stats
GET/api/v1/campaigns/{id}/recipientsPer-recipient status (?status=, ?limit=)
POST/api/v1/campaigns/{id}/launchSend 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.

POST /api/v1/campaigns
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

POST /api/v1/campaigns/{id}/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.

POST /api/v1/media (multipart)
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.

RouteDescription
POST /api/v1/mediaUpload. Multipart: file (required), companyId (required with a partner-wide key), filename (optional).
GET /api/v1/mediaList. Filters kind, companyId, cursor pagination, plus the storage figure.
GET /api/v1/media/{id}One file's metadata.
GET /api/v1/media/{id}/contentThe bytes, authenticated by your key.
DELETE /api/v1/media/{id}Delete now, without waiting for expiry.
POST /api/v1/media/header-handleProduce 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.

POST /api/v1/media/header-handle (multipart)
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:

POST /api/v1/templates
{
  "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.

POST /api/v1/messages
{
  "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

MethodEndpointDescription
POST/api/v1/messagesSend 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.

FieldWindowDescription
templatebillablePre-approved template. The only way to start a conversation outside the 24h window.
textfree*{ "text": "Hi" } or { "text": { "body": "…", "previewUrl": true } }
image / video / audio / document / stickerfree*{ "image": { "assetId": "ast_1" } } (recommended), { "link": "…" } or { "id": "<media-id>" }; caption/filename optional
locationfree*{ "location": { "latitude": …, "longitude": …, "name": "…", "address": "…" } }
contactsfree*{ "contacts": [ … Meta contact objects … ] }
reactionfree*{ "reaction": { "messageId": "wamid…", "emoji": "👍" } }
buttons / list / ctafree*Interactive reply buttons, a list menu, or a call-to-action URL button.
locationRequestfree*{ "locationRequest": { "body": "Where should we deliver?" } } — shows a Send-location button.
rawfree*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.

POST /api/v1/messages (template, simple)
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…" } }
POST /api/v1/messages (template, header + body + button)
{
  "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).

POST /api/v1/messages (authentication)
{
  "connectionId": "con_1",
  "to": "+237690000001",
  "template": { "name": "verification_code", "language": "en_US", "otp": "472913" }
}

Free-form session messages

text
{ "connectionId": "con_1", "to": "+237690000001", "text": "Thanks, talk soon!" }
image (with caption)
{ "connectionId": "con_1", "to": "+237690000001",
  "image": { "link": "https://acme.co/promo.jpg", "caption": "New arrivals 🎉" } }
document
{ "connectionId": "con_1", "to": "+237690000001",
  "document": { "link": "https://acme.co/invoice.pdf", "filename": "invoice-1024.pdf" } }
interactive reply buttons
{
  "connectionId": "con_1",
  "to": "+237690000001",
  "buttons": {
    "body": "Confirm your order?",
    "footer": "Acme Coffee",
    "buttons": [
      { "id": "yes", "title": "Confirm" },
      { "id": "no",  "title": "Cancel" }
    ]
  }
}
interactive list
{
  "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" }
      ] }
    ]
  }
}
call-to-action URL button
{
  "connectionId": "con_1",
  "to": "+237690000001",
  "cta": { "body": "Your receipt is ready.", "displayText": "View receipt", "url": "https://acme.co/r/1024" }
}
reply in-thread + reaction
{ "connectionId": "con_1", "to": "+237690000001", "replyTo": "wamid.HBg…", "text": "On its way!" }

{ "connectionId": "con_1", "to": "+237690000001",
  "reaction": { "messageId": "wamid.HBg…", "emoji": "👍" } }

Subscription

MethodEndpointDescription
GET/api/v1/subscriptionCurrent plan, limits and usage
GET /api/v1/subscription
{
  "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

Examples
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 failing

What 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:

422 — refused by Meta
{
  "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.

On this page