Connect WhatsApp Business numbers and send and receive WhatsApp messages through the Postgale REST API and MCP server.
Postgale connects WhatsApp Business phone numbers through Meta's Cloud API and lets your backend or AI agent send and receive
WhatsApp messages: text, media, templates, buttons and lists, locations and reactions, with delivery and read statuses as webhooks,
plus broadcasts and contacts, Flows, groups, Business Calling, the business profile, commerce and Click-to-WhatsApp conversions.
Everything here is available over the REST API (paths relative to https://staging.postgale.com/api/v1, Bearer API key) and
the MCP server (tools whatsapp_*). Each connected number is one account with platform
whatsapp; WhatsApp accounts cannot publish posts.
Connect a number
Embedded Signup (recommended): ask for a connect link and send the number's owner to it. The link opens a Postgale
page that runs Meta's Embedded Signup, where they log in with Facebook, pick or create the WhatsApp Business Account (WABA) and
verify the phone number. Postgale then exchanges the code, subscribes the app to the WABA's webhooks, registers the number for
the Cloud API and emits account.connected. With several numbers and no choice made in the signup, the result is a pending
connection (connection.pending, then POST /connect/pending/{id}/select).
curl https://staging.postgale.com/api/v1/connect/whatsapp?externalId=cust-1 \
-H 'Authorization: Bearer sk_…'
# {"authUrl":"https://staging.postgale.com/connect/whatsapp?state=…","expiresInSeconds":600}Manual connect, for your own numbers or testing: pass a token you already have (a system user token from Meta Business
Settings, or the temporary token from WhatsApp Manager → API Setup) with the phone number id and WABA id. Postgale reads both with the
token, subscribes and registers (register: false skips registration; pin sets the 6-digit two-step
verification PIN, otherwise a random one is generated). The token is stored encrypted and never returned.
curl -X POST https://staging.postgale.com/api/v1/whatsapp/connect \
-H 'Authorization: Bearer sk_…' \
-H 'Content-Type: application/json' \
-d '{"accessToken":"EAAG…","phoneNumberId":"106540352242922","wabaId":"102290129340398","externalId":"cust-1"}'The response holds the account and its whatsapp view: display number, verified name, quality rating, messaging limit
tier and the onboarding state. When registration failed (onboarding.registerError), retry with
POST /whatsapp/accounts/{id}/register and an optional {"pin":"123456"}.
GET /whatsapp/accounts/{id}/phone-number?refresh=true reads the current values from Meta.
The 24-hour window
- WhatsApp only allows free-form messages (text, media, interactive, location, reactions) within 24 hours of the person's last message. Every inbound message reopens the window; each conversation reports it as
messagingWindow. - Outside the window, or when the person never wrote, only an approved template can be sent. Postgale answers
409 outside_messaging_windowbefore calling Meta, so nothing is sent or billed. - Templates (business-initiated messages) are charged by Meta per message; replies within the window are free. There is no
HUMAN_AGENTtag on WhatsApp. - While approval applies to the key, organization or account, sends are queued for a person:
202 {id, status: "pending_approval"}, sent once an owner or admin approves them (see Approval).
Send messages
POST /whatsapp/accounts/{id}/messages with the person as to (phone number in international format),
recipient (business-scoped user id) or conversationId, plus type and its fields.
replyTo (a wamid) quotes an earlier message. Inside a conversation, POST /inbox/conversations/{id}/messages
takes the same fields without to (the type is inferred). The answer (201) is the stored message and conversation; the
message is a live WhatsApp message to a real person.
Text
curl -X POST https://staging.postgale.com/api/v1/whatsapp/accounts/acc_whatsapp/messages \
-H 'Authorization: Bearer sk_…' \
-H 'Content-Type: application/json' \
-d '{"to":"+15551234567","type":"text","text":"Yes, the blue one ships tomorrow: https://shop.example/blue","previewUrl":true}'Image, video, audio, document, sticker
One of mediaUrl (public https), whatsappMediaId (uploaded, see Media below) or mediaId (a Postgale
upload with a public URL); caption for image, video and document, filename for documents.
curl -X POST https://staging.postgale.com/api/v1/whatsapp/accounts/acc_whatsapp/messages \
-H 'Authorization: Bearer sk_…' \
-H 'Content-Type: application/json' \
-d '{"to":"+15551234567","type":"document","mediaUrl":"https://shop.example/invoices/1234.pdf","filename":"invoice-1234.pdf","caption":"Your invoice"}'Template
curl -X POST https://staging.postgale.com/api/v1/whatsapp/accounts/acc_whatsapp/messages \
-H 'Authorization: Bearer sk_…' \
-H 'Content-Type: application/json' \
-d '{"to":"+15551234567","type":"template","template":{"name":"order_shipped","language":"en_US","components":[{"type":"body","parameters":[{"type":"text","text":"Sheena"},{"type":"text","text":"#1234"}]}]}}'Interactive: reply buttons, list, call-to-action URL
curl -X POST https://staging.postgale.com/api/v1/whatsapp/accounts/acc_whatsapp/messages \
-H 'Authorization: Bearer sk_…' \
-H 'Content-Type: application/json' \
-d '{"to":"+15551234567","type":"interactive","interactive":{"type":"button","body":"Is this delivery time OK?","buttons":[{"id":"yes","title":"Yes"},{"id":"later","title":"Later please"}]}}'curl -X POST https://staging.postgale.com/api/v1/whatsapp/accounts/acc_whatsapp/messages \
-H 'Authorization: Bearer sk_…' \
-H 'Content-Type: application/json' \
-d '{"to":"+15551234567","type":"interactive","interactive":{"type":"list","body":"Pick a size","buttonText":"Sizes","sections":[{"title":"In stock","rows":[{"id":"s","title":"Small"},{"id":"m","title":"Medium","description":"2 left"}]}]}}'curl -X POST https://staging.postgale.com/api/v1/whatsapp/accounts/acc_whatsapp/messages \
-H 'Authorization: Bearer sk_…' \
-H 'Content-Type: application/json' \
-d '{"to":"+15551234567","type":"interactive","interactive":{"type":"cta_url","body":"Track your parcel","displayText":"Track","url":"https://shop.example/track/1234"}}'Limits: up to 3 buttons (titles ≤20 characters), up to 10 list rows, body ≤1024 characters. Button and list replies arrive as message.received with type interactive and the id you set.
Location and reaction
curl -X POST https://staging.postgale.com/api/v1/whatsapp/accounts/acc_whatsapp/messages \
-H 'Authorization: Bearer sk_…' \
-H 'Content-Type: application/json' \
-d '{"to":"+15551234567","type":"location","location":{"latitude":52.3676,"longitude":4.9041,"name":"Postgale Store","address":"Damrak 1, Amsterdam"}}'curl -X POST https://staging.postgale.com/api/v1/whatsapp/accounts/acc_whatsapp/messages \
-H 'Authorization: Bearer sk_…' \
-H 'Content-Type: application/json' \
-d '{"to":"+15551234567","type":"reaction","reaction":{"messageId":"wamid.HBgLMTU1NTEyMzQ1NjcVAgASGBQzQTdCM0Y","emoji":"👍"}}'Mark an inbound message read (blue ticks), optionally with a typing indicator while you prepare the answer:
POST /whatsapp/accounts/{id}/messages/{wamid}/read with {"typing":true}.
POST /inbox/conversations/{id}/read marks the latest inbound message read.
Templates
GET /whatsapp/accounts/{id}/templates: the WABA's templates (?status=APPROVED&name&category&language&limit&after). OnlyAPPROVEDtemplates can be sent.POST /whatsapp/accounts/{id}/templates: submit a template for review; it starts asPENDINGand the decision arrives astemplate.status.GET /whatsapp/accounts/{id}/templates/{templateId}andDELETE(this language;?allLanguages=truedeletes every language of the name).
curl -X POST https://staging.postgale.com/api/v1/whatsapp/accounts/acc_whatsapp/templates \
-H 'Authorization: Bearer sk_…' \
-H 'Content-Type: application/json' \
-d '{"name":"order_shipped","language":"en_US","category":"UTILITY","components":[{"type":"BODY","text":"Hi {{1}}, your order {{2}} has shipped.","example":{"body_text":[["Sheena","#1234"]]}}]}'Media
POST /whatsapp/accounts/{id}/mediawith{"mediaId":"…"}(a Postgale upload fromPOST /media) or{"url":"https://…"}(fetched by Postgale, at most 100 MB) uploads the file to WhatsApp and returns its id; send it withwhatsappMediaId. Meta keeps uploads for 30 days.GET /whatsapp/accounts/{id}/media/{waMediaId}returns{id, mimeType, sha256, size}; add?download=1to stream the bytes (inbound images, voice notes and documents carry such ids in their attachments). Meta's own media URLs need the business token and expire after 5 minutes, so always download through Postgale.DELETEon the same path removes the media from WhatsApp.
curl -o photo.jpg 'https://staging.postgale.com/api/v1/whatsapp/accounts/acc_whatsapp/media/1037543291543636?download=1' \
-H 'Authorization: Bearer sk_…'Webhooks
Subscribe an endpoint (POST /webhooks) to the events you need; deliveries are signed and retried like every Postgale
webhook (see Webhooks in the REST reference).
message.received: a person wrote (text, media, interactive and button replies, location, contacts, orders);message.reactionfor reactions;message.sentfor messages sent through Postgale.message.status:sent,delivered,readorfailed(with Meta'serrors) for each message the account sent, withpricing.message.failed(a message the account sent failed, witherror),message.edited(the person edited a message: the newtextandcontent, witheditHistory) andmessage.deleted(the person deleted a message, or the business deleted its own;directiontells which).template.status(approved, rejected, paused, disabled…),template.quality(quality score or category changes) andaccount.quality(the number's quality rating or messaging limit changed).
{
"id": "cmdlw1a2b0001wa0000001",
"event": "message.received",
"createdAt": "2026-10-18T09:15:02.481Z",
"data": {
"eventId": "cmdlw19zz0000wa0000001",
"platform": "whatsapp",
"accountId": "acc_whatsapp",
"externalId": "cust-1",
"conversationId": "cmdlw0k700003wa0000001",
"messageId": "cmdlw1a0q0002wa0000001",
"platformMessageId": "wamid.HBgLMTU1NTEyMzQ1NjcVAgASGBQzQTdCM0Y",
"platformMessageIds": [
"wamid.HBgLMTU1NTEyMzQ1NjcVAgASGBQzQTdCM0Y"
],
"direction": "inbound",
"participant": {
"id": "15551234567",
"username": null,
"name": "Sheena"
},
"text": "Do you have the blue one in stock?",
"attachments": [],
"postback": null,
"reaction": null,
"watermark": null,
"sentAt": "2026-10-18T09:15:00.000Z",
"receivedAt": "2026-10-18T09:15:02.470Z",
"type": "text",
"content": {
"body": "Do you have the blue one in stock?"
},
"contact": {
"phone": "15551234567",
"userId": "US.13491208655302741918",
"parentUserId": null,
"name": "Sheena",
"username": null
}
}
}{
"id": "cmdlw2c3d0001wa0000002",
"event": "message.status",
"createdAt": "2026-10-18T09:16:04.112Z",
"data": {
"eventId": "cmdlw2c2z0000wa0000002",
"platform": "whatsapp",
"accountId": "acc_whatsapp",
"externalId": "cust-1",
"conversationId": "cmdlw0k700003wa0000001",
"messageId": "cmdlw1x9q0004wa0000001",
"platformMessageId": "wamid.HBgLMTU1NTEyMzQ1NjcVAgARGBI5QTNDQTVCM0Q",
"status": "read",
"errors": [],
"pricing": {
"billable": false,
"pricing_model": "PMP",
"category": "service",
"type": "free_customer_service"
},
"conversation": null,
"recipient": {
"phone": "15551234567",
"userId": null
},
"timestamp": "2026-10-18T09:16:03.000Z",
"receivedAt": "2026-10-18T09:16:04.100Z"
}
}{
"id": "cmdlw3e4f0001wa0000003",
"event": "template.status",
"createdAt": "2026-10-18T10:02:11.020Z",
"data": {
"eventId": "cmdlw3e3z0000wa0000003",
"platform": "whatsapp",
"accountId": "acc_whatsapp",
"externalId": "cust-1",
"wabaId": "102290129340398",
"kind": "status",
"templateId": "1689556908129832",
"name": "order_shipped",
"language": "en_US",
"event": "APPROVED",
"reason": "NONE",
"otherInfo": null,
"receivedAt": "2026-10-18T10:02:11.000Z"
}
}Contacts and broadcasts
A broadcast sends one approved template to many people, now or at a scheduled time, and tracks delivery per recipient. The flow is: create a draft, add recipients, then send or schedule. Recipients are contacts with a channel on the account (the phone); phones you add are turned into contacts automatically.
POST /contacts,POST /contacts/bulk(≤1000, duplicates skipped with their tags merged),GET /contacts(?search&tag&tags&accountId&isSubscribed&limit&skip),GET|PATCH|DELETE /contacts/{contactId},GET /contacts/{contactId}/channels.POST /broadcastswithaccountId,nameandtemplate {name, language, components?, variableMapping?}.variableMappingfills{{1}},{{2}}… per recipient from a contact field (name,phone,email,company) or a fixedcustomvalue.POST /broadcasts/{id}/recipientswithphones,contactIds,recipients([{phone, components}]for per-recipient components) oruseSegment: true(the broadcast'ssegmentFilters:tags,isSubscribed,customFields). Unsubscribed or blocked contacts and duplicates are skipped.POST /broadcasts/{id}/sendsends now: the first batch in the request, the rest by the worker.POST /broadcasts/{id}/schedulewithscheduledAtsends it then.POST /broadcasts/{id}/cancelstops a scheduled or sending broadcast.GET /broadcasts,GET /broadcasts/{id}(counts),GET /broadcasts/{id}/recipients(?status, witherrorCode,errorExplanationand asummary),PATCHandDELETEfor drafts.
curl -X POST https://staging.postgale.com/api/v1/broadcasts \
-H 'Authorization: Bearer sk_…' \
-H 'Content-Type: application/json' \
-d '{"accountId":"acc_whatsapp","platform":"whatsapp","name":"Order confirmations","template":{"name":"order_confirmation","language":"en","components":[{"type":"body","parameters":[{"type":"text","text":"{{1}}"},{"type":"text","text":"{{2}}"}]}],"variableMapping":{"1":{"field":"name"},"2":{"field":"custom","customValue":"VIP-2027"}}}}'Meta's messaging-limit tier caps how many different people a number may start conversations with in 24 hours
(TIER_250 for a new number). Postgale counts the people the number already reached with templates in the last 24 hours
and stops there: the remaining recipients stay pending, lastError says why, and the worker sends them as capacity frees
up. Sending or scheduling a broadcast reaches real people, so it is queued for a person while approval applies.
Flows
WhatsApp Flows are forms inside WhatsApp. Create a DRAFT flow, upload its Flow JSON, publish it, then send it.
GET|POST /whatsapp/flows(cloneFlowIdcopies a flow; withasVersion: truethe clone is the next version of that flow's lineage),GET|PATCH|DELETE /whatsapp/flows/{flowId}.PUT /whatsapp/flows/{flowId}/jsonwithflow_jsonanswers Meta'svalidation_errors;GETreturns the asset with a download URL.POST /whatsapp/flows/{flowId}/publishand/deprecate(both irreversible),GET /whatsapp/flows/{flowId}/preview,GET /whatsapp/flows/{flowId}/versions.POST /whatsapp/flows/sendwithto,flow_id,flow_cta,body(andflow_action,flow_action_payload,draft): a live message, within the 24-hour window.flow_tokendefaults to<flowId>:<uuid>.GET /whatsapp/flow-responses?flowId: what people submitted (thenfm_replymessage), with the fields parsed intodata.GET|POST /whatsapp/flows/encryption-key: the RSA public key for flows with a data exchange endpoint.
Groups
GET|POST /whatsapp/wa-groups (create returns groupId and inviteLink), GET|POST|DELETE /whatsapp/wa-groups/{groupId},
POST .../invite-link (a new link revokes the old one), POST|DELETE .../participants (phoneNumbers; a group holds at
most 8 participants) and GET|POST|DELETE .../join-requests for groups with joinApprovalMode: approval_required.
Messages to and from a group arrive in the inbox like any conversation.
Business account
- Profile:
GET|POST /whatsapp/business-profile(about ≤139, address, description ≤512, email, websites ≤2, vertical) andPOST /whatsapp/business-profile/photowith a publicurlor a PostgalemediaId(JPEG or PNG, ≤5 MB). - Username:
GET|POST|DELETE /whatsapp/business-profile/username(transferAction: force_transfertakes it from another number) andGET .../username/suggestions. - Display name:
GET|POST /whatsapp/business-profile/display-name; Meta reviews the change and the current name stays visible until then. - Blocked users:
GET|POST|DELETE /whatsapp/block-users(users: phones or wa_ids; per-user results) andGET /whatsapp/block-users/status?user. Blocking also marks the contactisBlocked. - Ice breakers and commands:
GET|POST|DELETE /whatsapp/conversational-automation(promptsup to 3,commandsup to 30,enable_welcome_message). - Number:
GET /whatsapp/number-info(live from Meta),POST /accounts/{id}/whatsapp/register,/request-codeand/verify-code. - Insights:
GET /whatsapp/pricing-analytics(start,end,granularity,dimensions) andGET /whatsapp/account-events(template reviews, quality and limit changes, account alerts).
These writes go to Meta as the account and are refused (approval_required) for API keys while approval applies.
Templates by name and the template library
Besides the per-account routes above, templates can be handled the way Zernio does: GET|POST /whatsapp/templates?accountId
(exact name and language filters), GET|PATCH|DELETE /whatsapp/templates/{templateName} (language picks one variant;
DELETE without it removes every language), GET|PATCH|DELETE /whatsapp/templates/id/{templateId}. PATCH changes
components and/or message_send_ttl_seconds and sends the template back to review. GET /whatsapp/template-library?name
reads a pre-approved Meta library template and POST /whatsapp/templates with library_template_name creates it, approved at once.
Calling
WhatsApp Business Calling lets people call the number and lets the business call them back with their permission.
POST /phone-numbers/{id}/whatsapp/calling(id: a bought number or the WhatsApp account id) withforwardTo(tel:+E164,sip:user@hostorwss://host),recordingEnabled, SIP credentials (stored encrypted) andmaxCallDurationSeconds;PATCHupdates it,DELETEturns calling off,GET /whatsapp/calling?accountIdreads it.GET /whatsapp/call-permissions?tosays whether the person allows calls;POST /whatsapp/callswithaction: send_call_permission_requestasks them.POST /whatsapp/callswithtoplaces the call (queued for a person while approval applies).GET /whatsapp/calls,GET /whatsapp/calls/{id},GET /whatsapp/calls/{id}/recordingandGET /whatsapp/calls/estimate?to&minutes(zero markup; the Meta part is billed to your WABA).
Commerce and Click-to-WhatsApp conversions
GET|PUT /whatsapp/commerce-settings(isCatalogVisible,isCartEnabled) andGET|POST|DELETE /whatsapp/catalogs(catalogId;catalogAccountIduses another connected account's token).POST /whatsapp/conversionsreports an event (LeadSubmitted,Purchase…) for a conversation that started from a Click-to-WhatsApp ad, so Meta attributes it to the ad. Email andexternalIdare SHA-256 hashed before they leave Postgale.GET /whatsapp/conversionslists what was sent;GET|POST /whatsapp/datasetreads or creates the dataset (created automatically on the first event).
Sandbox
POST /whatsapp/sandbox/sessions with a phone sends an activation template from Postgale's shared sandbox number; the
session turns active once the phone replies. GET lists the sessions and the sandbox number, DELETE .../{sessionId} revokes one.
Buy a WhatsApp number
When the Postgale instance sells numbers, an organization that has already connected a WhatsApp Business Account can get a new
number without owning a SIM: Postgale buys a US number from its carrier, adds it to that WABA with your display name, asks Meta for
the verification code by voice call (SMS after 10 minutes), answers the call, transcribes the code, verifies and
registers the number and connects it as a new WhatsApp account (account.connected and
phone_number.provisioned). Otherwise these endpoints answer 403 numbers_disabled.
curl https://staging.postgale.com/api/v1/whatsapp/numbers/available?country=US&areaCode=415&limit=5 \
-H 'Authorization: Bearer sk_…'curl -X POST https://staging.postgale.com/api/v1/whatsapp/numbers \
-H 'Authorization: Bearer sk_…' \
-H 'Content-Type: application/json' \
-d '{"phoneNumber":"+14155550123","accountId":"acc_whatsapp","verifiedName":"Lucky Shrub"}'
# {"number":{"id":"…","status":"code_requested","phoneNumber":"+14155550123","codeMethod":"VOICE",…}}statusmoves throughpurchasing,purchased,adding_to_waba,code_requested,verifying,registeringtoactive(the newaccountId). PollGET /whatsapp/numbers/{id}or listen to the webhooks.- A failed step sets
status: failedwitherror(e.g.otp_timeout,add_failed: …) and emitsphone_number.failed;POST /whatsapp/numbers/{id}/retrycontinues from that step. - Each organization may hold a limited number of numbers (
403 number_limit_reached). The monthly price is shown by the search. - A number with no production message (sent or received) for 30 days is released:
phone_number.release_scheduled3 days before, thenphone_number.released, and its account becomesneeds_reconnect.DELETE /whatsapp/numbers/{id}releases it at once. - If Meta does not let Postgale add the number to your WABA, connect it yourself with Embedded Signup: type the number, choose voice call, and read the code from
lastCodeinGET /whatsapp/numbers/{id}(kept for 10 minutes).
MCP: whatsapp_search_numbers, whatsapp_buy_number, whatsapp_list_numbers, whatsapp_get_number, whatsapp_release_number.
Errors
Errors use the usual {"error":{"message":"…","code":"…"}} shape; Meta's Cloud API codes are mapped as follows.
| Status | Code | Meaning |
|---|---|---|
| 409 | outside_messaging_window | A free-form message more than 24 hours after the person's last message (or before they ever wrote). Send a template. Checked before calling Meta, and Meta's 131047 maps to it too |
| 409 | recipient_unavailable | The number is not on WhatsApp, uses an old app version, or is the business number itself (131026, 131021) |
| 409 | phone_number_not_registered | The number is not registered for the Cloud API (133010): POST /whatsapp/accounts/{id}/register |
| 409 | reconnect_required | The token is no longer valid (190): reconnect the number |
| 403 | recipient_not_allowed | Test numbers only message the up to 5 recipients in their allowed list (131030; WhatsApp Manager → API Setup) |
| 403 | account_restricted | The WhatsApp Business Account is restricted or has a payment issue (131031, 368, 131042) |
| 403 | missing_permission | The token lacks whatsapp_business_messaging or whatsapp_business_management; reconnect |
| 400 | template_error | Template name, language, parameters or status rejected (132000–132016) |
| 400 | unsupported_message_type | WhatsApp does not support this message type (131051) |
| 400 | invalid_token | Manual connect: Meta refused the access token |
| 400 | not_supported | Not a WhatsApp account, or a Messenger-only option (tag) on WhatsApp |
| 429 | rate_limited | Too many messages to this person or for the number's throughput (131056, 130429, 131048, 131057) |
| 429 | marketing_limit | Meta limits how many marketing templates one person receives (131049); retry later |
| 502 | platform_error | Any other Meta failure |
Mock mode
Without PROVIDER_LIVE_WHATSAPP=1 (and the Meta app credentials) WhatsApp runs in mock mode: the connect link comes back
immediately with a mock number (+1 555-010-0000, quality GREEN), sends return wamid.mock… ids, templates
live in memory (a hello_world template is approved) and media downloads return a small PNG. The 24-hour window,
approval gate, storage and webhooks behave exactly as in live mode, so integrations can be built and tested without Meta.
Profiles, usernames, blocked users, groups, Flows, calling settings, catalogs and datasets also live in memory, and calls placed
in mock mode end at once (42 seconds, with a recording when it is on). Live outbound calls need a SIP media bridge that this
instance does not run yet (not_supported).
Instagram, Facebook and Threads
DM settings (ice breakers, persistent menu, greeting, Get Started), interactive DMs and private replies, reactions, typing and handover, Facebook reviews, Page info and analytics, and Instagram stories, tags, mentions and follow status through the Postgale API and MCP server.
TikTok
Publish TikTok videos and photo carousels (direct post or inbox draft) and read creator info, profile and video stats through the Postgale API and MCP server.