• Virtual Marketer CRM logo
    Virtual Marketer CRMDocumentation
  • Introduction
Getting Started
  • Quickstart
  • Core Concepts
Connect your AI
  • Custom connector
  • CLI & editors
  • Rate limits
Integrations
  • MCP
  • Webhooks
  • Website leads
  • OpenAPI 3.1.0
  • N8N
  • WhatsApp API
App guide
  • Dashboard
  • Inbox
  • Records
  • Profile
  • Company
  • Lead sending
  • API Keys
  • Filter Syntax
Licensing & security
  • Architecture & Security
  • Go back
  1. Introduction
  2. WhatsApp API

WhatsApp API

Sending and reading WhatsApp messages from your own systems — connect a number, find the account id, send to a contact or a group, retry safely, and a complete Google Apps Script example.

Once a WhatsApp number is connected to your workspace, your own systems can send through it over REST: a form that should confirm a booking, a script that follows up on an order, a sheet that reminds a customer. This page covers the whole path — connecting, finding the id you need, sending to a person or a group, retrying safely, and the limits that apply.

Connect a number

WhatsApp is connected per person, from Profile → Connected accounts: scan a QR code with the phone that owns the number. Every user connects their own number; a connected number is never shared between users or workspaces, and reconnecting later never changes who it belongs to.

Find your connectedAccountId

Every call below needs the id of the connected WhatsApp account to send from:

curl -H "x-api-key: $VM_CRM_API_KEY" \
     https://crm.virtual-marketer.de/api/v1/messaging/connected-accounts

Look for the entry with "provider": "whatsapp" and read its id. Keep it — it does not change unless the number is disconnected and reconnected.

Send to a number

curl -X POST https://crm.virtual-marketer.de/api/v1/messaging/whatsapp/send \
     -H "x-api-key: $VM_CRM_API_KEY" \
     -H "Content-Type: application/json" \
     -d '{
       "connectedAccountId": "YOUR_CONNECTED_ACCOUNT_ID",
       "to": "+4915112345678",
       "text": "Ihre Bestellung ist unterwegs.",
       "idempotencyKey": "order-4821-shipped"
     }'

201 with { "externalMessageId": "...", "sentAt": "...", "replayed": false, "outcome": "sent" }. to is a phone number in E.164 form, or a WhatsApp chat id from the groups endpoint below. text is optional if you send at least one file in attachments (base64, filename + content_type + content, 25 MB in total) — the text then becomes the caption on the first file.

The message is ingested into your inbox the moment it sends, so it shows up in the same thread a reply from the customer would land in.

Send to a group

List the groups the connected number belongs to:

curl -H "x-api-key: $VM_CRM_API_KEY" \
     "https://crm.virtual-marketer.de/api/v1/messaging/whatsapp/groups?connectedAccountId=YOUR_CONNECTED_ACCOUNT_ID"

Returns { "items": [{ "id": "...@g.us", "name": "...", "participantCount": 12 }, ...] }. Use a group's id as to in the send call above — the CRM does not create groups, only sends into ones the number already belongs to.

Retrying safely: idempotencyKey

Every send call takes an optional idempotencyKey (1–128 characters, your own choice — an order id, a booking id, anything unique to that one message). Replaying the same key returns the original result again instead of sending a second time, so a retry after a timeout, a dropped connection, or a script that runs twice cannot duplicate a message. Without a key, every call is a new send.

A key already in use by a send that is still in progress answers 409 Conflict — wait a moment and retry with the same key rather than sending a different message under a new one. A replay (the key already completed) answers 200 instead of 201, with the same JSON body shape but only the identifiers — never the message text — plus "replayed": true.

outcome and what sent_unconfirmed means

Every response carries an outcome field:

  • "sent" — the normal case: the message went out and the CRM recorded it.
  • "sent_unconfirmed" — the message was delivered to WhatsApp, but the CRM failed to record it afterwards (a database hiccup, for example). The call answers 502 the first time this happens, with a message saying exactly that. Do not resend — retry with the same idempotencyKey instead; that retry finds the recorded attempt and returns it as a replay (200, "outcome": "sent_unconfirmed") rather than sending a second message.
  • "unknown" — the CRM could not tell whether the message reached WhatsApp before the send itself failed (a timeout, a dropped connection, a 5xx from the WhatsApp transport) — or the original request was abandoned mid-flight (the process restarted, for instance) and enough time passed that the CRM lost track of it. The first case also answers 502 the first time it happens, with a message saying the outcome is unknown. Either way, do not resend — retry with the same idempotencyKey; that retry returns the recorded "unknown" outcome as a replay (200) instead of sending a second message. This is the one case to stop and check by hand — look at the thread in the inbox before deciding whether to send again under a new key.

Rate limits

