Skip to content

WhatsApp API

WhatsApp Business runs on Meta’s Cloud API. A WhatsApp profile is a messaging profile: it sends and receives direct messages, manages the message templates that open conversations, and administers the phone numbers, business profile, blocked users, groups and Click-to-WhatsApp conversions of a WhatsApp Business Account (WABA).

It does not publish posts. There are no comments and no post insights — POST /api/posts with a WhatsApp profile returns 422, and WhatsApp profiles don’t appear in the app’s composer.

Conversations themselves — chats, sending, receiving, reactions, read receipts, typing — live in the Direct Messages API and behave like the other DM networks. This page covers the WhatsApp-specific parameters and everything around the conversation.

Every request below uses the base URL https://api.postproxy.dev and an Authorization: Bearer YOUR_API_KEY header. Replace YOUR_API_KEY and the example IDs with your own.

A connected WABA is one profile, and each phone number on it is a placement of that profile. Reference the profile in a request by its id (the prof_abc123 in the examples below) or by the platform name "whatsapp", which selects the group’s WhatsApp profile (a group holds at most one profile per platform). List what’s connected with GET /api/profiles.

Profiles live in profile groups — containers that organize the accounts for one brand, client, or project. List groups with GET /api/profile_groups, and connect a WABA with the Initialize Connection endpoint — see Connecting a WhatsApp Business Account below.

Example: get profiles and profile groups

List the profiles you can message from — GET /api/profiles:

Terminal window
curl -X GET "https://api.postproxy.dev/api/profiles" \
-H "Authorization: Bearer YOUR_API_KEY"
{
"data": [
{
"id": "prof_abc123",
"name": "Acme Coffee",
"platform": "whatsapp",
"status": "active",
"profile_group_id": "grp_xyz789",
"expires_at": null,
"post_count": 0,
"avatar_url": null
}
]
}

List your profile groups — GET /api/profile_groups:

Terminal window
curl -X GET "https://api.postproxy.dev/api/profile_groups" \
-H "Authorization: Bearer YOUR_API_KEY"
{
"data": [
{ "id": "grp_xyz789", "name": "Main Brand", "profiles_count": 5 },
{ "id": "grp_def456", "name": "Client Project", "profiles_count": 3 }
]
}
Platform IDwhatsapp
PublishingNo — messaging only. POST /api/posts returns 422
Formats—
MediaImages 5 MB, video and audio 16 MB, documents 100 MB
PlacementsPhone numbers — the placement id is Meta’s phone_number_id
CommentsNo
Direct messagesYes — text, media, templates, interactive messages, location, contacts, reactions, read receipts
Messaging window24 hours from the customer’s last message. Outside it: a template, or a utility Direct Send
Group chatsYes — inbound and outbound
Post chainsNo

WhatsApp connects through Meta’s Embedded Signup: a Facebook login that grants Postproxy access to a WABA and its phone numbers. Start it like any OAuth network with Initialize Connection:

Terminal window
curl -X POST "https://api.postproxy.dev/api/profile_groups/grp_xyz789/initialize_connection" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"platform": "whatsapp",
"redirect_url": "https://myapp.com/oauth/callback",
"onboarding": "api"
}'

onboarding says where the number lives today. It is WhatsApp-only.

onboardingUse whenWhat happens
business_appThe number is in the WhatsApp Business app on a phone and the team answers customers from itCoexistence. The user types the number, Meta shows a QR code, they scan it from WhatsApp Business (Settings → Linked devices). The phone keeps working, Postproxy is added next to it and sees the same chats, and up to 6 months of history is imported
apiThe number is already on the Cloud API (another provider, your own Meta setup), or is a fresh number that will only be used from PostproxyStandard Cloud API onboarding. The number is claimed for the API and stops working in the WhatsApp app. A new number is verified by SMS or voice call during signup. No old chats are imported
(omitted)You don’t knowThe connect page opens with a “Where is your number now?” chooser that explains both options and lets the user pick before the Facebook login

Meta asks the user to connect their account to 64Bit Labs — the company behind Postproxy.

A coexistence number (one still living in the WhatsApp Business app) carries Meta-imposed limits worth telling your users about up front:

  • Lower sending throughput than an API-only number.
  • No Groups API access.
  • Meta disconnects the number if the phone stays offline for about 14 days.
  • Numbers in the personal WhatsApp app aren’t eligible — they have to move to the free WhatsApp Business app first.
  • Very new numbers with no chat history can be refused by Meta.

A coexistence number reports a platform_type other than CLOUD_API in its placement metadata.

One connected WABA becomes one profile (platform: "whatsapp", platform_id = the WABA id, name = the WABA name), and each of its phone numbers becomes a placement. If the Facebook login grants several WABAs at once, one profile is created per WABA.

After profile.connected, Postproxy:

  1. Pulls the phone numbers into placements — profile.placements_synced fires when they’re ready.
  2. Subscribes the WABA to webhooks, so messages, template reviews and number updates arrive.
  3. Registers every number for Cloud API messaging. A number that already has its own two-step verification PIN keeps working for everything except sending until it is re-registered with that PIN; Meta’s error is recorded on the placement as metadata.registration_warning.
  4. Mirrors the WABA’s message templates.

The profile’s platform_url is https://wa.me/<first number> and username repeats the WABA name.

A WhatsApp profile is disconnected (profile.disconnected with origin: "automated") when Meta reports that Postproxy was removed from the WABA, the WABA was disabled or deleted, or the token stopped working. Reconnect with refresh_connection: true.

Every phone number on the WABA is a placement of the profile. Its id is Meta’s phone number id, and that same value is:

  • the phone_number_id every number-level endpoint on this page takes,
  • the placement_id you pass when creating a chat,
  • the external_placement_id on every WhatsApp chat.
