API referencev1

One key, two products.

Mossmoon ships a single REST API that fronts two products: SMS verification numbers and always-on WhatsApp lines. Same key, same wallet, same webhook signature scheme. Base URL https://mossmoon.app/api/v1.

01

Overview

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

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_balanceWallet too low for the operation
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
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
}
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.

SMS · 01

SMS — overview

Mossmoon aggregates multiple SMS-verification providers behind a single balance. You request a number by service (e.g. whatsapp, telegram) and country; the router picks the cheapest in-stock source, hides which provider fulfilled it, and refunds you automatically if the number expires without a code.

SMS · 02

SMS — endpoints

GET/api/v1/services
Every service you can request. No auth required.
GET/api/v1/countries
Supported countries (ISO-2 codes). No auth required.
GET/api/v1/pricing?service=&country=
Current price and availability for a service/country pair.
POST/api/v1/numbers
Buy a number. Body { "service": "whatsapp", "country": "gb" }. Debits your balance and returns an order with id, number, status, price.
GET/api/v1/numbers/:id
Poll an order. Returns the SMS code once it's arrived; status transitions waiting → completed | expired | cancelled | refunded.
DELETE/api/v1/numbers/:id
Cancel a still-pending order and refund the balance.
SMS · 03

SMS — example: buy & poll

# 1. Buy a UK WhatsApp number
curl -X POST https://mossmoon.app/api/v1/numbers \
  -H "Authorization: Bearer $MOSSMOON_KEY" \
  -H "Content-Type: application/json" \
  -d '{"service":"whatsapp","country":"gb"}'

# => { "id": "ord_ab12...", "number": "+44...", "status": "waiting", "price": 0.42 }

# 2. Poll until the code arrives (or it expires + auto-refunds)
curl https://mossmoon.app/api/v1/numbers/ord_ab12... \
  -H "Authorization: Bearer $MOSSMOON_KEY"

# => { "id": "ord_ab12...", "status": "completed", "code": "123456", ... }
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. Same key as the SMS product, same wallet.

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.
}

→ 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.

Region routing. Each line gets a dedicated residential-ISP IP that Mossmoon's WhatsApp companion connects through. 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 for Mode A companion linking.

Geographic match to the customer's phone is a hygiene signal for WhatsApp's anti-abuse, not a strict requirement — WA Web is designed for “primary on phone, companion elsewhere” (laptop at the office, hotel WiFi, etc). 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 5 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. After 15 minutes of no scan, 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 never scans, the line transitions to released after 15 minutes. The assigned proxy IP returns to the regional pool automatically (DB trigger) and is available for the next customer. Nothing sits indefinitely. Agencies that want to keep a customer past 15 minutes need to re-provision via POST /api/v1/wa/lines.

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 15-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.

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.

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.

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

Connection was lost (typically the end-customer's phone has been offline for an extended period). Re-authorize by sending them to the included reconnect_url; same line_id is preserved.

{
  "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/..."
  }
}

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",
    "from": "+15559998888",         // backward-compat: best-effort E.164,
                                    // may be LID-derived (see Contact identity below)
    "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",
    "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 7-day signed Supabase Storage
    // URL; fetch it to process. null on plain-text messages.
    "media_url": null,
    "media_mime": null,
    "media_kind": null,       // "image" | "video" | "audio" | "voice" | "document"
    "media_filename": null
  }
}

message.received (with media)

When a customer sends an image, voice note, document, or video, we mirror it to a private storage bucket and ship a 7-day signed URL. Fetch the URL with a normal GET — no auth header needed, the signature is in the query string. After 7 days the URL stops working; if you need the file long-term, copy it to your own storage on first receipt.

{
  "event": "message.received",
  "line_id": "wa_8f3c2e1a",
  "data": {
    "message_id": "wamsg_doc_abc",
    "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",        // always set for outbound (you passed it)
    "to_wa_id": "[email protected]",   // raw WhatsApp identifier
    "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.Cross-channel matching (SMS, email lookup, etc.). When null, fall back to from_wa_id as the key.
fromAlways set. Best-effort E.164-shaped string (may be a LID dressed up to look like a phone).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 — 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 (150/day)Send succeeds. Response includes X-Mossmoon-Soft-Warning: approaching-daily-cap.
Hard (300/day)Send refused with 429. Response includes Retry-After and X-Mossmoon-Daily-Reset.

Every send response includes:

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

The day window is UTC midnight to UTC midnight. Resets happen automatically.

WA · 09

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 · 10

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 · 11

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 · 12

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