Back to blog
Chatmaid Developers··12 min read

WhatsApp API Quickstart for Developers: Send Messages (2026)

Send WhatsApp messages from your SaaS app with the Chatmaid API: free sandbox, Node.js, Python and Ruby code, webhooks, retries and a go-live checklist.

Chatmaid Team
WhatsApp API Quickstart for Developers: Send Messages (2026)

To add WhatsApp to a SaaS product with Chatmaid Developers, you need four pieces: an API key in the Authorization header, a sender phone number registered in the dashboard, one POST /v1/messages/send call, and, if you want replies and delivery receipts pushed to you, an HTTPS webhook endpoint. Everything else in this guide (idempotency, error handling, retries, rate limits) builds on those four.

You can build and test the whole integration in the free sandbox with an sk_test_* key. Nothing reaches WhatsApp until you switch to an sk_live_* key on the $7.99/month plan. Chatmaid links your own WhatsApp number as a linked device, the way WhatsApp Web does, so there is no Meta Business verification or template approval. It is not Meta's official WhatsApp Business API. The code below is current as of October 2026.

Prerequisites

  • A Chatmaid Developers account (the sandbox is free)
  • A server with a public HTTPS URL, for webhooks only
  • Node.js 18 or later (npm install @chatmaid/sdk express), Python 3 (pip install requests flask), or Ruby, depending on which examples you follow

Part 1: Authentication

Every request carries your API key as a Bearer token:

Authorization: Bearer sk_test_xxxxxxxxxxxx

There are two kinds of key, and the prefix decides the environment. The host is the same for both: https://developers-api.chatmaid.net.

  • sk_test_*: sandbox. Sends are simulated and never touch WhatsApp. Free, no subscription needed.
  • sk_live_*: production. Real WhatsApp messages. Requires an active $7.99/month subscription; a live key on an account without one gets a 403.

Create keys in the dashboard under API Keys, then Create API Key, and pick Test or Live as the key type. New keys get the Read Messages and Send Messages permissions by default, which is all this guide needs.

Use an sk_test_* key while you build. The sandbox returns the same response shapes as production, and its message.sent webhooks use the production payload format, so your code does not change when you go live.

Keep the key in an environment variable, along with the sender number you will set up in Part 2:

export CHATMAID_API_KEY=sk_test_xxxxxxxxxxxx
export SENDER_PHONE=+15551234567   # your registered number

Never hardcode keys in source files or commit them to version control.

Part 2: Connecting a phone number

The sender is a WhatsApp number you register in the dashboard under Phone Numbers, then Add Number. Enter your own number in international format with the country code. A number can be registered only once per environment, so use one you own; the examples below use +15551234567 as a placeholder.

  • Sandbox numbers need no QR code. Click Connect and the number connects right away.
  • Live numbers connect like WhatsApp Web: click Connect, then on that phone open WhatsApp, go to Linked devices (under Settings on iPhone, or the three-dot menu on Android), tap Link a device and scan the QR code. Connecting a live number needs the subscription.

Phone numbers belong to one environment. An sk_test_* key only sees sandbox numbers and an sk_live_* key only sees live ones, so register the number again in production when you go live. The dashboard adds numbers to whichever environment the sidebar switch shows: Test Mode for sandbox numbers, Live for live ones. The switch unlocks once you subscribe.

In API calls, fromPhoneId takes the number itself in E.164 format (recommended) or its dashboard ID. To see which numbers a key can use:

curl https://developers-api.chatmaid.net/v1/phone-numbers \
  -H "Authorization: Bearer $CHATMAID_API_KEY"

You can connect more than one number, for example one for support and one for operational alerts. See pricing for extra numbers.

Part 3: Sending messages

Basic text message

curl -X POST https://developers-api.chatmaid.net/v1/messages/send \
  -H "Authorization: Bearer $CHATMAID_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "fromPhoneId": "+15551234567",
    "to": "+15557654321",
    "content": "Hello from the API!"
  }'