Terminal window
curl -X GET "https://api.postproxy.dev/api/profiles/prof_abc123/placements" \
-H "Authorization: Bearer YOUR_API_KEY"
{
"data": [
{
"id": "106540352242922",
"name": "Acme Coffee",
"avatar_url": null,
"metadata": {
"display_phone_number": "+1 555-010-0001",
"quality_rating": "GREEN",
"messaging_limit_tier": "TIER_1K",
"name_status": "APPROVED",
"status": "CONNECTED",
"platform_type": "CLOUD_API",
"is_official_business_account": false,
"throughput": { "level": "STANDARD" }
}
}
]
}
metadata keyDescription
display_phone_numberThe number in international format
quality_ratingMeta’s quality rating: GREEN, YELLOW, RED, or UNKNOWN
messaging_limit_tierBusiness-initiated conversations per 24 h: TIER_50, TIER_250, TIER_1K, TIER_10K, TIER_100K, TIER_UNLIMITED
name_statusReview state of the display name: APPROVED, PENDING_REVIEW, DECLINED, EXPIRED, AVAILABLE_WITHOUT_REVIEW, NONE
statusNumber status on the Cloud API — e.g. CONNECTED, PENDING, OFFLINE, FLAGGED, RESTRICTED
platform_typeCLOUD_API for an API-only number. Anything else (SMB_APP, NOT_APPLICABLE) marks a coexistence number still living in the WhatsApp Business app
is_official_business_accountWhether the number carries the green Official Business Account badge
throughputMeta’s throughput object — { "level": "STANDARD" | "HIGH" }
registration_warningPresent only when the registration after connect failed; carries Meta’s error message. Clears once Register Phone Number succeeds

Metadata is refreshed on every Number Info call and whenever Meta pushes a quality, name or account update — see Webhooks.

Numbers can be assigned to other profile groups like any placement. A group-scoped key only sees the numbers in its group, and every endpoint on this page resolves phone_number_id through the placements that key can reach — a number outside the scope returns 404.

WhatsApp conversations use the Direct Messages API — the same chats and messages endpoints as Messenger, Instagram, Telegram and Bluesky.

CapabilitySupported
Send / receive textYes
AttachmentsYes — image, video, audio, document, sticker
Message templatesYes — the way to message outside the 24-hour window
Interactive messagesYes — reply buttons, lists, CTA URLs, catalog products, Flows
Location and contactsYes, both directions
Reply threadingYes — reply_to_external_id quotes a message
ReactionsYes, both directions — any emoji
Read receiptsYes — Mark read sends the blue ticks
Typing indicatorYes
Edit outbound messageNo — PATCH /api/messages/:id returns 422
Group chatsYes
Meta quick replies / buttonsNo — use interactive or template buttons
HUMAN_AGENT tagNo — Facebook and Instagram only
Private reply to commentNo
Backfill chatsNo — history only arrives via coexistence onboarding
Inbound deliveryWebhook

POST /api/profiles/:id/chats requires placement_id on WhatsApp — the phone number the conversation runs on. A customer who writes to two of your numbers has two chats.

participant_external_id is the customer’s phone number. Pass it in any format; it’s reduced to digits, Meta’s wa_id form (13105550007).

Terminal window
curl -X POST "https://api.postproxy.dev/api/profiles/prof_abc123/chats" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"placement_id": "106540352242922",
"participant_external_id": "+1 310 555 0007"
}'

WhatsApp chats carry external_placement_id (the number), group, and within_messaging_window.

Meta only permits free-form messages within 24 hours of the customer’s last inbound message. A Click-to-WhatsApp referral opens the window too.

within_messaging_window on the chat tells you which case you’re in. Outside the window there are exactly two ways through:

  1. Send an approved message template — also the only way to start a conversation.
  2. Send a plain text with category: "utility", where the account is eligible for Meta’s Direct Send.

WhatsApp has no HUMAN_AGENT tag — that’s a Facebook and Instagram mechanism.

Exactly one of body, media, template, interactive, location or contacts per call to POST /api/chats/:id/messages. Outbound media carries no caption.

ParameterTypeDescription
bodystringMessage text. Free-form sends need an open window
mediaarrayOne attachment URL
templateobjectAn approved message template. Works inside or outside the window
interactiveobjectReply buttons, lists, CTA URLs, products, Flows. Session message — needs an open window
locationobject{ "latitude", "longitude", "name", "address" } — latitude and longitude required
contactsarrayMeta contact objects
categorystring"utility" for Direct Send. Only valid with body
reply_to_external_idstringThe wamid of a message in the chat — renders the send as a quoted reply
link_previewbooleanRender a preview for a link in body
voice_notebooleanSend an audio attachment as a voice note
filenamestringFilename shown for a document attachment

quick_replies, buttons and card are Facebook and Instagram parameters and return 422 on a WhatsApp chat. reply_markup stays Telegram-only.

Terminal window
# Free-form reply inside the 24-hour window
curl -X POST "https://api.postproxy.dev/api/chats/chat_wa789/messages" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "body": "Your order is roasting now — it ships tomorrow." }'

Enforced at send time.

MediaMax size
Image5 MB
Video16 MB
Audio16 MB
Document100 MB

Inbound attachments are mirrored to Postproxy storage and served from attachments[].url — Meta’s own media URLs expire and require the WABA token.

interactive takes Meta’s interactive object unchanged. Reply buttons:

Terminal window
curl -X POST "https://api.postproxy.dev/api/chats/chat_wa789/messages" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"interactive": {
"type": "button",
"body": { "text": "Your order is ready. Pick up or deliver?" },
"action": {
"buttons": [
{ "type": "reply", "reply": { "id": "PICKUP", "title": "Pick up" } },
{ "type": "reply", "reply": { "id": "DELIVER", "title": "Deliver" } }
]
}
}
}'

A list:

{
"interactive": {
"type": "list",
"header": { "type": "text", "text": "Roasts" },
"body": { "text": "Which one would you like?" },
"action": {
"button": "Choose",
"sections": [
{ "title": "Espresso", "rows": [ { "id": "ESP-1", "title": "House blend", "description": "Chocolate, nutty" } ] },
{ "title": "Filter", "rows": [ { "id": "FLT-1", "title": "Ethiopia Guji", "description": "Floral, citrus" } ] }
]
}
}
}

