01

Overview

Mossmoon ships a single REST API. The flagship product is WhatsApp lines — always-on, hosted WhatsApp connections an agency can embed in its own product. Base URL https://mossmoon.app/api/v1.

Every Mossmoon account gets one wallet and one or more API keys. Keys are shared between both products: the same mm_live_… token can provision an always-on WhatsApp line for an agency's customer and buy a verification SMS number.

All endpoints are JSON over HTTPS. Responses are JSON objects; errors include at least an error machine-readable code and a message human-readable string. The base URL is https://mossmoon.app/api/v1.

02

Authentication

Every authenticated request carries a Bearer token. Create one in Dashboard → API Keys; the raw key is shown exactly once. Keep it server-side; never embed in a browser bundle.

Authorization: Bearer mm_live_xxxxxxxxxxxxxxxxxxxxxxxx

Public endpoints (GET /services, GET /countries) accept anonymous requests; everything else returns 401 unauthorized without a valid key.

03

Errors

Status codes follow HTTP semantics. Error bodies are stable enough to switch on error programmatically.

CodeMeaningWhen you'll see it
400bad requestMalformed JSON or missing required field
401unauthorizedMissing/invalid/revoked API key
402insufficient_balance / billing_requiredWallet too low or no card on file
404not_foundResource doesn't exist or isn't yours
409conflictResource is in an incompatible state (e.g. line_not_ready)
422validationField present but value rejected
429rate_limitedHit a soft or hard cap; includes Retry-After
503no_proxy_in_countryPer-line provision only — no proxy inventory for the requested country. See retryable below.
5xxserver errorOn our side; safe to retry with backoff
{
  "error": "insufficient_balance",
  "message": "Wallet balance $2.10 is below the $15.00 per-line monthly price.",
  "balance_cents": 210,
  "required_cents": 5000
}

For per-line provision (POST /v1/wa/lines) we also emit a richer typed error when no proxy can be allocated. The retryable boolean tells your provision flow whether automatic retry has a reasonable chance of succeeding.

{
  "error": {
    "code": "no_proxy_in_country",
    "country": "AR",
    "hub": "US",
    "message": "No proxy inventory currently available for AR.",
    "retryable": true
  }
}

When retryable: true, retry with backoff (e.g. 30s → 2min → 5min) — inventory often drips back within minutes. When retryable: false, surface the error and offer the user a different country.

04

Wallet & billing

Your wallet funds both products. Top up by card via Square in the dashboard. Charges debit immediately and atomically; the wallet can never go negative.

GET/api/v1/balance
Your current balance in USD. { "balance": 12.34 }

SMS purchases debit at order time and auto-refund on expiry without a code. WhatsApp lines debit the first month at line.ready; provisioning is free if the link is never completed. See Pricing for the WA model.

WA · 01

WhatsApp — overview

Mossmoon's WhatsApp API lets agencies embed a  “Connect WhatsApp” flow in their product. The agency's end-customer scans a code with their own phone; their WhatsApp stays primary on their phone. Mossmoon runs a hosted connection that delivers their messages to your webhook in real time and sends on their behalf via the API.

The agency never touches the QR or the connection. The end-customer never installs anything new.

Each line ships in one of two shapes: messaging ($15/month) or messaging + calling ($20/month). Calling adds click-to-call voice through your customer's WhatsApp, with real-time call-state webhooks. You pick per line — mix and match under the same API key, same wallet, same webhook secret. See Calling for how to enable it (agency-wide or per-end-user opt-in) and Pricing for the billing model.

WA · 02

WhatsApp — connection flow

  1. Agency: POST /api/v1/wa/lines with { mode: "existing", webhook_url, agency_external_user_id }. Get back a line_id, connect_url, and a webhook_secret shown exactly once.
  2. Agency: embed the connect_url in your product (link button, QR code, or iframe). Forward your end-customer to it.
  3. End-customer: sees a Mossmoon-hosted page with a QR and scans it from WhatsApp on their phone. The hosted page walks them through the exact tap path on first load.
  4. Mossmoon: page shows ✓ Connected. Fires line.ready webhook to your webhook_url. Debits the first month from your wallet.
  5. Steady state: inbound messages stream as message.received webhooks. Outbound via POST /lines/:id/send. Delivery state via message.delivered webhooks.
WA · 03

WhatsApp — endpoints

Base path /api/v1/wa. All require the Authorization: Bearer header.

Provision a line

POST/api/v1/wa/lines
Create a new WhatsApp line for one of your end-customers. Returns 402 if your wallet can't cover the first month and the card on file can't be charged. Returns 409 if an active line already exists for the same agency_external_user_id (see Dedup, below).
POST /api/v1/wa/lines
Authorization: Bearer mm_live_...
Content-Type: application/json

{
  "mode": "existing",                          // currently the only supported value
  "webhook_url": "https://agency.example.com/wa/hook",
  "agency_external_user_id": "user_12345",     // STRONGLY RECOMMENDED — enables dedup,
                                               // see below. Opaque to us; echoed back in webhooks.

  // ── Region hints (both optional; see "Region routing" below) ──
  "country_hint": "AE",                        // ISO-3166-1 alpha-2. We use this to
                                               // assign a country-matched proxy IP.
  "customer_phone_e164": "+971501234567",      // E.164. Alternative to country_hint —
                                               // we parse the country from the number.

  // ── Calling (all optional; default = messaging-only $15/mo) ──
  "calling_enabled": false,                    // true → provision into the call-capable
                                               // pool, line bills $20/mo. End-user gets no
                                               // choice. Use for the "Bundle" model where
                                               // calling is on for every line you provision.
  "allow_calling": false,                      // true → show the "Make and receive calls?"
                                               // toggle on the connect page. End-user picks
                                               // before scanning. Lines they enable bill $20;
                                               // lines they skip stay $15. Ignored when
                                               // calling_enabled is true. Use for the "Flex"
                                               // model. See WA · 10 — Calling.

  // ── Branding (optional) ──
  "logo_url": "https://cdn.agency.example.com/logo.png"
                                               // https URL to a square logo PNG/JPEG/WebP/SVG,
                                               // <= 200KB. We fetch ONCE at line creation,
                                               // validate, and embed it at the center of the
                                               // pairing QR. Subsequent QR rotations keep
                                               // the same logo (no upstream re-fetch).
                                               // Omit for the default unbranded QR.
}

→ 201 Created
{
  "line_id": "wa_8f3c2e1a",
  "status": "pending_link",
  "mode": "existing",
  "calling_enabled": false,                    // resolved value; true if you forced it on,
                                               // or if the end-user toggled it on (Flex).
                                               // Still false at line.ready means messaging-only.
  "monthly_price_cents": 1500,                 // 1500 for messaging, 2000 with calling.
                                               // Updated automatically when calling is toggled
                                               // by the end-user on the connect page.
  "connect_url": "https://mossmoon.app/wa/connect/<token>",
  "webhook_secret": "wsec_…",                  // store this; cannot be retrieved later, only rotated
  "created_at": "2026-05-30T18:42:11.123Z"
}

Dedup behaviour. When you pass agency_external_user_id, a second POST for the same id while a line is still active (status: pending_link · provisioning · ready · disconnected · recovering) returns 409 Conflict with the existing line's connect_url. Re-show that URL to the customer instead of provisioning a new line. Lines in terminal states (released · banned · failed) don't block. Omit agency_external_user_id entirely and there's no dedup — every POST creates a new billable line. Pass it on every call to avoid accidental double-charges.

Branding the QR. Pass logo_url on line creation to embed your logo at the center of the pairing QR. The QR uses high error correction whenever a logo is present, so scanning works the same as the unbranded version. Constraints: https:// source URL, payload up to 200KB, and content-type must be image/png, image/jpeg, image/webp, or image/svg+xml. Square logos render best. The logo is rendered black-and-white QR with your mark in the middle; we do not currently support changing the QR or background color (keeps the scan reliable across every camera). Omit logo_url for an unbranded QR.

Region routing. Each line gets a dedicated residential-ISP IP that Mossmoon's WhatsApp connection uses. We pick the IP's region from one of three hubs — US (Americas), NL (Europe, UAE, Middle East, Africa), or SG (Asia & Pacific) — based on the country we can determine for the end-customer. The resolution chain runs in this order, first one wins:

  1. country_hint on this body (most reliable — pass it if you collect customer country at signup).
  2. customer_phone_e164 on this body (we parse the country code).
  3. When the end-customer loads the connect page (or bare iframe), we run a GeoIP + VPN-detection check on their request IP. If GeoIP returns a clean country we silently migrate the line to the matching hub before the QR appears (“Setting up…” spinner for ~5–10s). If the IP is flagged as a VPN / proxy / hosting connection we render a country dropdown and let the customer confirm.
  4. Falls back to NL for unmapped or indeterminate countries — safe default for any European/MEA customer, slight geographic mismatch but fine.

Geographic match to the customer's phone is a hygiene signal for WhatsApp's anti-abuse, not a strict requirement. Layer 3 only fires for embeds that involve a page render — the default connect page and Option 1 (bare iframe). Raw-image embeds (Option 2) skip Layer 3 since there's no UI moment to refine, so on that path you should always pass country_hint or customer_phone_e164.

POST /api/v1/wa/lines   // same agency_external_user_id, line already active

→ 409 Conflict
{
  "error": "line_already_exists",
  "message": "An active line already exists for agency_external_user_id 'user_12345'.
              Reuse the existing connect_url, release the line before creating a new one,
              or pass a different agency_external_user_id (e.g. 'user_12345_2') to
              intentionally provision a second line.",
  "existing_line": {
    "line_id": "wa_8f3c2e1a",
    "status": "ready",
    "phone_number": "+1...",
    "connect_url": "https://mossmoon.app/wa/connect/<token>",
    "agency_external_user_id": "user_12345",
    "created_at": "2026-05-30T18:42:11.123Z"
  }
}

Retrieve a line

GET/api/v1/wa/lines/:id
Snapshot of one line. The qr field is populated whenever a fresh scan can link or re-link the device — status pending_link or provisioning for first-time setup, disconnected or recovering for re-link after the customer unlinks Mossmoon from their phone or after the 14-day rule. Suppressed when the line is ready (QR would be stale) or in a terminal state. For embedding the QR directly in your UI without parsing this response, see Embed the QR.

Pairing code (alternative to QR)

POST/api/v1/wa/lines/:id/pairing-code
Returns an 8-character code the end-customer can type into WhatsApp's Link a device → Link with phone number screen, instead of scanning the QR on the connect page. Useful when the device WhatsApp is installed on can't conveniently scan a QR — accessibility flows, kiosk setups, or any automation that types codes rather than reading screens. Only valid while the line is in pending_link; codes expire in ~60s, just call again to mint a fresh one.
POST /api/v1/wa/lines/wa_8f3c2e1a/pairing-code
Authorization: Bearer mm_live_...
Content-Type: application/json

{
  "phone_number": "+14155551234"               // E.164 of the WhatsApp number being linked
}

→ 200 OK
{
  "line_id": "wa_8f3c2e1a",
  "code": "ABCD1234",
  "expires_in_seconds": 60
}

→ 409 Conflict   // line isn't in pending_link (already ready, released, etc.)
{
  "error": "line_not_pending",
  "status": "ready"
}