Replace +15551234567 with the number you registered and +15557654321 with a phone you can check. A successful send returns 201 with the message wrapped in data:

{
  "success": true,
  "data": {
    "id": "msg_6f1c2a9e4b7d3e8a0c5f1b2d",
    "from": "+15551234567",
    "to": "+15557654321",
    "isGroup": false,
    "groupId": null,
    "content": "Hello from the API!",
    "mediaUrls": [],
    "status": "sent",
    "errorCode": null,
    "errorMessage": null,
    "createdAt": "2026-10-22T10:30:00.000Z",
    "sentAt": "2026-10-22T10:30:00.412Z",
    "deliveredAt": null,
    "readAt": null,
    "failedAt": null
  }
}

status is sent once WhatsApp has accepted the message. delivered and read follow later as WhatsApp receipts arrive (see Part 7). Save data.id: it is the key you use to match webhooks, status checks and support requests.

to is the recipient in E.164 format, with the + and country code. content can be up to 4,096 characters.

Idempotent sending

If your system retries requests (job queues, timeouts, worker restarts), send an idempotencyKey in the request body. If the same key arrives twice, Chatmaid returns the original message instead of sending a second one.

curl -X POST https://developers-api.chatmaid.net/v1/messages/send \
  -H "Authorization: Bearer $CHATMAID_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "fromPhoneId": "+15551234567",
    "to": "+15557654321",
    "content": "Your order #4892 has shipped.",
    "idempotencyKey": "order-4892-shipped"
  }'

Rules worth knowing:

  • Keys are at most 64 characters.
  • Build the key from the event that triggered the message, such as order-4892-shipped, not from a random value generated at send time. Otherwise a retry gets a new key and sends again.
  • Keys are scoped to your account, across both environments. A key you already used in the sandbox returns that sandbox message if you send it again with a live key, and nothing goes out. Don't reuse test event IDs in production.
  • A key also replays failures. If the first attempt failed, sending the same key returns the same error. To try again after a failed send, use a new key (Part 5 shows how).

Most of the send examples in this guide use the key order-4892-shipped. If you run several of them against the same account, the later ones return the first message instead of sending again. That is idempotency doing its job.

Sending media

Pass public HTTPS URLs in mediaUrls. Chatmaid fetches each file and attaches it; plain http:// URLs are rejected.

{
  "fromPhoneId": "+15551234567",
  "to": "+15557654321",
  "content": "Here is your invoice.",
  "mediaUrls": ["https://yourapp.com/invoices/inv-4892.pdf"]
}

With one media URL, content becomes the caption. With several, the text goes out first, followed by each file without a caption. Each request needs content, mediaUrls, or both.

Sending to a WhatsApp group

to also accepts a group ID (a JID ending in @g.us). List the groups a sender can post to:

curl "https://developers-api.chatmaid.net/v1/groups?fromPhoneId=%2B15551234567" \
  -H "Authorization: Bearer $CHATMAID_API_KEY"

Then pass a returned id, such as 120363043211234567@g.us, as to. The sender must be a member of the group and allowed to post in it. With a sandbox key, this call returns a fixed list of sample groups. One of them, 120363000000000001@g.us, is a sandbox group you can send to right away. For a worked example, see sending ops alerts to a team group.

Part 4: Sending from Node.js, Python and Ruby

Node.js with the SDK

The official SDK is @chatmaid/sdk. It unwraps the data envelope, throws typed errors, and attaches an idempotency key to every send.

npm install @chatmaid/sdk
// send.mjs (Node.js 18+)
import { Chatmaid } from "@chatmaid/sdk";

const cm = new Chatmaid({ apiKey: process.env.CHATMAID_API_KEY });

const message = await cm.messages.send({
  fromPhoneId: process.env.SENDER_PHONE,
  to: "+15557654321",
  content: "Your order #4892 has shipped.",
  idempotencyKey: "order-4892-shipped",
});

console.log(message.id, message.status);