The customer’s choice arrives as an inbound message. There is no tapped_action on WhatsApp — read the id you assigned from interactive:

{
"direction": "inbound",
"body": "Deliver",
"interactive": { "type": "button_reply", "button_reply": { "id": "DELIVER", "title": "Deliver" } }
}

cta_url, product, product_list, flow and Meta’s other types use the same envelope. Interactive messages are session messages: they need an open window and can’t go to a group.

A tap on a template quick-reply button arrives as a plain text message whose body is the button label.

Meta lets an eligible business send a plain utility text outside the 24-hour window without a template — order updates, appointment reminders, account notices. Pass category: "utility" with body and nothing else:

Terminal window
curl -X POST "https://api.postproxy.dev/api/chats/chat_wa789/messages" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"body": "Your order ORD-12345 was delivered. Thanks for shopping with Acme.",
"category": "utility"
}'

The window check is skipped and Meta decides. An account that isn’t eligible gets Meta’s rejection back as message.failed. category alongside media, template or any other payload returns 422.

POST /api/chats/:id/mark_read

Sends the blue-tick read receipt on WhatsApp, and stamps metadata.read_at on every network.

Terminal window
curl -X POST "https://api.postproxy.dev/api/chats/chat_wa789/mark_read" \
-H "Authorization: Bearer YOUR_API_KEY"

start_typing works on WhatsApp too, and marks the newest inbound message read as Meta requires.

A message in a WhatsApp group your number belongs to arrives as a chat whose participant_external_id is the group id and whose group flag is true. The individual sender is in platform_data.from.

Text, media, template, location and contacts sends go to a group. interactive does not. Administering groups — creating them, participants, invite links — is under Groups.

On a coexistence number, messages the team sends from the WhatsApp Business app on the phone show up as outbound messages with source: "synced" and platform_data.source: "whatsapp_business_app", and fire message.sent. The history imported at connect does not fire webhooks.

Inbound text, images, video, audio, documents and stickers (attachments[].type), location, contact cards, interactive replies, template button taps, catalog orders (platform_data.order), reactions, read receipts, deletions and ad referrals all arrive through the standard DM webhooks. Types Meta can’t deliver arrive with is_unsupported: true.

A message the customer deletes for everyone stamps external_deleted_at and fires message.deleted.

A message Meta accepts and later fails to deliver — blocked number, invalid recipient, paused template, closed window — moves to status: "failed" with error_details and fires message.failed, possibly some minutes after message.sent.

Once Meta reports a send as delivered, the conversation category and pricing Meta attached are stored under platform_data.conversation and platform_data.pricing.

WhatsApp only lets a business start a conversation, or continue one more than 24 hours after the customer’s last message, with a pre-approved message template. Templates belong to the WABA — the profile — not to a number, and every number on the account can send them. Meta reviews each one and can pause or disable it later based on customer feedback.

Postproxy keeps a mirror of the WABA’s templates, so sends are validated without a round trip to Meta and review outcomes arrive as webhooks instead of needing a poll.

MethodEndpointDescription
GET/api/profiles/:profile_id/templatesList templates
GET/api/profiles/:profile_id/templates/:idGet a template
POST/api/profiles/:profile_id/templatesCreate a template
PATCH/api/profiles/:profile_id/templates/:idUpdate a template
DELETE/api/profiles/:profile_id/templates/:idDelete a template
GET/api/profiles/:profile_id/templates/libraryLook up Meta’s template library

A template :id accepts the Postproxy hashid, Meta’s template id, or the template name. A name is shared by every language variant, so pass ?language= alongside it. A name that exists in several languages without a language returns 409:

{
"error": "Template order_update exists in several languages; pass language",
"code": "ambiguous_template",
"languages": ["en_US", "pt_BR"]
}

Non-WhatsApp profiles return 422 on every template endpoint.

{
"id": "tpl_8f2k1a",
"external_id": "1234567890123456",
"profile_id": "prof_abc123",
"name": "order_update",
"language": "en_US",
"category": "UTILITY",
"status": "APPROVED",
"parameter_format": "POSITIONAL",
"components": [
{ "type": "HEADER", "format": "TEXT", "text": "Order {{1}}" },
{ "type": "BODY", "text": "Hi {{1}}, your order {{2}} has been confirmed and ships on {{3}}.", "example": { "body_text": [["Ana", "ORD-12345", "Friday"]] } },
{ "type": "FOOTER", "text": "Acme Coffee" },
{ "type": "BUTTONS", "buttons": [
{ "type": "URL", "text": "Track order", "url": "https://shop.example.com/orders/{{1}}", "example": ["ORD-12345"] },
{ "type": "QUICK_REPLY", "text": "Stop notifications" }
] }
],
"variable_count": 5,
"created_at": "2026-09-14T10:00:00.000Z",
"updated_at": "2026-09-14T10:02:31.000Z"
}
FieldTypeDescription
idstringPostproxy hashid
external_idstring|nullMeta’s template id
profile_idstringThe WABA profile
namestringLowercase letters, digits and underscores, starting with a letter. Together with language it identifies the template on Meta
languagestringMeta language code — e.g. en_US, pt_BR, de
categorystringUTILITY, MARKETING or AUTHENTICATION. Meta can recategorize during review — see message_template.category_updated
statusstringMeta review status: APPROVED, PENDING, REJECTED, PAUSED, DISABLED, IN_APPEAL, PENDING_DELETION. Only APPROVED templates can be sent
parameter_formatstring|nullPOSITIONAL ({{1}}, {{2}}) or NAMED ({{customer_name}})
componentsarrayMeta’s component array — HEADER, BODY, FOOTER, BUTTONS — exactly as stored on Meta
message_send_ttl_secondsintegerPresent when the template overrides Meta’s default time-to-live for undelivered sends
rejected_reasonstringPresent when Meta rejected the template
quality_scoreobjectPresent once Meta has scored the template — e.g. { "score": "GREEN" }
variable_countintegerHow many variables a send expects: header text variables, then body variables, then dynamic URL buttons. See Sending a template
created_at, updated_atstringISO 8601