End-customer steps. On the phone where WhatsApp is installed: open WhatsApp → Settings → Linked Devices → Link a device → tap Link with phone number instead → enter the 8-character code. line.ready fires when linking completes — same as the QR path.

After a disconnect. When a line drops back to pending_link after a line.disconnected event, call this endpoint again to re-link the same line_id. The phone number, webhook secret, and conversation history are all preserved.

Rate limits. WhatsApp throttles repeated link attempts on the same number (~4-5 per hour). Persistent 4xx responses after several rapid retries usually mean the number is in a temporary cooldown — back off for an hour before retrying.

Embed the QR in your own UI

The Mossmoon-hosted connect page is the default and the easiest path — drop your connect_url in front of the end-customer and you're done. When you want the QR to live inside your product without a redirect or our branding, three options. All three use the same connect_token already encoded in the connect_url you got back from POST /api/v1/wa/lines — no API key required, the token itself is the credential.

Option 1 — Bare connect page (iframe)

<iframe
  src="https://mossmoon.app/wa/connect/<token>?bare=1"
  width="320"
  height="440"
  style="border: 0; background: transparent;"
></iframe>

Same page as the connect URL, but with header, instructions, footer, and background stripped — just the QR (or a one-line status when the line is ready/terminal). Polls on its own and rotates the QR automatically, so you don't need to write any client code. Easiest path when you want a fully working embed in under a minute.

Sizing. The QR itself is 288×288px, but the iframe also briefly renders a “Setting up your secure connection…” spinner during region auto-detection (see Region routing), or a small country dropdown if the customer's IP is on a VPN. Both need a little vertical room. We recommend height: 440px (or min-height: 440px if your layout supports it) so nothing scrolls. Width 320–360px is fine. Customers who pass country_hint on POST /lines skip both states, in which case 320×360 also works.

Option 2 — Raw image (PNG or SVG)

<!-- SVG is preferred — crisp at any size, smaller payload -->
<img src="https://mossmoon.app/api/wa/connect/<token>/qr.svg"
     alt="Scan with WhatsApp" />

<!-- PNG for email, PDF, or any context that can't render SVG -->
<img src="https://mossmoon.app/api/wa/connect/<token>/qr.png"
     alt="Scan with WhatsApp" />

Returns the raw image bytes — no JSON wrapper, no base64. Drop into an <img> tag, a CSS background-image, an email, a PDF — wherever you need a QR image. Returns 404 when the line is ready or in a terminal state (no scannable QR exists). Cache-Control: no-store on every response, so the browser won't serve a stale rotation.

One-shot, not auto-updating. The QR rotates server-side every ~20s for security. The image endpoint always returns the current rotation at request time — agencies wanting live updates should either refetch on a timer (your code) or use the bare page above (handles it for you). For most flows, a single fetch when the customer opens the screen is enough — they scan within seconds.

Region routing on this path. Because the browser is fetching bytes (not loading our page), we can't infer the customer's country at image-fetch time. Always pass country_hint or customer_phone_e164 on POST /api/v1/wa/lines when you use this embed mode — otherwise the line falls back to the NL default proxy region. Options 1 and 3 don't need this because they involve a page load we can GeoIP from.

No automatic idle prompt. Option 1 (bare iframe) hides the QR after 3 minutes of inactivity and shows a “Still there?” refresh button — we render that automatically because we own the markup. On this path the agency owns the markup, so the QR just stays visible until the line auto-releases. If the customer dismisses their tab or hasn't scanned within 5 minutes, the line is released server-side (backend cleanup, see below) and qr.png / qr.svg start returning 404. Handle this client-side with <img onerror> or by polling the status endpoint alongside — or use Option 1 if you want the built-in UX.

Backend cleanup is automatic on every path. If a customer dismisses the page or never scans, the line transitions to released — eagerly on page dismiss (within ~500 ms via navigator.sendBeacon), or otherwise within 5 minutes via the backend sweep. The assigned proxy IP returns to the regional pool automatically (DB trigger). Nothing sits indefinitely. If the customer comes back to a released line, Mossmoon respawns a fresh QR under the same line.id + connect token — agencies don't need to re-provision after every refresh.

Option 3 — Roll your own

Poll GET /api/v1/wa/lines/:id and render the qr field (a base64 PNG data URL) however you want. Useful when you want to mix the QR with other line state (phone number, status pill, agency-side UX) in a single render. Polling cadence: every 3-5 seconds is fine; the field updates as the QR rotates and clears to null when the line goes ready. You also see status flip from pending_link to released when the customer dismisses the page or the 5-minute backend cleanup fires — you control the UX from there (refresh button, expiry message, etc.).

List your lines

GET/api/v1/wa/lines
Query params: status, mode, limit (default 50, max 200).

Send a message

POST/api/v1/wa/lines/:id/send
Queue an outbound message. 202 means accepted, not delivered — track delivery via message.delivered webhooks. Pass either to (a phone number) or to_wa_id (a WhatsApp JID, used for replying to LID-routed contacts whose real phone is unknown — see "Contact identity" above).text is always required.
POST /api/v1/wa/lines/wa_8f3c2e1a/send
Authorization: Bearer mm_live_...
Content-Type: application/json

{
  "to": "+15709302189",                        // OR to_wa_id, see below
  "text": "Hi Sarah — following up on the listing."
}

→ 202 Accepted
X-Mossmoon-Daily-Limit: 300
X-Mossmoon-Daily-Remaining: 247
X-Mossmoon-Daily-Reset: 2026-05-31T00:00:00.000Z

{
  "message_id": "wamsg_a1b2c3d4e5f6",
  "status": "queued",
  "line_id": "wa_8f3c2e1a",
  "to": "+15709302189",
  "created_at": "2026-05-30T19:20:33.456Z"
}

Replying to a LID-only contact. When an inbound arrives with from_e164: null (the contact is LID-routed and we have no resolved phone), pass to_wa_id directly from the inbound's from_wa_id. Mossmoon routes by the WA ID, so the reply still lands on the right contact.

POST /api/v1/wa/lines/wa_8f3c2e1a/send
Authorization: Bearer mm_live_...

{
  "to_wa_id": "36554986758374@lid",            // raw from inbound webhook
  "text": "Thanks for reaching out — how can I help?"
}

Recommended pattern in your auto-reply handler: prefer to: from_e164 when it's set; fall back to to_wa_id: from_wa_id when from_e164 is null. That handles both resolved and unresolved contacts with the same call shape.

Sending into a group chat. Pass the group's JID (ends in @g.us) as to_wa_id. Capture it from from_wa_id on the first group inbound, or from the group.joined webhook. See the Group chats section for the full contract.

POST /api/v1/wa/lines/wa_8f3c2e1a/send
Authorization: Bearer mm_live_...

{
  "to_wa_id": "[email protected]",
  "text": "Morning team — here's the brief."
}

Reply to a specific message

Pass quoted_message_id on POST /send to render the send as a reply. The recipient's WhatsApp shows the grey quote bubble above your message — same UX as long-pressing → Reply on the phone. Works for DMs and groups, text and media. Pair this with the quoted_message_id field on inbound message.received events to thread a real conversation: a user replies to your msg X → you receive an inbound with quoted_message_id: "X" → your bot replies threading the same id.

POST /api/v1/wa/lines/wa_8f3c2e1a/send
Authorization: Bearer mm_live_...

{
  "to": "+15559998888",
  "text": "Yes — Tuesday at 2pm works.",
  "quoted_message_id": "[email protected]_3EB0E5496B952338D56182"
}

The id must be a message_id from a message previously delivered in the same chat (yours from message.delivered or theirs from message.received). If the id isn't quotable from this chat, WhatsApp silently drops the quote but still delivers the message — no error.

Send media (images, voice notes, documents)

Pass media_url instead of (or alongside) text to attach an image, video, voice note, or document. We HEAD-fetch the URL to validate it's reachable, under 50 MB, and a supported MIME — then pass the URL to the host which downloads + sends. The URL must be https:// and publicly fetchable (we can't reach localhost, private IPs, or auth-protected CDNs).

Media typeHow it sendsNotes
ImageInline preview in the chatimage/jpeg, image/png, image/webp, image/gif. text becomes the caption.
VideoInline player in the chatvideo/mp4, video/3gpp. text becomes the caption.
Voice notePlays as a PTT recording (one tap, no download)Pass voice_note: true. media_url must be audio/ogg / audio/mpeg. No caption — voice notes don't support text.
DocumentDownload chip with filenameapplication/pdf, docx, xlsx, etc. Use media_filename for the displayed name. Pass as_document: true to force-render an image/video as a document instead of inline preview.
// Image with caption
POST /api/v1/wa/lines/wa_8f3c2e1a/send
{
  "to": "+15709302189",
  "media_url": "https://your-cdn.com/listings/apt-123.jpg",
  "text": "Here's the 2-bed in Marina — viewing slots next week?"
}

// Voice note (PTT-style, plays inline)
POST /api/v1/wa/lines/wa_8f3c2e1a/send
{
  "to_wa_id": "36554986758374@lid",
  "media_url": "https://your-cdn.com/ai-voice/reply-9f8a.ogg",
  "voice_note": true
}

// PDF document with explicit filename
POST /api/v1/wa/lines/wa_8f3c2e1a/send
{
  "to": "+15709302189",
  "media_url": "https://your-cdn.com/quotes/q-2026-06-001.pdf",
  "media_filename": "Quote-2026-06-001.pdf",
  "text": "Quote attached — let me know if anything looks off."
}

Size caps: 50 MB hard cap on our side; WhatsApp's own caps are stricter (≈5 MB images, ≈16 MB video/audio, ≈100 MB documents). Outside our 50 MB cap → 400 at /send. Outside WhatsApp's cap → wa-host returns send_failed at delivery time. Each media send counts as 1 against the per-line daily quota, same as a text message.

Interactive buttons and list messages (coming soon). Interactive button and list message types are on our near-term roadmap. We're working through the account-safety engineering required to send these at API-driven volume without raising ban risk on customer numbers. Short-term recipient UX isn't worth long-term account health if it costs you the line. For tap-to-action UX today: send a URL in text and control the auto-rendered preview card via OG tags (og:title, og:description, og:image) on your destination page. A well-designed OG image is visually indistinguishable from a button. Or use a PDF document (as_document: true with a branded media_filename) for a tap-to-open tile. Or attach a designed image with the action URL in the caption. These render consistently across every WhatsApp client and platform, including group chats.

Send a voice note from a browser recording

POST/api/v1/wa/lines/:id/voice-notes
Same outcome as /send with voice_note: true, but you upload the audio bytes directly instead of hosting them at a URL. Useful when your CRM records audio in the browser (hold-to-record button) and wants to ship the recording straight to WhatsApp without first storing it on your own CDN.

Send as multipart/form-data with one file field named audio. Accepted containers: audio/ogg, audio/webm, or audio/mp4 — that covers every modern browser's MediaRecorder default output (Firefox emits OGG, Chrome/Edge emit WebM, Safari emits MP4). We transcode the upload to OGG/Opus server-side before delivery, so the recipient always sees a clean voice-note bubble. Hard cap 10 MB per upload (5 min of voice is ~1.5 MB).

POST /api/v1/wa/lines/wa_8f3c2e1a/voice-notes
Authorization: Bearer mm_live_...
Content-Type: multipart/form-data