Run it with node send.mjs. If you leave out idempotencyKey, the SDK generates a random one per call. That protects against transport retries inside the SDK, but not against your own job running twice, so pass a key derived from your event whenever you can.

Node.js with fetch

No SDK needed if you prefer plain HTTP:

// send-fetch.mjs (Node.js 18+)
async function sendWhatsApp(to, content, idempotencyKey) {
  const res = await fetch("https://developers-api.chatmaid.net/v1/messages/send", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.CHATMAID_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      fromPhoneId: process.env.SENDER_PHONE,
      to,
      content,
      ...(idempotencyKey ? { idempotencyKey } : {}),
    }),
  });

  const body = await res.json().catch(() => ({}));
  if (!res.ok) {
    const details = [].concat(body.message ?? []).join("; ");
    throw new Error(`Chatmaid ${res.status} ${body.error ?? "error"}: ${details}`);
  }
  return body.data;
}

const message = await sendWhatsApp("+15557654321", "Your order #4892 has shipped.", "order-4892-shipped");
console.log(message.id, message.status);

Python

import os
from typing import Optional

import requests

CHATMAID_API_KEY = os.environ["CHATMAID_API_KEY"]
SENDER_PHONE = os.environ["SENDER_PHONE"]
BASE_URL = "https://developers-api.chatmaid.net/v1"


def send_whatsapp(to: str, content: str, idempotency_key: Optional[str] = None) -> dict:
    payload = {"fromPhoneId": SENDER_PHONE, "to": to, "content": content}
    if idempotency_key:
        payload["idempotencyKey"] = idempotency_key

    response = requests.post(
        f"{BASE_URL}/messages/send",
        headers={"Authorization": f"Bearer {CHATMAID_API_KEY}"},
        json=payload,
        timeout=30,
    )
    response.raise_for_status()
    return response.json()["data"]


if __name__ == "__main__":
    message = send_whatsapp("+15557654321", "Your order #4892 has shipped.", "order-4892-shipped")
    print(message["id"], message["status"])

For a full Flask setup with background jobs, see how to send WhatsApp notifications from a Python Flask app.

Ruby

require "json"
require "net/http"
require "uri"

def send_whatsapp(to, content, idempotency_key: nil)
  uri = URI("https://developers-api.chatmaid.net/v1/messages/send")

  request = Net::HTTP::Post.new(uri)
  request["Authorization"] = "Bearer #{ENV.fetch('CHATMAID_API_KEY')}"
  request["Content-Type"] = "application/json"

  payload = { fromPhoneId: ENV.fetch("SENDER_PHONE"), to: to, content: content }
  payload[:idempotencyKey] = idempotency_key if idempotency_key
  request.body = payload.to_json

  response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
    http.request(request)
  end

  body = JSON.parse(response.body) rescue {} # a proxy error page is not JSON
  unless response.is_a?(Net::HTTPSuccess)
    raise "Chatmaid #{response.code} #{body.fetch('error', 'error')}: #{Array(body['message']).join('; ')}"
  end

  body["data"]
end

message = send_whatsapp("+15557654321", "Your order #4892 has shipped.", idempotency_key: "order-4892-shipped")
puts message["id"], message["status"]

Part 5: Error handling

Every error uses the same envelope. error is a short label, message is always an array, and statusCode repeats the HTTP status:

{
  "success": false,
  "error": "Rate limit exceeded",
  "message": ["Rate limit exceeded"],
  "statusCode": 429,
  "timestamp": "2026-10-22T10:31:00.000Z",
  "path": "/v1/messages/send",
  "retryAfter": 60
}

There are two kinds of failure, and they need different handling.

Request errors (nothing was sent)

These happen before a message exists, so the body has no messageId.