GET /api/profiles/:profile_id/templates

ParameterTypeRequiredDescription
namestringNoExact template name
languagestringNoLanguage code
statusstringNoReview status, case-insensitive (approved, pending, …)
refreshbooleanNotrue pulls the WABA’s templates from Meta before answering. Use it right after creating templates elsewhere, or when a status looks stale

Returns every matching template under data, ordered by name then language. Not paginated.

Terminal window
curl -X GET "https://api.postproxy.dev/api/profiles/prof_abc123/templates?status=approved" \
-H "Authorization: Bearer YOUR_API_KEY"
{
"data": [
{ "id": "tpl_8f2k1a", "name": "order_update", "language": "en_US", "status": "APPROVED", "...": "..." }
]
}

A refresh=true that fails at Meta — rate limit, revoked token — returns 422 with Meta’s message rather than stale data.

GET /api/profiles/:profile_id/templates/:id

Terminal window
curl -X GET "https://api.postproxy.dev/api/profiles/prof_abc123/templates/order_update?language=en_US" \
-H "Authorization: Bearer YOUR_API_KEY"

Returns the Template object. 404 when nothing matches, 409 ambiguous_template when a name matches several languages and no language was given.

POST /api/profiles/:profile_id/templates

Two ways to create one: a custom template you design with components, or a copy of one of Meta’s pre-approved library templates, which is approved immediately.

ParameterTypeRequiredDescription
namestringYesLowercase letters, digits and underscores, starting with a letter. Unique per language on the WABA
categorystringYesUTILITY, MARKETING or AUTHENTICATION (case-insensitive)
languagestringYesMeta language code
componentsarrayYes for customMeta’s component array. Variables are {{1}}-style for POSITIONAL or {{name}}-style for NAMED; a component with variables needs an example for review
parameter_formatstringNoPOSITIONAL (default) or NAMED
message_send_ttl_secondsintegerNoTime-to-live for undelivered sends. Ranges: AUTHENTICATION 30–900, UTILITY 30–43200, MARKETING 43200–2592000. -1 on AUTHENTICATION or UTILITY keeps Meta’s default explicitly
library_template_namestringYes for libraryName of the Meta library template to copy — see Template library. components isn’t needed then
library_template_body_inputsobjectNoBody inputs for the library template, in Meta’s shape
library_template_button_inputsarrayNoButton inputs for the library template, in Meta’s shape
Terminal window
curl -X POST "https://api.postproxy.dev/api/profiles/prof_abc123/templates" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "order_update",
"category": "UTILITY",
"language": "en_US",
"components": [
{ "type": "BODY", "text": "Hi {{1}}, your order {{2}} has been confirmed.", "example": { "body_text": [["Ana", "ORD-12345"]] } },
{ "type": "FOOTER", "text": "Acme Coffee" }
]
}'

Copying a library template:

Terminal window
curl -X POST "https://api.postproxy.dev/api/profiles/prof_abc123/templates" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "account_created",
"category": "UTILITY",
"language": "en_US",
"library_template_name": "account_creation_confirmation_2",
"library_template_button_inputs": [
{ "type": "URL", "url": { "base_url": "https://acme.example/login" } }
]
}'

Returns 201 with the Template object. A custom template starts as PENDING and moves to APPROVED or REJECTED when Meta finishes review — subscribe to message_template.status_updated or poll Get template. A library copy comes back APPROVED.

Errors: 400 for a missing or malformed field (name format, unknown category, components missing on a custom template, TTL outside the category’s range); 422 when Meta rejects the template (duplicate name in that language, invalid components, unsupported language); 403 when the token lacks template permissions.

PATCH /api/profiles/:profile_id/templates/:id

ParameterTypeRequiredDescription
componentsarrayOne ofReplaces the whole component array. Editing components sends the template back to review — status returns to PENDING and it can’t be sent until approved again
message_send_ttl_secondsintegerOne ofNew TTL, within the category’s range. -1 is not accepted on update
Terminal window
curl -X PATCH "https://api.postproxy.dev/api/profiles/prof_abc123/templates/order_update?language=en_US" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "message_send_ttl_seconds": 3600 }'

Returns the updated template. 400 when neither field is sent or the TTL is out of range; 422 when Meta rejects the edit. Meta allows only a limited number of component edits per template per day, and those rejections pass through as 422.

DELETE /api/profiles/:profile_id/templates/:id

Deleting by name with no language removes every language variant. Deleting by hashid, Meta id, or name plus ?language= removes one variant.

Terminal window
# One language
curl -X DELETE "https://api.postproxy.dev/api/profiles/prof_abc123/templates/order_update?language=pt_BR" \
-H "Authorization: Bearer YOUR_API_KEY"
{ "deleted": true, "scope": "language", "name": "order_update", "language": "pt_BR" }
Terminal window
# Every language
curl -X DELETE "https://api.postproxy.dev/api/profiles/prof_abc123/templates/order_update" \
-H "Authorization: Bearer YOUR_API_KEY"
{ "deleted": true, "scope": "all_languages", "name": "order_update" }

Meta keeps a deleted template’s name reserved for 30 days and keeps delivering already-sent messages for the same period; the template reads as PENDING_DELETION on Meta meanwhile. Messages already sent keep their template reference with the name and language.

GET /api/profiles/:profile_id/templates/library

ParameterTypeRequiredDescription
namestringYesLibrary template name
languagestringNoLanguage code

Looks a template up in Meta’s pre-approved library and returns Meta’s entry under template (null when the name is unknown) — its body, the inputs it expects and its buttons, everything you need to fill library_template_body_inputs and library_template_button_inputs on Create template.