Sending is capped per user: 30 messages a minute, 300 an hour. A call over the limit answers 429 Too Many Requests with a Retry-After header — the seconds until the oldest message counted in that window ages out (at least 1 second, up to the window length). Wait for that window, then retry with the same idempotencyKey — retrying a send that already failed on the limit is exactly what the key is for. If Retry-After comes back large (the hourly limit can report up to an hour), do not spin-wait for it: stop after a bounded wait — the example below caps it at 6 minutes — and surface the failure instead of looping, keeping the same idempotencyKey for whenever the caller tries again.

(This is the limit on messages you originate. Reading and syncing a connected account has its own, separate budget — see Messaging rate limits.)

Cold outreach is not what this is for

WhatsApp access here runs over an unofficial transport (WAHA), not the WhatsApp Business Platform. Use it only for conversations the recipient already started or clearly consented to — a booking confirmation, a support follow-up, an order update. Messaging someone who has never written first risks the number being banned by WhatsApp, and unsolicited commercial messaging is unlawful under UWG §7 in Germany regardless of the transport. Do not build a cold-outreach flow against this endpoint.

A complete example: Google Apps Script

Confirming an order from a Google Sheet, with a retry loop that waits out a 429 and reuses the same idempotencyKey so a retry cannot double-send:

const CRM_API_KEY = PropertiesService.getScriptProperties().getProperty("VM_CRM_API_KEY");
const CONNECTED_ACCOUNT_ID = "YOUR_CONNECTED_ACCOUNT_ID";

function sendWhatsAppConfirmation(orderId, phoneE164, messageText) {
  const url = "https://crm.virtual-marketer.de/api/v1/messaging/whatsapp/send";
  const payload = {
    connectedAccountId: CONNECTED_ACCOUNT_ID,
    to: phoneE164,
    text: messageText,
    idempotencyKey: "order-" + orderId,
  };
  const options = {
    method: "post",
    contentType: "application/json",
    headers: { "x-api-key": CRM_API_KEY },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true,
  };

  const maxAttempts = 5;
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const response = UrlFetchApp.fetch(url, options);
    const status = response.getResponseCode();

    if (status === 201 || status === 200) {
      const body = JSON.parse(response.getContentText());

      if (body.outcome === "unknown") {
        // The CRM lost track of whether this actually sent. Do not retry automatically —
        // log it and have a human check the thread before deciding to send again.
        Logger.log("WhatsApp send outcome unknown for order " + orderId + " — check the thread by hand.");
        return body;
      }

      return body;
    }

    if (status === 409) {
      // Another attempt with the same idempotencyKey is still in flight — wait and retry
      // the SAME key rather than inventing a new one, which is exactly what would cause
      // a duplicate send.
      const retryAfter = Number(response.getHeaders()["Retry-After"] || 5);
      Utilities.sleep(Math.min(retryAfter, 60) * 1000);
      continue;
    }

    if (status === 429) {
      const retryAfter = Number(response.getHeaders()["Retry-After"] || 5);
      const maxAutoWaitSeconds = 360;

      if (retryAfter > maxAutoWaitSeconds) {
        throw new Error(
          "WhatsApp send for order " + orderId + " is rate limited for " + retryAfter +
            "s, longer than this script waits automatically. Retry later with the SAME idempotencyKey.",
        );
      }

      Utilities.sleep(retryAfter * 1000);
      continue;
    }

    if (status === 502) {
      // sent_unconfirmed: WhatsApp already has the message, the CRM just failed to record
      // it. Retry with the SAME key to fetch the recorded result — never send it again.
      Utilities.sleep(2000 * attempt);
      continue;
    }

    if (status >= 500) {
      Utilities.sleep(2000 * attempt);
      continue;
    }

    throw new Error("WhatsApp send failed (" + status + "): " + response.getContentText());
  }

  throw new Error("WhatsApp send did not succeed after " + maxAttempts + " attempts for order " + orderId);
}

function onOrderShipped() {
  const sheet = SpreadsheetApp.getActiveSheet();
  const row = sheet.getActiveCell().getRow();
  const orderId = sheet.getRange(row, 1).getValue();
  const phone = sheet.getRange(row, 2).getValue();

  sendWhatsAppConfirmation(orderId, phone, "Ihre Bestellung " + orderId + " ist unterwegs.");
}

Store the key with Project Settings → Script Properties, never inline in the script — the same rule as any other server-side secret.

Next

  • API keys: create, rotate, revoke.
  • Messaging rate limits: the separate budget for reading and syncing an account.
  • OpenAPI: the full REST reference with every field.
  • Webhooks: get notified when a reply comes back, instead of polling.
Connect a number
Find your connectedAccountId
Send to a number
Send to a group
Retrying safely: idempotencyKey
outcome and what sent_unconfirmed means
Rate limits
Cold outreach is not what this is for
A complete example: Google Apps Script
Next