Fields:
  to       +15709302189            (E.164 of the recipient; OR use to_wa_id)
  audio    <Blob>                  (the recorded audio; one file field)

→ 202 Accepted
{
  "message_id": "[email protected]_3EB0C...",
  "status": "queued",
  "line_id": "wa_8f3c2e1a",
  "to": "+15709302189",
  "created_at": "2026-06-02T05:54:12.180Z"
}

Example browser code — record on mouse-hold, ship on release. MediaRecorder picks the best supported MIME automatically:

const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
const chunks = [];
recorder.ondataavailable = (e) => chunks.push(e.data);
recorder.onstop = async () => {
  const blob = new Blob(chunks, { type: recorder.mimeType });
  const fd = new FormData();
  fd.append("to", "+15709302189");
  fd.append("audio", blob, "voice-note");
  const res = await fetch(`/api/v1/wa/lines/${lineId}/voice-notes`, {
    method: "POST",
    headers: { Authorization: `Bearer ${apiKey}` },
    body: fd,
  });
  // 202 → message_id in response body; delivery confirmation via webhook
  stream.getTracks().forEach((t) => t.stop());
};
recorder.start();
// later, on mouseup:
recorder.stop();

When to use which voice-note path: /send with voice_note + media_url when the audio already lives on a CDN (AI TTS output, voicemail recordings on your storage). /voice-notes multipart when the audio was just recorded in the browser and you don't want to upload it somewhere yourself first. Same per-line daily quota applies to both.

Presence (typing, recording, read)

POST/api/v1/wa/lines/:id/presence
Set a chat-level presence indicator: typing dots, recording-audio dots, clear, or mark-all-as-read. Useful while your AI is generating a reply so the end-customer sees a "..." instead of silence. Accepts the same to / to_wa_id dual routing as /send — use to_wa_id when firing typing dots at a LID-routed contact whose phone is unresolved.
POST /api/v1/wa/lines/wa_8f3c2e1a/presence
Authorization: Bearer mm_live_...
Content-Type: application/json

{
  "to": "+15709302189",   // OR to_wa_id, see /send → "Replying to a LID-only contact"
  "state": "typing"       // "typing" | "recording" | "stop" | "read"
}

→ 202 Accepted
{ "ok": true }
stateEffectNotes
typingShows "..." dots in the end-customer's chat.Auto-expires after ~25s on WhatsApp's side. Call again to refresh, or call `stop` (or `send` a message) to clear.
recordingShows "recording audio..." in the end-customer's chat.Same auto-expiry as typing.
stopClears whichever indicator is up.Cheap. Idempotent. Call it before send for cleanest UX.
readMarks all unread messages in the chat as read (blue checks).Chat-level — WhatsApp's API can't mark a single message read.

Recommended pattern when your AI is composing a reply:

// Same pattern as /send — pick to vs to_wa_id once, reuse for both calls.
const target = event.data.from_e164
  ? { to: event.data.from_e164 }
  : { to_wa_id: event.data.from_wa_id };

await mossmoon.presence(lineId, { ...target, state: "typing" });
const reply = await ai.generate(...);     // 5-10s
await mossmoon.presence(lineId, { ...target, state: "stop" });
await mossmoon.send(lineId, { ...target, text: reply });
// Optional:
await mossmoon.presence(lineId, { ...target, state: "read" });

Presence calls don't count against the per-line daily send quota — they're tiny live-channel packets, not encrypted messages. Still, don't spam them: continuous "..." for minutes looks unnatural and could draw scrutiny from WhatsApp's abuse heuristics.

Profile (name, about, picture)

GET/api/v1/wa/lines/:id/profile
Read the linked WhatsApp account's current profile. Pulled live from WhatsApp each call — useful for pre-filling an edit UI before calling PATCH below. Any field that isn't set on the account returns null.
GET /api/v1/wa/lines/wa_8f3c2e1a/profile
Authorization: Bearer mm_live_...