Terminal window
curl -X GET "https://api.postproxy.dev/api/profiles/prof_abc123/templates/library?name=account_creation_confirmation_2&language=en_US" \
-H "Authorization: Bearer YOUR_API_KEY"

Templates go out through the regular Send message endpoint with a template object instead of body. A template send works inside or outside the 24-hour window and re-opens it. Only APPROVED templates can be sent.

Terminal window
curl -X POST "https://api.postproxy.dev/api/chats/chat_wa789/messages" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"template": {
"name": "order_update",
"language": "en_US",
"variables": ["Ana", "ORD-12345", "Friday"]
}
}'
template. fieldTypeDescription
idstringTemplate hashid or Meta id. Either id or name is required
namestringTemplate name. Add language when the name exists in several languages, otherwise 409
languagestringLanguage code
variablesarrayValues as strings, in this order: header text variables, then body variables, then one value per dynamic URL button. For POSITIONAL templates the count must equal variable_count (422 otherwise); for NAMED templates values are matched to the placeholders in order of appearance
button_paramsarrayOverrides for buttons needing runtime parameters — each { "index": 0, "sub_type": "url", "parameters": [...] } in Meta’s shape. sub_type is url, copy_code, flow, quick_reply or voice_call. A dynamic URL button given here no longer consumes a value from variables
header_mediaobject{ "link": "https://..." } or { "id": "<meta media id>" } for templates with an IMAGE, VIDEO or DOCUMENT header. Omitted, the sample media the template was submitted with is used
header_locationobject{ "latitude", "longitude", "name", "address" } — required for templates with a LOCATION header

The stored message’s body is the template body rendered with the variables, and the message carries a template object so you can see which template went out:

{
"id": "msg_222",
"direction": "outbound",
"status": "pending",
"body": "Hi Ana, your order ORD-12345 has been confirmed and ships on Friday.",
"template": {
"id": "tpl_8f2k1a",
"external_id": "1234567890123456",
"name": "order_update",
"language": "en_US",
"variables": ["Ana", "ORD-12345", "Friday"]
}
}

Errors: 404 template not found; 409 the name exists in several languages (pass template.language); 422 the template isn’t APPROVED or the wrong number of variables was given — the message names how many the template expects.

Meta’s per-template pacing, marketing opt-out handling and TTL apply to the send exactly as they do outside Postproxy.

The mirror is refreshed from Meta after connect, on GET .../templates?refresh=true, and after every create, update and delete. Meta’s template webhooks keep individual rows current in between and fire message_template.status_updated and message_template.category_updated, so a review outcome is usually available without polling. A template created directly in Business Manager first appears through that webhook. A template deleted on Meta drops out of the mirror on the next full sync.

Every endpoint in this section takes phone_number_id — the placement id.

GET /api/profiles/:profile_id/whatsapp/number_info

ParameterTypeRequiredDescription
phone_number_idstringYesPlacement id

Reads the number’s live state from Meta together with the WABA’s, and refreshes the placement’s metadata from what comes back.

Terminal window
curl -X GET "https://api.postproxy.dev/api/profiles/prof_abc123/whatsapp/number_info?phone_number_id=106540352242922" \
-H "Authorization: Bearer YOUR_API_KEY"
{
"phone": {
"display_phone_number": "+1 555-010-0001",
"verified_name": "Acme Coffee",
"name_status": "APPROVED",
"new_name_status": null,
"quality_rating": "GREEN",
"messaging_limit_tier": "TIER_1K",
"throughput": { "level": "STANDARD" },
"status": "CONNECTED",
"platform_type": "CLOUD_API",
"is_official_business_account": false
},
"waba": {
"name": "Acme Coffee",
"account_review_status": "APPROVED",
"business_verification_status": "verified",
"timezone_id": "1",
"ownership_type": "CLIENT_OWNED"
},
"placement_id": "106540352242922"
}

waba is null when the account-level read fails; the phone part still answers. A number Meta no longer shares with Postproxy returns 422 asking for a reconnect.

POST /api/profiles/:profile_id/whatsapp/register_phone_number

ParameterTypeRequiredDescription
phone_number_idstringYesPlacement id
pinstringNoThe number’s 6-digit two-step verification PIN. Defaults to the PIN used at connect

Registers the number for Cloud API messaging. Needed when the registration after connect left a registration_warning on the placement — typically because the number already has its own PIN. Pass that PIN here. Registering an already-registered number is a no-op.

{ "registered": true, "already_registered": false, "placement_id": "106540352242922" }

400 when pin isn’t 6 digits; 422 with Meta’s message when the PIN doesn’t match the one set on the number.

POST /api/profiles/:profile_id/whatsapp/request_verification_code

ParameterTypeRequiredDescription
phone_number_idstringYesPlacement id
methodstringNoSMS (default) or VOICE
languagestringNoLocale for the message, default en_US

Asks Meta to send an ownership verification code to the number. Only needed for a number that Embedded Signup left unverified (status: "PENDING" in its metadata).

{ "requested": true, "method": "SMS", "placement_id": "106540352242922" }

POST /api/profiles/:profile_id/whatsapp/verify_phone_number

ParameterTypeRequiredDescription
phone_number_idstringYesPlacement id
codestringYesThe code received
{ "verified": true, "placement_id": "106540352242922" }

What a customer sees when they open the number’s details in WhatsApp: about text, address, description, email, websites, industry and the profile photo. One per number.

GET /api/profiles/:profile_id/whatsapp/business_profile

ParameterTypeRequiredDescription
phone_number_idstringYesPlacement id
{
"business_profile": {
"about": "Neighbourhood coffee since 2009.",
"address": "1 Main St, Springfield",
"description": "Roasted in-house. Open daily 7–19.",
"email": "[email protected]",
"websites": ["https://acme.example"],
"vertical": "RESTAURANT",
"profile_picture_url": "https://pps.whatsapp.net/..."
},
"placement_id": "106540352242922"
}