HTTP statusTypical causeWhat to do
400Invalid body: a recipient that is not in E.164 or group-ID format, content over 4,096 characters, a non-HTTPS media URL, no content and no media, or a live sender that is not connectedFix the request, or reconnect the number
401Missing, invalid or expired API keyCheck the Authorization header
403The key lacks the permission, or a live key on an account without an active subscriptionUse a key with the right permissions, or check billing
404fromPhoneId is not a number on this account in this environmentUse a number from GET /v1/phone-numbers
429Too many requestsWait retryAfter seconds, then retry with the same idempotency key

Send failures (a message was created and failed)

When WhatsApp rejects a send, the API still answers with an error status, but the body also carries the messageId and "status": "failed", and error holds a stable error code:

{
  "success": false,
  "error": "RECIPIENT_NOT_REACHABLE",
  "message": ["The message could not be delivered to this recipient. They may need to message you first, or may not be reachable on WhatsApp."],
  "statusCode": 422,
  "timestamp": "2026-10-22T10:32:00.000Z",
  "path": "/v1/messages/send",
  "messageId": "msg_6f1c2a9e4b7d3e8a0c5f1b2d",
  "status": "failed"
}

The same code appears as errorCode on the message and in the message.failed webhook.

Error codeHTTP statusWhat happenedRetry?
INVALID_RECIPIENT400The number or group is not a valid WhatsApp addressNo. Fix the recipient
MEDIA_ERROR400A media URL could not be fetched, or the file type is not supportedNo. Fix the URL or file
NOT_GROUP_MEMBER403The sender is not in the group or cannot post thereNo
NOT_CONNECTED409The sender phone is not connected to WhatsAppAfter reconnecting the number
NOT_AUTHORIZED409The WhatsApp session was logged outAfter reconnecting the number
RECIPIENT_NOT_REACHABLE422WhatsApp refused delivery to this recipientNo. They may need to message you first
RATE_LIMITED429WhatsApp is temporarily limiting this numberYes, later, with a new key
INTERNAL_ERROR500Unexpected error (the response body says InternalServerError)Yes, with backoff and a new key
SERVICE_UNAVAILABLE503WhatsApp or the send service is briefly unavailableYes, with backoff and a new key
TIMEOUT504WhatsApp did not answer in time, so the message may or may not have gone outCheck with the recipient before resending

A retry policy that does not double-send

