WhatsApp

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

GET /api/v1/connect/whatsapp
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.

POST /api/v1/whatsapp/connect
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_window before 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_AGENT tag 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

POST /api/v1/whatsapp/accounts/{id}/messages
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). Only APPROVED templates can be sent.
  • POST /whatsapp/accounts/{id}/templates: submit a template for review; it starts as PENDING and the decision arrives as template.status.
  • GET /whatsapp/accounts/{id}/templates/{templateId} and DELETE (this language; ?allLanguages=true deletes every language of the name).
POST /api/v1/whatsapp/accounts/{id}/templates
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}/media with {"mediaId":"…"} (a Postgale upload from POST /media) or {"url":"https://…"} (fetched by Postgale, at most 100 MB) uploads the file to WhatsApp and returns its id; send it with whatsappMediaId. Meta keeps uploads for 30 days.
  • GET /whatsapp/accounts/{id}/media/{waMediaId} returns {id, mimeType, sha256, size}; add ?download=1 to 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.
  • DELETE on the same path removes the media from WhatsApp.
GET /api/v1/whatsapp/accounts/{id}/media/{waMediaId}?download=1
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.reaction for reactions; message.sent for messages sent through Postgale.
  • message.status: sent, delivered, read or failed (with Meta's errors) for each message the account sent, with pricing.
  • message.failed (a message the account sent failed, with error), message.edited (the person edited a message: the new text and content, with editHistory) and message.deleted (the person deleted a message, or the business deleted its own; direction tells which).
  • template.status (approved, rejected, paused, disabled…), template.quality (quality score or category changes) and account.quality (the number's quality rating or messaging limit changed).
message.received
{
  "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
    }
  }
}
message.status
{
  "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"
  }
}
template.status
{
  "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 /broadcasts with accountId, name and template {name, language, components?, variableMapping?}. variableMapping fills {{1}}, {{2}}… per recipient from a contact field (name, phone, email, company) or a fixed custom value.
  • POST /broadcasts/{id}/recipients with phones, contactIds, recipients ([{phone, components}] for per-recipient components) or useSegment: true (the broadcast's segmentFilters: tags, isSubscribed, customFields). Unsubscribed or blocked contacts and duplicates are skipped.
  • POST /broadcasts/{id}/send sends now: the first batch in the request, the rest by the worker. POST /broadcasts/{id}/schedule with scheduledAt sends it then. POST /broadcasts/{id}/cancel stops a scheduled or sending broadcast.
  • GET /broadcasts, GET /broadcasts/{id} (counts), GET /broadcasts/{id}/recipients (?status, with errorCode, errorExplanation and a summary), PATCH and DELETE for drafts.
POST /api/v1/broadcasts
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 (cloneFlowId copies a flow; with asVersion: true the clone is the next version of that flow's lineage), GET|PATCH|DELETE /whatsapp/flows/{flowId}.
  • PUT /whatsapp/flows/{flowId}/json with flow_json answers Meta's validation_errors; GET returns the asset with a download URL.
  • POST /whatsapp/flows/{flowId}/publish and /deprecate (both irreversible), GET /whatsapp/flows/{flowId}/preview, GET /whatsapp/flows/{flowId}/versions.
  • POST /whatsapp/flows/send with to, flow_id, flow_cta, body (and flow_action, flow_action_payload, draft): a live message, within the 24-hour window. flow_token defaults to <flowId>:<uuid>.
  • GET /whatsapp/flow-responses?flowId: what people submitted (the nfm_reply message), with the fields parsed into data.
  • 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) and POST /whatsapp/business-profile/photo with a public url or a Postgale mediaId (JPEG or PNG, ≤5 MB).
  • Username: GET|POST|DELETE /whatsapp/business-profile/username (transferAction: force_transfer takes it from another number) and GET .../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) and GET /whatsapp/block-users/status?user. Blocking also marks the contact isBlocked.
  • Ice breakers and commands: GET|POST|DELETE /whatsapp/conversational-automation (prompts up to 3, commands up to 30, enable_welcome_message).
  • Number: GET /whatsapp/number-info (live from Meta), POST /accounts/{id}/whatsapp/register, /request-code and /verify-code.
  • Insights: GET /whatsapp/pricing-analytics (start, end, granularity, dimensions) and GET /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) with forwardTo (tel:+E164, sip:user@host or wss://host), recordingEnabled, SIP credentials (stored encrypted) and maxCallDurationSeconds; PATCH updates it, DELETE turns calling off, GET /whatsapp/calling?accountId reads it.
  • GET /whatsapp/call-permissions?to says whether the person allows calls; POST /whatsapp/calls with action: send_call_permission_request asks them.
  • POST /whatsapp/calls with to places the call (queued for a person while approval applies). GET /whatsapp/calls, GET /whatsapp/calls/{id}, GET /whatsapp/calls/{id}/recording and GET /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) and GET|POST|DELETE /whatsapp/catalogs (catalogId; catalogAccountId uses another connected account's token).
  • POST /whatsapp/conversions reports an event (LeadSubmitted, Purchase…) for a conversation that started from a Click-to-WhatsApp ad, so Meta attributes it to the ad. Email and externalId are SHA-256 hashed before they leave Postgale. GET /whatsapp/conversions lists what was sent; GET|POST /whatsapp/dataset reads 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.

GET /api/v1/whatsapp/numbers/available
curl https://staging.postgale.com/api/v1/whatsapp/numbers/available?country=US&areaCode=415&limit=5 \
  -H 'Authorization: Bearer sk_…'
POST /api/v1/whatsapp/numbers
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",…}}
  • status moves through purchasing, purchased, adding_to_waba, code_requested, verifying, registering to active (the new accountId). Poll GET /whatsapp/numbers/{id} or listen to the webhooks.
  • A failed step sets status: failed with error (e.g. otp_timeout, add_failed: …) and emits phone_number.failed; POST /whatsapp/numbers/{id}/retry continues 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_scheduled 3 days before, then phone_number.released, and its account becomes needs_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 lastCode in GET /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.

StatusCodeMeaning
409outside_messaging_windowA 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
409recipient_unavailableThe number is not on WhatsApp, uses an old app version, or is the business number itself (131026, 131021)
409phone_number_not_registeredThe number is not registered for the Cloud API (133010): POST /whatsapp/accounts/{id}/register
409reconnect_requiredThe token is no longer valid (190): reconnect the number
403recipient_not_allowedTest numbers only message the up to 5 recipients in their allowed list (131030; WhatsApp Manager → API Setup)
403account_restrictedThe WhatsApp Business Account is restricted or has a payment issue (131031, 368, 131042)
403missing_permissionThe token lacks whatsapp_business_messaging or whatsapp_business_management; reconnect
400template_errorTemplate name, language, parameters or status rejected (132000–132016)
400unsupported_message_typeWhatsApp does not support this message type (131051)
400invalid_tokenManual connect: Meta refused the access token
400not_supportedNot a WhatsApp account, or a Messenger-only option (tag) on WhatsApp
429rate_limitedToo many messages to this person or for the number's throughput (131056, 130429, 131048, 131057)
429marketing_limitMeta limits how many marketing templates one person receives (131049); retry later
502platform_errorAny 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).

On this page