Meta omits fields that were never set, so keys can be absent.

PATCH /api/profiles/:profile_id/whatsapp/update_business_profile

ParameterTypeRequiredDescription
phone_number_idstringYesPlacement id
aboutstringNoUp to 139 characters
addressstringNoStreet address
descriptionstringNoUp to 512 characters
emailstringNoContact email
websitesarrayNoUp to 2 URLs
verticalstringNoMeta industry value — e.g. RETAIL, RESTAURANT, PROF_SERVICES, OTHER

Send only what changes. At least one field is required, otherwise 400.

Terminal window
curl -X PATCH "https://api.postproxy.dev/api/profiles/prof_abc123/whatsapp/update_business_profile" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone_number_id": "106540352242922",
"about": "Now with a roastery.",
"websites": ["https://acme.example", "https://shop.acme.example"]
}'
{ "updated": true, "placement_id": "106540352242922" }

POST /api/profiles/:profile_id/whatsapp/update_business_profile_photo

ParameterTypeRequiredDescription
phone_number_idstringYesPlacement id
urlstringOne ofPublic https:// URL of the image. Redirects are followed
datastringOne ofBase64-encoded image, with or without a data:image/...;base64, prefix
content_typestringNoMIME type for data when it carries no data-URI prefix; otherwise detected

JPEG or PNG, at most 5 MB.

{ "updated": true, "placement_id": "106540352242922" }

The verified business name shown next to the number. Meta reviews every change.

GET /api/profiles/:profile_id/whatsapp/display_name

ParameterTypeRequiredDescription
phone_number_idstringYesPlacement id
{
"display_name": {
"name": "Acme Coffee",
"status": "APPROVED",
"new_name_status": "PENDING_REVIEW",
"phone_number": "+1 555-010-0001"
}
}

status is the review state of the current name. new_name_status is the state of a pending change request, null when there is none.

POST /api/profiles/:profile_id/whatsapp/request_display_name_change

ParameterTypeRequiredDescription
phone_number_idstringYesPlacement id
display_namestringYesThe new name, following Meta’s display name guidelines
{
"display_name": { "name": "Acme Coffee Roasters", "status": "PENDING_REVIEW" },
"placement_id": "106540352242922"
}

The outcome arrives as a placement.name_status_updated webhook with Meta’s decision; the placement’s name and metadata.name_status are updated at the same time.

A handle customers can reach the number at instead of the phone number. One per number; Meta may reserve it before it becomes active.

GET /api/profiles/:profile_id/whatsapp/username

ParameterTypeRequiredDescription
phone_number_idstringYesPlacement id
{ "username": "acmecoffee", "status": "approved", "placement_id": "106540352242922" }

username is null and status is none when the number has no username. Other status values follow Meta’s, lowercased — e.g. reserved, pending.

PUT /api/profiles/:profile_id/whatsapp/set_username

ParameterTypeRequiredDescription
phone_number_idstringYesPlacement id
usernamestringYesLetters, digits, periods and underscores; must contain a letter; no leading, trailing or consecutive periods
transfer_actionstringNonone (default), or force_transfer to move a username the same business already holds on another number

Returns the same shape as Get username. 400 for a malformed username; 422 when Meta refuses it — taken, reserved, or not eligible.

DELETE /api/profiles/:profile_id/whatsapp/delete_username

ParameterTypeRequiredDescription
phone_number_idstringYesPlacement id
{ "deleted": true, "placement_id": "106540352242922" }

GET /api/profiles/:profile_id/whatsapp/username_suggestions

ParameterTypeRequiredDescription
phone_number_idstringYesPlacement id
{ "suggestions": ["acmecoffee", "acme.coffee", "acmecoffee_1"] }

Blocking a number stops its messages from reaching this phone number. Blocks are per number, not per WABA.

GET /api/profiles/:profile_id/whatsapp/blocked_users

ParameterTypeRequiredDescription
phone_number_idstringYesPlacement id
limitintegerNoPage size
afterstringNoCursor from a previous response’s next_cursor
{ "blocked_users": [ { "wa_id": "13105550007" } ], "next_cursor": null }

GET /api/profiles/:profile_id/whatsapp/blocked_user_status

ParameterTypeRequiredDescription
phone_number_idstringYesPlacement id
userstringYesPhone number in any format; non-digits are stripped
{ "blocked": true, "wa_id": "13105550007" }

POST /api/profiles/:profile_id/whatsapp/block_users

ParameterTypeRequiredDescription
phone_number_idstringYesPlacement id
usersarrayYesUp to 1000 phone numbers or wa_ids
Terminal window
curl -X POST "https://api.postproxy.dev/api/profiles/prof_abc123/whatsapp/block_users" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone_number_id": "106540352242922",
"users": ["+1 310 555 0007", "13105550008"]
}'
{
"succeeded": [
{ "input": "+1 310 555 0007", "wa_id": "13105550007" },
{ "input": "13105550008", "wa_id": "13105550008" }
],
"failed": [],
"action": "blocked"
}

Numbers Meta could not block are listed in failed with Meta’s reasons; the call itself still returns 200. Meta restricts blocking to numbers that have messaged this phone number, so an unknown number lands in failed.

POST /api/profiles/:profile_id/whatsapp/unblock_users

Same parameters and response shape as Block users, with "action": "unblocked" and the removed numbers under succeeded.

WhatsApp groups created and administered by a business number through the API.

Messages in a group are ordinary chats whose participant_external_id is the group id and whose group flag is true.

GET /api/profiles/:profile_id/whatsapp/groups

ParameterTypeRequiredDescription
phone_number_idstringYesPlacement id
limitintegerNoPage size
afterstringNoCursor from a previous response’s next_cursor
{
"groups": [ { "id": "120363012345678901", "subject": "VIP customers", "created_at": 1758000000 } ],
"next_cursor": null
}

