# Maytapi - WhatsApp API for Developers and Businesses > WhatsApp Unofficial API for developers. Send and receive all message types, manage groups, channels, communities, business catalogs, and stories via simple REST API. Fixed monthly pricing per number, unlimited messaging, no per-message fees, multi-device support, 99.98% uptime. ## Key Facts - What it is: an unofficial WhatsApp API. It connects your own WhatsApp number as a linked device (QR code pairing); it is not the official WhatsApp Business Platform (Cloud API), so no Meta approval or message templates are needed. - Pricing: $24 per connected number per month billed annually, or $29 billed monthly. No per-message fees. - Free trial: 3 days, every endpoint, no credit card required. - Sandbox plan: free, all endpoints for testing, up to 5 recipient numbers. - Company: a United Kingdom startup founded in 2019 by George Andro. - Uptime: 99.98% over the last 12 months, published on the [status page](https://status.maytapi.com/). - Languages: the site and support are available in English, Turkish, Spanish, Portuguese, Romanian and Russian. ## Getting Started 1. Create account at [Console](https://console.maytapi.com/register) 2. Pair your WhatsApp by scanning QR code 3. Get `product_id` and `token` from [Settings](https://console.maytapi.com/settings/token) 4. Make API calls to `https://api.maytapi.com/api/{product_id}/{phone_id}/` 5. Authenticate with header: `x-maytapi-key: YOUR_TOKEN` ## Documentation - [API Documentation](https://maytapi.com/whatsapp-api-documentation): Full interactive endpoint reference with examples - [OpenAPI Spec (JSON)](https://maytapi.com/openapi.json): Machine-readable API specification (OpenAPI 3.0) - [llms-full.txt](https://maytapi.com/llms-full.txt): This file plus the full endpoint list and the full text of every blog guide, in one Markdown file - [Blog RSS feed](https://maytapi.com/blog/feed.xml): New guides and product updates - [Postman Collection](https://maytapi.com/whatsapp-postman-documentation): Ready-to-use API workspace - [Pricing](https://maytapi.com/whatsapp-api-pricing): Plans and pricing details - [About Maytapi](https://maytapi.com/about): Company, founder and how we work ## API Capabilities ### Messaging (`POST /{phone_id}/sendMessage`) Send to any WhatsApp number, group, or channel. Supported types: text, image, video, audio, document, location, contact/vCard, poll, button, list, catalog, product, reaction, reply, forward, edit, delete. Media supports both URL and base64. Built-in spam filter (bypass with `skip_filter: true`). ### Groups Create groups, list groups, get group info, add/remove participants, promote/demote admins, generate invite links, set group name/description/icon, leave group. ### Channels (Newsletters) Create channels, list channels, send messages to channels, search public channels, follow/unfollow channels, set channel description/icon, get channel messages. ### Communities Create communities, list communities, get community info, add/remove groups to community. ### Business Catalog List/create/edit/delete products, manage collections, list orders, get order details, send product and catalog messages. ### Stories (Beta) Publish text, image and video stories (WhatsApp Status) with `createStory`, read your own stories with `getStories` and your contacts’ stories with `contactsStories`. ### Webhooks Set webhook URL to receive: incoming messages (all types), delivery receipts (ack), read receipts, group events, channel events. Configure per-product or per-phone. ### Phone Management List phones, get QR code/screen status, check connection status, redeploy instance, factory reset, logout, view logs, check/purge message queue. ### Contacts & Conversations List contacts, get contact info, check if number is on WhatsApp, list conversations, get conversation messages, change profile picture. ## Number Formats - Individual: `1415xxxxx67` or `1415xxxxx67@c.us` - Groups: `XXXXXXXXXX@g.us` - Channels: `XXXXXXXXXX@newsletter` ## Example Projects - [Node.js Bot](https://github.com/maytapi-com/whatsapp-api-nodejs-example): Echo bot with media support - [Python Bot](https://github.com/maytapi-com/whatsapp-api-python-example): Python webhook handler - [Node.js Dialogflow](https://github.com/maytapi-com/whatsapp-api-nodejs-dialogflow-example): AI chatbot integration - [Python Dialogflow](https://github.com/maytapi-com/whatsapp-api-python-dialogflow-example): Python AI chatbot ## Integrations - [MCP Server](https://maytapi.com/tools/maytapi-mcp-server): Model Context Protocol integration - [n8n Integration](https://maytapi.com/tools/maytapi-n8n-integration): No-code workflow automation - [Google Contacts Integration](https://maytapi.com/tools/maytapi-google-contacts-integration): Sync WhatsApp with Google Contacts ## Free Tools - [WhatsApp Number Checker](https://maytapi.com/tools/free-whatsapp-number-checker-tool): Verify if number is on WhatsApp - [Chat Link Generator](https://maytapi.com/tools/free-whatsapp-link-generator): Create wa.me links - [QR Code Generator](https://maytapi.com/tools/free-whatsapp-qr-code-generator): Generate WhatsApp QR codes - [Text Formatter](https://maytapi.com/tools/whatsapp-text-formatter): Bold, italic, strikethrough formatting - [Profile Picture Tool](https://maytapi.com/tools/get-whatsapp-profile-picture-tool): Fetch a WhatsApp profile photo - [JSON Escape/Unescape](https://maytapi.com/tools/json-escaper-unescaper): JSON utility ## Feature Pages - [WhatsApp Group API](https://maytapi.com/features/whatsapp-group-api): Group management overview - [WhatsApp Channel API](https://maytapi.com/features/whatsapp-channel-api): Channel operations overview - [WhatsApp Community API](https://maytapi.com/features/whatsapp-community-api): Community management overview - [WhatsApp Status (Story) API](https://maytapi.com/features/whatsapp-status-api): Publish and read WhatsApp stories (Status) - [Multi-Agent Platform](https://maytapi.com/multi-agent-platform-for-whatsapp): Multi-agent customer support - [WhatsApp Webhook API](https://maytapi.com/features/whatsapp-webhook-api): Incoming messages, delivery and read receipts on your server - [Interactive Messages API](https://maytapi.com/features/whatsapp-interactive-messages-api): Buttons, lists and carousels without templates - [Media Messages API](https://maytapi.com/features/whatsapp-media-messages-api): Images, video, GIFs, PDF/Excel/Word documents, audio, stickers and media in button messages - [Product Catalog API](https://maytapi.com/features/whatsapp-product-catalog-api): Products, collections and orders - [Number Check API](https://maytapi.com/features/whatsapp-number-check-api): Check whether numbers are on WhatsApp before sending - [LID to Phone Number API](https://maytapi.com/features/whatsapp-lid-to-phone-number-api): Resolve @lid identifiers to phone numbers ## Support & Resources - [Console Dashboard](https://console.maytapi.com/) - [Help Center](https://support.maytapi.com/): Step-by-step guides and documentation - [Status Page](https://status.maytapi.com/) - [Changelog](https://maytapi.com/changelog) - [FAQ](https://maytapi.com/whatsapp-api-faq) - [Partner Program](https://maytapi.com/whatsapp-api-partner) - [Blog](https://maytapi.com/blog): Tutorials, guides and product updates - [GitHub](https://github.com/maytapi-com) - [Contact](https://maytapi.com/contact) - [Terms of Service](https://maytapi.com/legal/terms) - [Privacy Policy](https://maytapi.com/legal/privacy) ## Guides and comparisons - [Send WhatsApp messages with the API](https://maytapi.com/send-whatsapp-message-api): Text, media, buttons, groups and channels from your own number - [Unofficial WhatsApp API](https://maytapi.com/unofficial-whatsapp-api): What an unofficial API is and how it differs from the official Cloud API - [WhatsApp Business API alternative](https://maytapi.com/whatsapp-business-api-alternative): Maytapi vs the WhatsApp Business API (Cloud API), compared row by row - [Whapi.Cloud alternative](https://maytapi.com/whapi-alternative): Maytapi vs Whapi.Cloud - [Green API alternative](https://maytapi.com/green-api-alternative): Maytapi vs Green API - [whatsapp-web.js alternative](https://maytapi.com/whatsapp-web-js-alternative): Hosted API vs the self-hosted whatsapp-web.js library - [Baileys alternative](https://maytapi.com/baileys-alternative): Hosted API vs the self-hosted Baileys library - [Send your first message](https://maytapi.com/whatsapp-api-send-first-message): From sign-up to the first API call - [Ban prevention](https://maytapi.com/whatsapp-api-ban-prevention): Safe Mode, number warm-up and sending limits - [Pricing in India](https://maytapi.com/whatsapp-api-pricing-in-india): Local pricing for Indian numbers ## Language Guides - [Python](https://maytapi.com/guides/send-whatsapp-message-python) - [Node.js](https://maytapi.com/guides/send-whatsapp-message-nodejs) - [PHP](https://maytapi.com/guides/send-whatsapp-message-php) - [Java](https://maytapi.com/guides/send-whatsapp-message-java) - [C#](https://maytapi.com/guides/send-whatsapp-message-csharp) ## Blog - [WhatsApp API for Business: 8 Use Cases That Actually Pay Off](https://maytapi.com/blog/whatsapp-api-business-use-cases): Order updates, support, reminders, catalogs, channels and AI agents: 8 WhatsApp API automations businesses run on their own number, with the endpoints for each. - [Build a WhatsApp Bot with Node.js: Send, Receive and Reply](https://maytapi.com/blog/whatsapp-bot-nodejs): Node.js tutorial: send a WhatsApp message with fetch, receive webhooks in Express and build a command bot that replies with text, media, location and buttons. - [Build a WhatsApp Bot in Python with Flask](https://maytapi.com/blog/whatsapp-bot-python): Step-by-step Python tutorial: receive WhatsApp messages on a Flask webhook, reply with text, media and polls, and run it safely in production. - [How to Create and Manage WhatsApp Groups with an API](https://maytapi.com/blog/create-whatsapp-group-api): Create WhatsApp groups from code with the Maytapi API: add and promote members, handle join requests, change settings and invite links, and track group events. - [Send Messages to WhatsApp Groups via API: Text, Media, Polls and More](https://maytapi.com/blog/send-message-to-whatsapp-group-api): Every message type you can post to a WhatsApp group from code, with request examples, mentions, @all and best practices for busy groups. - [WhatsApp @lid Explained: How to Get the Phone Number Behind a LID](https://maytapi.com/blog/whatsapp-lid-to-phone-number): Why WhatsApp started showing 123456@lid instead of phone numbers, what it breaks, and how to convert LIDs to numbers (and back) with the API. - [How to Avoid WhatsApp Bans When Automating: Safe Mode and Warm-up](https://maytapi.com/blog/whatsapp-ban-prevention-safe-mode): How to warm up a new WhatsApp number: what gets numbers banned, a 14-day warm-up plan, and how Safe Mode and the new-chat limit keep automated numbers healthy. - [Control WhatsApp from Claude, Cursor and Other AI Agents with MCP](https://maytapi.com/blog/whatsapp-mcp-server-claude): Set up the Maytapi WhatsApp MCP server in a minute and let Claude Desktop, Claude Code or Cursor read chats, send messages and manage groups for you. - [Automate WhatsApp with n8n: Send, Receive and Build Workflows](https://maytapi.com/blog/whatsapp-n8n-automation): Install the Maytapi n8n node, send WhatsApp messages from any workflow and trigger flows from incoming messages, without writing code. - [WhatsApp Channel API: Post Updates, Polls and Quizzes and Read Statistics](https://maytapi.com/blog/whatsapp-channel-api-polls-quizzes): Publish text, media, polls, questions and quizzes to a WhatsApp Channel from code, manage admins, and pull follower and reach statistics. - [Managing WhatsApp Communities Programmatically](https://maytapi.com/blog/whatsapp-community-api-guide): Use the WhatsApp Community API to create a community, link sub-groups, post announcements, manage members and admins, and read comments under posts. - [Post WhatsApp Status Updates (Stories) from Code](https://maytapi.com/blog/whatsapp-status-api-post-stories): Use the WhatsApp Status API to publish image, video, text and audio stories, choose exactly who sees them, and read your contacts' stories from code. - [WhatsApp Webhooks: Every Event Your Server Can Receive](https://maytapi.com/blog/whatsapp-webhook-events-guide): A practical tour of WhatsApp webhook events: messages, acks, reactions, edits, polls, calls, labels, orders and group events, with payload examples. - [Send WhatsApp Buttons, Lists and Carousels via API](https://maytapi.com/blog/whatsapp-interactive-messages-buttons-list-carousel): Interactive WhatsApp messages without template approval: reply buttons, URL and call buttons, list menus and product-style carousels, with request examples. - [Sell on WhatsApp: Catalog, Products and Order Webhooks via API](https://maytapi.com/blog/whatsapp-catalog-api-orders): Use the WhatsApp Catalog API to manage products and collections from code, send them in chat, and receive customer orders as webhooks on your server. - [Build a WhatsApp AI Agent with n8n and Claude](https://maytapi.com/blog/whatsapp-ai-agent-n8n-claude): Connect the Maytapi n8n node to an AI Agent with a Claude model and per-chat memory, with loop protection, label-based human handoff and rate limits. - [WhatsApp Call Webhooks: Missed, Rejected and Ringing Calls via API](https://maytapi.com/blog/whatsapp-call-webhooks): Receive WhatsApp call events on your webhook, read call history with getCalls, reject or start calls, and auto-reply to missed calls from your CRM. - [Approve WhatsApp Group Join Requests with an API](https://maytapi.com/blog/whatsapp-group-join-requests-api): Turn on approval mode, list pending join requests and approve or reject them automatically against your CRM or paid-member list. - [WhatsApp Profile Picture API: Fetch, Store and Sync to CRM](https://maytapi.com/blog/whatsapp-profile-picture-api): Get WhatsApp profile pictures of contacts and groups with getProfileImage, handle privacy limits, store the images and keep CRM avatars in sync. - [WhatsApp for Shopify & WooCommerce: Carts, Orders and COD](https://maytapi.com/blog/whatsapp-shopify-abandoned-cart-cod): Connect Shopify or WooCommerce webhooks to the Maytapi API to recover abandoned carts, send order updates and confirm cash-on-delivery orders on WhatsApp. - [WhatsApp API Send Message: Every Message Type with Examples](https://maytapi.com/blog/whatsapp-api-send-message): Send text, media, voice notes, locations, contacts, polls and reactions with one sendMessage call. Request bodies, to_number formats, acks and error codes. - [WhatsApp API Getting Started: QR Code, First Message, Webhook](https://maytapi.com/blog/whatsapp-api-getting-started): Connect a WhatsApp number by QR code or pairing code, send your first message with curl, receive replies on a webhook and keep the phone connected. - [WhatsApp Username API: Resolve, Message and Set @handles](https://maytapi.com/blog/whatsapp-username-api): Resolve WhatsApp usernames to contacts, send messages to @handles, handle key-protected usernames and hidden numbers, and set your own username. - [Export WhatsApp Group Members to CSV or CRM with the API](https://maytapi.com/blog/whatsapp-group-members-export): Export WhatsApp group members with getGroups, resolve @lid ids to phone numbers, add names and write a CSV, limited to groups where members opted in. - [Forward WhatsApp Group Messages to Slack Channels](https://maytapi.com/blog/whatsapp-group-messages-to-slack): A WhatsApp Slack integration with a Maytapi webhook and Node.js: map groups to channels, post sender, text and media, and reply from Slack threads. - [WhatsApp Appointment Reminders via API: Cut No-Shows](https://maytapi.com/blog/whatsapp-appointment-reminders): Send WhatsApp appointment reminders 24h and 2h before, let customers confirm, reschedule or cancel from a list, and update bookings from your webhook. - [Automate WhatsApp Client Onboarding Groups from Your CRM](https://maytapi.com/blog/whatsapp-client-onboarding-groups): Create a WhatsApp group when a deal is won: add the account team, set admins and settings, pin a welcome message, store the invite link and close it cleanly. - [Paid WhatsApp Group with Stripe: Auto Add and Remove Members](https://maytapi.com/blog/whatsapp-paid-group-stripe): Sync a paid WhatsApp group with Stripe subscriptions: add buyers on checkout, send the invite link if adding fails, and remove them when the subscription ends. - [Build a WhatsApp Bot in PHP: cURL, Webhooks and a List Menu](https://maytapi.com/blog/whatsapp-bot-php): Build a WhatsApp bot with plain PHP 8 and cURL: read webhooks from php://input, route commands with a list menu and reply with images and PDFs. - [Send WhatsApp Messages from Form Submissions](https://maytapi.com/blog/whatsapp-form-submissions): Turn Tally, Typeform, Google Forms or WordPress submissions into a WhatsApp confirmation for the lead and an instant alert for your sales group. - [WhatsApp API Pricing After October 2026: What a Support Bot Costs](https://maytapi.com/blog/whatsapp-api-pricing-october-2026): Meta now charges for service replies past 1,000 a month and for utility templates in the 24h window. Worked cost examples vs a flat per-number API. - [Build a WhatsApp Bot in Java: HttpClient, Webhooks and a List Menu](https://maytapi.com/blog/whatsapp-bot-java): Build a WhatsApp bot with Java 17, HttpClient and the JDK HTTP server: filter webhooks, route commands with a list menu and reply with images and PDFs. - [WhatsApp from Google Sheets: Send Messages and Log Replies](https://maytapi.com/blog/whatsapp-google-sheets): Send personalized WhatsApp messages from a Google Sheet with the console sender or Apps Script, pace them safely and log replies back. - [Download WhatsApp Media from Webhooks: Images, Voice Notes, Files](https://maytapi.com/blog/whatsapp-media-download): Read media messages on your webhook, download images, voice notes and documents to disk or S3, handle large files, and send files back via API. - [Test WhatsApp Webhooks Locally with ngrok or cloudflared](https://maytapi.com/blog/whatsapp-webhook-local-testing): Run a local Express or Flask receiver, expose it with ngrok, point a Maytapi webhook at it, replay requests in the inspector and switch back to production. - [WhatsApp Automation with Make.com: HTTP and Webhooks](https://maytapi.com/blog/whatsapp-make-integration): Send WhatsApp messages from Google Sheets or Typeform and react to replies in Make.com using the HTTP module and a Custom webhook. - [Build a WhatsApp Chatbot with Google Dialogflow](https://maytapi.com/blog/whatsapp-dialogflow-chatbot): Connect a Dialogflow ES agent to your WhatsApp number with a small webhook bridge: per-chat sessions, menus, a human handoff and a production checklist. - [WhatsApp API Partner Program: White-Label Phones for Agencies](https://maytapi.com/blog/whatsapp-api-partner-program): Resell WhatsApp API under your brand: create free trial phones for clients with the partner API, then switch them to live or closed with one call. - [Sync WhatsApp Business Labels with Your CRM](https://maytapi.com/blog/whatsapp-labels-api-crm-sync): Use the WhatsApp Labels API to create and assign WhatsApp Business labels from code and keep them in two-way sync with your CRM through label webhooks. - [How to Check if Numbers Are on WhatsApp Before You Send](https://maytapi.com/blog/bulk-whatsapp-number-check): Clean your contact list in bulk: check which numbers have WhatsApp with the API or the free checker, and why it matters for delivery and bans. - [WhatsApp Broadcast via API: Send to Many Contacts Without Getting Banned](https://maytapi.com/blog/whatsapp-broadcast-api): How to send one message to hundreds of contacts with the API: broadcast vs groups vs channels, personalization, pacing, queues and opt-outs. ## API Endpoint Reference Base URL: `https://api.maytapi.com/api/{product_id}`. Every request carries the `x-maytapi-key: YOUR_TOKEN` header. Machine-readable spec: https://maytapi.com/openapi.json ### Message Operations - `POST /{phone_id}/sendMessage`: Sends a text, media, poll, vcard, contact, forward, delete, reaction, edit, sticker, location, link, product, buttons,carousel messages, also supports channel operations. Used for sending message to a chat. A text, image, video, sound and document can be sent. **This endpoint adds messages to your phone's sending queue. If your messages not delivering to phones please check your instance logs, queue endpoint or connection (/screen).** **Curl example:** (You need to change product_id, phone_id and token[x-maytapi-key] with your product's values...) ```bash curl -X POST \ 'https://api.maytapi.com/api/dc01968f-####-####-####-7cfcf51aa423/12/sendMessage' \ -H 'Content-Type: application/json' \ -H 'x-maytapi-key: b267697c-####-####-####-2435e812efc1' \ -d '{ "message": "http://placehold.it/180", "text": "", "to_number": "+905301111111", "type": "media" }' - `POST /{phone_id}/sendMessage`: Sends interactive messages: buttons (id, url, phoneNumber, code), list, and carousel - `GET /{phone_id}/getMessage/{msgId}`: Retrieves specific message. Provides information about a specific message. - `GET /{phone_id}/getMessages/{conversation_id}`: Retrieves chat messages ### Group Chat Operations - `POST /{phone_id}/createGroup`: Creates a group. Create a new WhatsApp group. Direct member addition will be attempted first for each number. If direct addition fails (e.g., due to privacy settings), an invitation message will be sent to those members. The creator of the group automatically becomes an admin. - `GET /{phone_id}/getGroups`: Lists all groups. Get conversations list. - communityId: the community the group belongs to (same as the id in getCommunities). - isCommunityAnnounce: true for the community's announcement group. - Communities themselves are not listed here; use getCommunities. - `GET /{phone_id}/getGroups/{conversation_id}`: Gets specific group. Get a conversation info. - `POST /{phone_id}/group/setProfileImage`: Sets group image. Change the profile picture of a group conversation. You need to use square images for better view. We suggest 512x512 images. - `POST /{phone_id}/group/deleteProfileImage`: Delete group image. Delete the profile picture of a group conversation. This action will remove the group's current profile picture and revert to the default group avatar. - `POST /{phone_id}/group/config`: Configures group settings. Change config of a group conversation. To learn conversation_id you can use /getGroups endpoint. You can change who can edit group info(edit), can send message(send) and disappearing messages enabled(disappear). For edit and send config you can use values "admins" or "all". Disappear only accepts true/false values. Config values are optional. If you want to only change a config you should only use that config's key in request body. - `POST /{phone_id}/group/add`: Adds group participants. Add participants to a group conversation. To learn conversation_id you can use /getGroups endpoint. - `POST /{phone_id}/group/remove`: Removes group participant. Remove a participant from a group conversation. To learn conversation_id you can use /getGroups endpoint. - `POST /{phone_id}/group/promote`: Promotes group admin. Promote a participant in a group conversation. To learn conversation_id you can use /getGroups endpoint. - `POST /{phone_id}/group/demote`: Demotes group admin. Demote a participant in a group conversation. To learn conversation_id you can use /getGroups endpoint. - `POST /{phone_id}/group/acceptInvite`: Accepts group invite. Accept an invitation to join a group conversation. To get the inviteCode, you need to receive it from the group admin or another member. - `POST /{phone_id}/group/acceptInviteV4`: Accepts group invite (V4). Accept an invitation to join a group conversation. To get the invitation details, you need to receive them from the group admin or another member. The required invitation information (inviteCode, inviteGrp, inviteCodeExp, from) will be received through the webhook when an invitation is sent. - `POST /{phone_id}/group/revokeInvite`: Revokes group invite. Revoke and regenerate the invite link for a group. You can get the group information from the /getGroups endpoint to use this function. - `GET /{phone_id}/group/approvalRequestList/{conversation_id}`: Lists join requests. Get the list of pending join requests for a group. This endpoint retrieves all users waiting for approval to join the group. - `POST /{phone_id}/group/approvalRequest/Approve`: Approves join requests. Approve requests you receive from /approvalRequestList. - `POST /{phone_id}/group/approvalRequest/Reject`: Rejects join requests. Reject requests you receive from /approvalRequestList. - `GET /{phone_id}/group/getGroupInfoByInviteCode/{inviteCode}`: Gets group info. Get group information using an invite code received from a webhook. - `POST /{phone_id}/leaveGroup`: Leaves the group. You can leave the group you want with the help of this endpoint. ### Account Information Retrieval - `GET /product`: Returns product information. Returns the information of the product with the given product id. - `POST /addPhone`: Adds new phone. Adds a new phone to the product. - `GET /listPhones`: Lists registered phones. Returns information of all the phones registered to the given product. - `POST /setWebhook`: Sets webhook address. Sets a webhook address for the selected product. Webhook is used for delivering the incoming messages and acknowledge notifications of sent messages to the user. The notification formats that reaches to the webhook are as follows: - `POST /setAckPreference`: Sets acknowledgment preference. Sets the option for getting the acknowledge messages from the webhook to true or false. - `GET /logs`: Returns all phones logs. Returns logs of all the phones for the product. - Logs are kept for 10 days. - To export a period: set `from` and `to`, then paginate with `after` until the response `next` is null. - The endpoint may respond with HTTP 429 (Too many requests) and a `Retry-After` header; repeat the same request after that many seconds. No records are skipped. - `GET /{phone_id}/logs`: Returns specific phone logs. Returns logs of the specific phone. - Logs are kept for 10 days. - To export a period: set `from` and `to`, then paginate with `after` until the response `next` is null. - The endpoint may respond with HTTP 429 (Too many requests) and a `Retry-After` header; repeat the same request after that many seconds. No records are skipped. ### Session Information Getters - `GET /{phone_id}/screen`: Gets session screen. Returns a screenshot of the current session screen as a PNG image. On some connections, only the QR code image is returned while the phone is waiting to be linked. - `GET /{phone_id}/status`: Gets session status. Returns the status of the current session as JSON. - `GET /{phone_id}/logout`: Logs out user. Logouts the number from the current session and converts the screen to the QR code screen (status qr-screen). - `POST /{phone_id}/connectWithPhoneNumber`: Connects with phone number. Connect with the phone number for your phone with the post operation - `GET /{phone_id}/qrCode`: Gets QR code. Returns the qrCode in the current session if there is one. - `GET /{phone_id}/getNewChatLimit`: Get new chat limit. Returns the new-chat limit (first message to a new contact) that WhatsApp applies to the connected number. This limit is currently in testing by WhatsApp and for now only appears on new WhatsApp accounts. - **limited** false means WhatsApp applies no quota to this number (limit and remaining are null). - When limited, limit/used/remaining are integers and capped becomes true once the quota is exhausted; cycleStart/cycleEnd are unix seconds. ### Contact Operations - `GET /{phone_id}/getContacts`: Lists all contacts. Returns the information of the number you specified in Whatsapp - `GET /{phone_id}/getContacts/{conversation_id}`: Gets specific contact. Returns the information of the number you specified in Whatsapp - `GET /{phone_id}/getContacts/lidToPhone/{lid}`: Resolves a LID to phone number. Resolves a @lid identifier to the real phone number. Returns success:false with no data when the contact's number is hidden or cannot be resolved. - `GET /{phone_id}/getContacts/phoneToLid/{number}`: Resolves a phone number to LID. Resolves a phone number to its @lid identifier. Returns success:false when the number is not on WhatsApp or cannot be resolved. - `GET /{phone_id}/getContacts/usernameToPhone/{username}`: Resolves a username to phone number. Resolves a WhatsApp username to its identity. When the phone number is not available, id and lid are the @lid identifier and phone is null. When the username is key-protected and the key is missing or wrong, data contains only username and keyRequired: true. - `GET /{phone_id}/getContacts/phoneToUsername/{number}`: Resolves a phone number to username. Resolves a phone number to its identity including the WhatsApp username. username is null when the user has no username. - `POST /{phone_id}/checkPhones`: Checks phone numbers. Checks the WhatsApp registration status of provided phone numbers. - `POST /{phone_id}/blockContact/{conversation_id}`: Blocks, Unblocks a contact. Blocks or unblocks the contact associated with the specified conversation_id. - `GET /{phone_id}/getProfileImage`: Get chat image.. Returns profile picture of a specified contact or group. - `POST /{phone_id}/setProfileImage`: Set account profile image. Change your WhatsApp account's profile picture. You need to use square images for better view. We suggest 512x512 images. - `POST /{phone_id}/deleteProfileImage`: Delete account profile image. Delete your WhatsApp account's profile picture. This action will remove your current profile picture and revert to the default avatar. - `GET /{phone_id}/getProfileName`: Gets account profile name. Returns the WhatsApp profile (display) name of the connected number and whether it can be changed. - **name**: the current profile name. - **canChange**: false on WhatsApp Business accounts (the name can only be changed with forceForBusiness), true otherwise. - `POST /{phone_id}/setProfileName`: Set account profile name. Changes the WhatsApp profile (display) name of the connected number. - **name** (string, required): 1-25 characters. - **forceForBusiness** (boolean, optional, default false): required to change the name on WhatsApp Business accounts. Can be used at most 2 times per phone in 24 hours. - `GET /{phone_id}/getProfileAbout`: Get account about text. Returns the "About" text of the connected number. - **timestamp**: unix seconds of the last update. - **duration**: lifetime in seconds; **expiresAt** = timestamp + duration. - An About set from the phone app is permanent and is returned with duration -1. - `GET /{phone_id}/getProfileAbout/{conversation_id}`: Get contact about text. Returns the "About" text of a contact. about may be an empty string if the contact hides it. - `POST /{phone_id}/setProfileAbout`: Set account about text. Sets the "About" text of the connected number. WhatsApp requires a duration; the text disappears when it expires. - **about** (string, required): 1-50 characters. - **duration** (integer, optional): lifetime in seconds, default 86400 (1 day). - `GET /{phone_id}/getBlockContacts`: Lists blocked contacts. Returns the blocked contacts of the connected number. name is only present when the contact is saved. Contacts that were never messaged may be returned as LID identities (server "lid"). - `GET /{phone_id}/getProfileUsername`: Get account username. Returns the WhatsApp username of the connected number (the unique @handle, not the display name). state is ACTIVE, RESERVED, or null when no username is set. - `POST /{phone_id}/setProfileUsername`: Set account username. Sets the WhatsApp username of the connected number. - **username** (string, required): 3-35 characters; letters, digits, underscore and dot; must contain at least one letter. WhatsApp validates availability. - `POST /{phone_id}/contact/add`: Adds a contact. Add a new contact to your WhatsApp addressbook. This will save the contact with the provided first name and last name. **Important:** Before using this endpoint, you must enable contact management in WhatsApp. On your phone, go to WhatsApp > Settings > Privacy > Contacts and turn on 'Manage contacts from web'. - `POST /{phone_id}/contact/edit`: Edits a contact. Edit an existing contact in your WhatsApp addressbook. This will update the contact's first name and last name. **Important:** Before using this endpoint, you must enable contact management in WhatsApp. On your phone, go to WhatsApp > Settings > Privacy > Contacts and turn on 'Manage contacts from web'. - `POST /{phone_id}/contact/remove`: Removes a contact. Remove a contact from your WhatsApp addressbook. This will delete the contact permanently. **Important:** Before using this endpoint, you must enable contact management in WhatsApp. On your phone, go to WhatsApp > Settings > Privacy > Contacts and turn on 'Manage contacts from web'. ### Chat Operations - `GET /{phone_id}/getConversations`: Lists all chats. Lists all conversations. Each conversation also includes its chat state: favorite, pinned, archived, muted (muteUntil) and the disappearing messages timer. - `GET /{phone_id}/getConversations/{conversation_id}`: Gets specific chat. You can find out who the conversation is between, your messages, and your phone. Each conversation also includes its chat state: favorite, pinned, archived, muted (muteUntil) and the disappearing messages timer. - `POST /{phone_id}/favoriteConversations/{conversation_id}`: Favorites a chat. Manage favorite status for a specific conversation. - `POST /{phone_id}/pinConversations/{conversation_id}`: Pins a chat. Pin or unpin a specific conversation. - `POST /{phone_id}/archiveConversations/{conversation_id}`: Archives a chat. Archive or unarchive a specific conversation. - `POST /{phone_id}/muteConversations/{conversation_id}`: Mutes a chat. Mute or unmute a specific conversation. - `POST /{phone_id}/clearConversations/{conversation_id}`: Clears a chat. Clear all messages from a specific conversation while keeping the conversation itself. - `POST /{phone_id}/disappearingConversations/{conversation_id}`: Sets disappearing messages timer. Sets the disappearing (ephemeral) messages timer for a one-to-one or group conversation (admin rights are required in groups). New messages in the chat are automatically deleted after the selected duration. This endpoint can also be used for groups; the **disappear** option in the group config endpoints only turns it on or off (true = 7 days). ### Channel Chat Operations - `POST /{phone_id}/createChannel`: Creates a channel. Channel creation feature. - `GET /{phone_id}/getChannels`: Lists all channels. Get all channels list - `GET /{phone_id}/getChannels/{conversation_id}`: Gets specific channel. Get a channel info - `POST /{phone_id}/deleteChannel`: Deletes a channel. Channel delete feature. - `POST /{phone_id}/channel/config`: Configures channel settings. You can change your channel name, description and image. - `POST /{phone_id}/channel/inviteCode`: Generates invite code. Channel invite code feature. - `GET /{phone_id}/channel/searchByCountry/{countryCode}`: Searches channels by country. Retrieves channels based on the specified country code. - `POST /{phone_id}/channel/search`: Searches channels by text. Retrieves channels based on the specified search text. - `GET /{phone_id}/channel/getMessages/{conversation_id}`: Gets specific channel. Retrieves messages from a specified newsletter channel conversation. - `POST /{phone_id}/channel/subscribe`: Subscribes to channel. Subscribe to a channel using the provided conversation ID. - `POST /{phone_id}/channel/unsubscribe`: Unsubscribes from channel. Unsubscribe from a channel using the provided conversation ID. - `POST /{phone_id}/channel/subscribeByInviteCode`: Subscribes using invite code. Subscribe to a channel using the provided invite code. - `POST /{phone_id}/channel/unsubscribeByInviteCode`: Unsubscribes using invite code. Unsubscribe from a channel using the provided invite code. - `GET /{phone_id}/channel/getInfoByInviteCode/{inviteCode}`: Gets channel info. Retrieve channel information using the provided invite code. - `GET /{phone_id}/channel/statistics/{conversation_id}`: Gets channel statistics. Retrieve detailed statistics and analytics for a WhatsApp channel. - `POST /{phone_id}/channel/demote`: Demotes channel admin. Demote an admin in a channel conversation. To learn conversation_id you can use /getChannels endpoint. - `POST /{phone_id}/channel/acceptAdminInvite`: Accepts channel admin invite. Accept an admin invitation for a channel. The invitation details are received through webhook when a channel admin invite is sent. - `POST /{phone_id}/channel/revokeAdminInvite`: Revoke admin request. Revokes admin rights request for the specified number in the given channel. ### Community Chat Operations - `POST /{phone_id}/createCommunity`: Creates a community. Create a new WhatsApp community. Communities allow you to organize related groups under one structure and provide centralized management. - `GET /{phone_id}/getCommunities`: Lists all communities. Get communities list. The id of each community matches the communityId field returned by getGroups for its sub-groups and announcement group. - `GET /{phone_id}/getCommunities/{conversation_id}`: Get community details. Retrieves details of a specific community. - `POST /{phone_id}/community/add`: Adds community participants. Add participants to a community conversation. To learn conversation_id you can use /getCommunities endpoint. - `POST /{phone_id}/community/remove`: Removes community participant. Remove a participant from a community conversation. To learn conversation_id you can use /getCommunities endpoint. - `POST /{phone_id}/community/promote`: Promotes community admin. Promote a participant in a community conversation. To learn conversation_id you can use /getCommunities endpoint. - `POST /{phone_id}/community/demote`: Demotes community admin. Demote a participant in a community conversation. To learn conversation_id you can use /getCommunities endpoint. - `POST /{phone_id}/community/addSubGroup`: Adds subgroups to community. Add subgroups to a community conversation. To learn conversation_id you can use /getCommunities endpoint. - `POST /{phone_id}/community/removeSubGroup`: Removes subgroups from community. Remove subgroups from a community conversation. To learn conversation_id you can use /getCommunities endpoint. - `POST /{phone_id}/community/revokeInvite`: Revokes community invite. Revoke and regenerate the invite link for a community. You can get the community information from the /getCommunities endpoint to use this function. - `GET /{phone_id}/community/unjoinedSubGroups/{conversation_id}`: Lists unjoined community sub-groups. Returns the sub-groups of a community that the connected number has not joined yet. conversation_id may be the community id or its announcement group id. - `POST /{phone_id}/community/joinSubGroup`: Joins a community sub-group. Joins a sub-group of a community the connected number is a member of. - **conversation_id**: the community id (or its announcement group id). - **subGroupId**: one of the ids returned by community/unjoinedSubGroups. - `POST /{phone_id}/deactiveCommunity`: Deactivates the community. You can deactivate the community you want with the help of this endpoint. ### Call Operations - `GET /{phone_id}/getCalls`: Lists call history. Lists the call history. - `GET /{phone_id}/getCalls/{callId}`: Gets call information. Returns information about an ongoing or finished call. Returns data:null when the call cannot be found. - `POST /{phone_id}/call/start`: Starts an outgoing call. Starts an outgoing call to the given number. The call rings the recipient and automatically ends when they answer or when the `duration` seconds elapse - it is an attention/ping call and carries no audio or conversation. - `POST /{phone_id}/call/reject`: Rejects the incoming call. You can reject incoming calls with the help of this endpoint. The callId must be obtained from the webhook notification received when a call comes in. - `POST /{phone_id}/call/createCallLink`: Creates a call link for voice or video calls. You can create a call link with the help of this endpoint. This link can be shared with others to join a call. ### Story Operations (Beta) - `POST /{phone_id}/createStory`: Creates a story. You can share media (image, video) your stories. This feature is currently in Beta, you may experience some media items not being shared. - `GET /{phone_id}/getStories`: Gets user's stories. Allows you to get information about the story you share - `GET /{phone_id}/contactsStories`: Gets contacts stories. You can access the stories of people in your contact list here - `GET /{phone_id}/contactsStories/{conversation_id}`: Gets contact's stories. View stories of a specific contact - `GET /{phone_id}/getStoryPrivacy`: Get story privacy. Returns who can see the stories of the connected number. setting: contacts (all contacts), allowList (only the numbers in allowList) or denyList (all contacts except denyList). Both lists are always returned; only the one matching setting is in effect. - `POST /{phone_id}/setStoryPrivacy`: Set story privacy. Sets who can see the stories of the connected number. The setting is permanent and is synced to the phone. - **setting** (string, required): contacts, allowList or denyList. - **contacts** (array of numbers): required for allowList and denyList, ignored for contacts. ### Queue Operations - `GET /queues`: Lists all queues. Returns information of all the phones registered to the given product with queue information. - `GET /{phone_id}/queue`: Gets phone's queue. Returns the queue info of the phone. - `GET /{phone_id}/purgeQueue`: Clears message queue. Clears phone's send message queue. ### Session Controlling Operations - `GET /{phone_id}/redeploy`: Redeploys phone instance. Redeploys docker instance of your phone. If your phone having any unexpected problems you can use this endpoint to reboot the instance. - `GET /{phone_id}/factoryreset`: Resets phone instance. Factory resets everything about phone instance to a new created instance state. This is a replacement for delete/create a new phone. Your phone instances can't be deleted anymore. This is implemented beacuse some of our customers were loosing their phone instance when their subscription(or trial) suspended. With the new system your phones would only set to disabled state and after you fix your subscription you can re-enable your phones either making a request(/screen etc.) to instance or from our control panel. - `GET /{phone_id}/config`: Gets phone config. Get phone specifig config for webhook and ack_delivery. If empty product webhook config would be used for delivery. - `POST /{phone_id}/config`: Set phone config. Set phone specifig config for webhook and ack_delivery. If empty product webhook config would be used for delivery. With this api you can define webhook for individual phones. ### Labels - `POST /{phone_id}/createLabel`: Creates a new label. Creates a new label in WhatsApp Business. - `GET /{phone_id}/getLabels`: Lists all labels. You can see all your defined tags in your WhatsApp Business - `GET /{phone_id}/getLabels/{label_id}`: gets labeled chats. You can list the conversations to which labels have been assigned - `GET /{phone_id}/label/getlabelColors`: Gets all available label colors. WhatsApp Business returns all color options given to you for your labels. - `POST /{phone_id}/label/edit`: Edits a label. Edits an existing label in WhatsApp Business. - `POST /{phone_id}/label/remove`: Deletes labels from the WhatsApp. Permanently deletes one or multiple labels from WhatsApp Business. This will remove the label itself from the Labels. - `POST /{phone_id}/addLabel`: Adds a label. In WhatsApp Business you can associate a label to a chat. - `POST /{phone_id}/removeLabel`: Removes a label from a chat. Removes a label from a specific chat without deleting the label itself from the system. ### Presences - `GET /{phone_id}/getPresences/{conversation_id}`: Gets presence status. Find out the current presence of the person. - `GET /{phone_id}/subscribePresences/{conversation_id}`: Subscribes to presence. You can subscribe to listen to the person's current presence. - `POST /{phone_id}/sendPresences`: Sends presence status. You can send presence to people. ### Business Catalog - `GET /{phone_id}/catalog/getProducts`: Lists catalog products. You can list catalog products in your WhatsApp Business - `GET /{phone_id}/catalog/getProduct/{product_id}`: Gets specific product. You can get catalog product in your WhatsApp Business - `POST /{phone_id}/catalog/addProduct`: Adds a product. You can add catalog product in your WhatsApp Business - `POST /{phone_id}/catalog/editProduct`: Edits a product. You can edit catalog product in your WhatsApp Business - `POST /{phone_id}/catalog/removeProduct`: Removes a product. You can remove catalog product in your WhatsApp Business - `POST /{phone_id}/catalog/addProductImage`: Adds product image. You can add image to catalog product in your WhatsApp Business - `POST /{phone_id}/catalog/removeProductImage`: Removes product image. You can remove image from catalog product in your WhatsApp Business. **Note:** The `imageUrl` parameter is deprecated due to URL inconsistencies. Please use `index` parameter instead. You can get the image index from the `additionalImageCdnUrl` array in the product response. - `GET /{phone_id}/catalog/getProducts/{conversation_id}`: Gets products by contact. You can list catalog products in other WhatsApp Business - `GET /{phone_id}/catalog/getCollections`: Lists product collections. You can list catalog collections in your WhatsApp Business - `GET /{phone_id}/catalog/getCollection/{collection_id}`: Gets specific collection. You can list products of catalog collections in your WhatsApp Business - `POST /{phone_id}/catalog/addCollection`: Adds a collection. You can add catalog collection in your WhatsApp Business - `POST /{phone_id}/catalog/editCollection`: Edits a collection. You can edit catalog collection in your WhatsApp Business - `POST /{phone_id}/catalog/removeCollection`: Removes a collection. You can remove catalog collection in your WhatsApp Business - `POST /{phone_id}/catalog/addCollectionProducts`: Adds product to collection. You can add product to catalog collection in your WhatsApp Business - WhatsApp assigns a new collection id after the products of a collection change. Use the `id` returned in the response for later requests. - `POST /{phone_id}/catalog/removeCollectionProduct`: Removes product from collection. You can remove product from catalog collection in your WhatsApp Business - WhatsApp assigns a new collection id after the products of a collection change. Use the `id` returned in the response for later requests. - `GET /{phone_id}/catalog/getOrders`: Lists orders from memory. Returns orders from memory (only orders received while instance is running). Orders are captured via webhook and stored temporarily. For persistent storage, save orders to your own database when received via webhook. - `GET /{phone_id}/catalog/getOrders/{orderId}`: Gets order details. Returns detailed information about a specific order including products, quantities and prices. - `GET /{phone_id}/catalog`: Gets catalog products. Returns catalog products info of the business profile. You only need productId to send products to users. - `GET /{phone_id}/getBusinessProfile`: Get business profile. Returns the business profile of the connected number (WhatsApp Business accounts). Empty fields are returned as null. businessHours.config keys are mon..sun; mode is one of open_24h, appointment_only or specific_hours, and specific_hours carries hours as [[openMinute, closeMinute]]. categories[].id and memberSinceText may be null depending on the session type. - `GET /{phone_id}/getBusinessProfile/{conversation_id}`: Get contact business profile. Returns the public business profile of another business number. Same response shape as getBusinessProfile. - `POST /{phone_id}/setBusinessProfile`: Set business profile. Updates the business profile of the connected number. Business accounts only. All fields are optional; only the given fields are changed. Sending an empty string clears a text field (e.g. "description": ""). website accepts at most 2 URLs. ## Blog and Guides (full text) ### WhatsApp API Send Message: Every Message Type with Examples URL: https://maytapi.com/blog/whatsapp-api-send-message Published: 2026-10-27, updated: 2026-10-27 Category: Guides > Send text, media, voice notes, locations, contacts, polls and reactions with one sendMessage call. Request bodies, to_number formats, acks and error codes. Every message your code sends through Maytapi goes through one endpoint: `POST /sendMessage`. The `type` field decides what the recipient sees, whether that's a line of text, a PDF, a voice note, a poll, or a reaction to a message they sent you. This guide covers how to **send WhatsApp messages with the API**: each one-to-one message type with a working request body, the recipient formats `to_number` accepts, what the response tells you, how to follow delivery, and which errors to handle. Maytapi connects your own WhatsApp number as a linked device, the same way WhatsApp Web does. Messages go out from that number, with no templates and no per-message fees. ### The sendMessage request Every request goes to the base URL `https://api.maytapi.com/api/PRODUCT_ID/PHONE_ID/` and carries your token in the `x-maytapi-key` header. The product ID and token are on the console's [Product ID & Token page](https://console.maytapi.com/settings/token). The phone ID is on the Phones page, or you can get it from `listPhones`. A text message is the simplest case: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/sendMessage" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "to_number": "905301234567", "type": "text", "message": "Hi Ayse, your order #1042 has shipped." }' ``` The same call in Node.js 18+, wrapped in a helper that the rest of this guide reuses: ```js async function send(body) { const res = await fetch( `https://api.maytapi.com/api/${PRODUCT_ID}/${PHONE_ID}/sendMessage`, { method: "POST", headers: { "x-maytapi-key": TOKEN, "Content-Type": "application/json" }, body: JSON.stringify(body) } ); return res.json(); } const r = await send({ to_number: "905301234567", type: "text", message: "Hello!" }); console.log(r.data.msgId); ``` The rest of this guide shows only the JSON body. Every example goes to the same endpoint. ### Who you can send to: to_number formats `to_number` accepts several formats. The API normalizes them before queuing the message: | You pass | Treated as | Example | |---|---|---| | Digits with country code | A person | `905301234567` (a leading `+` is accepted) | | Chat id ending in `@c.us` | A person | `905301234567@c.us` | | Group id ending in `@g.us` | A group | `120363012345678901@g.us` | | Privacy id ending in `@lid` | A person | `112893567721673@lid` | | `@` plus a WhatsApp username | A person | `@john_doe` or `@john_doe:1234` | Plain digits become `@c.us`. Digits with a dash (the old group id format, like `905301234567-1574073754`) become `@g.us`. Newer group ids are digits only, so always include `@g.us` for groups. Without it, the API treats the id as a phone number. To find group ids and post to groups, see [send messages to a WhatsApp group with the API](/blog/send-message-to-whatsapp-group-api). A `@lid` is the privacy id WhatsApp uses when it hides a phone number, mostly in groups. You can reply to one directly. To match it to a phone number, see [WhatsApp LID to phone number](/blog/whatsapp-lid-to-phone-number). For usernames, `to_number` must start with `@`. Without it, the value is read as a phone number. If the account protects its username with a key, add the 4-digit key after a colon. You only need the key for the first message from your number. WhatsApp doesn't reveal the person's number, so the chat id on acks and webhooks is `@c.us` when your account already knows the number and `@lid` when it doesn't. ### What the response means A successful call returns right away: ```json { "success": true, "data": { "chatId": "905301234567@c.us", "msgId": "50d8b950-a67f-11eb-91cc-d97af631df95" } } ``` `success: true` means the message was **accepted and added to your phone's sending queue**, not that it reached the phone. Your number works through the queue in order. `chatId` is the normalized recipient, and `msgId` is Maytapi's id for this message. Store it: you'll use it to match receipts, and you can pass it to `reply_to`, `delete`, `edit`, `forward` and the other actions below. The API translates it to the real WhatsApp message id. When you send media as base64, the response also includes `media_url`, the hosted copy of your file. To see how many messages are waiting, call `GET /queue`: ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/queue" \ -H "x-maytapi-key: $TOKEN" ``` It returns `{ "success": true, "stats": { "sending": 0 } }`. If the phone is disconnected, `sendMessage` still accepts messages. They wait in the queue and go out once the phone reconnects. A growing `sending` count usually means the phone needs attention. `GET /purgeQueue` drops everything still waiting. ### Text messages and replies ```json { "to_number": "905301234567", "type": "text", "message": "Your appointment is *tomorrow at 10:00*. Reply YES to confirm." } ``` WhatsApp formatting (`*bold*`, `_italic_`, `~strike~`) and emojis work as usual. Preview the formatting with the [text formatter](/tools/whatsapp-text-formatter). To quote a message, add `reply_to`. For a message you sent, use the `msgId` from the send response. For a received message, use its full serialized id from your webhook, like `false_905301234567@c.us_3EB0A81ECEA6D47F2F13`: ```json { "to_number": "905301234567@c.us", "type": "text", "message": "Yes, we deliver on Saturdays too.", "reply_to": "false_905301234567@c.us_3EB0A81ECEA6D47F2F13" } ``` As a quote, `reply_to` works with text, media, voice notes and polls. Reactions, edits and pins also take `reply_to`, but there it names the target message instead of quoting it. Mentions with `@number` and `@all` are mainly a group feature. The [group messaging guide](/blog/send-message-to-whatsapp-group-api) covers them. The documentation also lists optional `typing` and `duration` fields for text. Not every connection honors them, so don't build anything that depends on a typing indicator. ### Images, video and documents Every file type goes through `type: "media"`. Put a public URL in `message` and an optional caption in `text`: ```json { "to_number": "905301234567@c.us", "type": "media", "message": "https://example.com/images/order-1042.jpg", "text": "Here is your order, packed and ready." } ``` The file type comes from the URL, so a `.jpg` goes out as an image, an `.mp4` as a video and a `.pdf` as a document. For documents, set `filename` so the recipient sees a proper name: ```json { "to_number": "905301234567@c.us", "type": "media", "message": "https://example.com/files/invoice-1042.pdf", "filename": "invoice-1042.pdf", "text": "Invoice for order #1042" } ``` If the file isn't hosted anywhere, send it as a base64 data URI. The API uploads it and returns the hosted address as `media_url`: ```json { "to_number": "905301234567@c.us", "type": "media", "message": "data:image/png;base64,iVBORw0KG...", "text": "Your QR ticket" } ``` Two optional flags: `isGif: true` plays an MP4 as a looping GIF, and `isHd: true` asks for HD image quality on connections that support it. The [media messages API](/features/whatsapp-media-messages-api) page has more on files and formats. ### Voice notes, stickers and short videos `audio` sends a file as a voice note, the kind people record with the microphone button. Sending the same file with `type: "media"` delivers it as an attached audio file instead of a voice note bubble. OGG with the Opus codec is what WhatsApp itself uses for voice notes, so it's the safest format: ```json { "to_number": "905301234567@c.us", "type": "audio", "message": "https://example.com/audio/welcome.ogg" } ``` `sticker` sends an image as a sticker. Use a WebP file, as in the documentation example: ```json { "to_number": "905301234567@c.us", "type": "sticker", "message": "https://example.com/stickers/thanks.webp" } ``` `shortVideo` sends a video as a round video note, the circular clip WhatsApp plays inline. Keep it short, like one recorded in the app: ```json { "to_number": "905301234567@c.us", "type": "shortVideo", "message": "https://example.com/video/hello.mp4" } ``` ### Location, contacts and link previews A location pin needs `latitude` and `longitude`. `text` is the label under the pin: ```json { "to_number": "905301234567@c.us", "type": "location", "text": "Our Kadikoy store", "latitude": "41.093292", "longitude": "29.061737" } ``` There are two ways to share a contact. `vcard` sends a contact card you build yourself, with a `displayName` and a full vCard string: ```json { "to_number": "905301234567@c.us", "type": "vcard", "message": { "displayName": "Acme Support", "vcard": "BEGIN:VCARD\nVERSION:3.0\nFN:Acme Support\nTEL;TYPE=CELL:+905301234568\nEND:VCARD" } } ``` `contact` shares an existing WhatsApp account by chat id. Pass one id as a string, or several as an array: ```json { "to_number": "905301234567@c.us", "type": "contact", "message": ["905301234568@c.us", "905301234569@c.us"] } ``` `link` sends a URL with a rich preview card. The URL goes in `message`, and the line of text that goes with it goes in `text`: ```json { "to_number": "905301234567@c.us", "type": "link", "message": "https://example.com/track/1042", "text": "Track your delivery here" } ``` The preview's title, description and image come from the page's metadata, so pages with Open Graph tags look best. ### Polls ```json { "to_number": "905301234567@c.us", "type": "poll", "message": "Which delivery slot works for you?", "options": ["Morning", "Afternoon", "Evening"], "only_one": true } ``` Give at least two options. `only_one: true` allows a single choice; leave it out to allow several. Votes arrive on your webhook as `pollVoteUpdate` messages. ### Act on existing messages: react, edit, delete, forward, star, pin These types change a message that already exists. Each one points to its target with an id, either the `msgId` returned when you sent it or the serialized id of a received message. Comparison of sendMessage types: new content types like text, media, location, poll and contact versus action types like reaction, edit, pinMessage, delete, forward, star and unreact that target an existing message id. **React and unreact.** The emoji goes in `message` and the target in `reply_to`. To remove your reaction, send `unreact` with the target id in `message`: ```json { "to_number": "905301234567@c.us", "type": "reaction", "message": "👍", "reply_to": "false_905301234567@c.us_3EB0A81ECEA6D47F2F13" } ``` ```json { "to_number": "905301234567@c.us", "type": "unreact", "message": "false_905301234567@c.us_3EB0A81ECEA6D47F2F13" } ``` **Edit.** Put the new text in `message` and the message you're editing in `reply_to`. You can only edit your own messages, and WhatsApp only allows edits for a limited time after sending: ```json { "to_number": "905301234567@c.us", "type": "edit", "message": "Your order ships Monday (updated).", "reply_to": "50d8b950-a67f-11eb-91cc-d97af631df95" } ``` **Delete.** Pass the message id in `message`. Set `deleteForEveryone` explicitly: `true` removes the message for everyone, `false` only from your own chat: ```json { "to_number": "905301234567@c.us", "type": "delete", "message": "50d8b950-a67f-11eb-91cc-d97af631df95", "deleteForEveryone": true } ``` **Forward.** `to_number` is the destination and `message` is the id of the message to forward. The documentation examples add `forward_caption: true` so media keeps its caption: ```json { "to_number": "905301234569@c.us", "type": "forward", "message": "false_905301234567@c.us_3EB0A81ECEA6D47F2F13", "forward_caption": true } ``` **Star.** `action: 1` stars the message in `message`, `action: 2` unstars it. **Pin.** `pinMessage` takes the target in `reply_to`, `action: 1` to pin or `action: 2` to unpin, and a `duration` of 86400, 604800 or 2592000 seconds (1, 7 or 30 days): ```json { "to_number": "905301234567@c.us", "type": "pinMessage", "action": 1, "duration": 604800, "reply_to": "50d8b950-a67f-11eb-91cc-d97af631df95" } ``` ### Interactive, catalog, channel and group messages `sendMessage` handles a few more families, each covered in its own guide: - **Buttons, lists and carousels** (`buttons`, `list`, `carousel`): [WhatsApp interactive messages](/blog/whatsapp-interactive-messages-buttons-list-carousel). - **Products and your catalog** (`product`, `catalog`): [WhatsApp catalog API and orders](/blog/whatsapp-catalog-api-orders). - **Channels** (`channel_text`, `channel_media`, `channel_poll`, `channel_quiz` and more): [WhatsApp channel API](/blog/whatsapp-channel-api-polls-quizzes). - **Groups**, including mentions and `@all`: [send messages to a WhatsApp group](/blog/send-message-to-whatsapp-group-api). ### Message type reference | `type` | Required fields | Example value | |---|---|---| | `text` | `message` | `"Hello!"` | | `media` | `message` (URL or base64), optional `text`, `filename` | `"https://example.com/invoice.pdf"` | | `audio` | `message` (URL) | `"https://example.com/welcome.ogg"` | | `sticker` | `message` (URL) | `"https://example.com/thanks.webp"` | | `shortVideo` | `message` (URL) | `"https://example.com/hello.mp4"` | | `location` | `latitude`, `longitude`, optional `text` | `"41.093292"`, `"29.061737"` | | `vcard` | `message.displayName`, `message.vcard` | `"BEGIN:VCARD\n..."` | | `contact` | `message` (chat id or array) | `"905301234568@c.us"` | | `link` | `message` (URL), optional `text` | `"https://example.com/track/1042"` | | `poll` | `message`, `options`, optional `only_one` | `["Morning", "Evening"]` | | `reaction` | `message` (emoji), `reply_to` | `"👍"` | | `unreact` | `message` (message id) | `"false_905301234567@c.us_3EB0..."` | | `edit` | `message` (new text), `reply_to` | `"Updated text"` | | `delete` | `message` (message id), `deleteForEveryone` | `true` | | `forward` | `message` (message id) | `"50d8b950-a67f-..."` | | `star` | `message` (message id), `action` | `1` star, `2` unstar | | `pinMessage` | `reply_to`, `action`, `duration` | `604800` | To quote a message, add `reply_to` to a text, media, voice note or poll message. ### Track delivery with ack webhooks Because `sendMessage` only queues the message, delivery shows up later on your webhook. With ack delivery turned on (`ack_delivery` in `setWebhook`), each receipt arrives as a `type: "ack"` event. Flow of a sent message: POST /sendMessage, the message is queued and a msgId returned, the phone sends it, then an ack webhook with the same msgId reports delivered, reached, seen or played, or an error event reports a code. Each item in the event's `data` array has a `msgId` that matches the one `sendMessage` returned, plus an `ackCode`: 1 `delivered` (to WhatsApp's servers), 2 `reached` (the recipient's phone), 3 `seen`, 4 `played` (voice notes) and 5 `deleted`. Receipts can arrive more than once and out of order, so keep the highest `ackCode` per message. A message that can't be sent arrives as a `type: "error"` event instead, with a `code`, a `message`, and the original request in `data`. Its `data.id` is the same `msgId`, so you can mark the right message as failed. The [webhook events guide](/blog/whatsapp-webhook-events-guide) shows every payload and how to build a reliable handler. ### Errors you should handle Some problems are rejected right in the HTTP response: | Response `message` | Cause | |---|---| | `to_number is missing..!` | No `to_number` in the body | | `Invalid phone number` | `to_number` shorter than 10 characters (usernames excepted) | | `body.type is required.` | No `type` in the body | | `API token does not match! Please check your API token.` | Wrong or missing `x-maytapi-key` | | `[PHONE_ID] is not a valid phone instance id...` / `You dont own the phone[...]` | Wrong phone ID in the URL | Everything else is checked when your phone takes the message from the queue, so it comes back as an `error` webhook event: | `code` | `message` | What to do | |---|---|---| | `E01` | `The contact cannot be found!` | The number isn't on WhatsApp. Check the country code, or check numbers in advance with [bulk number check](/blog/bulk-whatsapp-number-check). | | `E01` | `Found Message Dupe.` | The duplicate filter dropped a repeated message (see below). It shares `E01` with the row above, so branch on `message` as well as `code`. | | `E02` | `Message type '...' is invalid...` | Typo in `type`. Compare against the table above. | | `E03` | `Message is empty.` | `message` is missing or empty. | | `M01` | `Can not found forwarded message.` | The message to forward isn't available to your phone. | | `F12` | `This WhatsApp number is temporarily restricted...` | WhatsApp has limited new conversations. See [ban prevention and Safe Mode](/blog/whatsapp-ban-prevention-safe-mode). | Group sends have their own codes, such as `OA2` when your number isn't a member. A phone that's disconnected doesn't produce an error: as described above, the message waits in the queue. `sendMessage` has a duplicate filter that drops the same content sent to the same chat several times in a short window. It protects your number from loops in your own code. If a repeat is intentional, add `"skip_filter": true` to that request rather than to every message. ### Next steps You now know every one-to-one message type and the field each one needs. To build on it: - Build a bot that receives messages and answers with these types: [WhatsApp bot with Node.js](/blog/whatsapp-bot-nodejs) or [WhatsApp bot with Python](/blog/whatsapp-bot-python). - Send to many people safely with queues, pacing and opt-outs: [WhatsApp broadcast API](/blog/whatsapp-broadcast-api). - Handle acks, errors and incoming messages: [WhatsApp webhook events guide](/blog/whatsapp-webhook-events-guide). - Look up every field and example body in the [API documentation](/whatsapp-api-documentation). ### WhatsApp Username API: Resolve, Message and Set @handles URL: https://maytapi.com/blog/whatsapp-username-api Published: 2026-10-23, updated: 2026-10-27 Category: Features > Resolve WhatsApp usernames to contacts, send messages to @handles, handle key-protected usernames and hidden numbers, and set your own username. WhatsApp is adding usernames: a unique handle like `@john_doe` that people can share instead of their phone number. For a business that changes two things. Customers may start giving you a handle on a signup form or in a support chat instead of a number, and some of them will never show you their number at all. This guide shows how to work with WhatsApp usernames through the Maytapi API: resolve a username to a contact, look up the username behind a phone number, send a message straight to `@handle`, deal with key-protected usernames, and read or set the username of your own connected number. ### What WhatsApp usernames are A WhatsApp username is a unique handle attached to an account. It is not the display name (the "pushname" people see at the top of a chat) and it is not the business name. One account has at most one username, and other people can start a chat with that handle without knowing the phone number behind it. Two details matter for an integration: - **Rollout.** WhatsApp is rolling usernames out gradually, as its [username announcement](https://blog.whatsapp.com/its-time-to-reserve-your-whatsapp-username) and [help article](https://faq.whatsapp.com/658755553162769) explain. Many accounts don't have one yet, and some can't set one yet. Treat the username as an optional field and keep the phone number or chat id as your primary key. - **Username key.** A user can protect their username with a 4-digit key. Someone who doesn't already chat with them needs both the handle and the key to reach them. The key is the user's own setting; you receive it from them, you can't look it up. Maytapi exposes usernames on these endpoints: | Task | Endpoint | Body or parameter | |---|---|---| | Username to contact | `GET /getContacts/usernameToPhone/{username}` | `john_doe` or `john_doe:1234` | | Phone number to username | `GET /getContacts/phoneToUsername/{number}` | `905301234567` | | Send to a username | `POST /sendMessage` | `to_number`: `@john_doe` or `@john_doe:1234` | | Read your own username | `GET /getProfileUsername` | | | Set your own username | `POST /setProfileUsername` | `username` | All of them live under `https://api.maytapi.com/api/PRODUCT_ID/PHONE_ID/` and use your token in the `x-maytapi-key` header. If you haven't connected a phone yet, start with the [getting started guide](/blog/whatsapp-api-getting-started). ### Resolve a username to a contact `usernameToPhone` takes a handle without the `@` and tells you who it belongs to. It's a lookup only: it doesn't open a chat or send anything. ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/getContacts/usernameToPhone/john_doe" \ -H "x-maytapi-key: $TOKEN" ``` When WhatsApp shares the phone number with your account, you get the full identity: ```json { "success": true, "data": { "id": "905301234567@c.us", "name": "John Doe", "phone": "905301234567", "lid": "123456789012345@lid", "username": "john_doe", "keyRequired": false } } ``` `id` is the chat id you can use anywhere else in the API, `phone` is the number, and `lid` is the person's WhatsApp privacy id. Handles are matched in lowercase, so `John_Doe` and `john_doe` resolve to the same account. There are three other outcomes, and your code should handle each one: | Result | What you get | What it means | |---|---|---| | Number hidden | `id` and `lid` are the `@lid` id, `phone` is `null` | The account exists, but WhatsApp doesn't reveal the number to you | | Key required | `data` holds only `username` and `keyRequired: true` | The username is key-protected and the key is missing or wrong | | Not found | `success: false` | No account uses this username | A key-protected username is not an error. The request succeeds and tells you a key is needed. Put the key after a colon in the same path segment and call again: ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/getContacts/usernameToPhone/john_doe:1234" \ -H "x-maytapi-key: $TOKEN" ``` With the right key you get the identity as above. With a wrong key you get `keyRequired: true` again. The colon format is the same one `sendMessage` uses, which keeps both calls consistent. ### When the phone number is hidden Usernames exist so people can talk without sharing their number, so a hidden number is a normal result, not a failure: ```json { "success": true, "data": { "id": "123456789012345@lid", "name": "John Doe", "phone": null, "lid": "123456789012345@lid", "username": "john_doe", "keyRequired": false } } ``` You can still message this person. Use the `@lid` id or the username as the recipient. What you can't do is match them against a phone-number column in your CRM. Store them under the `lid` (and the username as a label), not under an empty phone. If WhatsApp shares the number later, for example after the person writes to you, the [LID to phone number guide](/blog/whatsapp-lid-to-phone-number) shows how to resolve a `@lid` id with `lidToPhone` and merge the records. Flow for resolving a WhatsApp username: call usernameToPhone, retry with the key if keyRequired is true, then store by phone or by lid when phone is null ### Send a message to a WhatsApp username You don't have to resolve a username before messaging it. Pass the handle as `to_number` and Maytapi resolves it when the message is sent. The `@` at the start is required. Without it the value is treated as a phone number. ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/sendMessage" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "to_number": "@john_doe", "type": "text", "message": "Hi John, your order is ready for pickup." }' ``` For a key-protected username, add the key after a colon: ```json { "to_number": "@john_doe:1234", "type": "text", "message": "Hi John, your order is ready for pickup." } ``` You only need the key for the first message from your number. Once a chat exists, WhatsApp lets your number reach that username without it. Everything else in the request works as usual, so `media` and the other message types from the [send message guide](/blog/whatsapp-api-send-message) take a username recipient too. #### What the response and webhooks contain `sendMessage` queues the message and answers right away, before the username is resolved. Because of that, `chatId` in the response is exactly the `to_number` you sent: ```json { "success": true, "data": { "chatId": "@john_doe:1234", "msgId": "b2c1f0e0-a4d2-11f0-9c41-3b2f5d7e8a10" } } ``` Two consequences. First, don't use this `chatId` as the contact key in your database. Use `msgId` to follow the message, and take the real chat id from the ack webhook: its `chatId` is `905301234567@c.us` when your account knows the number, otherwise the `@lid` id. Second, if you sent a key, it's echoed in the response, so keep it out of logs you share. If the username can't be used, the message fails and your webhook receives a `type: "error"` event with the original request in `data`: | Code | Cause | |---|---| | `UN5` | The username is key-protected and the key is missing or wrong | | `E01` | No account uses this username | Neither is retried, so fix the input and send again. If you want to fail fast in a form or chatbot, call `usernameToPhone` first and only queue the message once `keyRequired` is `false`. The [webhook events guide](/blog/whatsapp-webhook-events-guide) has the full ack and error payloads. ### Find the username behind a phone number `phoneToUsername` goes the other way. Pass a number with country code and no special characters: ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/getContacts/phoneToUsername/905301234567" \ -H "x-maytapi-key: $TOKEN" ``` ```json { "success": true, "data": { "id": "905301234567@c.us", "name": "John Doe", "phone": "905301234567", "lid": "123456789012345@lid", "username": "john_doe" } } ``` When the person has no username, the call still succeeds and `username` is `null`. That's the common case during the rollout. `success: false` means the number itself couldn't be resolved. If you're checking a whole list of numbers, filter it with `checkPhones` first as shown in [bulk WhatsApp number check](/blog/bulk-whatsapp-number-check), then look up usernames only for numbers that are on WhatsApp. You often don't need a separate call. Incoming message webhooks include `user.username` when the sender has a username your number knows about, and `getContacts` entries carry a `username` field under the same rule. In both places the field is simply missing when there's no username, so check for it rather than expecting `null`. > **Note:** Look up usernames only for people who gave you the handle or already chat with you. Running large lists of guessed handles through `usernameToPhone` is enumeration: it exposes people who chose not to share their number, and bulk lookups from one number are the kind of activity that gets it restricted. ### Read and set your own username Your connected number can have a username too, which lets customers find your business by handle. Read it with: ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/getProfileUsername" \ -H "x-maytapi-key: $TOKEN" ``` ```json { "success": true, "data": { "username": "mybusiness_01", "state": "RESERVED", "pin": null } } ``` `state` is `ACTIVE` or `RESERVED`, and `null` together with `username: null` when no username is set. `pin` is the username key when WhatsApp returns one for your account; it's usually `null`. To set or change it, send the new handle: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/setProfileUsername" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "username": "mybusiness_01" }' ``` The username must be 3 to 35 characters long, use only letters, digits, underscore and dot, and contain at least one letter. WhatsApp also checks that it's available. On success the response has the same shape as `getProfileUsername`. On failure `success` is `false` with a `code`: | Code | Cause | |---|---| | `UN1` | `username` is empty or `null` | | `UN2` | The value breaks the format rules | | `UN3` | WhatsApp refused the change, for example because the name is taken or your account can't use usernames yet | `username` is the only body field. The endpoint doesn't set a username key and it doesn't remove a username; sending `null` or an empty string returns `UN1`. Manage the key and removal from the WhatsApp app on the phone. Checklist for handling WhatsApp usernames in an integration: optional field, @ prefix to send, colon for the key, store by lid when phone is null, real chat id from ack webhook ### Example: accept WhatsApp usernames on a signup form Here is a small Node.js handler for a form field that accepts either a phone number or a `@handle`. It resolves the handle, asks for the key when needed, and stores the contact under the best id available. ```js const base = `https://api.maytapi.com/api/${PRODUCT_ID}/${PHONE_ID}`; const headers = { "x-maytapi-key": TOKEN, "Content-Type": "application/json" }; async function resolveRecipient(input, key) { const value = input.trim(); if (!value.startsWith("@")) return { to_number: value }; // phone number const handle = value.slice(1) + (key ? `:${key}` : ""); const res = await fetch( `${base}/getContacts/usernameToPhone/${encodeURIComponent(handle)}`, { headers } ).then((r) => r.json()); if (!res.success) return { error: "No WhatsApp account uses this username." }; if (res.data.keyRequired) return { needKey: true }; return { to_number: res.data.id, // @c.us, or @lid when the number is hidden phone: res.data.phone, // null when hidden lid: res.data.lid, username: res.data.username, }; } async function welcome(input, key) { const r = await resolveRecipient(input, key); if (r.error || r.needKey) return r; // show an error or a "username key" field await saveContact(r); // key by phone if present, else by lid await fetch(`${base}/sendMessage`, { method: "POST", headers, body: JSON.stringify({ to_number: r.to_number, type: "text", message: "Welcome! Reply 1 to confirm your signup." }), }); return { ok: true }; } ``` Sending to the resolved `id` instead of the handle means the response `chatId` is already a real chat id, and you never store the key. Asking for a typed reply ("Reply 1 to confirm") keeps the confirmation step simple, and the [interactive messages guide](/blog/whatsapp-interactive-messages-buttons-list-carousel) covers list messages if you want something richer. ### Next steps Usernames are a new, optional way to reach people on WhatsApp. Treat them as an extra field, resolve them with `usernameToPhone`, send with an `@` prefix, and store contacts by `lid` when the number stays hidden. - Handle `@lid` ids and merge records with [WhatsApp LID to phone number](/blog/whatsapp-lid-to-phone-number). - See every message type you can send to a username in the [send message guide](/blog/whatsapp-api-send-message). - Read ack, error and incoming payloads in the [webhook events guide](/blog/whatsapp-webhook-events-guide). - Clean a number list before looking up usernames with [bulk WhatsApp number check](/blog/bulk-whatsapp-number-check). - Check every parameter in the [API documentation](/whatsapp-api-documentation). - Resolve your first username on the [3-day free trial](https://console.maytapi.com/register). ### Sync WhatsApp Business Labels with Your CRM URL: https://maytapi.com/blog/whatsapp-labels-api-crm-sync Published: 2026-10-20, updated: 2026-10-27 Category: Guides > Use the WhatsApp Labels API to create and assign WhatsApp Business labels from code and keep them in two-way sync with your CRM through label webhooks. Labels are how many WhatsApp Business teams track where a customer stands: "New lead", "Quote sent", "Paid", "Follow up". The trouble starts when the same information also lives in a CRM, and someone updates one but not the other. This guide shows how to use the WhatsApp Labels API in Maytapi to create and assign labels from code, and how to keep them in two-way sync with your CRM using label webhooks without creating update loops. ### What the WhatsApp Labels API can do Labels are a WhatsApp Business feature, so the paired number must be a WhatsApp Business account. With a regular WhatsApp account these endpoints have nothing to work with. | Task | Endpoint | Body or parameter | |---|---|---| | List labels | `GET /getLabels` | | | List chats with a label | `GET /getLabels/{label_id}` | | | Available colors | `GET /label/getlabelColors` | | | Create a label | `POST /createLabel` | `label_name`, `color` | | Rename or recolor | `POST /label/edit` | `label_id`, `label_name`, `color` | | Delete labels | `POST /label/remove` | `label_ids` array | | Label a chat | `POST /addLabel` | `label_id`, `conversation_id` | | Unlabel a chat | `POST /removeLabel` | `label_id`, `conversation_id` | All of them live under `https://api.maytapi.com/api/PRODUCT_ID/PHONE_ID/` and use your token in the `x-maytapi-key` header. On top of that, since October 2026 every label change on a chat is delivered to your webhook as a `label/add` or `label/remove` event. That is what makes a real two-way sync possible. ### Create labels that match your CRM stages Start by deciding which CRM field the labels mirror. Pipeline stage is the usual choice because it's a single value per contact and changes at clear moments. Workflow for creating WhatsApp labels that mirror CRM stages: pick the CRM field, fetch allowed colors, create labels and save the stage-to-ID mapping. Labels can only use WhatsApp's predefined colors, so fetch the list first: ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/label/getlabelColors" \ -H "x-maytapi-key: $TOKEN" ``` The response is an array of hex codes such as `#54C265` and `#F0B330`. Pass one of them as `color` when you create a label: ```js const base = `https://api.maytapi.com/api/${PRODUCT_ID}/${PHONE_ID}`; const headers = { "x-maytapi-key": TOKEN, "Content-Type": "application/json" }; const stages = [ { stage: "lead", label_name: "New lead", color: "#54C265" }, { stage: "quoted", label_name: "Quote sent", color: "#F0B330" }, { stage: "won", label_name: "Paid", color: "#A62C71" } ]; for (const s of stages) { const r = await fetch(`${base}/createLabel`, { method: "POST", headers, body: JSON.stringify({ label_name: s.label_name, color: s.color }) }); const { data } = await r.json(); console.log(s.stage, "->", data.id); } ``` Each response includes the label `id`. Save the mapping between CRM stage and label ID in your config or database; everything else in the sync uses the IDs, not the names. If labels already exist on the phone, `GET /getLabels` returns them with `id`, `name`, `colorIndex` and a `chatCount`, so you can map existing ones instead of creating duplicates. To rename or recolor later, use `label/edit` with the `label_id` (send a valid `color` as well, even if you only rename). `label/remove` deletes one or more labels permanently (`label_ids: ["1", "2"]`); the response lists which deletions succeeded and which IDs did not exist. ### Assign labels to chats Labeling a chat takes the label ID and the chat's `conversation_id`: ```js async function setLabel(conversationId, labelId, on = true) { const r = await fetch(`${base}/${on ? "addLabel" : "removeLabel"}`, { method: "POST", headers, body: JSON.stringify({ label_id: labelId, conversation_id: conversationId }) }); return r.json(); } await setLabel("905301234567@c.us", "3"); ``` `removeLabel` only detaches the label from that chat; the label itself stays. A chat can carry several labels, so if your labels represent mutually exclusive stages, remove the previous stage's label when you add the new one. `GET /getLabels/{label_id}` lists every chat that carries a label, with `id`, `name` and `phone`. That is your tool for an initial reconciliation: run it once per stage label and compare with the CRM before you switch on live sync. ### Receive label webhooks When a label is added to or removed from a chat, Maytapi sends a webhook message. The event has the usual message envelope, with an `info` message and a `label` object: ```json { "type": "message", "conversation": "905301234567@c.us", "message": { "type": "info", "subtype": "label/add", "fromMe": true, "label": { "id": "845838", "name": "Order complete", "color": "#F0B330", "colorIndex": 5 } } } ``` The payload also includes `user`, `conversation_name`, `timestamp` and the message `id`, like other webhook messages. See the [webhook events guide](/blog/whatsapp-webhook-events-guide) for the full envelope. Three details matter for a sync: - **`fromMe` is always `true`.** Labels belong to the account, so every label change counts as yours. The usual "ignore `fromMe`" filter in bot handlers would drop every label event; handle labels before that check. - **Events fire on real changes only.** Adding a label a chat already has, or removing one it doesn't have, produces no event. - **Your own changes can come back.** Labels set on the phone are reported, and so can be the ones your sync just set through the API. Treat every event as a possible echo of your own write. Occasionally `conversation` can be a privacy ID ending in `@lid` instead of a phone-number chat ID, when WhatsApp hasn't shared the number. The [LID to phone number API](/features/whatsapp-lid-to-phone-number-api) resolves those. ### Two-way sync between WhatsApp labels and your CRM The sync has two directions: 1. **CRM to WhatsApp.** When a deal moves to a new stage, call `removeLabel` for the old stage label and `addLabel` for the new one. 2. **WhatsApp to CRM.** When a `label/add` event arrives for a stage label, move the contact to that stage in the CRM. Checklist for a two-way label and CRM sync that avoids update loops: idempotent writes, echo suppression, conflict rules, logging and a shared store. Wired up naively, these two feed each other. The CRM moves a deal, your code adds a label, the webhook reports the label, your handler "updates" the CRM, the CRM fires its stage-change hook, and so on. #### Avoid update loops Two rules break the loop, and you should use both: **Make every write idempotent.** Before writing, compare with the current state and skip if nothing changes. If the webhook says "Paid" and the CRM deal is already "won", do nothing. If the CRM says "won" and the chat already has the Paid label, don't call `addLabel` (and even if you do, WhatsApp emits no event for an unchanged label). **Suppress your own echoes.** When your code calls `addLabel` or `removeLabel`, remember that change for a short time. When the matching webhook arrives, consume the record and skip the CRM update: ```js import express from "express"; const pending = new Map(); // key -> expiry timestamp const key = (conv, labelId, action) => `${conv}|${labelId}|${action}`; export async function labelFromCrm(conv, labelId, on) { pending.set(key(conv, labelId, on ? "add" : "remove"), Date.now() + 60_000); await setLabel(conv, labelId, on); } const app = express(); app.post("/maytapi/webhook", express.json(), async (req, res) => { res.sendStatus(200); // acknowledge first const event = req.body; const m = event.message; if (event.type !== "message" || m?.type !== "info" || !m.subtype?.startsWith("label/")) return; const action = m.subtype.split("/")[1]; // "add" or "remove" const k = key(event.conversation, m.label.id, action); if ((pending.get(k) ?? 0) > Date.now()) { pending.delete(k); // our own change coming back return; } const stage = stageForLabel(m.label.id); // your label-id -> stage map if (stage && action === "add") await crm.moveIfDifferent(event.conversation, stage); }); app.listen(3000); ``` `stageForLabel` and `crm.moveIfDifferent` stand for your own mapping and CRM client. Keep the pending store in Redis or your database if you run more than one instance. Decide upfront which side wins in a conflict (for example, the CRM wins for "won" and "lost", WhatsApp wins for early stages), and log every skipped echo while you test. > **Note:** Label events only reach you if your webhook is set. Configure it once with `setWebhook` or in the console, and have your handler return 200 right away, then do the CRM work. ### Practical tips - **Map by ID, display by name.** Names can be edited on the phone at any time; IDs stay stable. - **Ignore labels you don't sync.** Agents may use extra labels for their own purposes. Only act on the IDs in your mapping. - **Run a nightly reconciliation.** Webhooks can be missed during downtime. A scheduled job that compares `getLabels/{label_id}` with the CRM catches drift. - **Combine with a shared inbox.** If several people answer the same number, the [multi-agent platform](/multi-agent-platform-for-whatsapp) runs on the same connection, so your label sync keeps working while agents reply. - **Test on a separate test number first.** Two-way sync bugs are easiest to find with a handful of test chats. ### Next steps - Learn the full payloads for messages, acks and info events in the [webhook events guide](/blog/whatsapp-webhook-events-guide). - Set up and test your endpoint with the [webhook API](/features/whatsapp-webhook-api). - Check every label endpoint in the [API documentation](/whatsapp-api-documentation). ### WhatsApp API Getting Started: QR Code, First Message, Webhook URL: https://maytapi.com/blog/whatsapp-api-getting-started Published: 2026-10-14, updated: 2026-10-27 Category: Tutorials > Connect a WhatsApp number by QR code or pairing code, send your first message with curl, receive replies on a webhook and keep the phone connected. This guide takes you from an empty account to a working WhatsApp API integration in one sitting. You'll find your credentials, connect a phone with a QR code or a pairing code, send your first message with one `curl` call, receive replies on a webhook, and learn the few calls that keep the number connected. Every step is a plain HTTP request, so you can follow along in a terminal before you write any application code. Maytapi is an unofficial WhatsApp API. You link your own WhatsApp number as a linked device, the same way WhatsApp Web works, and drive it over REST. There are no message templates, no Meta approval and no per-message fees. Because the number is a normal WhatsApp account, WhatsApp's usual spam rules apply to it, and the last part of this guide covers what that means in practice. ### What you need before you start 1. **A Maytapi account.** The [free trial](https://console.maytapi.com/register) runs for three days and doesn't ask for a card. During the trial you can message up to 30 different numbers, so your first message can go to any number, including your own. The free Sandbox plan is limited to 5 recipient numbers. 2. **Your product ID and API token.** Both are on the **Product ID & Token** page in the console ([console.maytapi.com/settings/token](https://console.maytapi.com/settings/token)). The product ID goes in the URL and the token goes in the `x-maytapi-key` header of every request. 3. **A phone with WhatsApp.** Regular WhatsApp and WhatsApp Business both work. The phone needs the app installed and an internet connection while you link it. 4. **A terminal with `curl`.** Later steps use Node.js for the webhook receiver. Keep the credentials in environment variables so they never end up in your code or shell history files: ```bash export PRODUCT_ID="your-product-id" export TOKEN="your-token" ``` All phone-level endpoints live under `https://api.maytapi.com/api/PRODUCT_ID/PHONE_ID/`. A few account-wide endpoints, such as `listPhones` and `setWebhook`, sit one level up at `https://api.maytapi.com/api/PRODUCT_ID/`. In this guide endpoints are written short, like `GET /qrCode`. Getting started with the WhatsApp API in five steps: get the product ID and token, connect a phone by QR code or pairing code, send a first message, set the webhook and receive replies. ### Step 1: Add a phone and find its phone ID A "phone" in Maytapi is an instance that one WhatsApp number connects to. The simplest way to create one is the **+ Add New Phone** button on the console's Phones page. Your trial or plan sets how many phones a product can have. You can also add one from the API with `POST /addPhone` at product level. The body is optional. If you send a `number`, it's stored with the phone; otherwise the number is filled in when the phone connects: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/addPhone" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"number": "905301234567"}' ``` The response is the new phone record, and its `id` is your phone ID. > **Note:** If the product has already reached its phone limit, `addPhone` adds another paid phone to your subscription, which increases your bill. Check your plan's phone limit before you script it. To see every phone on the product, call `GET /listPhones`: ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/listPhones" \ -H "x-maytapi-key: $TOKEN" ``` Each item has an `id` (the phone ID), the `number`, a `status` and the phone's `data`, which includes its own `webhook` if one is set. Save the ID: ```bash export PHONE_ID="12345" ``` A new phone starts in `qr-screen` or `idle`. The status values you'll see are: | Status | Meaning | |---|---| | `loading` | The instance is starting or restoring a session | | `qr-screen` | Waiting for you to scan a QR code or enter a pairing code | | `active` | Connected and able to send and receive | | `idle` | Nobody linked a device in time, so the instance was parked. Requesting a QR code starts it again | | `conn-retry` | The connection dropped and is reconnecting | | `phone-error` | WhatsApp reports a problem on the phone side | | `disabled` | Turned off, for example when a subscription ends | | `logout` | The session was logged out, so the number has to be linked again | | `banned` | WhatsApp banned the number | ### Step 2: Connect the phone with a QR code `GET /qrCode` returns the current QR code as a PNG image, not JSON. Save it to a file and open it: ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/qrCode" \ -H "x-maytapi-key: $TOKEN" \ -o qr.png ``` If there's no QR code to show, for example because the phone is already connected, you get JSON instead with `success: false` and a `message`. If the phone was `idle`, the first request starts the instance and waits for it, so it can take a little longer than usual. `GET /screen` also returns a PNG. On some connections it's a screenshot of the whole session screen, and on others it returns the QR code while the phone is waiting to be linked. Use `qrCode` when you only need the code to show to a user, and `screen` when you want to see what the session is doing. Now scan it on the phone: 1. Open WhatsApp on the phone you're connecting. 2. Go to **Settings** (on iPhone) or the **⋮** menu (on Android) and tap **Linked devices**. 3. Tap **Link a device** and scan the QR code. QR codes rotate after a short time. If the scan fails, request `qrCode` again and scan the fresh one. The console shows the same code on the phone's **QR Code** tab and refreshes it for you. ![Maytapi console Phones page with a phone in qr-screen status, the QR Code, Screen and Connect With Number tabs, and actions such as Redeploy, Factory Reset and Logout](/images/showcase/whatsapp-api-qr-pairing-console.webp) *The Phones page: a new phone waits in qr-screen with its QR code, a pairing-code tab and the session actions.* ### Step 2 (alternative): Connect with a pairing code If you can't show a QR code to the phone, for example when the user is setting it up remotely, link it with a pairing code instead. Send the WhatsApp number in international format, without `+` or spaces: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/connectWithPhoneNumber" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"number": "905301234567"}' ``` The code comes back in `connectionResult`: ```json { "success": true, "connectionResult": "A5GD36H" } ``` On the phone, open **Linked devices**, tap **Link a device**, choose **Link with phone number instead** and type the code. The phone has to be in `qr-screen` when you call this endpoint, and the number you send must be the one you're linking. The console offers the same flow on the **Connect With Number** tab. | | QR code | Pairing code | |---|---|---| | Endpoint | `GET /qrCode` | `POST /connectWithPhoneNumber` | | Returns | PNG image | Code in `connectionResult` | | On the phone | Scan with the camera | Type the code | | Best for | You can see the phone | Remote or headless setup | ### Step 3: Check that the phone is connected `GET /status` asks the live session for its state: ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/status" \ -H "x-maytapi-key: $TOKEN" ``` The response has `success`, the connected `number`, and a `status` object. The fields to check are `loggedIn` (true once the phone is linked), `isQr` (true while it waits for a scan) and `loading`: ```json { "success": true, "status": { "loggedIn": true, "isQr": false, "loading": false, "number": "905301234567" }, "number": "905301234567" } ``` The real response has more fields, but these three are enough for a "connected or not" check. On a phone that's `idle`, `status` answers with `success: false` and asks you to scan a phone first. For a quick check across all your numbers, the `status` field in `listPhones` should read `active`. ### Step 4: Send your first message With the phone `active`, send a text message. `to_number` is the recipient in international format with no `+`: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/sendMessage" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"to_number": "905301234567", "type": "text", "message": "Hello from the Maytapi API!"}' ``` A successful call returns the chat it went to and an ID for the queued message: ```json { "success": true, "data": { "chatId": "905301234567@c.us", "msgId": "f3a9c2e0-4b7d-11ef-9a1c-0242ac120002" } } ``` `sendMessage` puts the message in the phone's sending queue and answers right away. The message then leaves from your phone, and its delivery and read receipts arrive on your webhook as `ack` events. If nothing arrives, check the phone's status and the message logs in the console. Images, documents, locations, polls, buttons and the other types use the same endpoint with a different `type`. They're all covered in [how to send WhatsApp messages with the API](/blog/whatsapp-api-send-message). ### Step 5: Receive messages on a webhook Incoming messages, receipts and phone status changes are posted as JSON to a webhook URL. Set it once for the whole product with `POST /setWebhook`: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/setWebhook" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"webhook": "https://example.com/maytapi/webhook"}' ``` `setWebhook` posts a test text message to the URL to check it. If your server doesn't accept it, the call answers `success: false` with an error message, so start your receiver first. You can also paste the URL on the console's **Developers > Webhook** page. If one number should report to a different server, give that phone its own URL with `POST /config`. Phones without their own URL use the product webhook: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/config" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"webhook": "https://client-a.example.com/webhook"}' ``` An incoming text message looks like this: ```json { "type": "message", "product_id": "your-product-id", "phone_id": "12345", "user": { "id": "905301234567@c.us", "name": "Customer", "phone": "905301234567" }, "message": { "id": "3EB081AF26FADE6416D8", "type": "text", "text": "Hi!", "fromMe": false }, "conversation": "905301234567@c.us", "receiver": "905307654321", "timestamp": 1574081887, "reply": "https://api.maytapi.com/api/your-product-id/12345/sendMessage" } ``` `conversation` is the chat to answer in, and `reply` is the `sendMessage` URL for the phone that received the message. A minimal Express receiver answers `200` first and only then looks at the event. It also skips messages with `fromMe: true`, which are messages sent from your own number: ```js import express from "express"; const app = express(); app.post("/maytapi/webhook", express.json(), (req, res) => { res.sendStatus(200); // acknowledge first const event = req.body; if (event.type === "status") console.log("phone", event.phone_id, event.status); if (event.type !== "message" || event.message.fromMe) return; console.log(`${event.user.phone}: ${event.message.text}`); }); app.listen(3000); ``` Anything slow, like a database write or an AI call, belongs after the response or on a job queue. While you develop locally, expose port 3000 with a tunnel such as `cloudflared` or `ngrok` and register the HTTPS URL it prints. Every event type, including acks, group events and calls, is described in the [webhook events guide](/blog/whatsapp-webhook-events-guide). ### Step 6: Keep the phone connected A linked device can be logged out from the phone, lose its connection or need a restart. Your webhook tells you when that happens with a `status` event: ```json { "type": "status", "product_id": "your-product-id", "phone_id": 12345, "status": "qr-screen" } ``` The event is sent when the phone's status changes, and `status` is one of the values from the table in step 1, or `logout` when the session was logged out. Alert someone on `qr-screen`, `idle`, `logout` or `phone-error`, because the number can't send until it's linked again. A switch back to `active` means it's healthy. Checklist for keeping a WhatsApp API phone connected: watch status webhooks, relink on qr-screen or logout, redeploy when stuck, factory reset only as a last resort, and warm up new numbers. Three session calls cover most problems. All three are phone-level `GET` requests that return `success` and a short `message`: | Call | What it does | When to use it | |---|---|---| | `GET /redeploy` | Restarts the phone's instance and keeps the session | The phone seems stuck or stops responding | | `GET /logout` | Logs the number out and returns the phone to the QR screen | You want to link a different number, or unlink this one | | `GET /factoryreset` | Recreates the instance, clears the stored session and number, and empties the phone's sending queue | Nothing else helps | ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/redeploy" \ -H "x-maytapi-key: $TOKEN" ``` > **Note:** `factoryreset` wipes the WhatsApp session and drops messages still waiting in the queue. You'll have to scan a new QR code afterwards. Try `redeploy` first. After a logout or a factory reset, connect again with step 2. The phone ID stays the same, so your code and webhook settings don't change. **Avoid bans.** The number is a real WhatsApp account, and WhatsApp restricts accounts that look like spam. Replying to people who wrote to you first carries little risk. Sending to many new contacts in a burst carries a lot. Let a new number warm up for a few days before it sends anything proactive, and read [how Safe Mode prevents WhatsApp bans](/blog/whatsapp-ban-prevention-safe-mode). Before a campaign, [check which numbers are on WhatsApp](/blog/bulk-whatsapp-number-check) so you don't send to numbers that don't exist. ### Next steps You now have a connected number that sends messages and receives replies on your server. From here you can: - Send media, locations, polls and buttons with [the sendMessage guide](/blog/whatsapp-api-send-message). - Build a command bot in [Node.js](/blog/whatsapp-bot-nodejs) or [Python](/blog/whatsapp-bot-python). - Connect WhatsApp to your tools without code using [n8n](/blog/whatsapp-n8n-automation), or let Claude use your number through the [Maytapi MCP server](/blog/whatsapp-mcp-server-claude). - Work with groups: [create a group](/blog/create-whatsapp-group-api) and [send messages to a group](/blog/send-message-to-whatsapp-group-api). - Sync conversations to a CRM with the [labels API](/blog/whatsapp-labels-api-crm-sync). - Try every endpoint from the [Postman collection](/whatsapp-postman-documentation), or look up bodies and responses in the [API documentation](/whatsapp-api-documentation). ### WhatsApp API Pricing After October 2026: What a Support Bot Costs URL: https://maytapi.com/blog/whatsapp-api-pricing-october-2026 Published: 2026-10-06, updated: 2026-10-27 Category: Business > Meta now charges for service replies past 1,000 a month and for utility templates in the 24h window. Worked cost examples vs a flat per-number API. On October 1, 2026, Meta started charging again for two kinds of WhatsApp Business Platform messages that had been free: the replies a business sends inside the 24-hour customer service window, and utility templates sent in response to a customer. For a support bot or an online store that mostly answers customers, that can turn a WhatsApp bill that used to be close to zero into a real monthly line item. This post explains what changed, shows how to estimate your own cost with Meta's published rates, and compares that with a flat-priced API like Maytapi, including the cases where the official API is still the better choice. We run Maytapi, an unofficial WhatsApp API, so we have an interest here. Every Meta fact below comes from Meta's own [WhatsApp pricing documentation](https://developers.facebook.com/docs/whatsapp/pricing) and its rate cards effective October 1, 2026. Rates change, so check them yourself before you budget. Maytapi's own current prices are always on our [pricing page](/whatsapp-api-pricing). ### What changed on October 1, 2026 Meta has billed the WhatsApp Business Platform (Cloud API) per delivered message since July 1, 2025. The price depends on the message category (marketing, utility, authentication, service) and on the recipient's country calling code, which Meta calls the market. Messages from customers to your business are not charged. The October 2026 update changed three things: - **Service messages are charged again, after a free tier.** Service messages are the free-form, non-template replies you send while a customer service window is open. They had been free since November 1, 2024. Since October 1, 2026, each business phone number gets **1,000 free delivered service messages per month**. From the 1,001st, every delivered service message is charged. Unused free messages do not roll over. - **Utility templates inside the service window are charged.** Order confirmations, shipping updates and similar utility templates sent in reply to a customer had been free since July 1, 2025. They are now billed at the normal utility rate, with no free allowance. - **Rate card updates.** Meta changed 40 rates across markets, moved several countries out of "Rest of" regions into their own rates, and added authentication-international rates in more markets. Meta also asked businesses to add a payment method by September 30, 2026. Without one, Meta delivers service messages up to the free tier and stops delivering them after that. A few things still cost nothing on the official side: incoming messages, messages in a free entry point window (opened when someone contacts you from a Click-to-WhatsApp ad or a Facebook Page call-to-action button), and, through December 31, 2027, service messages from eligible government and non-profit organizations. The full list is in [Meta's pricing documentation](https://developers.facebook.com/docs/whatsapp/pricing). ### How to estimate your Meta bill For one business phone number, the monthly cost on the Cloud API comes down to this: ``` Meta cost = max(0, service messages - 1,000) x service rate + utility templates x utility rate + marketing templates x marketing rate + authentication templates x auth rate (+ your Business Solution Provider's fees, if you use one) ``` Flow showing how a Meta WhatsApp bill adds up: service replies use the 1,000 free monthly tier first, then each extra service message, every utility template and every marketing template is charged per delivered message at the recipient market's rate. Each rate is the one for the recipient's market. Only delivered messages count. Utility and authentication messages get [volume tier discounts](https://developers.facebook.com/docs/whatsapp/pricing) at high monthly volumes, starting above 100,000 utility messages in the UK and Germany, for example. Service and marketing messages have no volume tiers. Here are the October 1, 2026 USD list rates for four markets, taken from Meta's rate card. In these four markets the service rate is the same as the utility rate. | Market (Meta rate card) | Service | Utility | Marketing | |---|---|---|---| | United Kingdom | $0.0220 | $0.0220 | $0.0635 | | Germany | $0.0550 | $0.0550 | $0.1365 | | North America | $0.0034 | $0.0034 | $0.0250 | | India | $0.0014 | $0.0014 | $0.0118 | These are illustrative. Meta publishes rates in several currencies, updates them on a regular calendar, and your actual charge depends on where each recipient's number is registered. ### Worked example 1: a support bot Assumptions: - One business phone number. - 2,000 customer support chats per month, all started by the customer. - The bot and agents send 5 replies per chat on average: 10,000 service messages per month. - No templates, no ad-driven chats, no Business Solution Provider fees. Meta charges for 10,000 - 1,000 = 9,000 service messages. | Market | Billable service messages | Meta cost per month | Maytapi per month | |---|---|---|---| | United Kingdom | 9,000 x $0.0220 | $198.00 | $24 (annual) or $29 (monthly) | | Germany | 9,000 x $0.0550 | $495.00 | $24 (annual) or $29 (monthly) | | North America | 9,000 x $0.0034 | $30.60 | $24 (annual) or $29 (monthly) | | India | 9,000 x $0.0014 | $12.60 | $24 (annual) or $29 (monthly) | Before October 1, the same bot cost nothing in Meta fees in every row, because all of these messages were free service replies. Now a UK support desk at this volume pays about $198 a month, and a German one about $495. The table also shows where a flat price does not help. In North America the two are close, and in India the Meta bill is lower than a Maytapi subscription at this volume. #### At what volume does a flat price win? Volume decides it. Below 1,000 service replies a month, a number pays Meta nothing, so a small desk that only answers a few messages a day is cheaper on the Cloud API. Above the free tier, each reply adds the service rate, and the point where the Meta bill passes $24 (one Maytapi number on the annual plan) is `1,000 + 24 ÷ service rate`: | Market | Service rate | Maytapi is cheaper above | |---|---|---| | Germany | $0.055 | ~1,450 replies a month | | UK | $0.022 | ~2,100 replies a month | | North America | $0.0034 | ~8,100 replies a month | | India | $0.0014 | ~18,100 replies a month | These thresholds count service replies only. Utility and marketing templates are billed from the first message at higher rates, so a business that also sends order updates or campaigns reaches the break-even point much sooner, as the next example shows. ### Worked example 2: an e-commerce store Assumptions: - One business phone number. - 50,000 outgoing messages per month: - 30,000 service replies (questions about sizes, stock, returns); - 15,000 utility templates (order confirmations and shipping updates); - 5,000 marketing templates (back-in-stock and promotional messages to opted-in customers). - Utility volume stays below the first volume tier, so list rates apply. | Market | Service (29,000 billable) | Utility (15,000) | Marketing (5,000) | Meta total per month | |---|---|---|---|---| | United Kingdom | $638.00 | $330.00 | $317.50 | $1,285.50 | | Germany | $1,595.00 | $825.00 | $682.50 | $3,102.50 | | North America | $98.60 | $51.00 | $125.00 | $274.60 | | India | $40.60 | $21.00 | $59.00 | $120.60 | On Maytapi, the same number costs $24 a month on the annual plan or $29 billed monthly, with unlimited messages. A store sending 50,000 messages a month may want to spread that load over two or three numbers to keep each one healthy (more on that below). Even at three numbers, that is $72 a month on the annual plan, which is still well below the Meta total in the UK and Germany and below it in North America and India too. The comparison counts fees only. The 5,000 marketing messages are the part least suited to an unofficial number, because promotional sends carry the most ban risk, and a restricted number costs you its chat history and the time to re-link and warm up a new one. ### Why a flat price is simpler to plan The bigger difference is not only the total. It is how much you have to track to know the total. Comparison of Meta Cloud API pricing, billed per delivered message by category and country after a 1,000 message service free tier, and Maytapi pricing, a flat fee per number with unlimited messages and no templates. On the Cloud API, your bill depends on how many replies your bot sends per conversation, which category Meta assigns to each template, which countries your customers are in, whether a chat started from an ad, and which volume tier you reached. A chattier bot or a new market changes next month's cost. That is a reasonable design for Meta's platform, but it means finance and engineering both have to watch it. With Maytapi you pay per connected number, and that is the whole bill: - **One price per number.** $24 per number per month on the annual plan, $29 on the monthly plan. A reply, a reminder and a promotion cost the same: nothing extra. - **No templates or approval.** You can start a conversation with a free-form message. There is no template review and no category to be billed under. - **No 24-hour window.** You can reply to a customer the next day or a week later without switching to a template. - **Your existing number.** You scan a QR code with the phone that already runs your WhatsApp or WhatsApp Business number. The phone keeps working alongside the API. - **More than one-to-one chat.** Groups, channels, communities, status, labels and the catalog are available on the same number. Sending a message is a single call to `POST /sendMessage` with your `PRODUCT_ID`, `PHONE_ID` and `TOKEN`. Our [WhatsApp Business API alternative](/whatsapp-business-api-alternative) page compares setup, features and risk side by side. ### The trade-offs A lower, flat price comes with real differences. You should weigh them before you move anything. **Maytapi is an unofficial API.** It connects your number to WhatsApp as a linked device, the same way WhatsApp Web does. Meta does not operate, approve or endorse it, and WhatsApp does not endorse third-party clients that automate personal or WhatsApp Business app accounts. If a number sends cold outreach, identical bulk messages, or a sudden burst of volume, WhatsApp can restrict or ban it, and no provider can reverse that. You are responsible for consent and for following WhatsApp's terms. That risk is manageable for most support and order-update traffic, because the customer usually wrote to you first. It needs more care for outbound campaigns: - Message people who opted in, and warm up new numbers gradually. Our guide to [avoiding WhatsApp bans with Safe Mode](/blog/whatsapp-ban-prevention-safe-mode) covers pacing and limits. - Clean your list first. A [bulk WhatsApp number check](/blog/bulk-whatsapp-number-check) removes numbers that are not on WhatsApp before you send anything. - Spread heavy volume across several numbers rather than pushing one number hard. **The official Cloud API is the better choice when:** - you need the verified Official Business Account badge, or a vendor your legal or compliance team knows is operated by Meta; - you work in a regulated industry, such as banking, insurance or healthcare, where an unofficial channel is not acceptable; - you send very high volumes of opt-in marketing and can live with template review and per-message billing; - your customers are mainly in low-rate markets and you mostly reply to inbound messages, as in the India and North America rows above; - you rely on Click-to-WhatsApp ads, whose free entry point window can make ad-driven conversations free; - losing a number would be unacceptable for your business. Some companies use both: the official API for regulated or large-scale marketing, and Maytapi on a separate number for support, groups and day-to-day automation that has become expensive or impossible on the Cloud API. ### How to switch or start If the numbers above look like your situation, you can test the flat-price route without moving your existing setup: 1. Create an account and connect a number by scanning the QR code. The [getting started guide](/blog/whatsapp-api-getting-started) walks through it, and the free trial runs for 3 days with no card. 2. Send a first message with `POST /sendMessage` using the examples in [how to send a WhatsApp message with the API](/blog/whatsapp-api-send-message). 3. Point your webhook at your bot or helpdesk so incoming messages reach the same logic that handles your Cloud API traffic today. 4. Compare a month of real volume against your Meta invoice, using the formula above and your own markets. Keep in mind that a number registered to the Cloud API is set up differently from one used in the WhatsApp app. Moving the same number between the two takes planning. Starting the trial on a second number is the low-risk way to compare. ### Next steps - Check your own Meta rates in the [WhatsApp pricing documentation](https://developers.facebook.com/docs/whatsapp/pricing) and plug them into the formula above. - See [Maytapi pricing](/whatsapp-api-pricing): $24 per number per month billed annually, $29 billed monthly, unlimited messages. - Read the [WhatsApp Business API alternative](/whatsapp-business-api-alternative) page if you are still deciding. - [Start a free trial](https://console.maytapi.com/register) and connect a test number in a few minutes. ### Build a WhatsApp AI Agent with n8n and Claude URL: https://maytapi.com/blog/whatsapp-ai-agent-n8n-claude Published: 2026-09-29, updated: 2026-10-27 Category: Tutorials > Connect the Maytapi n8n node to an AI Agent with a Claude model and per-chat memory, with loop protection, label-based human handoff and rate limits. A WhatsApp AI agent that answers customers in natural language used to mean writing a webhook server, a prompt pipeline and a memory store. With n8n you can wire the same thing together visually: the Maytapi community node receives the message, n8n's AI Agent node asks a Claude model for a reply, and the Maytapi node sends it back. This guide builds that workflow step by step and adds the guardrails a real number needs: no reply loops, no answers in groups, a clean human handoff using WhatsApp labels, and per-chat rate limits. ### How the WhatsApp AI agent workflow fits together The finished workflow has seven nodes in a straight line, with one branch at the end: | # | Node | Job | |---|---|---| | 1 | **Maytapi Trigger** (Message Event) | Starts the workflow for every message on your number | | 2 | **Filter** | Drops your own messages, group messages and non-text messages | | 3 | **Maytapi** (Label > Get Labeled Chats) | Reads which chats a human has taken over | | 4 | **IF** | Stops if this chat is handed off | | 5 | **Code** | Per-chat rate limit | | 6 | **AI Agent** + Anthropic Chat Model + memory | Writes the reply | | 7 | **Maytapi** (Message > Send Message) | Sends the reply, or labels the chat for a human | Flow of the n8n workflow: Maytapi Trigger receives the message, a filter drops fromMe and group messages, a label check skips handed-off chats, the AI Agent with a Claude model writes the reply, and the Maytapi node sends it You need a self-hosted n8n instance with the `@maytapi/n8n-nodes-maytapi` community node installed, a Maytapi product with a paired number, and an Anthropic API key. If you haven't installed the node yet, the [n8n automation guide](/blog/whatsapp-n8n-automation) covers installation and credentials. The handoff step uses labels, which only exist on WhatsApp Business accounts; on a regular WhatsApp account, skip steps 3 and 4. ### Step 1: Receive messages with the Maytapi Trigger Add a **Maytapi Trigger** node, pick your Maytapi API credential and leave **Events** on **Message Event** only. ACK, group, call and story events would only create executions you then have to filter out. When the workflow is activated, the trigger calls `setWebhook` and registers its n8n URL as your product's webhook. For a message event it outputs these fields next to the raw payload in `body`: | Field | Source in the webhook | Example | |---|---|---| | `conversation` | `conversation` | `905301234567@c.us` | | `fromNumber`, `fromName` | `user.phone`, `user.name` | `905301234567`, `Ayse` | | `messageType` | `message.type` | `text` | | `messageText` | `message.text` | `Do you ship to Izmir?` | | `fromMe` | `message.fromMe` | `false` | | `messageId`, `timestamp` | `message.id`, `timestamp` | | | `receiver` | `receiver` | your own number | The raw `body` also carries `phone_id` and `product_id`, which you will need if the product has more than one phone. The full payload is documented in the [webhook events guide](/blog/whatsapp-webhook-events-guide). > **Note:** The trigger sets the product-level webhook. If another system already receives this product's webhooks, activating the workflow replaces that URL, and deactivating it does not restore the old one. ### Step 2: Filter out your own messages and groups This is the most important node in the workflow. Message events fire for messages your number sends, too, with `fromMe: true`. That includes the replies this workflow sends. Without a filter, the agent reads its own answer as a new message, answers it, reads that answer, and keeps going. Add a **Filter** node (or an IF node with only the true branch connected) with three conditions joined by AND: | Value | Condition | Compare to | |---|---|---| | `{{ $json.fromMe }}` | Boolean is false | | | `{{ $json.conversation }}` | String does not end with | `@g.us` | | `{{ $json.messageType }}` | String equals | `text` | The group check keeps the bot from replying to every message in every group the number belongs to; group chat IDs end in `@g.us`. The type check matters because `messageText` is filled from `message.text`, which text messages have. Images, videos and documents carry their caption in `message.caption` instead, so `messageText` is empty for them and the model would receive nothing. If you want media handled, add a second branch that sends a fixed reply such as "I can only read text, a colleague will look at your file." The `fromMe` filter has a useful side effect: when a staff member answers from the WhatsApp app on the phone, those messages are also `fromMe: true`, so the bot never mistakes a human reply for a customer message. ### Step 3: Check for a human handoff with labels Sooner or later a customer needs a person. Labels are a simple way to mark that: when a chat carries a label such as "Human", the bot stays quiet; when the agent removes the label in the WhatsApp Business app, the bot takes over again. Create the label once, in the app or with the Maytapi node's **Label > Create Label** operation, and note its ID from **Label > Get Labels**. Then add a **Maytapi** node after the filter: - Resource: *Label* - Operation: *Get Labeled Chats* - Label ID: the ID of your "Human" label This calls `GET /getLabels/{label_id}` and returns one item with a `data` array of chats that carry the label, each with `id`, `name` and `phone`. Follow it with an **IF** node that continues only when the chat is not in that list: ```js {{ !$json.data.some(c => c.id === $('Maytapi Trigger').first().json.conversation || c.phone === $('Maytapi Trigger').first().json.fromNumber) }} ``` Comparing the phone as well as the chat ID covers the case where the incoming `conversation` is a privacy ID ending in `@lid` while the label list shows the phone-number form. The [LID guide](/blog/whatsapp-lid-to-phone-number) explains when that happens. Because the Get Labeled Chats node replaces the item, later nodes read message fields from the trigger with `$('Maytapi Trigger').first().json...`, as in the expression above. Each execution handles one webhook, so `.first()` always points at the incoming message, and it keeps working after Code nodes that don't carry n8n's paired-item links. ### Step 4: Add a per-chat rate limit An AI agent replies to everything it receives, so a customer who sends ten short messages in a row gets ten answers, and ten model calls on your bill. A small **Code** node (mode *Run Once for All Items*) caps that with a cooldown per conversation: ```js const data = $getWorkflowStaticData('global'); data.lastReply = data.lastReply || {}; const trig = $('Maytapi Trigger').first().json; const now = Date.now(); const last = data.lastReply[trig.conversation] || 0; // at most one AI reply per chat every 5 seconds if (now - last < 5000) return []; data.lastReply[trig.conversation] = now; return [{ json: trig }]; ``` Returning an empty array ends the execution for that message. Workflow static data is saved only for active (production) executions, not when you test in the editor, so test the cooldown with the workflow activated. Know the trade-off: a message that arrives inside the 5-second window is skipped, not answered later, and it doesn't reach the agent's memory either. Keep the window short so a customer who adds a detail can simply write again. Static data is also not a lock: two messages that arrive within milliseconds can both pass the check. On a busy number, keep the cooldown in Redis instead (for example a key per conversation set only if it doesn't exist, with a 5-second expiry). Rate limiting the bot is also about how WhatsApp sees your number. Maytapi is an unofficial API that runs on your own WhatsApp number, and a bot that answers instantly and at high volume looks automated. Replying only to people who wrote first, as this workflow does, is the safe pattern; the [ban prevention guide](/blog/whatsapp-ban-prevention-safe-mode) covers the rest. ### Step 5: Configure the AI Agent with Claude Add n8n's **AI Agent** node and connect two sub-nodes to it. #### Anthropic Chat Model Attach an **Anthropic Chat Model** sub-node, create an Anthropic credential with your API key, and pick a Claude model from the list. A smaller, faster model is usually enough for customer support replies; move up to a larger one if your answers need more reasoning. Keep the maximum tokens modest: WhatsApp replies should be a few sentences. #### Memory keyed by conversation Without memory, every message starts a fresh conversation and the agent forgets what the customer said a minute ago. Attach a **Simple Memory** sub-node and set: - Session ID: *Define below* - Key: `{{ $('Maytapi Trigger').first().json.conversation }}` - Context Window Length: around 10 messages Keying the memory on `conversation` gives each WhatsApp chat its own history, so two customers never see each other's context. Simple Memory lives in the n8n process; for history that survives restarts or runs in queue mode, use a Postgres or Redis chat memory sub-node with the same key. #### Prompt and system message In the AI Agent node, set the prompt source to *Define below* and the prompt to: ```js {{ $('Maytapi Trigger').first().json.messageText }} ``` Then add a system message under **Options**. Keep it specific to your business and include an explicit escalation rule: ```text You are the WhatsApp assistant for Example Store. Answer in the customer's language, in 1-3 short sentences. Plain text only: no markdown headings or tables. Only answer questions about orders, shipping, returns and products. If the customer asks for a human, is angry, or asks about refunds, reply with exactly: HANDOFF ``` The `HANDOFF` keyword gives the next node something deterministic to check, instead of trying to interpret free text. ### Step 6: Send the reply, or hand off to a human Add a **Switch** or **IF** node on `{{ $json.output }}` (the AI Agent's answer). **When the output is a normal answer**, send it with a Maytapi node: - Resource: *Message*, Operation: *Send Message* - Message Type: *Text* - To Phone Number: `{{ $('Maytapi Trigger').first().json.conversation }}` - Message: `{{ $json.output }}` - Reply To Message ID (optional): `{{ $('Maytapi Trigger').first().json.messageId }}` to quote the customer's message Under the hood this is a regular `sendMessage` call, the same one you would make from code: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/sendMessage" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "to_number": "905301234567@c.us", "type": "text", "message": "Yes, we ship to Izmir. Delivery takes 2-3 business days.", "reply_to": "false_905301234567@c.us_3EB0A81ECEA6D47F2F13" }' ``` `reply_to` takes the ID of the customer's message, which is the trigger's `messageId`. **When the output is `HANDOFF`**, run two Maytapi nodes instead: 1. *Label > Add Label to Chat*, Label ID: your "Human" label, Conversation ID: `{{ $('Maytapi Trigger').first().json.conversation }}`. This calls `POST /addLabel` with `label_id` and `conversation_id`. 2. *Message > Send Message* with a fixed text such as "Thanks, I'm passing you to a colleague. They'll reply here shortly." From the next message on, step 3 finds the label and the bot stays silent. Your team sees the label in the WhatsApp Business app (or in a shared inbox) and picks up the chat. When they are done, they remove the label and the bot resumes. If you also want n8n to notify the team, add a Slack or email node to the handoff branch. ### Multiple phones on one product The trigger receives events for every phone on the product; the Phone ID in your credential does not filter them. If the product has more than one number, either add `{{ $json.body.phone_id }}` to the filter in step 2, or reply from the phone that received the message: in every Maytapi node after the trigger, set **Phone ID Source** to *Custom* and the Phone ID to `{{ $('Maytapi Trigger').first().json.body.phone_id }}`. Otherwise a message received on one number would be answered from the credential's default number. ### Production checklist - **Activate before you trust it.** The editor's *Listen for test event* registers n8n's test URL; the production URL is registered when you activate the workflow. - **Keep the `fromMe` filter first.** Any change that moves it after the AI Agent brings the reply loop back. - **Watch the duplicate filter.** `sendMessage` can reject a message identical to the recent ones sent to the same chat (error "Found Message Dupe."). If your agent may legitimately repeat an answer, enable **Skip Duplicate Filter** (`skip_filter: true`). - **Handle failures.** Turn on *Continue On Fail* or an error workflow for the send node, so one disconnected phone or API error doesn't stall the queue silently. A *Session > Get Status* check tells you whether the phone is connected. - **Review answers early.** Read the first few days of conversations in the n8n execution list and tighten the system message where the model drifts. Checklist of guardrails for a WhatsApp AI agent in n8n: fromMe filter, skip groups, human label handoff, per-chat cooldown, reply from the receiving phone, duplicate filter ### n8n agent or MCP server? This workflow makes Claude answer your customers automatically. The other way to use Claude with WhatsApp is the reverse: you talk to Claude, and Claude operates WhatsApp for you, for example "summarize today's unread chats" or "send the delivery update to these three customers." That is what the [Maytapi MCP server](/blog/whatsapp-mcp-server-claude) does in Claude Desktop, Claude Code and Cursor. Many teams use both: the n8n agent for first-line replies, the MCP server for staff tasks. ### Next steps - Install the node and build simpler flows first with the [n8n automation guide](/blog/whatsapp-n8n-automation). - Use Claude as an operator instead of a bot with the [WhatsApp MCP server guide](/blog/whatsapp-mcp-server-claude). - Sync handoff and pipeline labels with your CRM using the [labels API guide](/blog/whatsapp-labels-api-crm-sync). - Learn every field the trigger receives in the [webhook events guide](/blog/whatsapp-webhook-events-guide). - Prefer code? Build the same bot in [Node.js](/blog/whatsapp-bot-nodejs) or [Python](/blog/whatsapp-bot-python), or see all endpoints in the [API documentation](/whatsapp-api-documentation). ### Export WhatsApp Group Members to CSV or CRM with the API URL: https://maytapi.com/blog/whatsapp-group-members-export Published: 2026-09-22, updated: 2026-10-27 Category: Tutorials > Export WhatsApp group members with getGroups, resolve @lid ids to phone numbers, add names and write a CSV, limited to groups where members opted in. A WhatsApp group you run is often your warmest audience: workshop attendees, a customer community, a beta-tester circle. Sooner or later someone asks for that list in the CRM. This guide shows how to export WhatsApp group members with the Maytapi API: list your groups, read each group's participants and admins, turn `@lid` privacy ids into phone numbers where WhatsApp allows it, add contact names, and write everything to a CSV you can import anywhere. It also covers the part that matters most: which members you may actually treat as leads. ### What you can read about a group Everything here is read-only. Nothing is sent and the group doesn't change, so you can run an export as often as you need. | Task | Endpoint | Useful parameters | |---|---|---| | Your own number | `GET /status` | | | List your groups | `GET /getGroups` | `page`, `sort`, `full`, `refresh` | | One group with participants | `GET /getGroups/{conversation_id}` | `refresh` | | Contact names | `GET /getContacts` | `type=contact` | | Resolve a privacy id | `GET /getContacts/lidToPhone/{lid}` | | All of them live under `https://api.maytapi.com/api/PRODUCT_ID/PHONE_ID/` and use your token in the `x-maytapi-key` header. The export only sees groups that your connected number belongs to. It can't read a group you aren't in. ### List your groups with getGroups ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/getGroups?sort=true&page=1" \ -H "x-maytapi-key: $TOKEN" ``` The response is paged: ```json { "success": true, "limit": 500, "count": 1, "total": 1, "page": 1, "data": [ { "id": "120363012345678901@g.us", "name": "Spring Workshop 2026", "participants": ["905301234567@c.us", "905301234568@c.us", "112893567721673@lid"], "admins": ["905301234567@c.us"], "config": { "edit": "admins", "send": "all", "disappear": false, "membersCanAddMembers": false, "approveNewMembers": true } } ] } ``` A few parameters shape the list: - **`page`** starts at 1. A page holds up to 500 groups, 100 with `full=true` and 50 with `invite=true`; the `limit` field in each response tells you the page size actually used. Keep requesting pages until you've collected `total` groups or a page comes back with `count: 0`. - **`sort=true`** is what the docs suggest pairing with `page`, so the order stays stable between pages. Deduplicate by `id` anyway. - **`full=true`** asks the API to load participants for older groups whose member list isn't loaded yet. Use it if some groups come back with an empty `participants` array. - **`refresh=true`** refreshes groups on the page whose participants are still `@lid` without a phone number. More on that below. Communities themselves are not listed here (use `getCommunities` for those), but a group that belongs to a community carries a `communityId`, and the community's announcement group also has `isCommunityAnnounce: true`. Announcement groups tend to be large and full of people who never spoke to you, so most exports should skip them. ### Read one group's participants When you already know the group, read it directly: ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/getGroups/120363012345678901@g.us" \ -H "x-maytapi-key: $TOKEN" ``` `data` has the same shape as one item of the list. The member fields are simpler than many people expect: | Field | Type | Meaning | |---|---|---| | `participants` | array of strings | Every member's id, `@c.us` or `@lid` | | `admins` | array of strings | The ids of the admins, a subset of `participants` | | `pendingParticipants` | array of strings | Only present when there are pending members | There is no per-member object with `isAdmin` or `phone` fields. A member is an admin when their id is in `admins`, and the phone number is the part of a `@c.us` id before the `@`. For `905301234567@c.us` that's `905301234567`, country code first, no `+`. If the group doesn't exist or your number can't access it, the response has `success: false` with a `message` and `code`. Log it and move on rather than stopping the whole export. ### Resolve @lid participants to phone numbers In larger and newer groups WhatsApp hides members' numbers from linked devices and shows a privacy id like `112893567721673@lid` instead. A CSV row with a LID is useless for a CRM, so resolve it: ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/getContacts/lidToPhone/112893567721673@lid" \ -H "x-maytapi-key: $TOKEN" ``` ```json { "success": true, "data": { "id": "905301234569@c.us", "name": "Deniz", "phone": "905301234569", "lid": "112893567721673@lid" } } ``` When the number can't be resolved, you get `{ "success": false }` with no data. That usually means the member hides their number. Treat it as a normal result: keep the LID in its own column and leave the phone empty. `name` can be an empty string when the name isn't known. Before resolving one by one, try `refresh=true` on `GET /getGroups/{conversation_id}` (or on the list). On some connections it re-reads the group from WhatsApp and returns members as `@c.us` wherever the number is available. Each refreshed group is one query to WhatsApp, so use it when you actually see `@lid` members, not on every run. The [LID to phone number guide](/blog/whatsapp-lid-to-phone-number) explains the background and a storage pattern for keeping both ids. ### Add contact names with getContacts Group participants come back as ids only. For names, load your contact list once and use it as a lookup table: ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/getContacts?type=contact" \ -H "x-maytapi-key: $TOKEN" ``` Each item has `id`, `name` and `type` (`chat` for people, `group` for groups), plus `isAddressBook` and `isBusiness`. Keep the `chat` items and map `id` to `name`. On some connections `name` falls back to the phone number when no name is known, so treat a name that equals the number as empty. For a resolved LID, the `name` from `lidToPhone` is a second source. ### Export script: groups you admin to CSV The script below runs on Node.js 18+ (ESM) with the built-in `fetch`. It exports only groups where your number is an admin, and only the group ids you list in `GROUP_IDS`. That explicit list is deliberate: you decide group by group which audiences you're allowed to export, instead of sweeping up every group the number has ever joined. Flow of the group member export: read your number with status, read each allowed group, check you are an admin, resolve @lid members with lidToPhone, write one row per person to CSV ```js import { writeFileSync } from "node:fs"; const { PRODUCT_ID, PHONE_ID, TOKEN } = process.env; const BASE = `https://api.maytapi.com/api/${PRODUCT_ID}/${PHONE_ID}`; const HEADERS = { "x-maytapi-key": TOKEN }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // Groups whose members agreed to be contacted (see the compliance section) const GROUP_IDS = ["120363012345678901@g.us"]; async function get(path, params = {}) { const qs = new URLSearchParams(params).toString(); const r = await fetch(`${BASE}/${path}${qs ? "?" + qs : ""}`, { headers: HEADERS, signal: AbortSignal.timeout(60000) }); return r.json(); } const lidCache = new Map(); // Return { phone, lid, name } for a participant id async function resolve(memberId) { if (!memberId.endsWith("@lid")) return { phone: memberId.split("@")[0], lid: "", name: "" }; if (!lidCache.has(memberId)) { const res = await get(`getContacts/lidToPhone/${memberId}`); const data = res.success ? res.data : null; lidCache.set(memberId, { phone: data ? data.phone : "", lid: memberId, name: (data && data.name) || "" }); await sleep(500); // resolve slowly, not in a burst } return lidCache.get(memberId); } const me = (await get("status")).number; const names = {}; for (const c of (await get("getContacts", { type: "contact" })).data || []) { const phone = c.id.split("@")[0]; if (c.type === "chat" && c.name && c.name !== phone) names[c.id] = c.name; } const rows = new Map(); for (const gid of GROUP_IDS) { const res = await get(`getGroups/${gid}`, { refresh: "true" }); const group = res.success ? res.data : null; if (!group || !group.participants) { console.log("skip", gid, res.message); continue; } const admins = new Set(); for (const a of group.admins || []) admins.add((await resolve(a)).phone); if (!admins.has(me)) { console.log("skip, not an admin:", group.name); continue; } for (const member of group.participants) { const { phone, lid, name: lidName } = await resolve(member); if (phone === me) continue; const key = phone || lid; if (!rows.has(key)) { rows.set(key, { phone, lid, name: names[`${phone}@c.us`] || lidName, isAdmin: false, groups: [] }); } const row = rows.get(key); row.isAdmin = row.isAdmin || admins.has(phone); row.groups.push(group.name || gid); } } const cell = (v) => `"${String(v).replace(/"/g, '""')}"`; const lines = ["phone,lid,name,is_admin,groups,source"]; for (const r of rows.values()) { lines.push([r.phone, r.lid, r.name, r.isAdmin, r.groups.join("; "), "whatsapp_group"].map(cell).join(",")); } writeFileSync("group_members.csv", lines.join("\n") + "\n", "utf8"); console.log(rows.size, "members exported"); ``` Some details that keep the output clean: - **One row per person.** Someone in three of your groups appears once, with all three group names in `groups`. The key is the phone number, or the LID when the number stays hidden. - **Your own number is left out.** `GET /status` returns the connected `number`, which is also how the script checks that you're an admin. - **LIDs are cached.** The same person can be a LID in several groups. Each one is resolved once, with a pause between lookups. - **Unresolved members stay in the file** with an empty `phone`. Drop them on import if your CRM requires a number. To export every group you admin instead, page through `GET /getGroups` as shown earlier, skip announcement groups, and keep the ones where your number is in `admins`. Read the compliance section below before you do that. #### Importing into a CRM Most CRMs import a CSV directly, so `phone` maps to the mobile field, `name` to the contact name, and `groups` to a tag or list. If you'd rather push the rows through your CRM's API, upsert by phone number so a second export updates existing contacts instead of duplicating them. Store the `lid` column too: if the same person later reaches you in a group as a LID, you can still match them. Add `source` and the export date, so anyone looking at the contact later knows where it came from. ### Compliance: who counts as a lead Being in the same WhatsApp group as someone doesn't mean they agreed to hear from your business. Before an exported number becomes a lead, check where it came from and what the member agreed to. Checklist before treating exported WhatsApp group members as leads: only groups you own or admin, consent decides use, GDPR applies, ask for opt-in in the group, no cold messaging - **Only groups you own or admin.** Export from groups your business created and runs, where members joined to interact with you. Don't export other people's groups, groups you joined through public invite links, or community announcement groups. - **Consent decides what you can do with the list.** A workshop group whose members signed up with your terms is different from a neighbourhood chat. If members never agreed to marketing, the export is useful for moderation, attendance or support, not for campaigns. - **GDPR and similar laws apply.** A phone number tied to a name is personal data. Under GDPR you need a lawful basis for processing it, you must tell people what you do with their data, and you have to honour deletion and opt-out requests. Other regions have their own rules. Keep the export minimal and the file access-controlled. - **Ask for opt-in in the group.** The cleanest route is to ask. Post a message in the group, for example "Reply JOIN to get our product updates on WhatsApp", and record only the people who answer. Group messages arrive on your webhook with `conversation` set to the group id and the sender in `user`. Senders can show up as `@lid` too, so resolve them the same way. Mark these contacts as opted in, with the date. - **No cold messaging scraped numbers.** Sending one-to-one messages to hundreds of people who never wrote to you is spam to WhatsApp as much as to the recipients. Reports and blocks put your number at risk, and the new-chat limits described in the [ban prevention and Safe Mode guide](/blog/whatsapp-ban-prevention-safe-mode) are there to stop exactly that pattern. Message exported members only if they opted in, and keep the pace of a person, not a script. A simple rule of thumb: if a member would be surprised to get a message from you, they aren't a lead yet. ### Next steps You now have a repeatable WhatsApp group member export: list groups with `getGroups`, read `participants` and `admins`, resolve `@lid` ids with `lidToPhone`, add names from `getContacts`, and write one row per person to CSV, limited to groups where members expect to hear from you. - Understand privacy ids in depth with [LID to phone number](/blog/whatsapp-lid-to-phone-number). - Manage members, admins and settings with the [group API guide](/blog/create-whatsapp-group-api). - Control who gets into your groups with [group join requests](/blog/whatsapp-group-join-requests-api). - Clean a number list before importing it with the [bulk WhatsApp number check](/blog/bulk-whatsapp-number-check). - Capture opt-in replies with the [webhook events guide](/blog/whatsapp-webhook-events-guide). - Check every parameter in the [API documentation](/whatsapp-api-documentation). - List your own groups with `getGroups` on the [3-day free trial](https://console.maytapi.com/register). ### WhatsApp Call Webhooks: Missed, Rejected and Ringing Calls via API URL: https://maytapi.com/blog/whatsapp-call-webhooks Published: 2026-09-15, updated: 2026-10-27 Category: Guides > Receive WhatsApp call events on your webhook, read call history with getCalls, reject or start calls, and auto-reply to missed calls from your CRM. Calls are the part of WhatsApp that most integrations ignore. A customer rings your business number, nobody picks up, and the only trace is a missed-call line on someone's phone. With the Maytapi API, every call on your connected number becomes a webhook event and an entry in a call history you can query. This guide covers the call events you receive, what their payloads contain, the call endpoints (`getCalls`, `call/reject`, `call/start`, `call/createCallLink`), and a working pattern that logs missed calls to a CRM and answers the caller with a WhatsApp message. ### What call events you receive Call events arrive on your webhook as ordinary `message` events. The `message.type` is `info` and the `message.subtype` tells you what happened: | Subtype | When it is sent | |---|---| | `call/ringing` | A call starts ringing on your number | | `call/miss` | The call ended without being answered | | `call/reject` | The call was declined | | `call/accept` | The call was answered on another device, such as the phone itself | Lifecycle of a WhatsApp call as webhook events: call/ringing while it rings, then one of call/miss, call/reject or call/accept when it ends There are two kinds of event here. `call/ringing` is a live notification: it fires while the call is still ringing, so you can react before it ends. The other three are call log entries that WhatsApp writes when the call is over, and they reach you the same way messages do. A note on `call/accept`: your API connection works as a linked device and cannot answer a call itself. When you see `call/accept`, someone took the call on the phone or another device linked to the number. Each event also carries `isVideo`, so you can tell voice calls from video calls. WhatsApp does not report call duration in these events, and there are no separate "initiated" or "answered" stages beyond the four subtypes above. ### The call webhook payload All call events use the standard webhook envelope described in the [webhook events guide](/blog/whatsapp-webhook-events-guide). A `call/ringing` event looks like this: ```json { "type": "message", "product_id": "1a2b3c4d-...", "phone_id": 12345, "user": { "id": "905301234567@c.us", "name": "John Doe", "phone": "905301234567" }, "conversation": "905301234567@c.us", "conversation_name": "John Doe", "receiver": "905307654321", "timestamp": 1704702074, "message": { "id": "001FD1B8FA609DB8ED33F441A777AF7B", "_serialized": "001FD1B8FA609DB8ED33F441A777AF7B", "type": "info", "subtype": "call/ringing", "fromMe": false, "isVideo": false }, "reply": "https://api.maytapi.com/api/1a2b3c4d-.../12345/sendMessage" } ``` The fields you will actually use: - **`user`** is the caller, with `id`, `name` and `phone`. Privacy ids (`@lid`) are resolved to a phone number when WhatsApp makes one available. - **`conversation`** is the chat the call belongs to. For one-to-one calls it is the caller's `@c.us` id. - **`message.fromMe`** is `true` when the call was made from your own number, for example from the phone. Filter on it so you only act on incoming calls. - **`message.isVideo`** separates voice and video calls. - **The call id.** Some connections include a separate `message.callId` with the raw call id and put a full message id in `message.id`. Read `message.callId` first and fall back to `message.id`. That is the value `call/reject` and `getCalls/{callId}` expect. To see which form your connection uses, log the first `call/ringing` event you receive. A `call/miss`, `call/reject` or `call/accept` event has the same envelope. Its `message` carries `type`, `subtype`, `id`, `_serialized` and `fromMe`: ```json { "type": "info", "subtype": "call/miss", "id": "false_905301234567@c.us_3EB0C7A1B2D3E4F5A6B7", "_serialized": "false_905301234567@c.us_3EB0C7A1B2D3E4F5A6B7", "fromMe": false, "isVideo": false } ``` `isVideo` is included on most connections for these three subtypes as well, but it is not guaranteed on every connection, so treat a missing value as unknown rather than as a voice call. The `id` here is the call log message id. Don't use it with `call/reject`. > **Note:** Call events reach you only after a webhook is set. Configure it once with `setWebhook` or in the console. Delivery receipts (acks) are off by default and only arrive once you enable them with `setAckPreference`, so a call-only handler sees just messages and events. ### Read call history with getCalls Webhooks can be missed while your server is down, so the API also keeps a call history. `GET /getCalls` lists finished calls: ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/getCalls?page=1" \ -H "x-maytapi-key: $TOKEN" ``` ```json { "success": true, "limit": 500, "count": 1, "total": 1, "page": 1, "data": [ { "timestamp": 1704702000, "uid": "905301234567@c.us", "fromMe": false, "conversation": "905301234567@c.us", "user": { "id": "905301234567@c.us", "name": "John Doe", "phone": "905301234567" }, "message": { "id": "...", "_serialized": "...", "type": "info", "subtype": "call/miss", "isVideo": false } } ] } ``` Each item uses the same shape as the webhook: `user` is who made the call (you, when `fromMe` is true) and `conversation` is the other party, or the group id for a group call. Three query parameters control the list: | Parameter | Effect | |---|---| | `full` | `true` adds full contact details (name, phone, image) to `user` and lowers the page size from 500 to 100 | | `page` | Page number, starting at 1 | | `sort` | `true` returns oldest first; the default is newest first | The history only contains the three outcomes `call/miss`, `call/reject` and `call/accept`. Calls still ringing are not listed. It is also limited to the most recent calls the connected session can see, up to 5000. Calls from before you paired the number may be missing. #### Look up a single call `GET /getCalls/{callId}` returns one call in the same item shape. While the call is still ringing, the subtype is `call/ringing`. Once it has ended, you get the final outcome: ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/getCalls/001FD1B8FA609DB8ED33F441A777AF7B" \ -H "x-maytapi-key: $TOKEN" ``` Use the call id from the `call/ringing` event. When the call cannot be found, `data` is `null`. Check `data` rather than `success`, because `success` is not the same on every connection in the not-found case. ### Reject, start and share calls #### Reject an incoming call `POST /call/reject` declines a call that is still ringing. It takes the call id from the `call/ringing` webhook: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/call/reject" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"callId": "001FD1B8FA609DB8ED33F441A777AF7B"}' ``` Rejecting only works while the call rings. After it ends there is nothing left to reject, and the request returns `success: false` with a message. #### Start an outgoing call `POST /call/start` rings a number from your account. This is an attention call: it rings the recipient and hangs up as soon as they answer or when `duration` runs out. It carries no audio, so use it as a nudge before an important message, not as a phone line. ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/call/start" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"to_number": "905301234567", "duration": 5}' ``` `to_number` is the number with country code and no `+`. Always send `duration`: it must be a whole number of seconds from 1 to 30, or the call is not started. A successful response returns `data.callId` and `data.chatId`. On failure you get `success: false` with a `message` and a `code`, for example when the number is not on WhatsApp or another call is already active. #### Create a call link `POST /call/createCallLink` returns a shareable `https://call.whatsapp.com/...` link that people can use to join a call: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/call/createCallLink" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"callType": "video", "timestamp": 1893456000}' ``` Send `callType` and a `timestamp` (Unix seconds, in the future) for the scheduled call. The link comes back in `data`. You can send it in a normal text message, for example in a booking confirmation. ### Log missed calls to a CRM and reply automatically Here is the use case most teams want: when a customer calls and nobody answers, create a CRM activity and send them a WhatsApp message so the conversation keeps going. Missed-call automation: webhook receives call event, filters incoming one-to-one calls, rejects after hours, logs to CRM and sends one WhatsApp reply The handler below runs on Express and Node.js 18 or newer. It does two things: 1. On `call/ringing` during closed hours, it rejects the call so the caller is not left waiting, and follows up right away. 2. On `call/miss` or `call/reject` from a customer, it logs the call and sends one reply. ```js import express from "express"; const { PRODUCT_ID, PHONE_ID, TOKEN } = process.env; const base = `https://api.maytapi.com/api/${PRODUCT_ID}/${PHONE_ID}`; const headers = { "x-maytapi-key": TOKEN, "Content-Type": "application/json" }; const app = express(); app.use(express.json()); const lastFollowUp = new Map(); // conversation -> time of last follow-up (ms) const isOpen = () => { const h = new Date().getHours(); return h >= 9 && h < 18; }; async function followUp(event, outcome) { const last = lastFollowUp.get(event.conversation) ?? 0; if (Date.now() - last < 6 * 60 * 60 * 1000) return; // once per 6 hours per chat lastFollowUp.set(event.conversation, Date.now()); await crm.logCall({ phone: event.user?.phone, name: event.user?.name, outcome, video: event.message.isVideo, at: new Date(event.timestamp * 1000) }); const text = isOpen() ? "Sorry we missed your call. Reply here and we'll answer you in a few minutes." : "Thanks for calling. We're closed right now and open again at 9:00. Leave us a message here."; await fetch(`${base}/sendMessage`, { method: "POST", headers, body: JSON.stringify({ to_number: event.conversation, type: "text", message: text }) }); } app.post("/webhook", async (req, res) => { res.sendStatus(200); // acknowledge first, work after const event = req.body; const m = event.message; if (event.type !== "message" || m?.type !== "info") return; if (!m.subtype?.startsWith("call/") || m.fromMe) return; if (event.conversation?.endsWith("@g.us")) return; // skip group calls if (m.subtype === "call/ringing" && !isOpen()) { const callId = m.callId || m.id; await fetch(`${base}/call/reject`, { method: "POST", headers, body: JSON.stringify({ callId }) }); return followUp(event, "reject"); } if (m.subtype === "call/miss" || m.subtype === "call/reject") { return followUp(event, m.subtype.split("/")[1]); } }); app.listen(3000); ``` `crm.logCall` stands for your own CRM client. A few details are worth keeping: - **One follow-up per caller.** A call you reject can be followed by a `call/reject` log event, and a caller who tries three times in a row produces three events. The per-chat window turns all of that into one CRM entry and one message. Keep it in Redis or your database if you run more than one instance. - **Resolve `@lid` ids.** If `conversation` ends in `@lid`, WhatsApp did not share the number. The [LID to phone number guide](/blog/whatsapp-lid-to-phone-number) shows how to resolve it before you match the CRM record. - **Reconcile from history.** A scheduled job that reads `getCalls` and compares it with the CRM catches any calls you missed while your server was offline. ### Practical tips - **Answer only people who called you.** A short reply to a missed call is expected. Sending automated messages to numbers that never contacted you is a different matter. Read [how Safe Mode prevents WhatsApp bans](/blog/whatsapp-ban-prevention-safe-mode) before you add any outbound flow. - **Use `call/start` sparingly.** An unexpected call is more intrusive than a message, and automated calls to people who don't know your number are the kind of activity that gets a number restricted. Keep it for people who asked to be called, such as an appointment reminder, and read the [Safe Mode and ban prevention guide](/blog/whatsapp-ban-prevention-safe-mode) first. - **Label callers.** If your CRM tracks stages, combine this with the [labels sync guide](/blog/whatsapp-labels-api-crm-sync) to tag chats that came in by phone. - **Hand off to agents.** On a shared number, route missed-call replies into the [multi-agent platform](/multi-agent-platform-for-whatsapp) so a person picks up the thread. ### Next steps - See every event type and the full envelope in the [webhook events guide](/blog/whatsapp-webhook-events-guide). - Build the reply logic into a full bot with [Node.js](/blog/whatsapp-bot-nodejs) or [Python](/blog/whatsapp-bot-python). - Run the same flow without code using [n8n](/blog/whatsapp-n8n-automation). - Check every call endpoint and its body in the [API documentation](/whatsapp-api-documentation). ### WhatsApp for Shopify & WooCommerce: Carts, Orders and COD URL: https://maytapi.com/blog/whatsapp-shopify-abandoned-cart-cod Published: 2026-09-08, updated: 2026-10-27 Category: Integrations > Connect Shopify or WooCommerce webhooks to the Maytapi API to recover abandoned carts, send order updates and confirm cash-on-delivery orders on WhatsApp. Most online stores already send order emails, and most of those emails are never opened. A WhatsApp message about a cart someone left five minutes ago, or a quick "Can you confirm your cash-on-delivery order?" before the courier leaves, gets read. This guide shows how to build that WhatsApp automation for a Shopify or WooCommerce store yourself: your store sends a webhook to a small server, the server sends the message with Maytapi, and the customer's answer comes back to the same server through the Maytapi webhook. We cover abandoned cart recovery, order notifications and COD confirmation, and the checks that keep your number healthy. ### How the pieces fit together There is no Maytapi plugin to install in Shopify or WooCommerce. The integration is a short chain of webhooks you control, which also means you decide exactly what is sent and when. Flow: store webhook to your server, server calls Maytapi sendMessage, customer reply comes back through the Maytapi webhook 1. **Your store fires a webhook.** Shopify can notify you about new checkouts and orders; WooCommerce has order webhooks under **Settings > Advanced > Webhooks**. 2. **Your server decides what to send.** It reads the phone number, order number and total from the store payload, and checks your own opt-out list. 3. **Maytapi sends the WhatsApp message.** One `POST /sendMessage` request from your server, sent from your own WhatsApp number. 4. **The customer replies.** The reply arrives at your Maytapi webhook as a `message` event, and your server updates the order. All Maytapi calls use the base URL `https://api.maytapi.com/api/PRODUCT_ID/PHONE_ID/` and your token in the `x-maytapi-key` header. If you would rather not run a server, the same chain works in n8n with the [Maytapi community node](/blog/whatsapp-n8n-automation): a store webhook trigger on one side, the Maytapi node on the other. ### What to automate first | Flow | Store trigger | WhatsApp message | Reply needed? | |---|---|---|---| | Order confirmation | Shopify `orders/create`, WooCommerce "Order created" | "Thanks, order #1042 received" | No | | COD confirmation | Same order event, payment method is COD | List with Confirm / Cancel | Yes | | Shipping update | Shopify `fulfillments/create`, WooCommerce "Order updated" | Tracking link | No | | Abandoned cart | Shopify `checkouts/create` plus a delayed check | Link back to the checkout | Optional | COD confirmation usually pays off first. Every unconfirmed COD parcel that gets refused at the door costs you shipping both ways, and a one-tap confirmation catches most fake or impulse orders before they ship. ### Normalize and check the phone number Store checkouts collect phone numbers in every format: `+90 530 123 45 67`, `0530 123 4567`, with or without the country code. Maytapi's `to_number` expects the country code followed by the national number, digits only, so normalize first: ```js function toWhatsAppNumber(raw, defaultCountry = "90") { let n = String(raw || "").replace(/\D/g, ""); if (!n) return null; if (n.startsWith("00")) n = n.slice(2); else if (n.startsWith("0")) n = defaultCountry + n.slice(1); return n; } ``` Then confirm the number is actually on WhatsApp before the first message to a new customer. `checkPhones` takes an array and returns one result per number: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/checkPhones" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"numbers": ["905301234567"]}' ``` Each result has `valid` (`true` or `false`) and `id._serialized`, for example `905301234567@c.us`, which you can pass straight to `to_number`. If the number is not valid, fall back to email or SMS and don't retry on WhatsApp. Cache the result per customer so you don't check the same number on every order. The [bulk number check guide](/blog/bulk-whatsapp-number-check) covers formats and large lists. ### Send order notifications Order confirmations and shipping updates are plain text messages. Point a Shopify `orders/create` webhook (or a WooCommerce "Order created" webhook) at your server and send one request per order: ```js import express from "express"; const { PRODUCT_ID, PHONE_ID, TOKEN } = process.env; const base = `https://api.maytapi.com/api/${PRODUCT_ID}/${PHONE_ID}`; const headers = { "x-maytapi-key": TOKEN, "Content-Type": "application/json" }; async function send(body) { const r = await fetch(`${base}/sendMessage`, { method: "POST", headers, body: JSON.stringify(body) }); return r.json(); } const app = express(); app.post("/shopify/orders-create", express.json(), async (req, res) => { res.sendStatus(200); // answer the store quickly, work afterwards const order = req.body; const phone = toWhatsAppNumber(order.phone || order.shipping_address?.phone); if (!phone || (await optedOut(phone))) return; await send({ to_number: phone, type: "text", message: `Hi ${order.shipping_address?.first_name || ""}, this is Acme Store. ` + `We received order ${order.name} (${order.total_price} ${order.currency}). ` + `We'll message you when it ships. Reply STOP to stop these updates.` }); }); ``` For WooCommerce, the order webhook body has the phone in `billing.phone`, the order number in `number` and the total in `total`; the sending part is the same. In production, verify the store's signature before trusting the payload: Shopify signs each webhook with the `X-Shopify-Hmac-Sha256` header, WooCommerce with `X-WC-Webhook-Signature`. Both are an HMAC-SHA256 of the raw request body, so read the raw body for those routes. `sendMessage` adds the message to your phone's sending queue and returns `success: true` right away. That means "queued", not "delivered". Delivery and read receipts come later to your webhook as `ack` events, described in the [webhook events guide](/blog/whatsapp-webhook-events-guide). ### Confirm cash-on-delivery orders When the order's payment method is cash on delivery (Shopify lists it in `payment_gateway_names`, usually as `"Cash on Delivery (COD)"`, so match it case-insensitively; WooCommerce sends `payment_method: "cod"`), send a confirmation request instead of the plain notification and hold the order until the customer answers. #### Use a list message, with a typed fallback Reply buttons look like the obvious choice, but a button tap does not reach your webhook as a reply on every connection. List selections do: when the customer picks a row, the webhook receives a normal text message with the row title in `message.text` and your `rowId` in `message.payload`. So for a step your code depends on, send a list and also ask for a typed answer in the body: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/sendMessage" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "to_number": "905301234567@c.us", "type": "list", "title": "Order #1042", "message": "Please confirm your cash-on-delivery order of 749.90 TRY. Reply 1 to confirm or 2 to cancel.", "footer": "Acme Store", "button_text": "Choose", "sections": [ { "title": "Your order", "rows": [ { "rowId": "cod_confirm_1042", "title": "Confirm order", "description": "Ship it, I will pay at the door" }, { "rowId": "cod_cancel_1042", "title": "Cancel order" } ] } ] }' ``` Putting the order number into the `rowId` means the reply tells you which order it is about, even if the same customer has two open COD orders. The typed "1" or "2" is there for customers who answer before opening the list, and for any client that does not show the list well. If you prefer reply buttons for the look, the request is the same with `"type": "buttons"` and `buttons: [{ "id": "cod_confirm_1042", "text": "Confirm" }, ...]`. Keep the "Reply 1 to confirm" line in the body either way, and test the tap on your own number first. The [interactive messages guide](/blog/whatsapp-interactive-messages-buttons-list-carousel) explains the differences between the formats. #### Handle the customer's answer Set your Maytapi webhook once with `setWebhook` or in the console. Every incoming message arrives as a POST with `type: "message"`, the chat in `conversation`, the sender in `user`, the content in `message`, and a ready `reply` URL for answering: ```json { "type": "message", "product_id": "your-product-id", "phone_id": 12345, "conversation": "905301234567@c.us", "user": { "id": "905301234567@c.us", "name": "Ayse", "phone": "905301234567" }, "message": { "type": "text", "text": "Confirm order", "payload": "cod_confirm_1042", "fromMe": false, "id": "3EB081AF26FADE6416D8" }, "reply": "https://api.maytapi.com/api/your-product-id/12345/sendMessage", "timestamp": 1761568800 } ``` A typed answer looks the same without `payload`. The handler reads the row id first and falls back to the text: ```js app.post("/maytapi/webhook", express.json(), async (req, res) => { res.sendStatus(200); const { type, message, conversation, user, reply } = req.body; if (type !== "message" || message?.fromMe || message?.type !== "text") return; const phone = user?.phone || conversation.split("@")[0]; const text = (message.text || "").trim().toLowerCase(); if (["stop", "unsubscribe"].includes(text)) return addOptOut(phone); let action, orderId; const m = /^cod_(confirm|cancel)_(\d+)$/.exec(message.payload || ""); if (m) [, action, orderId] = m; else if (text === "1" || text === "2") { orderId = await latestPendingCodOrder(phone); // your lookup by phone action = text === "1" ? "confirm" : "cancel"; } if (!orderId) return; // not a COD answer, leave it for a human await markOrder(orderId, action); // e.g. tag or cancel the order in your store await fetch(reply, { method: "POST", headers, body: JSON.stringify({ to_number: conversation, type: "text", message: action === "confirm" ? "Thanks, your order is confirmed." : "Your order has been cancelled." }) }); }); ``` `latestPendingCodOrder` and `markOrder` stand for your own database and store API calls, for example adding a "COD confirmed" tag to the Shopify order or moving the WooCommerce order to processing. Set a deadline as well: if there is no answer within a few hours, send one reminder, and after that let your team call the customer instead of shipping blind. Occasionally `conversation` is a privacy ID ending in `@lid` rather than a phone-number chat. When the number is missing, the [LID to phone number guide](/blog/whatsapp-lid-to-phone-number) shows how to resolve it, and the order id in `payload` still tells you which order the answer belongs to. ### Recover abandoned carts Shopify does not send an "abandoned" event. It sends `checkouts/create` and `checkouts/update` when a customer starts a checkout, and the checkout carries `abandoned_checkout_url`, a link that restores the cart. The pattern is to schedule a check: Shopify treats checkout and order webhooks as protected customer data, so an app that subscribes to them needs that access approved in its Shopify app settings first. 1. On `checkouts/create`, store the checkout `token`, the phone number and `abandoned_checkout_url`, and schedule a job for, say, one hour later. 2. When an order arrives, it has a `checkout_token`. Mark that checkout as completed. 3. When the job runs and the checkout is still not completed, send one message with the recovery link. ```js await send({ to_number: phone, type: "text", message: `Hi, this is Acme Store. You left a few items in your cart. ` + `You can finish your order here: ${checkout.abandoned_checkout_url} ` + `Reply STOP if you'd rather not get these messages.` }); ``` Send one reminder per checkout, not a series. The customer has never chatted with your number before, so this is exactly the kind of cold, link-carrying message WhatsApp watches most closely. Consent to order updates is not consent to marketing, and a cart reminder counts as marketing under GDPR and similar rules, so only send it to customers who ticked a separate opt-in for WhatsApp reminders at checkout, and keep the daily volume moderate. In the first two Safe Mode tiers, links in first messages are blocked, so on a freshly connected number send the reminder without the link and share it when the customer replies. WooCommerce core has no checkout-started webhook, so it has no abandoned cart trigger out of the box. You need a cart-recovery plugin that captures the phone number and calls your server, or your own hook in the checkout page. ### Keep your number safe Store automation runs every day, so a small mistake repeats hundreds of times. A few rules: - **Check before the first send.** Run `checkPhones` on new customers and skip invalid numbers instead of retrying. - **Honor opt-outs everywhere.** End marketing-type messages with "Reply STOP", save the opt-out in your handler, and check the list before every send, including order updates. - **Space out bulk sends.** Order events are naturally spread out, but a flash sale or a backlog of abandoned checkouts is not. Add a delay between sends, as in the [broadcast API guide](/blog/whatsapp-broadcast-api), and watch `GET /queue` so the backlog does not pile up. - **Turn on Safe Mode for new numbers.** It paces a new number through a 14-day warm-up with per-minute, daily and new-chat limits; the [ban prevention and Safe Mode guide](/blog/whatsapp-ban-prevention-safe-mode) explains the tiers. - **Prefer replies over new conversations.** COD confirmations and shipping updates are expected; repeated promotions to people who never wrote to you are what gets numbers restricted. - **Acknowledge webhooks first.** Both your store and Maytapi expect a quick 200. Do the slow work after responding. Checklist of safe sending rules for store automation on WhatsApp ### Next steps - Learn every field of `message` and `ack` events in the [webhook events guide](/blog/whatsapp-webhook-events-guide). - Compare buttons, lists and carousels in [Send WhatsApp Buttons, Lists and Carousels via API](/blog/whatsapp-interactive-messages-buttons-list-carousel). - Build the same flows without code in [WhatsApp automation with n8n](/blog/whatsapp-n8n-automation). - Sell directly in chat with [WhatsApp catalog and order webhooks](/blog/whatsapp-catalog-api-orders). - Pace larger sends with the [WhatsApp broadcast API guide](/blog/whatsapp-broadcast-api). - Check every endpoint in the [API documentation](/whatsapp-api-documentation). ### Test WhatsApp Webhooks Locally with ngrok or cloudflared URL: https://maytapi.com/blog/whatsapp-webhook-local-testing Published: 2026-08-25, updated: 2026-10-27 Category: Tutorials > Run a local Express or Flask receiver, expose it with ngrok, point a Maytapi webhook at it, replay requests in the inspector and switch back to production. Writing a WhatsApp webhook handler is easy. Testing it is the awkward part: Maytapi needs a public URL to post events to, and your code is running on `localhost:3000`. Deploying every change to a server just to see one incoming message is slow. A tunnel fixes that. This guide shows how to run a small receiver on your machine, expose it with ngrok or cloudflared, point a Maytapi webhook at it, look at every request in the ngrok inspector and replay it while you fix your code, and then switch back to your production URL without losing traffic. ### How local webhook testing works A tunnel tool runs on your machine and gives you a public HTTPS address. Requests that reach that address are forwarded to a local port. Maytapi doesn't know or care that the other end is your laptop. It sends the same JSON `POST` it would send to a production server. | Step | What you run | Where | |---|---|---| | Start a receiver | Express on port 3000 | Your machine | | Open a tunnel | `ngrok http 3000` or `cloudflared tunnel --url http://localhost:3000` | Your machine | | Register the URL | `POST /config` (one phone) or `POST /setWebhook` (whole product) | Maytapi API | | Send a test message | WhatsApp, from another phone | Real chat | | Inspect and replay | `http://localhost:4040` | ngrok inspector | Local webhook testing flow: run a receiver on port 3000, open an ngrok or cloudflared tunnel, register the HTTPS URL with Maytapi, send a WhatsApp message and inspect it at localhost:4040 The API calls use `https://api.maytapi.com/api/PRODUCT_ID/PHONE_ID/` (or the product-level base without `PHONE_ID`) and your token in the `x-maytapi-key` header. If you don't have those yet, the [getting started guide](/blog/whatsapp-api-getting-started) shows where to find them. ### Run a local receiver Start with a receiver that logs everything and answers `200` right away. You want to see raw payloads before you write any routing logic. Node.js with Express: ```js import express from "express"; const app = express(); app.post("/maytapi/webhook", express.json({ limit: "5mb" }), (req, res) => { res.sendStatus(200); // answer first, work after const event = req.body; console.log(event.type, JSON.stringify(event, null, 2)); }); app.listen(3000, () => console.log("Listening on http://localhost:3000")); ``` Check it before you add a tunnel, so you know any later problem is the tunnel and not your code: ```bash curl -X POST http://localhost:3000/maytapi/webhook \ -H "Content-Type: application/json" \ -d '{"type": "message", "message": {"type": "text", "text": "hello"}}' ``` ### Expose port 3000 with a tunnel With ngrok installed and logged in: ```bash ngrok http 3000 ``` ngrok prints a forwarding line with a public HTTPS address, something like `https://a1b2-203-0-113-7.ngrok-free.app -> http://localhost:3000`. Your webhook URL is that address plus your route: `https://a1b2-203-0-113-7.ngrok-free.app/maytapi/webhook`. If you prefer Cloudflare's tool, a quick tunnel needs no account: ```bash cloudflared tunnel --url http://localhost:3000 ``` It prints a `https://...trycloudflare.com` address. It works the same way for Maytapi, but it has no request inspector, so the replay steps below are ngrok only. Use the HTTPS address both tools give you. Deliveries are a plain JSON `POST` that follows redirects, so the path you register must be the exact route your app listens on. ### Register the tunnel URL with Maytapi There are two places a webhook can live, and for testing the difference matters. | | `POST /config` | `POST /setWebhook` | |---|---|---| | Scope | One phone | Every phone on the product | | Path | `/api/PRODUCT_ID/PHONE_ID/config` | `/api/PRODUCT_ID/setWebhook` | | Sends a test request | No | Yes | | Read it back | `GET /config` | `GET /product` | | Fallback | Empty phone config uses the product webhook | | #### Per phone: the safe choice If the product already has a production webhook, don't move the whole product to your laptop. Point one test number at the tunnel instead: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/config" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"webhook": "https://a1b2-203-0-113-7.ngrok-free.app/maytapi/webhook"}' ``` The response returns the phone's stored config: ```json { "success": true, "config": { "webhook": "https://a1b2-203-0-113-7.ngrok-free.app/maytapi/webhook" } } ``` Only fields you send are changed, so this call leaves the phone's `ack_delivery` and other settings alone. From now on that phone's events go to your machine, and every other phone keeps using the product webhook. #### Product level: setWebhook On a development product with no live traffic, you can set the product webhook directly: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/setWebhook" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"webhook": "https://a1b2-203-0-113-7.ngrok-free.app/maytapi/webhook"}' ``` `setWebhook` posts a test request to the URL as part of the call, so start the receiver and the tunnel first. On success it returns the product record, including `pid`, `webhook` and `ack_delivery`. If it returns `success: false` with a `message`, fix the URL and call it again. A successful response doesn't prove your handler works, though. Look for the test request in your terminal or in the ngrok inspector. It arrives with the sample event nested under a `body` key: ```json { "body": { "type": "message", "user": { "id": "905801234567@c.us", "name": "Test Number", "phone": "905801234567" }, "message": { "id": "false_905801234567@c.us_2E44F8B915B6192020A4A4716A7B68EF", "text": "Test", "type": "text", "fromMe": false, "_serialized": "false_905801234567@c.us_2E44F8B915B6192020A4A4716A7B68EF" }, "phone_id": 12345, "product_id": "PRODUCT_ID", "conversation": "905801234567@c.us", "conversation_name": "Test Number", "timestamp": 1761552000 } } ``` The actual request also carries `phoneId`, `productId` and `receiver`, but no `reply` URL, since it isn't a real chat. Because of the wrapper, a handler that reads `req.body.type` sees `undefined` for this one request. That's fine. Ignore events you don't recognise instead of throwing, and the test request does no harm. ### Send a real message and read the payload Now message the connected number from another phone. A real incoming text arrives without a wrapper, with `type` at the top level: ```json { "product_id": "PRODUCT_ID", "phone_id": 12345, "type": "message", "message": { "type": "text", "text": "Hi, is my order ready?", "fromMe": false, "id": "false_905301234567@c.us_3EB0..." }, "user": { "id": "905301234567@c.us", "name": "Ayse", "phone": "905301234567" }, "conversation": "905301234567@c.us", "conversation_name": "Ayse", "receiver": "905309876543", "timestamp": 1761552000, "reply": "https://api.maytapi.com/api/PRODUCT_ID/12345/sendMessage" } ``` Use this stage to collect one real sample of each event you plan to handle: a text, an image, a group message, an `ack` for a message you sent. The [webhook events guide](/blog/whatsapp-webhook-events-guide) lists every type. Save the samples as JSON files and they become test fixtures for your handler. ### Inspect and replay requests in the ngrok inspector While ngrok runs, open `http://localhost:4040` in your browser. Every request that went through the tunnel is listed with its headers, the full JSON body and the response your app returned. Checklist for local webhook testing: answer 200 fast, re-register after a tunnel restart, test on one phone with POST /config, ignore unknown payloads, remove the tunnel URL when done Replay is the most useful part. Pick a request and click **Replay**, and ngrok sends the exact same request to your local server again. You can fix a bug, restart your app and replay the message that broke it, with no need to ask anyone to send another WhatsApp message. You can also edit the body before replaying to try an edge case, such as a missing field or a group conversation id like `120363012345678901@g.us`. The saved JSON samples work the same way without the inspector: ```bash curl -X POST http://localhost:3000/maytapi/webhook \ -H "Content-Type: application/json" \ -d @samples/text-message.json ``` Replies sent from replayed events are real. If your handler answers with `POST /sendMessage`, every replay sends another WhatsApp message to that person. Point those tests at your own second number, or log the reply instead of sending it while you debug. ### Common pitfalls #### Answer 200 before doing the work The first delivery attempt waits about 10 seconds for your answer. A slow handler, or a debugger paused on a breakpoint, makes that attempt fail. A failed delivery, whether it times out or gets a non-2xx status, is tried again: once more shortly after, and a final time about 30 minutes later. Answer first, then process, and deduplicate on `message.id` so a retry doesn't run your logic twice. #### The tunnel URL changes on restart On free plans, ngrok and cloudflared quick tunnels give you a new random address each time you start them. Maytapi keeps posting to the old one, which no longer exists. After every restart, register the new URL again with `POST /config` or `POST /setWebhook`. A reserved or static domain from your tunnel provider avoids this. #### Your laptop isn't a server When the lid closes, the tunnel drops. Events that arrive then fail, and the 30-minute retry won't help if the URL has changed. That's fine for testing, but never leave a production number pointed at a tunnel. #### Empty string vs null To clear a phone's webhook, send `null`, not an empty string. `null` removes the setting so the phone falls back to the product webhook. An empty string is stored as the phone's webhook, and that phone then delivers nowhere. ### Switch back to production Before you start testing, save what's there now. `GET /product` returns the product `webhook`, and `GET /config` returns the phone's own one, if it has one. When you're done: If you tested on one phone, remove its override so it falls back to the product webhook: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/config" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"webhook": null}' ``` If that phone had its own production URL before, send that URL instead of `null`. If you changed the product webhook, put the production URL back with `setWebhook`. It sends the test request to your production server too, so make sure that server ignores unknown payloads the same way your local handler does: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/setWebhook" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"webhook": "https://example.com/maytapi/webhook"}' ``` Then read both settings again with `GET /product` and `GET /config` and check that no tunnel address is left anywhere. You can also see and edit all of these on the console's **Developers > Webhook** page. ### Next steps A receiver, a tunnel and one test phone pointed at it give you a fast loop: send a message, read the payload, fix the code, replay. Once the handler behaves, deploy it and point the webhook at the real server. - See every event your handler can receive in the [webhook events guide](/blog/whatsapp-webhook-events-guide). - Set up credentials, a phone and your first message with [getting started](/blog/whatsapp-api-getting-started). - Turn the receiver into a bot with [Build a WhatsApp Bot with Node.js](/blog/whatsapp-bot-nodejs) or the [Python version](/blog/whatsapp-bot-python). - Reply to the events you receive with the [send message guide](/blog/whatsapp-api-send-message). - Check every parameter in the [API documentation](/whatsapp-api-documentation). ### How to Avoid WhatsApp Bans When Automating: Safe Mode and Warm-up URL: https://maytapi.com/blog/whatsapp-ban-prevention-safe-mode Published: 2026-08-11, updated: 2026-10-27 Category: Guides > How to warm up a new WhatsApp number: what gets numbers banned, a 14-day warm-up plan, and how Safe Mode and the new-chat limit keep automated numbers healthy. The fastest way to lose a WhatsApp number is to connect it to an API and start sending at full speed on day one. WhatsApp watches how a number behaves, and automated traffic that looks like spam gets restricted or banned, sometimes within hours. This guide is about warming up a WhatsApp number: what triggers restrictions, how a 14-day warm-up works, and how Safe Mode and WhatsApp's new-chat limit help you pace a number so it stays healthy. If a number is already restricted, start with our [WhatsApp API ban prevention page](/whatsapp-api-ban-prevention) instead. To be clear from the start: Maytapi is an unofficial API, and WhatsApp does not publish its enforcement rules. Nothing below guarantees a number won't be banned. These are the factors that consistently matter, plus the tools we built around them. ### What gets WhatsApp numbers banned WhatsApp doesn't publish a rulebook, but the same patterns come up again and again in restricted numbers: - **Reports and blocks.** When recipients tap "Report" or "Block", that is the strongest signal there is. In the restricted numbers we see, a few reports in a short period can be enough. - **Cold outreach.** Many first messages to people who have never messaged you, especially from a number with no history. - **Identical bulk content.** The same text or image sent to many chats in a short window looks like a broadcast tool. Varying the wording, even slightly, helps. - **Links to new chats.** A link in the very first message to someone who has never talked to you is treated far more suspiciously than the same link later in a conversation. - **Sudden volume.** A number that sent ten messages last week and two thousand today. New SIMs and long-idle numbers are the most exposed. - **One-way traffic.** WhatsApp pays attention to whether people answer. A number that only sends and almost never receives looks like spam even when nobody reports it. - **Machine-like rhythm.** Messages leaving exactly every two seconds, around the clock. "Banned" is not just one thing, either. Our [WhatsApp API ban prevention page](/whatsapp-api-ban-prevention) describes three levels: | Restriction | What you see | Lifts on its own? | |---|---|---| | Direct ban | The number can't connect; the app says the account isn't allowed to use WhatsApp | No; appeal from the app on the phone | | New-conversation restriction | Existing chats work; messages to new numbers fail with error F12 | Yes, at an end time WhatsApp sets | | Linked devices only | Same as above from the API, but the phone itself can still start new chats | Yes | If a number gets an F12, stop all first-contact messages until it lifts and keep answering people who write to you. Retrying only adds to the signal that caused it. ### A 14-day warm-up plan for new numbers Warming up means treating a new number like a new person on WhatsApp. It starts small, talks mostly with people who write back, and grows week by week. Safe Mode in the Maytapi console enforces this for you across five tiers, then switches itself off after day 14. The tiers are our own heuristics from restricted numbers we have seen, not limits WhatsApp publishes, and like Safe Mode itself they are in beta: | Tier | Days | Msg/min | Min. gap | Msg/day | Sending window | New chats/day | Link in first message | Group actions | |---|---|---|---|---|---|---|---|---| | 1 | 0–1 | 1 | 45s | 60 | 6h | 8 | Blocked | Read-only | | 2 | 2–3 | 2 | 35s | 150 | 8h | 18 | Blocked | Read-only | | 3 | 4–7 | 3 | 18s | 400 | 10h | 40 | Allowed | Up to ~5 members at a time | | 4 | 8–10 | 5 | 9s | 800 | 12h | 90 | Allowed | Allowed | | 5 | 11–14 | 8 | 5s | 1500 | 16h | 150 | Allowed | Allowed | Timeline of the Safe Mode warm-up tiers from day 0 to day 14, with daily message caps and new chat limits, and no limits from day 15. From day 15, no limits apply. Even if you don't turn Safe Mode on, these numbers are a reasonable benchmark for your own sending. ![Safe Mode card in the Maytapi console at Tier 1 with its limits](/images/showcase/whatsapp-api-safe-mode-warm-up.webp) *Safe Mode at Tier 1: 1 message a minute, a 45-second minimum gap, 60 messages a day and 8 new chats, with links in first messages blocked.* What a good first week looks like in practice: - **Days 0–1.** Answer people who wrote to you, plus a handful of first contacts. No campaign. - **Days 2–3.** Normal support traffic. First messages still don't contain links. - **Days 4–7.** Links and group actions open up. Start adding outbound flows, but keep them to contacts who expect you. - **Days 8–14.** Raise volume gradually toward your real workload. The Warming Up tool in the console can build real conversation history alongside this: numbers in the warming pool message each other, so your number isn't sending into a void. ### How Safe Mode enforces the limits Safe Mode is set per number from the phone detail page in the console. It is only turned on when you ask for it: per number, or through an account option that switches it on for new phones you add later. It runs in one of two modes: - **Automatic.** Maytapi picks the tier from how long the number has been connected to Maytapi, then adjusts it using the reply rate and chat history described below. - **Starting tier.** You pick a starting tier yourself (Tier 1 for a brand-new SIM, a higher tier for a partly established number). The day counter starts when you switch it on and the number moves up one tier at a time on the calendar. Safe Mode is currently in beta, so the exact limits may be adjusted between releases. The difference between "delayed" and "rejected" matters when you build around it: | Limit | What happens | Result | |---|---|---| | Messages per minute, minimum gap | The message waits in the queue and goes out when allowed (with small random variation in the gap) | Sent, just later | | Link in a first message to a new chat | Not sent | Error F13 | | Daily cap, daily sending window, new chats per day | Not sent until the counters reset at midnight UTC. The sending window opens with the day's first sent message and closes after the tier's hours | Error F14 | | Group actions in early tiers | Not performed | Error F15 | Safe Mode itself never drops a message silently: every rejection comes back with an error code, and a rejected message doesn't count against your quota. Delayed messages still follow the normal queue rules, so a message that waits in the queue for more than two days expires. The phone detail page shows today's usage: messages sent against the cap, new chats left, and how long the sending window stays open. In automatic mode, two things can shift the tier the number actually uses. If you pick a starting tier yourself, the calendar alone decides: - **Reply rate.** If fewer than 15% of recent messages get an answer, the number is held at Tier 2 at most. If more than 50% do, it runs two days ahead of the calendar. This only kicks in once there are at least 20 messages to measure. - **Existing chat history.** An account with no chats at all is held at the strictest tier. One with 30+ chats can start from Tier 3. > **Note:** Safe Mode reduces risk; it doesn't undo a ban. If a number is already directly banned, only WhatsApp can reverse it. Appeal from the app on the phone, and don't keep re-scanning the QR code. ### WhatsApp's own new-chat limit and getNewChatLimit Separately from Safe Mode, WhatsApp itself has started limiting how many new contacts some numbers can message per cycle. WhatsApp is currently testing this, and for now it only appears on new WhatsApp accounts. When the quota is used up, messages to new numbers fail with error F16. Existing chats keep working, and new chats open again when WhatsApp resets the cycle. You can read the quota before you hit it with `GET /getNewChatLimit`: ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/getNewChatLimit" \ -H "x-maytapi-key: $TOKEN" ``` ```json { "success": true, "data": { "limited": false, "status": "none", "capped": false, "limit": null, "used": 0, "remaining": null, "cycleStart": 1704067200, "cycleEnd": 1706745599, "timestamp": 1704702074, "oteStatus": "not_eligible" } } ``` When `limited` is `false`, WhatsApp applies no quota to the number, and `limit` and `remaining` are `null`. When it is `true`, `limit`, `used` and `remaining` are integers, `capped` becomes `true` once the quota is exhausted, and `cycleStart`/`cycleEnd` are Unix seconds. A simple guard before a batch of first contacts: ```js const res = await fetch( `https://api.maytapi.com/api/${PRODUCT_ID}/${PHONE_ID}/getNewChatLimit`, { headers: { "x-maytapi-key": TOKEN } } ); const { data } = await res.json(); if (data.limited && (data.capped || data.remaining === 0)) { const resetsAt = new Date(data.cycleEnd * 1000); console.log(`New-chat quota used up; resumes after ${resetsAt.toISOString()}`); } else { // safe to send first contacts (up to data.remaining when limited) } ``` ### Content, consent and list hygiene Pacing only goes so far if the messages themselves invite reports. The habits that help most: - **Get opt-in.** Message people who gave you their number and expect to hear from you: customers, sign-ups, existing contacts. Say who you are in the first line and make opting out easy ("Reply STOP to stop these updates"), then honor it. - **Start conversations, not campaigns.** Ask a question or give a reason to reply. Replies are the best signal a number can have. - **Vary your text.** Use the customer's name, order details and a few phrasing variants instead of one identical message for everyone. - **Keep links for the second message.** Send the first message without a link and follow up once there is a conversation. - **Check numbers before you send.** Messages to numbers that aren't on WhatsApp never arrive, and an old list full of dead numbers makes every campaign look colder. Run the list through `checkPhones` first, as shown in our [bulk WhatsApp number check guide](/blog/bulk-whatsapp-number-check). - **Move broadcasts to a channel.** For news and announcements, a [WhatsApp Channel](/features/whatsapp-channel-api) reaches followers who opted in, without opening hundreds of one-to-one chats. - **Keep the session in the right region.** A connection from far away from where the number is registered is an extra risk signal. A proxy in the right region keeps this consistent. Checklist of content and consent habits that reduce ban risk: opt-in, conversations, varied text, links later, number checks and channels for broadcasts. ### Next steps Turn on Safe Mode for every new or recently restricted number before its first message. You'll find it on the phone detail page in the [Maytapi console](https://console.maytapi.com/register). Our [ban prevention page](/whatsapp-api-ban-prevention) explains how to diagnose a restriction you already have. If you're still choosing between the official Cloud API and an unofficial one, read our [WhatsApp Business API alternative](/whatsapp-business-api-alternative) page for the trade-offs. ### WhatsApp Appointment Reminders via API: Cut No-Shows URL: https://maytapi.com/blog/whatsapp-appointment-reminders Published: 2026-07-28, updated: 2026-10-27 Category: Tutorials > Send WhatsApp appointment reminders 24h and 2h before, let customers confirm, reschedule or cancel from a list, and update bookings from your webhook. A booked slot that nobody shows up for costs a clinic, salon or repair service the full price of that hour. Most no-shows are not bad intent: people forget, or plans change and they don't get round to calling. A WhatsApp reminder the day before, with a one-tap way to confirm, reschedule or cancel, fixes most of that. This guide builds WhatsApp appointment reminders with the Maytapi API: a scheduled job that sends a reminder 24 hours and 2 hours before each booking, a webhook that reads the customer's answer and updates the booking, and a calendar link once the slot is confirmed. The example is in Node.js, with notes on quiet hours, pacing and opt-outs. Maytapi links your own WhatsApp number as a linked device and drives it over REST, so reminders come from the same number customers already chat with, without message templates or per-message fees. All phone-level endpoints live under `https://api.maytapi.com/api/PRODUCT_ID/PHONE_ID/` and take your token in the `x-maytapi-key` header. If you haven't connected a number yet, start with [getting started](/blog/whatsapp-api-getting-started). ### How the reminder flow works The whole system is two small pieces that share one bookings table: 1. **A scheduler** runs every minute, finds bookings that are due for a reminder and sends one with `POST /sendMessage`. 2. **A webhook handler** receives the customer's answer, maps it to confirm, reschedule or cancel, updates the booking and replies. | Moment | What you send | Why | |---|---|---| | Booking created | Nothing yet, run `checkPhones` | Find out early whether the number is on WhatsApp | | 24 hours before | List message: Confirm / Reschedule / Cancel | Gives time to refill a cancelled slot | | Customer confirms | Text with a calendar link | Puts the appointment in their calendar | | 2 hours before | Short text reminder, pending or confirmed bookings | Catches people who forgot on the day | Appointment reminder flow: check the number at booking, send a list reminder 24 hours before, read the answer on the webhook, send a calendar link on confirm, and a short text 2 hours before. Keep the booking state simple: `pending`, `confirmed`, `reschedule_requested` and `cancelled`, plus two timestamps (`reminded_24h_at`, `reminded_2h_at`) so a reminder is never sent twice. ### Check the number when the booking is made Booking forms collect phone numbers in every possible format, and some customers aren't on WhatsApp at all. Check each new number once with `POST /checkPhones`, when the booking is created, rather than finding out the night before. The number must be digits only in international format, without `+` or spaces. ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/checkPhones" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"numbers": ["905301234567"]}' ``` ```json { "success": true, "data": [ { "status": "valid", "valid": true, "id": { "server": "c.us", "user": "905301234567", "_serialized": "905301234567@c.us" } } ] } ``` Store `valid` on the booking. If it is `false`, send that customer's reminders by SMS or email instead. The check sends nothing to the number. For cleaning a whole customer list in batches, see the [bulk number check guide](/blog/bulk-whatsapp-number-check). ### Send the 24-hour reminder as a list message A list message shows your text with one button. Tapping it opens a sheet of rows, and each row has a `rowId` that comes back to your webhook when the customer picks it. Put the booking id into the `rowId` so the answer can be matched to the right appointment even if the customer has more than one booking. ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/sendMessage" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "to_number": "905301234567@c.us", "type": "list", "title": "Appointment reminder", "message": "Hi Ayse, this is Smile Dental. Your check-up is on Thursday at 10:30.\n\nReply 1 to confirm, 2 to reschedule, 3 to cancel.", "footer": "Reply STOP to stop reminders", "button_text": "Choose an option", "sections": [ { "title": "Your appointment", "rows": [ { "rowId": "confirm:1042", "title": "Confirm", "description": "I will be there" }, { "rowId": "reschedule:1042", "title": "Reschedule", "description": "Pick another time" }, { "rowId": "cancel:1042", "title": "Cancel", "description": "Free the slot" } ] } ] }' ``` Three details matter here: - **The body works without the list.** "Reply 1 to confirm, 2 to reschedule, 3 to cancel" covers customers on older apps, people who just type a reply, and anyone who ignores the button. - **Say who you are in the first line.** A reminder from an unknown number reads like spam; the clinic name in the opening sentence doesn't. - **Use a list rather than reply buttons.** Reply-button taps don't arrive as webhook events on every connection, while list choices do. The [interactive messages guide](/blog/whatsapp-interactive-messages-buttons-list-carousel) compares the formats. The 2-hour reminder can be plain text, sent only to bookings that are `confirmed` or still `pending`: ```json { "to_number": "905301234567@c.us", "type": "text", "message": "See you at 10:30 today, Ayse. Smile Dental, Bagdat Cd. 120. Reply 3 if you can't make it." } ``` ### Schedule reminders with a simple cron loop The scheduler below runs as one long-lived Node.js process and wakes up every minute. It picks bookings whose reminder window has started, skips anything already sent, opted out or outside quiet hours, and paces the sends. `db` stands for your own data layer; the queries are described in comments. ```js const { PRODUCT_ID, PHONE_ID, TOKEN } = process.env; const BASE = `https://api.maytapi.com/api/${PRODUCT_ID}/${PHONE_ID}`; const HEADERS = { "x-maytapi-key": TOKEN, "Content-Type": "application/json" }; const TZ = "Europe/Istanbul"; const SEND_FROM = 9, SEND_UNTIL = 20; // local hours when reminders may go out const HOUR = 60 * 60 * 1000; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const pace = () => sleep(8000 + Math.random() * 12000); // 8-20 s between sends async function send(payload) { const r = await fetch(`${BASE}/sendMessage`, { method: "POST", headers: HEADERS, body: JSON.stringify(payload) }); const body = await r.json(); if (!body.success) throw new Error(body.message); return body; } const local = (d, opts) => new Intl.DateTimeFormat("en-GB", { timeZone: TZ, ...opts }).format(d); const localTime = (d) => local(d, { hour: "2-digit", minute: "2-digit" }); function inSendWindow(now) { const hour = Number(local(now, { hour: "numeric", hourCycle: "h23" })); return SEND_FROM <= hour && hour < SEND_UNTIL; } function reminder24h(b) { const when = `${local(b.starts_at, { weekday: "long" })} at ${localTime(b.starts_at)}`; const rows = [ { rowId: `confirm:${b.id}`, title: "Confirm", description: "I will be there" }, { rowId: `reschedule:${b.id}`, title: "Reschedule", description: "Pick another time" }, { rowId: `cancel:${b.id}`, title: "Cancel", description: "Free the slot" } ]; return { to_number: `${b.phone}@c.us`, type: "list", title: "Appointment reminder", message: `Hi ${b.first_name}, this is Smile Dental. Your ${b.service} is on ${when}.\n\n` + "Reply 1 to confirm, 2 to reschedule, 3 to cancel.", footer: "Reply STOP to stop reminders", button_text: "Choose an option", sections: [{ title: "Your appointment", rows }] }; } async function tick() { const now = new Date(); if (!inSendWindow(now)) return; // bookings starting 6-26 h from now, wa_valid, not opted out, // status pending, reminded_24h_at IS NULL (closer bookings only get the 2 h text) for (const b of await db.due24h(new Date(+now + 6 * HOUR), new Date(+now + 26 * HOUR))) { await send(reminder24h(b)); await db.mark(b.id, { reminded_24h_at: now }); await pace(); } // bookings starting within the next 2 h, status pending or confirmed, // reminded_2h_at IS NULL for (const b of await db.due2h(now, new Date(+now + 2 * HOUR))) { await send({ to_number: `${b.phone}@c.us`, type: "text", message: `See you at ${localTime(b.starts_at)} today, ${b.first_name}. Reply 3 if you can't make it.` }); await db.mark(b.id, { reminded_2h_at: now }); await pace(); } } while (true) { try { await tick(); } catch (err) { console.error("reminder tick failed:", err.message); } await sleep(60 * 1000); } ``` A few choices in this loop are deliberate. The 24-hour window is wide (6 to 26 hours) so that a booking whose ideal reminder time falls at night still gets one the evening before or the next morning. It stops six hours out so a booking made on the day doesn't get the list and then, minutes later, the 2-hour text. The 2-hour reminder is simply not sent if it falls inside quiet hours; an 8:00 appointment is covered by the reminder from the day before. And each booking is marked as soon as its message is accepted, so a crash or restart never sends the same reminder twice. If you'd rather use system cron, run `tick()` from a script every minute instead of the `while` loop. Just make sure two runs can't overlap, for example with a lock file or a database lock. ### Handle replies on the webhook When a customer picks a list row, it reaches your webhook as an ordinary incoming `message` event with `message.type` set to `text`. `message.text` holds the row title they saw ("Confirm") and `message.payload` holds your `rowId` ("confirm:1042"). A typed "1" arrives the same way, without a `payload`. That makes one handler enough for both: ```json { "product_id": "PRODUCT_ID", "phone_id": 12345, "type": "message", "user": { "id": "905301234567@c.us", "name": "Ayse", "phone": "905301234567" }, "conversation": "905301234567@c.us", "message": { "type": "text", "text": "Confirm", "payload": "confirm:1042", "fromMe": false } } ``` The Express handler acknowledges first, ignores your own messages, and resolves the action from the payload when present, otherwise from the typed text: ```js import express from "express"; const app = express(); const TYPED = { "1": "confirm", "2": "reschedule", "3": "cancel", confirm: "confirm", reschedule: "reschedule", cancel: "cancel" }; const STOP_WORDS = new Set(["stop", "unsubscribe"]); app.post("/maytapi/webhook", express.json(), async (req, res) => { res.sendStatus(200); // acknowledge first const event = req.body; const msg = event.message || {}; if (event.type !== "message" || msg.fromMe || msg.type !== "text") return; const phone = (event.user || {}).phone; const chat = event.conversation; const text = (msg.text || "").trim().toLowerCase(); if (STOP_WORDS.has(text)) { await db.optOut(phone); await send({ to_number: chat, type: "text", message: "Done, you won't get appointment reminders from us anymore." }); return; } let action = null, booking = null; if (msg.payload && msg.payload.includes(":")) { const i = msg.payload.indexOf(":"); action = msg.payload.slice(0, i); booking = await db.get(msg.payload.slice(i + 1)); } else if (Object.hasOwn(TYPED, text)) { action = TYPED[text]; booking = await db.nextUpcomingForPhone(phone); } if (!booking || booking.phone !== phone || !["confirm", "reschedule", "cancel"].includes(action)) { return; // not a reminder answer: leave it for staff } await handleAction(booking, action, chat); }); app.listen(3000); ``` The `booking.phone != phone` check stops a forwarded or guessed `rowId` from changing someone else's appointment. Typed answers are matched to the customer's next upcoming booking, which is right in nearly every case. Anything the handler doesn't understand is left alone so a person can answer it. In some chats WhatsApp identifies people by a LID instead of a phone number. If `user.phone` doesn't match your booking records, the [LID to phone number guide](/blog/whatsapp-lid-to-phone-number) shows how to resolve it. ### Update the booking and send a calendar link Each action updates the booking and sends one short answer. On confirm, include an "add to calendar" link. Google Calendar accepts a plain URL with the event details in the query string, so no calendar API access is needed: ```js function calendarLink(b) { const fmt = (d) => d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, ""); // 20261029T070000Z const end = new Date(+b.starts_at + b.duration_min * 60 * 1000); return "https://calendar.google.com/calendar/render?" + new URLSearchParams({ action: "TEMPLATE", text: `${b.service} at Smile Dental`, dates: `${fmt(b.starts_at)}/${fmt(end)}`, location: "Bagdat Cd. 120, Istanbul", details: `Booking #${b.id}. Reply 3 on WhatsApp to cancel.` }); } async function handleAction(b, action, chat) { let message; if (action === "confirm") { await db.update(b.id, { status: "confirmed" }); message = `Thanks, you're confirmed. Add it to your calendar: ${calendarLink(b)}`; } else if (action === "reschedule") { await db.update(b.id, { status: "reschedule_requested" }); message = `No problem. Pick a new time here: https://example.com/book?ref=${b.id}`; } else { await db.update(b.id, { status: "cancelled" }); await db.offerSlotToWaitlist(b); message = "Your appointment is cancelled. Book again any time on this chat."; } await send({ to_number: chat, type: "text", message }); } ``` For rescheduling, a booking page link is the simplest option. If you want to keep it inside WhatsApp, send a second list whose rows are free slots, with ids like `slot:1042:2026-10-30T14:00`, and handle them in the same webhook. A cancelled slot is the best moment to message your waitlist, which is often where the real savings come from. ### Quiet hours, pacing and opt-outs Reminders are messages people expect, which is why they are among the safest things to automate on WhatsApp. They can still go wrong if the sending looks like a blast. Maytapi is an unofficial API, so the reminders go out from a normal WhatsApp account and WhatsApp's terms and anti-spam limits apply to that number. Ask for consent to WhatsApp reminders when the booking is made, and keep health details such as diagnoses or treatment names out of the message text. A few rules keep them friendly: - **Respect quiet hours in the customer's time zone.** The loop above only sends between 9:00 and 20:00 local time. If you serve several regions, store a time zone per booking. - **Pace the sends.** A clinic with 80 appointments tomorrow shouldn't push 80 lists in one second. A random 8 to 20 second gap spreads them over about 20 minutes, which is fine for a reminder. - **Personalize every message.** The name, service and time make each text different. The API also has a duplicate filter that drops the same content sent to the same chat in a short window, which protects you from loops in your own code. - **Honor STOP immediately.** The handler stores the opt-out and the scheduler checks it before every send. Keep the footer line so the option is always visible. - **Only message people who booked.** Reminders work because the customer gave you the number for this purpose. Don't reuse the list for promotions without asking. Checklist for safe reminder sending: quiet hours in the customer's time zone, paced sends, personalized text, immediate STOP handling and messaging only people who booked. The [ban prevention and Safe Mode guide](/blog/whatsapp-ban-prevention-safe-mode) covers warm-up and limits if the number is new. ### Practical tips - **Track the outcome.** Log confirm, reschedule, cancel and no answer per booking. After a few weeks you'll know whether the 2-hour reminder pays for itself or only the 24-hour one matters. - **Call the unconfirmed.** A booking still `pending` a few hours before is your highest no-show risk. Put those on the front desk's call list. - **Keep the webhook fast.** Return 200 first, then do the database work, as in the [webhook guide](/blog/whatsapp-webhook-events-guide). - **Test on your own number.** Book a fake appointment for yourself, shift `starts_at` to 25 hours ahead and watch the full cycle before going live. ### Next steps - Learn the request formats for text, media and interactive messages in the [send message guide](/blog/whatsapp-api-send-message). - Compare list messages, buttons and carousels in [the interactive messages guide](/blog/whatsapp-interactive-messages-buttons-list-carousel). - See the full webhook payloads for messages and receipts in the [webhook events guide](/blog/whatsapp-webhook-events-guide). - Turn the reply handler into a full assistant with [Build a WhatsApp Bot with Node.js](/blog/whatsapp-bot-nodejs). - Check every parameter of `sendMessage` and `checkPhones` in the [API documentation](/whatsapp-api-documentation). ### Send WhatsApp Messages from Form Submissions URL: https://maytapi.com/blog/whatsapp-form-submissions Published: 2026-07-14, updated: 2026-10-27 Category: Integrations > Turn Tally, Typeform, Google Forms or WordPress submissions into a WhatsApp confirmation for the lead and an instant alert for your sales group. Someone fills in your contact form, gets a generic "thanks, we'll be in touch" page, and then waits. Meanwhile the lead sits in an inbox until someone on the sales team notices it. WhatsApp closes that gap on both sides: the person who submitted the form gets a confirmation on their phone within seconds, and your sales group gets a lead alert where they already talk. This guide shows how to send WhatsApp messages from form submissions with Tally, Typeform, Google Forms or a WordPress or Drupal form: a small Node.js endpoint receives the form's webhook, cleans up the phone number, checks it with `POST /checkPhones`, sends both messages and labels the chat for follow-up. ### How the flow works Every form tool can call a URL when a form is submitted, either built in or through a plugin or script. Point that call at your own small endpoint, not at Maytapi directly. Your endpoint knows your token, your sales group and your rules, and it can turn each tool's payload into the same shape before anything is sent. | Step | What happens | Maytapi endpoint | |---|---|---| | 1 | The form tool posts the submission to your endpoint | | | 2 | Your code maps fields and normalizes the phone number | | | 3 | Check that the number is on WhatsApp | `POST /checkPhones` | | 4 | Send a confirmation to the person who submitted | `POST /sendMessage` | | 5 | Send a lead alert to the sales group | `POST /sendMessage` with an `@g.us` id | | 6 | Optionally label the chat for follow-up | `POST /addLabel` | Flow of a form submission to WhatsApp: form webhook reaches your endpoint, phone is normalized, checkPhones confirms it, a confirmation goes to the submitter and a lead alert to the sales group. All Maytapi calls go to `https://api.maytapi.com/api/PRODUCT_ID/PHONE_ID/` with your token in the `x-maytapi-key` header. If you haven't connected a number yet, start with [getting started](/blog/whatsapp-api-getting-started). Only message people who gave you their number for this purpose. Add a line under the phone field such as "We'll confirm your request on WhatsApp" so the message is expected, not a surprise. ### Map the form fields Each form tool sends its own JSON, so the first job of your endpoint is to pull out the same four values every time: name, phone, email and whatever the person asked about. Keep the per-tool details in one mapping function and let the rest of the code work on a clean object. | Form source | How it reaches your endpoint | Where the answers are | |---|---|---| | Tally | Webhook integration on the form | `data.fields`, an array with `label`, `type` and `value` | | Typeform | Webhook in the form's Connect settings | `form_response.answers`, matched by field id or ref | | Google Forms | Apps Script `onFormSubmit` trigger posting with `UrlFetchApp` | Item titles and responses you choose to send | | WordPress | A form plugin's webhook or "remote post" option, sometimes as an add-on | Usually a flat object keyed by field name | | Drupal Webform | The Remote post handler | Submission values keyed by element key | Field labels change when someone edits the form, so match on something stable where the tool offers it, such as a field id, a ref or the field type. Log the raw body of your first test submission from each tool and build the mapping from what you actually receive. Here is a trimmed Tally submission, showing only the parts this guide uses: ```json { "eventType": "FORM_RESPONSE", "data": { "formName": "Request a demo", "fields": [ { "key": "question_1", "label": "Name", "type": "INPUT_TEXT", "value": "Ayse" }, { "key": "question_2", "label": "Phone", "type": "INPUT_PHONE_NUMBER", "value": "+905301234567" }, { "key": "question_3", "label": "Company size", "type": "INPUT_TEXT", "value": "10-50" } ] } } ``` ### Normalize the phone number Maytapi expects international format: country code followed by the national number, digits only, with no `+`, spaces, dashes or brackets. Form fields return anything from `+90 530 123 45 67` to `0530 123 45 67`. A phone-type field with a country picker helps a lot, but plain text fields are common on WordPress and Drupal sites. ```js function normalizePhone(raw, defaultCc = "90") { const s = String(raw || "").trim(); const digits = s.replace(/\D/g, ""); if (!digits) return null; if (s.startsWith("+")) return digits; if (digits.startsWith("00")) return digits.slice(2); if (digits.startsWith("0")) return defaultCc + digits.slice(1); return digits; } ``` `normalizePhone("0530 123 45 67")` returns `905301234567`. Set `defaultCc` to the country most of your visitors are in, or better, use a phone field with a country selector so you rarely need the fallback. The [bulk number check guide](/blog/bulk-whatsapp-number-check) has more formatting traps, such as a trunk `0` left in after the country code. ### Check the number with checkPhones A normalized number can still be wrong. People mistype, and some numbers simply aren't on WhatsApp. `POST /checkPhones` takes an array of numbers and returns one result per number. Checking sends nothing to the person. ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/checkPhones" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"numbers": ["905301234567"]}' ``` ```json { "success": true, "data": [ { "status": "valid", "valid": true, "id": { "server": "c.us", "user": "905301234567", "_serialized": "905301234567@c.us" } } ] } ``` If `valid` is `true`, use `id._serialized` as the chat id for the confirmation and the label. If it's `false`, don't drop the lead: send the sales alert anyway with a note that the number isn't on WhatsApp, so someone can call or email instead. ### Build the endpoint in Node.js The handler below accepts a Tally-style body, but only `mapSubmission` knows that. Swap it for your own mapping for other tools. ```js import express from "express"; const { PRODUCT_ID, PHONE_ID, TOKEN, FORM_SECRET } = process.env; const BASE = `https://api.maytapi.com/api/${PRODUCT_ID}/${PHONE_ID}`; const headers = { "x-maytapi-key": TOKEN, "Content-Type": "application/json" }; const SALES_GROUP = "120363012345678901@g.us"; const NEW_LEAD_LABEL = "3"; // from GET /getLabels, optional async function api(path, body) { const r = await fetch(`${BASE}/${path}`, { method: "POST", headers, body: JSON.stringify(body) }); return r.json(); } function mapSubmission(body) { if (body?.source === "google-forms") { // from the Apps Script below const a = body.answers || {}; return { form: "Google Form", name: a["Name"] || "there", phone: a["Phone"], topic: a["Company size"] || "" }; } const fields = body?.data?.fields || []; const byLabel = (l) => fields.find((f) => f.label === l)?.value; const phoneField = fields.find((f) => f.type === "INPUT_PHONE_NUMBER"); return { form: body?.data?.formName || "Website form", name: byLabel("Name") || "there", phone: phoneField?.value || byLabel("Phone"), topic: byLabel("Company size") || "" }; } // At most one confirmation per number per 10 minutes and 3 per day, // so nobody can use your form to flood someone else's phone. const sent = new Map(); // phone -> { day, count, last } function allowConfirmation(phone) { const day = new Date().toISOString().slice(0, 10); const prev = sent.get(phone); const today = prev && prev.day === day ? prev : { day, count: 0, last: 0 }; if (today.count >= 3 || Date.now() - today.last < 10 * 60 * 1000) return false; sent.set(phone, { day, count: today.count + 1, last: Date.now() }); return true; } const app = express(); app.post("/forms/:secret", express.json(), async (req, res) => { if (req.params.secret !== FORM_SECRET) return res.sendStatus(404); res.sendStatus(200); // answer the form tool quickly try { const lead = mapSubmission(req.body); const phone = normalizePhone(lead.phone); let chatId = null; if (phone) { const check = await api("checkPhones", { numbers: [phone] }); const hit = check.success && check.data?.[0]; if (hit?.valid) chatId = hit.id._serialized; } if (chatId && allowConfirmation(chatId)) { await api("sendMessage", { to_number: chatId, type: "text", message: `Hi ${lead.name}, thanks for your request on *${lead.form}*. ` + `Our team will reach out shortly. Reply here if you have any questions.` }); if (NEW_LEAD_LABEL) { const label = await api("addLabel", { label_id: NEW_LEAD_LABEL, conversation_id: chatId }); if (!label.success) console.warn("addLabel failed (Business account?):", label.message); } } await api("sendMessage", { to_number: SALES_GROUP, type: "text", message: `New lead from *${lead.form}*\n` + `Name: ${lead.name}\nPhone: ${phone || lead.phone || "-"}\n` + `Details: ${lead.topic || "-"}\n` + (chatId ? "Confirmation sent on WhatsApp." : "Not on WhatsApp, follow up by phone or email.") }); } catch (err) { console.error("form handler failed", err); } }); app.listen(3000); ``` `normalizePhone` is the function from the previous section. A few notes on the code: - **Don't let the form message strangers.** Anyone can type someone else's number into a public form. The `allowConfirmation` cap above (kept in memory here; use Redis or your database in production) limits the damage, and a CAPTCHA on the form stops bots. Put a consent line such as "I agree to receive a WhatsApp message about my request" next to the phone field and send nothing without it. - **Answer first, work after.** Form tools expect a fast response and may retry or flag the webhook if your endpoint is slow. Return 200, then call Maytapi. - **Protect the URL.** A secret in the path is the simplest guard, because every form tool lets you paste a full URL. If the tool signs its requests (Tally and Typeform both offer a signing secret), verify the signature as well. - **Check `success` on every call.** Maytapi answers HTTP 200 with `success: false` and a `message` when something goes wrong, so read the body, not just the status code. - **`success: true` means queued.** A sent message is accepted into your phone's sending queue. Delivery and read receipts arrive later on your webhook, as described in the [send message guide](/blog/whatsapp-api-send-message). #### The sales group alert The lead alert is a normal text message with the group id as `to_number`. The group id ends in `@g.us`; list your groups with `GET /getGroups` to find it. Your connected number must be a member, and if the group only lets admins send, an admin. The [group messaging guide](/blog/send-message-to-whatsapp-group-api) covers both. Use `*bold*` for the form name and keep one field per line so the alert reads well on a phone. #### Label the chat for follow-up Labels are a WhatsApp Business feature, so `POST /addLabel` only works when the connected number is a WhatsApp Business account. Find the label's id with `GET /getLabels` (or create one with `POST /createLabel`), then attach it with `label_id` and `conversation_id`. Your sales team sees "New lead" on the chat in WhatsApp Business, and your code can list every labeled chat later with `GET /getLabels/{label_id}`. The [labels and CRM sync guide](/blog/whatsapp-labels-api-crm-sync) shows how to move contacts between stages from there. ### Google Forms with Apps Script Google Forms has no webhook setting, but Apps Script fills the gap. Open the form, go to the script editor, add the function below and create an installable trigger for it ("From form", "On form submit"). ```js function onFormSubmit(e) { const answers = {}; e.response.getItemResponses().forEach(function (r) { answers[r.getItem().getTitle()] = r.getResponse(); }); UrlFetchApp.fetch("https://example.com/forms/YOUR_FORM_SECRET", { method: "post", contentType: "application/json", payload: JSON.stringify({ source: "google-forms", answers: answers }) }); } ``` This posts to the same endpoint, so the Maytapi token stays on your server, not in the script. The `google-forms` branch at the top of `mapSubmission` reads `answers["Name"]` and `answers["Phone"]`; use the exact question titles of your form. ### WordPress and Drupal forms On WordPress, the options depend on the form plugin: some have a webhook feature built in, others offer it as an add-on, and some need a small custom hook. On Drupal, Webform's Remote post handler sends submissions to a URL you configure. In both cases point the request at your endpoint, send JSON if the plugin allows it, and map the field names you set on the form. If the plugin can only send form-encoded data, add `express.urlencoded({ extended: true })` to the route. Checklist for a production form-to-WhatsApp endpoint: secret URL, map by stable field ids, normalize phones, checkPhones before sending, alert sales even for invalid numbers, avoid duplicate messages. ### Practical tips - **Avoid duplicates.** People double-click submit and form tools retry. Store a submission id or the phone plus form name for a few minutes and skip repeats, so nobody gets two confirmations. - **Keep the confirmation short and specific.** Name the form or request, say what happens next and invite a reply. A reply turns the chat into a real conversation, and your [webhook](/blog/whatsapp-webhook-events-guide) receives it as a normal `message` event. - **Ask for a typed answer when you need one.** "Reply 1 to book a call" works on every connection and arrives as text. If you want menus, the [interactive messages guide](/blog/whatsapp-interactive-messages-buttons-list-carousel) explains why lists are more reliable than reply buttons. - **Watch volume on new numbers.** Maytapi is an unofficial API, so the number you connect can be restricted, and a sudden burst of first messages from a fresh number looks like spam to WhatsApp. Keep first messages expected and paced. Read [how to avoid WhatsApp bans with Safe Mode](/blog/whatsapp-ban-prevention-safe-mode) before you put the form on a busy landing page. - **Prefer no code?** The same steps fit an [n8n workflow](/blog/whatsapp-n8n-automation): a Webhook trigger node, a small Code node for the phone, and HTTP Request nodes for Maytapi. ### Next steps A form-to-WhatsApp endpoint is a small piece of code with a visible payoff: leads hear from you in seconds and your team sees them where they already work. Map the fields, normalize the phone, check it with `checkPhones`, then send the confirmation, the group alert and the label. - Clean and verify numbers at scale with the [bulk WhatsApp number check](/blog/bulk-whatsapp-number-check). - Format group alerts and mentions with [send a message to a WhatsApp group](/blog/send-message-to-whatsapp-group-api). - Keep lead stages in sync with the [labels and CRM sync guide](/blog/whatsapp-labels-api-crm-sync). - Handle replies from new leads with the [webhook events guide](/blog/whatsapp-webhook-events-guide). - Check every parameter in the [API documentation](/whatsapp-api-documentation). ### Forward WhatsApp Group Messages to Slack Channels URL: https://maytapi.com/blog/whatsapp-group-messages-to-slack Published: 2026-06-30, updated: 2026-10-27 Category: Integrations > A WhatsApp Slack integration with a Maytapi webhook and Node.js: map groups to channels, post sender, text and media, and reply from Slack threads. Many teams run part of their support or operations inside WhatsApp groups: a group per key customer, a group with field technicians, a group with couriers or suppliers. The conversations matter, but they live on one phone, and the rest of the team works in Slack. This guide shows how to forward WhatsApp group messages to Slack channels with a Maytapi webhook and a small Node.js server: map each group to a channel, post the sender, the text and a link to any media, skip your own messages, and, as an optional extra, answer from a Slack thread back into the WhatsApp group. It's a WhatsApp Slack integration you own end to end. > **Note:** You will be copying group members' messages, names and files into Slack. Tell the members of each mirrored group, and limit the Slack channels to the people who need them. ### How the forwarding works The flow has three parts, and Maytapi only needs to be configured once: 1. Your number is in the WhatsApp groups you want to mirror. It does not need to be an admin, because receiving group messages works for any member. 2. Maytapi posts every incoming message to your webhook URL as a `message` event. Group messages carry the group id, ending in `@g.us`, in `conversation`. 3. Your server looks up which Slack channel belongs to that group and posts a formatted message there. | What you need | Where it comes from | |---|---| | Maytapi product id, phone id and token | Maytapi console | | A webhook URL pointing at your server | `POST /setWebhook` or the console's Developers > Webhook page | | The group ids you want to forward | `GET /getGroups` | | A Slack Incoming Webhook URL, or a Slack app bot token | Your Slack app settings at api.slack.com | Flow of forwarding a WhatsApp group message to Slack: a member posts in the group, Maytapi sends a message webhook, the server drops fromMe and unmapped groups, then posts to the mapped Slack channel with chat.postMessage All Maytapi endpoints live under `https://api.maytapi.com/api/PRODUCT_ID/PHONE_ID/` and use your token in the `x-maytapi-key` header. If you haven't connected a number yet, start with [getting started](/blog/whatsapp-api-getting-started). To find the group ids, list your groups: ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/getGroups?sort=true&page=1" \ -H "x-maytapi-key: $TOKEN" ``` Each group in `data` has an `id` (like `120363012345678901@g.us`) and a `name`. Those ids are the keys of your group-to-channel map. The [group API guide](/blog/create-whatsapp-group-api) covers the rest of the response. ### What a group message webhook contains Here is an image someone posted in a group, as your webhook receives it (the `participants` list is left out for brevity): ```json { "type": "message", "product_id": "1a2b3c4d-...", "phone_id": 12345, "conversation": "120363012345678901@g.us", "conversation_name": "Acme - Support", "user": { "id": "905301234567@c.us", "name": "Ayse", "phone": "905301234567" }, "message": { "type": "image", "url": "https://cdn.maytapi.com/...", "mime": "image/jpeg", "caption": "Error on the checkout screen", "id": "false_120363012345678901@g.us_3EB0A81ECEA6D47F2F13_905301234567@c.us", "fromMe": false }, "receiver": "905307654321", "timestamp": 1761552000, "reply": "https://api.maytapi.com/api/1a2b3c4d-.../12345/sendMessage" } ``` The fields you'll use for Slack: | Field | Use | |---|---| | `conversation` | The group id. Look up the Slack channel with it | | `conversation_name` | The group's name, handy as a heading or fallback label | | `user.name`, `user.phone` | Who wrote the message. `name` falls back to the number when no name is known | | `message.type` | `text`, `image`, `video`, `document`, `audio`, `ptt` (voice note), `sticker` and more | | `message.text` | The body of a `text` message | | `message.url`, `message.caption`, `message.filename`, `message.mime` | Media messages: a link to the file, its caption, name and type | | `message.id` | The full message id. Save it if you want to reply to this message later | | `message.fromMe` | `true` for messages sent from your own number. Skip these | Two details to plan for. First, the media `url` is usually a direct file link. For very large files it can instead be an `api_media` link that fetches the file from your connected phone the first time it's opened, so it only works while the phone is connected. Second, group members who hide their number can arrive with an `@lid` id, and then `user.phone` isn't a phone number. [LID to phone number](/blog/whatsapp-lid-to-phone-number) explains how to handle them. Show the name and treat the number as best effort. Media links are not guaranteed to stay available forever, so if Slack history must keep the files, download them (see [download WhatsApp media](/blog/whatsapp-media-download)) and upload them to the channel with Slack's `files.uploadV2` instead of posting the link. Groups also produce events that aren't chat messages: members joining or leaving (`type: "info"` with a `group/...` subtype), reactions, poll votes, edits. Decide which ones your channel should see. Most teams forward only real messages and drop the rest. The [webhook guide](/blog/whatsapp-webhook-events-guide) lists every type. ### Choose how to post to Slack Slack gives you two standard ways to post into a channel. Comparison of Slack Incoming Webhooks and chat.postMessage for forwarding WhatsApp messages: webhooks are one URL per channel with no message id returned, chat.postMessage uses a bot token, returns ts and supports thread replies **Incoming Webhooks.** You create a Slack app, turn on Incoming Webhooks and add one webhook per channel. Each webhook URL posts to one fixed channel, and you send it `{"text": "..."}`. No token handling, no scopes. The catch: the response is just `ok`, so you don't get the posted message's id back, and you can't thread replies to it later. **The Web API's `chat.postMessage`.** You install the app with the `chat:write` scope, invite the bot to each channel and call `https://slack.com/api/chat.postMessage` with a bot token, the channel id and the text. The response includes `ts`, the message's timestamp id in Slack. You need that `ts` if you want two-way replies. | | Incoming Webhooks | `chat.postMessage` | |---|---|---| | Setup | One URL per channel | One bot token, bot invited to channels | | Channel choice | Fixed per URL | Any channel id per call | | Returns the Slack message id | No | Yes, as `ts` | | Two-way replies from threads | No | Yes, with the Events API | If you only need visibility, Incoming Webhooks are enough. The example below uses `chat.postMessage` so you can add replies later without rewriting anything. ### Forward group messages with Node.js The server below receives Maytapi webhooks, keeps only real messages from mapped groups, formats them and posts them to Slack. It uses Express and the built-in `fetch` of Node 18+. ```js import express from "express"; const { PRODUCT_ID, PHONE_ID, TOKEN, SLACK_BOT_TOKEN } = process.env; // WhatsApp group id -> Slack channel id const CHANNELS = { "120363012345678901@g.us": "C0123SUPPORT", "120363012345678902@g.us": "C0456FIELDOPS" }; const FORWARD = new Set(["text", "image", "video", "document", "audio", "ptt", "sticker"]); // Slack ts -> original WhatsApp message (for optional replies) const threads = new Map(); const app = express(); app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf; } })); const esc = (s = "") => s.replace(/&/g, "&").replace(//g, ">"); function format(body) { const { user = {}, message } = body; const who = `*${esc(user.name || user.phone || "Unknown")}* (${esc(user.phone || "")})`; if (message.type === "text") return `${who}\n${esc(message.text)}`; const label = esc(message.filename || message.type); const link = message.url ? `<${message.url}|${label}>` : `_${message.type} without a link_`; const caption = message.caption ? `\n${esc(message.caption)}` : ""; return `${who} sent ${message.type}: ${link}${caption}`; } async function postToSlack(channel, text) { const r = await fetch("https://slack.com/api/chat.postMessage", { method: "POST", headers: { Authorization: `Bearer ${SLACK_BOT_TOKEN}`, "Content-Type": "application/json; charset=utf-8" }, body: JSON.stringify({ channel, text }) }); const res = await r.json(); if (!res.ok) throw new Error(res.error); return res.ts; } app.post("/maytapi/webhook/7f3c9a", async (req, res) => { res.sendStatus(200); // answer fast, work afterwards const body = req.body; if (body.type !== "message" || body.product_id !== PRODUCT_ID) return; const message = body.message; if (!message || message.fromMe) return; const channel = CHANNELS[body.conversation]; if (!channel || !FORWARD.has(message.type)) return; try { const ts = await postToSlack(channel, format(body)); threads.set(ts, { conversation: body.conversation, messageId: message.id }); } catch (err) { console.error("Slack post failed:", err.message); } }); app.listen(3000); ``` Point Maytapi at it once: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/setWebhook" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"webhook": "https://example.com/maytapi/webhook/7f3c9a"}' ``` #### Why the filters matter - **`fromMe`.** Messages sent from your number, from the phone or through the API, also reach your webhook with `fromMe: true`. Skipping them keeps your own replies from being posted twice, and it is what prevents a loop once you add replies from Slack. - **The channel map.** Your number may be in dozens of groups, plus private chats. Only ids you list in `CHANNELS` get forwarded, so a new group never leaks into a Slack channel by accident. - **The type list.** `FORWARD` drops join and leave notices, reactions and poll votes. Add `info` if you want membership changes in the channel too. - **Escaping.** Slack treats `&`, `<` and `>` as control characters in message text, so escape them in anything that came from WhatsApp. #### Using Incoming Webhooks instead If you went with Incoming Webhooks, map each group to its webhook URL and replace `postToSlack` with a plain POST: ```js const HOOKS = { "120363012345678901@g.us": process.env.SLACK_HOOK_SUPPORT }; await fetch(HOOKS[body.conversation], { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: format(body) }) }); ``` Everything else stays the same, except that there is no `ts` to store, so the reply section below doesn't apply. ### Optional: reply from Slack back to the group With `chat.postMessage` you can let the team answer from Slack. Someone replies in the thread under a forwarded message, and your server sends that text to the WhatsApp group as a reply to the original message. This uses Slack's Events API: subscribe your app to the `message.channels` bot event (add `message.groups` for private channels), give it the matching `channels:history` or `groups:history` scope, and set the Request URL to your server. ```js import crypto from "node:crypto"; const BASE = `https://api.maytapi.com/api/${PRODUCT_ID}/${PHONE_ID}`; function fromSlack(req) { const ts = req.get("X-Slack-Request-Timestamp"); if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; const expected = "v0=" + crypto .createHmac("sha256", process.env.SLACK_SIGNING_SECRET) .update(`v0:${ts}:${req.rawBody}`) .digest("hex"); const got = req.get("X-Slack-Signature") || ""; return got.length === expected.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected)); } app.post("/slack/events", async (req, res) => { if (!fromSlack(req)) return res.sendStatus(401); const { type, challenge, event } = req.body; if (type === "url_verification") return res.json({ challenge }); res.sendStatus(200); if (!event || event.type !== "message" || event.subtype || event.bot_id) return; const origin = event.thread_ts && threads.get(event.thread_ts); if (!origin) return; await fetch(`${BASE}/sendMessage`, { method: "POST", headers: { "x-maytapi-key": TOKEN, "Content-Type": "application/json" }, body: JSON.stringify({ to_number: origin.conversation, type: "text", message: event.text, reply_to: origin.messageId }) }); }); ``` How the pieces fit: - `to_number` is the group id, which is how `sendMessage` targets a group. See [send a message to a WhatsApp group](/blog/send-message-to-whatsapp-group-api). - `reply_to` takes the full message id from the webhook, so the answer appears in WhatsApp quoting the customer's message. - Only thread replies under a forwarded message are sent. Top-level messages in the channel stay internal, which keeps team chatter out of the customer's group. - Bot messages (`bot_id`) and edits or deletions (`subtype`) are ignored, and the reply comes back to your Maytapi webhook with `fromMe: true`, where the first filter drops it. No loops in either direction. Slack sends text in its own format, so a user mention arrives as `<@U012AB3CD>` and `&` as `&`. Clean those up before sending if your team uses them. Also keep in mind that everyone in the Slack channel can now write to the group as your number, so use this only in channels where that's intended. ### Keep it reliable A forwarder that runs for months needs a few extra habits: - **Store the thread map.** The `threads` Map lives in memory and is lost on restart. In production, keep Slack `ts` to WhatsApp `message.id` in Redis or your database, with an expiry of a few days. - **Answer webhooks fast.** Send `200` first and post to Slack afterwards, so a slow Slack call never holds up a webhook. On the Slack side this matters even more: Slack retries events that aren't acknowledged within three seconds. - **Don't post twice.** Keep `message.id` for a while and skip ids you've already forwarded, in case an event reaches you more than once. - **Respect Slack's rate limits.** `chat.postMessage` allows roughly one message per second per channel. For busy groups, queue messages per channel instead of firing them all at once. - **Watch the connection.** If the phone disconnects, nothing reaches Slack. Handle `type: "status"` events from the same webhook and post an alert to an ops channel when the number goes to `qr-screen`, `idle` or `phone-error`. - **Mind privacy.** You're copying customer messages and file links into another tool. Make sure the group members are fine with that, and limit the Slack channels to the people who need them. ### Next steps Forwarding WhatsApp groups to Slack takes one webhook, a group-to-channel map and a short formatting function. Start with visibility, then add thread replies when the team is ready to answer from Slack. - Prefer no code? Build the same flow in n8n with the Maytapi trigger and n8n's Slack node, following the [n8n WhatsApp automation guide](/blog/whatsapp-n8n-automation). - See every event your server can receive in the [webhook events guide](/blog/whatsapp-webhook-events-guide). - Find and manage group ids with the [group API guide](/blog/create-whatsapp-group-api). - Learn the options for posting into groups in [send a message to a WhatsApp group](/blog/send-message-to-whatsapp-group-api). - Resolve hidden member numbers with [LID to phone number](/blog/whatsapp-lid-to-phone-number). - Build a full webhook server with the [WhatsApp bot in Node.js](/blog/whatsapp-bot-nodejs) guide, and check parameters in the [API documentation](/whatsapp-api-documentation). - Mirror your first group on the [3-day free trial](https://console.maytapi.com/register). ### Automate WhatsApp Client Onboarding Groups from Your CRM URL: https://maytapi.com/blog/whatsapp-client-onboarding-groups Published: 2026-06-16, updated: 2026-10-27 Category: Tutorials > Create a WhatsApp group when a deal is won: add the account team, set admins and settings, pin a welcome message, store the invite link and close it cleanly. Most agencies and B2B teams end up running client communication in WhatsApp groups, whether they planned to or not. The first week of every new account looks the same: someone creates a group, forgets to add the designer, gives the client admin rights by accident, posts a welcome message that scrolls away by Wednesday, and the group lives on long after the contract ends. This guide shows how to automate the whole lifecycle with the Maytapi API: create a client onboarding group when a deal is won in your CRM, add the account team, set admins and permissions, post and pin a welcome message, store the invite link, and close the group cleanly when the project ends. ### The onboarding group lifecycle Every step is a plain API call on the WhatsApp number you connected to Maytapi. That number creates the groups, so it becomes their admin and can manage them from code. | Step | Endpoint | Body or parameter | |---|---|---| | Create the group | `POST /createGroup` | `name`, `numbers`, `sendInvite`, `message`, `image`, `config` | | Make a teammate admin | `POST /group/promote` | `conversation_id`, `number` | | Take admin rights away | `POST /group/demote` | `conversation_id`, `number` | | Add or remove people | `POST /group/add`, `POST /group/remove` | `conversation_id`, `numbers` / `number` | | Settings, name, description | `POST /group/config` | `conversation_id`, `config` | | Welcome message | `POST /sendMessage` | `to_number`, `type: "text"`, `message` | | Pin it | `POST /sendMessage` | `type: "pinMessage"`, `reply_to`, `action`, `duration` | | Invite link | `GET /getGroups/{conversation_id}?invite=true` | | | Archive the chat | `POST /archiveConversations/{conversation_id}` | `action: "add"` | | Leave the group | `POST /leaveGroup` | `conversation_id` | All of them live under `https://api.maytapi.com/api/PRODUCT_ID/PHONE_ID/` and use your token in the `x-maytapi-key` header. If you haven't connected a number yet, start with the [getting started guide](/blog/whatsapp-api-getting-started). Client onboarding group lifecycle: deal won in the CRM, createGroup with client and team, promote the account lead, post and pin the welcome message, archive and leave when the project ends ### Create the group when the deal is won `createGroup` takes the group name and the numbers to add, and it can set the photo and the settings in the same call. For a client group, put the client's contacts and your account team in `numbers`: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/createGroup" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Corp x Studio North", "numbers": ["905301234567", "905301234568", "905301234569"], "sendInvite": true, "message": "Hi! This is the project group for Acme Corp and Studio North.", "image": "https://example.com/logos/acme-studio-north.png", "config": { "edit": "admins", "send": "all", "membersCanAddMembers": false, "approveNewMembers": true } }' ``` Numbers go in with the country code and no `+`. Maytapi tries to add each person directly. When someone's privacy settings don't allow being added to groups, `sendInvite: true` sends them a private invitation with your `message` instead. Set `sendInvite` explicitly rather than relying on a default. When some numbers couldn't be added, the response includes a `cannotAdd` object listing them, which is worth writing back to the CRM so the account manager knows who still has to accept. Tell the client at kickoff or in the contract that a WhatsApp group will be created, and that every member will see the others' numbers and your number. Adding people to a group they didn't expect is a common reason for reports, which put the number at risk. If you need to check numbers before the call, run them through the [bulk WhatsApp number check](/blog/bulk-whatsapp-number-check) first. The response's `data` is the new group, with its `id` (ending in `@g.us`), `name`, `participants`, `admins` and `config`. Store `data.id` on the deal record. Every later call uses it, and it's what stops you from creating a second group when the CRM sends the same event twice. The `config` above is a sensible default for client work: | Key | Value | Why | |---|---|---| | `edit` | `admins` | Only your team changes the group name, photo and description | | `send` | `all` | The client and the team can both talk | | `membersCanAddMembers` | `false` | The client can't add a cousin from procurement on their own | | `approveNewMembers` | `true` | Anyone joining through the link waits for approval | The `image` should be a public URL. A square image works best for group photos. ### Promote the account lead, demote when roles change Your connected number is the group's first admin. Promote the account lead too, so a person can still manage the group if your integration is down: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/group/promote" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"conversation_id": "120363012345678901@g.us", "number": "905301234569@c.us"}' ``` `group/promote` works on one participant per call, given in `number` in chat ID form. The response is just `success`. When an account manager moves to another client, reverse it with `group/demote` and the same body, then `group/remove` if they should leave the group entirely. New teammates come in with `group/add`, which takes a `numbers` array and the same `sendInvite` fallback as `createGroup`. Keep clients out of the admin list unless you have a reason. With `edit` set to `admins` and `membersCanAddMembers` off, the admins decide who joins and what the group is called. ### Set the description and lock down settings `group/config` changes any combination of settings. Send only the keys you want to change. The same call sets the description, which is a good place for the things people search for later: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/group/config" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "conversation_id": "120363012345678901@g.us", "config": { "description": "Account lead: Selin. Support hours 09:00-18:00. Urgent issues: reply URGENT." } }' ``` `edit` and `send` take `admins` or `all`. `disappear`, `membersCanAddMembers` and `approveNewMembers` take `true` or `false`, and `name` and `description` take text. The response returns the settings that were applied. ### Send and pin the welcome message The welcome message is an ordinary `sendMessage` call with the group ID in `to_number`: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/sendMessage" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "to_number": "120363012345678901@g.us", "type": "text", "message": "Welcome to the team! Your account lead is Selin.\n\nKickoff call: Monday 10:00\nShared folder: https://example.com/acme\n\nReply URGENT for anything blocking." }' ``` The response gives you `data.msgId`. Pinning uses `sendMessage` again, with `type: "pinMessage"`, `action: 1` and the message ID in `reply_to`: ```json { "to_number": "120363012345678901@g.us", "type": "pinMessage", "action": 1, "duration": 2592000, "reply_to": "msgId-from-the-welcome-message" } ``` `duration` is in seconds and takes WhatsApp's three pin lengths: `86400` (1 day), `604800` (7 days) or `2592000` (30 days). `action: 2` unpins. Messages go through your phone's sending queue, so pin after the welcome has actually been sent. A delay of a few seconds is usually enough. If the pin call fails, wait and try once more before you log it. Pins expire, so a long project needs a re-pin every 30 days, or a fresh welcome message for each phase. The [send message guide](/blog/whatsapp-api-send-message) covers the other message actions. ### Get the invite link Clients add colleagues over time. Instead of giving them admin rights, give them the invite link and keep approval mode on: ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/getGroups/120363012345678901@g.us?invite=true" \ -H "x-maytapi-key: $TOKEN" ``` The group in `data` now carries an `invite` field with the link. It's `false` when your number isn't an admin of the group and can't read the link. Save the link on the deal or account record, so the account manager can paste it into an email. Because `approveNewMembers` is on, people who open it become join requests. The [join requests guide](/blog/whatsapp-group-join-requests-api) shows how to approve them against your contact list automatically. If a link ends up in the wrong place, `group/revokeInvite` resets it. ### A Node.js handler for the CRM webhook Most CRMs can call a URL when a deal changes stage, through a workflow action or an outgoing webhook. Point it at a small Express endpoint. The payload shape below is a simplified example, so map the field names to whatever your CRM sends. Checklist for a reliable onboarding webhook: answer 200 first, skip deals that already have a group, send sendInvite explicitly, promote one person per call, pin after the welcome is sent, save group id and invite link on the deal ```js import express from "express"; const BASE = `https://api.maytapi.com/api/${PRODUCT_ID}/${PHONE_ID}`; const headers = { "x-maytapi-key": TOKEN, "Content-Type": "application/json" }; const post = (path, body) => fetch(`${BASE}/${path}`, { method: "POST", headers, body: JSON.stringify(body) }).then((r) => r.json()); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const app = express(); app.post("/crm/deal-won", express.json(), async (req, res) => { res.sendStatus(200); // answer the CRM first, then do the work const deal = req.body; // { id, company, clientPhones: [...], team: [{ phone, name, role }] } if (await db.groupIdForDeal(deal.id)) return; // CRM retries must not create a second group const team = deal.team.map((m) => m.phone); const created = await post("createGroup", { name: `${deal.company} x Studio North`, numbers: [...deal.clientPhones, ...team], sendInvite: true, message: `You're invited to the ${deal.company} project group.`, config: { edit: "admins", send: "all", membersCanAddMembers: false, approveNewMembers: true } }); if (!created.success) return console.warn("createGroup failed:", created.message); const groupId = created.data.id; await db.saveGroupId(deal.id, groupId); const lead = deal.team.find((m) => m.role === "lead"); if (lead) await post("group/promote", { conversation_id: groupId, number: `${lead.phone}@c.us` }); const welcome = await post("sendMessage", { to_number: groupId, type: "text", message: `Welcome, ${deal.company}! Your account lead is ${lead ? lead.name : "our team"}.\nReply URGENT for anything blocking.` }); await sleep(10000); // let the welcome leave the queue before pinning if (welcome.success) { await post("sendMessage", { to_number: groupId, type: "pinMessage", action: 1, duration: 2592000, reply_to: welcome.data.msgId }); } const info = await fetch(`${BASE}/getGroups/${groupId}?invite=true`, { headers }).then((r) => r.json()); if (info.success && info.data.invite) await crm.updateDeal(deal.id, { whatsappGroupLink: info.data.invite }); if (created.data.cannotAdd) await crm.addNote(deal.id, "Some contacts were invited instead of added."); }); app.listen(3000); ``` `db` and `crm` stand for your own storage and CRM client. Two things matter more than the rest. The handler answers 200 before doing anything slow, because CRMs retry webhooks that time out. And it checks for an existing group first, since a retry or a deal moved back and forth between stages would otherwise create duplicates. Check `success` on every response: `createGroup`, `group/promote` and `group/config` change the group, and on numbers in the stricter [Safe Mode](/blog/whatsapp-ban-prevention-safe-mode) tiers they can be blocked while sending and reading still work. If you'd rather not run a server, the same steps fit an [n8n workflow](/blog/whatsapp-n8n-automation) with a webhook trigger and HTTP request nodes. ### Watch the group on your webhook Group changes arrive on your [Maytapi webhook](/blog/whatsapp-webhook-events-guide) as `message` events with `message.type: "info"`, a `subtype` and usually a `participant`. A few are worth acting on for client groups: | Subtype | What to do | |---|---| | `group/remove`, `group/leave` | Alert the account lead when a client contact leaves | | `group/add`, `group/invite` | Add the new person to the CRM contact list | | `group/promote`, `group/demote` | Log admin changes made from a phone | Match `conversation` (the group ID) against the IDs you stored on deals to find the account. Participants who hide their number can appear as `@lid` IDs. The [LID to phone number guide](/blog/whatsapp-lid-to-phone-number) explains how to resolve them. ### Close the group when the project ends When the deal moves to "Closed" or the contract ends, run the same steps in reverse. Post a closing message with the support contact, then make the group read-only so it stays as a record: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/group/config" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"conversation_id": "120363012345678901@g.us", "config": {"send": "admins"}}' ``` If you want to keep the group but get it out of the way, archive the chat on your number: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/archiveConversations/120363012345678901@g.us" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"action": "add"}' ``` `action: "remove"` brings it back. Archiving only changes your number's chat list; members still see the group as before. When you're done with the group for good, leave it: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/leaveGroup" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"conversation_id": "120363012345678901@g.us"}' ``` Leave after a grace period, not on the same day, and only once the account lead is an admin, so a person can still answer a late question. The history stays on members' phones. A number that stays in every old client group keeps receiving their messages and webhook traffic, so regular cleanup keeps both quieter. Adding many people to new groups is one of the patterns WhatsApp watches for spam. Client onboarding is low volume and the people expect the group, which helps, but grow a new number slowly and use invitations when direct adds fail. ### Next steps A won deal can now produce a ready-to-use client group in seconds: the right people, the right admins, locked settings, a pinned welcome and an invite link on the CRM record. - Learn every group endpoint in more depth with the [group API guide](/blog/create-whatsapp-group-api). - Approve colleagues who join through the invite link with the [group join requests guide](/blog/whatsapp-group-join-requests-api). - Post updates, mentions and polls to client groups with [send messages to WhatsApp groups](/blog/send-message-to-whatsapp-group-api). - Track account stages on chats with the [Labels API and CRM sync](/blog/whatsapp-labels-api-crm-sync). - Check every parameter in the [API documentation](/whatsapp-api-documentation). ### Paid WhatsApp Group with Stripe: Auto Add and Remove Members URL: https://maytapi.com/blog/whatsapp-paid-group-stripe Published: 2026-05-26, updated: 2026-10-27 Category: Integrations > Sync a paid WhatsApp group with Stripe subscriptions: add buyers on checkout, send the invite link if adding fails, and remove them when the subscription ends. A paid WhatsApp group sounds simple: someone pays, they get in, and when they stop paying, they're out. Done by hand, it turns into a spreadsheet of numbers, a backlog of "I paid but I'm not in the group yet" messages, and former members who stay in for months because nobody remembered to remove them. This guide connects Stripe to a WhatsApp group with the Maytapi API so membership follows the subscription: the buyer is added when checkout completes, gets the invite link if WhatsApp won't let you add them, and is removed when the subscription ends. ### How the pieces fit together There is no Maytapi app to install in Stripe. The integration is two webhooks and a small table you own: 1. **The customer pays on Stripe Checkout.** Checkout collects their phone number along with the payment. 2. **Stripe calls your server** with `checkout.session.completed`. You verify the signature, save the phone number next to the subscription ID, and call `POST /group/add`. 3. **If WhatsApp refuses the add** because of the person's privacy settings, you send them the group's invite link in a private message. 4. **When the subscription ends**, Stripe sends `customer.subscription.deleted`. You look up the phone number and call `POST /group/remove`. Flow of a paid WhatsApp group: Stripe checkout completes, server verifies the webhook and stores the phone, Maytapi group/add adds the buyer or the invite link is sent, subscription deleted triggers group/remove All Maytapi calls use the base URL `https://api.maytapi.com/api/PRODUCT_ID/PHONE_ID/` and your token in the `x-maytapi-key` header. The connected number must be an admin of the group, since only admins can add and remove members. | Step | Stripe event | Maytapi endpoint | |---|---|---| | Grant access | `checkout.session.completed` | `POST /group/add` | | Check membership | | `GET /getGroups/{conversation_id}` | | Get the invite link | | `GET /getGroups/{conversation_id}?invite=true` | | Send the link privately | | `POST /sendMessage` | | Revoke access | `customer.subscription.deleted` | `POST /group/remove` | | Reset a leaked link | | `POST /group/revokeInvite` | ### Prepare the group and Stripe Checkout Create the group once (or use an existing one) and note its ID, which ends in `@g.us`. The [group API guide](/blog/create-whatsapp-group-api) shows how to create it and find IDs with `getGroups`. Then lock it down so only admins can add people: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/group/config" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "conversation_id": "120363012345678901@g.us", "config": { "membersCanAddMembers": false } }' ``` On the Stripe side, create the Checkout Session in `subscription` mode and turn on phone number collection, so the number arrives in the webhook as `customer_details.phone`: ```js const session = await stripe.checkout.sessions.create({ mode: "subscription", line_items: [{ price: "price_123", quantity: 1 }], phone_number_collection: { enabled: true }, success_url: "https://example.com/welcome", cancel_url: "https://example.com/pricing" }); ``` Stripe returns the number in international format, for example `+905301234567`. Maytapi wants digits only, country code first, so strip everything that isn't a digit before you store it. ### Store the subscription to phone mapping Every later decision depends on knowing which WhatsApp number belongs to which subscription. Keep one row per subscription: | Column | Example | Why | |---|---|---| | `subscription_id` | `sub_1Q...` | Key for the cancellation event | | `customer_id` | `cus_Q...` | Find the member from Stripe's dashboard | | `phone` | `905301234567` | Digits only, used for every Maytapi call | | `group_id` | `120363012345678901@g.us` | Lets one server run several paid groups | | `status` | `active`, `invited`, `removed` | What your app last did | Add a second table of processed Stripe event IDs. Stripe can deliver the same event more than once, and it retries any delivery your endpoint didn't acknowledge. Storing `event.id` after a successful run lets you skip duplicates without thinking about them. ### Add the buyer when checkout completes Here is the whole receiver in Node.js with Express and the official `stripe` package. The Stripe route must read the raw request body, because the signature check runs on the exact bytes Stripe sent. ```js import express from "express"; import Stripe from "stripe"; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET; const { PRODUCT_ID, PHONE_ID, TOKEN } = process.env; const BASE = `https://api.maytapi.com/api/${PRODUCT_ID}/${PHONE_ID}`; const headers = { "x-maytapi-key": TOKEN, "Content-Type": "application/json" }; const GROUP_ID = "120363012345678901@g.us"; const app = express(); app.post("/stripe/webhook", express.raw({ type: "application/json" }), async (req, res) => { let event; try { event = stripe.webhooks.constructEvent(req.body, req.headers["stripe-signature"], endpointSecret); } catch (err) { return res.status(400).send(`Webhook Error: ${err.message}`); } if (await db.eventSeen(event.id)) return res.sendStatus(200); try { await handle(event); await db.markEventSeen(event.id); res.sendStatus(200); } catch (err) { console.error(event.type, err.message); res.sendStatus(500); // Stripe will retry } }); async function handle(event) { const obj = event.data.object; if (event.type === "checkout.session.completed") { if (obj.mode !== "subscription") return; const phone = (obj.customer_details?.phone || "").replace(/\D/g, ""); if (!phone) return db.flagForManualReview(obj.id); await db.upsertMember({ subscription_id: obj.subscription, customer_id: obj.customer, phone, group_id: GROUP_ID }); const result = await grantAccess(phone); await db.setStatus(obj.subscription, result === "invited" ? "invited" : "active"); } if (event.type === "customer.subscription.deleted") { const member = await db.findBySubscription(obj.id); if (!member || member.status === "removed") return; await revokeAccess(member.phone); await db.setStatus(obj.id, "removed"); } } app.listen(3000); ``` `db` stands for your own storage. Keep `express.json()` off this route, or apply it only to your other routes, otherwise the body is parsed before Stripe's check sees it and every signature fails. #### What `group/add` tells you The add call takes the group ID and the buyer's number. Add one buyer per call, which makes the response easy to read: ```js async function maytapi(path, body) { const opts = body ? { method: "POST", headers, body: JSON.stringify(body) } : { headers }; const r = await fetch(`${BASE}/${path}`, opts); return r.json(); } async function isMember(phone) { const res = await maytapi(`getGroups/${GROUP_ID}`); return !!res.success && (res.data.participants || []).includes(`${phone}@c.us`); } async function grantAccess(phone) { if (await isMember(phone)) return "already"; const res = await maytapi("group/add", { conversation_id: GROUP_ID, numbers: [phone], sendInvite: true, message: "Welcome to Pro Members! Tap to join the group." }); if (!res.success) { if (/ALREADY_EXISTS/.test(res.message || "")) return "already"; throw new Error(`group/add failed: ${res.message}`); } if (res.data && (res.data.cannotAdd || res.data.inviteLink)) { await sendInviteLink(phone, res.data.inviteLink); return "invited"; } return "added"; } ``` There are three outcomes to handle: - **Added.** `success` is `true` and `data` has neither `cannotAdd` nor `inviteLink`. The person is in the group. Depending on the connection, `data` is `true` or the updated group object, so don't rely on its exact shape. - **Not added.** `success` is still `true`, but `data` includes `inviteLink`, and usually a `cannotAdd` object whose `cannotAdded` array lists the numbers that failed. This is what happens when someone's privacy settings only allow contacts to add them, or when the number isn't on WhatsApp. With `sendInvite: true`, people blocked by privacy also receive WhatsApp's own invitation card with your `message`. - **Call failed.** `success` is `false` and `message` explains why: the group wasn't found, your number isn't an admin, or the Safe Mode tier blocked the change. On some connections, adding someone who is already a member also comes back as `success: false` with an "already exists" message, which is why the code checks membership first and treats that message as done. Pass `sendInvite: true` explicitly. Don't rely on a default. #### Send the invite link when the add fails WhatsApp's invitation card is easy to miss, so follow it with a plain message that has the link. The add response usually carries `inviteLink` already. If it doesn't, read it from the group: ```js async function sendInviteLink(phone, link) { if (!link) { const res = await maytapi(`getGroups/${GROUP_ID}?invite=true`); link = res.success && res.data.invite; } if (!link) throw new Error("No invite link available"); await maytapi("sendMessage", { to_number: phone, type: "text", message: `Thanks for subscribing! Join the members group here: ${link}` }); } ``` `invite` holds the `https://chat.whatsapp.com/...` link, or `false` when your number can't get one (usually because it isn't an admin). The [send message guide](/blog/whatsapp-api-send-message) covers the rest of `sendMessage`. If you want to catch typos before paying customers wonder where their invite went, check the number first with the [bulk number check](/blog/bulk-whatsapp-number-check) endpoints. A number that isn't on WhatsApp should go to manual review, not to a retry loop. ### Remove the member when the subscription ends Stripe sends `customer.subscription.deleted` when a subscription actually ends: immediately for an instant cancellation, or at the end of the paid period if the customer cancelled with `cancel_at_period_end`. That's the right moment to take access away. `group/remove` takes one participant in `number`, in chat ID form: ```js async function revokeAccess(phone) { const res = await maytapi("group/remove", { conversation_id: GROUP_ID, number: `${phone}@c.us` }); if (!res.success && (await isMember(phone))) { throw new Error(`group/remove failed: ${res.message}`); } } ``` The response is just `success`. It's `false` when the person is no longer a participant (they left on their own) as well as when the call genuinely failed, so the code checks membership before deciding it's an error. A member who already left counts as removed. Don't confuse `checkout.session.expired` with a membership expiring. That event means a Checkout page was abandoned before payment. For fixed-length passes sold as one-time payments, store an end date in your table and run a daily job that calls the same `revokeAccess` for rows past it. Failed renewals go through Stripe's retry settings. Depending on your configuration, the subscription moves to `past_due` or `unpaid` (reported in `customer.subscription.updated`) before it's cancelled. Whether you remove people at `past_due` or wait for `deleted` is a business decision. Waiting is friendlier, and the code above already handles it. ### Optional: approval mode instead of direct adds Direct adds keep the experience instant, but they can fail on privacy settings, and an invite link sent to one buyer can be forwarded to anyone. Approval mode closes that gap. Turn on `approveNewMembers`, show the invite link on your welcome page, and anyone who opens it becomes a pending request instead of a member: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/group/config" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "conversation_id": "120363012345678901@g.us", "config": { "approveNewMembers": true } }' ``` A small job then lists requests with `GET /group/approvalRequestList/{conversation_id}` every minute or two, approves the numbers that have an active row in your table with `POST /group/approvalRequest/Approve`, and leaves or rejects the rest. WhatsApp doesn't send a webhook for new requests, so polling is how this works. The [group join requests guide](/blog/whatsapp-group-join-requests-api) has the full loop. Approval mode and direct adds also work together: admins can still add buyers directly, and the link only needs approval for everyone else. If a link leaks without approval mode on, reset it with `POST /group/revokeInvite`. The old link stops working, and the response returns the new one as `data.inviteLink`. ### Make it safe to run twice Webhooks arrive late, twice, or not in the order you expect. A few habits keep the group correct anyway: - **Skip processed events.** Check `event.id` before doing anything, and mark it only after the work succeeded, so a failed run is retried. - **Check membership before adding.** `getGroups/{conversation_id}` returns `participants` as `@c.us` IDs. Some participants who hide their number show up as `@lid` IDs instead, and `isMember` above does not match those, so a member with a LID looks like a non-member and the add runs again. The "already exists" branch catches that case; to avoid the extra call, resolve LIDs first as the [LID to phone number guide](/blog/whatsapp-lid-to-phone-number) explains. Use `?refresh=true` when the list looks stale, not on every call. - **Let your table win.** Before acting on an event, read the subscription's row. If it says `removed`, a late `checkout.session.completed` retry shouldn't put them back. - **Listen to the group too.** Your Maytapi webhook receives `info` messages for membership changes, with the person in `participant`. A join arrives as `group/add` or `group/invite` (joined through a link; on some connections link joins also come as `group/add`), and a departure as `group/remove` or `group/leave`. Treat each pair the same way: move a row from `invited` to `active` on a join, and note departures without touching the subscription. The [webhook events guide](/blog/whatsapp-webhook-events-guide) lists the payloads. - **Keep the handler fast.** If group calls make your Stripe endpoint slow, save the event and process it from a queue, then return `200` straight away. Checklist for an idempotent Stripe to WhatsApp group sync: skip processed event IDs, check membership before adding, let the subscription table win, listen to group webhooks, keep the Stripe handler fast To test the flow end to end, use the Stripe CLI: `stripe listen --forward-to localhost:3000/stripe/webhook` prints the webhook secret for local use, and `stripe trigger checkout.session.completed` sends a test event. Use a test group and your own second number while you're at it. Adding and removing members changes the group, so on numbers in the stricter [Safe Mode](/blog/whatsapp-ban-prevention-safe-mode) tiers these calls can be blocked or limited. Always read `success` and `message` instead of assuming the change happened. Know the risk before you sell access. Maytapi is an unofficial API, so the connected number can be restricted by WhatsApp, and if it is, your paying members lose the admin that manages their group. Use a dedicated number rather than your personal one, keep a second admin or a fallback channel such as email, and tell buyers that group members can see each other's numbers. ### Next steps Stripe already knows who is paying. With two webhook events, a phone column and three Maytapi calls, your WhatsApp group knows it too, and nobody has to manage the member list by hand. - Set up the group, its settings and admins with the [group API guide](/blog/create-whatsapp-group-api). - Gate the invite link with approval mode in the [group join requests guide](/blog/whatsapp-group-join-requests-api). - Post announcements and polls to members with [send a message to a WhatsApp group](/blog/send-message-to-whatsapp-group-api). - Track joins and leaves with the [webhook events guide](/blog/whatsapp-webhook-events-guide). - Check every parameter in the [API documentation](/whatsapp-api-documentation). ### WhatsApp Automation with Make.com: HTTP and Webhooks URL: https://maytapi.com/blog/whatsapp-make-integration Published: 2026-05-12, updated: 2026-10-27 Category: Integrations > Send WhatsApp messages from Google Sheets or Typeform and react to replies in Make.com using the HTTP module and a Custom webhook. Make.com (formerly Integromat) is a visual automation tool that connects hundreds of apps without code. There is no native Maytapi app in Make, but you don't need one: Maytapi is a plain REST API with a webhook, and Make has two generic modules that cover both directions. **HTTP > Make a request** sends WhatsApp messages, and **Webhooks > Custom webhook** starts a scenario whenever a message arrives on your number. This guide builds two scenarios with them: sending WhatsApp messages from Google Sheets or Typeform, and receiving WhatsApp replies to answer them or update a CRM. ### What you need - **A Maytapi account with a connected phone.** If you haven't linked a number yet, the [getting started guide](/blog/whatsapp-api-getting-started) is the place to start. The phone's status must be `active` before anything will send. - **Your product ID, phone ID and token.** They're on the **Product ID & Token** page in the console. The product ID and phone ID go in the URL, and the token goes in the `x-maytapi-key` header. - **A Make account.** Any plan works for these scenarios. Keep an eye on its operation allowance, which is covered at the end of this guide. Every phone-level call goes to `https://api.maytapi.com/api/PRODUCT_ID/PHONE_ID/`, and endpoints are written short below, like `POST /sendMessage`. The webhook is set at product level, one step up, at `https://api.maytapi.com/api/PRODUCT_ID/setWebhook`. ### How Make talks to the WhatsApp API | Direction | Make module | Maytapi side | |---|---|---| | Send a message | HTTP > Make a request | `POST /sendMessage` with `x-maytapi-key` | | Receive messages | Webhooks > Custom webhook | `POST /setWebhook` pointing at the Make hook URL | | Check the session | HTTP > Make a request | `GET /status` before a scheduled run | Because both modules are generic, every Maytapi endpoint is available to you, not just the ones a prebuilt app happens to expose. The trade-off is that you write the request yourself, so it's worth getting the first one right. ### Scenario 1: Send WhatsApp messages from Google Sheets or Typeform The trigger can be anything that produces a phone number and some text. Two common ones: - **Google Sheets > Watch New Rows** for a sheet your team fills in, such as appointments or order updates. (To do the same without Make, see [WhatsApp from Google Sheets](/blog/whatsapp-google-sheets).) - **Typeform > Watch Responses** for a lead or booking form. Make scenario for sending WhatsApp messages: a Google Sheets or Typeform trigger, an HTTP Make a request module calling POST sendMessage with the x-maytapi-key header, and a filter that checks success in the response. #### Configure the HTTP module Add **HTTP > Make a request** after the trigger and fill it in: | Field | Value | |---|---| | URL | `https://api.maytapi.com/api/PRODUCT_ID/PHONE_ID/sendMessage` | | Method | POST | | Headers | `x-maytapi-key`: your token, and `Content-Type`: `application/json` | | Body | Raw, content type JSON (application/json) | | Parse response | Yes | The request body is the same JSON you would send with `curl`. In Make you map the trigger's fields into it instead of typing values: ```json { "to_number": "{{1.phone}}", "type": "text", "message": "Hi {{1.name}}, your appointment is on {{1.date}}. Reply 1 to confirm or 2 to reschedule." } ``` `{{1.phone}}` stands for whatever field you pick from module 1 in Make's mapping panel. To check the request outside Make first, here is the equivalent call: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/sendMessage" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"to_number": "905301234567", "type": "text", "message": "Hello from Make"}' ``` A successful response looks like this: ```json { "success": true, "data": { "chatId": "905301234567@c.us", "msgId": "50d8b950-a67f-11eb-91cc-d97af631df95" } } ``` `success: true` means the message was accepted into your phone's sending queue. Store `msgId` in the sheet if you want to match delivery receipts later. The [send message guide](/blog/whatsapp-api-send-message) covers images, documents and the other message types you can put in the same body. #### Get the phone number format right Most failed sends in Make come from the phone column, not the API: - **Use the full international number, digits only.** `905301234567` works. Send it without `+`; a leading `+` is tolerated, but spaces, brackets and a national trunk `0` are not. A number shorter than 10 digits is rejected with `Invalid phone number`. - **Watch spreadsheet formatting.** Sheets may turn a long number into scientific notation or drop characters. Format the column as plain text, or clean it in Make with a text function such as `replace` before it reaches the body. - **Group ids work too.** To post into a group, map an id like `120363012345678901@g.us` into `to_number`. To check numbers before you send, [bulk number check](/blog/bulk-whatsapp-number-check) shows how `POST /checkPhones` tells you which ones are on WhatsApp. #### Keep the JSON valid When you map free text into a raw JSON body, a quote mark or line break in the value (a Typeform "comments" answer, for example) breaks the JSON and the request fails. If a mapped field can contain user-typed text, build the body with Make's **JSON > Create JSON** module and map its output into the HTTP body, so Make escapes the values for you. #### Handle errors from the response Many API errors come back with HTTP status 200 and `success: false` plus a `message`, so Make won't always flag them as failed runs. With **Parse response** turned on, add a filter after the HTTP module on `data.success` equal to `false` and route those bundles to a logging step, a sheet column or an email. Typical messages are `Invalid phone number` and `Found Message Dupe.`. That last one comes from the duplicate filter on `sendMessage`, which drops the same content sent to the same chat several times in a short window. It protects you from loops in a scenario. If a repeat is intentional, add `"skip_filter": true` to that request's body. #### Sending to a whole sheet Watch New Rows can return many rows in one run, and Make runs the HTTP module once per row. The API queues every message and your number sends them in order, but a long list of first-contact messages still looks like spam to WhatsApp. Add a **Tools > Sleep** module between rows for large batches, keep lists to people who expect to hear from you, and read [ban prevention and safe mode](/blog/whatsapp-ban-prevention-safe-mode) before you send in bulk. ### Scenario 2: Receive WhatsApp messages in Make The second direction starts with a webhook. Maytapi posts every event on your number to one URL as JSON, and Make gives you that URL. #### Create the Custom webhook 1. Start a new scenario and add **Webhooks > Custom webhook** as the trigger. 2. Click **Add**, name the hook and copy the URL Make generates, something like `https://hook.eu1.make.com/...`. 3. Leave the module listening for data while you do the next step. #### Point Maytapi at the hook Set the URL as your product webhook: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/setWebhook" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"webhook": "https://hook.eu1.make.com/your-hook-id"}' ``` Maytapi checks that the URL responds before saving it and returns `success: false` with a message if it doesn't. You can also paste the URL on the Developers > Webhook page in the console. If only one number should feed this scenario, give that phone its own URL with `POST /config` and `{"webhook": "..."}` instead, and the other phones keep using the product webhook. Now send a real WhatsApp message to your connected number from another phone. Make receives it and determines the data structure, so the fields appear in the mapping panel. An incoming text looks like this: ```json { "type": "message", "product_id": "1a2b3c4d-...", "phone_id": 12345, "user": { "id": "905301234567@c.us", "name": "Customer", "phone": "905301234567" }, "message": { "id": "3EB081AF26FADE6416D8", "type": "text", "text": "1", "fromMe": false }, "conversation": "905301234567@c.us", "receiver": "905307654321", "timestamp": 1574081887, "reply": "https://api.maytapi.com/api/1a2b3c4d-.../12345/sendMessage" } ``` If a field you need is missing from the panel, send another message and use **Redetermine data structure** on the webhook module. #### Filter before you act The same URL receives more than chat messages: delivery receipts (`type: "ack"`), session changes (`type: "status"`), your own outgoing messages and group notifications. Add a **Router** after the webhook and put a filter on each route. For a route that answers customers, require all of these: | Condition | Why | |---|---| | `type` equal to `message` | Skips receipts and status events | | `message.fromMe` equal to `false` | Skips messages sent from your own number, so the scenario never replies to itself | | `message.type` equal to `text` | Skips media, reactions, edits and notifications | | `conversation` does not contain `@g.us` | Optional: ignore group chats | A second route with `type` equal to `status` and `status` equal to `qr-screen`, `idle` or `phone-error` can email or message someone, because the number needs attention before it can send again. The [webhook events guide](/blog/whatsapp-webhook-events-guide) lists every event type and field if you want more routes. Make router filters for incoming WhatsApp webhooks: type equals message, message.fromMe equals false, message.type equals text, optionally skip group ids, and a separate route for status alerts. #### Reply to the sender Each message event carries `reply`, a ready-made `sendMessage` URL for the phone that received it. Don't map that field into the URL, though: the module sends your token to whatever URL it gets, and the webhook body is not authenticated. Use a fixed URL in an **HTTP > Make a request** module, `https://api.maytapi.com/api/PRODUCT_ID/PHONE_ID/sendMessage` with your own IDs, add the same `x-maytapi-key` header as before, and answer into the same chat. Keep the Make hook URL private, since anyone who has it can post events to your scenario: ```json { "to_number": "{{1.conversation}}", "type": "text", "message": "Thanks, your appointment is confirmed." } ``` Put the routes in a sensible order: a route with an extra filter such as `message.text` equal to `1` sends the confirmation, and a fallback route answers anything else with the options again. Typed replies like this work reliably on every connection. If you want a tappable menu, a list message is the better choice; see [interactive messages](/blog/whatsapp-interactive-messages-buttons-list-carousel) for the body format. #### Update a CRM or a sheet instead Replying is optional. The same filtered route can search for the contact in your CRM's Make module (or in the original Google Sheet) by `user.phone`, then update a status column, add a note with `message.text` or create a task for an agent. When WhatsApp hides a sender's number, mostly in groups, the ids end in `@lid` instead of `@c.us`; [LID to phone number](/blog/whatsapp-lid-to-phone-number) explains how to match those. For a confirmation flow, combine both scenarios: Scenario 1 sends "Reply 1 to confirm" from the sheet, and Scenario 2 marks the row as confirmed when the `1` arrives. ### Keep operations under control Make bills by operations, and every module that runs in a scenario uses some. A webhook scenario runs once per incoming event, so on a busy number the count adds up fast, and receipts are often the biggest share. Delivery receipts are off by default and only arrive once you enable them with setAckPreference. If you enabled them and no Make scenario uses them, turn them off again for the product: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/setAckPreference" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"ack_delivery": false}' ``` The per-phone `ack_delivery` flag in `POST /config` does the same for a single number. A few more habits help: - **Filter right after the webhook.** Bundles that fail the first filter stop there instead of running every module. - **Check the session on schedules.** For a daily send, a `GET /status` call at the start tells you whether the phone is connected before you spend operations on messages. - **Expect repeats.** A webhook delivery can arrive more than once. If a duplicate would cause harm, such as two CRM tasks, store `message.id` and skip ones you've already seen. If your volume outgrows your Make plan, or you'd like ready-made actions instead of hand-written requests, [n8n](/blog/whatsapp-n8n-automation) has a community Maytapi node and can be self-hosted. The API calls are the same, so moving a scenario over is mostly a matter of rebuilding the steps. ### Next steps Two generic Make modules are all it takes: HTTP > Make a request for `sendMessage` and Webhooks > Custom webhook for incoming events. Start with one send scenario, add a filtered receive scenario, and switch off what you don't need to keep operations low. - Learn every field your Custom webhook receives in the [webhook events guide](/blog/whatsapp-webhook-events-guide). - Send images, documents and replies with the [send message guide](/blog/whatsapp-api-send-message). - Compare with the community node in the [n8n WhatsApp automation guide](/blog/whatsapp-n8n-automation). - Turn form submissions into WhatsApp alerts with [WhatsApp form submissions](/blog/whatsapp-form-submissions). - Check every endpoint and parameter in the [API documentation](/whatsapp-api-documentation). ### Send WhatsApp Buttons, Lists and Carousels via API URL: https://maytapi.com/blog/whatsapp-interactive-messages-buttons-list-carousel Published: 2026-04-28, updated: 2026-10-27 Category: Features > Interactive WhatsApp messages without template approval: reply buttons, URL and call buttons, list menus and product-style carousels, with request examples. Plain text works, but a customer tapping "Confirm" is faster and easier to handle in code than a customer typing "yes I think so". With a WhatsApp buttons API you can send reply buttons, link and call buttons, list menus and swipeable carousels from your own number, with no Meta template review. This post shows the request for each format, explains how the customer's choice comes back to your webhook, and covers the limits worth knowing before you build a flow around them. ### What you can send: buttons, lists and carousels All interactive formats go through the same `POST /sendMessage` endpoint. Only the `type` field and its options change. | Format | `type` | Best for | Key fields | |---|---|---|---| | Reply buttons | `buttons` | Quick answers like Confirm / Cancel | `buttons[].id`, `buttons[].text` | | Link buttons | `buttons` | Tracking pages, payment links | `buttons[].url` | | Call buttons | `buttons` | Calling your support line | `buttons[].phoneNumber` | | Copy-code buttons | `buttons` | Coupons, one-time codes | `buttons[].code` | | List message | `list` | Menus with many options | `sections[].rows[]`, `button_text` | | Carousel | `carousel` | Products and offers side by side | `cards[]` (up to 10) | If your code must know what the customer picked, use a list: list replies reach your webhook on every connection. Reply-button taps do not reach it on every connection, as explained in [how replies reach your webhook](#how-button-and-list-replies-reach-your-webhook). Because Maytapi sends from your own number as a linked device rather than through Meta's Business Platform, there is no template approval step and no per-message fee. The trade-off is that you are automating a regular WhatsApp account under WhatsApp's terms, so keep interactive messages for people who expect to hear from you. The [ban prevention guide](/whatsapp-api-ban-prevention) explains what to avoid. ### Send WhatsApp reply buttons with the API A buttons message has a body (`message`) and an array of buttons. Each reply button has an `id`, which usually comes back to your code when the customer taps it, and a `text` label. `title`, `footer` and `media` are optional. ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/sendMessage" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "to_number": "905301234567@c.us", "type": "buttons", "title": "Your order #10482", "message": "Your order is ready to ship. Please confirm the delivery address.", "footer": "Maytapi Store", "buttons": [ { "id": "confirm", "text": "Confirm" }, { "id": "change_address", "text": "Change address" } ] }' ``` Choose ids your code can route on directly, such as `confirm` or `order_10482_confirm`. The label is for the customer and you may want to change or translate it later, while the id stays stable. #### Add an image or video header Since 13.03.2026, buttons messages accept a `media` field with a URL. The image appears above the body, which is useful for order confirmations, appointment cards and offers: ```json { "to_number": "905301234567@c.us", "type": "buttons", "message": "Your order is ready to ship.", "media": "https://example.com/order.jpg", "buttons": [{ "id": "confirm", "text": "Confirm" }] } ``` WhatsApp buttons message with a parcel image, order title and Confirm, Change address, Call us and Copy code buttons *A buttons message with a media header mixing reply, call and copy-code buttons. Reply-button taps usually come back to your webhook carrying the button id.* ### URL, call and copy-code buttons You can mix button kinds in one message. Instead of an `id`, give a button a `url` to open a link, a `phoneNumber` to start a call, or a `code` the customer can copy with one tap: ```json { "to_number": "905301234567@c.us", "type": "buttons", "message": "Track your parcel or call us with any questions.", "buttons": [ { "url": "https://example.com/track/10482", "text": "Track order" }, { "phoneNumber": "905301234567", "text": "Call us" }, { "code": "WELCOME10", "text": "Copy code" } ] } ``` Comparison of link, call and copy-code buttons, which act only on the phone, with reply buttons and lists, whose choices arrive on the webhook. Link, call and copy-code buttons act on the customer's phone: the link opens, the dialer starts, the code is copied. They do not send a reply into the chat, so do not expect a webhook event for them. If you need to know who clicked a link, track it on the page itself, for example with a per-customer URL. ### List messages for longer menus When a few buttons are not enough, send a list. The customer sees your message with a single button (`button_text`). Tapping it opens a sheet with titled sections, and each row has a `rowId`, a `title` and an optional `description`. ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/sendMessage" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "to_number": "905301234567@c.us", "type": "list", "title": "How can we help?", "message": "Choose a topic and we will connect you to the right team.", "footer": "Support", "button_text": "See options", "sections": [ { "title": "Support topics", "rows": [ { "rowId": "status", "title": "Order status", "description": "Track a recent order" }, { "rowId": "returns", "title": "Returns", "description": "Start a return or exchange" } ] } ] }' ``` Lists work well for support menus, branch pickers and appointment slots. Group rows into sections so the sheet stays easy to scan, and keep row titles short enough to read on a phone. Use the `description` line for the detail that helps someone choose, such as opening hours for a branch or the time zone for a slot, rather than repeating the title. A list is also a good first step in a bot: the selected `rowId` tells you which branch of the conversation to follow, and you can answer with a second list or a buttons message for the next decision. ### Carousel messages: swipeable cards Carousels, added on 21.04.2026, show several cards side by side. Each card has its own `media` (required), `title`, `body`, optional `footer` and its own buttons. The API reference sets the limits: up to 10 cards per carousel and up to 5 buttons per card. Cards accept the same reply, URL and call buttons as a buttons message. ```json { "to_number": "905301234567@c.us", "type": "carousel", "message": "New arrivals this week", "cards": [ { "title": "Classic sneaker", "body": "Sizes 38-45", "media": "https://example.com/sneaker.jpg", "buttons": [ { "id": "add_sneaker", "text": "Add to cart" }, { "url": "https://example.com/sneaker", "text": "Details" } ] }, { "title": "Canvas backpack", "body": "Free shipping", "media": "https://example.com/backpack.jpg", "buttons": [ { "id": "add_backpack", "text": "Add to cart" }, { "url": "https://example.com/backpack", "text": "Details" } ] } ] } ``` Give every reply button a card-specific id (`add_sneaker`, `add_backpack`) so that, when the tap reaches your webhook, you know which card it came from. Put a short typed alternative in the card text as well ("reply SNEAKER"), for the same reason as with buttons. If you sell from a WhatsApp Business catalog, a product or catalog message is the other option: [Sell on WhatsApp: Catalog, Products and Order Webhooks via API](/blog/whatsapp-catalog-api-orders) compares the two. ### How button and list replies reach your webhook When a customer picks a list row, the choice arrives at your webhook as an incoming `message` event, just like a typed message. It is delivered as text: `message.text` holds the row title the customer saw, and `message.payload` holds the `rowId` you defined. Reply-button taps usually arrive the same way, with the button `id` in `payload`, but not on every connection: on some connections a tap does not produce a `message` event and may not reach your webhook at all. Tap your own buttons while testing and log the events you receive to see which case you have, and use a list when your code must know the exact choice. A Node.js handler that routes on the id: ```js app.post("/maytapi/webhook", express.json(), async (req, res) => { res.sendStatus(200); const { type, message, conversation, reply } = req.body; if (type !== "message" || message.fromMe) return; const choice = message.payload || message.text; // id when present, label as a fallback if (choice === "confirm") { await fetch(reply, { method: "POST", headers: { "x-maytapi-key": TOKEN, "Content-Type": "application/json" }, body: JSON.stringify({ to_number: conversation, type: "text", message: "Thanks, your order is confirmed." }) }); } }); ``` Falling back to `message.text` keeps the flow working if a reply comes in without a payload, and it also covers customers who simply type the answer instead of tapping. Ask for a typed answer in the body too ("or reply CONFIRM"), so a button tap that never reaches your webhook doesn't leave the customer stuck. Log the raw event the first time you test each format, so you know exactly what your handler receives. The [webhook events guide](/blog/whatsapp-webhook-events-guide) lists the other fields in a `message` event. ### Limits and good practice Interactive messages are rendered by the WhatsApp app on the customer's side, and WhatsApp decides how they look. A few rules keep your flows robust: - **Make the body stand on its own.** Write the message so it still makes sense if the buttons are not shown or the customer ignores them, for example "Reply 1 to confirm" next to the button. - **Test on the devices your customers use.** Check the message on Android, iPhone and the desktop or web apps before a campaign, and repeat after WhatsApp updates. The [changelog](/changelog) records fixes for interactive sending over time, a sign that WhatsApp changes these formats. - **Respect the carousel limits.** Up to 10 cards, up to 5 buttons per card, media on every card. - **Use stable ids.** Route on `payload` when it is present, with the label or a typed keyword as the fallback. - **Do not blast interactive messages to cold lists.** Buttons do not change how WhatsApp judges unsolicited messages. Send them in conversations customers started or expect. Checklist of good practice for interactive messages: self-contained body, device testing, carousel limits, stable ids and no cold-list blasts. > **Note:** Link and call buttons do not produce webhook events. If a flow depends on knowing what the customer did, use a list, or reply buttons together with a typed fallback. ### Next steps - See every format with a phone preview on the [interactive messages API](/features/whatsapp-interactive-messages-api) page. - Try the request bodies in the [API documentation](/whatsapp-api-documentation), where every format has a ready-made example. - Put buttons into a full bot with [Build a WhatsApp Bot with Node.js](/blog/whatsapp-bot-nodejs). ### Build a WhatsApp Bot in Java: HttpClient, Webhooks and a List Menu URL: https://maytapi.com/blog/whatsapp-bot-java Published: 2026-04-14, updated: 2026-10-27 Category: Tutorials > Build a WhatsApp bot with Java 17, HttpClient and the JDK HTTP server: filter webhooks, route commands with a list menu and reply with images and PDFs. Java has shipped a usable HTTP client since Java 11 and a small HTTP server inside the JDK for much longer, so a WhatsApp bot doesn't need a framework. This tutorial builds a **WhatsApp bot in Java** with the Maytapi API, using Java 17, `java.net.http.HttpClient` for outgoing requests, the JDK's `com.sun.net.httpserver` for the webhook and Jackson for JSON. You'll send a first message, receive webhook events, route commands through a list menu and reply with images and documents. A Spring Boot version of the webhook comes at the end. Maytapi is an unofficial WhatsApp API. You link your own WhatsApp number as a device by scanning a QR code, the same way WhatsApp Web works, and then control it over REST and webhooks. There are no message templates and no per-message fees. The other side of that is that your bot speaks from a normal WhatsApp account, so WhatsApp's anti-spam rules apply to it. The production section links to the details. ### What you need - **Java 17 or newer.** The code uses switch expressions and `var`, and nothing newer than that. - **Maven or Gradle** with one dependency, `com.fasterxml.jackson.core:jackson-databind`. Any recent 2.x version works. - **A Maytapi account** with a connected phone. The [free trial](https://console.maytapi.com/register) runs for three days and doesn't ask for a card. Scan the QR code in the console with the number the bot should use. - **Your product ID, token and phone ID.** The product ID and token are on the console's [Product ID & Token page](https://console.maytapi.com/settings/token). The phone ID is on the Phones page, or in the `id` field returned by `GET /listPhones`. The Maven dependency looks like this: ```xml com.fasterxml.jackson.core jackson-databind 2.17.2 ``` Pass the credentials as environment variables, so the same build runs on your laptop and on the server: ```bash export PRODUCT_ID="your-product-id" export PHONE_ID="your-phone-id" export TOKEN="your-token" ``` ### Send a WhatsApp message with HttpClient Every request goes to `https://api.maytapi.com/api/PRODUCT_ID/PHONE_ID/` with the token in the `x-maytapi-key` header. One shared `HttpClient` is enough for the whole bot, because it's thread-safe and reuses connections. Put it in `Maytapi.java`: ```java import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; import java.util.Map; public final class Maytapi { static final ObjectMapper JSON = new ObjectMapper(); private static final String TOKEN = System.getenv("TOKEN"); private static final String PREFIX = "https://api.maytapi.com/api/" + System.getenv("PRODUCT_ID") + "/"; private static final HttpClient HTTP = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); private Maytapi() {} static JsonNode post(String url, Map body) { // webhooks are unauthenticated: only send the token to your own Maytapi product if (url == null || !url.startsWith(PREFIX)) { return JSON.createObjectNode().put("success", false).put("message", "unexpected URL"); } try { HttpRequest req = HttpRequest.newBuilder(URI.create(url)) .timeout(Duration.ofSeconds(10)) .header("Content-Type", "application/json") .header("x-maytapi-key", TOKEN) .POST(HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body))) .build(); HttpResponse res = HTTP.send(req, HttpResponse.BodyHandlers.ofString()); return JSON.readTree(res.body()); } catch (Exception e) { return JSON.createObjectNode().put("success", false).put("message", e.toString()); } } } ``` Now check that the number can send. Create `SendTest.java`: ```java import java.util.Map; public class SendTest { public static void main(String[] args) { String PRODUCT_ID = System.getenv("PRODUCT_ID"); String PHONE_ID = System.getenv("PHONE_ID"); var res = Maytapi.post( "https://api.maytapi.com/api/" + PRODUCT_ID + "/" + PHONE_ID + "/sendMessage", Map.of("to_number", "905301234567", "type", "text", "message", "Hello from Java!")); System.out.println(res.toPrettyString()); } } ``` Use your own number in `to_number`: country code first, no `+` and no spaces. A successful call returns `success: true` and a `data` object with the `chatId` and `msgId`. `POST /sendMessage` puts the message in the phone's sending queue, so if nothing arrives, check the phone's status in the console first. The [Java quick reference](/guides/send-whatsapp-message-java) has more one-off request examples. ### Register your webhook URL Incoming messages, delivery receipts and phone status changes all go to one URL, set at product level with `POST /setWebhook`: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/setWebhook" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"webhook": "https://bot.example.com/maytapi/webhook"}' ``` Maytapi checks the URL by posting a sample `message` event before it saves it, so start the bot first and register second. The sample has no `reply` field, and the handler below skips events without one. If one product runs several numbers and each should reach a different server, `POST /config` sets a webhook for a single phone. The [webhook events guide](/blog/whatsapp-webhook-events-guide) covers every event type. ### Receive messages with the JDK HTTP server Each event arrives as a JSON `POST`. An incoming text message looks like this: ```json { "type": "message", "product_id": "your-product-id", "phone_id": "12345", "user": { "id": "905301234567@c.us", "name": "Customer", "phone": "905301234567" }, "message": { "id": "3EB081AF26FADE6416D8", "type": "text", "text": "menu", "fromMe": false }, "conversation": "905301234567@c.us", "receiver": "905307654321", "timestamp": 1574081887, "reply": "https://api.maytapi.com/api/your-product-id/12345/sendMessage" } ``` `conversation` is the chat to answer in. `reply` is the `sendMessage` URL of the phone that received the message, so the bot never has to build URLs, and a bot serving several numbers always answers from the right one. **Check the reply URL before you send your token.** Anyone who learns your webhook address can post a fake event to it, so never send `x-maytapi-key` to a URL taken from the request without checking it. The send helper only accepts a `reply` that starts with `https://api.maytapi.com/api/` followed by your own product ID. For extra protection, register the webhook with a hard-to-guess path or query parameter (for example `/maytapi/webhook?key=…`) and reject requests that don't carry it. If your firewall supports allowlists, the static IP option described in the [webhook events guide](/blog/whatsapp-webhook-events-guide) lets you accept deliveries from one address only. Workflow of the Java webhook: read the request body, return 200 at once, skip non-message events, fromMe and group chats, then hand the event to a worker thread that posts the answer to the reply URL. `com.sun.net.httpserver.HttpServer` is part of the JDK, so the webhook needs no extra dependency. Create `Bot.java`: ```java import com.fasterxml.jackson.databind.JsonNode; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; import java.io.IOException; import java.net.InetSocketAddress; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class Bot { private static final ExecutorService WORKERS = Executors.newFixedThreadPool(4); public static void main(String[] args) throws IOException { HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0); server.createContext("/maytapi/webhook", Bot::webhook); server.start(); System.out.println("Listening on http://localhost:8080/maytapi/webhook"); } private static void webhook(HttpExchange ex) throws IOException { byte[] raw = ex.getRequestBody().readAllBytes(); ex.sendResponseHeaders(200, -1); // answer Maytapi first, no body ex.close(); JsonNode event = parse(raw); if (event == null || !"message".equals(event.path("type").asText())) return; JsonNode msg = event.path("message"); String chat = event.path("conversation").asText(""); if (msg.path("fromMe").asBoolean(false)) return; // our own messages, including bot replies if (chat.endsWith("@g.us")) return; // one-to-one chats only if (event.path("reply").asText("").isEmpty()) return; // e.g. the setWebhook check WORKERS.submit(() -> Router.handle(event)); } private static JsonNode parse(byte[] raw) { try { return Maytapi.JSON.readTree(raw); } catch (IOException e) { return null; } } } ``` The handler answers `200` before it does anything else, then passes the event to a small thread pool. Maytapi gets its response right away, and a slow reply never holds the connection open. `fromMe` is true for anything your own number sends, including the bot's replies, so skipping it stops the bot from answering itself in a loop. Group chat IDs end in `@g.us`, and a command bot that replies to every message in a busy group annoys people quickly. Remove that line once you've decided how the bot should behave in groups. ### Route commands with a list menu Customers don't remember typed commands, so give them a menu. A list message shows one button that opens titled sections, and each row has a `rowId`. When someone picks a row, the webhook receives a normal `text` message with the row title in `message.text` and the `rowId` in `message.payload`. Create `Router.java`. Using the digits `1`, `2` and `3` as row IDs means a tap and a typed "2" end up in the same `case`: ```java import com.fasterxml.jackson.databind.JsonNode; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; final class Router { // reactions, poll votes, edits, pins, calls and group notices need no answer private static final Set SILENT_TYPES = Set.of("reaction", "pollVoteUpdate", "message_edit", "pinMessage", "info"); private Router() {} static void handle(JsonNode event) { JsonNode msg = event.path("message"); Map body; if (SILENT_TYPES.contains(msg.path("type").asText())) { return; } if (!"text".equals(msg.path("type").asText())) { body = Map.of("type", "text", "message", "I can read text for now. Type *menu* to start."); } else { JsonNode input = msg.hasNonNull("payload") ? msg.get("payload") : msg.path("text"); String key = input.asText().trim().toLowerCase(Locale.ROOT); body = switch (key) { case "1", "prices" -> priceList(); case "2", "hours" -> openingHours(); case "3", "agent" -> Map.of("type", "text", "message", "Thanks! A team member will reply in this chat."); default -> menu(); }; } Map out = new HashMap<>(body); out.put("to_number", event.path("conversation").asText()); JsonNode res = Maytapi.post(event.path("reply").asText(), out); if (!res.path("success").asBoolean(false)) { System.err.println("sendMessage failed: " + res); } } static Map menu() { return Map.of( "type", "list", "title", "Acme Store", "message", "Hi! How can we help? Tap the menu, or reply 1, 2 or 3.", "footer", "Type menu anytime", "button_text", "Open menu", "sections", List.of(Map.of( "title", "Shop", "rows", List.of( row("1", "Price list", "PDF with current prices"), row("2", "Opening hours", "Store photo and hours"), row("3", "Talk to a person", "A team member replies here")))), "skip_filter", true); } private static Map row(String id, String title, String description) { return Map.of("rowId", id, "title", title, "description", description); } } ``` Reactions, poll votes, edits, pins and `info` notices (calls, group changes) also arrive as `message` events, so the router skips them instead of answering. Anything the router doesn't recognize, including "hi", gets the menu, so a first-time customer always has a next step. Reading `payload` first and falling back to `text` keeps the flow working whether people tap or type. The menu sets `skip_filter`. `sendMessage` has a duplicate filter that drops identical content sent to the same chat several times in a row. It protects you from accidental loops, but a menu is meant to repeat, so it opts out. Leave the filter on for everything else. Lists are also the safest way to collect a choice. Reply-button taps don't reach the webhook as a regular message on every connection, so this bot uses list rows and accepts typed numbers too. The [buttons, lists and carousels guide](/blog/whatsapp-interactive-messages-buttons-list-carousel) compares the formats. ### Send images and documents Images, videos, audio and documents all use `type: "media"`. Put a public URL in `message`, an optional caption in `text`, and use `filename` to set the name a document shows in the chat. Add the two missing methods to `Router`: ```java static Map priceList() { return Map.of( "type", "media", "message", "https://example.com/files/price-list.pdf", "text", "Our current price list", "filename", "acme-price-list.pdf"); } static Map openingHours() { return Map.of( "type", "media", "message", "https://example.com/images/store-front.jpg", "text", "Open Mon-Sat, 09:00-19:00.\nReply menu to go back."); } ``` If the file isn't public, send it inline as a base64 data URI instead of a URL: ```java byte[] pdf = Files.readAllBytes(Path.of("/srv/files/invoice-10482.pdf")); String data = "data:application/pdf;base64," + Base64.getEncoder().encodeToString(pdf); Map body = Map.of("type", "media", "message", data, "filename", "invoice-10482.pdf"); ``` That snippet needs `java.nio.file.Files`, `java.nio.file.Path` and `java.util.Base64`. Customers can send files too. Incoming media arrives with a `message.type` such as `image` or `document`, plus `url`, `mime`, `filename` and an optional `caption`. To keep a copy, download `url` with the same `HttpClient` and `BodyHandlers.ofFile(...)`. The router answers media with a short note for now, which is the place to hook that in. ### Test the Java bot locally Compile and start the bot, then expose port 8080 with a tunnel in a second terminal: ```bash mvn -q compile exec:java -Dexec.mainClass=Bot # second terminal, pick one: cloudflared tunnel --url http://localhost:8080 ngrok http 8080 ``` Register the HTTPS URL the tunnel prints, with `/maytapi/webhook` added, using `setWebhook`. Then message the bot's number from another phone: "hi" should bring up the menu, and "1" should send the PDF. Messages typed on the bot's own phone arrive with `fromMe: true` and get ignored. Free tunnel URLs usually change on restart, so if the bot goes quiet, check the webhook URL first. ### Production checklist for a Java WhatsApp bot Package the bot as a jar, run it behind a reverse proxy with HTTPS, and register the server's stable URL. Then go through these points before real customers arrive. Production checklist for a Java WhatsApp bot: return 200 before work, skip repeated message ids, use a bounded worker pool, alert on status events, keep the token in the environment and only message people who wrote first. **Answer first, work after.** The thread pool already does this for quick replies. For AI calls or order lookups, keep the pool bounded, or put events on a real queue so a traffic spike can't exhaust memory. **Skip repeats.** Handle each `message.id` once, so a redeploy or a duplicate delivery never sends the same PDF twice. A Java process keeps memory between requests, so a bounded map is enough for a single instance: ```java private static final Map SEEN = Collections.synchronizedMap( new LinkedHashMap(1024, 0.75f, false) { @Override protected boolean removeEldestEntry(Map.Entry eldest) { return size() > 10_000; } }); static boolean firstTime(String id) { return SEEN.putIfAbsent(id, Boolean.TRUE) == null; } // in webhook(): if (!firstTime(msg.path("id").asText())) return; ``` Use a database or Redis instead once you run more than one instance or need the IDs to survive restarts. **Watch the phone.** `status` events tell you when the number disconnects or returns to the QR screen. Alert someone when that happens. `ack` events report whether replies were delivered and read. **Send like a person would.** A bot that answers people who wrote first is the safest kind of automation on an unofficial API. Large proactive sends from a new number are what get accounts restricted. Read [how Safe Mode prevents WhatsApp bans](/blog/whatsapp-ban-prevention-safe-mode) before you add broadcasts. > **Note:** Never commit `TOKEN` to your repository or bake it into the jar. Read it from the environment or your secret manager. #### The same webhook in Spring Boot If the bot lives in a Spring Boot app, the webhook is a `@RestController`. Spring's Jackson setup binds the body straight to a `JsonNode`, and the `Router` and `Maytapi` classes above work unchanged: ```java import com.fasterxml.jackson.databind.JsonNode; import java.util.concurrent.CompletableFuture; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @RestController public class WebhookController { @PostMapping("/maytapi/webhook") public ResponseEntity webhook(@RequestBody JsonNode event) { JsonNode msg = event.path("message"); String chat = event.path("conversation").asText(""); if ("message".equals(event.path("type").asText()) && !msg.path("fromMe").asBoolean(false) && !chat.endsWith("@g.us") && !event.path("reply").asText("").isEmpty()) { CompletableFuture.runAsync(() -> Router.handle(event)); } return ResponseEntity.ok().build(); } } ``` `CompletableFuture.runAsync` uses the common pool. In a larger app, pass your own executor or use `@Async`, so bot work doesn't compete with the rest of the application. ### Next steps Your Java bot now receives messages, shows a list menu and sends images and documents. To go further: - Learn every webhook payload, including acks, status and group events, in the [webhook events guide](/blog/whatsapp-webhook-events-guide). - Look up the body for every message type in the [send message guide](/blog/whatsapp-api-send-message). - Build richer menus with [buttons, lists and carousels](/blog/whatsapp-interactive-messages-buttons-list-carousel). - Compare with the same bot in [PHP](/blog/whatsapp-bot-php), [Node.js](/blog/whatsapp-bot-nodejs) or [Python](/blog/whatsapp-bot-python). - Check request bodies in the [API documentation](/whatsapp-api-documentation). ### Build a WhatsApp Bot in PHP: cURL, Webhooks and a List Menu URL: https://maytapi.com/blog/whatsapp-bot-php Published: 2026-03-24, updated: 2026-10-27 Category: Tutorials > Build a WhatsApp bot with plain PHP 8 and cURL: read webhooks from php://input, route commands with a list menu and reply with images and PDFs. PHP still runs a large share of the web, and a WhatsApp bot fits it well. Each incoming message is one HTTP request to a script, and each reply is one cURL call. This tutorial builds a **WhatsApp bot in PHP** with the Maytapi API, using plain PHP 8 and the cURL extension, with no framework and no Composer packages. You'll send a first message, read webhook events from `php://input`, route commands through a list menu, and reply with images and documents. A short Laravel version comes at the end. Maytapi is an unofficial WhatsApp API. You link your own WhatsApp number as a device by scanning a QR code, the same way WhatsApp Web works, and then control it over REST and webhooks. There are no message templates and no per-message fees. The other side of that is that your bot speaks from a normal WhatsApp account, so WhatsApp's anti-spam rules apply to it. The production section links to the details, and our [WhatsApp Business API alternative](/whatsapp-business-api-alternative) page compares this approach with Meta's official Cloud API. ### What you need - **PHP 8.0 or newer** with the `curl` and `json` extensions. Most hosting plans and Docker images have both. - **A Maytapi account** with a connected phone. The [free trial](https://console.maytapi.com/register) runs for three days and doesn't ask for a card. Scan the QR code in the console with the number the bot should use. - **Your product ID, token and phone ID.** The product ID and token are on the console's [Product ID & Token page](https://console.maytapi.com/settings/token). The phone ID is on the Phones page, or in the `id` field returned by `GET /listPhones`. Keep the credentials in environment variables so the same code runs locally and on the server: ```bash export PRODUCT_ID="your-product-id" export PHONE_ID="your-phone-id" export TOKEN="your-token" ``` On PHP-FPM, set them in the pool config (`env[TOKEN] = ...`) or your hosting panel instead, because FPM workers don't inherit your shell's variables by default. ### Send a WhatsApp message with PHP and cURL Every request goes to `https://api.maytapi.com/api/PRODUCT_ID/PHONE_ID/` with the token in the `x-maytapi-key` header. Put one small helper in `maytapi.php` and reuse it everywhere: ```php false, 'message' => 'unexpected URL']; } $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'x-maytapi-key: ' . getenv('TOKEN'), ], CURLOPT_POSTFIELDS => json_encode($body), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, ]); $raw = curl_exec($ch); if ($raw === false) { return ['success' => false, 'message' => curl_error($ch)]; } return json_decode($raw, true) ?? ['success' => false, 'message' => 'Invalid JSON']; } ``` Now check that the number can send. Create `send.php`: ```php '905301234567', 'type' => 'text', 'message' => 'Hello from PHP!'] ); print_r($res); ``` Run `php send.php` with your own number in `to_number`: country code first, no `+` and no spaces. A successful call returns `success: true` and a `data` object with the `chatId` and `msgId`. `POST /sendMessage` adds the message to the phone's sending queue, so if nothing arrives, check the phone's status in the console first. The [PHP quick reference](/guides/send-whatsapp-message-php) has more one-off request examples. ### Register your webhook URL Incoming messages, delivery receipts and phone status changes all go to one URL, set at product level with `POST /setWebhook`: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/setWebhook" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"webhook": "https://bot.example.com/webhook.php"}' ``` Before saving the URL, Maytapi checks it by posting a sample `message` event from a test number. Your endpoint therefore has to be reachable when you call `setWebhook`. The sample has no `reply` field, and the handler below uses that to skip it. If one product runs several numbers and each should reach a different server, `POST /config` sets a webhook for a single phone. The [webhook events guide](/blog/whatsapp-webhook-events-guide) covers every event type. ### Read incoming messages from php://input Maytapi sends each event as a JSON `POST`, so `$_POST` stays empty. Read the raw body from `php://input` instead. An incoming text message looks like this: ```json { "type": "message", "product_id": "your-product-id", "phone_id": "12345", "user": { "id": "905301234567@c.us", "name": "Customer", "phone": "905301234567" }, "message": { "id": "3EB081AF26FADE6416D8", "type": "text", "text": "menu", "fromMe": false }, "conversation": "905301234567@c.us", "receiver": "905307654321", "timestamp": 1574081887, "reply": "https://api.maytapi.com/api/your-product-id/12345/sendMessage" } ``` `conversation` is the chat to answer in. `reply` is the `sendMessage` URL for the phone that received the message, so your code doesn't have to build it, and a bot serving several numbers always answers from the right one. **Check the reply URL before you send your token.** Anyone who learns your webhook address can post a fake event to it, so never send `x-maytapi-key` to a URL taken from the request without checking it. The send helper only accepts a `reply` that starts with `https://api.maytapi.com/api/` followed by your own product ID. For extra protection, register the webhook with a hard-to-guess path or query parameter (for example `/maytapi/webhook?key=…`) and reject requests that don't carry it. If your firewall supports allowlists, the static IP option described in the [webhook events guide](/blog/whatsapp-webhook-events-guide) lets you accept deliveries from one address only. Workflow of the PHP webhook: decode php://input, return 200 early, skip non-message events, fromMe and group chats, then route the text and post the answer to the reply URL. Create `webhook.php`: ```php 'list', 'title' => 'Acme Store', 'message' => "Hi! How can we help? Tap the menu, or reply 1, 2 or 3.", 'footer' => 'Type menu anytime', 'button_text' => 'Open menu', 'sections' => [[ 'title' => 'Shop', 'rows' => [ ['rowId' => '1', 'title' => 'Price list', 'description' => 'PDF with current prices'], ['rowId' => '2', 'title' => 'Opening hours', 'description' => 'Store photo and hours'], ['rowId' => '3', 'title' => 'Talk to a person', 'description' => 'A team member replies here'], ], ]], 'skip_filter' => true, ]; } function handle(array $event): void { $msg = $event['message']; // reactions, poll votes, edits, pins, calls and group notices need no answer if (in_array($msg['type'] ?? '', ['reaction', 'pollVoteUpdate', 'message_edit', 'pinMessage', 'info'], true)) { return; } if (($msg['type'] ?? '') !== 'text') { $body = ['type' => 'text', 'message' => 'I can read text for now. Type *menu* to start.']; } else { $key = strtolower(trim((string) ($msg['payload'] ?? $msg['text'] ?? ''))); $body = match ($key) { '1', 'prices' => price_list(), '2', 'hours' => opening_hours(), '3', 'agent' => ['type' => 'text', 'message' => 'Thanks! A team member will reply in this chat.'], default => menu(), }; } $res = maytapi_post($event['reply'], ['to_number' => $event['conversation']] + $body); if (empty($res['success'])) { error_log('sendMessage failed: ' . json_encode($res)); } } ``` Reactions, poll votes, edits, pins and `info` notices (calls, group changes) also arrive as `message` events, so the router skips them instead of answering. Anything the router doesn't recognize, including "hi", gets the menu, so a first-time customer always has a next step. Reading `payload` first and falling back to `text` keeps the flow working whether people tap or type. The menu sets `skip_filter`. `sendMessage` has a duplicate filter that drops the same content sent to the same chat several times in a short window. That protects you from accidental loops, but a menu is meant to repeat, so it opts out. Leave the filter on for everything else. List rows are the safest way to collect a choice. Reply-button taps don't reach the webhook as a regular message on every connection, which is why this bot uses a list and accepts typed numbers too. The [buttons, lists and carousels guide](/blog/whatsapp-interactive-messages-buttons-list-carousel) compares the formats. ### Send images and documents Images, videos, audio and documents all use `type: "media"`. Put a public URL in `message`, an optional caption in `text`, and use `filename` to control the name a document shows in the chat. Add the two remaining functions to `router.php`: ```php function price_list(): array { return [ 'type' => 'media', 'message' => 'https://example.com/files/price-list.pdf', 'text' => 'Our current price list', 'filename' => 'acme-price-list.pdf', ]; } function opening_hours(): array { return [ 'type' => 'media', 'message' => 'https://example.com/images/store-front.jpg', 'text' => "Open Mon-Sat, 09:00-19:00.\nReply menu to go back.", ]; } ``` If the file isn't public, send it inline as a base64 data URI instead of a URL: ```php $data = 'data:application/pdf;base64,' . base64_encode(file_get_contents('/srv/files/invoice-10482.pdf')); $body = ['type' => 'media', 'message' => $data, 'filename' => 'invoice-10482.pdf']; ``` Customers can send files too. Incoming media arrives with a `message.type` such as `image` or `document`, plus `url`, `mime`, `filename` and an optional `caption`. To keep a copy, download it with `file_get_contents($msg['url'])` and save it. The handler above answers media with a short note, which is the place to hook that in. ### Test the PHP bot locally Run PHP's built-in server and expose it with a tunnel in a second terminal: ```bash php -S localhost:8000 # second terminal, pick one: cloudflared tunnel --url http://localhost:8000 ngrok http 8000 ``` Register the HTTPS URL the tunnel prints, with `/webhook.php` added, using `setWebhook`. Then message the bot's number from another phone: "hi" should bring up the menu, and "1" should send the PDF. Messages you type on the bot's own phone arrive with `fromMe: true` and get ignored. Free tunnel URLs usually change when they restart, so if the bot goes quiet, check the webhook URL first. ### Production checklist for a PHP WhatsApp bot Run the bot behind nginx or Apache with PHP-FPM and HTTPS, and register the server's stable URL. Then work through these points before real customers arrive. Production checklist for a PHP WhatsApp bot: answer 200 early, skip repeated message ids, send slow work to a queue, alert on status events, keep the token out of code and only message people who wrote first. **Answer first, work after.** `fastcgi_finish_request()` handles quick replies. For AI calls, order lookups or retries, write the event to a job table or a Redis list and process it with a worker, so the webhook script only parses, stores and returns. **Skip repeats.** Use `message.id` as a key and handle each ID once, so a redeploy or a duplicate delivery never sends the same PDF twice. PHP keeps no memory between requests, so store the IDs. SQLite is enough for one server: ```php function first_time(string $id): bool { $db = new PDO('sqlite:' . __DIR__ . '/seen.sqlite'); $db->exec('CREATE TABLE IF NOT EXISTS seen (id TEXT PRIMARY KEY)'); $stmt = $db->prepare('INSERT OR IGNORE INTO seen (id) VALUES (?)'); $stmt->execute([$id]); return $stmt->rowCount() === 1; } // in webhook.php: if (!first_time($msg['id'] ?? '')) exit; ``` **Watch the phone.** `status` events tell you when the number disconnects or returns to the QR screen. Alert someone when that happens. `ack` events report whether replies were delivered and seen. **Send like a person would.** A bot that answers people who wrote first is the safest kind of automation on an unofficial API. Large proactive sends from a new number are what get accounts restricted. Read [how Safe Mode prevents WhatsApp bans](/blog/whatsapp-ban-prevention-safe-mode) before you add broadcasts. > **Note:** Never commit `TOKEN` to your repository or leave it in a public web root. Load it from the environment or a config file outside the document root. #### The same webhook in Laravel In a Laravel app, the webhook is a route in `routes/api.php`. API routes skip CSRF checks, so Maytapi can post to them. On Laravel 11 or newer, create the file with `php artisan install:api` if it doesn't exist yet. Store the token in `config/services.php` as `services.maytapi.token`: ```php use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Route; Route::post('/maytapi/webhook', function (Request $request) { $event = $request->json()->all(); $msg = $event['message'] ?? []; if (($event['type'] ?? '') === 'message' && empty($msg['fromMe']) && !str_ends_with($event['conversation'] ?? '', '@g.us') && !empty($event['reply'])) { Http::withHeaders(['x-maytapi-key' => config('services.maytapi.token')]) ->timeout(10) ->post($event['reply'], ['to_number' => $event['conversation'], 'type' => 'text', 'message' => 'Got it!']); } return response('', 200); }); ``` The URL becomes `https://your-app.com/api/maytapi/webhook`. When the handler grows, move the send into a queued job so the route returns immediately. ### Next steps Your PHP bot now receives messages, shows a list menu and sends images and documents. To go further: - Learn every webhook payload, including acks, status and group events, in the [webhook events guide](/blog/whatsapp-webhook-events-guide). - Look up the body for every message type in the [send message guide](/blog/whatsapp-api-send-message). - Build richer menus with [buttons, lists and carousels](/blog/whatsapp-interactive-messages-buttons-list-carousel). - Compare with the same bot in [Node.js](/blog/whatsapp-bot-nodejs), [Python](/blog/whatsapp-bot-python) or [Java](/blog/whatsapp-bot-java). - Check request bodies in the [API documentation](/whatsapp-api-documentation). ### Automate WhatsApp with n8n: Send, Receive and Build Workflows URL: https://maytapi.com/blog/whatsapp-n8n-automation Published: 2026-03-03, updated: 2026-10-27 Category: Integrations > Install the Maytapi n8n node, send WhatsApp messages from any workflow and trigger flows from incoming messages, without writing code. n8n is a workflow automation tool that connects hundreds of apps through a visual editor. With the Maytapi community node you can add WhatsApp to those workflows: send a message when a spreadsheet row appears, route incoming chats to an AI model, or post a reply when an order ships, without writing a backend. This guide covers installing the n8n WhatsApp node, setting up credentials, and building one sending and one receiving workflow, plus the details that tend to trip people up. ### What the Maytapi n8n WhatsApp node includes The package is published on npm as `@maytapi/n8n-nodes-maytapi` and adds two nodes to n8n: | Node | Role | |---|---| | **Maytapi** | Action node. Sends messages and calls other API operations. | | **Maytapi Trigger** | Trigger node. Starts a workflow when Maytapi delivers a webhook event. | Checklist of the five event groups the Maytapi Trigger node can listen for: messages, acks, group member events, calls and stories. The action node is organized by resource. Message, Contact, Chat, Group, Queue, Call, Account, Session and Session Control cover the day-to-day work: sending any message type, checking numbers, archiving chats, managing group members, reading logs and checking the connection. Recent versions of the package also add Label, Community, Channel, Story, Presence and Business Catalog resources. The trigger node listens for five event groups, and you choose which ones should start the workflow: | Event | Fires when | |---|---| | Message Event | A message is received or sent on the number | | Message Status (ACK) | Delivery status changes (sent, delivered, read) | | Group Member Events | Someone joins or leaves a group, or group settings change | | Call Events | Incoming, outgoing, missed or rejected calls | | Story Events | Story-related messages arrive | Behind both nodes is the regular Maytapi REST API: the node sends requests to `https://api.maytapi.com/api/PRODUCT_ID/...` with your token in the `x-maytapi-key` header. Anything the node does, you can reproduce with the [API documentation](/whatsapp-api-documentation) if you need to. ### Install the community node On a self-hosted n8n instance: 1. Open **Settings > Community Nodes**. 2. Click **Install**, enter `@maytapi/n8n-nodes-maytapi`, accept the risk notice and install. 3. The **Maytapi** and **Maytapi Trigger** nodes now appear in the node panel. If you manage n8n through a config or Docker image, install the package with npm in n8n's nodes directory, `~/.n8n/nodes` (inside the container for Docker, for example after `docker exec -it n8n sh`), and restart n8n: ```bash mkdir -p ~/.n8n/nodes && cd ~/.n8n/nodes npm install @maytapi/n8n-nodes-maytapi ``` n8n Cloud only lets you install verified community nodes. If the Maytapi node does not show up there, use a self-hosted instance or call the API with n8n's **HTTP Request** node. You also need a Maytapi product with a paired number. Sign up for the [3-day free trial](https://console.maytapi.com/register), add a phone and scan the QR code in the console. ### Add your Maytapi credentials Create a new credential of type **Maytapi API** with three fields: | Field | Where to find it | |---|---| | Product ID | [Console settings](https://console.maytapi.com/settings/token) | | Token | Same page; this is your `x-maytapi-key` | | Phone ID | Optional. The default phone the nodes use. | Every Maytapi operation outside the Account resource has a **Phone ID Source** option. Leave it on **From Credentials** to use the default phone, or switch to **Custom** to target a different number in that node. That is how one workflow can work with several WhatsApp numbers on the same product. ### Send WhatsApp messages from a workflow A common starting point: a new row in a Google Sheet (a form submission, a booking, a lead) should trigger a WhatsApp message to the person in that row. Assume the sheet has columns `name`, `phone` and `appointment`. Build the workflow like this: 1. **Google Sheets Trigger**, event *Row Added*, pointing at your sheet. 2. **Maytapi** node: - Resource: *Message* - Operation: *Send Message* - Message Type: *Text* - To Phone Number: `{{ $json.phone }}` - Message: `Hi {{ $json.name }}, your appointment on {{ $json.appointment }} is confirmed. Reply here if you need to change it.` Phone numbers go in international format without `+` or spaces, for example `905301234567`. If your sheet stores numbers with spaces or a leading `+`, clean them with an expression such as `{{ $json.phone.replace(/\D/g, '') }}`. The same Send Message operation handles the other message types: switch Message Type to media and give it a file URL plus an optional caption, or use location, vCard, poll, reaction and so on. The fields change with the type you pick. #### Sending to many rows If one run produces many items (for example, a scheduled workflow that reads 200 rows), n8n will call the Maytapi node once per item. Two habits keep that safe: - Check numbers first. The Contact resource has a **Check Phones** operation that tells you which numbers are on WhatsApp, so you don't message numbers that can't receive it. The [number checker post](/blog/bulk-whatsapp-number-check) explains why this matters. - Slow down. Put a **Loop Over Items** node with a small batch size and a **Wait** node between batches. Messages are queued per phone on Maytapi's side, but pacing still matters for how WhatsApp sees your number. Bulk sends from a new or cold number are the most common reason WhatsApp restricts an account. Maytapi is an unofficial API that runs on your own number, so follow the [ban prevention guidance](/whatsapp-api-ban-prevention) before you automate outreach to people who haven't messaged you. ### Trigger workflows from incoming WhatsApp messages The second half of most automations is receiving. Add a **Maytapi Trigger** node, select your credential and tick **Message Event**. Workflow of an n8n auto-reply: activating the workflow registers the webhook, the Maytapi Trigger fires, an IF node filters and the Maytapi node replies. When you activate the workflow, the trigger registers its n8n webhook URL with Maytapi automatically. For message events, its output includes ready-to-use fields alongside the raw payload in `body`: | Field | Contents | |---|---| | `conversation` | Chat ID to reply to (`905301234567@c.us` or a group ID) | | `fromNumber`, `fromName` | Sender's number and name | | `messageType` | `text`, `image`, `document` and so on | | `messageText` | Text of the message | | `fromMe` | `true` for messages your own number sent | | `messageId`, `timestamp` | Message ID and time | A simple auto-reply looks like this: 1. **Maytapi Trigger** with Message Event. 2. **IF** node: `{{ $json.fromMe }}` is false **and** `{{ $json.messageType }}` equals `text`. 3. **Maytapi** node, Send Message: To Phone Number `{{ $json.conversation }}`, Message `Thanks {{ $json.fromName }}, we'll get back to you shortly.` The `fromMe` check matters. Message events also fire for messages sent from the phone itself (`fromMe: true`), for example when a colleague replies in the WhatsApp app. Without the filter, the workflow would answer those too. > **Note:** The trigger sets the product-level webhook (the same one as `setWebhook` in the API). If another system already receives your Maytapi webhooks, activating the n8n workflow replaces that URL, and deactivating the workflow does not restore it. Use one webhook consumer per product, fan events out from n8n to your other systems, or give the other system's phone its own URL with `POST /config`, since phones with their own URL do not use the product webhook. #### Route incoming chats to an AI model Swap the fixed reply for an n8n **AI Agent** (or any LLM node). Feed it `{{ $json.messageText }}` as the user message, give it a system prompt that describes your business, and send its output back through the Maytapi node to `{{ $json.conversation }}`. Add a **Switch** node in front if some messages should go to a human instead: for example, route anything containing "refund" or "complaint" to a Slack channel and only let the model handle the rest. The [AI agent guide](/blog/whatsapp-ai-agent-n8n-claude) builds this workflow step by step with Claude. For a shared team inbox rather than a bot, the [multi-agent platform](/multi-agent-platform-for-whatsapp) runs on the same connection, so your n8n flows and your agents can work side by side. ### Tips for reliable n8n WhatsApp workflows - **Test with the sandbox.** Maytapi's free sandbox plan allows up to 5 recipient numbers, which is enough to build and debug a workflow without messaging real customers. - **Use production URLs.** n8n has separate test and production webhook URLs. When you click *Listen for test event*, the trigger registers the test URL; once you activate the workflow, it uses the production one. - **Watch for duplicates.** `sendMessage` has a duplicate filter that guards against sending the same message twice by accident. If a workflow legitimately repeats a message, turn on **Skip Duplicate Filter** in the Send Message node (the API's `skip_filter: true`). - **Check the session.** A Session > Get Status node at the start of a scheduled workflow tells you if the phone is disconnected before you try to send. - **Pick only the events you need.** Ticking ACK events on a busy number produces a lot of executions. Enable them only in workflows that actually track delivery. ### Make, Pabbly and other tools If your team uses Make or Pabbly Connect instead of n8n, the same pattern works with their generic modules: an HTTP request module calls `sendMessage` with your `x-maytapi-key` header, and a custom webhook module receives events once you point Maytapi's webhook at it. The [webhook events guide](/blog/whatsapp-webhook-events-guide) shows what each payload looks like. ### Next steps - See the full operation list on the [n8n integration page](/tools/maytapi-n8n-integration). - Build a WhatsApp AI agent with n8n and Claude in the [AI agent guide](/blog/whatsapp-ai-agent-n8n-claude). - Prefer to talk to WhatsApp through Claude or Cursor? Read the [MCP server guide](/blog/whatsapp-mcp-server-claude). - Learn the webhook payloads your trigger receives in the [webhook events guide](/blog/whatsapp-webhook-events-guide). ### Sell on WhatsApp: Catalog, Products and Order Webhooks via API URL: https://maytapi.com/blog/whatsapp-catalog-api-orders Published: 2026-02-26, updated: 2026-11-04 Category: Features > Use the WhatsApp Catalog API to manage products and collections from code, send them in chat, and receive customer orders as webhooks on your server. Many shops already sell through WhatsApp chats, but keep the WhatsApp catalog up to date by hand and copy orders out of the app one by one. A WhatsApp catalog API removes both chores: your store pushes products into the catalog, your agents or bot send product cards in chat, and orders arrive on your server as webhook events. This post walks through the catalog endpoints, the two message types for sharing products, and how to receive and store orders. ### Before you start: a WhatsApp Business number The catalog is a WhatsApp Business feature, so the connected number must be a WhatsApp Business number. A personal WhatsApp account has no catalog to manage. If your number runs on the regular WhatsApp app, move it to the WhatsApp Business app first, then pair it with Maytapi as usual with a QR code or pairing code. Everything below uses the same base URL, `https://api.maytapi.com/api/PRODUCT_ID/PHONE_ID/`, and your API token in the `x-maytapi-key` header. Here `product_id` is your Maytapi product id, not a catalog product, which matters because the catalog endpoints also talk about product ids. ### Manage products with the WhatsApp catalog API #### Add a product `catalog/addProduct` creates a product. The request takes a `name`, `description`, `price` (sent as a string), `currency`, `imageUrl`, an optional product `url`, your own `retailerId` (your SKU, for example) and `isHidden`. ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/catalog/addProduct" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Classic sneaker", "description": "Canvas sneaker, sizes 38-45", "price": "49.90", "currency": "USD", "imageUrl": "https://example.com/sneaker.jpg", "url": "https://example.com/sneaker", "retailerId": "SKU-1042", "isHidden": false }' ``` The response contains the new product's `id`. Save it next to your own SKU, because every other product call uses it. The response also includes a `reviewStatus` field (for example `NO_REVIEW`), which reflects WhatsApp's review state for the item. #### List, edit, hide and remove products - `GET catalog/getProducts` lists every product in your catalog, and `GET catalog/getProduct/{productId}` returns one. Each item has `productId`, `title`, `description`, `price`, `currency`, `image`, `url`, `retailerId` and a `status`. - `POST catalog/editProduct` takes a `productId` and a `data` object with the fields to change (the same fields as `addProduct`). Set `isHidden: true` to hide a sold-out item without deleting it. - `POST catalog/removeProduct` with `{ "productId": "..." }` deletes a product. ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/catalog/editProduct" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"productId": "8269823289766677", "data": {"price": "39.90", "isHidden": false}}' ``` #### Product images `catalog/addProductImage` adds an extra image to a product from a URL: ```json { "productId": "8269823289766677", "imageUrl": "https://example.com/sneaker-side.jpg" } ``` `catalog/removeProductImage` removes one. Pass the image's `index` in the product's `additionalImageCdnUrl` array. The older `imageUrl` parameter is deprecated. There is also `GET catalog/getProducts/{conversation_id}`, which lists the catalog of another WhatsApp Business account, for example a supplier, by its chat id. ### Group products into collections Collections are the sections customers see when they browse your catalog, such as "New arrivals" or "Sale". The API covers the full cycle: | Endpoint | Body | Purpose | |---|---|---| | `GET catalog/getCollections` | none (query: `page`, `limit`, `count`) | List collections with their products | | `GET catalog/getCollection/{collection_id}` | none | One collection | | `POST catalog/addCollection` | `name`, `productIds` | Create a collection | | `POST catalog/editCollection` | `collection_id`, `name` | Rename it | | `POST catalog/removeCollection` | `collection_id` | Delete it | | `POST catalog/addCollectionProducts` | `collection_id`, `productIds` | Add products | | `POST catalog/removeCollectionProduct` | `collection_id`, `productIds` | Remove products | `getCollections` is paged, so large catalogs load completely. `page` starts at 1, `limit` sets the number of collections per page (default 10, max 50), and `count` sets how many products come back for each collection (default 3, max 100). To get every product in one collection, call `getCollection/{collection_id}`: ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/catalog/getCollections?page=1&limit=50&count=100" \ -H "x-maytapi-key: $TOKEN" ``` Workflow for syncing a store with the WhatsApp catalog: map SKUs to product ids, add new products, edit on price or stock changes and hide sold-out items. ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/catalog/addCollection" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"name": "New arrivals", "productIds": ["8269823289766677", "8269823289766678"]}' ``` Like products, collection responses include a `reviewStatus`. #### Keeping the catalog in sync with your store The simplest sync job keeps a mapping of your SKU (`retailerId`) to the WhatsApp product id. When a product is created in your store, call `addProduct` and save the id. When the price or stock changes, call `editProduct`, and hide items at zero stock instead of deleting them, so the id stays valid. Run bulk syncs at a steady pace rather than firing hundreds of calls at once. Every change is an action your number performs on WhatsApp. ### Send products and your catalog in chat Two `sendMessage` types put your catalog in front of a customer. **Product message.** Send a single product card with `type: "product"` and the product id. The customer sees the native product card and can view and order it. ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/sendMessage" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"to_number": "905301234567@c.us", "type": "product", "productId": "3121315354601301"}' ``` Take the id from `catalog/getProducts` or from your SKU mapping. The API reference's example sends nothing but the `productId`. **Catalog message.** Since 20.02.2026 you can share the whole catalog with `type: "catalog"`. `title` and `description` are optional: ```json { "to_number": "905301234567@c.us", "type": "catalog", "message": "Check out our products!", "title": "Our catalog", "description": "New arrivals every week" } ``` Use the product card when a customer asks about one item, and the catalog message when they ask "what do you have?". Both work well inside a support flow: an agent, or a bot reading the webhook, can match a question like "do you have the sneaker in 42?" to a `retailerId`, look up the WhatsApp product id from your mapping and answer with the card instead of a paragraph of text and a link. Because the card comes from your catalog, the price and image the customer sees are the ones you last synced. If you want a custom layout with your own buttons instead of the native card, a carousel is an alternative. See [Send WhatsApp Buttons, Lists and Carousels via API](/blog/whatsapp-interactive-messages-buttons-list-carousel). ### Receive WhatsApp orders on your webhook When a customer sends an order from your catalog, it is delivered to your webhook as a `message` event whose `message.type` is `order`. Order delivery was added on 20.02.2026. The order part of the payload looks like this: ```json { "type": "order", "id": "3EB08D542BF8D2092951EE", "business": "905301234567@c.us", "title": "Shop Name", "products": [ { "productId": "8269823289766677", "title": "Classic sneaker", "retailerId": "", "currency": "USD", "price": 12, "price1000": 12000, "quantity": 1 } ], "subtotal": 12, "total": 12, "currency": "USD", "createdAt": 1768802838, "fromMe": false } ``` Workflow of a WhatsApp catalog order: the customer orders, an order message reaches the webhook, lines are mapped to SKUs, the order is saved and confirmed. Order lines carry the WhatsApp `productId`, while `retailerId` currently comes back empty, so map each line to your own products through the SKU-to-product-id mapping from your sync job. Prices come both as `price` and as `price1000` (the amount multiplied by 1000). The event's `user` and `conversation` fields tell you who ordered and where to reply. An Express handler that saves orders and confirms them: ```js import express from "express"; const BASE = `https://api.maytapi.com/api/${PRODUCT_ID}/${PHONE_ID}`; const app = express(); app.post("/maytapi/webhook", express.json(), async (req, res) => { res.sendStatus(200); // acknowledge first const { type, conversation, message: msg = {} } = req.body; if (type !== "message" || msg.type !== "order" || msg.fromMe) return; await saveOrder(conversation, msg); // your database, keyed by msg.id await fetch(`${BASE}/sendMessage`, { method: "POST", headers: { "x-maytapi-key": TOKEN, "Content-Type": "application/json" }, body: JSON.stringify({ to_number: conversation, type: "text", message: `Thanks! We received your order of ${msg.total} ${msg.currency}.` }) }); }); app.listen(3000); ``` An order event is a cart the customer sent you, not a payment. Taking the payment, shipping and updating the customer on the order status all happen in your own system, usually with a payment link and follow-up messages in the same chat. Catalog content must also follow WhatsApp's Commerce Policy. Key the stored order on the message id, so a repeated delivery of the same event does not create a second order. The [webhook events guide](/blog/whatsapp-webhook-events-guide) covers that pattern for every event type. #### Listing recent orders with getOrders `GET catalog/getOrders` returns the ids of recent orders. It accepts `page`, `sort` (newest first) and a `conversation_id` filter. `GET catalog/getOrders/{orderId}` returns the full details: products, quantities and prices. ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/catalog/getOrders?sort=true&conversation_id=905301234567@c.us" \ -H "x-maytapi-key: $TOKEN" ``` > **Note:** `getOrders` reads from memory and only returns orders received while the instance is running. It is not an order history. Save every order to your own database when the webhook delivers it. Order ids embed the chat id, and that part can be in the `@lid` privacy format, for example `false_905301234567@lid_3EB08D542BF8D2092951EE`. Use the event's `conversation` field to identify the customer, and see [WhatsApp @lid Explained](/blog/whatsapp-lid-to-phone-number) if you need to resolve a LID to a number. ### Next steps Selling on WhatsApp through the API comes down to three loops: keep the catalog in sync with your store, answer questions with product and catalog messages, and turn order webhooks into records in your own system. - Read the [product catalog API](/features/whatsapp-product-catalog-api) feature page. - Try every catalog endpoint in the [API documentation](/whatsapp-api-documentation). - Run a Shopify or WooCommerce store? Add cart recovery and order updates with [WhatsApp for Shopify & WooCommerce](/blog/whatsapp-shopify-abandoned-cart-cod). - See where catalogs fit among other automations in [WhatsApp API for Business: 8 Use Cases](/blog/whatsapp-api-business-use-cases). ### Control WhatsApp from Claude, Cursor and Other AI Agents with MCP URL: https://maytapi.com/blog/whatsapp-mcp-server-claude Published: 2026-02-24, updated: 2026-10-27 Category: Integrations > Set up the Maytapi WhatsApp MCP server in a minute and let Claude Desktop, Claude Code or Cursor read chats, send messages and manage groups for you. You can already drive WhatsApp from code. A WhatsApp MCP server lets you drive it from a conversation instead: you ask Claude or Cursor to "send the shipping update to the three customers in this list" and the assistant calls the WhatsApp API for you. This guide sets up the Maytapi MCP server in Claude Desktop, Claude Code and Cursor, shows which tools your prompts trigger, and covers the guardrails you want before an AI agent writes from your number. ### What a WhatsApp MCP server does The [Model Context Protocol](https://modelcontextprotocol.io) (MCP) is an open standard, created by Anthropic, for connecting AI assistants to external tools. An MCP server publishes a list of tools with typed inputs; the assistant decides which tool to call, fills in the arguments and reads the result. Workflow of the WhatsApp MCP server: you write a prompt, the assistant picks a tool, the local MCP server calls the API and your number sends the message. The Maytapi MCP server is a small Node.js program, published on npm as [`@maytapi/mcp-server`](https://www.npmjs.com/package/@maytapi/mcp-server), that wraps the Maytapi REST API. Each API operation becomes a tool: `send_message`, `get_conversations`, `create_group`, `channel_statistics`, `add_label_to_chat` and so on. The current npm release lists 140 tools across these areas: | Area | Example tools | |---|---| | Product and phone | `list_phones`, `get_status`, `get_qr_code`, `get_phone_logs` | | Messaging | `send_message`, `get_message`, `get_messages` | | Chats and contacts | `get_conversations`, `get_contacts`, `check_phones`, `block_contact` | | Groups | `create_group`, `get_groups`, `group_add`, `group_config` | | Channels and communities | `create_channel`, `channel_statistics`, `create_community`, `community_add_subgroup` | | Stories, labels, catalog | `create_story`, `get_labels`, `add_label_to_chat`, `get_catalog_products` | | Webhooks | `set_webhook`, `set_ack_preference` | The server runs locally on your machine and talks to `https://api.maytapi.com` over HTTPS. Your credentials stay in your local MCP config and are only sent to the Maytapi API. Remember what sits behind those tools: Maytapi is an unofficial WhatsApp API. Your own WhatsApp number is paired as a linked device, so every message the assistant sends comes from that number, exactly as if you had typed it. ### Before you start You need four things: - Node.js 18 or later (the server is started with `npx`). - A Maytapi account with a product. The [free trial](https://console.maytapi.com/register) runs for 3 days with no card. - A paired phone. Add a phone in the console and scan the QR code, or use the pairing code option. - Your Product ID and API token from the [console settings page](https://console.maytapi.com/settings/token). The server reads two environment variables: | Variable | Value | |---|---| | `MAYTAPI_PRODUCT_ID` | Your product ID | | `MAYTAPI_API_TOKEN` | Your API token (the `x-maytapi-key` value) | You do not configure a phone ID. Most tools take `phone_id` as an argument, and the assistant usually finds it by calling `list_phones` first. If you have several numbers, say which one to use in your prompt. ### Set up the MCP server in Claude Desktop Open the Claude Desktop config file: - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` - Windows: `%APPDATA%\Claude\claude_desktop_config.json` Add a `maytapi` entry under `mcpServers` (merge it with any servers you already have): ```json { "mcpServers": { "maytapi": { "command": "npx", "args": ["-y", "@maytapi/mcp-server"], "env": { "MAYTAPI_PRODUCT_ID": "your-product-id", "MAYTAPI_API_TOKEN": "your-api-token" } } } } ``` Quit and reopen Claude Desktop. The Maytapi tools appear in the tools menu of a new chat. `npx -y` downloads the package on first run, so there is nothing to install globally. If you prefer a global install, run `npm install -g @maytapi/mcp-server` and set `"command": "maytapi-mcp-server"` with no `args`. ### Add it to Claude Code Claude Code registers MCP servers from the terminal. Pass the environment variables with `-e`, then put the server command after `--`: ```bash claude mcp add maytapi \ -e MAYTAPI_PRODUCT_ID=your-product-id \ -e MAYTAPI_API_TOKEN=your-api-token \ -- npx -y @maytapi/mcp-server ``` By default the server is added for the current project only. Add `--scope user` before the name if you want it in every project. Claude Code stores the entry in `~/.claude.json`. Run `claude mcp list` to confirm the server connects, or type `/mcp` inside a session to see its status and tools. Claude Code is useful when the WhatsApp work sits next to code: you can ask it to read a CSV in your repo, check which numbers are on WhatsApp, and draft the messages, all in one session. ### Configure Cursor Cursor reads MCP servers from `.cursor/mcp.json` in your project (or `~/.cursor/mcp.json` for all projects). The format is the same as Claude Desktop: ```json { "mcpServers": { "maytapi": { "command": "npx", "args": ["-y", "@maytapi/mcp-server"], "env": { "MAYTAPI_PRODUCT_ID": "your-product-id", "MAYTAPI_API_TOKEN": "your-api-token" } } } } ``` You can also add it from Cursor Settings > MCP. If you commit `.cursor/mcp.json`, keep the real token out of the repository and use the global file instead. ### Example prompts and the tools they call Once connected, talk to the assistant in plain language. A few prompts and what typically happens behind them: | You ask | Tools the assistant calls | |---|---| | "Is my WhatsApp connected?" | `list_phones`, then `get_status` | | "Send 'Your order has shipped' to 905301234567" | `send_message` with `type: "text"` | | "Summarize the last 20 messages from Ayse" | `get_conversations` to find the chat, then `get_messages` | | "Which of these 30 numbers use WhatsApp?" | `check_phones` | | "Create a group called Project Team with these three numbers" | `create_group` | | "Post a poll to my Company Updates channel: Mon, Tue or Wed?" | `get_channels`, then `send_message` with `type: "channel_poll"` | | "How many followers did my channel gain this month?" | `channel_statistics` | | "Tag every chat that mentioned a refund with the Refund label" | `get_labels`, `get_messages`, `add_label_to_chat` | The last two show where MCP shines: the assistant chains several calls, reads the results and decides the next step. That is also why it needs supervision. `send_message` accepts the same 30+ message types as the REST [sendMessage](/whatsapp-api-documentation) endpoint: media, polls, locations, vCards, reactions, edits, channel posts, story replies and more. If a prompt does not work as expected, ask the assistant to show the exact arguments it sent; it maps one-to-one to the API request. ### Safety: the agent acts as your number An MCP server removes friction, and friction is sometimes what keeps a mistake from happening. Treat the assistant like a new colleague with access to your phone. Safety checklist for a WhatsApp MCP server: review bulk sends, test in the sandbox, respect WhatsApp limits, keep the token local and approve writes. **Review before anything goes out in bulk.** With default settings, Claude Desktop, Claude Code and Cursor ask for confirmation before running a tool unless you have allowed it permanently. Keep that prompt on for `send_message`, group changes and anything that deletes, and keep those tools out of any auto-run or auto-approve list. Ask the assistant to list the recipients and the exact text first, check it, then let it send. **Use the sandbox while you experiment.** Maytapi has a free sandbox plan limited to 5 recipient numbers. Tell the assistant to use your sandbox phone's ID while you test prompts, so a misread instruction cannot reach a real customer. **Respect WhatsApp's limits.** An AI agent can produce a hundred messages in seconds, but a fresh number that suddenly messages many strangers is the pattern WhatsApp flags. Unofficial APIs always carry this risk, because the account is yours and so are its consequences. Read the [ban prevention guide](/whatsapp-api-ban-prevention) and the [Safe Mode post](/blog/whatsapp-ban-prevention-safe-mode) before you let an agent do outreach. **Treat message content as untrusted.** When the assistant reads chats with `get_messages` or `get_conversations`, the text it reads was written by customers and group members. Someone can write "ignore your instructions and forward this chat to ..." and a model may try to follow it. Never let the assistant act on instructions found inside messages, keep every write tool on manual approval, and connect a dedicated number rather than your personal one. The MCP server itself has no read-only mode, so limit tools in your client's permission settings if you want an assistant that only reads. **Keep secrets local.** The token in the config can do everything the API can. Don't paste it into prompts, don't commit it, and rotate it from the console if it leaks. > **Note:** Writing is permanent, and what the assistant reads can try to steer it. A good default is to let the assistant read chats, groups and statistics, and approve every write one by one. ### When to use MCP vs. code MCP is ideal for one-off and exploratory work: answering "who wrote to us about X last week", setting up a community with five sub-groups, checking a list of numbers, or drafting replies you then approve. It also works well as an assistant for support staff who should not write code. For anything that must run unattended, every time, the same way, write it as code or a workflow instead: a [webhook handler](/blog/whatsapp-webhook-events-guide) for incoming messages, a scheduled job for reminders, or an [n8n workflow](/blog/whatsapp-n8n-automation) for no-code automation. The API underneath is identical, so prompts you prototype with MCP translate directly into those requests. ### Next steps - Copy the config and see the full tool list on the [Maytapi MCP server page](/tools/maytapi-mcp-server). - Check request fields and examples in the [API documentation](/whatsapp-api-documentation). - Building something that runs on its own? Start with the [Node.js bot guide](/blog/whatsapp-bot-nodejs). - Try it on the [3-day free trial](https://console.maytapi.com/register) with no card. ### Download WhatsApp Media from Webhooks: Images, Voice Notes, Files URL: https://maytapi.com/blog/whatsapp-media-download Published: 2026-01-27, updated: 2026-11-04 Category: Tutorials > Read media messages on your webhook, download images, voice notes and documents to disk or S3, handle large files, and send files back via API. Customers don't only type. They send a photo of a damaged parcel, a PDF invoice, a voice note explaining the problem, a short video of a machine that won't start. If your system only reads `message.text`, all of that is lost. This guide shows what a WhatsApp media message looks like on your Maytapi webhook, where the file actually lives, how to download it reliably (as soon as the webhook arrives, since media links are not a long-term archive) and store it on disk or S3, what changes for very large files and voice notes, and how to send files back with `POST /sendMessage`. ### What a media message looks like on your webhook Incoming files arrive as a normal `message` event, the same one described in the [webhook guide](/blog/whatsapp-webhook-events-guide). The difference is inside `message`: its `type` is a media type, and instead of `text` you get a link to the file. ```json { "type": "message", "product_id": "1a2b3c4d-...", "phone_id": 12345, "user": { "id": "905301234567@c.us", "name": "Ayse", "phone": "905301234567" }, "message": { "type": "document", "url": "https://cdnydm.com/wh_new/Xq3kP9vB2mT7wLs1aZ8eRf.pdf", "mime": "application/pdf", "filename": "invoice-1042.pdf", "caption": "Here is the invoice", "id": "false_905301234567@c.us_3EB0C4A1F2D9E8B7A6C5", "_serialized": "false_905301234567@c.us_3EB0C4A1F2D9E8B7A6C5", "fromMe": false }, "conversation": "905301234567@c.us", "receiver": "905307654321", "timestamp": 1761552000, "reply": "https://api.maytapi.com/api/1a2b3c4d-.../12345/sendMessage" } ``` These are the media types you can receive and the fields that come with them: | `message.type` | What it is | Notes | |---|---|---| | `image` | Photo | `filename` is generated from the message id | | `video` | Video clip | | | `gif` | Video sent as a looping GIF | Arrives as an MP4 | | `ptv` | Round video note | | | `document` | Any file sent as a document | `filename` is the name the sender used | | `ptt` | Voice note recorded in WhatsApp | Usually `audio/ogg; codecs=opus` | | `audio` | Audio file sent as a file | | | `sticker` | Sticker | WebP | Every one of them carries `url`, `mime` and `filename`. Images, videos and documents can also have a `caption`, which is an empty string when the sender didn't write one. A few things are not in the payload: there is no file size field, and voice note duration (`seconds`) is only included on some connections. If you need the size, read the `Content-Length` header when you download. `message.fromMe` is `true` for files sent from your own number, including files you sent through the API. Skip those unless you want to archive both sides of the chat. #### Motion photos A motion photo (an Android Motion Photo or an iPhone Live Photo) arrives as a single `image` message with two extra fields. `url` is the still photo, `isMotion` is `true`, and `motionUrl` points to a short MP4 of the moving part: ```json "message": { "type": "image", "url": "https://cdnydm.com/wh_new/Xq3kP9vB2mT7wLs1aZ8eRf.jpeg", "mime": "image/jpeg", "caption": "", "isMotion": true, "motionUrl": "https://cdnydm.com/wh_new/Rf8eZa1sLw7Tm2Bv9Pk3qX.mp4", "fromMe": false } ``` The same fields appear when a motion photo is quoted in a reply and in `getConversations` and `getMessages` results. `motionUrl` is only present when the moving part could be retrieved, so check for it rather than relying on `isMotion` alone. If you only need the picture, treat it as a normal image. If you archive everything, download `motionUrl` next to the still photo. One edge case: on some connections, a photo, video or document whose caption contains a `chat.whatsapp.com` group invite link arrives with `type: "group_invite"` instead, with `inviteCode` and `inviteLink` added next to the usual media fields. If you archive every file, check for a `url` rather than only for the media types above. ### Where the file lives You never have to deal with WhatsApp's encrypted media servers. Before the webhook is sent, Maytapi downloads the file from WhatsApp, decrypts it and uploads it to its own media storage. `message.url` points to that copy, normally on `cdnydm.com` (sometimes `cdn.maytapi.com`). Storage is keyed by the file's content hash, so the same file sent twice usually gets the same URL. Path of an incoming WhatsApp file: sender uploads to WhatsApp, Maytapi downloads and decrypts it, stores a copy on its media storage, then posts the webhook with message.url for your worker to download Three practical consequences: - **The webhook waits for the copy.** A small image arrives almost immediately. A 100 MB video has to be downloaded and uploaded first, so its webhook comes later than a text sent at the same moment. Don't assume your events arrive in the order they were sent. - **The URL is a plain public link.** No `x-maytapi-key` is needed to fetch it, and anyone who has the link can open it. Don't paste it into places customers or third parties can see, and treat the files themselves as personal data. - **Retention isn't something to build on.** There is no documented guarantee of how long a media link stays available. Download the file as soon as the webhook arrives and keep your own copy. Store the Maytapi URL only as a reference. Executable script files (JavaScript) are never copied to storage. Depending on the connection, such a message arrives without a usable file or not at all. #### When the URL looks like /api_media/ Sometimes `message.url` has a different shape: `https://cdnydm.com/api_media/...`. You get this when the file couldn't be copied before the webhook went out, for example because the download from WhatsApp timed out or the file is too large. This link is an on-demand fallback. When you request it, Maytapi asks your connected phone for the message again, copies the file, and answers with a redirect to the stored file. If that works once, later requests redirect straight away. If it doesn't, you get an HTTP 500 with a JSON body: ```json { "success": false, "message": "Could not crawl image! Please try again later." } ``` The phone has to be connected for this to work. If the instance is idle, disabled or still loading, the message says so (for example `Instance is idle`). Treat a 500 here as "try again later", with a few retries spaced minutes apart. #### Very large files WhatsApp lets people send documents far bigger than most systems want to handle. Maytapi copies incoming media up to 180 MB. Above that the file is not uploaded, the webhook is still delivered, and `message.url` is the `/api_media/` fallback, which will keep failing for that file because it can't be stored either. For anything that big, ask the sender to share it another way or open it on the phone itself. Your code should log the message id and move on instead of retrying forever. ### Download and store the file The pattern that holds up is: acknowledge the webhook immediately, put the media message in a queue, and let a worker download it. Downloads of large videos take seconds, and a webhook handler should never wait for them. Checklist for downloading WhatsApp media: return 200 first, skip fromMe, stream instead of buffering, never use the sender's filename as a path, retry api_media links, keep a private copy Here is a Node.js version (Node 18 or newer, so `fetch` is built in). It writes files to disk; the S3 variant follows. ```js import express from "express"; import fs from "node:fs"; import path from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; const MEDIA_TYPES = ["image", "video", "gif", "ptv", "document", "ptt", "audio", "sticker"]; const STORAGE_DIR = "./media"; const queue = []; const app = express(); app.use(express.json({ limit: "2mb" })); app.post("/webhook", (req, res) => { res.sendStatus(200); // acknowledge first const body = req.body; if (body.type !== "message") return; const msg = body.message || {}; if (msg.fromMe || !msg.url) return; if (!MEDIA_TYPES.includes(msg.type) && msg.type !== "group_invite") return; queue.push({ body, attempts: 0 }); }); function safeName(msg) { const ext = path.extname(msg.filename || "").replace(/[^.a-zA-Z0-9]/g, ""); const id = msg.id.replace(/[^a-zA-Z0-9]/g, "_").slice(-40); return `${Date.now()}_${id}${ext}`; } const MEDIA_HOSTS = ["cdnydm.com", "cdn.maytapi.com"]; const MAX_BYTES = 180 * 1024 * 1024; async function download(job) { const msg = job.body.message; const u = new URL(msg.url); // the webhook is a public URL: only fetch Maytapi's media hosts, never arbitrary addresses if (u.protocol !== "https:" || !MEDIA_HOSTS.includes(u.hostname)) throw new Error(`unexpected media host ${u.hostname}`); const r = await fetch(u, { redirect: "follow", signal: AbortSignal.timeout(120000) }); if (!r.ok) throw new Error(`HTTP ${r.status}`); if (Number(r.headers.get("content-length")) > MAX_BYTES) throw new Error("file too large"); const file = path.join(STORAGE_DIR, safeName(msg)); await pipeline(Readable.fromWeb(r.body), fs.createWriteStream(file)); return { file, size: Number(r.headers.get("content-length")) || null, mime: msg.mime, originalName: msg.filename, caption: msg.caption || "", from: job.body.user && job.body.user.phone, chat: job.body.conversation }; } async function worker() { const job = queue.shift(); if (!job) return setTimeout(worker, 1000); try { const saved = await download(job); console.log("saved", saved); // insert into your database here } catch (err) { job.attempts += 1; if (job.attempts < 5) { setTimeout(() => queue.push(job), job.attempts * 60 * 1000); } else { console.warn("giving up on", job.body.message.id, err.message); } } setImmediate(worker); } fs.mkdirSync(STORAGE_DIR, { recursive: true }); worker(); app.listen(3000); ``` A few choices in there are deliberate: - **Streaming, not buffering.** `pipeline` writes the response to disk as it arrives, so a 150 MB video doesn't sit in memory. - **Your own file name.** `filename` on a document is whatever the sender typed. Never use it as a path. Keep it in your database as `originalName` for display and build the stored name from the message id. - **Redirects followed.** The `/api_media/` fallback answers with a redirect, and `fetch` follows it by default. A 500 from that link is retried a few minutes later. - **An in-memory queue is only a sketch.** In production, use something that survives restarts (Redis, SQS, a database table), and make the job idempotent by message id, since webhooks can arrive more than once. #### Store to S3 instead of disk To keep files in S3 (or any S3-compatible storage), swap the disk write for a streaming upload with the AWS SDK v3: ```js import { S3Client } from "@aws-sdk/client-s3"; import { Upload } from "@aws-sdk/lib-storage"; const s3 = new S3Client({ region: "eu-central-1" }); async function toS3(msg, response) { const key = `whatsapp/${new Date().toISOString().slice(0, 10)}/${safeName(msg)}`; await new Upload({ client: s3, params: { Bucket: "my-whatsapp-media", Key: key, Body: Readable.fromWeb(response.body), ContentType: msg.mime || "application/octet-stream" } }).done(); return key; } ``` Keep the bucket private and hand out short-lived signed URLs to your own users. That fixes the public-link concern above: once your copy is stored, nothing in your system needs to point at the Maytapi URL. ### Voice notes A voice note recorded in WhatsApp arrives as `ptt`. An audio file the sender attached from their phone (an MP3, for example) arrives as `audio`. Both are downloaded the same way. WhatsApp's own voice notes are OGG files with the Opus codec, which most speech-to-text services accept directly, so you can pass the downloaded file to your transcription provider without converting it. If you reply in a chat where the customer prefers voice, `type: "audio"` sends a file as a voice note. OGG/Opus is the safest format there as well. See the voice notes section of the [send message guide](/blog/whatsapp-api-send-message). ### Send files back Every outgoing file goes through `POST /sendMessage` with `type: "media"`. Put a public URL in `message` and an optional caption in `text`. For documents, add `filename` so the recipient sees a proper name instead of a random one: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/sendMessage" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "to_number": "905301234567", "type": "media", "message": "https://example.com/files/receipt-1042.pdf", "filename": "receipt-1042.pdf", "text": "Your receipt for order #1042" }' ``` If the file only exists on your server, send it as a base64 data URI instead of a URL. Maytapi uploads it first, sends it, and returns the hosted address as `data.media_url` in the response, so you can reuse that URL for the next recipient instead of uploading the same file again: ```js const data = fs.readFileSync("./label-1042.png").toString("base64"); const r = await fetch(`https://api.maytapi.com/api/${PRODUCT_ID}/${PHONE_ID}/sendMessage`, { method: "POST", headers: { "x-maytapi-key": TOKEN, "Content-Type": "application/json" }, body: JSON.stringify({ to_number: "905301234567", type: "media", message: `data:image/png;base64,${data}`, text: "Your shipping label" }) }); const res = await r.json(); console.log(res.success, res.data && res.data.msgId, res.data && res.data.media_url); ``` The file type is taken from the file itself, so a `.jpg` arrives as a photo, an `.mp4` as a video and a `.pdf` as a document. Sending to a group works the same way with a group id such as `120363012345678901@g.us` in `to_number`. To pass along a file a customer sent you without downloading it, use `type: "forward"` with the message id, as covered in the [send message guide](/blog/whatsapp-api-send-message). ### Next steps Media handling comes down to a short loop: read `message.url` from the webhook, acknowledge fast, download in a worker, keep your own copy, and retry the `/api_media/` fallback a few times before giving up. - See every event type your endpoint can receive in the [webhook events guide](/blog/whatsapp-webhook-events-guide). - Send images, documents, voice notes and stickers with the [send message guide](/blog/whatsapp-api-send-message). - Put the download step into a full bot with [build a WhatsApp bot in Node.js](/blog/whatsapp-bot-nodejs). - Pass incoming files and voice notes to an AI agent with [WhatsApp AI agent with n8n and Claude](/blog/whatsapp-ai-agent-n8n-claude). - Check every parameter in the [API documentation](/whatsapp-api-documentation). ### WhatsApp @lid Explained: How to Get the Phone Number Behind a LID URL: https://maytapi.com/blog/whatsapp-lid-to-phone-number Published: 2025-12-22, updated: 2026-10-27 Category: Features > Why WhatsApp started showing 123456@lid instead of phone numbers, what it breaks, and how to convert LIDs to numbers (and back) with the API. If you work with WhatsApp data from code, you have probably seen an id like `112893567721673@lid` where you expected `905301234567@c.us`. That is a WhatsApp LID, and it quietly breaks anything that matches people by phone number. This post explains what a LID is, where it shows up, what it breaks, and how to convert a WhatsApp LID to a phone number (and back) with the Maytapi API, plus a storage pattern that keeps your CRM clean. ### What a WhatsApp LID is A LID (linked identifier) is a privacy id that WhatsApp uses instead of a phone number in some chats, groups and events. It looks like a long number followed by `@lid`, for example `112893567721673@lid`. The number part is not a phone number and has no country code. It is an opaque identifier that WhatsApp maps to an account internally. WhatsApp rolled LIDs out gradually so that linked devices see fewer raw phone numbers, especially in places where people did not explicitly share their number with you: large groups, communities and their announcement groups. Because Maytapi connects your number as a linked device (the same way WhatsApp Web does), your session receives whatever identifier WhatsApp hands to that device. When WhatsApp sends a LID, the API has to resolve it before it can show you a phone number, and sometimes it cannot. These are the id suffixes you will meet most often: | Suffix | Meaning | Example | |---|---|---| | `@c.us` | A person, identified by phone number | `905301234567@c.us` | | `@g.us` | A group | `120363012345678901@g.us` | | `@lid` | A person, identified by a privacy id | `112893567721673@lid` | | `@newsletter` | A WhatsApp Channel | `120363098765432109@newsletter` | | `@broadcast` | Status updates (stories) | `status@broadcast` | ### Where @lid shows up LIDs appear wherever WhatsApp hides the phone number from linked devices. In practice you will see them in these places: - **Group participants.** Large or newer groups often list members as `@lid` instead of `@c.us`, both in `getGroups` responses and in group event participants. - **Communities.** Community members and announcement-group participants are commonly returned as LIDs. The changelog notes a fix for `getCommunities` returning participants in LID format, with the caveat that some participants may still be returned as `@lid`. - **Webhook events.** Senders, mentions, and the `participant` field of info messages (group adds, removes, promotions) can reference a LID. - **Message and order ids.** Message ids embed the chat id, so they can contain the `@lid` form of a chat. The `catalog/getOrders` example, for instance, returns ids like `false_905301234567@lid_3EB08D542BF8D2092951EE`. Checklist of the places WhatsApp LIDs appear: group participants, communities, webhook events and message or order ids. Maytapi has been resolving LIDs on the server side as WhatsApp expanded them. Over 2025 the changelog lists fixes for `@lid` ids in group messages, mentions, info-message participants and webhook message payloads. The 15.12.2025 release went further: `/getContacts` and `/getConversations` now return proper `@c.us` or `@g.us` ids, and so do the `conversation` field of webhook payloads and the `chatId` of ack (receipt) payloads. If you built your integration before that date and added workarounds, it is worth re-checking them. ### What LIDs break in your integration The trouble with a LID is not the format, it is identity. Most systems key a WhatsApp contact by phone number. When the same person reaches you as `905301234567@c.us` in a one-to-one chat and as `112893567721673@lid` in a group, a naive integration sees two different people. Typical symptoms: - **Duplicate CRM contacts.** A new record is created for every LID because the lookup by phone fails. - **Lost history.** A bot or helpdesk that stores state per sender splits one customer's conversation across two keys. - **Broken exports.** Group member exports for moderation or reporting contain ids that nobody can call or message. - **Failed allow lists.** Logic like "only admins with these numbers can run this command" silently stops matching. None of this throws an error, which is why LIDs often go unnoticed until someone looks at the data. ### Convert a WhatsApp LID to a phone number with the API The `lidToPhone` endpoint takes a LID and returns the chat id, name and phone number when WhatsApp exposes them. It accepts both the full form (`112893567721673@lid`) and the bare number (`112893567721673`). ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/getContacts/lidToPhone/112893567721673@lid" \ -H "x-maytapi-key: $TOKEN" ``` A resolved LID returns: ```json { "success": true, "data": { "id": "905301234567@c.us", "name": "John Doe", "phone": "905301234567", "lid": "112893567721673@lid" } } ``` When the number cannot be resolved, the response is simply `{ "success": false }` with no data. The API documentation names the usual reasons: the contact hides their phone number, or the number is not on WhatsApp. Treat `success: false` as a normal outcome, not an error. Some people will stay as a LID, and your data model should allow for that. In Node.js: ```js const lid = "112893567721673@lid"; const res = await fetch( `https://api.maytapi.com/api/${PRODUCT_ID}/${PHONE_ID}/getContacts/lidToPhone/${lid}`, { headers: { "x-maytapi-key": TOKEN } } ); const { success, data } = await res.json(); console.log(success ? data.phone : "number hidden"); ``` #### Going the other way: phone number to LID `phoneToLid` does the reverse. Pass the number with its country code and no `+` or spaces. The response has the same shape as `lidToPhone`, and returns `success: false` when the number is not on WhatsApp or cannot be resolved. ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/getContacts/phoneToLid/905301234567" \ -H "x-maytapi-key: $TOKEN" ``` This is handy when you already have a list of customers by phone number and want to recognize them the first time they appear as a LID, for example in a group event. See the [LID to phone number API](/features/whatsapp-lid-to-phone-number-api) page for more examples. ### Resolving LIDs in group participant lists Groups are where most LIDs live. `getGroups` and `getGroups/{conversation_id}` accept a `refresh=true` parameter. With it, Maytapi refreshes group info from WhatsApp for groups on the page whose participant list is incomplete, so participants come back as `@c.us` wherever the number is available. ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/getGroups/120363012345678901@g.us?refresh=true" \ -H "x-maytapi-key: $TOKEN" ``` Two caveats from the docs and changelog: - Each refreshed group costs one query to WhatsApp. Use `refresh=true` when you actually see `@lid` participants, not on every call. - Cached data may still return `@lid` numbers even after they were converted to `@c.us`, and participants who hide their number stay as `@lid` no matter what. Your code still needs to handle LIDs after a refresh. > **Note:** Do not loop over thousands of LIDs or refresh every group on a schedule. A group refresh is a query your number makes to WhatsApp, and unusual bursts of automated activity are the kind of thing that puts a number at risk. Resolve lazily and cache, as described below. The [ban prevention guide](/whatsapp-api-ban-prevention) covers what else to keep an eye on. For more on group automation in general, see [How to Create and Manage WhatsApp Groups with an API](/blog/create-whatsapp-group-api). ### A practical pattern: store both ids, resolve lazily, cache A robust integration treats a WhatsApp person as one record with up to two keys, the phone-based id and the LID, rather than picking one. Four-step pattern for handling LIDs: store both ids, resolve lazily with lidToPhone, cache results including failures, and merge records. 1. **Store both ids.** Give each contact a `wa_id` (the `@c.us` id, when known) and a `lid` (when known). Index both. Your lookup for an incoming id checks the column that matches its suffix. 2. **Resolve lazily.** Only call `lidToPhone` when you meet a LID you have not seen before and you need the number, for example to match a CRM record. Do not resolve every participant of every group up front. 3. **Cache results, including failures.** Store the mapping when `success` is true. When it is false, store that too with a timestamp and retry much later, so a hidden number does not trigger a lookup on every message. 4. **Merge when you learn more.** If a LID-only contact later resolves, or someone you know by phone turns up via `phoneToLid`, merge the two records instead of keeping duplicates. Here is a compact Node.js version of steps 2 and 3: ```js const BASE = `https://api.maytapi.com/api/${PRODUCT_ID}/${PHONE_ID}`; const cache = new Map(); // use Redis or your database in production async function resolve(chatId) { if (!chatId.endsWith("@lid")) return chatId; if (cache.has(chatId)) return cache.get(chatId); const res = await fetch(`${BASE}/getContacts/lidToPhone/${chatId}`, { headers: { "x-maytapi-key": TOKEN }, signal: AbortSignal.timeout(10000) }).then((r) => r.json()); cache.set(chatId, res.success ? res.data.id : chatId); return cache.get(chatId); } ``` The function returns the `@c.us` id when the LID resolves and the LID itself when it does not, so callers always get a stable key. In a webhook handler, run it on the sender or participant before you look the person up in your CRM, and acknowledge the webhook with a 200 first so slow lookups never delay delivery. The [webhook events guide](/blog/whatsapp-webhook-events-guide) shows where those ids sit in each payload. ### Next steps LIDs are part of how WhatsApp works now, and more of them will appear over time. Store both ids, treat "unresolvable" as a valid state, and call the resolver only when you need a number. - Try `lidToPhone` and `phoneToLid` in the [API documentation](/whatsapp-api-documentation). - Read the [LID to phone number API](/features/whatsapp-lid-to-phone-number-api) feature page. - Follow LID-related fixes in the [changelog](/changelog). - See how usernames, the other way WhatsApp hides numbers, work in the [username API guide](/blog/whatsapp-username-api). - Try `lidToPhone` on your own groups with the [3-day free trial](https://console.maytapi.com/register). ### Build a WhatsApp Chatbot with Google Dialogflow URL: https://maytapi.com/blog/whatsapp-dialogflow-chatbot Published: 2025-11-18, updated: 2026-10-27 Category: Integrations > Connect a Dialogflow ES agent to your WhatsApp number with a small webhook bridge: per-chat sessions, menus, a human handoff and a production checklist. Google Dialogflow is still one of the quickest ways to build a rules-based chatbot: you define intents and training phrases, Dialogflow matches what people type, and it hands back the answer you wrote. What it doesn't do is talk to WhatsApp. Maytapi fills that gap. Your own WhatsApp number receives the message, Dialogflow decides what it means, and the reply goes back from the same number. This guide builds a small webhook bridge on your own server: it keeps a Dialogflow session per conversation, ignores your own messages and group chats, and can hand a chat over to a person. This guide uses Dialogflow ES. Google steers new projects toward Dialogflow CX (Conversational Agents), which uses the separate `@google-cloud/dialogflow-cx` client and a session path that includes a location and an agent ID. The webhook side below stays the same; only the `ask` function changes for CX. ### Create the Dialogflow agent and a service account key You need three things from Google Cloud: 1. **An agent** in the [Dialogflow ES console](https://dialogflow.cloud.google.com/), with your intents and training phrases. The agent lives in a Google Cloud project, and the bridge needs that project's ID. 2. **The Dialogflow API enabled** in that project. 3. **A service account with a JSON key.** In Google Cloud, open IAM & Admin, create a service account, give it the *Dialogflow API Client* role, and create a key in JSON format. Give the service account only that role. The JSON key is a password for your Google project, so don't commit it to a repository and don't paste it into chat tools. ### Build your own bridge with the webhook The bridge has your code in the middle: Maytapi posts the message to your webhook, your server asks Dialogflow, and the answer goes out through `POST /sendMessage`. Flow of a self-hosted Dialogflow bridge: WhatsApp message, Maytapi webhook, your server, Dialogflow detectIntent, reply with sendMessage #### Set up the project ```bash mkdir dialogflow-bridge && cd dialogflow-bridge npm init -y npm pkg set type=module npm install express @google-cloud/dialogflow ``` Pass the credentials as environment variables. `GOOGLE_APPLICATION_CREDENTIALS` is the standard way to point Google's client library at your key file: ```bash export PRODUCT_ID="your-product-id" export PHONE_ID="your-phone-id" export TOKEN="your-token" export WEBHOOK_SECRET="a-long-random-string" export DF_PROJECT_ID="your-gcp-project" export GOOGLE_APPLICATION_CREDENTIALS="/secure/path/dialogflow-key.json" ``` Then point the product's webhook at your server with `POST /setWebhook` (product level, no phone ID in the path), or paste the URL on the console's webhook page: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/setWebhook" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "webhook": "https://example.com/maytapi/webhook/a-long-random-string" }' ``` #### What arrives at your webhook An incoming text message looks like this: ```json { "type": "message", "product_id": "your-product-id", "phone_id": "12345", "user": { "id": "905301234567@c.us", "name": "Ayse", "phone": "905301234567" }, "message": { "id": "3EB081AF26FADE6416D8", "type": "text", "text": "What time do you open?", "fromMe": false }, "conversation": "905301234567@c.us", "conversation_name": "Ayse", "receiver": "905309876543", "timestamp": 1761552000, "reply": "https://api.maytapi.com/api/your-product-id/12345/sendMessage" } ``` Three fields do most of the work. `message.text` goes to Dialogflow. `conversation` is the chat to answer in and doubles as the session key. `reply` is the `sendMessage` URL for the phone that received the message; the bridge below builds that URL from your own IDs instead, because anyone can post a fake event to a public webhook. The [webhook events guide](/blog/whatsapp-webhook-events-guide) lists every other event type. #### Call detectIntent with one session per conversation Dialogflow keeps contexts per session ID, so the session ID decides whether a follow-up answer is understood. Use one session per WhatsApp conversation. Dialogflow ES limits session IDs to 36 characters and recommends hashing user identifiers, so hash `conversation` instead of passing the raw number: ```js import express from "express"; import crypto from "node:crypto"; import dialogflow from "@google-cloud/dialogflow"; const { PRODUCT_ID, PHONE_ID, TOKEN, WEBHOOK_SECRET, DF_PROJECT_ID } = process.env; const sessions = new dialogflow.SessionsClient(); const app = express(); function sessionPath(conversation) { const id = crypto.createHash("sha256").update(conversation).digest("hex").slice(0, 32); return sessions.projectAgentSessionPath(DF_PROJECT_ID, id); } async function ask(conversation, text) { const [response] = await sessions.detectIntent({ session: sessionPath(conversation), queryInput: { text: { text, languageCode: "en" } } }); return response.queryResult; } ``` #### Send the answer with POST /sendMessage An agent can return several text messages for one intent. They're in `fulfillmentMessages`, and `fulfillmentText` holds the first one, so loop over the messages to send them all: ```js // Build the URL from your own IDs; never send your token to a URL taken from the request body. const SEND_URL = `https://api.maytapi.com/api/${PRODUCT_ID}/${PHONE_ID}/sendMessage`; const paused = new Set(); // conversations a person has taken over async function send(body) { const r = await fetch(SEND_URL, { method: "POST", headers: { "x-maytapi-key": TOKEN, "Content-Type": "application/json" }, body: JSON.stringify(body) }); return r.json(); } app.post("/maytapi/webhook/:secret", express.json(), async (req, res) => { if (req.params.secret !== WEBHOOK_SECRET) return res.sendStatus(404); res.sendStatus(200); // answer Maytapi first, then work const { type, message, conversation } = req.body || {}; if (type !== "message" || !message || message.fromMe) return; if (typeof conversation !== "string" || conversation.endsWith("@g.us")) return; // 1:1 chats only if (message.type !== "text" || !message.text) return; if (paused.has(conversation)) return; // a person is answering this chat try { const result = await ask(conversation, message.text); if (await handleAction(conversation, result)) return; // menu or handoff, see below const texts = (result.fulfillmentMessages || []) .flatMap((m) => (m.text ? m.text.text : [])) .filter(Boolean); if (!texts.length && result.fulfillmentText) texts.push(result.fulfillmentText); for (const text of texts) { await send({ to_number: conversation, type: "text", message: text }); } } catch (err) { console.error("bridge failed", err); } }); app.listen(3000); ``` Skipping `fromMe` keeps the bot from answering its own messages, and skipping `@g.us` keeps it out of groups. Remove that line if you do want the agent to answer in a group. Everything `sendMessage` can do is available here, as described in the [send message guide](/blog/whatsapp-api-send-message). ### Menus, actions and a human handoff The Dialogflow `queryResult` tells you more than the text. Use `result.action` or `result.intent.displayName` to decide what to send next, instead of putting WhatsApp formatting into the agent. **Menus.** For a menu intent, send a list message instead of plain text. When the customer picks a row, it arrives as a normal text message whose `message.text` is the row title, so name your rows like the agent's training phrases and Dialogflow matches them without extra code: ```js const fallbacks = new Map(); // conversation -> fallbacks in a row async function handleAction(conversation, result) { if (result.action === "show.menu") { await send({ to_number: conversation, type: "list", message: "What can we help you with?", button_text: "See options", sections: [{ title: "Topics", rows: [ { rowId: "hours", title: "Opening hours" }, { rowId: "order", title: "Order status" }, { rowId: "agent", title: "Talk to a person" } ] }] }); return true; } const unknown = result.action === "input.unknown"; const count = unknown ? (fallbacks.get(conversation) || 0) + 1 : 0; fallbacks.set(conversation, count); // "Talk to a person" is an intent in your agent with that display name if (count >= 2 || result.intent?.displayName === "Talk to a person") { paused.add(conversation); fallbacks.delete(conversation); await send({ to_number: conversation, type: "text", message: "Thanks, a colleague will answer here shortly." }); // notify your team here: email, Slack or a CRM task return true; } return false; // let the agent's own answer go out } ``` `message.payload` carries the `rowId` if you prefer to route on ids yourself. Lists are a safer choice than reply buttons here, because button taps don't arrive as webhook events on every connection. The [interactive messages guide](/blog/whatsapp-interactive-messages-buttons-list-carousel) has the details. Always write the menu so a typed answer works too. **Handoff.** When the Default Fallback Intent fires (`action` is `input.unknown`) twice in a row, or the customer picks "Talk to a person", stop calling Dialogflow for that conversation and notify your team. `handleAction` above adds the conversation to the `paused` set, which the handler checks first. In production, keep that set in Redis or your database so it survives restarts. A person then answers from the phone or the console's shared inbox, and you remove the conversation from the set when they're done. **Fulfillment.** If an intent needs live data, such as an order status, use Dialogflow's own fulfillment webhook or look the data up in your bridge after `detectIntent` returns the action and parameters (`result.parameters`). Doing it in the bridge keeps all your WhatsApp logic in one place. ### Production checklist - **Return 200 quickly.** Acknowledge the webhook first and call Dialogflow afterwards. A slow agent shouldn't hold up delivery. - **Ignore duplicates.** Keep recent `message.id` values for a few minutes and skip any you've already answered. - **Expect sessions to expire.** Dialogflow ES contexts expire after about 20 minutes without activity, so a customer who returns later starts fresh. Design follow-up intents with that in mind. - **Handle errors without silence.** If `detectIntent` fails, send a short "Sorry, please try again" instead of nothing, and log the error. - **Pick the language per conversation** if you serve several markets, rather than hardcoding `en`. - **Keep it conversational.** The bot runs on a regular WhatsApp number, so it should answer people who wrote to you, not start conversations with cold lists. The [Safe Mode guide](/blog/whatsapp-ban-prevention-safe-mode) explains how Maytapi helps protect the number. Production checklist for a WhatsApp Dialogflow bridge: return 200 fast, skip duplicate message ids, expect sessions to expire, reply on errors, pick the language per chat, only answer people who wrote first ### Dialogflow or an LLM agent? Dialogflow is predictable: an answer is only what you wrote for that intent, and that's often exactly what a support FAQ needs. The cost is maintenance. Every new question means new intents and training phrases. If you'd rather have the bot answer from your documents in its own words, the same webhook can feed a large language model instead. The [AI agent with n8n and Claude](/blog/whatsapp-ai-agent-n8n-claude) guide builds that version, and a mixed setup works too: Dialogflow for the fixed flows, an LLM for the fallback intent. ### Next steps A small bridge on your own server gives a Dialogflow agent memory, menus and a path to a person, using nothing more than the webhook and `POST /sendMessage`. - Build a general-purpose bot step by step with the [Node.js WhatsApp bot tutorial](/blog/whatsapp-bot-nodejs), or in [Python](/blog/whatsapp-bot-python). - Use lists and buttons in your flows with the [interactive messages guide](/blog/whatsapp-interactive-messages-buttons-list-carousel). - See every event your bridge can receive in the [webhook events guide](/blog/whatsapp-webhook-events-guide). - Try an LLM instead of intents with the [AI agent with n8n and Claude](/blog/whatsapp-ai-agent-n8n-claude). - Check every parameter in the [API documentation](/whatsapp-api-documentation). ### WhatsApp from Google Sheets: Send Messages and Log Replies URL: https://maytapi.com/blog/whatsapp-google-sheets Published: 2025-10-21, updated: 2026-10-27 Category: Integrations > Send personalized WhatsApp messages from a Google Sheet with the console sender or Apps Script, pace them safely and log replies back. A spreadsheet is where most small teams already keep the list: customers to remind, students to notify, orders waiting for pickup. Copying those numbers into WhatsApp one by one doesn't scale, and a full CRM is more than many teams need. This guide shows two ways to send WhatsApp messages from Google Sheets with Maytapi: the ready-made Google Sheets Message Sender in the console for one-off sends, and a short Google Apps Script that personalizes each message, paces the sends on a timer, respects opt-outs and logs every reply back into the same spreadsheet. ### Built-in sender or Apps Script The Maytapi console has a no-code **Google Sheets Message Sender** under **Integrations**. You pick a connected phone, share your sheet with the service account email shown on the page, paste the sheet's URL and click **Send Messages with Google Sheets**. It reads columns A to G of the first tab and sends one message per row: | Column | Content | |---|---| | A | Message type: `text`, `media` or `poll` | | B | Recipient number, digits only with the country code, like `905301234567` | | C | The text, a media URL or a base64 data URI, or the poll question | | D | Caption for `media` rows, or the first poll option | | E, F | Second and third poll options (polls support up to three) | | G | `TRUE` for a poll that allows only one choice | Rows where A, B or C is empty are skipped, so keep only message rows on that tab. The console asks for **Editor** access because it can write delivery status back to the sheet. The template file is on the same page under **Download**. The built-in sender is the fastest route when the list is ready and you just want it sent. It doesn't log replies, and it sends the whole tab in one go, so pacing is left to your phone's sending queue and Safe Mode settings. When you want control over timing, opt-outs and replies, use Apps Script. > **Note:** Sending a whole sheet at once is the riskiest pattern for a WhatsApp number. Send only to contacts who opted in, and start with a few dozen rows before you send a full list. Comparison of the Maytapi console Google Sheets Message Sender and a Google Apps Script: the console sender needs no code, reads columns A to G and sends the whole tab at once; Apps Script personalizes, paces sends on a timer, honors opt-outs and logs replies. | | Console sender | Apps Script | |---|---|---| | Setup | No code, share the sheet | Paste a script, add a trigger | | Message types | Text, media, poll | Anything `sendMessage` accepts | | Personalization | Sheet formulas in column C | Template per row in code | | Pacing | Whole tab queued at once | Small batches with random gaps | | Replies and opt-outs | Not logged | Logged to a Replies tab | ### How the Apps Script workflow fits together Everything lives in one spreadsheet with three tabs and a script bound to it: - **Contacts**: `phone`, `name`, `detail`, `status`, `msgId`, `sent_at`, one row per person. - **Replies**: filled in by the webhook with the time, number, name and text of every reply. - **OptOut**: numbers that answered STOP. Nothing is sent to them again. A time-driven trigger runs `sendBatch()` every 10 minutes. Each run sends a few messages through `POST /sendMessage`, waits a random interval between them with `Utilities.sleep`, and writes the `msgId` and a status back to the row. The script is also deployed as a web app, and its `doPost` function is your Maytapi webhook: incoming messages are appended to Replies, and receipts update the status column. Flow of the Google Sheets WhatsApp workflow: checkPhones marks rows READY, a 10-minute trigger runs sendBatch, POST sendMessage queues each message with a random gap, and the doPost web app logs replies, opt-outs and receipts back into the sheet. All Maytapi endpoints below live under `https://api.maytapi.com/api/PRODUCT_ID/PHONE_ID/` and use your token in the `x-maytapi-key` header. If you haven't connected a number yet, start with [getting started](/blog/whatsapp-api-getting-started). ### Store your credentials and call the API Open the sheet, choose **Extensions > Apps Script**, and under **Project Settings > Script properties** add `PRODUCT_ID`, `PHONE_ID`, `TOKEN` and `WEBHOOK_KEY` (any long random string). Keeping them in script properties means they aren't visible to everyone who can read the code. Format the `phone` column as plain text (**Format > Number > Plain text**) so Sheets doesn't turn long numbers into scientific notation. Numbers go in international format without `+`, like `905301234567`. A small helper wraps every call: ```javascript const PROPS = PropertiesService.getScriptProperties(); const PRODUCT_ID = PROPS.getProperty('PRODUCT_ID'); const PHONE_ID = PROPS.getProperty('PHONE_ID'); const TOKEN = PROPS.getProperty('TOKEN'); const BASE = `https://api.maytapi.com/api/${PRODUCT_ID}/${PHONE_ID}`; function maytapi(path, body) { const res = UrlFetchApp.fetch(BASE + path, { method: 'post', contentType: 'application/json', headers: { 'x-maytapi-key': TOKEN }, payload: JSON.stringify(body), muteHttpExceptions: true }); return JSON.parse(res.getContentText()); } const digits = (v) => String(v).replace(/\D/g, ''); ``` Errors come back as JSON with `success: false` and a `message`, so check the body, not just the HTTP status. ### Check the numbers first Sends to numbers that aren't on WhatsApp fail and make your sending look colder than it is. Run new rows through `POST /checkPhones` once before the first send and mark the dead ones: ```javascript function checkNumbers() { const sheet = SpreadsheetApp.getActive().getSheetByName('Contacts'); const rows = sheet.getDataRange().getValues(); const todo = []; for (let i = 1; i < rows.length; i++) { if (rows[i][0] && !rows[i][3]) todo.push({ row: i + 1, phone: digits(rows[i][0]) }); } for (let i = 0; i < todo.length; i += 50) { const chunk = todo.slice(i, i + 50); const res = maytapi('/checkPhones', { numbers: chunk.map((c) => c.phone) }); if (!res.success) throw new Error(res.message); const valid = {}; res.data.forEach((r) => { valid[r.id.user] = r.valid; }); chunk.forEach((c) => { sheet.getRange(c.row, 4).setValue(valid[c.phone] ? 'READY' : 'NOT_ON_WHATSAPP'); }); Utilities.sleep(5000); } } ``` Each item in `data` has `valid` (`true` or `false`), a `status` (`valid` or `invalid`) and an `id` whose `user` is the number you sent. The batch size of 50 is a starting point, not a documented limit. The [bulk number check guide](/blog/bulk-whatsapp-number-check) covers pacing for larger lists. ### Send personalized messages in paced batches `sendBatch()` picks up rows with status `READY`, skips anyone on the OptOut tab, and sends at most five messages per run with 20 to 45 seconds between them: ```javascript const BATCH_SIZE = 5; const TEMPLATES = [ 'Hi {name}, this is Acme Store. Your order {detail} is ready for pickup. Reply STOP to opt out.', 'Hello {name}! Acme Store here: order {detail} is packed and waiting for you. Reply STOP to stop these updates.' ]; function sendBatch() { const hour = Number(Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'H')); if (hour < 9 || hour >= 20) return; // quiet hours const lock = LockService.getScriptLock(); if (!lock.tryLock(1000)) return; // previous run still busy try { const ss = SpreadsheetApp.getActive(); const sheet = ss.getSheetByName('Contacts'); const optOut = new Set(ss.getSheetByName('OptOut').getDataRange().getValues().map((r) => digits(r[0]))); const rows = sheet.getDataRange().getValues(); let sent = 0; for (let i = 1; i < rows.length && sent < BATCH_SIZE; i++) { const [phone, name, detail, status] = rows[i]; if (status !== 'READY') continue; const to = digits(phone); if (optOut.has(to)) { sheet.getRange(i + 1, 4).setValue('OPTED_OUT'); continue; } const text = TEMPLATES[Math.floor(Math.random() * TEMPLATES.length)] .replace('{name}', name || 'there') .replace('{detail}', detail); const res = maytapi('/sendMessage', { to_number: to, type: 'text', message: text }); sheet.getRange(i + 1, 4, 1, 3).setValues([[ res.success ? 'SENT' : 'ERROR: ' + res.message, res.success ? res.data.msgId : '', new Date() ]]); SpreadsheetApp.flush(); sent++; if (sent < BATCH_SIZE) Utilities.sleep(20000 + Math.random() * 25000); } } finally { lock.releaseLock(); } } ``` A few details matter here: - **`success: true` means queued, not delivered.** `sendMessage` adds the message to your phone's sending queue and returns a `chatId` and a `msgId` right away. The receipt comes later on the webhook, which is why the `msgId` is stored. - **Each run stays short.** Apps Script stops a run after six minutes, so five sends with up to 45 seconds between them fit comfortably. The lock stops two runs from overlapping. - **Every message is different.** Names, order numbers and two rotating phrasings keep the text from looking like a bulk blast. The [broadcast guide](/blog/whatsapp-broadcast-api) explains why that matters. Create the trigger once by running this function from the editor: ```javascript function installTrigger() { ScriptApp.newTrigger('sendBatch').timeBased().everyMinutes(10).create(); } ``` That's at most 30 messages an hour during the sending window. Google limits the total daily runtime of triggers, with a lower limit on free Gmail accounts than on Workspace, and most of each run here is spent sleeping, so check Google's current Apps Script quotas before you scale up. On a new number, turn on Safe Mode as well: it delays messages in the queue when they come too fast, whatever your script does. The [ban prevention guide](/blog/whatsapp-ban-prevention-safe-mode) covers the settings. ### Log replies and opt-outs with doPost To get replies into the sheet, add a `doPost` function and deploy the script as a web app (**Deploy > New deployment > Web app**, execute as **Me**, access **Anyone**). Apps Script doesn't expose request headers to `doPost`, so the secret goes in the URL as a query parameter. That key is the only thing protecting a public URL that writes into your sheet, and it is also stored in your Maytapi webhook settings, so treat the whole URL as a secret and change the key if the URL leaks: ```javascript function doPost(e) { if (e.parameter.key !== PROPS.getProperty('WEBHOOK_KEY')) return ContentService.createTextOutput('ignored'); const body = JSON.parse(e.postData.contents); const ss = SpreadsheetApp.getActive(); const lock = LockService.getScriptLock(); lock.waitLock(5000); try { if (body.type === 'message' && body.message && !body.message.fromMe && !String(body.conversation).endsWith('@g.us')) { const m = body.message; const phone = (body.user && body.user.phone) || ''; const text = m.type === 'text' ? m.text : '[' + m.type + ']'; ss.getSheetByName('Replies').appendRow([new Date(), phone, body.user && body.user.name, text, m.id]); if (/^(stop|unsubscribe)$/i.test(String(text).trim())) { ss.getSheetByName('OptOut').appendRow([phone, new Date()]); } } if (body.type === 'ack' && Array.isArray(body.data)) { const contacts = ss.getSheetByName('Contacts'); body.data.forEach((a) => { const cell = contacts.createTextFinder(a.msgId).matchEntireCell(true).findNext(); if (cell) contacts.getRange(cell.getRow(), 4).setValue(a.ackType.toUpperCase()); }); } } finally { lock.releaseLock(); } return ContentService.createTextOutput('ok'); } ``` Copy the web app URL, add `?key=` with your secret, and point Maytapi at it. To avoid replacing the webhook other systems on your product use, give just this phone its own URL with `POST /config`: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/config" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"webhook": "https://script.google.com/macros/s/DEPLOYMENT_ID/exec?key=YOUR_SECRET", "ack_delivery": true}' ``` `POST /setWebhook` at product level works too if the sheet is your only consumer. When you set a product webhook, Maytapi first posts a sample `text` message from `905801234567` to check the URL. That check arrives wrapped in a `body` key, so `doPost` sees no top-level `type` and writes no row for it; the first row you see is a real reply. What arrives in `doPost`: | Event | Fields the script uses | |---|---| | `type: "message"` | `conversation`, `user.phone`, `user.name`, `message.type`, `message.text`, `message.id`, `message.fromMe` | | `type: "ack"` | `data[]` with `msgId`, `ackType` (`delivered`, `reached`, `seen`, `played`, `deleted`) and `ackCode` 1 to 5 | Receipts can arrive more than once and out of order, so a row may briefly show `REACHED` after `SEEN`. If that bothers you, keep the highest `ackCode` instead of the latest. Some contacts arrive with an `@lid` id instead of a phone number. [LID to phone number](/blog/whatsapp-lid-to-phone-number) explains how to match them. Edit the code and use **Manage deployments > Edit > New version**, or the URL keeps serving the old code. ### Practical tips - **Ask for a typed reply.** "Reply 1 to confirm, 2 to reschedule" arrives as a plain `text` message on every connection and lands in Replies as-is. Reply-button taps may not arrive as webhook events on every connection. - **Honor STOP before anything else.** The OptOut tab is checked on every run, so a number that opts out at 10:02 is skipped from 10:10 on. - **Keep the webhook fast.** Maytapi waits a limited time for your endpoint. A few `appendRow` calls are fine. Don't call other APIs from `doPost`. - **Start small.** Send to a few dozen opted-in contacts who already chat with you, watch the Replies and status columns, and only then widen the list. ### Next steps One spreadsheet, one script and two Maytapi endpoints give you personalized sends, paced delivery and a reply log your whole team can read. Try it on the [3-day free trial](https://console.maytapi.com/register), no card needed. - Plan volume, personalization and opt-outs with the [WhatsApp broadcast API guide](/blog/whatsapp-broadcast-api). - Keep a new number safe with [ban prevention and Safe Mode](/blog/whatsapp-ban-prevention-safe-mode). - Clean large lists before a campaign with the [bulk WhatsApp number check](/blog/bulk-whatsapp-number-check). - See every payload `doPost` can receive in the [webhook events guide](/blog/whatsapp-webhook-events-guide). - Prefer a visual builder? The [Make integration guide](/blog/whatsapp-make-integration) watches new sheet rows without code. ### WhatsApp API Partner Program: White-Label Phones for Agencies URL: https://maytapi.com/blog/whatsapp-api-partner-program Published: 2025-09-09, updated: 2026-10-27 Category: Business > Resell WhatsApp API under your brand: create free trial phones for clients with the partner API, then switch them to live or closed with one call. If you run an agency, a SaaS product or a chatbot platform, your customers probably want WhatsApp inside the tool they already pay you for. Building that on top of a regular Maytapi account works for a handful of numbers, but it gets awkward fast: every new customer means a manual "add phone" in the console, and you pay for numbers that are still in a sales demo. The Maytapi partner program is built for that situation. You sell WhatsApp API under your own brand, give every prospect a free trial phone from your own signup flow, and switch numbers to live or closed with one API call. This guide explains who the program is for, how partner phones work, and how to automate client onboarding with the partner API. ### Who the partner program is for The partner program is for companies that offer WhatsApp messaging to their own customers rather than only using it themselves: - **Agencies** that set up WhatsApp automations, bots or campaigns for clients. - **SaaS products** (CRMs, help desks, booking tools, e-commerce apps) that want a "Connect WhatsApp" button. - **Chatbot and automation platforms** that need a WhatsApp backend per customer. - **Resellers and startups** that want to offer a white-label WhatsApp API under their own brand. Maytapi runs the infrastructure, and you keep the customer relationship. Your customers sign up in your product and see your brand, while your backend talks to Maytapi. A partner account is set up by Maytapi, so you start by getting in touch through the [partner program page](/whatsapp-api-partner). Conditions, agreements and prices are discussed directly with the team. **What to tell your clients.** Maytapi is an unofficial API: each client's number connects as a linked device, the same way WhatsApp Web does, and is not approved by Meta. WhatsApp can restrict or ban a number that sends spam-like traffic, and no provider can reverse that. Ask each client to use a dedicated number, to message only people who expect it, and to warm new numbers up with [Safe Mode](/blog/whatsapp-ban-prevention-safe-mode) before real traffic starts. ### How partner phones work A "phone" in Maytapi is one instance that a WhatsApp number connects to. On a partner account every phone has a **mode** on top of its connection status: | Mode | What it means | How you get there | |---|---|---| | `trial` | A new phone, free while it's in trial | Every phone created with the partner `addPhone` starts here, for three days | | `live` | A paid, running phone | `POST /changeMod` with `"mod": "live"` | | `closed` | Switched off; the phone is stopped | `POST /changeMod` with `"mod": "closed"` | The billing rule on the partner page is short: you don't pay anything for trial phones, you only pay for the phones you make live. So the intended flow is to create a trial phone the moment a prospect signs up, let them connect their number and try your product, then make it live when they buy or close it when they don't. You never pay for a demo that didn't convert. Partner phone lifecycle: addPhone creates a trial phone, the client links WhatsApp by QR code, then changeMod switches it to live when they buy or closed when they don't Mode is separate from connection status. A phone can be `trial` and `active` (connected and sending), or `live` and `qr-screen` (paid, waiting for the client to scan). The status values are the same as on any Maytapi phone and are listed in the [getting started guide](/blog/whatsapp-api-getting-started). ### The partner API The partner API is deliberately small. It has two endpoints, and both live under a different base URL from the rest of the API: ``` https://api.maytapi.com/partner/PRODUCT_ID/ ``` | Task | Endpoint | Body | |---|---|---| | Create a trial phone | `POST /addPhone` | optional `number` | | Switch a phone to live or closed | `POST /PHONE_ID/changeMod` | `mod`: `"live"` or `"closed"` | Everything else (connecting the number, setting a webhook, sending messages, reading groups) uses the standard API at `https://api.maytapi.com/api/PRODUCT_ID/PHONE_ID/` with the **same product ID and the same token**. Authentication is identical too: send your token in the `x-maytapi-key` header. You find both on the console's token settings page. Comparison of the partner API base /partner/PRODUCT_ID for addPhone and changeMod with the standard API base /api/PRODUCT_ID for qrCode, config, sendMessage and listPhones On a partner product, three standard endpoints redirect you to the partner API instead of running: - `POST /addPhone` on the `/api/` base answers `Please use /partner/addPhone for adding new phone.` - `/delete` answers `Please use /partner/changeMod for disabled.` - `GET /enable` answers `Please use /partner/changeMod for enable.` If you're moving existing code over to a partner account, those messages tell you which calls to switch. ### Add a phone for a client ```bash curl -X POST "https://api.maytapi.com/partner/$PRODUCT_ID/addPhone" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"number": "905301234567"}' ``` The body is optional. If you send a `number`, it's stored with the phone; otherwise the number is filled in when the client connects. The response is the new phone record: ```json { "id": 12345, "pid": "1a2b3c4d-...", "number": "905301234567", "type": "whatsapp" } ``` The record has a few more fields, such as `status` and `data`, but they're empty or still changing at this point. `id` is the phone ID you use in every later call, and `pid` is your product ID. Store the `id` against the client in your own database straight away. That mapping is what lets you route webhooks and bill the right customer later. The call fails with `success: false` and `Maximum number of phones are created, no more allowed!` when the product has reached its phone limit. Unlike the standard `addPhone`, the partner version doesn't extend your subscription automatically, so handle that message in your signup flow and contact Maytapi if you need a higher limit. ### Onboard a client in Node.js Here is a complete onboarding step for a new customer: create the trial phone, point its webhook at your platform, and fetch the QR code to show in your UI. The webhook is set per phone with `POST /config`, so every client's events arrive at a URL that identifies them. ```js const PARTNER = `https://api.maytapi.com/partner/${PRODUCT_ID}`; const API = `https://api.maytapi.com/api/${PRODUCT_ID}`; const headers = { "x-maytapi-key": TOKEN, "Content-Type": "application/json" }; async function onboardClient(clientId) { // 1. Create a trial phone const phone = await fetch(`${PARTNER}/addPhone`, { method: "POST", headers, body: JSON.stringify({}) }).then((r) => r.json()); if (!phone.id) throw new Error(phone.message || "addPhone failed"); await db.clients.update(clientId, { phoneId: phone.id, mode: "trial" }); // 2. Send this phone's events to the client's webhook on your platform await fetch(`${API}/${phone.id}/config`, { method: "POST", headers, body: JSON.stringify({ webhook: `https://app.example.com/webhooks/whatsapp/${clientId}` }) }); // 3. Get the QR code (a PNG image) for the client to scan const qr = await fetch(`${API}/${phone.id}/qrCode`, { headers }); if (qr.headers.get("content-type")?.includes("image")) { const png = Buffer.from(await qr.arrayBuffer()).toString("base64"); return { phoneId: phone.id, qr: `data:image/png;base64,${png}` }; } return { phoneId: phone.id, qr: null }; // not ready yet, retry shortly } ``` A new phone needs a few seconds to start. If `qrCode` returns JSON with `success: false` instead of an image, wait and try again. QR codes also rotate, so refresh the image in your UI every so often rather than showing one code forever. If your client can't scan a code, for example because they set things up from the same phone, use the pairing code flow: `POST /connectWithPhoneNumber` with their `number`, and show them the code from `connectionResult`. Both flows are covered in the [getting started guide](/blog/whatsapp-api-getting-started). Once the client is connected, their phone works exactly like any other Maytapi phone. Messages go out with `POST /sendMessage` (see the [send message guide](/blog/whatsapp-api-send-message)), and incoming messages and delivery updates reach the webhook you configured (see the [webhook guide](/blog/whatsapp-webhook-events-guide)). ### Move a phone to live or closed When the client pays, make the phone live: ```bash curl -X POST "https://api.maytapi.com/partner/$PRODUCT_ID/$PHONE_ID/changeMod" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"mod": "live"}' ``` ```json { "success": true, "message": "12345 mod changed to live" } ``` When they cancel or the trial ends without a purchase, send `"mod": "closed"` instead. Here is what each value does: - **`closed`** stops the phone. Its session is backed up and the instance is shut down, so the phone ends up `disabled`. If a close is already in progress, a second request returns `changeMod rejected: phone is already in dispatch state, backup task is already running.` - **`live`** on a running trial phone just marks it as paid. On a phone you closed earlier, it also starts the instance again, but only if your product has room: the active phone count must be below your phone limit, otherwise you get `Your active phone count is greater than your phone limit.` - **Anything else**, including `trial`, returns `mod must be live or closed`. You can't put a phone back into trial from the API. A typical integration calls `changeMod` from your billing system: `live` from the "payment succeeded" handler, `closed` from "subscription cancelled" or from a nightly job that closes trials older than three days. Always check `success` in the response before you update your own records. ### Track modes and connection health `GET /listPhones` on the standard base returns every phone on the product. On a partner account, each item also has a `phone_mod` field with `trial`, `live` or `closed`, next to the usual `id`, `number` and `status`: ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/listPhones" \ -H "x-maytapi-key: $TOKEN" ``` Use it to reconcile your billing records with Maytapi once a day: a phone that's `live` in `phone_mod` but cancelled in your system should be closed, and a paying customer whose phone shows `closed` needs attention. For connection health, don't poll. Your webhook receives `type: "status"` events when a phone's state changes, and they carry the `phone_id`, so you know which client to notify: ```json { "type": "status", "product_id": "1a2b3c4d-...", "phone_id": 12345, "status": "qr-screen" } ``` When a client's phone drops to `qr-screen` or `phone-error`, show a "reconnect WhatsApp" prompt in your product or email them. That's the most common support ticket you'll get as a partner, and catching it early saves you most of them. ### White-label resale or referral The partner page offers two ways to work with Maytapi. **Sell under your own brand.** With the partner API, your clients sign up, connect and message inside your product. They see your brand, not Maytapi's. The partner program also includes premium technical support through a dedicated channel, and Maytapi works with partners on feature requests and custom development. **Refer customers instead.** If you'd rather send customers to Maytapi than resell, the referral program gives you a personalised panel with your logo and name and a unique referral link. You earn a share of the first-year revenue from the customers you refer, growing with the number of referrals: | Referred clients | Your share of first-year revenue | |---|---| | 20 | 30% | | 50 | 40% | | 100 or more | 50% | You track referrals and earnings in the panel, and payouts go directly to your account. Exact conditions for either option are agreed with Maytapi, so use the contact on the [partner program page](/whatsapp-api-partner) to start. ### Next steps The partner model comes down to two calls: `addPhone` for every new prospect, and `changeMod` when they buy or leave. Everything in between is the standard Maytapi API you may already be using. - Learn the phone basics, QR codes and pairing codes in the [getting started guide](/blog/whatsapp-api-getting-started). - Handle every event your clients' phones send in the [webhook events guide](/blog/whatsapp-webhook-events-guide). - Keep new client numbers safe while they warm up with [Safe Mode](/blog/whatsapp-ban-prevention-safe-mode). - Ideas to package for your clients are in [WhatsApp API business use cases](/blog/whatsapp-api-business-use-cases). - Compare a flat per-number price with Meta's per-message billing in [WhatsApp API pricing after October 2026](/blog/whatsapp-api-pricing-october-2026). - Check the standard endpoints in the [API documentation](/whatsapp-api-documentation). Ready to set up a partner account? [Contact the team on the partner program page](/whatsapp-api-partner). ### WhatsApp Webhooks: Every Event Your Server Can Receive URL: https://maytapi.com/blog/whatsapp-webhook-events-guide Published: 2025-05-06, updated: 2026-10-27 Category: Guides > A practical tour of WhatsApp webhook events: messages, acks, reactions, edits, polls, calls, labels, orders and group events, with payload examples. Polling for new WhatsApp messages is slow and wasteful. A WhatsApp webhook flips it around: every time something happens on your number, Maytapi sends an HTTPS POST with a JSON body to a URL you control. This guide covers how to set the webhook (globally and per number), how to secure it, including the optional static IP, and then walks through every event type you can receive, with payload examples taken from the API reference. ### How the WhatsApp webhook works You register one HTTPS URL. From then on, every phone connected to your product posts its events to that URL as JSON. Each event has a top-level `type` field, so a single endpoint can route everything: - `message`: anything that arrives in a chat, plus reactions, poll votes, edits, pins, calls, labels, orders and group notifications, which all ride on this type with their own `message.type` or `subtype`. - `ack`: delivery and read receipts for messages you sent. - `status`: session state changes for a connected phone. - `error`: a message you queued could not be sent. - `live_event`: agent events, only when you use the API together with LiveChat. Every event carries `product_id` (and `phone_id` where it applies), so one endpoint can serve all the numbers on your account. ### Set the webhook URL `setWebhook` is a product-level endpoint, so there is no phone id in the path: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/setWebhook" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"webhook": "https://example.com/maytapi/webhook"}' ``` Workflow for configuring the WhatsApp webhook: set the product URL, optionally a per-phone URL, turn receipts on or off and test from the console. You can also set and change it from the console on the Developers > Webhook page. #### A different webhook per phone If different numbers belong to different clients or systems, give a phone its own URL with `POST /config`. When a phone's config is empty, the product webhook is used. ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/config" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"webhook": "https://client-a.example.com/webhook", "ack_delivery": true}' ``` `GET /config` returns the current values. #### Turn receipts on or off Ack events can be a large share of your traffic. If you do not need them, switch them off for the product with `setAckPreference`, and your webhook will only receive incoming messages and status changes: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/setAckPreference" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"ack_delivery": false}' ``` The per-phone `ack_delivery` flag in `/config` does the same for a single number. ![Maytapi console webhook page with the default webhook URL, the static IP toggle and phone-specific webhooks](/images/showcase/whatsapp-webhook-api-console.webp) *The Developers > Webhook page: a default URL, a static IP switch and a separate webhook per phone, each with a Test button.* The Test button posts a sample `message` event to your URL and shows the response time, which is the quickest way to check that your endpoint is reachable. ### Secure your webhook endpoint Your webhook URL is a public endpoint that accepts JSON from the internet, so treat it like one. - **Use HTTPS.** The webhook must be reachable from the internet. During development, a tunnel such as ngrok can expose your local server. - **Use an unguessable path.** Something like `/maytapi/webhook/7f3c9a...` keeps random scanners from posting to it. - **Check the ids.** Reject events whose `product_id` is not yours, and route by `phone_id` only to numbers you know. - **Know what is not there.** Requests are not signed and carry no secret header, so the URL path, the id checks and the static IP below are your protection. Keep the URL private and change it if it leaks. - **Allowlist the static IP.** Since 14.08.2026 you can enable a static IP on the [console webhook page](https://console.maytapi.com/developers/webhook), so deliveries reach you from a single IP. Turn on the switch, copy the IP shown there and allow only that address in your firewall. This is the strongest of these options if your network supports allowlists. ### Message events: text, media and more The `message` event is the one you will handle most. A plain text message looks like this: ```json { "type": "message", "product_id": "1a2b3c4d-...", "phone_id": 12345, "user": { "id": "905301234567@c.us", "name": "Customer", "phone": "905301234567" }, "message": { "id": "3EB081AF26FADE6416D8", "type": "text", "text": "Is my order shipped?", "fromMe": false }, "conversation": "905301234567@c.us", "receiver": "905307654321", "timestamp": 1574081887, "reply": "https://api.maytapi.com/api/1a2b3c4d-.../12345/sendMessage" } ``` `conversation` is the chat id (a group id ending in `@g.us` for group messages), `user` is the sender, and `reply` is a ready-made `sendMessage` URL for answering. `message.fromMe` is true for messages sent from your own number, so ignore those in bots or you will reply to yourself. `message.type` tells you what arrived. Media types (`image`, `video`, `document`, `ptt`, `audio`, `sticker`, `gif`, `ptv`) include a `url`, `mime`, `filename` and an optional `caption`. A `location` message carries `payload` as `"latitude,longitude"`. Contact cards arrive as `vcard` or `multi_vcard`. When a message mentions people, `mentions` lists their ids and contains `"all"` when the whole group was mentioned. #### Reactions, poll votes, edits and pins These events arrive as `message` events with their own `message.type`, and they point back to the original message through `parentMsgId`. Read reactions and poll votes from these `message` events. Some connections still send a legacy copy of them under `ack`, which is deprecated as of 30.10.2026. A reaction: ```json { "type": "reaction", "id": "3EB0...", "parentMsgId": "7EB065B353B9493D00G2", "reaction": "", "fromMe": false } ``` A poll vote (`pollVoteUpdate`) includes the vote that was just cast: ```json { "type": "pollVoteUpdate", "id": "3EB0...", "parentMsgId": "7EB065B353B9493D00G2", "text": "Poll Message", "currentVoteOption": { "id": 1, "name": "a", "votes": 1 }, "fromMe": false } ``` An edit (`message_edit`) gives you both versions, `prevText`/`newText` for text and `prevCaption`/`newCaption` for media: ```json { "type": "message_edit", "id": "3EB0...", "parentMsgId": "7EB065B353B9493D00G2", "prevText": "See you at 5", "newText": "See you at 6", "fromMe": false } ``` A pin (`pinMessage`, on the webhook since 21.04.2026) uses `action` 1 for pin and 2 for unpin, and `duration` in seconds (86400, 604800 or 2592000, `null` on unpin): ```json { "type": "pinMessage", "id": "3EB0...", "parentMsgId": "7EB065B353B9493D00G2", "action": 1, "pinned": true, "duration": 86400, "fromMe": false } ``` #### Event responses and live location When someone answers a WhatsApp event you created, you get `event_response` with `eventResponse` set to `going`, `maybe` or `notGoing`. Shared live locations arrive as `locationLive` updates: ```json { "type": "locationLive", "id": "3EB0...", "payload": "38.3978531,28.968876", "isLive": true } ``` ### Group, call and label notifications Notifications that are not chat messages arrive with `message.type` set to `info` and a `subtype`. **Group events** include `group/add`, `group/remove`, `group/promote`, `group/demote`, `group/leave`, `group/invite`, `group/name`, `group/description`, `group/restrict`, `group/announce`, `group/member_add_mode`, `group/membership_approval_mode` and `group/revoke_invite`. The `participant` field names the member involved, and `conversation` is the group: ```json { "type": "info", "id": "3EB0...", "subtype": "group/add", "participant": "905301234567@c.us" } ``` Group participants can appear as `@lid` privacy ids. [WhatsApp @lid Explained](/blog/whatsapp-lid-to-phone-number) shows how to resolve them. **Calls** use `call/ringing`, `call/miss`, `call/reject` and `call/accept`. Since 15.09.2026 they include `isVideo`, so you can tell voice from video calls: ```json { "type": "info", "id": "3EB0...", "subtype": "call/ringing", "isVideo": false } ``` **Labels** (WhatsApp Business) arrive as `label/add` and `label/remove` since 12.10.2026, with the label's id, name and color: ```json { "type": "info", "subtype": "label/add", "id": "true_905301234567@c.us_LABELADD845838T1790935070_908501234567@c.us", "fromMe": true, "label": { "id": "845838", "name": "Order complete", "color": "#F0B330", "colorIndex": 5 } } ``` There is also `chat/disappearing`, sent when the disappearing-messages timer of a chat changes, with the new timer in `ephemeral` (seconds). ### Orders, receipts and session status **Orders.** Since 20.02.2026, an order placed from your WhatsApp catalog arrives as a `message` event with `message.type` set to `order`. It contains the products (title, quantity, price, `retailerId`), `subtotal`, `total` and `currency`. The [catalog and orders guide](/blog/whatsapp-catalog-api-orders) covers it in detail. **Acks.** Receipts for messages you sent come as `type: "ack"`, with a `data` array: ```json { "type": "ack", "product_id": "1a2b3c4d-...", "data": [{ "ackType": "seen", "ackCode": 3, "chatId": "905301234567@c.us", "msgId": "50d8b950-a67f-11eb-91cc-d97af631df95", "time": 1586874663 }] } ``` Match `msgId` to the id that `sendMessage` returned. Codes run 1 `delivered` (WhatsApp servers), 2 `reached` (the phone), 3 `seen`, 4 `played` (voice notes) and 5 `deleted`. **Session status.** `type: "status"` reports the state of a connected phone: `active`, `qr-screen`, `idle` (left on the QR screen for three minutes), `conn-retry` (reconnecting after a dropped connection), `phone-error`, `loading`, `disabled`, `banned`, or `logout` when the session was logged out. Alert someone when a number sits in `qr-screen`, `idle`, `logout` or `phone-error`, because it needs attention before it can send again. ```json { "type": "status", "product_id": "1a2b3c4d-...", "phone_id": 12345, "status": "qr-screen" } ``` **Send errors.** A message that `sendMessage` accepted but your phone could not send arrives as `type: "error"`, with a `code`, a `message` and the original request in `data`. `data.id` is the `msgId` the send returned, so you can mark the right message as failed: ```json { "type": "error", "product_id": "1a2b3c4d-...", "phone_id": 12345, "code": "E01", "message": "The contact cannot be found!", "data": { "id": "3EB0A1B2C3D4E5F6", "to_number": "905301234567", "type": "text", "message": "Hello" } } ``` Common codes are `E01` (number not on WhatsApp, or a duplicate dropped by the filter), `F12` (WhatsApp has restricted new conversations), `F13` and `F14` (Safe Mode limits), `F16` (new-chat quota used up) and `UN5` (key-protected username without the right key). The [send message guide](/blog/whatsapp-api-send-message) lists the full table and what to do for each code. ### WhatsApp webhook event reference | Where | Value | What it means | |---|---|---| | `type` | `message` | Incoming or own message; inspect `message.type` | | `message.type` | `text`, `image`, `video`, `document`, `ptt`, `audio`, `sticker`, `gif`, `ptv`, `location`, `vcard`, `multi_vcard` | Regular chat content | | `message.type` | `reaction`, `pollVoteUpdate`, `message_edit`, `pinMessage` | Updates that point to `parentMsgId` | | `message.type` | `event_response`, `locationLive` | Event RSVP, live location update | | `message.type` | `order` | Order placed from your catalog | | `message.type` | `group_invite`, `group_invite_v4` | Group invitation sent in a chat | | `message.subtype` | `group/...` | Group membership and settings changes | | `message.subtype` | `call/ringing`, `call/miss`, `call/reject`, `call/accept` | Calls, with `isVideo` | | `message.subtype` | `label/add`, `label/remove` | Label changes on a chat | | `type` | `ack` | Delivery and read receipts | | `type` | `status` | Session state of a phone | | `type` | `error` | A queued message could not be sent; see `code` | | `type` | `live_event` | LiveChat agent events (API + LiveChat only) | ### Retries, idempotency and a minimal handler A few habits make a webhook handler reliable: - **Return 200 fast.** Acknowledge first, then do the work in a queue or background task. A slow database or AI call should never hold the response. - **Know the retry policy.** Any 2xx response counts as delivered. If your endpoint times out (about 10 seconds) or returns another status, Maytapi tries again right away, and if that fails too, makes one last attempt about 30 minutes later. After three failed attempts the event is dropped, so a long outage loses events. - **Expect duplicates.** Because of these retries, the same event may arrive more than once. Deduplicate on `message.id` for messages and on `msgId` plus `ackCode` for receipts. - **Do not assume order.** A `seen` ack can be processed before `reached` if your workers run in parallel. Keep the highest `ackCode` you have seen per message. - **Ignore what you do not handle.** New event types get added over time (the changelog lists them), so log unknown types instead of throwing. Checklist for a reliable webhook handler: return 200 fast, expect duplicates, do not assume order and ignore event types you do not handle. A minimal Express handler that follows these rules: ```js import express from "express"; const { TOKEN } = process.env; const app = express(); const seen = new Set(); // use Redis or your database in production app.post("/maytapi/webhook", express.json(), async (req, res) => { res.sendStatus(200); // acknowledge first const event = req.body; if (event.type !== "message" || event.message.fromMe) return; if (seen.has(event.message.id)) return; seen.add(event.message.id); if (event.message.type === "text" && event.reply) { await fetch(event.reply, { method: "POST", headers: { "x-maytapi-key": TOKEN, "Content-Type": "application/json" }, body: JSON.stringify({ to_number: event.conversation, type: "text", message: "Thanks, we got your message." }) }); } }); app.listen(3000); ``` Auto-replies are where automation most often trips WhatsApp's spam detection, so keep them relevant and rate-limited. The [ban prevention guide](/whatsapp-api-ban-prevention) explains why. ### Next steps - See the [webhook API](/features/whatsapp-webhook-api) feature page and the full callback schemas in the [API documentation](/whatsapp-api-documentation). - Turn these events into a working bot with [Build a WhatsApp Bot with Node.js](/blog/whatsapp-bot-nodejs). - Prefer no code? Point the webhook at n8n with the [Maytapi n8n integration](/tools/maytapi-n8n-integration). ### Managing WhatsApp Communities Programmatically URL: https://maytapi.com/blog/whatsapp-community-api-guide Published: 2025-03-25, updated: 2026-10-27 Category: Features > Use the WhatsApp Community API to create a community, link sub-groups, post announcements, manage members and admins, and read comments under posts. A WhatsApp Community groups related chats under one roof: an announcement group that reaches every member, plus sub-groups for topics, locations or cohorts. Setting one up by hand is fine once; keeping members, admins and sub-groups in sync with your school, store network or customer program is not. This guide walks through the WhatsApp Community API in Maytapi: creating a community, linking sub-groups, posting announcements, managing members and admins, and reading comments under your announcements. ### How WhatsApp Communities map to the API A community is made of three kinds of chats, and each has a group ID ending in `@g.us`: | Part | What it is | How you find it | |---|---|---| | Community | The parent container | `getCommunities` | | Announcement group | Where admins post to all members | `getGroups`, item with `isCommunityAnnounce: true` | | Sub-groups | Regular groups linked to the community | `linked_subgroups` in `getCommunities`, or `communityId` in `getGroups` | The community endpoints manage the container (members, admins, linked groups, invite link). Messaging and per-group settings still go through the regular [group API](/features/whatsapp-group-api) and `sendMessage`, so everything you know about groups applies to sub-groups. All requests go to `https://api.maytapi.com/api/PRODUCT_ID/PHONE_ID/...` with your token in the `x-maytapi-key` header. The Node.js snippets below share this setup: ```js const base = `https://api.maytapi.com/api/${PRODUCT_ID}/${PHONE_ID}`; const headers = { "x-maytapi-key": TOKEN, "Content-Type": "application/json" }; const post = (path, body) => fetch(`${base}/${path}`, { method: "POST", headers, body: JSON.stringify(body) }).then(r => r.json()); const get = (path) => fetch(`${base}/${path}`, { headers }).then(r => r.json()); ``` ### Plan the structure before you call the API A few minutes of planning saves a lot of re-linking later. Decide what one community represents and what each sub-group is for: - **A school or academy:** one community per school, a sub-group per class or course, and the announcement group for timetable changes and closures. - **A store network:** one community for the brand, a sub-group per location for local questions, and announcements for openings and campaigns. - **A customer program:** one community per product line, with sub-groups for beginners, power users and beta testers. Checklist of example community structures for a school, a store network and a customer program, plus storing the ID mapping. Keep the mapping in your own database: your internal entity (class, store, product) next to the WhatsApp group ID. Every call below takes IDs, and having them stored means your code never has to search by name. ### Create a community with the WhatsApp Community API `createCommunity` takes a `name`, an optional `description` and an optional `subGroupIds` array of existing groups to link right away: ```js const created = await post("createCommunity", { name: "Acme Community", description: "News and support for Acme customers", subGroupIds: ["120363012345678901@g.us", "120363012345678902@g.us"] }); const COMMUNITY = created.data.id; console.log(COMMUNITY, created.data.linked_subgroups?.length ?? 0); ``` The response includes the community `id`, its `participants` and `admins`, the `linked_subgroups` with their own settings, and the community `config`. Your number is the creator and admin. If you still need to create the sub-groups, do that first with `createGroup` (see [how to create a WhatsApp group with the API](/blog/create-whatsapp-group-api)) and pass their IDs here, or link them later. ### Link and unlink sub-groups To attach more groups to an existing community, call `community/addSubGroup` with the community ID and the group IDs: ```js await post("community/addSubGroup", { conversation_id: COMMUNITY, subGroupIds: ["120363012345678903@g.us"] }); ``` The response lists the linked IDs under `linked_subgroupsIds`. `community/removeSubGroup` takes the same body and detaches the groups; the groups themselves keep existing as normal groups. To see the structure at any time: - `GET /getCommunities` lists your communities. Add `full=true` to include sub-group details, and `sort=true` with `page` to paginate. - `GET /getCommunities/{conversation_id}` returns a single community. - `GET /getGroups` now returns `communityId` on every group that belongs to a community and `isCommunityAnnounce: true` on the announcement group. Communities themselves are not listed in `getGroups`. ### Post to the announcement group The announcement group is the community's broadcast channel: every member is in it and, by default, only admins can post. Find its ID by filtering `getGroups`: ```js const groups = await get("getGroups?sort=true"); const announce = groups.data.find( g => g.communityId === COMMUNITY && g.isCommunityAnnounce ); ``` `getGroups` is paginated, so on a number with many groups, loop over `page` until you find it and store the ID once. Posting is a normal `sendMessage` call with the announcement group as `to_number`. Any message type works: text, media, polls, locations, buttons. ```js const sent = await post("sendMessage", { to_number: announce.id, type: "media", message: "https://example.com/camden-store.jpg", text: "Our new Camden store opens this Saturday at 10:00. First 100 visitors get a gift!" }); ``` WhatsApp community announcement group where only admins can send, showing an image announcement sent through the sendMessage API *One sendMessage call to the announcement group reaches every member of the community.* For mentions, polls and other group message details, the [group messaging guide](/blog/send-message-to-whatsapp-group-api) applies unchanged. ### Read and post comments under announcements Members can comment under announcement-group messages, and since October 2026 the API handles both sides of that conversation. **Reading comments.** `GET /getMessage/{msgId}` on an announcement message returns two extra fields when it has comments: | Field | Contents | |---|---| | `replyCount` | Number of comments | | `comments` | Array sorted by time, each with `type: "comment"`, `text`, `user` (`id`, `name`, `phone`) and `timestamp` | ```js const msg = await get(`getMessage/${announcementMsgId}`); const m = (Array.isArray(msg.data) ? msg.data[0] : msg.data).message; console.log(`${m.replyCount ?? 0} comments`); for (const c of m.comments ?? []) console.log(c.user.name, ":", c.text); ``` **Posting a comment.** Use the `comment` message type. `to_number` is the announcement group and `reply_to` is the ID of the announcement you are commenting on: ```json { "to_number": "120363012345678900@g.us", "type": "comment", "reply_to": "false_120363012345678900@g.us_3EB0A81ECEA6D47F2F13_905301234567@c.us", "message": "Doors open at 9:45 for early birds." } ``` This is useful for follow-ups ("Update: parking is free on Saturday") that belong with the original post instead of a new announcement. ### Manage community members and admins Member operations take the community ID as `conversation_id`: | Action | Endpoint | Body | |---|---|---| | Add members | `community/add` | `numbers` array, optional `sendInvite` and `message` | | Remove a member | `community/remove` | `number` | | Make admin | `community/promote` | `number` | | Remove admin rights | `community/demote` | `number` | ```js await post("community/add", { conversation_id: COMMUNITY, numbers: ["905301234567", "905301234568"], sendInvite: true, message: "Welcome to the Acme community!" }); await post("community/promote", { conversation_id: COMMUNITY, number: "905301234567@c.us" }); ``` `sendInvite` controls whether invitations are sent to the new members, and `message` is included in that invitation. `community/add` also accepts `force: true`, which works as in `group/add`: numbers that are not on WhatsApp are skipped instead of failing the whole request. Some connections ignore `force`, so check the response for numbers that were not added. `community/revokeInvite` invalidates the current invite link and returns a fresh `inviteCode` and `inviteLink`. Use it when a link has been shared somewhere it shouldn't be. Adding many people at once is exactly the kind of activity WhatsApp watches. Add members who expect it (customers who opted in, staff, students), in reasonable batches, and read the [ban prevention guide](/whatsapp-api-ban-prevention) before you automate it. ### Keep members in sync with your own list Most communities mirror a list that lives elsewhere: enrolled students, active customers, current staff. A scheduled job keeps them aligned: 1. Read the community with `GET /getCommunities/{conversation_id}` and collect `participants`. 2. Compare with your source list. 3. Call `community/add` for the missing numbers and `community/remove` for people who should no longer be there. Workflow of a scheduled job that keeps community members aligned with your own list: read participants, resolve LIDs, compare, add and remove. Some participants may come back as privacy IDs ending in `@lid` instead of `@c.us` numbers. Resolve them before comparing, as explained in the [LID to phone number guide](/blog/whatsapp-lid-to-phone-number), or you will try to re-add people who are already members. ### Join sub-groups and deactivate a community As a member, your number does not automatically join every sub-group. Two endpoints handle that: - `GET /community/unjoinedSubGroups/{conversation_id}` lists the sub-groups you haven't joined. The ID can be the community or its announcement group. - `POST /community/joinSubGroup` with `conversation_id` (the community) and `subGroupId` joins one of them. ```js const { data: open } = await get(`community/unjoinedSubGroups/${COMMUNITY}`); for (const g of open) { await post("community/joinSubGroup", { conversation_id: COMMUNITY, subGroupId: g.id }); } ``` This is handy for a support or moderation number that needs to see every sub-group. When a community has run its course, `deactiveCommunity` with `conversation_id` deactivates it. Treat it as irreversible and keep it out of anything that runs automatically. > **Note:** Communities run on your own WhatsApp number through an unofficial API, not Meta's Business Platform. That gives you full community features without templates or per-message fees, but the number's reputation is yours to protect. ### Next steps - See all endpoints and examples on the [WhatsApp Community API page](/features/whatsapp-community-api). - Creating the sub-groups first? Follow [Create a WhatsApp group with the API](/blog/create-whatsapp-group-api). - Track joins, leaves and admin changes with the [webhook events guide](/blog/whatsapp-webhook-events-guide). - Create a community with one sub-group on the [3-day free trial](https://console.maytapi.com/register). ### Post WhatsApp Status Updates (Stories) from Code URL: https://maytapi.com/blog/whatsapp-status-api-post-stories Published: 2025-03-04, updated: 2026-10-27 Category: Features > Use the WhatsApp Status API to publish image, video, text and audio stories, choose exactly who sees them, and read your contacts' stories from code. WhatsApp Status (the Stories feature) is one of the few places where a business shows up for its contacts without sending them a message. Posting a status by hand every morning is easy to forget, though, and impossible to schedule. This guide shows how to use the WhatsApp Status API in Maytapi to publish image, video, text and audio stories, control exactly who sees them, check who viewed them, and read and reply to your contacts' stories. ### What the WhatsApp Status API covers Stories live on the number you pair with Maytapi, and the API exposes six endpoints plus two message types: | Task | Endpoint | |---|---| | Post a story | `POST /createStory` | | List your stories and their viewers | `GET /getStories` | | Read your contacts' stories | `GET /contactsStories`, `GET /contactsStories/{conversation_id}` | | Read or change who can see your stories | `GET /getStoryPrivacy`, `POST /setStoryPrivacy` | | Reply to a contact's story | `sendMessage` with `story_text` or `story_media` | Checklist of the story types the Status API posts, media, text and audio, plus the 24-hour visibility and storing the story ID. All endpoints are under `https://api.maytapi.com/api/PRODUCT_ID/PHONE_ID/` and authenticate with the `x-maytapi-key` header. The examples use Node.js 18+ with the built-in `fetch`: ```js const PRODUCT_ID = "your-product-id"; const PHONE_ID = "your-phone-id"; const TOKEN = "your-api-token"; const BASE = `https://api.maytapi.com/api/${PRODUCT_ID}/${PHONE_ID}`; const HEADERS = { "x-maytapi-key": TOKEN, "Content-Type": "application/json" }; const post = (path, body) => fetch(`${BASE}/${path}`, { method: "POST", headers: HEADERS, body: JSON.stringify(body) }).then((r) => r.json()); ``` As in the app, a status is visible to your contacts for 24 hours. As in WhatsApp itself, a contact usually sees your status only when their number is in your phone's address book and they have saved your number too. Within that group, your privacy setting decides who sees it, which we'll get to below. ### Post image and video stories A media story needs `type: "media"` and a public `media` URL. `text` is an optional caption: ```js const story = await post("createStory", { type: "media", media: "https://example.com/camden-store.jpg", text: "We are open! Visit our new store in Camden" }); console.log(story); ``` The response's `data` is the new story's ID, for example `true_status@broadcast_3EB00968DB76C3F4593410_905301234567@c.us`. Store it if you want to match viewers to a specific story later. > **Note:** If you add `contacts` or `excludeContacts` to `createStory`, it changes the number's account-wide story privacy setting, not just this story's audience. Read [Choose who sees your WhatsApp status](#choose-who-sees-your-whatsapp-status) before you send a targeted story. The same call posts a video: point `media` at an MP4 instead of an image. Use reasonably sized, good-quality files. Story publishing is still marked beta in the API documentation, and very low-quality media may not be shared. WhatsApp status showing a store photo with a caption, posted through the createStory API, with getStories and contactsStories endpoints *A photo story posted with createStory and type media, visible to contacts for 24 hours.* ### Text stories with a background color For a text-only status, use `type: "text"` and set `backgroundColor` to a hex color: ```js await post("createStory", { type: "text", text: "Weekend sale: 20% off everything until Sunday", backgroundColor: "#1E90FF" }); ``` Text stories are a good fit for automated content that changes daily: opening hours on holidays, today's menu, a stock alert, or an "order cut-off in 2 hours" reminder. ### Audio stories Audio stories were added in October 2026. Use `type: "audio"` with the file in `media`, and optionally a `backgroundColor`: ```js await post("createStory", { type: "audio", media: "https://example.com/weekly-update.oga", backgroundColor: "#1E90FF" }); ``` > **Note:** Audio stories currently accept OGG/Opus files only (`.oga`). Convert MP3 or WAV first, for example with `ffmpeg -i update.mp3 -c:a libopus update.oga`. #### Schedule stories The API has no scheduling field, so timing is up to your code. A cron entry or any job scheduler is enough: keep a table of upcoming stories (type, media URL or text, color, audience, publish time), and have a job that runs every few minutes post the ones that are due and record the returned story ID. Recording the ID lets you avoid double-posting if the job runs twice, and lets you look up viewers for that exact story later. Keep the media on a stable public URL; the API fetches it when the story is created. ### Choose who sees your WhatsApp status There are two ways to control the audience, and they interact, so it's worth understanding both. Workflow showing that a targeted story updates the account privacy setting, so the next story inherits it until you reset it. #### The account-wide privacy setting `GET /getStoryPrivacy` returns the current setting of the number: | `setting` | Who sees your stories | |---|---| | `contacts` | All your contacts | | `allowList` | Only the numbers in `allowList` | | `denyList` | All contacts except the numbers in `denyList` | Both lists are always returned; only the one that matches `setting` is in effect. To change it, call `setStoryPrivacy` with the setting and, for the list modes, a `contacts` array: ```js await post("setStoryPrivacy", { setting: "denyList", contacts: ["905301234567"] }); ``` This setting is permanent and synced to the phone, so it affects stories you post from the app as well. #### Per-story audience `createStory` also accepts `contacts` (only these numbers see the story) or `excludeContacts` (everyone except these): ```js await post("createStory", { type: "text", text: "VIP early access starts now", backgroundColor: "#792138", contacts: ["905301234567", "905301234568"] }); ``` Here is the part to remember: either field updates the account's story privacy setting before the story is shared, exactly as if you had called `setStoryPrivacy`. Your next story, from the API or the phone, inherits that audience unless you change it. If you mix targeted and public stories, call `setStoryPrivacy` with `setting: "contacts"` (or your usual list) after posting a targeted one, or pass the audience explicitly on every `createStory` call. ### See who viewed your stories `GET /getStories` lists the stories you have posted. Each item has the story `id`, a `storyContent` object (`type`, and `url` for media stories) and, once someone has seen it, a `viewers` array of those contacts: ```js const list = await fetch(`${BASE}/getStories?full=true`, { headers: HEADERS }).then((r) => r.json()); for (const s of list.data || []) { console.log(s.id, s.storyContent.type, (s.viewers || []).length, "views"); } ``` Results are paginated with `page`; `full=true` returns everything in one response. Viewer lists are useful for follow-ups, for example tagging contacts who viewed a promotion story or checking how many customers saw a service notice. ### Read and reply to contacts' stories The other direction is just as useful for relationship-driven businesses: seeing what your customers or partners post and responding. `GET /contactsStories` returns the contacts that currently have stories, each with a `chatId` and a `storyContent` array (`id`, `type` and, for image and video, a `url`). To check one person, use `GET /contactsStories/{conversation_id}`, for example `/contactsStories/905301234567@c.us`. Both support `page` and `full`. To reply, send a regular `sendMessage` to that contact with the story's `id` as `storyId`. Use `story_text` for a text reply: ```js await post("sendMessage", { to_number: "905301234567@c.us", type: "story_text", message: "Congratulations on the new office!", storyId: "false_status@broadcast_3AE1AC34B021C0E2D835_905301234567@c.us" }); ``` Use `story_media` to reply with an image or other media: put the media URL in `message` and an optional caption in `text`. The contact receives it in your chat as a reply to that story. Replies that contacts send to your stories come in through your [webhook](/features/whatsapp-webhook-api); the message carries a `storyId` that tells you which story they answered. The [webhook events guide](/blog/whatsapp-webhook-events-guide) shows how to route them. ### Use cases and good practice Some patterns that work well with the Status API: - **Daily or scheduled content.** A cron job posts the day's offer, menu or opening hours every morning. - **Catalog highlights.** When a product is restocked, post its photo as a story with a short caption. - **Segmented announcements.** Use `allowList` for VIP or wholesale customers and the public setting for everyone else. - **Engagement follow-up.** Combine `viewers` with your CRM to see which customers keep up with your updates. - **Event countdowns.** Post a short text story each day before a launch or event, then a photo story on the day itself. Stories only reach contacts who are in your address book and have saved your number, so they're less intrusive than direct messages. Still, the account is your own number on an unofficial API, so keep posting at a natural rhythm (a few stories a day, not dozens) and follow the [ban prevention guide](/whatsapp-api-ban-prevention). ### Next steps - See all story endpoints on the [WhatsApp Status API page](/features/whatsapp-status-api). - Sending images and videos in chats as well? See the [media messages API](/features/whatsapp-media-messages-api). - Check every request field in the [API documentation](/whatsapp-api-documentation). - Post your first story on the [3-day free trial](https://console.maytapi.com/register). ### WhatsApp Channel API: Post Updates, Polls and Quizzes and Read Statistics URL: https://maytapi.com/blog/whatsapp-channel-api-polls-quizzes Published: 2025-02-18, updated: 2026-10-27 Category: Features > Publish text, media, polls, questions and quizzes to a WhatsApp Channel from code, manage admins, and pull follower and reach statistics. WhatsApp Channels are one-way broadcast feeds: followers see your posts, react and vote, but cannot reply in a chat. That makes them a good fit for product news, daily content or community updates, and a tedious one to run by hand every day. This guide shows how to use the WhatsApp Channel API in Maytapi to create a channel, publish every post type (including polls, questions and quizzes), read votes and answers back, manage admins and pull follower statistics. ### How the WhatsApp Channel API works A channel has an ID ending in `@newsletter`, for example `120363098765432109@newsletter`. Your paired number owns or administers the channel, and you use the same endpoints you already know: | Task | Endpoint | |---|---| | Create, list, edit, delete | `createChannel`, `getChannels`, `channel/config`, `deleteChannel` | | Publish and delete posts | `sendMessage` with a `channel_*` type | | Read posts, votes and answers | `channel/getMessages/{conversation_id}`, `getMessage/{msgId}` | | Statistics | `channel/statistics/{conversation_id}` | | Admins | `channel_admin_invite` message, `channel/acceptAdminInvite`, `channel/revokeAdminInvite`, `channel/demote` | | Discovery and following | `channel/search`, `channel/searchByCountry/{countryCode}`, `channel/subscribe` and related | All endpoints live under `https://api.maytapi.com/api/PRODUCT_ID/PHONE_ID/` and take your token in the `x-maytapi-key` header. The examples below use these shell variables: ```bash export PRODUCT_ID=your-product-id export PHONE_ID=your-phone-id export TOKEN=your-api-token ``` ### Create and configure a channel `createChannel` takes a name, plus an optional description and image URL: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/createChannel" \ -H "x-maytapi-key: $TOKEN" -H "Content-Type: application/json" \ -d '{ "name": "Acme Store", "description": "New arrivals, restocks and weekly deals", "image": "https://example.com/acme-logo.png" }' ``` The response returns the new channel's `@newsletter` ID and its name. To find channels later, call `GET /getChannels`. Each item includes `channelName`, `owner`, `subscribeCount` and, with `?invite=true`, an `inviteLink` you can share on your site or in a [WhatsApp link](/tools/free-whatsapp-link-generator). To change the name, description or image afterwards, send the fields you want to update to `channel/config` together with `conversation_id`. `channel/inviteCode` returns the channel's invite link, and `deleteChannel` removes a channel you own. ### Post updates: text, links, media, audio and stickers Publishing works through `sendMessage`. Set `to_number` to the channel ID and pick a channel message type: | Type | `message` field | Extra fields | |---|---|---| | `channel_text` | Text of the post | | | `channel_link` | URL | `text` (optional) | | `channel_media` | Image, video or file URL | `text` caption (optional), `isGif` | | `channel_audio` | Audio file URL | | | `channel_sticker` | Sticker URL (`.webp`) | | | `channel_poll` | Poll question | `options`, `only_one` (optional) | | `channel_question` | Question text | | | `channel_quiz` | Quiz question | `options`, `correct_option` | | `channel_delete_message` | ID of the post to delete | | A media post with a caption from Node.js 18+: ```js const base = `https://api.maytapi.com/api/${PRODUCT_ID}/${PHONE_ID}`; const headers = { "x-maytapi-key": TOKEN, "Content-Type": "application/json" }; const CHANNEL = "120363098765432109@newsletter"; const res = await fetch(`${base}/sendMessage`, { method: "POST", headers, body: JSON.stringify({ to_number: CHANNEL, type: "channel_media", message: "https://example.com/autumn-collection.jpg", text: "Autumn styles are live. Free shipping this week only." }) }); console.log(await res.json()); ``` `channel_link` posts get a link preview. Since September 2026 the preview is sent at a larger size, with an automatic fallback to the smaller one when the large preview can't be sent. ### Polls, questions and quizzes in channels The three interactive post types are what make a channel feel two-way. Checklist of the three interactive channel post types, poll, question and quiz, with their key fields and limits. WhatsApp channel showing a photo post and a poll with vote counts, next to createChannel and channel_poll API calls *A channel created with createChannel, with a media post and a channel_poll sent through sendMessage.* #### Polls A poll needs the question in `message` and an `options` array. Set `only_one: true` if followers should pick a single answer: ```json { "to_number": "120363098765432109@newsletter", "type": "channel_poll", "message": "Which color should we add next?", "options": ["Forest green", "Navy", "Sand"], "only_one": true } ``` #### Questions `channel_question` posts an open question that followers can answer. It was added in August 2026: ```json { "to_number": "120363098765432109@newsletter", "type": "channel_question", "message": "What should our next workshop be about?" } ``` #### Quizzes `channel_quiz` (added October 2026) is a poll with a right answer. `correct_option` is the zero-based index of the correct option, so `1` below means "Paris". A quiz needs between 2 and 12 options, and they must be unique: ```json { "to_number": "120363098765432109@newsletter", "type": "channel_quiz", "message": "What is the capital of France?", "options": ["London", "Paris", "Berlin"], "correct_option": 1 } ``` A few ways teams use these post types: a weekly poll to pick the next product color or event date, a Friday quiz about your industry with the answer revealed in Monday's post, or a question asking followers what they want covered next. Because results are readable through the API, you can feed them into a dashboard or post a summary automatically. Keep the message ID from the `sendMessage` response (or read it from `channel/getMessages` later). You need it to read results or delete the post with `channel_delete_message`. ### Read votes, answers and reactions `GET /channel/getMessages/{conversation_id}` returns the channel's recent posts. Use `count` to set how many posts to fetch and `page` to go further back. To check a single post, use `GET /getMessage/{msgId}`. Each post's `message` object includes the results for its type: | Post type | Fields in the response | |---|---| | Poll | `options` (each with `name`, `id`, `votes`), `total_votes` | | Quiz | Same as a poll, plus `correct_option` and `correct_votes` | | Question | `responses` (each with `text`, `timestamp` and the responder's `user.name`; `id` and `phone` when the number is saved), `total` | | Any post | `reactions` per emoji, `totalReactionCount` | A small script that prints the score of every quiz in the latest 20 posts: ```js const r = await fetch(`${base}/channel/getMessages/${CHANNEL}?count=20`, { headers }); const { data } = await r.json(); // some connections wrap the result in an array: [{ users, messages, me }] const { messages } = Array.isArray(data) ? data[0] : data; for (const post of messages) { const m = post.message; if (m.correct_option === undefined) continue; console.log(m.text, `${m.correct_votes}/${m.total_votes} correct`); for (const o of m.options) console.log(` ${o.name}: ${o.votes}`); } ``` Run it on a schedule (or after a quiz closes) and you have a results feed for your next post: "62% got it right, the answer was Paris". ### Channel statistics: followers and reach `GET /channel/statistics/{conversation_id}` returns the analytics WhatsApp keeps for a channel you own: | Field | Meaning | |---|---| | `followers`, `netFollows`, `followersDelta` | Follower count and change | | `accountsReached`, `followersReached`, `nonFollowersReached` | Reach in the period | | `followersByCountry`, `reachByCountry` | Breakdown by country code | | `growthChartData` | Daily `activeFollowers`, `followersGained`, `followersLost` as `[timestamp, count]` pairs | | `rangeStart`, `rangeEnd`, `rangeInDays` | Period the numbers cover | ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/channel/statistics/120363098765432109@newsletter" \ -H "x-maytapi-key: $TOKEN" ``` Check `dataStatus` before using the numbers. Statistics come from WhatsApp, so a brand new channel may not have data yet. WhatsApp channel posts with reaction counts, next to channel/statistics, getChannels, channel/config and channel/inviteCode endpoints *Reactions on each post and the channel/statistics endpoint for followers and reach.* ### Manage channel admins Adding an admin is a two-step handshake: 1. Send a `channel_admin_invite` message to the person: `to_number` is their number, `channelId` is the channel and `message` is the invitation text. 2. If the invited number is also connected to Maytapi, the invitation arrives on its webhook as a `channel_admin_invite` message with the `channelId` and an expiry time (`inviteCodeExp`). That number accepts with `channel/acceptAdminInvite` and `conversation_id` set to the channel. Otherwise the person accepts in WhatsApp. Workflow of the two-step channel admin handshake: send an admin invite, the invitation arrives, the invitee accepts, with revoke and demote to undo. To cancel a pending invitation, call `channel/revokeAdminInvite` with `conversation_id` and `number`. To remove an existing admin, call `channel/demote` with `conversation_id` and `number` (for example `905301234567@c.us`). To invite followers rather than admins, the `channel_invite` message type sends a channel invitation card to a contact. ### Discover and follow other channels Your number can also act as a follower, which is handy for monitoring competitors or partner channels: - `POST /channel/search` with `text` (and `page`) searches channels by keyword. - `GET /channel/searchByCountry/{countryCode}` lists channels for a two-letter country code such as `US`. - `GET /channel/getInfoByInviteCode/{inviteCode}` resolves an invite link to a channel. - `channel/subscribe` / `channel/unsubscribe` take a `conversation_id`; `subscribeByInviteCode` / `unsubscribeByInviteCode` take an `inviteCode`. Search results come in pages of up to 50 channels with `channelName`, `subscribeCount` and `image`. Once you follow a channel, `channel/getMessages` reads its posts too. > **Note:** Channels run on your own WhatsApp number through an unofficial API. Posting to your channel is low risk, but keep follows and searches at a human pace and read the [ban prevention guide](/whatsapp-api-ban-prevention) before automating anything at volume. ### Next steps - See every channel endpoint on the [WhatsApp Channel API page](/features/whatsapp-channel-api). - Running groups as well? The [community API guide](/blog/whatsapp-community-api-guide) covers announcements and sub-groups. - Check request and response examples in the [API documentation](/whatsapp-api-documentation). - Create a test channel and post your first quiz on the [3-day free trial](https://console.maytapi.com/register). ### How to Check if Numbers Are on WhatsApp Before You Send URL: https://maytapi.com/blog/bulk-whatsapp-number-check Published: 2024-12-03, updated: 2026-10-27 Category: Guides > Clean your contact list in bulk: check which numbers have WhatsApp with the API or the free checker, and why it matters for delivery and bans. Every contact list goes stale. People change numbers, landlines end up in the "mobile" column, and some customers never used WhatsApp at all. Messages sent to those numbers never arrive, and from WhatsApp's point of view they make your sending look colder than it is. This guide shows how to check if a number is on WhatsApp before you send, one at a time or in bulk with the `checkPhones` endpoint, and how to use the results to clean your CRM. Checking a number sends nothing to it, and the owner is not notified. Only check numbers you already hold for a reason, such as customers, sign-ups or CRM contacts. A phone number is personal data under laws like GDPR and KVKK, and running generated or scraped number ranges through the check to find WhatsApp users goes against WhatsApp's terms. ### Why check if a number is on WhatsApp before sending Two reasons, one practical and one about keeping your number safe. **Delivery and reporting.** If 20% of a list isn't on WhatsApp, 20% of your sends will fail, and your delivery numbers will be wrong. Knowing in advance lets you route those contacts to SMS or email instead, and keeps your WhatsApp metrics honest. **Ban risk.** WhatsApp watches how a number behaves. First messages to many people, few replies and failed sends are all part of what an automated number looks like when it is about to be restricted. A clean list doesn't make cold outreach safe, but a dirty list makes every campaign look worse than it is. Combined with opt-in and a gradual warm-up (see [how to avoid WhatsApp bans with Safe Mode](/blog/whatsapp-ban-prevention-safe-mode)), list hygiene is one of the cheapest protections you have. ### Format numbers correctly first Most "invalid" results come from formatting, not from the number itself. `checkPhones` expects international format: country code followed by the national number, digits only, with no `+`, spaces, dashes or brackets. That is E.164 without the leading `+`. Comparison of phone numbers as stored in a CRM and the digits-only international format checkPhones expects. | As stored in the CRM | Send this | |---|---| | `+90 530 123 45 67` | `905301234567` | | `0530 123 45 67` (Turkey, national format) | `905301234567` | | `+1 (415) 555-0142` | `14155550142` | | `00 44 7700 900123` | `447700900123` | Watch two traps. National numbers often carry a trunk prefix (the leading `0` in Turkey or the UK) that must be dropped once you add the country code. And a number stored without a country code can't be guessed safely, so use the customer's billing country or flag it for review. A small normalizer in JavaScript: ```js function normalize(raw, defaultCc = "90") { const digits = String(raw || "").replace(/\D/g, ""); if (digits.startsWith("00")) return digits.slice(2); // international prefix if (String(raw || "").trim().startsWith("+")) return digits; if (digits.startsWith("0")) return defaultCc + digits.slice(1); // national format with trunk 0 return digits; } ``` Treat the output as a candidate, not a guarantee. The check itself tells you whether it is real. ### Check numbers with the checkPhones API `POST /checkPhones` takes an array of numbers and returns one result per number. It runs through one of your connected phones, so the phone must be active. ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/checkPhones" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"numbers": ["905301234567", "9053012345679"]}' ``` ```json { "success": true, "data": [ { "status": "valid", "valid": true, "id": { "server": "c.us", "user": "905301234567", "_serialized": "905301234567@c.us" } }, { "status": "invalid", "valid": false, "id": { "server": "c.us", "user": "9053012345679", "_serialized": "9053012345679@c.us" } } ] } ``` For each number you get: - `valid`: `true` if the number is registered on WhatsApp. - `status`: `"valid"` or `"invalid"`, the same answer as a string. - `id._serialized`: the WhatsApp id (for example `905301234567@c.us`), which you can use directly as `to_number` in `sendMessage`. The [number check API](/features/whatsapp-number-check-api) page has more examples, and the full schema is in the [API documentation](/whatsapp-api-documentation). ### Checking a whole list in batches The API doesn't document a hard maximum per request, but sending a 50,000-row export in one call is a bad idea. A failure loses the whole batch, and a sudden burst of lookups is not how a normal WhatsApp account behaves. Split the list into modest chunks and pause between them: ```js // Node.js 18+ (ESM) const BASE = `https://api.maytapi.com/api/${PRODUCT_ID}/${PHONE_ID}`; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function checkAll(numbers, size = 50, pauseMs = 5000) { const results = []; for (let i = 0; i < numbers.length; i += size) { const res = await fetch(`${BASE}/checkPhones`, { method: "POST", headers: { "x-maytapi-key": TOKEN, "Content-Type": "application/json" }, body: JSON.stringify({ numbers: numbers.slice(i, i + size) }), }); const body = await res.json(); if (!body.success) throw new Error(body.message || "checkPhones failed"); results.push(...body.data); await sleep(pauseMs); } return results; } const results = await checkAll(["905301234567", "14155550142"]); const onWhatsApp = results.filter((r) => r.valid).map((r) => r.id._serialized); ``` The batch size and pause above are starting points, not documented limits. The console's bulk tool is more cautious by default: 150 numbers per batch with a 20-minute pause. For large lists, spread the work over hours instead of minutes, and run checks on an established number rather than one you connected yesterday. ### Use the results to clean your CRM A check is only useful if the result lands where your team works. A simple pattern: 1. **Add two fields** to the contact record: `whatsapp_status` (`valid`, `invalid` or `unchecked`) and `whatsapp_checked_at`. 2. **Check on the way in.** Run new numbers through `checkPhones` at form submission or CRM import, so bad numbers never reach a campaign. 3. **Store the id.** Save `id._serialized` and send to that instead of the raw input. 4. **Route invalid contacts elsewhere.** Send them SMS or email, or ask them to confirm their number, instead of deleting them. 5. **Recheck periodically.** Numbers change hands and people install WhatsApp later. A recheck before a big send, or every few months for active segments, keeps the field honest. Five-step workflow for writing number check results back to a CRM: add fields, check on the way in, store the id, route invalid contacts and recheck. Once you start messaging the valid contacts, the [Google Contacts integration](/tools/maytapi-google-contacts-integration) can save each recipient to your Google address book automatically when you include `firstname` and `lastname` in `sendMessage`. Saved contacts help keep a WhatsApp account healthy. ### The free WhatsApp number checker and the console bulk tool You don't always need code. - **One number:** the [free WhatsApp number checker](/tools/free-whatsapp-number-checker-tool) tells you whether a single number is on WhatsApp. - **A spreadsheet:** the multi number checker under Tools in the Maytapi console takes an Excel or CSV file with numbers in column A, checks them through one of your connected phones and lets you download the results. It splits the file into batches with a pause between them by default, for the same pacing reason as above. Maytapi console number checker showing a valid WhatsApp number and a checkPhones request *The console checker confirms that a number is on WhatsApp. The same check is available through POST /checkPhones for bulk use.* > **Note:** A valid result only means the number can receive WhatsApp messages. It doesn't mean the person agreed to hear from you. Opt-in still decides who you message. ### When a number you trust comes back invalid If a customer swears they use WhatsApp but the check says `valid: false`, look at these first: - **Missing or doubled country code.** `5301234567` without `90`, or `9090530...` after a normalizer ran twice. - **Trunk prefix left in.** `9005301234567` keeps the national `0` after the country code. - **Extra or missing digits.** The second number in the example above has one digit too many, so it comes back invalid even though it looks almost right. - **A different number on WhatsApp.** Some people use WhatsApp on a second SIM or an old number. Ask them to confirm which one. - **The checking phone is offline.** If the connected phone isn't active, `checkPhones` can't answer. Check the phone's status in the console before you trust a batch of failures. Fix the formatting, then recheck only the invalid rows. There's no need to run the whole list again. ### Next steps Format your numbers, run the list through `checkPhones` in batches, and write the results back to your CRM before your next send. You can try it on the [3-day free trial](https://console.maytapi.com/register) with no card. Then see [8 WhatsApp API use cases for business](/blog/whatsapp-api-business-use-cases) for what to do with a clean list. ### Approve WhatsApp Group Join Requests with an API URL: https://maytapi.com/blog/whatsapp-group-join-requests-api Published: 2024-10-23, updated: 2026-10-27 Category: Tutorials > Turn on approval mode, list pending join requests and approve or reject them automatically against your CRM or paid-member list. A public invite link is the easiest way to grow a WhatsApp group, and the fastest way to fill it with people who shouldn't be there. WhatsApp's answer is approval mode: anyone who opens the link sends a join request, and an admin decides. That works for a dozen requests a week. For a paid community, a customer group or a course cohort, you want the decision made by your own rules. This guide shows how to turn approval mode on with the Maytapi API, read pending join requests, approve or reject them in bulk, and run the whole thing on a schedule against your CRM or paid-member list. ### How group join requests work When approval mode is on, a person who taps the group's invite link doesn't join right away. They become a pending request, and the group's admins can approve or reject it. Maytapi exposes every step of that flow: | Task | Endpoint | Body or parameter | |---|---|---| | Turn approval mode on or off | `POST /group/config` | `conversation_id`, `config.approveNewMembers` | | List pending requests | `GET /group/approvalRequestList/{conversation_id}` | | | Approve requests | `POST /group/approvalRequest/Approve` | `conversation_id`, `numbers` array | | Reject requests | `POST /group/approvalRequest/Reject` | `conversation_id`, `numbers` array | | Get the invite link | `GET /getGroups/{conversation_id}?invite=true` | | | Reset the invite link | `POST /group/revokeInvite` | `conversation_id` | | Inspect a group by invite code | `GET /group/getGroupInfoByInviteCode/{inviteCode}` | | Flow of a WhatsApp group join request: approval mode on, person opens invite link, request appears in approvalRequestList, your app approves or rejects it All of them live under `https://api.maytapi.com/api/PRODUCT_ID/PHONE_ID/` and use your token in the `x-maytapi-key` header. Your connected number must be an admin of the group. Only admins see join requests, and approving or rejecting fails for a non-admin. ### Turn on approval mode Approval mode is one key in the group's config. Send only the keys you want to change; everything else stays as it is: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/group/config" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "conversation_id": "120363012345678901@g.us", "config": { "approveNewMembers": true } }' ``` Send `false` to switch it off again, and from then on anyone with the link joins directly. The same `config` object also takes `edit`, `send`, `disappear`, `membersCanAddMembers`, `name` and `description`, so you can lock down a new group in one call. The [group API guide](/blog/create-whatsapp-group-api) covers those. To check the current state, read the group with `GET /getGroups/{conversation_id}`. Its `config.approveNewMembers` field is `true` when requests need approval. If someone changes the setting from a phone, your webhook receives an `info` message with the subtype `group/membership_approval_mode`: ```json { "type": "message", "conversation": "120363012345678901@g.us", "conversation_name": "Pro Members", "message": { "type": "info", "subtype": "group/membership_approval_mode", "id": "false_120363012345678901@g.us_3EB0C4A1F2D9E8B7A6C5", "_serialized": "false_120363012345678901@g.us_3EB0C4A1F2D9E8B7A6C5" }, "user": { "id": "905301234567@c.us", "name": "Ayse", "phone": "905301234567" }, "timestamp": 1761552000 } ``` `user` is the admin who made the change. The event says that the setting changed, not what it changed to, so read `getGroups/{conversation_id}` again if your automation depends on the new value. It's a good trigger for an alert: if approval mode gets switched off by hand, your rules stop applying to new members. ### List pending join requests ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/group/approvalRequestList/120363012345678901@g.us" \ -H "x-maytapi-key: $TOKEN" ``` The response holds one entry per pending request: ```json { "success": true, "data": [ { "timestamp": 1761551820, "userInfo": { "id": "905301234567@c.us", "name": "905301234567", "phone": "905301234567" }, "requsetMethod": "NonAdminAdd" } ] } ``` | Field | Meaning | |---|---| | `timestamp` | When the request was made, in Unix seconds | | `userInfo.id` | The person's WhatsApp id (`@c.us`) | | `userInfo.name` | Contact name if known, otherwise the number | | `userInfo.phone` | Number with country code, no `+`. Use this for approve and reject | | `requsetMethod` | How the request was made, as reported by WhatsApp | Two details to plan for. The key really is spelled `requsetMethod`, so read it with that spelling. And use `userInfo.phone` as your key, since `name` falls back to the number when your phone doesn't know the contact. Treat `userInfo` as optional in your code too, and skip any entry that has no number rather than crashing the whole run. ### Approve or reject requests Both actions take the group id and an array of plain numbers, country code first: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/group/approvalRequest/Approve" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "conversation_id": "120363012345678901@g.us", "numbers": ["905301234567", "905301234568"] }' ``` `group/approvalRequest/Reject` takes exactly the same body. The action in the path is case sensitive: `Approve` and `Reject` work, and anything else returns `Unknown action`. A successful call returns the numbers that were handled: ```json { "success": true, "data": [ { "id": { "server": "@c.us", "user": "905301234567", "_serialized": "905301234567@c.us" } } ] } ``` Don't assume every number you sent was processed. Check each one against `data`. A number counts as done only if it appears there without an `error` field. A number that had no pending request (it was already approved from the phone, or the person withdrew it) can be missing from `data`, or come back with an `error`, or fail the call with `success: false`. The safest check is to list the requests again on the next run. Anything still pending simply gets processed again. ### Automate it: approve paid members, reject the rest WhatsApp doesn't send your webhook an event when a new join request arrives. The settings change above is delivered, but a person waiting at the door is not. So the automation works by polling: every minute or two, list the pending requests, check each number against your own data, and approve or reject in batches. Checklist for a production join-request job: poll on a schedule, key on userInfo.phone, skip run if CRM is down, verify each number in the response, log decisions, reset leaked links Here is a Node.js version. `isAllowed` is where your rule goes: a CRM lookup, a paid-subscriptions table, a ticket list. ```js const BASE = `https://api.maytapi.com/api/${PRODUCT_ID}/${PHONE_ID}`; const headers = { "x-maytapi-key": TOKEN, "Content-Type": "application/json" }; const GROUP_ID = "120363012345678901@g.us"; async function isAllowed(phone) { // Replace with your CRM or paid-member check return paidMembers.has(phone); } async function decide(action, numbers) { if (!numbers.length) return []; const r = await fetch(`${BASE}/group/approvalRequest/${action}`, { method: "POST", headers, body: JSON.stringify({ conversation_id: GROUP_ID, numbers }) }); const res = await r.json(); if (!res.success) { console.warn(action, "failed:", res.message); return []; } return (res.data || []).filter((d) => !d.error).map((d) => d.id.user); } async function processRequests() { const r = await fetch(`${BASE}/group/approvalRequestList/${GROUP_ID}`, { headers }); const res = await r.json(); if (!res.success) return console.warn("list failed:", res.message); const approve = []; const reject = []; for (const req of res.data || []) { const phone = req.userInfo && req.userInfo.phone; if (!phone) continue; (await isAllowed(phone)) ? approve.push(phone) : reject.push(phone); } const approved = await decide("Approve", approve); const rejected = await decide("Reject", reject); console.log({ approved, rejected }); } let running = false; setInterval(async () => { if (running) return; // skip if the previous run is still going running = true; try { await processRequests(); } catch (err) { console.error("run skipped:", err.message); // e.g. CRM down: requests stay pending } finally { running = false; } }, 60 * 1000); ``` A few choices make this hold up in production: - **Reject on purpose, not by accident.** If your CRM is down, `isAllowed` should throw and skip the run, not return `false` and reject everyone. Keep a request pending until you know the answer. - **Hold instead of rejecting for unclear cases.** Leaving a request pending is a valid outcome. A person who paid five minutes ago may not be in your table yet; leave them for the next run instead of rejecting them. - **Tell people what happened.** After a rejection you can send a short message with `sendMessage` (`to_number`, `type: "text"`, `message`) explaining how to sign up, so they aren't left guessing. Keep it to people who actually requested, and don't message the same number on every run. - **Log decisions.** Store the phone, the decision and the time. When someone asks why they weren't let in, you have the answer. - **Keep the interval sensible.** One list call per group per minute is plenty. If you manage many groups, process them one after another rather than all at once. The same loop works for several groups: keep a list of group ids and a rule per group, for example one group per course with its own enrolment list. If you'd rather not run a server, the same steps fit an [n8n workflow](/blog/whatsapp-n8n-automation) with a schedule trigger and HTTP request nodes. ### Keep the invite link under control Approval mode protects the group, but the link is still how requests reach you. Get the current link with `GET /getGroups/{conversation_id}?invite=true`, which returns it in the `invite` field. If the link ends up somewhere you didn't intend and the request list fills with spam, reset it: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/group/revokeInvite" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "conversation_id": "120363012345678901@g.us" }' ``` The old link stops working and the response returns the new one as `data.inviteCode` and `data.inviteLink`. Put the new link wherever members are supposed to find it (your members' area, a welcome email). Requests that are already pending are separate from the link, so reject the spam ones with `group/approvalRequest/Reject` as usual. Resets also show up on your webhook as `group/revoke_invite`, including ones made from a phone. Going the other direction, `GET /group/getGroupInfoByInviteCode/{inviteCode}` tells you which group a code belongs to, its participants and its `config`, including `approveNewMembers`. Use it to check a link you received before you post it or join through it. Note that `group/config` and `group/revokeInvite` change the group, so on numbers in the stricter [Safe Mode](/blog/whatsapp-ban-prevention-safe-mode) tiers they can be blocked while reading still works. Check the response's `success` and `message` rather than assuming the change went through. ### Next steps Approval mode plus a small polling job gives you a gated WhatsApp group without anyone tapping through requests by hand. Turn on `approveNewMembers`, list the requests on a schedule, decide with your own data, and keep the invite link fresh. - Set up groups, members and settings with the [group API guide](/blog/create-whatsapp-group-api). - Post to the groups you manage with [send a message to a WhatsApp group](/blog/send-message-to-whatsapp-group-api). - Run larger member structures with the [WhatsApp Community API guide](/blog/whatsapp-community-api-guide). - See every group event your server can receive in the [webhook events guide](/blog/whatsapp-webhook-events-guide). - Match members whose ids show up as LIDs with [LID to phone number](/blog/whatsapp-lid-to-phone-number). - Check every parameter in the [API documentation](/whatsapp-api-documentation). ### WhatsApp API for Business: 8 Use Cases That Actually Pay Off URL: https://maytapi.com/blog/whatsapp-api-business-use-cases Published: 2024-09-06, updated: 2026-10-27 Category: Business > Order updates, support, reminders, catalogs, channels and AI agents: 8 WhatsApp API automations businesses run on their own number, with the endpoints for each. Most customers already have WhatsApp open all day, so a message there gets read when an email might sit unopened. The hard part is picking which jobs to automate and wiring them up properly. Below are eight ways businesses use a WhatsApp API for business on their own number. For each one you get what it does, the Maytapi endpoints or features behind it, and one practical tip for running it well. Everything here runs on Maytapi, an unofficial WhatsApp API: you link your existing number as a device with a QR code, then send and receive through REST calls and webhooks. You need no template approval and pay no per-message fees, but you also have to keep the number healthy yourself. We come back to that at the end. ### 1. Order and shipping notifications **What it is.** When an order is confirmed, packed, shipped or delivered, your store or ERP sends the customer a WhatsApp message. You can attach the invoice PDF or a tracking link. **What powers it.** `POST /sendMessage` with `type: "text"` for status updates and `type: "media"` for the invoice, with a URL in `message` and an optional `filename`. Delivery and read receipts (acks) come back to your webhook, so you can tell which customers actually saw the update. ```json { "to_number": "905301234567", "type": "media", "message": "https://example.com/invoice-10482.pdf", "text": "Your order #10482 has shipped. Invoice attached.", "filename": "invoice-10482.pdf" } ``` **Tip.** Only message people who gave their number at checkout and expect updates. Hold the tracking link for the second message ("Your package is on its way") instead of putting it in the very first message to a new contact. Links in a first message are a strong spam signal. ### 2. Appointment reminders **What it is.** Clinics, salons, repair services and consultants send a reminder the day before and let customers confirm or reschedule in the chat. Workflow of an automated appointment reminder: a scheduled job sends a list message, the customer taps a row and the booking system is updated. **What powers it.** A scheduled job calls `sendMessage`. Interactive messages make the answer one tap: a list message (`type: "list"`) with "Confirm" and "Reschedule" rows returns the chosen row's id to your webhook, and reply buttons (`type: "buttons"`) work too, as long as your handler also accepts a typed "confirm". For bookings that are fixed, the `createEvent` message type sends a native WhatsApp event with a start time, end time and location. **Tip.** Treat the reply as data. Write "confirmed" or "reschedule" back to your booking system as soon as the webhook arrives, so staff don't have to read chats to know who is coming. The [appointment reminders guide](/blog/whatsapp-appointment-reminders) builds this flow step by step. ### 3. Customer support with a shared WhatsApp inbox **What it is.** One well-known support number, answered by several people at once without passing a phone around. **What powers it.** The [multi-agent platform](/multi-agent-platform-for-whatsapp) uses the same connection as the API. Every incoming message lands in a shared inbox. Agents sign in from their browser, take a conversation, reply and close it, and you see waiting, taken and closed conversations per agent. One agent is included free. Bots and webhooks keep running on the same number, so automation can answer the easy questions and agents can handle the rest. **Tip.** Use WhatsApp Business labels (`createLabel`, `addLabel`) to tag chats by topic or status, such as "refund" or "VIP". Label changes also reach your webhook, so your CRM can stay in sync with what agents do. ### 4. Catalog selling and order webhooks **What it is.** Customers browse your products inside WhatsApp and send an order without leaving the chat. **What powers it.** The product catalog API (`catalog/addProduct`, `catalog/editProduct`, collections) keeps the WhatsApp catalog in sync with your web shop. `sendMessage` with `type: "product"` or `type: "catalog"` shares one product or the whole catalog in a chat. When a customer places an order, it arrives on your webhook as an `order` message, and `catalog/getOrders/{orderId}` returns the products, quantities and prices. **Tip.** Save every order to your own database the moment the webhook arrives. `getOrders` only keeps orders received while the session is running, so treat it as a convenience, not your record. The catalog is a WhatsApp Business feature, so the connected number must be a WhatsApp Business account. ### 5. Channels for broadcasting updates **What it is.** A WhatsApp Channel is a one-to-many feed that people choose to follow. It works well for product launches, price changes, opening hours and news, the kind of messages you would otherwise blast to a contact list. **What powers it.** The [channel API](/features/whatsapp-channel-api) creates and configures channels. `sendMessage` posts to the channel id (for example `120363098765432109@newsletter`) with types such as `channel_text`, `channel_media`, `channel_link` and `channel_poll`. `channel/statistics/{conversation_id}` reports follower and reach numbers. **Tip.** Channels are the safest way to reach many people from one number. Followers opted in, and you are not opening hundreds of new one-to-one chats. Move broadcast-style content here and keep direct messages for transactional and personal conversations. If you still need to message a list one by one, read [how to send a WhatsApp broadcast via API](/blog/whatsapp-broadcast-api) first. ### 6. Communities for members, customers and partners **What it is.** A WhatsApp Community groups related groups under one announcement group. Schools, franchises, clubs and B2B partner networks use it to send one announcement to everyone while topic groups stay separate. **What powers it.** The community API covers `createCommunity`, `community/addSubGroup`, member management and admin promotion. Announcements go to the announcement group through `sendMessage`. Member comments under an announcement can be read and answered (`type: "comment"`). **Tip.** Give each sub-group a clear purpose, such as one per branch or one per product line, and automate the joining step. When someone signs up, add them with `community/add` or send them a group invite, so nobody ends up in the wrong group. ### 7. AI agents and chatbots **What it is.** An AI assistant or rule-based bot answers common questions at any hour, collects details before a human takes over, or lets your team run WhatsApp from tools they already use. **What powers it.** Several routes, depending on how much code you want to write: | Route | Best for | How it connects | |---|---|---| | Your own bot | Full control over logic | Webhook in, `sendMessage` out | | [n8n integration](/tools/maytapi-n8n-integration) | No-code workflows with an LLM step | Maytapi Trigger node for incoming messages, Maytapi node for replies ([example with Claude](/blog/whatsapp-ai-agent-n8n-claude)) | | [MCP server](/tools/maytapi-mcp-server) | Claude, Cursor and other MCP clients | 140 tools for messages, groups, channels and more | **Tip.** Always give the bot a way out. When it can't answer, or the customer asks for a person, label the chat and hand it to the shared inbox instead of looping. In your webhook, ignore messages where `message.fromMe` is true so the bot never replies to itself. ### 8. Lead qualification and number checks **What it is.** Before sales calls or messages a new lead, confirm that the number is on WhatsApp, start the conversation, and route qualified leads to the right person. **What powers it.** `POST /checkPhones` takes an array of numbers and returns `valid: true` or `false` for each one, plus the WhatsApp id to use as `to_number`. After that, labels mark the lead's stage, and your webhook pushes replies into the CRM. **Tip.** Check numbers when you collect them, at form submission or CRM import, not at send time. A list full of numbers that aren't on WhatsApp leads to failed sends, and first contacts with no replies hurt your number. Our guide to [bulk WhatsApp number checks](/blog/bulk-whatsapp-number-check) shows the batching pattern. ### Keep the number healthy Every use case above runs on your own WhatsApp number, linked the same way WhatsApp Web is. That gives you a lot of flexibility, but WhatsApp is not endorsed by or partnered with any unofficial API. You must follow WhatsApp's terms, and numbers that send unwanted messages get restricted or banned. In practice: - Message people who expect to hear from you, and make opting out easy. - Grow volume gradually on new numbers. Safe Mode in the console ramps a number up over 14 days. - Vary your text, keep links out of first messages, and favor two-way conversations over one-way blasts. Checklist for keeping a WhatsApp number healthy: opt-in contacts, gradual volume with Safe Mode, varied text, no links in first messages and two-way conversations. Our post on [avoiding WhatsApp bans with Safe Mode and warm-up](/blog/whatsapp-ban-prevention-safe-mode) covers this in detail. ### Next steps Pick one use case, usually order notifications or support, and get it working end to end before adding the next. The [getting started guide](/blog/whatsapp-api-getting-started) takes you from QR code to first message and webhook. You can [start a 3-day free trial](https://console.maytapi.com/register) with no card. [Pricing](/whatsapp-api-pricing) is a flat fee per number with unlimited messages, and every endpoint mentioned here is in the API reference. ### How to Create and Manage WhatsApp Groups with an API URL: https://maytapi.com/blog/create-whatsapp-group-api Published: 2024-09-06, updated: 2026-10-27 Category: Tutorials > Create WhatsApp groups from code with the Maytapi API: add and promote members, handle join requests, change settings and invite links, and track group events. Running a few WhatsApp groups by hand is fine. Running one per customer, class, event or project isn't, once the groups need consistent settings and up-to-date member lists. This guide shows how to **create WhatsApp groups with an API** and manage them from code: create a group, add and promote members, handle join requests, change settings and invite links, and follow group events on your webhook. Every request below uses the Maytapi API, and the request bodies match the [API documentation](/whatsapp-api-documentation). Maytapi connects your own WhatsApp number as a linked device. Groups you create belong to that number, and it becomes their admin. That means the groups behave exactly like groups you create in the WhatsApp app, including WhatsApp's own privacy rules for who can be added. ### Group IDs and the base request All examples use the same base URL and header. In the shell examples, set these first: ```bash export PRODUCT_ID="your-product-id" export PHONE_ID="your-phone-id" export TOKEN="your-token" ``` Your product ID and token are on the console's [Product ID & Token page](https://console.maytapi.com/settings/token). The phone ID identifies the WhatsApp number that will own the groups. A group is identified by its ID, which ends in `@g.us`, for example `120363012345678901@g.us`. Most group endpoints take it as `conversation_id`. To see the groups your number is already in, call `getGroups`: ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/getGroups?sort=true&page=1" \ -H "x-maytapi-key: $TOKEN" ``` Each group in `data` comes with its `id`, `name`, `description`, `participants`, `admins` and `config`. Groups that belong to a community also have a `communityId`. The response is paginated (`count`, `total`, `limit`, `page`), and the docs recommend combining `page` with `sort=true`. Use `GET /getGroups/{conversation_id}` to fetch a single group. ### Create a WhatsApp group with the API `createGroup` takes a name and the numbers to add. You can also set the group photo and the settings in the same call: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/createGroup" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Order #10482 - Delivery", "numbers": ["905301234567", "905301234568"], "sendInvite": true, "message": "Hi! This group is for your delivery updates.", "image": "https://example.com/images/group-logo.png", "config": { "edit": "admins", "send": "all", "disappear": false, "membersCanAddMembers": false, "approveNewMembers": true } }' ``` Maytapi first tries to add each number directly. Some people's privacy settings don't allow anyone to add them to groups. For those numbers, with `sendInvite: true`, they get a private invitation with your `message`, which they can accept themselves. Your number becomes an admin automatically. The response returns the new group, including its `id`. Store that ID next to the record it belongs to (the order, the class, the customer), because every later call uses it. ### Add, remove, promote and demote members `group/add` accepts a list of numbers and the same invitation fallback as `createGroup`: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/group/add" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "conversation_id": "120363012345678901@g.us", "numbers": ["905301234569", "905301234570"], "sendInvite": true, "message": "You are invited to the Spring Workshop group." }' ``` Checklist of the group member endpoints: add, remove, promote and demote, with the invitation fallback and admin requirement. Removing, promoting and demoting work on one participant at a time. The participant goes in `number`, in chat ID form: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/group/promote" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"conversation_id": "120363012345678901@g.us", "number": "905301234569@c.us"}' ``` Change the path to `group/demote` to take admin rights away, or to `group/remove` to remove the person from the group. All three return `success`. Your number has to be an admin of the group for these calls to work, just as in the app. > **Note:** If the number is warming up in [Safe Mode](/blog/whatsapp-ban-prevention-safe-mode), calls that change a group (`createGroup`, `group/add`, `group/remove`, `group/promote`, `group/demote`, `group/config`, `group/revokeInvite`) fail with error F15 in Tiers 1 and 2. In Tier 3 only calls with more than five members at once are blocked. Reading groups and sending messages to them keeps working. WhatsApp group participant list with admins, next to group/add and group/promote API requests *Members added with group/add and an admin promoted with group/promote show up in the group like any other change.* ### Change group settings, name and photo `group/config` updates any combination of settings. Send only the keys you want to change: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/group/config" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "conversation_id": "120363012345678901@g.us", "config": { "name": "Spring Workshop 2026 - Announcements", "description": "Schedules and materials. Questions go to the main group.", "send": "admins" } }' ``` | Key | Values | What it controls | |---|---|---| | `edit` | `admins` / `all` | Who can edit the group info | | `send` | `admins` / `all` | Who can send messages (`admins` makes it an announcement-style group) | | `disappear` | `true` / `false` | Disappearing messages | | `membersCanAddMembers` | `true` / `false` | Whether non-admins can add people | | `approveNewMembers` | `true` / `false` | Whether joins need admin approval | | `name`, `description` | text | Group subject and description | To change the photo, call `group/setProfileImage` with `conversation_id` and an `image` URL. The docs recommend a square image, ideally 512x512. `group/deleteProfileImage` removes it and brings back the default avatar. ### Handle join requests and invite links With `approveNewMembers` on, people who use the invite link wait for approval instead of joining right away. List the pending requests: ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/group/approvalRequestList/120363012345678901@g.us" \ -H "x-maytapi-key: $TOKEN" ``` Each entry has a `timestamp` and `userInfo` with the person's `id`, `name` and `phone`. Approve or reject in bulk with plain numbers: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/group/approvalRequest/Approve" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"conversation_id": "120363012345678901@g.us", "numbers": ["905301234571"]}' ``` `group/approvalRequest/Reject` takes the same body. This is the usual place for your own rules. For example, approve only numbers that exist in your CRM or that bought a ticket, and reject the rest. Our [WhatsApp group join requests guide](/blog/whatsapp-group-join-requests-api) shows that approval flow end to end. **Invite links.** Add `invite=true` to `getGroups` or `getGroups/{conversation_id}` to include the group's invite link. If a link leaks, `group/revokeInvite` with the `conversation_id` invalidates it and returns a new `inviteCode` and `inviteLink`. To invite one person privately, send a `groupInvite` message through `sendMessage` with `groupId` set to the group. **Invites your number receives.** When someone sends your number a group link or invitation, the webhook delivers it as a `group_invite` or `group_invite_v4` message with the invite code. `GET /group/getGroupInfoByInviteCode/{inviteCode}` shows which group it is before you decide anything. `group/acceptInvite` (or `group/acceptInviteV4`, using the fields from the webhook) joins it. ### Leave groups you no longer need When a project or order is finished, archive what you need and leave the group: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/leaveGroup" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"conversation_id": "120363012345678901@g.us"}' ``` A number that stays in hundreds of old groups keeps receiving their messages and webhook traffic. Cleaning up regularly keeps both your phone and your webhook quieter. ### Example: one group per order, from start to finish Here is how the endpoints work together in a common setup. Each high-value order gets its own group with the customer, the sales rep and the installer. When the order is closed, the group is closed too. Workflow of a per-order WhatsApp group: create it, promote the sales rep, post a closing message, make it read-only and leave. ```js const BASE = `https://api.maytapi.com/api/${PRODUCT_ID}/${PHONE_ID}`; const call = (path, body) => fetch(`${BASE}/${path}`, { method: "POST", headers: { "x-maytapi-key": TOKEN, "Content-Type": "application/json" }, body: JSON.stringify(body) }).then((r) => r.json()); const { data } = await call("createGroup", { name: `Order ${order.id}`, numbers: [order.customerPhone, order.repPhone, order.installerPhone], sendInvite: true, config: { edit: "admins", membersCanAddMembers: false } }); await call("group/promote", { conversation_id: data.id, number: `${order.repPhone}@c.us` }); await db.saveGroupId(order.id, data.id); ``` The sales rep becomes a second admin, so a person can still manage the group if your integration is down. Members can't add other people, and only admins can change the name or description, so the group stays tied to the order. When the order is delivered, post a closing message, set `send` to `admins` with `group/config` so the chat becomes read-only, and call `leaveGroup` after a grace period. The history stays on the members' phones, and your number stops collecting old groups. Every step is an ordinary API call, so it fits into whatever job runner or workflow tool you already use. ### Track group events on your webhook Group changes come to your webhook as messages with `type: "info"` inside `message`, plus a `subtype` and usually the `participant` involved. The `conversation` field is the group ID. | Subtype | When it fires | |---|---| | `group/add`, `group/invite` | Someone was added, or joined through a link | | `group/remove`, `group/leave` | Someone was removed, or left | | `group/promote`, `group/demote` | Admin rights changed | | `group/name`, `group/description` | Subject or description changed | | `group/announce`, `group/restrict` | Who can send messages or edit info changed | | `group/member_add_mode`, `group/membership_approval_mode` | Add permissions or approval mode changed | | `group/revoke_invite` | The invite link was reset | A small Express handler is enough to keep a member list in sync: ```js app.post("/maytapi/webhook", express.json(), (req, res) => { res.sendStatus(200); const { type, message, conversation } = req.body; if (type !== "message" || message.type !== "info") return; if (message.subtype === "group/add" || message.subtype === "group/invite") { db.addMember(conversation, message.participant); } else if (message.subtype === "group/remove" || message.subtype === "group/leave") { db.removeMember(conversation, message.participant); } }); ``` > **Note:** Participants who hide their phone number can appear as `@lid` IDs instead of `@c.us`. `getGroups?refresh=true` re-fetches group info and resolves them where possible. The [LID to phone number guide](/blog/whatsapp-lid-to-phone-number) explains the rest. Adding many strangers to groups is one of the patterns WhatsApp watches for spam. Add people who expect it, use invitations when direct adds fail, and grow new numbers slowly. The [ban prevention guide](/whatsapp-api-ban-prevention) covers the details. ### Next steps - See all group capabilities on one page in the [WhatsApp group API](/features/whatsapp-group-api) overview. - Post text, media, polls and `@all` mentions to the groups you created with [send messages to WhatsApp groups via API](/blog/send-message-to-whatsapp-group-api). - Groups that belong together can live under one umbrella. See the [community API guide](/blog/whatsapp-community-api-guide). ### Build a WhatsApp Bot with Node.js: Send, Receive and Reply URL: https://maytapi.com/blog/whatsapp-bot-nodejs Published: 2024-09-04, updated: 2026-10-27 Category: Tutorials > Node.js tutorial: send a WhatsApp message with fetch, receive webhooks in Express and build a command bot that replies with text, media, location and buttons. A WhatsApp bot is a small web server. WhatsApp messages come in as webhook calls, your code decides what to say, and the answer goes out through one HTTP request. This tutorial builds a working **WhatsApp bot with Node.js** and the Maytapi API. You'll send your first message with `fetch`, receive messages in Express, route commands, and reply with text, images, a location, reply buttons and a poll. At the end you'll find the checks that matter before you point real customers at it. Maytapi is an unofficial WhatsApp API. You pair your own WhatsApp number as a linked device, the same way WhatsApp Web works, and drive it over REST. There are no message templates and no per-message fees. The trade-off is that the bot runs on a normal WhatsApp account, so WhatsApp's usual spam rules apply to it. We come back to that in the production section. ### What you need before writing the bot - **Node.js 18 or newer.** It has `fetch` built in, so you don't need an HTTP client package. - **A Maytapi account** with a phone connected. The [free trial](https://console.maytapi.com/register) runs for three days and doesn't ask for a card. Scan the QR code in the console with the WhatsApp number the bot should use. - **Three values:** your product ID, your API token and the phone ID. The product ID and token are on the **Product ID & Token** page in the console ([console.maytapi.com/settings/token](https://console.maytapi.com/settings/token)). Every request sends the token in the `x-maytapi-key` header. ![Maytapi console token page next to a first WhatsApp message sent with curl](/images/showcase/whatsapp-api-token-first-message.webp) *The product ID and token come from the console. One request is enough to send the first message.* The phone ID identifies the WhatsApp number inside your product. It's shown on the Phones page, and you can also get it from `listPhones`, which works at product level and has no phone ID in the path: ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/listPhones" \ -H "x-maytapi-key: $TOKEN" ``` Each item in the response has an `id` (the phone ID), the `number` and a `status`. Your phone should show `active` before you continue. Set up the project. Express is the only dependency: ```bash mkdir whatsapp-bot && cd whatsapp-bot npm init -y npm pkg set type=module npm install express ``` Keep credentials out of the code and pass them as environment variables: ```bash export PRODUCT_ID="your-product-id" export PHONE_ID="your-phone-id" export TOKEN="your-token" ``` ### Send your first WhatsApp message from Node.js Before you write any webhook code, check that sending works. Create `send.js`: ```js const { PRODUCT_ID, PHONE_ID, TOKEN } = process.env; const res = await fetch( `https://api.maytapi.com/api/${PRODUCT_ID}/${PHONE_ID}/sendMessage`, { method: "POST", headers: { "x-maytapi-key": TOKEN, "Content-Type": "application/json" }, body: JSON.stringify({ to_number: "905301234567", type: "text", message: "Hello from my Node.js bot!" }) } ); console.log(await res.json()); ``` Run `node send.js` with your own number in `to_number`: country code first, no `+`, no spaces. A successful call returns `success: true` with `data.chatId` and `data.msgId`. `sendMessage` adds the message to your phone's sending queue. If nothing arrives, check the phone's status and the message logs in the console. For more languages and a few one-off examples, see the short reference [Send a WhatsApp message with Node.js](/guides/send-whatsapp-message-nodejs). The rest of this post goes past a single request. ### Point Maytapi's webhook at your server Incoming messages, delivery receipts (acks) and phone status changes are sent to one webhook URL. You set it at product level with `setWebhook`: ```js await fetch(`https://api.maytapi.com/api/${PRODUCT_ID}/setWebhook`, { method: "POST", headers: { "x-maytapi-key": TOKEN, "Content-Type": "application/json" }, body: JSON.stringify({ webhook: "https://example.com/maytapi/webhook" }) }); ``` Workflow of the Node.js bot: a customer message is posted to the webhook, the handler returns 200 and skips fromMe, the router picks a reply and sends it. You can also paste the URL on the console's webhook page. If one product runs several numbers and each should go to a different server, `POST /config` sets a webhook for a single phone. The [webhook API page](/features/whatsapp-webhook-api) shows every payload type. An incoming text message looks like this. The fields the bot uses are `type`, `message`, `conversation` and `reply`: ```json { "type": "message", "product_id": "your-product-id", "phone_id": "12345", "user": { "id": "905301234567@c.us", "name": "Customer", "phone": "905301234567" }, "message": { "id": "3EB081AF26FADE6416D8", "type": "text", "text": "help", "fromMe": false }, "conversation": "905301234567@c.us", "receiver": "905307654321", "timestamp": 1574081887, "reply": "https://api.maytapi.com/api/your-product-id/12345/sendMessage" } ``` `conversation` is the chat to answer in. `reply` is the `sendMessage` URL for the phone that received the message, so the handler doesn't have to build it. **Check the reply URL before you send your token.** Anyone who learns your webhook address can post a fake event to it, so never send `x-maytapi-key` to a URL taken from the request without checking it. The send helper only accepts a `reply` that starts with `https://api.maytapi.com/api/` followed by your own product ID. For extra protection, register the webhook with a hard-to-guess path or query parameter (for example `/maytapi/webhook?key=…`) and reject requests that don't carry it. If your firewall supports allowlists, the static IP option described in the [webhook events guide](/blog/whatsapp-webhook-events-guide) lets you accept deliveries from one address only. ### Build the Express webhook handler Create `bot.js`. The handler returns `200` right away and does the work afterwards. It also skips your own outgoing messages (`fromMe`). Without that check the bot would answer itself forever. ```js import express from "express"; const { TOKEN, PRODUCT_ID } = process.env; const app = express(); const REPLY_PREFIX = `https://api.maytapi.com/api/${PRODUCT_ID}/`; async function send(replyUrl, body) { // webhooks are unauthenticated: only send the token to your own Maytapi product if (!replyUrl?.startsWith(REPLY_PREFIX)) return console.error("unexpected reply URL", replyUrl); const res = await fetch(replyUrl, { method: "POST", headers: { "x-maytapi-key": TOKEN, "Content-Type": "application/json" }, body: JSON.stringify(body) }); const json = await res.json(); if (!json.success) console.error("sendMessage failed", json); } app.post("/maytapi/webhook", express.json(), async (req, res) => { res.sendStatus(200); // acknowledge first const event = req.body; if (event.type !== "message" || event.message.fromMe) return; if (event.conversation.endsWith("@g.us")) return; // 1:1 chats only try { await handleMessage(event); } catch (err) { console.error(err); } }); app.listen(3000, () => console.log("Bot listening on :3000")); ``` The group check is there on purpose. Group chat IDs end in `@g.us`, and a command bot that answers every message in a busy group gets annoying quickly. Remove that line once you've decided how the bot should behave in groups. ### Route commands to text, media, location, buttons and polls Commands live in a plain object, so adding one means adding one entry. Each entry returns the body for `sendMessage` without `to_number`, which the router fills in. ```js const commands = { help: () => ({ type: "text", message: "Commands: *menu*, *photo*, *location*, *poll*" }), photo: () => ({ type: "media", message: "https://example.com/images/store-front.jpg", text: "Our store this morning" }), location: () => ({ type: "location", text: "Fatih Sultan Mehmet Bridge", latitude: "41.093292", longitude: "29.061737" }), menu: () => ({ type: "buttons", message: "What would you like to do?", footer: "Acme Store", buttons: [ { id: "order_status", text: "Order status" }, { id: "talk_to_agent", text: "Talk to a person" } ] }), poll: () => ({ type: "poll", message: "Which time suits you for delivery?", options: ["Morning", "Afternoon", "Evening"], only_one: true }) }; ``` `media` takes a public URL or a base64 data URI in `message`, plus an optional caption in `text`. Images, videos, audio and documents all use this type, and you can add `filename` to control the name a document gets. `location` takes the coordinates as strings. A `poll` with `only_one: true` lets each person pick a single option. Votes come back to your webhook as `pollVoteUpdate` events, so you can count them as they happen. The router reads the text, finds a command and sends the answer: ```js const answers = { order_status: "Send your order number and I'll look it up.", talk_to_agent: "Thanks, a team member will reply here shortly." }; async function handleMessage(event) { const msg = event.message; if (msg.type !== "text") return; const key = (msg.payload || msg.text || "").trim().toLowerCase(); const body = answers[key] ? { type: "text", message: answers[key] } : (commands[key] || commands.help)(); await send(event.reply, { to_number: event.conversation, ...body }); } ``` A choice from a list message reaches the webhook as a `text` message with the row title in `text` and its `rowId` in `payload`, and reply-button taps usually arrive the same way with the button `id` in `payload`. The router reads `payload` first and falls back to `text`, so taps and typed commands go through the same code. Button replies are not delivered identically on every connection, so test them on your own number, and if a flow must know the exact choice, send a list instead of buttons. For lists, carousels and link, call or copy-code buttons, see the [interactive messages API](/features/whatsapp-interactive-messages-api). ### Test the bot locally with a tunnel Maytapi has to reach your webhook over the public internet, and `localhost:3000` isn't reachable from outside. While you develop, run a tunnel in a second terminal. Use it as a command-line tool, not as a library inside the bot: ```bash node bot.js # in another terminal, either one: cloudflared tunnel --url http://localhost:3000 ngrok http 3000 ``` Copy the HTTPS URL the tunnel prints, add `/maytapi/webhook`, and register it with `setWebhook` (or paste it in the console). Then send `help`, `menu`, `photo` or `poll` to the bot's number from a different phone. Messages you type on the bot's own phone arrive with `fromMe: true`, and the handler ignores them. Free tunnel URLs usually change on every restart. When the bot stops replying, check the webhook URL first. When you deploy, register the server's stable HTTPS URL and stop using the tunnel. ### Production tips for a Node.js WhatsApp bot **Respond 200 fast.** The handler above sends the status before it calls any API or database. Anything slow, like an AI model or an order lookup, should run after the response or on a job queue, so webhook requests never pile up. Production checklist for a Node.js WhatsApp bot: fast 200 responses, idempotency, ack and status events, pacing, secrets and the duplicate filter. **Make handling idempotent.** Use `message.id` as the key and skip IDs you've already processed. Then a redeploy or a duplicate delivery never sends a customer the same reply twice. An in-memory `Set` is fine for a demo. With more than one process, use Redis or a unique database column. ```js const seen = new Set(); function firstTime(id) { if (seen.has(id)) return false; seen.add(id); return true; } // in the handler: if (!firstTime(event.message.id)) return; ``` **Handle the other event types.** `ack` events tell you whether a message was delivered, reached the phone or was seen. `status` events tell you when the phone disconnects or falls back to the QR screen, which is the moment to alert someone. Ack events are off by default; turn them on with `setAckPreference` (`ack_delivery: true`) when you need receipts. **Pace your messages.** The number is a real WhatsApp account, and WhatsApp can restrict accounts that look like spam. A bot that only answers people who write first carries little risk. Messaging many new contacts in a burst carries much more. New numbers should warm up slowly. Read [how Safe Mode prevents WhatsApp bans](/blog/whatsapp-ban-prevention-safe-mode) before you send anything proactive. > **Note:** `sendMessage` has a duplicate filter that can reject a repeated, identical message. If your bot really needs to repeat a message, add `"skip_filter": true` to that request rather than turning it on everywhere. **Keep secrets in the environment.** Load the token from environment variables or a secret manager, never from source control. ### Next steps You now have a Node.js bot that receives messages, routes commands and replies with five message types. From here you can: - New to Maytapi? The [getting started guide](/blog/whatsapp-api-getting-started) covers the account, QR pairing and webhook setup. - Read every webhook payload, including acks, status and group events, in the [webhook events guide](/blog/whatsapp-webhook-events-guide). - Build richer menus with lists and carousels from [buttons, lists and carousels](/blog/whatsapp-interactive-messages-buttons-list-carousel). - Look up every endpoint and body in the [API documentation](/whatsapp-api-documentation). ### Build a WhatsApp Bot in Python with Flask URL: https://maytapi.com/blog/whatsapp-bot-python Published: 2024-09-04, updated: 2026-10-27 Category: Tutorials > Step-by-step Python tutorial: receive WhatsApp messages on a Flask webhook, reply with text, media and polls, and run it safely in production. Python is a good fit for a WhatsApp bot. Flask handles the webhook in a few lines, and `requests` sends the replies. This tutorial builds a **WhatsApp bot in Python** on the Maytapi API. It receives messages on a Flask webhook, shows a list menu, and replies with text, documents and polls. The last part covers running it in production: gunicorn, environment variables and handling messages in the background. Maytapi is an unofficial WhatsApp API. You link your own WhatsApp number as a device by scanning a QR code, then drive it over REST and webhooks. There are no templates and no per-message fees. The other side of that is that the bot speaks from a normal WhatsApp account, so you need to respect WhatsApp's anti-spam rules. The production section links to the guidance. ### Prerequisites and credentials - **Python 3.9 or newer.** - **A Maytapi account** with a connected phone. The [trial](https://console.maytapi.com/register) is free for three days with no card. Scan the QR code in the console with the number the bot should use. - **Your product ID, token and phone ID.** The first two are on the console's [Product ID & Token page](https://console.maytapi.com/settings/token). The phone ID is shown on the Phones page, or you can get it from `GET /listPhones`. Install the dependencies in a virtual environment: ```bash python -m venv .venv && source .venv/bin/activate pip install flask requests gunicorn ``` Store credentials in environment variables from the start. The same code then runs on your laptop and on a server without changes: ```bash export PRODUCT_ID="your-product-id" export PHONE_ID="your-phone-id" export TOKEN="your-token" ``` ### Send a test message with requests First confirm that the number can send. This is the smallest possible `sendMessage` call: ```python import os import requests PRODUCT_ID = os.environ["PRODUCT_ID"] PHONE_ID = os.environ["PHONE_ID"] TOKEN = os.environ["TOKEN"] res = requests.post( f"https://api.maytapi.com/api/{PRODUCT_ID}/{PHONE_ID}/sendMessage", headers={"x-maytapi-key": TOKEN}, json={"to_number": "905301234567", "type": "text", "message": "Hello from Python!"}, timeout=10, ) print(res.json()) ``` Put the country code first, with no `+` or spaces. The response includes `success`, plus the `chatId` and `msgId` of the queued message. The [Python quick reference](/guides/send-whatsapp-message-python) has more single-request examples, such as media and webhook setup. This post focuses on the bot around them. ### Receive WhatsApp messages on a Flask webhook Maytapi posts every incoming message to the URL you register with `setWebhook`. Register the URL once, at product level: ```python requests.post( f"https://api.maytapi.com/api/{PRODUCT_ID}/setWebhook", headers={"x-maytapi-key": TOKEN}, json={"webhook": "https://bot.example.com/maytapi/webhook"}, timeout=10, ) ``` Workflow of the Flask webhook: register the URL once, receive message events, skip own and group messages, and answer quickly while work runs in the background. The webhook receives JSON with a top-level `type`. That is `message` for incoming messages, `ack` for delivery receipts and `status` for phone state changes. A message event carries the sender in `user`, the chat ID in `conversation`, the content in `message`, and a ready-made `reply` URL that points to `sendMessage` for the phone that received it. The [webhook API page](/features/whatsapp-webhook-api) lists the full payloads. ![Maytapi console webhook settings with a sample incoming message payload](/images/showcase/whatsapp-webhook-api-console.webp) *You can also set the webhook URL in the console, with an optional webhook per phone.* **Check the reply URL before you send your token.** Anyone who learns your webhook address can post a fake event to it, so never send `x-maytapi-key` to a URL taken from the request without checking it. The `send` helper below only accepts a `reply` that starts with `https://api.maytapi.com/api/` followed by your own product ID. For extra protection, register the webhook with a hard-to-guess path or query parameter (for example `/maytapi/webhook?key=…`) and reject requests that don't carry it. If your firewall supports allowlists, the static IP option described in the [webhook events guide](/blog/whatsapp-webhook-events-guide) lets you accept deliveries from one address only. Here is a minimal `app.py`. It answers quickly and passes the real work to a background thread: ```python import os import threading import requests from flask import Flask, request TOKEN = os.environ["TOKEN"] PRODUCT_ID = os.environ["PRODUCT_ID"] REPLY_PREFIX = f"https://api.maytapi.com/api/{PRODUCT_ID}/" app = Flask(__name__) http = requests.Session() http.headers.update({"x-maytapi-key": TOKEN}) def send(reply_url, body): # webhooks are unauthenticated: only send the token to your own Maytapi product if not (reply_url or "").startswith(REPLY_PREFIX): app.logger.error("unexpected reply URL: %s", reply_url) return res = http.post(reply_url, json=body, timeout=10) data = res.json() if not data.get("success"): app.logger.error("sendMessage failed: %s", data) @app.post("/maytapi/webhook") def webhook(): event = request.get_json(silent=True) or {} msg = event.get("message") or {} if event.get("type") == "message" and not msg.get("fromMe"): threading.Thread(target=handle, args=(event,), daemon=True).start() return "", 200 ``` Two checks keep the bot out of trouble. `fromMe` is true for messages your own number sends, including the bot's replies, so skipping them prevents loops. Group chats have IDs ending in `@g.us`. The handler below ignores them, so the bot only talks in one-to-one chats. ### Build a menu with a WhatsApp list message Typed commands work, but customers don't remember them. A list message shows a button that opens titled sections of rows. Each row has a `rowId` that your code receives when someone picks it. ```python MENU = { "type": "list", "title": "Acme Support", "message": "Hi! What can we help you with?", "footer": "Reply anytime", "button_text": "Open menu", "sections": [ { "title": "Orders", "rows": [ {"rowId": "invoice", "title": "Get my invoice", "description": "PDF of your last order"}, {"rowId": "feedback", "title": "Rate your delivery", "description": "Quick one-question poll"}, ], }, { "title": "Help", "rows": [ {"rowId": "agent", "title": "Talk to a person", "description": "A team member replies here"}, ], }, ], } ``` When the customer picks a row, the webhook receives a `text` message. The row title is in `text` and the `rowId` is in `payload`. Reading `payload` first, then `text`, lets the router treat taps and typed words the same way. If the menu needs to look different, the [buttons, lists and carousels guide](/blog/whatsapp-interactive-messages-buttons-list-carousel) shows the other interactive formats. ### Reply with text, documents and polls Each menu choice maps to a function that returns a `sendMessage` body: ```python def invoice(): return { "type": "media", "message": "https://example.com/invoices/10482.pdf", "text": "Here is the invoice for order #10482", "filename": "invoice-10482.pdf", } def feedback(): return { "type": "poll", "message": "How was your delivery?", "options": ["Great", "OK", "Not good"], "only_one": True, } def agent(): return {"type": "text", "message": "Thanks! A team member will reply in this chat."} ROUTES = {"invoice": invoice, "feedback": feedback, "agent": agent} ``` The `media` type covers images, video, audio and documents. Pass a public URL or a base64 data URI in `message`. `filename` sets the name a document shows in the chat, and `text` is the caption. A `poll` takes the question in `message` and the choices in `options`. With `only_one` set, each person can pick only one option. When people vote, your webhook receives `pollVoteUpdate` events, so you can record the results in your own database. Now connect it all in `handle`: ```python SILENT_TYPES = {"reaction", "pollVoteUpdate", "message_edit", "pinMessage", "info"} def handle(event): if event["conversation"].endswith("@g.us"): return msg = event["message"] if msg.get("type") in SILENT_TYPES: return # reactions, poll votes, edits, pins, calls and group notices need no answer if msg.get("type") != "text": send(event["reply"], {"to_number": event["conversation"], "type": "text", "message": "I can read text for now. Type *menu* to start."}) return key = (msg.get("payload") or msg.get("text") or "").strip().lower() body = ROUTES[key]() if key in ROUTES else MENU send(event["reply"], {"to_number": event["conversation"], **body}) ``` Reactions, poll votes, edits, pins and `info` notices (calls, group changes) also arrive as `message` events with their own `message.type`. The bot ignores them, so a vote on its own poll doesn't trigger the "I can read text" reply. Anything the bot doesn't recognize, including "hi", gets the menu, so a first-time user always has a next step. Media messages from customers also come to the webhook, with `url`, `mime` and `filename`. You can download and process them later. ### Test locally with a tunnel Start Flask's development server and expose it with a tunnel CLI in a second terminal: ```bash flask --app app run --port 5000 # second terminal, pick one: cloudflared tunnel --url http://localhost:5000 ngrok http 5000 ``` Register the HTTPS URL the tunnel prints, with `/maytapi/webhook` added, using `setWebhook`. Then message the bot's number from another phone. Keep the tunnel outside your application code. The bot shouldn't care how it's reached, and in production there is no tunnel at all. ### Deploy the Python WhatsApp bot with gunicorn Flask's built-in server is for development only. In production, run the app with gunicorn behind HTTPS: ```bash gunicorn --workers 2 --threads 4 --bind 0.0.0.0:8000 app:app ``` Comparison of the development setup and the production setup for a Python WhatsApp bot: server, URL, background work and idempotency store. Put a reverse proxy or your platform's load balancer in front for TLS, then call `setWebhook` once with the public URL. A few more things are worth doing before real traffic arrives. **Move work off the request.** The background thread above is fine for a small bot. When you add AI calls, database lookups or retries, push events onto a real queue such as Redis with RQ or Celery, and keep the webhook route to "parse, enqueue, return 200". **Be idempotent.** Record each `message["id"]` you've handled and skip repeats. A redeploy or a duplicate delivery then can't send the same invoice twice. With several gunicorn workers, an in-process set isn't shared, so use Redis `SET NX` or a unique column in your database. **Watch the phone, not only the code.** `status` events tell you when the number disconnects or shows the QR screen again. Alert on them. `ack` events show whether replies were delivered and seen. **Send like a person would.** A bot that answers people who wrote first is the safest kind of automation on an unofficial API. Large proactive sends from a new number are what get accounts restricted. Read [how Safe Mode prevents WhatsApp bans](/blog/whatsapp-ban-prevention-safe-mode) before you add broadcasts. > **Note:** Never commit `TOKEN` to your repository. Load it from the environment or a secret manager on the server, as `app.py` does. ### Next steps Your Flask bot now receives messages, shows a menu, sends documents and runs polls. To go further: - New to Maytapi? The [getting started guide](/blog/whatsapp-api-getting-started) covers the account, QR pairing and webhook setup. - Learn each webhook payload, including group and call events, in the [webhook events guide](/blog/whatsapp-webhook-events-guide). - Prefer JavaScript? The [Node.js bot tutorial](/blog/whatsapp-bot-nodejs) builds the same idea with Express and reply buttons. - Look up every request body in the [API documentation](/whatsapp-api-documentation). ### Send Messages to WhatsApp Groups via API: Text, Media, Polls and More URL: https://maytapi.com/blog/send-message-to-whatsapp-group-api Published: 2024-08-29, updated: 2026-10-27 Category: Tutorials > Every message type you can post to a WhatsApp group from code, with request examples, mentions, @all and best practices for busy groups. Posting to a WhatsApp group from code uses the same request as messaging one person. The difference is the recipient. This guide shows how to **send messages to a WhatsApp group via API**: text, images, documents, locations, contacts, polls, link previews and mentions, including the `@all` mention that notifies everyone at once. Each message type has a request example, followed by practices that keep busy groups readable and your number in good standing. Everything goes through Maytapi's `sendMessage` endpoint. Maytapi runs on your own WhatsApp number linked as a device, so your number has to be a member of the group. If the group only lets admins send, your number has to be an admin. ### Find the group ID To send to a group, set `to_number` to the group ID, which ends in `@g.us`. If your number created the group through the API, `createGroup` already returned the ID. Otherwise, list your groups: ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/getGroups" \ -H "x-maytapi-key: $TOKEN" ``` Workflow for sending to a group: list groups, pick the @g.us id, check who can post, then send with the group id as to_number. Each entry has an `id` such as `120363012345678901@g.us`, plus `name`, `participants`, `admins` and `config`. Note `config.send`: when it is `admins`, only admins can post. You can also get the ID from your webhook. When anyone writes in a group, the incoming event's `conversation` field is the group ID. The product ID and token are on the console's [Product ID & Token page](https://console.maytapi.com/settings/token). To create groups and manage their members, see [create and manage WhatsApp groups with an API](/blog/create-whatsapp-group-api). ### Send a text message to a WhatsApp group A text message to a group: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/sendMessage" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "to_number": "120363012345678901@g.us", "type": "text", "message": "Store opens at 10:00 today. See you there!" }' ``` The response returns `success: true`, plus `data.chatId` (the group) and `data.msgId`. Keep the `msgId` if you might later reply to, pin or delete that message. WhatsApp formatting such as `*bold*` and `_italic_` works in group messages too. The [text formatter](/tools/whatsapp-text-formatter) helps you preview it. The same request in Node.js 18+: ```js const GROUP = "120363012345678901@g.us"; async function sendToGroup(body) { const res = await fetch( `https://api.maytapi.com/api/${PRODUCT_ID}/${PHONE_ID}/sendMessage`, { method: "POST", headers: { "x-maytapi-key": TOKEN, "Content-Type": "application/json" }, body: JSON.stringify({ to_number: GROUP, ...body }) } ); return res.json(); } await sendToGroup({ type: "text", message: "Store opens at 10:00 today." }); ``` The examples below show only the JSON body. Each one goes to the same endpoint with the group ID in `to_number`. ### Message types you can post to a group | What you send | `type` | Key fields | |---|---|---| | Text | `text` | `message` | | Image, video, audio | `media` | `message` (URL or base64), `text` caption | | Document with a file name | `media` | `message`, `filename`, optional `text` | | Location | `location` | `latitude`, `longitude`, `text` | | Link preview | `link` | `message` (URL), optional `text` | | Contact card (vCard) | `vcard` | `message.displayName`, `message.vcard` | | WhatsApp contact | `contact` | `message` (one chat ID or an array) | | Poll | `poll` | `message` (question), `options`, `only_one` | | Event | `createEvent` | `message`, `description`, `startTime`, `endTime`, `location` | | Reply to a message | any of the above | add `reply_to` with the message ID | #### Images, video and documents `media` covers every file type. Send a public URL (or a base64 data URI) in `message` and an optional caption in `text`: ```json { "to_number": "120363012345678901@g.us", "type": "media", "message": "https://example.com/images/autumn-collection.jpg", "text": "Our autumn collection is live. Members get early access until Friday." } ``` For PDFs and other files, add `filename` so members see a proper name instead of a random one: ```json { "to_number": "120363012345678901@g.us", "type": "media", "message": "https://example.com/files/shift-plan-week-44.pdf", "filename": "shift-plan-week-44.pdf", "text": "Shift plan for next week" } ``` `sendMessage` also accepts `isGif` for GIF-style videos and `isHd` for HD images, and has a separate `sticker` type. The [media messages API](/features/whatsapp-media-messages-api) page has more on files and formats. #### Location, link previews and contacts ```json { "to_number": "120363012345678901@g.us", "type": "location", "text": "Meeting point: Fatih Sultan Mehmet Bridge", "latitude": "41.093292", "longitude": "29.061737" } ``` A `link` message sends a URL as a rich preview. Use `text` for the line that goes with it: ```json { "to_number": "120363012345678901@g.us", "type": "link", "message": "https://example.com/events/spring-workshop", "text": "Schedule and materials" } ``` To share a contact, use `vcard` with a full vCard string, or `contact` with an existing WhatsApp chat ID: ```json { "to_number": "120363012345678901@g.us", "type": "vcard", "message": { "displayName": "Acme Support", "vcard": "BEGIN:VCARD\nVERSION:3.0\nFN:Acme Support\nTEL;TYPE=CELL:+905301234567\nEND:VCARD" } } ``` #### Polls Polls are often the most useful message in a group: one message instead of fifty replies. ```json { "to_number": "120363012345678901@g.us", "type": "poll", "message": "Which day works for the next meetup?", "options": ["Tuesday", "Wednesday", "Thursday"], "only_one": true } ``` `only_one: true` limits each member to one choice. Leave it out to allow multiple answers. Votes come to your webhook as `pollVoteUpdate` events, so you can count them without opening the phone. WhatsApp group chat showing an image posted through the API, member replies and a mention *A media message sent with sendMessage to a group ID, followed by member replies and a mention.* ### Mention members and use @all To mention someone, write `@` followed by their number in the message text. The person sees it as a mention and gets notified: ```json { "to_number": "120363012345678901@g.us", "type": "text", "message": "@905301234567 your order is ready for pickup. @905301234568 yours ships tomorrow." } ``` You can mention several people in one message. Since the 12.10.2026 release, there is also `@all`, which notifies every member without listing their numbers: ```json { "to_number": "120363012345678901@g.us", "type": "text", "message": "@all The meeting starts in 10 minutes." } ``` > **Note:** `@all` works in `text` and `link` messages in group chats, and you can combine it with individual mentions. Following WhatsApp's own rule for mentioning everyone, it takes effect in groups with fewer than 33 participants, or when your number is a group admin. Otherwise the message is sent as plain text without the mention. Mentions also work in the other direction. Incoming webhook messages include a `mentions` array of the chat IDs that were tagged. When someone uses `@all`, the array also contains the string `"all"`. A bot can use that to answer only when it is tagged instead of reacting to every message in the group. ### Reply, react, pin and delete in groups `sendMessage` also handles the actions people use to keep a group conversation tidy. To reply to a specific message, add its ID in `reply_to`. For a received message, use the full serialized ID from the webhook. In groups it ends with the sender's chat ID (`false___`). For your own messages, the `msgId` from the send response works. Other actions: - **React:** `type: "reaction"` with an emoji in `message` and the target in `reply_to`. - **Pin:** `type: "pinMessage"`, `action: 1`, a `duration` of 86400, 604800 or 2592000 seconds, and `reply_to`. `action: 2` unpins. - **Delete for everyone:** `type: "delete"` with the message ID in `message` and `deleteForEveryone: true`. ```json { "to_number": "120363012345678901@g.us", "type": "pinMessage", "action": 1, "duration": 604800, "reply_to": "false_120363012345678901@g.us_3EB0A81ECEA6D47F2F13_905301234567@c.us" } ``` Pinning the week's schedule usually saves more questions than reposting it. ### Best practices for busy WhatsApp groups **Make announcement groups read-only.** If members shouldn't reply, set `send` to `admins` with `group/config`. Your number can still post, the group stays readable, and your webhook isn't flooded with replies. Checklist of best practices for busy WhatsApp groups: read-only announcements, rich messages, sparing @all, pacing, records and webhook filtering. **Prefer one rich message over many small ones.** A caption on an image, a poll instead of "reply 1, 2 or 3", and a pinned message instead of daily reposts all mean fewer notifications. Members are less likely to mute or leave a group that doesn't ping them all the time. **Use @all sparingly.** It notifies everyone, including people who muted the group's normal traffic. Save it for things that are actually urgent. **Pace multi-group sends.** Posting the same announcement to dozens of groups in a burst is the kind of pattern WhatsApp's spam systems notice. Space the requests out. Watch `GET /queue` if messages build up, and remember that `sendMessage` has a duplicate filter for identical messages. The [Safe Mode and warm-up guide](/blog/whatsapp-ban-prevention-safe-mode) explains how to warm up and pace a number. **Keep a record of what you sent.** Save the `msgId` of each announcement together with the group ID and a timestamp. You need the ID to reply to, pin or delete the message later. It also lets you match `ack` events on your webhook to the right announcement, and it gives you a clean answer when someone asks whether a group was told about a change. **Filter your webhook by group.** Every message in every group your number belongs to reaches your webhook. Check `conversation` against the groups you care about, ignore `fromMe` messages, and use `mentions` to decide when a bot should respond. ### Next steps - Create groups, manage members and handle join requests with [create and manage WhatsApp groups with an API](/blog/create-whatsapp-group-api). - See group messaging, members and events together on the [WhatsApp group API](/features/whatsapp-group-api) page. - For one-to-many announcements that members can't reply to, compare groups with [WhatsApp channels](/features/whatsapp-channel-api). You can try every request on this page with the [3-day free trial](https://console.maytapi.com/register), no card needed. ### WhatsApp Profile Picture API: Fetch, Store and Sync to CRM URL: https://maytapi.com/blog/whatsapp-profile-picture-api Published: 2024-08-07, updated: 2026-10-27 Category: Tutorials > Get WhatsApp profile pictures of contacts and groups with getProfileImage, handle privacy limits, store the images and keep CRM avatars in sync. A customer's WhatsApp photo is often the fastest way for an agent to recognise who they are talking to, and a CRM card with a face on it simply reads better than one with initials. Maytapi exposes WhatsApp profile pictures over REST: you can fetch the picture of a contact or a group, and you can change or remove the picture of your own number and of groups you manage. This guide covers the WhatsApp profile picture API endpoints, what they actually return, where WhatsApp's privacy settings stop you, and how to download the images and keep them in sync with a CRM. ### The profile picture endpoints at a glance | Task | Endpoint | Body or parameter | |---|---|---| | Get a contact's or group's picture | `GET /getProfileImage` | `number` query parameter | | Set your own picture | `POST /setProfileImage` | `image` (image URL) | | Remove your own picture | `POST /deleteProfileImage` | none | | Set a group's picture | `POST /group/setProfileImage` | `conversation_id`, `image` | | Remove a group's picture | `POST /group/deleteProfileImage` | `conversation_id` | All of them live under `https://api.maytapi.com/api/PRODUCT_ID/PHONE_ID/` and send your token in the `x-maytapi-key` header. The phone in the path is the WhatsApp number that does the looking up, so what you can see is what that number could see in the WhatsApp app. ### Get a contact's profile picture `getProfileImage` takes a single query parameter, `number`. Send the phone number with the country code and no spaces or symbols: ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/getProfileImage?number=905301234567" \ -H "x-maytapi-key: $TOKEN" ``` When the picture is visible, `data` is a plain string, the URL of the image on WhatsApp's servers: ```json { "success": true, "data": "https://pps.whatsapp.net/v/..." } ``` A few details about the `number` parameter, taken from how the endpoint normalises it: - A leading `+` is stripped, so `+905301234567` works too. - A value without `@` is treated as a contact and becomes `905301234567@c.us`. - A value that already contains `@` is used as is, so you can pass a chat id straight from a webhook payload. - A value without `@` that contains a `-` is treated as a group id (the older `905301234567-1574072436` format) and gets `@g.us` appended. Newer group ids are digits only, like `120363012345678901`, so always pass them with `@g.us`; without it they are treated as a phone number. If `number` is missing, the response is `{"success": false, "message": "Number required"}`. ### Get a group's picture The same endpoint works for groups. Pass the group id: ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/getProfileImage?number=905301234567-1574072436@g.us" \ -H "x-maytapi-key: $TOKEN" ``` If you already call `GET /getGroups/{conversation_id}` to read group details, you don't need a second request: the group object it returns includes an `image` field with the picture URL when the group has one. See [how to create a WhatsApp group with the API](/blog/create-whatsapp-group-api) for the rest of that object. ### When there is no picture: privacy and empty results WhatsApp only hands out a profile photo when the person's privacy settings allow your number to see it. If a contact set "Profile photo" to "My contacts" and your number isn't in their contacts, or to "Nobody", you get nothing back. The same happens when the contact simply has no photo, or the number isn't on WhatsApp. Comparison of getProfileImage responses: picture visible returns success true and a URL string; hidden, missing or unknown number returns success false with empty data In all of those cases the response has `success: false`, and `data` is empty (`null` or an empty string): ```json { "success": false, "data": null } ``` There is no field that tells you why. A hidden photo, a missing photo and an unknown number look the same, because WhatsApp itself doesn't tell the linked device the difference. Treat `success: false` as "no picture available right now", not as an error worth retrying in a loop. If you also need to know whether the number is on WhatsApp at all, check it first with the [bulk number check](/blog/bulk-whatsapp-number-check). Two more things to plan for: - **Lookups can be slow.** When WhatsApp doesn't answer, the request waits several seconds before returning an empty result. Use a client timeout of around 20 seconds and don't fire hundreds of lookups in parallel. - **Resolution can vary.** In some cases the returned URL points to WhatsApp's smaller preview version of the photo rather than the full-size image. Fine for an avatar, not for printing. ### Download and store the image The URL from `getProfileImage` points to WhatsApp's own image servers. These are signed, temporary links: they work for a while and then stop. Saving the URL in your CRM and rendering it later leads to broken images a few days on. Download the file when you get the URL and store the bytes yourself. ```js const base = `https://api.maytapi.com/api/${PRODUCT_ID}/${PHONE_ID}`; const headers = { "x-maytapi-key": TOKEN }; async function fetchAvatar(number) { const r = await fetch(`${base}/getProfileImage?number=${encodeURIComponent(number)}`, { headers, signal: AbortSignal.timeout(20000) }); const res = await r.json(); if (!res.success || !res.data) return null; // hidden, missing or not on WhatsApp const img = await fetch(res.data, { signal: AbortSignal.timeout(20000) }); if (!img.ok) return null; const bytes = Buffer.from(await img.arrayBuffer()); return { bytes, contentType: img.headers.get("content-type") || "image/jpeg" }; } ``` Upload `bytes` to your own storage (S3, your CRM's file field, a local disk) and keep that permanent URL on the contact record. A hash of the bytes is handy too, because it lets you skip writes when nothing changed. ### Sync profile pictures to your CRM A profile photo is personal data, so store it only for contacts who have chatted with you and you have a reason to keep, delete it with the rest of their record, and cover it in your privacy notice (GDPR and similar laws apply). There is no webhook that fires when a contact changes their photo, so syncing means looking pictures up at sensible moments and refreshing them on a schedule. A pattern that works well: 1. **On first contact.** When a message webhook arrives from a number your CRM doesn't know yet, create the contact, acknowledge the webhook with a 200, then fetch the avatar in the background. The [webhook events guide](/blog/whatsapp-webhook-events-guide) shows where the sender id sits in each payload. 2. **Store the result, including "none".** Save the stored image URL, the hash and a `checked_at` timestamp. When the lookup returns `success: false`, still save `checked_at` so you don't ask again on every message. 3. **Refresh slowly.** Re-check active contacts every week or two. Compare the new hash with the stored one and only update the CRM when it changed. A picture that disappears (`success: false` after a previous hit) may mean the contact removed it or tightened their privacy settings; clearing it in the CRM respects that. 4. **Throttle.** Run refreshes through a queue with a few lookups at a time, not a burst over your whole contact list. Flow for syncing WhatsApp profile pictures to a CRM: webhook from a new contact, getProfileImage lookup, download the temporary URL, store bytes and hash, refresh on a schedule ```js import crypto from "node:crypto"; async function syncAvatar(contact) { const found = await fetchAvatar(contact.phone); const now = new Date().toISOString(); if (!found) { return crm.update(contact.id, { avatar_url: null, avatar_checked_at: now }); } const hash = crypto.createHash("sha256").update(found.bytes).digest("hex"); if (hash === contact.avatar_hash) { return crm.update(contact.id, { avatar_checked_at: now }); } const url = await storage.put(`avatars/${contact.id}.jpg`, found.bytes, found.contentType); return crm.update(contact.id, { avatar_url: url, avatar_hash: hash, avatar_checked_at: now }); } ``` `crm` and `storage` stand for your own clients. If your contacts arrive with `@lid` ids instead of phone numbers, resolve them first as described in [WhatsApp LID to phone number](/blog/whatsapp-lid-to-phone-number) so the avatar lands on the right CRM record. ### Change your own profile picture `setProfileImage` sets the picture of the connected WhatsApp number. Send a publicly reachable image URL in `image`: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/setProfileImage" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"image": "https://yourwebsite.com/picture.png"}' ``` The response is `{"success": true}` when WhatsApp accepted the change. Use a square image; 512x512 pixels is the recommended size, and anything else may be cropped in the app. This is useful when you run several numbers and want the same brand logo on all of them, or rotate a seasonal picture from a script. To go back to the default avatar, call `deleteProfileImage` with no body: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/deleteProfileImage" \ -H "x-maytapi-key: $TOKEN" ``` ### Set or remove a group picture For groups, add the group id as `conversation_id`: ```bash curl -X POST "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/group/setProfileImage" \ -H "x-maytapi-key: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"conversation_id": "905301234567-1574072436@g.us", "image": "https://yourwebsite.com/picture.png"}' ``` `group/deleteProfileImage` takes only `conversation_id`. The usual WhatsApp group rules apply: when a group only lets admins edit its info, the connected number has to be an admin, otherwise the call fails with `success: false` and a `message`. A typical use is branding every customer group you create with your logo right after `createGroup`. ### Common mistakes | Symptom | Likely cause | |---|---| | `success: false`, empty `data` | Photo hidden by privacy, no photo set, or number not on WhatsApp | | CRM avatars break after a few days | You stored the WhatsApp URL instead of downloading the file | | Group lookup returns nothing | Group id missing `@g.us`, or your number is no longer in the group | | Picture looks small or blurry | A preview-size image was returned; keep the previous full-size copy if you have one | | `group/setProfileImage` fails | Your number isn't an admin and the group restricts info edits | ### Next steps Profile pictures are a small feature, but they make every CRM screen and support inbox easier to scan. Fetch once, store the bytes, refresh slowly and accept that some people choose not to share their photo. - Try the endpoints in the [API documentation](/whatsapp-api-documentation). - Fetch your own avatar with `getProfileImage` on the [3-day free trial](https://console.maytapi.com/register). - Keep CRM stages in sync too with the [WhatsApp Labels API guide](/blog/whatsapp-labels-api-crm-sync). - Map `@lid` senders to phone numbers with [WhatsApp LID to phone number](/blog/whatsapp-lid-to-phone-number). - Learn which events reach your server in the [webhook events guide](/blog/whatsapp-webhook-events-guide). - Manage the groups you brand with [create a WhatsApp group with the API](/blog/create-whatsapp-group-api). ### WhatsApp Broadcast via API: Send to Many Contacts Without Getting Banned URL: https://maytapi.com/blog/whatsapp-broadcast-api Published: 2023-09-07, updated: 2026-10-27 Category: Guides > How to send one message to hundreds of contacts with the API: broadcast vs groups vs channels, personalization, pacing, queues and opt-outs. Sending the same announcement to a few hundred customers is one of the first things people try with a WhatsApp API, and it is also the fastest way to get a number restricted. This guide covers how a WhatsApp broadcast API send actually works in Maytapi, when a group or a Channel is a better fit, and how to build a send loop that personalizes each message, paces itself, watches the queue, tracks delivery and respects opt-outs. Maytapi is an unofficial API: the messages go out from your own WhatsApp number, linked as a device. WhatsApp's rules still apply to that number, so most of this post is about sending in a way that doesn't look like spam. ### What "broadcast" means on WhatsApp In the WhatsApp app, a broadcast list is a saved set of contacts. You write one message and each person receives it as a normal one-to-one chat from you. Only people who have saved your number in their contacts receive it, and the list has a size cap. Comparison of a WhatsApp app broadcast list and an API send loop that calls sendMessage once per recipient. The Maytapi API has no broadcast-list endpoint. A broadcast over the API is a loop: your code calls `sendMessage` once per recipient, and each call creates or continues a one-to-one chat. That has real advantages over the app's lists. You can personalize every message, choose the order and timing, and get a receipt per recipient. It also means every recipient who has never chatted with you counts as a new chat, which is exactly what WhatsApp watches most closely. Before writing the loop, check whether a one-to-one broadcast is the right tool at all: | Option | Best for | Replies | Each send opens a 1:1 chat | |---|---|---|---| | API send loop | Personal, transactional or opt-in updates to known contacts | Private, per person | Yes | | [Group message](/blog/send-message-to-whatsapp-group-api) | A team, class or customer group that talks among itself | Visible to the whole group | No, one message to the group | | [Channel post](/blog/whatsapp-channel-api-polls-quizzes) | News, offers and content for followers who chose to follow | None in chat (reactions, poll votes) | No, followers subscribe | If your message is the same for everyone and nobody needs to reply privately, a Channel is usually the safer choice: followers opted in, and your number isn't starting hundreds of new conversations. Use the API loop when the message is personal (an order update, a renewal reminder, an appointment) or when you want each recipient to be able to answer you directly. ### Start with opt-in and a clean list Every number on your list should belong to someone who expects to hear from you on WhatsApp: a customer who gave you their number at checkout, a lead who filled in a form and ticked a WhatsApp box, a contact who has messaged you before. Bought lists and scraped numbers are the quickest path to reports, and reports are the strongest ban signal there is. Keep a record of when and how each person agreed, because privacy laws such as GDPR or KVKK may require you to show it. Pacing and varied wording lower the risk for messages people want; they don't make unsolicited marketing acceptable. For large promotional sends to people who haven't asked for them, Meta's official WhatsApp Business Platform with approved templates is the better fit. Our [WhatsApp Business API alternative](/whatsapp-business-api-alternative) page compares the two. Two pieces of list hygiene pay off before the first message goes out: - **Normalize the numbers.** `to_number` takes the country code followed by the national number, digits only, for example `905301234567`. - **Drop numbers that aren't on WhatsApp.** Sends to dead numbers fail and make your campaign look colder than it is. Run the list through `checkPhones` first; the [bulk WhatsApp number check guide](/blog/bulk-whatsapp-number-check) shows how to do it in batches and keep the results in your CRM. Also keep an opt-out list from day one, and check it before every send. More on that below. ### Personalize each WhatsApp broadcast message Identical text sent to many chats in a short window is one of the patterns that makes a number look like a spam tool. Since the API sends one message per recipient anyway, there is no reason to send the same string to everyone: - Use the person's name and something that is actually theirs: the order number, the appointment time, the product they asked about. - Keep two or three phrasings of the same message and rotate them. - Say who you are in the first line, and end with an easy way out, such as "Reply STOP to stop these updates." - Leave links out of the first message to people who have never chatted with you. Send the link once they reply. A small template helper in JavaScript: ```js const TEMPLATES = [ "Hi {name}, this is Acme Store. Your order {order} is ready for pickup. Reply STOP to stop these updates.", "Hello {name}! Acme Store here: order {order} is packed and waiting for you. Reply STOP to opt out.", "{name}, good news from Acme Store: order {order} is ready. Reply STOP if you'd rather not get these." ]; function render(contact) { const template = TEMPLATES[Math.floor(Math.random() * TEMPLATES.length)]; return template.replace(/\{(\w+)\}/g, (_, key) => contact[key] ?? ""); } ``` ### A WhatsApp broadcast API send loop with pacing The loop below reads a list of contacts, skips anyone who opted out, sends one personalized text per person and waits a random interval between sends. The `typing` and `duration` fields are optional; they ask for a typing indicator in the chat for a few seconds (1 to 30) before the message is sent. Not every connection shows it, so treat it as a nice touch, not as your pacing. ```js const { PRODUCT_ID, PHONE_ID, TOKEN } = process.env; const BASE = `https://api.maytapi.com/api/${PRODUCT_ID}/${PHONE_ID}`; const HEADERS = { "x-maytapi-key": TOKEN, "Content-Type": "application/json" }; const MIN_GAP = 20, MAX_GAP = 45; // seconds between sends const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function send(contact) { const body = { to_number: contact.number, type: "text", message: render(contact), typing: "typing", duration: 3 }; const r = await fetch(`${BASE}/sendMessage`, { method: "POST", headers: HEADERS, body: JSON.stringify(body) }); return r.json(); } async function broadcast(contacts, optedOut) { const sent = {}; for (const contact of contacts) { if (optedOut.has(contact.number)) continue; const result = await send(contact); if (result.success) { sent[result.data.msgId] = contact.number; } else { console.log("not queued:", contact.number, result.message); } await sleep((MIN_GAP + Math.random() * (MAX_GAP - MIN_GAP)) * 1000); } return sent; } ``` Store the `msgId` from each response. It is the id that delivery receipts refer to later, so it connects a webhook event back to a contact. How fast is safe depends on the number. A new or long-idle number should send far less than an established one with real conversation history. The limits Safe Mode enforces during its 14-day warm-up are a useful benchmark even if you don't turn it on: Tier 1 (days 0–1) allows 1 message a minute with a 45-second minimum gap, 60 messages and 8 new chats a day; Tier 5 (days 11–14) allows 8 a minute with a 5-second gap, 1,500 messages and 150 new chats a day. The full table is in [how to avoid WhatsApp bans with Safe Mode](/blog/whatsapp-ban-prevention-safe-mode). The number to watch in a broadcast is new chats, not total messages. A message to someone who already chats with you is low risk; a first message to a stranger is not. Split your list into existing contacts and first contacts, send to existing contacts first, and keep the first-contact share small on any given day. WhatsApp also applies its own new-chat quota to some numbers. It is still being tested and currently shows up on new WhatsApp accounts. Read it before a batch of first contacts with `GET /getNewChatLimit`, and stop when it is used up: ```js async function newChatsLeft() { const resp = await fetch(`${BASE}/getNewChatLimit`, { headers: HEADERS }).then((r) => r.json()); if (!resp.success) throw new Error(resp.message); // limit could not be read; don't assume there is none const { limited, capped, remaining } = resp.data; if (!limited) return null; // WhatsApp applies no quota to this number return capped ? 0 : remaining; } ``` ### Understand the sending queue `sendMessage` doesn't send synchronously. It adds the message to your phone's sending queue and returns right away with `success: true`, a `chatId` and a `msgId`. A worker connected to your number takes messages off that queue one by one and sends them in order. So `success: true` means "queued", not "delivered". Three endpoints let you look at and manage that queue: | Endpoint | Scope | What it does | |---|---|---| | `GET /queue` | One phone | Returns `stats.sending`, the number of messages still waiting in that phone's queue | | `GET /queues` | Product | Lists every phone in the product with its `id`, `status` and `queue` (waiting messages) | | `GET /purgeQueue` | One phone | Clears the phone's send queue; messages still waiting are dropped | ```bash curl "https://api.maytapi.com/api/$PRODUCT_ID/$PHONE_ID/queue" \ -H "x-maytapi-key: $TOKEN" ``` ```json { "success": true, "stats": { "sending": 0 } } ``` The queue is useful in a broadcast in a few ways: - **Don't pile messages up.** If you push the whole list at once, the queue holds everything, and the pace is no longer under your control. Keep your own delay between calls and check that `stats.sending` stays near zero. A growing number usually means the phone is disconnected or is being held back by limits. - **With Safe Mode on, expect a backlog.** Safe Mode doesn't reject messages that only exceed the per-minute rate or minimum gap; it holds them in the queue and releases them when allowed. A non-zero queue is normal then. - **Use purgeQueue as the stop button.** If you spot a typo in a template or the number starts getting errors, call `purgeQueue` to drop everything still waiting, and stop your loop. Messages that were already sent are not affected, and the one being sent at that moment may still go out. > **Note:** If the queue keeps growing and nothing is delivered, check the phone's connection status (`/queues` shows it per phone) before sending more. Messages left waiting in the queue for more than two days expire. ### Track delivery with ack webhooks Receipts for your broadcast arrive on your webhook as `ack` events. Each item in `data` has an `ackCode` (1 `delivered` to WhatsApp's servers, 2 `reached` the phone, 3 `seen`, 4 `played`, 5 `deleted`) and a `msgId` that matches the id `sendMessage` returned. Messages that couldn't be sent arrive as an `error` event with a `code` and a `message`, for example `F14` when a Safe Mode daily limit is reached or `F16` when WhatsApp's new-chat quota is used up. Ack delivery must be enabled for the product (`ack_delivery` in `setWebhook` or `setAckPreference`). An Express handler that records receipts and handles opt-outs in the same endpoint: ```js import express from "express"; const app = express(); const STATUS = new Map(); // msgId -> highest ackCode seen (use your database) const OPTED_OUT = new Set(); // numbers that replied STOP (use your database) const STOP_WORDS = new Set(["stop", "unsubscribe", "cancel"]); app.post("/maytapi/webhook", express.json(), (req, res) => { res.sendStatus(200); const event = req.body; if (event.type === "ack") { for (const ack of event.data || []) { STATUS.set(ack.msgId, Math.max(ack.ackCode, STATUS.get(ack.msgId) || 0)); } } else if (event.type === "message") { const msg = event.message || {}; if (!msg.fromMe && msg.type === "text" && STOP_WORDS.has((msg.text || "").trim().toLowerCase())) { OPTED_OUT.add(event.user.phone); } } }); app.listen(3000); ``` Keep the highest `ackCode` per message rather than the latest one, because receipts can arrive out of order and more than once. In production, return 200 first and do the database work in the background. The [WhatsApp webhook events guide](/blog/whatsapp-webhook-events-guide) has the full payloads and the rules for a reliable handler. With receipts stored, you can answer the questions that matter after a broadcast: how many messages reached a phone, how many were read, and which numbers never got past `delivered`. Replies are worth tracking too. A broadcast that gets answers is a good sign for your number; one that gets silence and blocks is a warning to slow down. ![Maytapi console Logs page listing incoming, outgoing and error events with the sendMessage request detail](/images/showcase/whatsapp-api-message-logs.webp) *The Logs page in the console shows every sendMessage request and webhook delivery, filterable by incoming, outgoing and errors.* ### Handle opt-outs and errors as you go An opt-out has to take effect before the next message, not at the next campaign. The handler above adds the number to the opt-out list the moment someone replies STOP; the send loop checks that list before every send. Send a short confirmation ("You won't get these updates anymore.") and don't message that number again unless they write to you first. Checklist of how to react during a broadcast: honor STOP replies immediately and handle F12, F16, F13, F14 and duplicate filter errors. Treat errors the same way, as signals to act on rather than retry blindly: - **F12** (WhatsApp has restricted new conversations) and **F16** (new-chat quota used up): stop all first contacts until the restriction lifts. Keep answering people who write to you. - **F13** (link in a first message to a new chat, blocked by Safe Mode): remove the link and send it later in the conversation. - **F14** (Safe Mode daily cap, sending window or new-chat limit): continue the rest of the list tomorrow. The API also has a duplicate filter: sending the same content to the same chat several times within a few minutes gets dropped and reported as an `error` event. If a resend is intentional, `sendMessage` accepts `"skip_filter": true`, but for a broadcast the better fix is usually to avoid repeating yourself. ### Next steps Start small: pick a few dozen opted-in contacts who already chat with you, run the loop with a generous delay, and watch receipts and replies before you widen the list. If the number is new, turn on Safe Mode first and read the [WhatsApp API ban prevention page](/whatsapp-api-ban-prevention) so you know how to recognize a restriction early. For announcements that don't need private replies, move them to a WhatsApp Channel. The full request and response schemas for `sendMessage` and the queue endpoints are in the [API documentation](/whatsapp-api-documentation).