The rule of thumb:

  • Network error or no response: you don't know if the send happened. Retry with the same idempotency key. If it did go through, you get the original message back.
  • 429 without a messageId (the API's own limiter): nothing was sent. Wait retryAfter seconds and retry with the same key.
  • Retryable send failure (RATE_LIMITED, SERVICE_UNAVAILABLE, InternalServerError): the old key now replays the failure, so retry with a new key.
  • Everything else: don't retry. Fix the cause.

In Python, building on send_whatsapp from Part 4:

import time

import requests

RETRYABLE_SEND_FAILURES = {"RATE_LIMITED", "SERVICE_UNAVAILABLE", "InternalServerError"}


def send_with_retry(to: str, content: str, event_id: str, max_attempts: int = 4) -> dict:
    # event_id identifies the thing you are notifying about, e.g. "order-4892-shipped".
    # Keep it under 60 characters so the suffix fits the 64-character limit.
    generation = 0
    for attempt in range(max_attempts):
        key = f"{event_id}:{generation}"
        try:
            return send_whatsapp(to, content, idempotency_key=key)
        except (requests.ConnectionError, requests.Timeout):
            time.sleep(2 ** attempt)  # outcome unknown: retry with the same key
            continue
        except requests.HTTPError as e:
            try:
                body = e.response.json()
            except ValueError:
                body = {}
            if not body:
                time.sleep(2 ** attempt)  # not a Chatmaid response (e.g. a proxy error)
                continue
            if e.response.status_code == 429 and "messageId" not in body:
                time.sleep(body.get("retryAfter", 60))  # API rate limit: nothing was sent
                continue
            if "messageId" in body and body.get("error") in RETRYABLE_SEND_FAILURES:
                generation += 1  # this key now replays the failure, so move to a new one
                time.sleep(5 * 2 ** attempt)
                continue
            raise
    raise RuntimeError(f"Sending {event_id} failed after {max_attempts} attempts")

The Node.js SDK already retries 429 responses (honoring retryAfter) and connection errors, twice by default. It does not retry its own 30-second timeout, and it never retries a failed POST on a 5xx. If a send times out, retry it yourself with the same idempotency key.

Two more catches. A RATE_LIMITED send failure is also a 429, and the SDK retries it with the same key, which only replays the failure. SDK errors also don't carry messageId, so branch on err.code instead: Rate limit exceeded is the API's own limiter (nothing was sent), while RATE_LIMITED, SERVICE_UNAVAILABLE and InternalServerError are send failures to retry later with a new key, such as order-4892-shipped:1. The HTTP status is in err.status:

// send-errors.mjs (Node.js 18+)
import { Chatmaid, ChatmaidError } from "@chatmaid/sdk";

const cm = new Chatmaid({ apiKey: process.env.CHATMAID_API_KEY });

try {
  await cm.messages.send({
    fromPhoneId: process.env.SENDER_PHONE,
    to: "+15557654321",
    content: "Your order #4892 has shipped.",
    idempotencyKey: "order-4892-shipped:0",
  });
} catch (err) {
  if (!(err instanceof ChatmaidError)) throw err;
  console.error(err.status, err.code, err.details);
}

The full list of error codes is in the API reference. The errors guide covers the response envelope and debugging.

Part 6: Webhooks (receiving messages)

Webhooks push events to your server: replies from customers, delivery and read receipts, failures, and phone connection changes. They are optional, since you can poll instead, but they are the only way to get replies and receipts as they happen.

Endpoint requirements

Your webhook endpoint must:

  • Use HTTPS (the dashboard rejects plain HTTP URLs)
  • Accept a POST with a JSON body
  • Return a 2xx status quickly. Chatmaid waits up to 30 seconds per attempt, so do the real work after you respond.

A failed delivery is retried about a minute later, then about five minutes after that, for three attempts in total. If your endpoint is down longer than that, those events are not redelivered, so reconcile with GET /v1/messages and GET /v1/messages/inbound after an outage.

Registering your webhook

In the dashboard, open Webhooks, enter your Webhook URL, tick the Events to receive, and click Configure Webhook. When you save, the dashboard shows your webhook secret, so copy it right away. Test Webhook sends a sample event to your endpoint.

EventFires when
message.sentWhatsApp accepted a message you sent through the API
message.deliveredThe message reached the recipient's device
message.readThe recipient read the message
message.failedA message you sent could not be delivered
message.receivedOne of your connected numbers received a message
message.outgoingSomeone sent a message from the connected phone itself, not through the API
phone.connectedA phone number connected to WhatsApp
phone.disconnectedA phone number disconnected from WhatsApp

Subscribe only to the events you handle. In the sandbox, each send fires message.sent, so you can test your receiver end to end before going live. message.received and message.outgoing only fire for live numbers.

Verifying signatures

Every delivery includes two headers, X-Chatmaid-Event and X-Chatmaid-Signature. The signature looks like t=1760870000,v1=<hex>: an HMAC-SHA256 of the timestamp, a dot, and the raw request body, keyed with your webhook secret.

Verify against the raw bytes, not re-serialized JSON. Parsing the body and calling JSON.stringify on it can change the bytes and break the check.

Always verify signatures. Without the check, anyone who finds your URL can post fake events to it.

If you don't use the SDK, here is the check in Python with Flask:

import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)
WEBHOOK_SECRET = os.environ["CHATMAID_WEBHOOK_SECRET"]
TOLERANCE_SECONDS = 15 * 60  # retries reuse the first attempt's signature


def verify_signature(raw_body: bytes, header: str, secret: str) -> bool:
    try:
        parts = dict(part.split("=", 1) for part in header.split(","))
        timestamp = int(parts["t"])
    except (ValueError, KeyError):
        return False
    if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
        return False
    signed = parts["t"].encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(parts.get("v1", "").encode(), expected.encode())