POST /api/profiles/:profile_id/whatsapp/create_group

ParameterTypeRequiredDescription
phone_number_idstringYesPlacement id
subjectstringYesGroup name, up to 128 characters
descriptionstringNoUp to 2048 characters
join_approval_modestringNoapproval_required or auto_approve
{
"group": {
"id": "120363012345678901",
"invite_link": "https://chat.whatsapp.com/AbCdEf...",
"subject": "VIP customers"
}
}

GET /api/profiles/:profile_id/whatsapp/group

ParameterTypeRequiredDescription
phone_number_idstringYesPlacement id
group_idstringYesGroup id
{
"group": {
"id": "120363012345678901",
"subject": "VIP customers",
"description": "Early access to new roasts",
"join_approval_mode": "approval_required",
"invite_link": "https://chat.whatsapp.com/AbCdEf...",
"participants": [ { "user": "13105550007" } ],
"participant_count": 1,
"created_at": 1758000000,
"is_suspended": false
}
}

PATCH /api/profiles/:profile_id/whatsapp/update_group

Takes phone_number_id, group_id, and at least one of subject, description or join_approval_mode — same rules as Create group.

{ "updated": true, "group_id": "120363012345678901" }

DELETE /api/profiles/:profile_id/whatsapp/delete_group

Takes phone_number_id and group_id.

{ "deleted": true, "group_id": "120363012345678901" }

POST /api/profiles/:profile_id/whatsapp/add_group_participants

POST /api/profiles/:profile_id/whatsapp/remove_group_participants

ParameterTypeRequiredDescription
phone_number_idstringYesPlacement id
group_idstringYesGroup id
phone_numbersarrayYesUp to 8 phone numbers per call — a group holds 8 participants
{ "added": ["13105550007", "13105550008"], "group_id": "120363012345678901" }

Remove answers with removed instead of added.

POST /api/profiles/:profile_id/whatsapp/create_group_invite_link

Takes phone_number_id and group_id. Generates a fresh invite link, invalidating the previous one.

{ "invite_link": "https://chat.whatsapp.com/GhIjKl...", "group_id": "120363012345678901" }

GET /api/profiles/:profile_id/whatsapp/group_join_requests

POST /api/profiles/:profile_id/whatsapp/approve_group_join_requests

POST /api/profiles/:profile_id/whatsapp/reject_group_join_requests

For groups in approval_required mode. The GET takes phone_number_id and group_id; the two POSTs also take phone_numbers, the requesters to approve or reject.

{
"join_requests": [ { "user": "13105550009", "timestamp": 1758003600 } ],
"group_id": "120363012345678901"
}
{ "approved": ["13105550009"], "group_id": "120363012345678901" }

When a customer reaches the number from a Click-to-WhatsApp ad, Meta tags the first message with a click id (ctwa_clid). Postproxy captures it on the chat as metadata.ctwa_clid and metadata.ctwa_captured_at, stores the ad’s details in metadata.ctwa (source_id, source_url, headline, body, media URLs), and fires a referral.received webhook.

Meta’s own detected conversions — a purchase or lead it spotted in the conversation — arrive as whatsapp.automatic_event and backfill ctwa_clid on the chat when it wasn’t captured yet.

To report your own outcomes back to Meta for ad optimization, the WABA needs a Conversions API dataset; each event is then sent against the chat that came from the ad. Both dataset endpoints are WABA-level and take no phone_number_id.

GET /api/profiles/:profile_id/whatsapp/dataset

{ "dataset_id": "1234567890123456" }

dataset_id is null when the WABA has no dataset yet. Read live from Meta; nothing is stored.

POST /api/profiles/:profile_id/whatsapp/create_dataset

Idempotent — returns the existing dataset with created: false when there is one.

{ "dataset_id": "1234567890123456", "created": true }

POST /api/profiles/:profile_id/whatsapp/send_conversion_event

ParameterTypeRequiredDescription
event_namestringYesLeadSubmitted, Purchase, AddToCart, InitiateCheckout or ViewContent
event_idstringYesYour unique id for the event — Meta dedupes on it
chat_idstringOne ofThe chat the customer arrived in
phonestringOne ofThe customer’s phone number instead; the most recently active chat with it is used
event_timeinteger|stringNoWhen it happened — Unix seconds or ISO 8601. Defaults to now
valuenumberNoMonetary value
currencystringNoISO 4217 code — e.g. USD
content_idsarrayNoProduct or content ids
emailstringNoCustomer email. SHA-256 hashed before it leaves Postproxy
external_idstringNoYour customer id. SHA-256 hashed before it leaves Postproxy
test_codestringNoMeta test event code, to see the event in Events Manager’s test tab without affecting optimization
dataset_idstringNoSend to a specific dataset instead of the WABA’s own
Terminal window
curl -X POST "https://api.postproxy.dev/api/profiles/prof_abc123/whatsapp/send_conversion_event" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"chat_id": "chat_wa789",
"event_name": "Purchase",
"event_id": "order-98213",
"value": 42.5,
"currency": "USD",
"content_ids": ["SKU-ESPRESSO-1KG"]
}'
{
"dataset_id": "1234567890123456",
"chat_id": "chat_wa789",
"events_received": 1,
"events_failed": 0,
"trace_id": "AbCdEf..."
}

Errors: 400 for an unknown event_name, a missing event_id, or neither chat_id nor phone; 404 when the chat can’t be found; 422 when the chat has no captured ctwa_clid — the conversation didn’t start from a Click-to-WhatsApp ad — or the WABA has no dataset yet.

GET /api/profiles/:profile_id/whatsapp/media

ParameterTypeRequiredDescription
phone_number_idstringYesPlacement id
media_idstringYesMeta media id

Streams a Meta media object’s bytes with its content type, for the rare media id that isn’t already an attachment on a message. Inbound attachments are mirrored to Postproxy storage and served from attachments[].url, which is the normal way to read them.

