Lightweight, dependency-free, in-memory WhatsApp Cloud API fake for testing code that talks to the Meta Graph API with axios.
Default port: 4657
It speaks the exact wire protocol an axios-based WhatsApp Cloud API integration uses: Bearer-token auth (Authorization: Bearer <ACCESS_TOKEN>), JSON request bodies (and multipart/form-data for media upload), and JSON responses with the Cloud API envelopes ({ messaging_product, contacts, messages } for sends, { id } for uploads, Graph-style { error: { … } } on failure). Everything sent is captured in memory for assertions and can be reset. Zero cost, zero side effects.
The Graph version prefix (e.g. v21.0) in the path is accepted but optional — /{version}/{id}/... and /{id}/... both route the same way.
Implemented Operations
Send messages (POST /{PHONE_NUMBER_ID}/messages)
A single endpoint that dispatches on the type field. Always requires messaging_product: "whatsapp". On success returns 200 with { messaging_product, contacts: [{ input, wa_id }], messages: [{ id: "wamid.…", message_status: "accepted" }] }.
type: "text"—text.bodyrequired. Supportspreview_urland replycontext.message_id.type: "template"—template.name+template.language.coderequired; the template must exist (seededhello_world) and supportscomponents.type: "image" | "audio" | "video" | "document" | "sticker"— the media object requires eitherid(uploaded media) orlink.type: "location"— requireslocation.latitudeandlocation.longitude.type: "contacts"— requires a non-emptycontactsarray.type: "interactive"— requiresinteractive.type(e.g.button,list,cta_url).type: "reaction"— requiresreaction.message_id(andemoji).
Mark as read & typing (POST /{PHONE_NUMBER_ID}/messages)
{ status: "read", message_id }— marks an inbound message read; returns{ success: true }.- Add
typing_indicator: { type: "text" }to also emit a typing indicator.
Media
POST /{PHONE_NUMBER_ID}/media— upload media (multipartmessaging_product+type+file, or JSON). Returns{ id }.GET /{MEDIA_ID}— retrieve media metadata:{ messaging_product, url, mime_type, sha256, file_size, id }. Theurlpoints at the local/__media/{id}download route.DELETE /{MEDIA_ID}— delete uploaded media ({ success: true }).GET /__media/{MEDIA_ID}— download the raw bytes (requires the Bearer token), mirroring Cloud API media downloads.
Phone number & WhatsApp Business Account
GET /{PHONE_NUMBER_ID}— phone number info (display_phone_number,verified_name,quality_rating,code_verification_status, …). Supports?fields=projection.GET /{WABA_ID}/phone_numbers— list phone numbers on the WABA ({ data, paging }).GET /{WABA_ID}— WABA info (name,currency,timezone_id,account_review_status, …). Supports?fields=.
Business profile
GET /{PHONE_NUMBER_ID}/whatsapp_business_profile— get the profile ({ data: [ { messaging_product, about, address, … } ] }). Supports?fields=.POST /{PHONE_NUMBER_ID}/whatsapp_business_profile— update editable fields (about,address,description,email,profile_picture_url,websites,vertical). Requiresmessaging_product.
Message templates (management)
GET /{WABA_ID}/message_templates— list templates ({ data, paging }). Seeded withhello_world(APPROVED).POST /{WABA_ID}/message_templates— create a template (name,category,languagerequired); returns{ id, status: "PENDING", category }.DELETE /{WABA_ID}/message_templates?name=…— delete a template by name ({ success: true }).
Registration & verification
POST /{PHONE_NUMBER_ID}/register— register a number with a 6-digitpin.POST /{PHONE_NUMBER_ID}/deregister— deregister the number.POST /{PHONE_NUMBER_ID}/request_code— request a verification code (code_method∈SMS|VOICE).POST /{PHONE_NUMBER_ID}/verify_code— verify the code (deterministic test code is123456). Must followrequest_code.
Webhook verification
GET /{any-path}?hub.mode=subscribe&hub.verify_token=…&hub.challenge=…— Meta-style verification handshake. Echoeshub.challengeastext/plain200when the token matchesWHATSAPP_VERIFY_TOKEN, else403.
Service & inspection operations (parlel-only, not part of the Cloud API)
GET /— service metadata.GET /health—{ "status": "ok" }.OPTIONS *—204(CORS preflight).POST /__parlel/reset— clear all ephemeral state and re-seed defaults.GET /__parlel/messages— every captured outbound message ({ messages, count }).GET /__parlel/read-receipts— captured read receipts.GET /__parlel/typing— captured typing indicators.GET /__parlel/media— uploaded media metadata (without raw bytes).GET /__parlel/templates— current templates.POST /__parlel/inbound— build & queue an inbound message webhook event ({ from, text, name }); returns the Meta webhook payload.POST /__parlel/status— build & queue a message-status webhook event ({ message_id, status, recipient_id }).GET /__parlel/inbound— list queued webhook events.
Quick Start
import { WhatsappServer } from "./services/whatsapp/src/server.js";
import axios from "axios";
const server = new WhatsappServer(4657);
await server.start();
const ACCESS_TOKEN = "parlel-test-access-token";
const PHONE_NUMBER_ID = "100000000000001";
const wa = axios.create({
baseURL: "http://127.0.0.1:4657/v21.0",
headers: { Authorization: `Bearer ${ACCESS_TOKEN}` },
});
// Send a text message
const { data } = await wa.post(`/${PHONE_NUMBER_ID}/messages`, {
messaging_product: "whatsapp",
recipient_type: "individual",
to: "15551230000",
type: "text",
text: { body: "Hello from parlel!" },
});
console.log(data.messages[0].id); // "wamid.…"
// Send an approved template
await wa.post(`/${PHONE_NUMBER_ID}/messages`, {
messaging_product: "whatsapp",
to: "15551230000",
type: "template",
template: { name: "hello_world", language: { code: "en_US" } },
});
await server.stop();
Surface coverage
This emulator faithfully replicates the API surface most application code and agents exercise. Anything below the supported lines is either an intentional design choice for a fast, zero-cost local emulator (✓ By design) or a candidate for a future release (⟳ Roadmap) — never a silent inaccuracy.
Legend: ✅ fully supported · ◐ accepted (stored, not strictly enforced) · ✓ by design · ⟳ on the roadmap.
| Feature | Supported |
|---|---|
| Send text / template / media / location / contacts / interactive / reaction | ✅ |
Reply context & preview_url | ✅ |
| Mark message as read + typing indicator | ✅ |
| Media upload / metadata / download / delete | ✅ |
Phone number info & WABA phone_numbers | ✅ |
| Business profile get/update | ✅ |
| Message template list/create/delete | ✅ |
| Number register / deregister / request_code / verify_code | ✅ |
Webhook verification handshake (hub.challenge) | ✅ |
| Inbound message & status webhook simulation (control plane) | ✅ |
| Bearer-token auth enforcement | ✅ |
Real OTP/SMS delivery (deterministic 123456 instead) | ✓ By design — Deterministic stub output — repeatable assertions, no API spend |
| Actual message delivery to a phone | ✓ By design — Captured in-memory for inspection — no real messages sent |
Template approval workflow (created templates stay PENDING) | ⟳ Roadmap — (simplified) |
Webhook delivery to your endpoint (build payloads via /__parlel/inbound) | ⟳ Roadmap — (inspect-only) |
| Flows, calling API, billing, analytics, conversation pricing | ⟳ Roadmap |
Error Codes / Shapes
Failures use the Graph API error envelope:
{
"error": {
"message": "(#100) The parameter to is required.",
"type": "GraphMethodException",
"code": 100,
"error_subcode": 33,
"fbtrace_id": "…",
"error_data": { "messaging_product": "whatsapp", "details": "to is required" }
}
}
| HTTP | code | When |
|---|---|---|
| 401 | 190 | Missing or invalid Bearer access token (OAuthException). |
| 400 | 100 | Missing/invalid parameter (messaging_product, to, message body, media id/link, etc.). |
| 404 | 100 | Unknown object id (phone number, media, WABA) or unknown template on delete. |
| 404 | 132001 | Sending a template whose name does not exist. |
| 400 | 136024 | Incorrect verification code in verify_code. |
| 400 | 136025 | verify_code called before request_code. |
| 403 | — | Webhook verification with a non-matching hub.verify_token (text/plain body). |
| 405 | 100 | Method not allowed on a known resource. |
Environment Variables
| Var | Default |
|---|---|
WHATSAPP_ACCESS_TOKEN | parlel-test-access-token |
WHATSAPP_PHONE_NUMBER_ID | 100000000000001 |
WHATSAPP_BUSINESS_ACCOUNT_ID | 200000000000001 |
WHATSAPP_API_VERSION | v21.0 |
WHATSAPP_VERIFY_TOKEN | parlel-verify-token |
WHATSAPP_BASE_URL | http://127.0.0.1:4657 |
Configuration — test.env
Copy these into your test.env (used by the bridge sidecar flow). Tokens are Parlel's seeded test credentials — any non-empty value is accepted by the emulator, so you rarely need to change them. Swap in real credentials only when pointing at the live service in prod.env.
WHATSAPP_ACCESS_TOKEN=parlel-test-access-token
WHATSAPP_PHONE_NUMBER_ID=100000000000001
WHATSAPP_BUSINESS_ACCOUNT_ID=200000000000001
WHATSAPP_API_VERSION=v21.0
WHATSAPP_VERIFY_TOKEN=parlel-verify-token
WHATSAPP_BASE_URL=http://parlel-bridge:4657
<!-- parlel:testenv:end -->