@app.post("/webhooks/chatmaid")
def chatmaid_webhook():
    signature = request.headers.get("X-Chatmaid-Signature", "")
    if not verify_signature(request.get_data(), signature, WEBHOOK_SECRET):
        abort(400)
    event = request.get_json()
    print(event["event"], event["data"])  # hand off to a queue in production
    return "", 200

Save it as webhook.py and run flask --app webhook run --port 3000.

Processing webhook events

With Node.js and Express, the SDK's verifyWebhook checks the signature and returns the parsed event. Note express.raw, which keeps the body as bytes:

// webhook.mjs (Node.js 18+): npm install express @chatmaid/sdk
import express from "express";
import { verifyWebhook, WebhookVerificationError } from "@chatmaid/sdk";

const app = express();
const seen = new Set(); // use your database in production

app.post("/webhooks/chatmaid", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = verifyWebhook({
      body: req.body,
      signature: req.header("x-chatmaid-signature"),
      secret: process.env.CHATMAID_WEBHOOK_SECRET,
      // Retries reuse the first attempt's signature and the last one can
      // arrive about 9 minutes later, so allow 15 minutes instead of the default 5.
      toleranceSeconds: 900,
    });
  } catch (err) {
    if (err instanceof WebhookVerificationError) return res.status(400).end();
    throw err;
  }

  // Respond first, then process. Retries can deliver the same event twice.
  res.status(200).end();

  const dedupeKey = `${event.event}:${event.data.messageId ?? event.timestamp}`;
  if (seen.has(dedupeKey)) return;
  seen.add(dedupeKey);

  setImmediate(() => handleEvent(event));
});

function handleEvent(event) {
  switch (event.event) {
    case "message.received":
      console.log(`Message from ${event.data.from}: ${event.data.content}`);
      break;
    case "message.delivered":
    case "message.read":
      console.log(`${event.data.messageId} is now ${event.data.status}`);
      break;
    case "message.failed":
      console.error(`${event.data.messageId} failed: ${event.data.errorCode}`);
      break;
  }
}

app.listen(3000, () => console.log("Listening on port 3000"));

One caveat as of October 2026: version 0.3.0 of the SDK, the current release on npm, does not recognize the message.outgoing event yet and rejects it as unknown. If you subscribe to message.outgoing, verify those deliveries with a manual check like the Python one above.

For inbound messages, data.from is the person who wrote to you and data.to is your connected number. For a group message, data.groupId holds the group ID, which you can pass as to to reply in the group. The webhook guide covers inbound processing in more depth, and the webhook docs show the event payloads.

Part 7: Checking message status

To check one message:

curl https://developers-api.chatmaid.net/v1/messages/msg_6f1c2a9e4b7d3e8a0c5f1b2d \
  -H "Authorization: Bearer $CHATMAID_API_KEY"
{
  "success": true,
  "data": {
    "id": "msg_6f1c2a9e4b7d3e8a0c5f1b2d",
    "from": "+15551234567",
    "to": "+15557654321",
    "isGroup": false,
    "groupId": null,
    "content": "Hello from the API!",
    "mediaUrls": [],
    "status": "delivered",
    "errorCode": null,
    "errorMessage": null,
    "createdAt": "2026-10-22T10:30:00.000Z",
    "sentAt": "2026-10-22T10:30:00.412Z",
    "deliveredAt": "2026-10-22T10:30:02.000Z",
    "readAt": null,
    "failedAt": null
  }
}

Status flow: pending → sent → delivered → read, or failed.

  • delivered and read come from WhatsApp receipts. read never arrives if the recipient has turned off read receipts, and a message can jump from sent straight to read.
  • In the sandbox, sends stop at sent, because there are no real receipts.

GET /v1/messages lists sent messages, newest first, with status, page and limit (up to 100) filters, plus phoneNumberId, which takes the number's dashboard ID from GET /v1/phone-numbers. GET /v1/messages/inbound lists received messages on live numbers.

