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.
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.
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_xxxxxxxxxxxxxxxxxxxxxxxxPublic endpoints (GET /services, GET /countries) accept anonymous requests; everything else returns 401 unauthorized without a valid key.
Errors
Status codes follow HTTP semantics. Error bodies are stable enough to switch on error programmatically.
{
"error": "insufficient_balance",
"message": "Wallet balance $2.10 is below the $15.00 per-line monthly price.",
"balance_cents": 210,
"required_cents": 5000
}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.
{ "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 — 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 — endpoints
{ "service": "whatsapp", "country": "gb" }. Debits your balance and returns an order with id, number, status, price.waiting → completed | expired | cancelled | refunded.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", ... }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.
WhatsApp — connection flow
- Agency:
POST /api/v1/wa/lineswith{ mode: "existing", webhook_url, agency_external_user_id }. Get back aline_id,connect_url, and awebhook_secretshown exactly once. - Agency: embed the
connect_urlin your product (link button, QR code, or iframe). Forward your end-customer to it. - 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.
- Mossmoon: page shows
✓ Connected. Firesline.readywebhook to yourwebhook_url. Debits the first month from your wallet. - Steady state: inbound messages stream as
message.receivedwebhooks. Outbound viaPOST /lines/:id/send. Delivery state viamessage.deliveredwebhooks.
WhatsApp — endpoints
Base path /api/v1/wa. All require the Authorization: Bearer header.
Provision a line
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:
country_hinton this body (most reliable — pass it if you collect customer country at signup).customer_phone_e164on this body (we parse the country code).- 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.
- Falls back to
NLfor 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
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)
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
status, mode, limit (default 50, max 200).Send a message
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).
// 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
/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)
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 }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
WhatsApp — status values
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_a1b2c3Delivery semantics
- At-least-once. Use
X-Mossmoon-Delivery-Idfor 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.
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.
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:
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"
}
}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.
Every send response includes:
X-Mossmoon-Daily-Limit: 300
X-Mossmoon-Daily-Remaining: 247
X-Mossmoon-Daily-Reset: 2026-05-31T00:00:00.000ZThe day window is UTC midnight to UTC midnight. Resets happen automatically.
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.
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:
// 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=truePlace a call
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/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
Call status values
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.
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
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.
WhatsApp — pricing & billing
WhatsApp lines bill flat per month, per line. Two prices, depending on calling:
- 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.releasedwebhook withreason: "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.
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