Beyond the standard message events and profile events, WhatsApp profiles emit a WhatsApp event group. Subscribe individually or with * in the Webhooks API.

EventFires when
message.received / .sentInbound / outbound message
message.delivered / .readMeta confirmed delivery / the customer read it
message.deletedThe customer deleted a message for everyone
message.failedAn outbound message failed permanently — Meta’s error in error_details
reaction.receivedThe customer reacted to a message
referral.receivedA Click-to-WhatsApp ad or wa.me link referral opened a conversation
message_template.status_updatedMeta approved, rejected, paused, disabled or re-enabled a template, or changed its quality score
message_template.category_updatedMeta recategorized a template — e.g. UTILITY → MARKETING
placement.name_status_updatedMeta decided on a display name change request
placement.quality_updatedA number’s quality rating or messaging limit tier changed
whatsapp.automatic_eventMeta detected a conversion in a Click-to-WhatsApp conversation
profile.connected / .disconnectedConnection state changed
profile.placements_syncedThe number list was refreshed
Terminal window
curl -X POST "https://api.postproxy.dev/api/webhooks" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/postproxy",
"events": ["message.received", "message_template.status_updated", "placement.quality_updated"]
}'
{
"object": "event",
"type": "message_template.status_updated",
"created_at": "2026-09-14T10:02:31.000Z",
"data": {
"object": {
"id": "tpl_8f2k1a",
"external_id": "1234567890123456",
"profile_id": "prof_abc123",
"platform": "whatsapp",
"name": "order_update",
"language": "en_US",
"category": "UTILITY",
"status": "APPROVED",
"rejected_reason": null,
"quality_score": null
}
}
}

status is the new review status. A rejection adds reason with Meta’s rejection reason. A quality change fires the same event with the new quality_score.

The same object plus previous_category, new_category, and — when Meta only flagged a recommended category — correct_category.

{
"object": "event",
"type": "placement.name_status_updated",
"created_at": "2026-09-15T08:12:00.000Z",
"data": {
"object": {
"id": "106540352242922",
"name": "Acme Coffee Roasters",
"platform": "whatsapp",
"profile_group_id": "grp_xyz789",
"profile_id": "prof_abc123",
"profile_name": "Acme Coffee",
"metadata": { "display_phone_number": "+1 555-010-0001", "name_status": "APPROVED", "quality_rating": "GREEN" },
"decision": "APPROVED",
"requested_name": "Acme Coffee Roasters",
"rejection_reason": null
}
}
}

The same placement object plus event (Meta’s event name — ONBOARDING, UPGRADE, DOWNGRADE, FLAGGED, UNFLAGGED), current_limit and old_limit. metadata.quality_rating and metadata.messaging_limit_tier already carry the new values when the event arrives.

{
"object": "event",
"type": "whatsapp.automatic_event",
"created_at": "2026-09-15T09:30:00.000Z",
"data": {
"object": {
"profile_id": "prof_abc123",
"chat_id": "chat_wa789",
"participant_external_id": "13105550007",
"external_message_id": "wamid.HBgL...",
"event_name": "Purchase",
"ctwa_clid": "ARAkLkA8rmlFeiCktEJQ-QTwRiyYHAFDLMNDBH0CD3qpjd0HR4irJ6LEkR7JwFF4XvnO2E4Nx0",
"custom_data": { "currency": "USD", "value": 42.5 },
"detected_at": "2026-09-15T09:30:00.000Z"
}
}
}

chat_id is null when the customer has no chat with the profile yet.

Connect whatsapp in a sandbox profile group and you get a sandbox WhatsApp profile: one phone number placement with a fabricated +1 555 … number, two seeded approved templates — hello_world (en_US, no variables) and order_update (en_US, Hi {{1}}, your order {{2}} has been confirmed.) — and every endpoint on this page answering production-shaped responses.

Sends return a wamid. message id, create_template returns PENDING (or APPROVED for a library copy), the block list and groups start empty, and create_dataset returns a stable dataset id. Inbound messages, statuses, reactions and referrals are fabricated from the Sandbox page in the dashboard and go through the same ingestion as Meta’s webhooks. See Direct Messages → Sandbox.

StatusMeaning
400A missing required parameter (phone_number_id, group_id, …) or a value outside its documented limit — lengths, counts, PIN or username format, TTL range, unknown enum value
403Meta refused for permission reasons — most often a number whose two-step PIN isn’t registered (re-register it with its PIN), or a token lacking a scope
404Profile not found, phone_number_id isn’t a placement the key can reach, or template or chat not found
409A template name matches several languages and no language was given — code: "ambiguous_template", with languages listing them
422The profile isn’t a WhatsApp profile, the number is no longer shared with Postproxy (reconnect), the connection needs re-authentication, or Meta rejected the payload — Meta’s message is passed through
429Meta is rate-limiting the WABA. Retry after the Retry-After header
502Meta’s API could not be reached. Retry

WhatsApp is a messaging channel, not a publishing one, so some things available elsewhere in Postproxy don’t apply:

  • Publish posts. POST /api/posts with a WhatsApp profile returns 422, and WhatsApp doesn’t appear in the composer.
  • Comments, post insights and profile stats. WhatsApp has no public feed.
  • Edit an outbound message. PATCH /api/messages/:id returns 422.
  • Free-form messages outside the 24-hour window. Send a template or a utility Direct Send.
  • Backfill conversations on demand. History only arrives through coexistence onboarding at connect.
  • Groups on a coexistence number, and interactive messages in any group.
  • One WABA is one profile; each of its phone numbers is a placement, and the placement id is Meta’s phone_number_id.
  • placement_id is required when creating a chat — a customer messaging two of your numbers has two chats.
  • Check within_messaging_window before a free-form send; fall back to a template.
  • variables on a template send are positional across header, body, then dynamic URL buttons — variable_count on the template tells you how many to supply.
  • Meta’s messaging limit tier caps business-initiated conversations per 24 hours; watch placement.quality_updated to see it move.