Polling is fine for one-off checks. For real-time updates at scale, use webhooks.

Part 8: Rate limits

By default, the API allows 100 requests per minute. Every response tells you where you stand:

RateLimit-Policy: 100;w=60
RateLimit-Limit: 100
RateLimit-Remaining: 87
RateLimit-Reset: 42

RateLimit-Reset is the number of seconds until the window resets. Over the limit, you get a 429 with retryAfter in the body (see Part 5). Read these headers rather than hard-coding the number.

To check volume from code, GET /v1/account/usage?period=day (or week, month) returns message counts by status and API request counts. With the SDK, use cm.account.usage({ period: "day" }).

This limit is separate from WhatsApp's own limits on a number, which show up as the RATE_LIMITED send failure. The surest way to avoid those, and to keep the number in good standing, is to send only to people who expect to hear from you. Numbers that send unsolicited bulk messages to people who have not saved them are the ones that get restricted.

Part 9: Production deployment checklist

Before going live:

  • Subscribe to the $7.99/month plan, then create an sk_live_* key
  • Flip the sidebar switch from Test Mode to Live, then add the number under Phone Numbers and connect it by scanning the QR code (sandbox numbers don't carry over)
  • Swap sk_test_* for sk_live_* in your production environment variables
  • Make sure production idempotency keys can't collide with ones you used in sandbox tests
  • Verify webhook signatures against the raw body
  • Your account has one webhook URL for both environments, and payloads don't say which one an event came from, so ignore message IDs your app did not store
  • Send an idempotencyKey derived from your own event on every send
  • Retry only network errors, 429s and retryable send failures, and switch to a new key after a failed send
  • Alert on message.failed and phone.disconnected
  • Store each messageId with the related record in your database for audits and support
  • Send one real message from the live number to a phone you control
  • Review who you send to: only contacts who opted in

For WhatsApp messages in a SaaS onboarding flow, see WhatsApp for SaaS onboarding.

Part 10: The MCP server for development

If you build with Claude Code, Cursor or Windsurf, the Chatmaid MCP server lets your coding agent call the API directly while you work. For Claude Code:

claude mcp add chatmaid \
  --env CHATMAID_API_KEY=sk_test_xxx \
  -- npx -y @chatmaid/mcp

The agent gets tools such as send_message, get_message, list_messages, list_phone_numbers, get_phone_status, list_groups and get_usage. With a sandbox key, it can send test messages and check their status while it writes your integration, without you running curl by hand. Webhook payloads still arrive at your own endpoint.

Setup for other editors is in the MCP server guide and the MCP docs.

Frequently asked questions

Do I need Meta Business verification to use this API?

No. Chatmaid connects your own WhatsApp number as a linked device, the way WhatsApp Web does, so there is no Meta business verification or template approval. The tradeoff is that it is not Meta's WhatsApp Business API. This guide explains the difference.

Is there a free way to test the WhatsApp API?

Yes. The sandbox is free and uses sk_test_* keys. It returns real response shapes and fires message.sent webhooks, but never sends anything to WhatsApp. Delivery, read and incoming-message events only happen on a live number.

How much does it cost to go live?

$7.99/month flat for production with an sk_live_* key. The sandbox stays free. Current details are on the pricing page.

Why do I get a 404 "Phone number not found"?

Usually because the key and the number are in different environments. An sk_test_* key can't send from a live number, and the reverse is also true. Call GET /v1/phone-numbers with the same key to see which numbers it can use.

Can my number get banned for using the API?

With normal use, a ban is very unlikely. WhatsApp restricts numbers that behave like spam, such as sending unsolicited bulk messages to people who have not saved your number. Send only to people who expect to hear from you, and stop when someone asks you to.

Which languages can I use?

Any language that can make HTTPS requests. There is an official JavaScript and TypeScript SDK (@chatmaid/sdk) and an MCP server (@chatmaid/mcp). The examples above cover curl, Node.js, Python and Ruby.