→ 200 OK
{
  "line_id": "wa_8f3c2e1a",
  "account_type": "business",
  "name": "internal-pushname",
  "about": "Open daily 9-6. Tap to chat about a listing.",
  "picture_url": "https://pps.whatsapp.net/v/t61.../n.jpg?ccb=11-4&oh=...",
  "business_name": "Acme Realty"
}
FieldNotes
account_type"personal" or "business". Branch your UI on this — the editable fields differ.
nameThe WhatsApp display name. On personal accounts this is what recipients see. On Business accounts the visible name is the business profile name (also returned as business_name); the underlying display name is still editable via PATCH for unverified Business accounts (subject to WA's rate limits). Verified Business accounts cannot have their name changed from a linked device.
aboutThe "About" line. Editable on both account types.
picture_urlSigned WhatsApp CDN URL — short-lived. Download server-side if you want to display it long-term.
business_name(Business only — null on personal.) The name recipients see in their chats. Unverified Business accounts can sometimes change it by PATCHing `name` (subject to rate limits). Verified Business accounts (green check) can only change it from the WhatsApp Business app on the primary device.
PATCH/api/v1/wa/lines/:id/profile
Edit the linked WhatsApp account's pushname, "about" line, and avatar. Any subset of fields can be sent in one call; omitted fields are left unchanged. Same operation as the customer changing them from WhatsApp Web on a desktop — no extra anti-abuse exposure beyond what messaging already has.
PATCH /api/v1/wa/lines/wa_8f3c2e1a/profile
Authorization: Bearer mm_live_...
Content-Type: application/json

{
  "name": "Acme Realty",                                  // 1-25 chars
  "about": "Open daily 9-6. Tap to chat about a listing.",// 0-139 chars
  "picture_url": "https://cdn.agency.example/acme.png"    // square JPEG/PNG ≥192px
}

→ 200 OK (Business account — name rejected by WA, others applied)
{
  "line_id": "wa_8f3c2e1a",
  "account_type": "business",
  "applied": { "name": false, "about": true, "picture": true },
  "rejections": {
    "name": {
      "reason": "name_rejected",
      "message": "WhatsApp rejected the name change. WA Business accounts have stricter rate limits than personal (often only one name change per few days), and verified Business accounts (green check) cannot change the displayed name from a linked device at all — that requires the WhatsApp Business app on the primary device. If you've changed the name recently, wait a few days and retry."
    }
  }
}
FieldTypeNotes
namestring, 1-25 charsWhatsApp display name. On personal and unverified Business accounts WA will usually accept the change (subject to rate limits — Business is stricter, often only one change per few days). Verified Business accounts (green check) cannot be renamed from a linked device — only the WA Business app on the primary device. Rejections come back with reason name_rejected.
aboutstring, 0-139 charsThe "About" line. Empty string clears it. Works on both account types.
picture_urlhttps URLSquare JPEG/PNG, ≥192×192px, under 5MB. We fetch it server-side and upload to WhatsApp; the URL itself isn't stored. Works on both account types.

The endpoint always returns 200 when the line was reachable. Inspect applied.<field> (boolean) and rejections.<field> ({ reason, message } — only present when rejected) per field to know what took. Branch your UI on reason rather than parsing message.

Rejection reasons

reasonWhen
name_rejectedWA refused the name change. We can't distinguish causes — likely a per-account rate limit (Business stricter than personal, often once per few days), a verified Business account (locks the name to the WA Business app), or a forbidden character. The message adapts based on account_type.
about_rejectedWA rejected the about line. Usually a forbidden character or a value past WA's server-side limit.
picture_rejectedWA rejected the picture. Requirements: square, ≥192×192, JPEG/PNG, <5MB. Most rejections are size-related.

Setting structured business-profile fields (description, hours, address, category) is not supported — those have no first-class setters in the WA Web protocol and would require fragile workarounds. Use the WhatsApp Business app on the primary device for those.

Contacts (line owner's address book)

Returns the linked WhatsApp account's saved contacts. Use case: if you run an AI auto-responder, let the line owner pick which personal contacts the AI should never reply to (their mom, their doctor) — feed this endpoint into a picker, store the exclusion list on your side.

The roster syncs in the background after line.ready — the link flow itself is not slowed down. On a freshly-scanned line, expect sync_status to start as pending/syncing and become complete a few seconds later. Either listen for line.contacts_synced or poll.

GET/api/v1/wa/lines/:id/contacts
List the line owner's saved WhatsApp contacts. Only entries they have saved on their phone — random message senders are NOT included.
GET /api/v1/wa/lines/wa_8f3c2e1a/contacts
Authorization: Bearer mm_live_...

→ 200 OK
{
  "line_id": "wa_8f3c2e1a",
  "sync_status": "complete",
  "contacts_synced_at": "2026-06-09T18:00:12.345Z",
  "contacts_count": 247,
  "contacts": [
    { "wa_id": "[email protected]", "e164": "+15551234567", "name": "Mom" },
    { "wa_id": "[email protected]", "e164": "+15559998888", "name": "Dr. Patel" },
    { "wa_id": "abc123@lid",       "e164": null,           "name": "Sarah K" }
  ]
}
FieldNotes
sync_statuspending (never synced), syncing (in progress), complete, or failed.
contacts_synced_atISO timestamp of the last successful sync, or null if never synced.
contacts_countTotal contacts for this line (NOT capped by the optional ?limit=N query param).
wa_idStable WhatsApp identifier. <digits>@c.us for standard contacts, <digits>@lid for privacy-routed.
e164Phone number, or null for LID-routed contacts where WhatsApp doesn't surface a phone.
nameThe line owner's saved label, falling back to the contact's pushname. null when neither is known.

Optional ?limit=N (1-5000). Default and hard cap is 5000 — large enough for any realistic picker UI. No profile pictures, no "about" lines — picker-grade data only.

POST/api/v1/wa/lines/:id/contacts/resync
Re-walk the contact roster. Surface this as a "Resync contacts" button in your UI for when the line owner has added new contacts on their phone since the initial sync. Returns immediately; listen for line.contacts_synced for the completion signal.
POST /api/v1/wa/lines/wa_8f3c2e1a/contacts/resync
Authorization: Bearer mm_live_...

→ 202 Accepted
{
  "line_id": "wa_8f3c2e1a",
  "ok": true,
  "contacts_count": 251,
  "synced_at": "2026-06-09T18:15:33.812Z"
}

Rate limited to one resync per 5 minutes per line. Repeat calls inside the cooldown return 429 with retry_after_seconds + standard Retry-After header so you can disable the button.

Message history & reconciliation

The reconciliation surface for inbound. If your webhook handler ever drops a message.received — deploy gap, downstream outage, code bug — walk this endpoint with ?since=<last_seen> and replay anything you missed. Every message we have on file for the line is returned, regardless of whether the original webhook reached you. Dedupe by message_id.

GET/api/v1/wa/lines/:id/messages
Inbound & outbound history. Cursor-paginated, filterable by direction, time range, and counterparty.
GET /api/v1/wa/lines/wa_8f3c2e1a/messages?direction=inbound&since=2026-05-30T00:00:00Z&limit=100
Authorization: Bearer mm_live_...

→ 200 OK
{
  "line_id": "wa_8f3c2e1a",
  "messages": [
    {
      "message_id": "wamsg_xyz",
      "line_id": "wa_8f3c2e1a",
      "direction": "inbound",
      "counterparty": "+15559998888",
      "text": "Hi, interested in the apartment",
      "media": null,
      "ack_status": null,
      "failure_reason": null,
      "quoted_message_id": null,
      "replay": false,
      "ts": "2026-05-30T19:15:09.001Z"
    }
  ],
  "next_cursor": null
}
Query paramDefaultDescription
directionbothinbound, outbound, or both.
sinceISO timestamp. Returns messages strictly newer than this.
untilISO timestamp. Returns messages strictly older than this.
counterpartyE.164 (+15559998888) or WA JID ([email protected] for groups). Filters to one conversation.
limit1001-200.
cursorOpaque token from a previous response's next_cursor.

Ordering. Newest-first by default. When since is provided we flip to oldest-first so reconciliation walks forward through the gap in delivery order.

Pagination. next_cursor is null when the result set is exhausted; otherwise pass it back as ?cursor=… for the next page.

Messages are retained 30 days from ts. Persist anything you need longer-term in your own store.

Force-reconcile pending inbound

The recovery escape hatch. Every Mossmoon line runs a heartbeat sweep every 5 minutes that pings WhatsApp's server for any messages it's been holding queued and replays whatever it finds through the normal message.received webhook. This endpoint asks us to run that sweep now instead of waiting up to 5 minutes — useful when you have a specific reason to believe a message is sitting queued (your tracking shows an outbound went through but the expected reply never arrived, etc).

You do not need to call this on every page load or webhook tick. The periodic sweep handles the common case. Reach for /sync when something specific feels off.

POST/api/v1/wa/lines/:id/sync
Trigger an immediate recovery sweep on this line. Any recovered inbound fires the normal message.received webhook.
POST /api/v1/wa/lines/wa_8f3c2e1a/sync
Authorization: Bearer mm_live_...

→ 200 OK
{
  "line_id": "wa_8f3c2e1a",
  "swept": true,
  "messages_recovered": 0,
  "last_swept_at": "2026-06-18T15:42:01.122Z",
  "status": "force"
}
FieldNotes
swepttrue means we ran a fresh sweep. false means a sweep ran within the last 30 seconds and we returned its cached result — calling /sync in a tight loop is safe but doesn't multiply work.
messages_recoveredHow many inbound this sweep surfaced (and fired message.received for). 0 is the common case.
last_swept_atISO timestamp of when the most recent sweep finished.
status'force' for a fresh sweep, 'recent' if coalesced, 'in_flight' if a sweep was already mid-run.

Recovered messages fire the same message.received webhook a live one would — your handler doesn't need a separate branch. Dedup by message_id; we never double-fire the same id even if the live event and the sweep race.

Error responses: 409 line_not_ready if the line isn't currently linked. 503 host_unavailable if our host can't be reached.

Lazy-refresh a connected device

Re-establishes the WhatsApp Web runtime session for a paired line without asking the customer to rescan. The proxy IP is preserved (we pin proxies per tenant, so the underlying network identity doesn't change), session auth is persisted from prior pairing, and the customer's phone stays linked throughout.

Reach for this when you want an already-paired line to pick up runtime-level changes: new webhook events we've added since the line was scanned, protocol updates, proxy warm-ups. Also useful as a diagnostic tool if a line feels sluggish and you want to force a fresh runtime cycle before considering harder recovery.

Concrete example: activating from_me on existing lines. The from_me: true variant of message.received (owner-typed messages arriving from the account owner's phone, the piece that lets your dashboard mirror both sides of the conversation) fires automatically only on lines paired after this feature shipped. If your customers scanned before then, their lines won't emit from_me: true until you call POST /lines/:id/refresh once against each. New lines are unaffected.

POST/api/v1/wa/lines/:id/refresh
Trigger a lazy-refresh of this line's runtime session. Idempotent within a short coalescing window.
POST /api/v1/wa/lines/wa_8f3c2e1a/refresh
Authorization: Bearer mm_live_...

→ 202 Accepted
{
  "line_id": "wa_8f3c2e1a",
  "status": "refreshing",
  "note": "The runtime session is being re-established on the existing proxy IP. The customer's device stays paired; no rescan is required in the normal case."
}

Small chance Meta asks for a rescan. Most refreshes complete with no customer-visible effect. In rare cases WhatsApp's server treats the fresh session handshake as a re-verification opportunity and asks the linked device to rescan. If that happens the line drops to line.disconnected and moves through the normal connect flow. Transient and infrequent. Expect it on well under 1% of refreshes in the wild.

Error responses: 401 invalid_api_key on auth failure. 404 line_not_found if the id doesn't belong to your agency.

Release a line

DELETE/api/v1/wa/lines/:id
Disconnects the line, releases its resources, and stops webhooks. The end-customer's phone WhatsApp continues working normally — only the Mossmoon connection is removed.
WA · 04

WhatsApp — status values

StatusMeaning
pending_linkLine provisioned; waiting for the end-customer to scan.
readyLine is live. Send/receive normally.
disconnectedConnection was lost (typically the end-customer's phone has been offline for an extended period).
releasedLine was deleted by the agency, by admin action, or auto-released for billing.
failedProvisioning failed unrecoverably; see failure_reason.
WA · 05

WhatsApp — webhooks

Every event we generate is POSTed to your webhook_url (configured per-line). Each request is signed; verify it before trusting the payload.

Headers

Content-Type: application/json
User-Agent: Mossmoon-Webhook/1.0
X-Mossmoon-Signature: sha256=<hex digest>
X-Mossmoon-Timestamp: 1748634033
X-Mossmoon-Event: message.received
X-Mossmoon-Delivery-Id: dlv_a1b2c3

Delivery semantics

  • At-least-once. Use X-Mossmoon-Delivery-Id for idempotency.
  • Retries: 1m, 5m, 30m, 2h, 6h, 24h. Max 6 attempts before drop.
  • Timeout: we wait 10 seconds for a 2xx; anything else (timeout, 4xx, 5xx) is treated as failure and retried.
  • Order: best-effort per-line; not guaranteed across retries.
WA · 06

WhatsApp — signature verification

Compute HMAC-SHA256 over <timestamp>.<raw body> with the per-line webhook_secret you received at provision time. Reject any request where the signature doesn't match or the timestamp is more than 5 minutes from now (replay protection).

// Node.js / Next.js route handler
import { createHmac, timingSafeEqual } from "crypto";

export async function POST(req: Request) {
  const raw = await req.text();                   // raw bytes BEFORE JSON.parse
  const sig = req.headers.get("x-mossmoon-signature") ?? "";
  const ts  = req.headers.get("x-mossmoon-timestamp") ?? "";

  // replay window
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
    return new Response("stale", { status: 401 });
  }

  const expected = "sha256=" +
    createHmac("sha256", process.env.MOSSMOON_WEBHOOK_SECRET!)
      .update(`${ts}.${raw}`)
      .digest("hex");

  if (!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return new Response("bad signature", { status: 401 });
  }

  const event = JSON.parse(raw);
  // ... handle event
  return new Response("ok");
}

Always verify against the raw body. If your framework re-serializes the JSON before you see it, the signature won't match.

WA · 07

WhatsApp — event payloads

Every event shares a common envelope:

{
  "event": "<event_name>",
  "line_id": "wa_8f3c2e1a",
  "agency_external_user_id": "user_12345",
  "ts": "2026-05-30T19:15:09.001Z",
  "data": { /* event-specific */ }
}

line.ready

The line is now live. First month is debited from your wallet at this point. The amount depends on whether calling was enabled at connect time — $15 for messaging, $20 with calling.

{
  "event": "line.ready",
  "line_id": "wa_8f3c2e1a",
  "agency_external_user_id": "user_12345",
  "ts": "...",
  "data": {
    "phone_number": "+14155551234",
    "mode": "existing",
    "calling_enabled": false,        // true if your end-user toggled it on
                                     // (Flex) or you forced it on (Bundle)
    "monthly_price_cents": 1500      // 1500 messaging-only, 2000 with calling
  }
}

line.disconnected

The line dropped its connection to WhatsApp. Most disconnects are WhatsApp-side session-invalidation events that we recover from automatically — your code should treatline.disconnected as "the line will probably be back shortly" rather than "the customer must rescan now."

How we handle every disconnect: we tear down the current WhatsApp session without logging out the device, spawn a fresh connection on the same proxy IP, and attempt a silent restore from the cached session keys on disk. If WhatsApp accepts the restore (the common case for transient invalidations), a fresh line.ready webhook fires within 10-30 seconds and the customer never notices anything.

When self-healing isn't enough — line.storm_detected: if WhatsApp keeps invalidating the session (5+ disconnects in a 10-minute window), our circuit breaker opens. We stop attempting automatic recovery because rapid retries against a hostile WhatsApp edge make things worse, not better. Instead we emit a separate line.storm_detected event (documented further down). Once that fires, the line stays disconnected until the customer or your UI explicitly triggers a reconnect via the included reconnect_url. Same line_id is preserved across all recovery cycles.

What to do on this event: stop sending to the line until line.ready arrives (outbound to a disconnected line returns 409). Show a soft state in your UI ("Reconnecting…") rather than an alarming one. Only escalate to a customer-action prompt if line.storm_detected arrives.

Common reason values:

  • whatsapp_invalidation_restart — WhatsApp's anti-abuse layer invalidated the session and our self-heal is running. Wait for line.ready. No customer action needed.
  • LOGOUT — WhatsApp explicitly unlinked the device. Customer must rescan via reconnect_url.
  • UNPAIRED — WhatsApp's 14-day primary-phone-inactivity unlink fired. Prompt customer to open WhatsApp on their phone, then re-link.
  • CONFLICT / TIMEOUT — transient WhatsApp-side or network issue. We retry; almost always recovers within 30 seconds.
  • PROXYBLOCK — WhatsApp blocked the outbound IP. We'll swap proxies on the next reconnect attempt automatically.
  • agency_deleted / billing — expected administrative releases. No customer action.

If you receive a reason value not in this list, treat it as transient. We add new values from time to time as WhatsApp adds new disconnect types; the broad rule "wait briefly, expect line.ready, fall back to prompting rescan if line.storm_detected arrives" handles all of them safely.

{
  "event": "line.disconnected",
  "line_id": "wa_8f3c2e1a",
  "agency_external_user_id": "user_12345",
  "ts": "...",
  "data": {
    "reason": "primary_offline_extended",
    "last_seen_at": "2026-05-16T08:00:00.000Z",
    "reconnect_url": "https://mossmoon.app/wa/connect/...",
    // Present when wa-host's classifier flagged the disconnect as
    // a restriction signal. Null on routine disconnects.
    "classification": {
      "class": "wa_anti_abuse_logout",
      "severity": "restriction_suspected",  // info | warn | restriction_suspected | banned
      "explanation": "WhatsApp logged out the device only 1s after it went ready...",  // operator-facing
      "recoverable": false,
      // CUSTOMER-FACING copy. Display these directly to your tenants
      // instead of the operator explanation or the raw class code.
      "customer_message": {
        "headline": "WhatsApp logged out this device",
        "body": "WhatsApp logged out the linked device almost immediately after pairing...",
        "hint": "If sending messages from the customer's own phone still works, the account is fine — they can try linking again."
      }
    }
  }
}

classification.customer_message is the same friendly copy we display in Mossmoon's own dashboard restriction tooltip — sourced from a shared module that's the single source of truth. Surfacing it on your side guarantees your tenant-facing wording stays in sync with ours as we refine the classifier (no manual translation table to maintain on your end).

severity is what to switch on: banned = terminal, use a new number; restriction_suspected = recoverable but prompt the customer to check WhatsApp; warn and info = no customer action needed. class is the machine-readable code for analytics. explanation is operator-facing prose, not for end-user display. Possible class values: wa_anti_abuse_logout, session_invalidated, qr_exhausted, device_unpaired, proxy_blocked, tos_blocked, auth_failure_restored, unlinked_by_user, companion_replaced, transient_timeout, client_outdated, state_machine_kicked, unknown.

line.contacts_synced

The background contact roster sync has finished. Fires a few seconds after line.ready (initial sync), and again whenever POST /lines/:id/contacts/resync completes. Use this to flip your picker UI from "Loading contacts…" to "Ready" without polling.

{
  "event": "line.contacts_synced",
  "line_id": "wa_8f3c2e1a",
  "agency_external_user_id": "user_12345",
  "ts": "...",
  "data": {
    "contacts_count": 247,
    "synced_at": "2026-06-09T18:00:12.345Z"
  }
}

The contacts themselves live at GET /contacts; this event carries the count only.

contact.first_seen

Fires exactly once per (line, wa_id) for the lifetime of the line, when a brand-new contact first messages your line. Carries the full enrichment payload — phone (when WhatsApp shares it), pushname, address-book name, About text, mirrored profile picture URL, and a business sub-object with verified name, category, description, websites, email, and address when the sender is a WhatsApp Business account. Use this as the cue to upsert a CRM contact record with full metadata, instead of dribbling fields in over many message.received events.

{
  "event": "contact.first_seen",
  "line_id": "wa_8f3c2e1a",
  "agency_external_user_id": "user_12345",
  "ts": "...",
  "data": {
    "wa_id": "180195537473758@lid",
    "addressing": "lid",                // "pn" | "lid" | "group_member"
    "phone_e164": "+6281958110112",     // null when WhatsApp doesn't share it
    "display_name": "Imran Sapputra",
    "address_book_name": null,          // only set if line owner has them saved
    "is_my_contact": false,
    "is_wa_contact": true,
    "is_business": true,
    "about_text": "Property developer in Lombok",
    "profile_picture_url": "https://...supabase.co/...",  // 72h signed URL
    "business": {
      "name": "Sapputra Property Investments",
      "verified": true,
      "category": "Real Estate Agency",
      "description": "Boutique villa investment & development...",
      "websites": ["https://sapputra-invest.com"],
      "email": "[email protected]",
      "address": "Jakarta, Indonesia",
      "business_hours": null,
      "latitude": null,
      "longitude": null
    },
    "triggering_message_id": "false_180195537473758@lid_3EB0...",
    "is_refresh": false
  }
}

Ordering caveat: contact.first_seen typically lands 1-3 seconds after the first message.received from the same wa_id. The two webhooks are emitted in parallel; the enrichment fetches take a moment. Your handler should be idempotent on wa_id and tolerant of either order — process message.received with whatever the message payload carries, then upsert when contact.first_seen arrives.

Profile picture expiry: profile_picture_url is a 72-hour signed URL. You must mirror the image to your own CRM storage on receipt if you want to display it long-term — the underlying object is eventually pruned by housekeeping. If you need a fresh URL later, call the refresh endpoint below.

Force-refresh: If a lead's business profile changed and you want to re-pull, call POST /v1/wa/lines/<id>/contacts/<wa_id>/refresh (URL-encode the wa_id's @). The agency webhook receives contact.profile_updated with the fresh enrichment within a few seconds. Each call triggers 3 fresh WhatsApp-edge profile fetches — use sparingly, do not loop across your full contact list.

contact.profile_updated

Same payload shape as contact.first_seen with is_refresh: true. Fired only from the force-refresh endpoint above; never automatic.

line.released

The line was released. Common reasons: billing (wallet ran out for 5+ days), insufficient_balance_at_ready (wallet drained between provision and link-complete), admin.

{
  "event": "line.released",
  "line_id": "wa_8f3c2e1a",
  "agency_external_user_id": "user_12345",
  "ts": "...",
  "data": { "reason": "billing" }
}

message.received

{
  "event": "message.received",
  "line_id": "wa_8f3c2e1a",
  "agency_external_user_id": "user_12345",
  "ts": "2026-05-30T19:15:09.001Z",
  "data": {
    "message_id": "wamsg_xyz",
    // Direction. false = outside contact sent this (standard inbound).
    // true = the account owner typed this on their own WhatsApp phone
    // (or another linked device). On from_me: true the from_* fields
    // hold the RECIPIENT (counterparty), not the owner. Payload shape
    // stays identical so you can key both directions on from_wa_id.
    // See "Owner-typed messages" below.
    "from_me": false,
    "from": "+15559998888",         // backward-compat: resolved E.164, the group JID
                                    // for groups, or the raw wa_id when LID-only
                                    // (never a synthetic "+<lid-digits>")
    "from_e164": "+15559998888",    // real E.164 ONLY when resolved; null otherwise
    "from_wa_id": "[email protected]",  // raw WhatsApp identifier; stable dedup key
    "from_name": "Sarah K",
    "addressing": "pn",             // "pn" | "lid" | "group" — see Contact identity below
    // Group context — for DMs is_group is false and author_* are null.
    // For group messages from / from_wa_id is the group JID (e.g.
    // [email protected]) and author_* identifies the person who actually
    // sent the message. See "Group chats" below for the full contract.
    "is_group": false,
    "author_wa_id": null,
    "author_e164": null,
    "author_name": null,
    // Reply / quote context — set when this inbound was a "Reply" to
    // an earlier message (long-press → Reply on the user's phone).
    // All null on non-reply messages. Match quoted_message_id against
    // a message_id from your own outbound (you'll have it from the
    // matching message.delivered) to detect "user replied to my msg X."
    "quoted_message_id": null,
    "quoted_text": null,
    "quoted_from_wa_id": null,
    "quoted_author_wa_id": null,
    "quoted_type": null,
    "text": "Hi, interested in the apartment",
    "type": "chat",
    // Media fields — populated when the customer sent a photo/voice
    // note/document/video. media_url is a 72-hour signed URL; fetch
    // and re-host if you need it long-term. null on plain-text.
    "media_url": null,
    "media_mime": null,
    "media_kind": null,       // "image" | "video" | "audio" | "voice" | "document"
    "media_filename": null,
    // ORIGINAL WhatsApp message timestamp from the sender's device. Same
    // value as the legacy data.ts field — message_ts is the explicitly-
    // named version (the envelope's top-level ts is event-delivery time).
    // Use this for dedupe on replays.
    "message_ts": "2026-05-30T19:15:08.812Z",
    // false on live deliveries (the common case). true when we surfaced
    // this message through the periodic recovery sweep that runs every
    // 5 minutes on every line, or through POST /lines/:id/sync. Payload
    // shape is identical — your handler doesn't need a separate branch.
    "replay": false,
    // null on live messages. On replays:
    //   "recovery-sweep" → system recovery (heartbeat or post-disconnect resume)
    //   "force-sync"     → operator/agency-triggered POST /lines/:id/sync
    "replay_source": null
  }
}

replay is false on messages delivered to you in real time. It's true when we surfaced the message through our recovery sweep — WhatsApp occasionally queues inbound on their side for a healthy device when its activity signal goes stale, and the sweep wakes that queue and replays anything we hadn't already pushed. The message_id is the same WhatsApp id (so dedup is automatic), the message_ts is the original WhatsApp timestamp (not when the sweep ran), and the payload shape is identical to a live event. Use the flag for instrumentation if you want to see how often recovery fires on your account.

replay_source disambiguates between the two replay paths. null on live messages; on replays it's "recovery-sweep" (system-initiated heartbeat sweep or post-disconnect resume) or "force-sync" (operator-triggeredPOST /lines/:id/sync). Treat force-sync with extra suspicion of full- history replays — it can deliver messages from hours or days ago.

message_ts is the original WhatsApp message timestamp (when the message was sent on the sender's device), the same value as the legacy data.ts field. Both exist because the webhook envelope ALSO has a top-level ts (which is event-delivery time), and data.ts vs envelopets was confusing. Use data.message_ts for unambiguous message-time semantics and as your dedupe key for history-sync replays.

Owner-typed messages (from_me)

message.received fires for both directions. Outside contacts sending to the line arrive with from_me: false (the common case). Messages the account owner types on their own WhatsApp phone (or another linked device) arrive with from_me: true. Agencies mirroring conversations in a CRM need both. Without from_me: true, the thread would show only what leads sent plus what the agency sent via /send, and the owner's own phone- typed replies would be missing.

Field on from_me: trueSemantics
from_metrue.
from_wa_idThe RECIPIENT (the counterparty in the conversation), NOT the owner. Payload shape is intentionally identical to inbound so you can key conversations on from_wa_id for both directions.
from / from_e164 / from_nameResolved from the recipient (same rules as inbound).
author_wa_id / author_e164 / author_nameAlways null on from_me: true, even in groups. You already know it was the owner. from_me is the direction signal.
message_idThe WhatsApp id assigned to the outbound. Use for dedupe as usual.
message_tsWhen the owner sent the message on their device.

Not fired for /send API messages. If your agency sent the message through POST /send, you get message.delivered for it (as always), and you do not also get message.received with from_me: true. Only owner-typed-on-device messages surface as inbound. In the rare case where a /send message races through as from_me: true, dedupe on message_id against the id POST /send returned. Same key you already use for message.delivered.

Activating from_me on already-connected lines. New lines scanned after this feature shipped pick it up automatically. Lines paired before then need a runtime nudge to start emitting from_me: true. Call POST /lines/:id/refresh once per such line. The refresh preserves the proxy IP and the customer's device stays paired; there is a small rescan chance documented on the endpoint. The same applies any time we later change the shape or behavior of from_me (or any other runtime-level webhook): existing lines pick up the change on their next /refresh, natural reconnect, or scheduled maintenance.

Detecting replies

WhatsApp lets users long-press any message → "Reply" to quote it. The resulting message.received carries the quoted_* fields:

FieldSet when
quoted_message_idAlways set on reply messages — the message_id of the message being replied to. Match this against message_ids you've stored for your own outbound (from message.delivered) to detect 'the user is replying to OUR message X.'
quoted_textThe text of the quoted message (so you can show it in your UI without an extra fetch).
quoted_from_wa_idThe chat the quoted message came from (in DMs: the contact JID; in groups: the group JID).
quoted_author_wa_idThe person who originally sent the quoted message. In groups this is the actual sender (different from quoted_from_wa_id, which is the group itself).
quoted_typeThe quoted message's type — 'chat', 'image', 'document', etc.

All five fields are null on non-reply messages. To thread a real reply back, pair this with quoted_message_id on POST /send — see Reply to a specific message.

message.received (with media)

When a customer sends an image, voice note, document, or video, we mirror it to a private bucket and ship a 72-hour signed URL. Fetch it with a normal GET; no auth header needed, the signature is in the query string.

You are responsible for long-term storage. After 72 hours the URL stops resolving and the file is purged from our bucket on the next sweep. If your product shows chat history weeks or months later, copy the bytes into your own database / object storage on first receipt and store your URL alongside the message. We do not retain inbound media past the 72-hour download window.

{
  "event": "message.received",
  "line_id": "wa_8f3c2e1a",
  "data": {
    "message_id": "wamsg_doc_abc",
    "from_me": false,
    "from_e164": "+15559998888",
    "from_wa_id": "[email protected]",
    "from_name": "Sarah K",
    "text": "Here's the signed contract",   // sent as caption
    "type": "document",
    "has_media": true,
    "media_url": "https://cdn.mossmoon.app/wa-inbound/wa_8f3c2e1a/wamsg_doc_abc.pdf?token=...",
    "media_mime": "application/pdf",
    "media_kind": "document",
    "media_filename": "contract-signed.pdf"
  }
}

media_kind values: image · video · audio (regular audio attachment) · voice (PTT recording — the round play-button bubble) · document (anything else).

Hard cap on inbound media: 100 MB. WhatsApp's own caps are stricter, so in practice you'll see ≤5 MB images, ≤16 MB voice notes / video, ≤100 MB documents.

// Recommended handler shape — uses media_kind to dispatch processing.
switch (event.data.media_kind) {
  case "voice":
    // PTT recording — transcribe to text, feed to your AI.
    const audio = await fetch(event.data.media_url).then((r) => r.arrayBuffer());
    const transcript = await whisper.transcribe(audio);
    return handleInbound({ ...event.data, text: transcript });
  case "document":
  case "image":
    // PDF / photo — extract text via OCR or vision model, feed to AI.
    const file = await fetch(event.data.media_url).then((r) => r.arrayBuffer());
    const extracted = await ocr.process(file, event.data.media_mime);
    return handleInbound({ ...event.data, text: extracted });
  default:
    // Plain text or video — just pass through.
    return handleInbound(event.data);
}

message.delivered

May fire more than once per message as state advances: sent → received → read.

{
  "event": "message.delivered",
  "line_id": "wa_8f3c2e1a",
  "agency_external_user_id": "user_12345",
  "ts": "...",
  "data": {
    "message_id": "wamsg_a1b2c3d4e5f6",
    "to": "+15709302189",
    "to_e164": "+15709302189",        // resolved E.164 — set for PN-addressed
                                      // recipients; may be null for LID-only
    "to_wa_id": "[email protected]",   // raw WhatsApp identifier
    "addressing": "pn",               // "pn" | "lid" | "group"
    "is_group": false,                // true when this was an outbound
                                      // group send (to / to_wa_id is the
                                      // group JID, to_e164 is null)
    "ack_status": "received"
  }
}

Contact identity: from_e164 vs from_wa_id

Each WhatsApp contact has a stable identifier we expose as from_wa_id. Most resolve to a real phone number (the digits before @ are the E.164 phone number). Some (typically brand-new conversations with contacts your end-customer hasn't messaged before) arrive with an opaque routing identifier that is NOT a phone number, for the first few messages until the contact resolves to a real number on our side.

That's why we ship two contact fields:

FieldWhen setUse it for
from_wa_idAlways set on every inbound.Your stable per-contact dedup key. Same person = same wa_id, forever.
from_e164Set when the contact is resolvable to a real phone number. null when only @lid is available and we haven't resolved it yet.Cross-channel matching (SMS, email lookup, etc.). When null, fall back to from_wa_id as the key.
addressingAlways set: "pn" (phone-number DM, from_e164 guaranteed set), "lid" (LID-addressed DM, from_e164 set when resolved, else null), or "group" (group chat, from_e164 always null).Decide at a glance which contract this inbound is under without inspecting JID suffixes.
fromAlways set. Resolved E.164 when known; the group JID for groups; the raw wa_id when LID-only. Never a synthetic +<lid-digits> string.Backward compatibility only. New integrations should prefer from_e164 + from_wa_id.

Self-healing. Even if an inbound arrives as @lid with from_e164: null, Mossmoon records the wa_id and fills in the real number the moment we can resolve it (the contact map populates, or you send an outbound to that contact via the API). From that point forward, every webhook for the same from_wa_id ships the correct E.164 — even retroactively in your own database lookups, if you key by wa_id.

Recommended: store both from_wa_id (as the primary key on your contact record) and from_e164 (nullable, fill in when present, leave alone otherwise). Same for outbound to_wa_id / to_e164.

message.failed

{
  "event": "message.failed",
  "line_id": "wa_8f3c2e1a",
  "agency_external_user_id": "user_12345",
  "ts": "...",
  "data": {
    "message_id": "wamsg_a1b2c3d4e5f6",
    "to": "+15709302189",
    "reason": "recipient_not_on_whatsapp"
  }
}
WA · 08

WhatsApp — group chats

A connected line can participate in WhatsApp group chats — receive group messages on the webhook, reply into the group via POST /lines/:id/send. The line behaves like any other group member: no special admin powers, can be added or removed at any time.

How the line joins a group

The line is added by another group member from the WhatsApp app (Group info → Add participant). Mossmoon does not expose a "create group" or "accept invite link" endpoint in v1. As soon as the line is added, you get a group.joined webhook with the group's JID — capture it; you'll need it to send into the group.

Identifying a group message

Every message.received event carries group context fields. For group messages:

FieldMeaning in a group
is_grouptrue
addressing"group"
from_wa_idThe group JID, e.g. [email protected]. Stable across the group's lifetime. Use this as your conversation key.
fromSame as from_wa_id (backward-compat alias).
from_e164Always null — groups don't have phone numbers.
from_nameGroup display name when available.
author_wa_idJID of the person inside the group who actually sent this message (e.g. [email protected] or <lid>@lid).
author_e164That person's E.164 when resolvable; null for LID-routed members.
author_nameTheir WhatsApp pushname when available.

For DMs, is_group is false and all author_* fields are null.

Sending into a group

Pass the group JID as to_wa_id to POST /lines/:id/send. Do not pass to — groups have no phone number. If you pass both, the group JID wins. Media and voice-note sends accept group JIDs the same way.

POST /api/v1/wa/lines/wa_8f3c2e1a/send
Authorization: Bearer mm_live_...

{
  "to_wa_id": "[email protected]",
  "text": "Morning team — here's the CEO brief."
}

The send response and the eventual message.delivered webhook echo back the group JID in to / to_wa_id, with to_e164: null and is_group: true.

Conversation keying

Mossmoon stores inbound and outbound group messages under the same conversation key (the group JID), so GET /lines/:id/[email protected] returns the unified group thread.

Recommended for dashboards: dedupe conversations on from_wa_id / to_wa_id. That key works uniformly for DMs (@c.us), LID-routed DMs (@lid), and groups (@g.us).

Create a new group

POST/api/v1/wa/lines/:id/groups
Create a fresh WhatsApp group from this line. The line becomes the group's owner + admin automatically. Common during onboarding: line goes ready → POST /groups with the team members → done.
POST /api/v1/wa/lines/wa_8f3c2e1a/groups
Authorization: Bearer mm_live_...

{
  "name": "CEO Briefing",
  "participants": ["+15551234567", "+15559998888"],
  "description": "Daily CEO updates from Acme Realty.",
  "initial_message": "Welcome — I'll post your daily summary here every morning.",
  "agency_external_group_id": "briefing_user_12345"
}

→ 201 Created
{
  "line_id": "wa_8f3c2e1a",
  "group_wa_id": "[email protected]",
  "name": "CEO Briefing",
  "invite_link": "https://chat.whatsapp.com/AbCdEfGhIjK1234567",
  "agency_external_group_id": "briefing_user_12345",
  "participants": [
    { "wa_id": "[email protected]", "e164": "+15551234567", "status": "added", "error": null },
    {
      "wa_id": "[email protected]",
      "e164": "+15559998888",
      "status": "invite_pending",
      "error": null,
      "invite_link": "https://chat.whatsapp.com/AbCdEfGhIjK1234567"
    }
  ],
  "created_at": "2026-06-05T10:00:00.000Z"
}
FieldRequiredNotes
nameyesGroup display name, 1-100 chars (WhatsApp limit).
participantsyesE.164 numbers or @c.us JIDs. Max 256 at create; add more later via /participants.
descriptionnoGroup description, applied right after creation.
initial_messagenoFirst message posted into the group (fire-and-forget — creation succeeds even if the send fails).
agency_external_group_idnoYour own reference id (≤255 chars). Echoed back on every group webhook for this group. Same pattern as agency_external_user_id on lines.

Per-participant status values: added (in the group, ready) · invite_pending (their privacy blocks direct add) · not_on_whatsapp · already_in_group · failed (see error field).

Backup path — invite_link: the group's join-by-link URL, returned both at the top level and duplicated onto every non-added participant. Hand it to anyone WhatsApp refused to add directly (privacy gated, wrong number, etc.) and they can join manually.

Identifying "our" groups later — three signals: (1) GET /groups/:id returns owner_wa_id = the line's JID (we're the owner), (2) our DB tracks created_by_us: true — surfaced on GET /groups/:id and every group webhook for that group, (3) your agency_external_group_id is echoed on every group webhook.

Anti-spam: don't loop this rapidly from a fresh line. WhatsApp rate-limits new-group creation per device — safe budget is a few per customer per day, not hundreds.

Add or remove members

POST/api/v1/wa/lines/:id/groups/:group_id/participants
Add WhatsApp users to a group the line is in. The line must be a group admin.
POST /api/v1/wa/lines/wa_8f3c2e1a/groups/[email protected]/participants
Authorization: Bearer mm_live_...

{ "participants": ["+15551234567", "+15559998888"] }

→ 200 OK
{
  "ok": true,
  "group_wa_id": "[email protected]",
  "invite_link": "https://chat.whatsapp.com/AbCdEfGhIjK1234567",
  "participants": [
    { "wa_id": "[email protected]", "e164": "+15551234567", "status": "added", "result": null },
    {
      "wa_id": "[email protected]",
      "e164": "+15559998888",
      "status": "invite_pending",
      "result": { "code": 417 },
      "invite_link": "https://chat.whatsapp.com/AbCdEfGhIjK1234567"
    }
  ]
}

Use the same endpoint with DELETE to remove members. Same body shape. Per-participant status mirrors the create-group enum, and invite_link is present at the top level plus duplicated onto any non-added participant as a manual-join fallback.

Promote / demote admins

POST/api/v1/wa/lines/:id/groups/:group_id/admins
Promote existing group members to admin. The line itself must be admin. Targets that aren't currently in the group are rejected by WhatsApp.
POST /api/v1/wa/lines/wa_8f3c2e1a/groups/[email protected]/admins
Authorization: Bearer mm_live_...

{ "participants": ["+15551234567"] }

→ 200 OK
{ "ok": true, "group_wa_id": "[email protected]", "participants": [...] }

Use the same endpoint with DELETE to demote admins back to regular members.

Update group settings

PATCH/api/v1/wa/lines/:id/groups/:group_id
Update name, description, and admin-only flags. Any subset of fields may be provided; omitted fields stay unchanged.
PATCH /api/v1/wa/lines/wa_8f3c2e1a/groups/[email protected]
Authorization: Bearer mm_live_...

{
  "name": "CEO Briefing (Q2)",
  "description": "Quarterly CEO updates.",
  "messages_admins_only": true,
  "info_admins_only": false,
  "agency_external_group_id": "briefing_user_12345_q2"
}

→ 200 OK
{ "line_id": "wa_8f3c2e1a", "group_wa_id": "[email protected]", "applied": { ... } }

name, description, and the admin-only flags require the line to be a group admin. agency_external_group_id is a DB-only field — updating it works regardless of admin status. Pass null to clear it.

Group metadata + participants

GET/api/v1/wa/lines/:id/groups/:group_id
Fetch the current name, description, owner, and member list for a group the line is in. WhatsApp's privacy model: the line can only inspect groups it is currently a member of.
GET /api/v1/wa/lines/wa_8f3c2e1a/groups/[email protected]
Authorization: Bearer mm_live_...

→ 200 OK
{
  "line_id": "wa_8f3c2e1a",
  "group_wa_id": "[email protected]",
  "name": "Off Plan Buyers",
  "description": "Internal coordination for off-plan listings.",
  "owner_wa_id": "[email protected]",
  "created_at": "2025-11-14T18:22:01.000Z",
  "participants": [
    {
      "wa_id": "[email protected]",
      "e164": "+15559998888",
      "name": "Sarah K",
      "is_admin": true,
      "is_super_admin": true
    },
    {
      "wa_id": "[email protected]",
      "e164": "+14155551234",
      "name": "Acme Realty",
      "is_admin": false,
      "is_super_admin": false
    }
  ],
  "created_by_us": true,
  "agency_external_group_id": "briefing_user_12345"
}

e164 is null for LID-routed members. created_by_us is true only when this line created the group via POST /groups; agency_external_group_id is whatever you passed at create time (or null). 400 if group_id isn't a @g.us JID, 404 if the line isn't in the group (or it doesn't exist), 409 if the line isn't ready.

Side effect: calling this endpoint also forces a metadata sync inside the wa-host's WA Web instance. Useful as a manual unstick if a group's sender-key state gets wedged and sends start failing — one GET call before a send often unsticks the next send.

Group webhook events

These events fire on group membership changes. None of them persist to GET /lines/:id/messages (they're not messages).

group.joined

Fires when the line itself is added to a group. Use this to bootstrap a group conversation (e.g. post a welcome) without waiting for the first inbound message.

{
  "event": "group.joined",
  "line_id": "wa_8f3c2e1a",
  "agency_external_user_id": "user_12345",
  "ts": "...",
  "data": {
    "group_wa_id": "[email protected]",
    "group_name": "Off Plan Buyers",
    "added_by_wa_id": "[email protected]"
  }
}

group.left

Fires when the line itself is removed from a group.

{
  "event": "group.left",
  "line_id": "wa_8f3c2e1a",
  "data": {
    "group_wa_id": "[email protected]",
    "group_name": "Off Plan Buyers",
    "removed_by_wa_id": "[email protected]"
  }
}

group.participant_added / group.participant_removed

Fires when someone else (not the line) is added to or removed from a group the line is in. participant_wa_ids is an array because WhatsApp batches multi-add operations.

{
  "event": "group.participant_added",
  "line_id": "wa_8f3c2e1a",
  "data": {
    "group_wa_id": "[email protected]",
    "group_name": "Off Plan Buyers",
    "added_by_wa_id": "[email protected]",
    "participant_wa_ids": ["[email protected]"]
  }
}

What's not supported in v1

  • No "list groups this line is in" endpoint — capture the group JID from the group.joined webhook (or the first inbound webhook after the line is added).
  • No create-group, leave-group, or accept-invite-link endpoints. The line can only join groups by being added by an existing member.
  • No admin promote/demote events.
  • The line's daily send cap counts each message into a group as one send, regardless of how many people receive it.
WA · 09

WhatsApp — rate limits

We enforce a conservative per-line daily message cap to protect your end-customers' accounts and keep deliverability high. The cap is expressed in the response headers below so your app can throttle ahead of 429s.

CapBehavior
Soft (750/day)Send succeeds. Response includes X-Mossmoon-Soft-Warning: approaching-daily-cap.
Hard (1500/day)Send refused with 429. Response includes Retry-After and X-Mossmoon-Daily-Reset.

Every send response includes:

X-Mossmoon-Daily-Limit: 1500
X-Mossmoon-Daily-Remaining: 1247
X-Mossmoon-Daily-Reset: 2026-05-31T00:00:00.000Z

The day window is UTC midnight to UTC midnight. Resets happen automatically. The 1500/day ceiling is for well-warmed lines only. Fresh lines should follow the ramp in the next section, not sit at the ceiling on day one.

WA · 10

WhatsApp — warming up a new line

WhatsApp's anti-abuse systems watch new numbers closely for the first two weeks. Connecting a brand-new number to Mossmoon (or any automation platform) on day one, then blasting outbound, is the single highest ban risk on the platform. This section covers what to do before the number is ever connected, how to scale volume phase-by-phase after connection, and the specific behaviors that trigger bans.

The rule of thumb: every new number needs warm-up. That includes brand-new WhatsApp registrations, established business numbers connecting to automation for the first time, numbers inactive for 30+ days, and any number recovering from a previous ban.

Before you connect (brand-new numbers)

If the number is brand new to WhatsApp (fresh SIM, or a number that just finished WA registration), do not connect it to Mossmoon for at least 48 hours, ideally 7 to 14 days. Meta's anti-abuse looks hardest at accounts that register and immediately link to any third-party session. During this window, use the phone directly for natural conversation only.

The 48-hour minimum for a fresh number, before scanning the QR:

  • Set the profile. Real display name, profile photo, and About text. An empty profile reads as a throwaway account.
  • Bias inbound over outbound. In the first 48 hours the number should receive more messages than it sends. Have friends and colleagues message it first. Two-way conversations signal legitimacy; one-way outbound reads as spam.
  • Join 2 to 3 WhatsApp groups. Relevant groups where the number engages naturally. This builds the account's footprint as a social, active number rather than a fresh outbound machine.
  • Save contacts on both sides. People messaging the number should have it saved in their contacts, and the number should have them saved back. Unsaved-both-ways conversations look like cold outreach.
  • Aim for a 50%+ reply rate in the first 48 hours. This is the single biggest reputation signal WhatsApp uses on new accounts.

For numbers that were previously banned, do the full 14-day pre-connect warm-up, not the 48-hour minimum. For numbers inactive 30+ days, 7 days is usually enough.

Day-by-day warm-up timeline (after connecting)

Once the number is connected to Mossmoon and goes ready, ramp volume gradually over the first two weeks. Never jump from low volume to high volume suddenly. A 15-20% daily increase is the ceiling.

PhaseVolumeBehavior
Phase 1 (Days 1-2)10-15 / dayInitial setup. Send to warm contacts who will reply. Aim for 50%+ reply rate. Keep tone personal, not promotional.
Phase 2 (Days 3-5)20-35 / dayMix warm leads with personal contacts. Space sends at least 15-20 seconds apart. Vary the message body per recipient.
Phase 3 (Days 6-10)50-100 / dayStart including opt-in database contacts. Space sends 10-15 seconds apart. Continue asking questions to encourage replies.
Phase 4 (Days 11-14)200-300 / dayFull automation workflows OK. Space sends 5-10 seconds apart. Watch for delivery-rate drops.
Phase 5 (Day 15+)500-1000+ / dayFull operation. Conservative 500-750, moderate 750-1000, absolute ceiling 1500. Never exceed 2000 even on a well-warmed line.

Pacing (drip) intervals per phase. Bulk sends without pacing are the second-highest ban trigger after cold outreach. Even after warm-up, keep at least 3-5 seconds between sends on any bulk campaign.

PhaseMinimum interval between sends
Days 1-515-20 seconds
Days 6-1010-15 seconds
Days 11-145-10 seconds
Day 15+3-5 seconds (never lower)

Ban triggers and warning signs

These are the specific behaviors that get numbers banned, plus the delivery signals that predict a ban is coming.

Content triggers:

  • Identical message text sent to multiple recipients. Always vary at least the greeting and one detail per recipient.
  • Links in the first message to a new contact. Wait for a reply before sending any URL.
  • Excessively promotional language, especially on cold outbound.
  • Robotic-sounding copy. Write like a human, not a template.

Behavior triggers:

  • Cold outreach to contacts who never opted in. This is the #1 direct cause of bans.
  • Sudden volume spikes (double-or-more day-over-day).
  • Sending at unusual hours (3am local time), or outside 9am-7pm recipient-local.
  • Purchased contact lists. Recipients hit "Report" and the number dies fast.
  • Turning off pacing on bulk campaigns.

Warning signs (stop sending immediately):

  • Delivery rate drops below 90%.
  • Increased message.failed events, especially with severity: restriction_suspected (see event payloads).
  • Messages stuck in pending state for more than 5 minutes.
  • Recipients reporting they didn't receive the message.

If you see warning signs: stop sending immediately, drop back one phase in the ramp above, wait 48-72 hours, then resume at the slower pace. Pushing through a warning is what turns a recoverable restriction into a permanent banned status.

The 1500/day hard cap in the previous section is only the ceiling once the line is well past Phase 5. Following this ramp is the difference between a line that lasts months and a line that's banned in week one. The cost of a banned line (new number, re-onboard, customer friction) is far higher than the cost of starting slow.

WA · 11

WhatsApp — calling

Calling lets the agency's dashboard place voice calls from the connected line to anyone — same WhatsApp identity as the messaging, same wallet, same webhook secret. Unlimited minutes, flat $20/month per calling-enabled line. No per-minute fees. Calling is opt-in per line — messaging-only lines are unaffected and stay at $15/month.

Availability

WhatsApp voice calling is a Meta feature that began rolling out account-by-account in February 2026. The rollout is most of the way through and the vast majority of accounts now have it — but a small number of older or region-specific accounts may not see it yet. Meta hasn't published a final-availability date; new accounts are picked up in waves.

Quick check. Your customer can confirm their own account in about ten seconds — open web.whatsapp.com, click into any chat, and look for the phone icon in the top-right of the chat header. If it's there, calling is live on their account and any line you provision in calling mode will work the moment it goes ready. If it's missing, Meta typically enables the account within a few weeks at most.

Provisioning is safe either way. You can enable calling on a line whose end-customer doesn't have the feature yet — the line provisions normally, billing proceeds at the calling tier, and outbound calls start working automatically the moment Meta enables their account. No re-provision, no re-scan, no support ticket. If you'd rather wait, leave the line as messaging-only and upgrade it later by releasing and re-provisioning with calling_enabled: true.

Two ways to enable calling

You pick at provision time. The choice is locked at QR-scan time (when the line goes ready) and the line bills at the resolved rate from that point on.

ModeField on POST /linesWho decidesBilling
Bundlecalling_enabled: trueYou (agency).Line bills $20/mo from line.ready. No end-user toggle.
Flexallow_calling: trueEnd-user picks on the connect page.$20/mo if they toggle on; $15/mo if they skip.
Messaging only— (neither)Nobody — calling never appears.Flat $15/mo. The toggle is never shown.

Flex is fully optional. By default allow_calling is false and the connect page shows only the QR — identical to the pre-calling flow. Pass it as true only when you want your customer to see the choice. You can pass it on some lines and not others.

Flex: what your customer sees

When you provision with allow_calling: true, the Mossmoon-hosted connect page shows a single toggle above the QR:

┌──────────────────────────────────────────┐
│  Make and receive calls?                 │
│  ┌──────────────────────────────────┐    │
│  │  ○ Off    ●  On                  │    │
│  └──────────────────────────────────┘    │
│  Connect your dashboard to make voice    │
│  calls in addition to messaging.         │
│                                          │
│  [ Continue → ]                          │
└──────────────────────────────────────────┘

No pricing is shown to your end-customer. The toggle is purely a feature choice on your product's connect surface. Your billing relationship with them is yours to set.

When the user picks and continues, we provision the line into the matching pool (call-capable or messaging-only), then show the QR. They scan once and the line is live with the right feature set.

Customizing the connect page

Pass query parameters on the connect_url to tune the choice screen without rebuilding it:

ParamDefaultEffect
allow_callingfalseShow the toggle at all. Overrides what was sent at POST time. Pass false to hide it for a specific user even when the line was provisioned with allow_calling.
default_callingoffWhich option is preselected (on or off).
force_callingfalseSkip the toggle entirely and provision as calling-enabled. Equivalent to having sent calling_enabled: true at POST time, useful when your own paywall already collected the upgrade payment.
// Show the toggle (already opted into Flex at POST)
https://mossmoon.app/wa/connect/<token>

// Hide the toggle even though the line was provisioned with allow_calling
https://mossmoon.app/wa/connect/<token>?allow_calling=false

// Pre-select calling as ON, but still let them toggle off
https://mossmoon.app/wa/connect/<token>?default_calling=on

// Skip the toggle entirely — line is calling-enabled, customer just scans
https://mossmoon.app/wa/connect/<token>?force_calling=true

Place a call

POST/api/v1/wa/lines/:id/call
Place an outbound voice call from the line to a phone number or WhatsApp JID. Returns 403 calling_disabled if the line wasn't provisioned with calling. Returns 409 line_not_ready if the line isn't live. Returns 503 capacity_busy in the rare event the line's call-capable machine is at its concurrent-call ceiling; the response includes retry_after seconds.
POST /api/v1/wa/lines/wa_8f3c2e1a/call
Authorization: Bearer mm_live_...
Content-Type: application/json

{
  "to": "+15709302189",                 // OR to_wa_id, same dual-routing as /send
  "audio_relay": {                      // how the agency's dashboard joins the call
    "mode": "webrtc",                   // "webrtc" (browser) or "sip" (softphone)
    "ice_servers": [                    // optional; we provide defaults
      { "urls": "stun:stun.mossmoon.app:3478" }
    ]
  },
  "caller_id_name": "Acme Realty"       // optional; displayed on the recipient's WA
}

→ 202 Accepted
{
  "call_id": "wacall_3a4b5c6d",
  "status": "ringing",
  "line_id": "wa_8f3c2e1a",
  "to_e164": "+15709302189",
  "to_wa_id": "[email protected]",
  "audio_relay_url": "wss://relay.mossmoon.app/v1/calls/wacall_3a4b5c6d?token=...",
  "expires_at": "2026-05-30T19:21:33.456Z",
  "created_at": "2026-05-30T19:20:33.456Z"
}

audio_relay_url is a short-lived signed WebSocket URL your dashboard connects to in order to send and receive the call's audio. The token in the query string expires when the call ends or after 60s of inactivity, whichever comes first. Open it from the user's browser with WebRTC, or from a SIP softphone when you set mode: "sip".

End a call

POST/api/v1/wa/lines/:id/call/:call_id/hangup
Hang up an active call. Idempotent — calling on an already-ended call returns 200 with the existing terminal state.
POST /api/v1/wa/lines/wa_8f3c2e1a/call/wacall_3a4b5c6d/hangup
Authorization: Bearer mm_live_...

→ 200 OK
{
  "call_id": "wacall_3a4b5c6d",
  "status": "ended",
  "ended_at": "2026-05-30T19:24:11.789Z",
  "duration_seconds": 218
}

Retrieve a call

GET/api/v1/wa/lines/:id/call/:call_id
Snapshot of one call. Useful for reconciliation after a webhook delivery failure.

Call status values

StatusMeaning
ringingOutbound call placed; recipient's WhatsApp is ringing.
answeredRecipient accepted; audio is flowing.
endedCall ended normally (either side hung up).
declinedRecipient declined the call.
missedRinging window elapsed without an answer.
failedCall couldn't be placed — see failure_reason.

Call webhook events

Calls fire signed webhooks on the same webhook_url as messaging, using the same signature scheme. Fields follow the standard envelope (see WA · 07 — Event payloads).

{
  "event": "call.ringing",
  "line_id": "wa_8f3c2e1a",
  "agency_external_user_id": "user_12345",
  "ts": "...",
  "data": {
    "call_id": "wacall_3a4b5c6d",
    "direction": "outbound",        // "outbound" or "inbound"
    "to_e164": "+15709302189",
    "to_wa_id": "[email protected]"
  }
}

{
  "event": "call.answered",
  "line_id": "wa_8f3c2e1a",
  "data": {
    "call_id": "wacall_3a4b5c6d",
    "answered_at": "2026-05-30T19:20:41.234Z"
  }
}

{
  "event": "call.ended",
  "line_id": "wa_8f3c2e1a",
  "data": {
    "call_id": "wacall_3a4b5c6d",
    "ended_at": "2026-05-30T19:24:11.789Z",
    "duration_seconds": 218,
    "ended_by": "agency"            // "agency" | "recipient" | "network"
  }
}

{
  "event": "call.failed",
  "line_id": "wa_8f3c2e1a",
  "data": {
    "call_id": "wacall_3a4b5c6d",
    "reason": "recipient_unreachable"   // or "declined" | "missed" | "capacity_busy" | etc.
  }
}

Inbound calls

When someone calls the customer's WhatsApp number, an inbound-direction call.ringing event fires. To answer, your dashboard POSTs to the included accept_url within the ringing window; ignore it and the call rolls into missed as the caller gives up.

{
  "event": "call.ringing",
  "line_id": "wa_8f3c2e1a",
  "data": {
    "call_id": "wacall_inb_9f8e7d6c",
    "direction": "inbound",
    "from_e164": "+15559998888",
    "from_wa_id": "[email protected]",
    "from_name": "Sarah K",
    "accept_url": "https://mossmoon.app/api/v1/wa/lines/wa_8f3c2e1a/call/wacall_inb_9f8e7d6c/accept",
    "expires_at": "2026-05-30T19:25:45.000Z"  // ringing window deadline
  }
}

Calling state on a line

The line object (GET /api/v1/wa/lines/:id) includes two calling-related fields:

{
  "line_id": "wa_8f3c2e1a",
  "status": "ready",
  // ... existing fields ...
  "calling_enabled": true,            // resolved state — true if either Bundle
                                      // or Flex (end-user chose) is in effect
  "monthly_price_cents": 2000         // 1500 for messaging-only, 2000 with calling
}

Calling rate limits

Per-line concurrent active calls: 1. Placing a second call while one is active returns 409 call_in_progress. Hang up the existing call first or wait for it to end.

Per-machine concurrent-call ceiling is enforced internally and normally invisible. The rare 503 capacity_busy response (with retry_after seconds) covers the case when a line lives on a call-capable machine that's momentarily at its peak.

WA · 12

Click-to-call

You don't always need an API call to start a WhatsApp conversation or place a call — WhatsApp's own URL scheme lets any link or button on the web deep-link straight into the right chat on the user's phone or desktop. Same shape as mailto:. Zero backend. No per-action cost. Works for every Mossmoon customer, on every plan, today.

Render a “Call via WhatsApp” button next to any contact in your product, point it at https://wa.me/<E.164>, and the user gets a two-tap flow: click the button → WhatsApp opens to that contact's chat → tap the phone icon in the header to place the call. On mobile it deep-links into the WhatsApp app with no browser interstitial; on desktop it opens WhatsApp Desktop if installed, falling back to WhatsApp Web in the same tab.

Drop-in snippet

<!-- Drop this anywhere you template a contact's phone into HTML -->
<a
  href="https://wa.me/{{ contact.phone_e164 | digits_only }}"
  target="_blank"
  rel="noopener"
>
  Call via WhatsApp
</a>

Number format: E.164 with the leading + and any spaces, dashes, or parens stripped — e.g. https://wa.me/15709302189, not https://wa.me/+1 (570) 930-2189. Mossmoon webhook payloads already give you the digits in from_e164 / to_e164; a single .replace(/\D/g, "") is usually all you need.

Prefer the installed app (desktop)

On desktop, wa.me shows a one-screen interstitial (“Open WhatsApp”) before handing off to the installed app. To skip it for users who already have WhatsApp Desktop, try the native scheme first and fall back gracefully:

function openWhatsAppCall(e164) {
  const digits = e164.replace(/\D/g, "");
  const app = `whatsapp://send?phone=${digits}`;
  const web = `https://wa.me/${digits}`;

  // Try the installed app; if nothing handles the scheme within ~500ms,
  // fall back to wa.me in a new tab.
  const start = Date.now();
  window.location.href = app;
  setTimeout(() => {
    if (Date.now() - start < 1500) window.open(web, "_blank", "noopener");
  }, 500);
}

When to use this vs. the /call endpoint

Use the wa.me deep-link whenUse POST /lines/:id/call when
The person placing the call is a human in your product, on their own phone or desktop.The call should originate from the connected line (consistent caller identity for the end-customer).
Any WhatsApp account on the user's device can place the call — caller identity doesn't have to be the connected line.You need to place the call from a server, an AI agent, or a dashboard surface that isn't the user's own WhatsApp.
You don't need ringing / answered / ended webhooks or duration data in your own system.You need call-state webhooks (ringing, answered, ended) or want to record / route / score the call.
Free — no Mossmoon billing involved.Line must be on the $20/mo calling tier. See WA · 09 — Calling.

The two are complementary, not exclusive. A common pattern: a “Call via WhatsApp” button on every contact card (free, instant, deep-links the user's own WhatsApp) plus a separate “Call from Mossmoon line” action in the dashboard for agents who want the call to come from the connected line for consistent identity.

A note on the call icon

The phone icon the user taps inside WhatsApp after the deep-link is the same Meta feature described in Calling → Availability. Most accounts have it as of the February 2026 rollout; a small number don't yet. The deep-link itself works regardless — for accounts without calling, the chat opens normally and the user can still message.

WA · 13

WhatsApp — pricing & billing

WhatsApp lines bill flat per month, per line. Two prices, depending on calling:

TierPriceWhat it includes
Messaging$15/line/moUnlimited inbound and outbound messages. Default for new lines.
Messaging + Calling$20/line/moEverything in Messaging, plus voice calling. Unlimited call minutes, no per-minute fees.
  • Provisioning is free; the first month is debited only when the line transitions to ready. The amount debited is whichever tier the line resolved to at QR-scan time (see Calling).
  • Monthly renewal runs automatically on the line's anniversary. Renewal amount matches the line's current tier — no re-quote.
  • Insufficient balance at renewal triggers a 5-day grace window; after that the line is auto-released and you receive a line.released webhook with reason: "billing".
  • Releasing a line at any time stops all future charges immediately.
  • There are no per-message and no per-minute fees on either tier. Sustained heavy calling on a single line (50+ minutes per day, every day) may be moved to a custom plan; we'll contact you before any change.
WA · 14

Versioning

This is /api/v1. Breaking changes ship as /api/v2. Non-breaking additions (new fields, new event types, new status values) may land in v1 without warning — please ignore unknown fields and don't crash on unexpected event types.

X-Mossmoon-Api-Version: 1