Quick start
The Mursilat API lets your system send messages from your own linked number — every type, from text and media to polls, buttons and lists, through one endpoint — and receive customer replies and delivery updates on your webhook.
- Link your number in the dashboard: Numbers → Add number, then scan the QR code.
- Open Numbers → your number → Developer and generate an API key (shown once) and choose its permissions.
- In the same tab, save your webhook URL, choose version 2, and copy the signing secret (shown once).
- Send your first buttons message (below). Every send needs an
Idempotency-Key. - The customer taps a button — or types its number — and you receive
interactive.replyon your webhook.
curl -X POST "https://mursilat.com/api/v1/whatsapp/public/messages" \
-H "Content-Type: application/json" \
-H "x-api-key: $WASLA_API_KEY" \
-H "Idempotency-Key: order-1042-confirm" \
-d '{
"to": "201001234567",
"type": "buttons",
"body": {
"text": "Do you confirm your order?"
},
"footer": {
"text": "Al Nour Store"
},
"buttons": [
{
"id": "confirm",
"title": "Confirm"
},
{
"id": "cancel_order",
"title": "Cancel order"
}
],
"reference": "order-1042"
}'// Node.js 18+ (built-in fetch)
const res = await fetch('https://mursilat.com/api/v1/whatsapp/public/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.WASLA_API_KEY,
'Idempotency-Key': 'order-1042-confirm',
},
body: JSON.stringify({
"to": "201001234567",
"type": "buttons",
"body": {
"text": "Do you confirm your order?"
},
"footer": {
"text": "Al Nour Store"
},
"buttons": [
{
"id": "confirm",
"title": "Confirm"
},
{
"id": "cancel_order",
"title": "Cancel order"
}
],
"reference": "order-1042"
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.code}: ${data.message}`);
console.log(data);<?php
$payload = [
'to' => '201001234567',
'type' => 'buttons',
'body' => [
'text' => 'Do you confirm your order?',
],
'footer' => [
'text' => 'Al Nour Store',
],
'buttons' => [
[
'id' => 'confirm',
'title' => 'Confirm',
],
[
'id' => 'cancel_order',
'title' => 'Cancel order',
],
],
'reference' => 'order-1042',
];
$ch = curl_init('https://mursilat.com/api/v1/whatsapp/public/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . getenv('WASLA_API_KEY'),
'Idempotency-Key: order-1042-confirm',
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($status >= 400) {
throw new RuntimeException($data['code'] . ': ' . json_encode($data['message']));
}
print_r($data);import os
import requests
payload = {
"to": "201001234567",
"type": "buttons",
"body": {
"text": "Do you confirm your order?",
},
"footer": {
"text": "Al Nour Store",
},
"buttons": [
{
"id": "confirm",
"title": "Confirm",
},
{
"id": "cancel_order",
"title": "Cancel order",
},
],
"reference": "order-1042",
}
res = requests.post(
"https://mursilat.com/api/v1/whatsapp/public/messages",
headers={
"x-api-key": os.environ["WASLA_API_KEY"],
"Idempotency-Key": "order-1042-confirm",
},
json=payload,
timeout=30,
)
data = res.json()
if not res.ok:
raise RuntimeError(f"{data.get('code')}: {data.get('message')}")
print(data)Base URL: https://mursilat.com/api/v1. Requests and responses are JSON (UTF-8); times are ISO-8601 in UTC. Phone numbers are international digits without + (e.g. 201001234567).
Authentication & API keys
Each number has its own API key. Send it in the x-api-key header from your server (never from a browser or mobile app). We store only a fingerprint of the key, so it is shown once when you generate it; generating a new one stops the old key immediately.
| Permission (scope) | Allows |
|---|---|
send | AllowsEvery send on POST /messages, react/edit/delete, GET /interactions/:id |
media | AllowsMedia types (together with send), media uploads and downloads |
webhook | AllowsReceiving webhook events |
API access is part of your plan. Without it every call answers 403 API_ACCESS_NOT_IN_PLAN.
Idempotency
Every send requires an Idempotency-Key header (up to 191 characters, unique per message — e.g. order-1042-confirm). If your request times out, retry with the same key: you get the original result and the message is never sent twice.
IDEMPOTENCY_KEY_REQUIRED— the header is missing.IDEMPOTENCY_KEY_REUSED— the key was already used with a different body.IDEMPOTENCY_IN_PROGRESS— the first request with this key is still running; retry in a few seconds.
Send any message (unified endpoint)
POST /whatsapp/public/messages is the one endpoint for every message type: send { "to": "…", "type": "…", …fields }. Scope send (media types also need media), a plan with API access, and an Idempotency-Key header.
| `type` | What it is | Details |
|---|---|---|
text | What it isText with WhatsApp formatting and an optional link preview | DetailsText |
image · video · audio · voice · document | What it isPhotos, videos (GIF / round video note), audio files, voice notes, files | DetailsMedia |
sticker | What it isWebP sticker — PNG/JPG/GIF are converted | DetailsStickers |
location | What it isA pin on the map (not live location) | DetailsLocation |
contact | What it is1–5 contact cards | DetailsContact cards |
poll | What it isQuestion with 2–12 options; votes come back as poll.vote | DetailsPolls |
event | What it isEvent invitation — experimental | DetailsEvents |
buttons · list · cta_url | What it isReply buttons, list menu, link button (with numbered fallback) | DetailsButtons |
Common fields
| Field | Notes |
|---|---|
to | NotesRequired. International digits, no + (e.g. 201001234567). |
type | NotesRequired. One of the types above. Others answer 400 MESSAGE_TYPE_NOT_SUPPORTED with a reason (see Not possible). |
reference | NotesOptional, ≤128. Your own id, echoed in message.status, interactive.reply and poll.vote. |
replyTo | NotesOptional. Quote a message: the messageId a send returned, or a Mursilat inbox message id, in this chat. |
curl -X POST "https://mursilat.com/api/v1/whatsapp/public/messages" \
-H "Content-Type: application/json" \
-H "x-api-key: $WASLA_API_KEY" \
-H "Idempotency-Key: order-1042-shipped" \
-d '{
"to": "201001234567",
"type": "text",
"text": "Hi Mona, your order #1042 has shipped and arrives tomorrow.",
"reference": "order-1042"
}'// Node.js 18+ (built-in fetch)
const res = await fetch('https://mursilat.com/api/v1/whatsapp/public/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.WASLA_API_KEY,
'Idempotency-Key': 'order-1042-shipped',
},
body: JSON.stringify({
"to": "201001234567",
"type": "text",
"text": "Hi Mona, your order #1042 has shipped and arrives tomorrow.",
"reference": "order-1042"
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.code}: ${data.message}`);
console.log(data);<?php
$payload = [
'to' => '201001234567',
'type' => 'text',
'text' => 'Hi Mona, your order #1042 has shipped and arrives tomorrow.',
'reference' => 'order-1042',
];
$ch = curl_init('https://mursilat.com/api/v1/whatsapp/public/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . getenv('WASLA_API_KEY'),
'Idempotency-Key: order-1042-shipped',
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($status >= 400) {
throw new RuntimeException($data['code'] . ': ' . json_encode($data['message']));
}
print_r($data);import os
import requests
payload = {
"to": "201001234567",
"type": "text",
"text": "Hi Mona, your order #1042 has shipped and arrives tomorrow.",
"reference": "order-1042",
}
res = requests.post(
"https://mursilat.com/api/v1/whatsapp/public/messages",
headers={
"x-api-key": os.environ["WASLA_API_KEY"],
"Idempotency-Key": "order-1042-shipped",
},
json=payload,
timeout=30,
)
data = res.json()
if not res.ok:
raise RuntimeError(f"{data.get('code')}: {data.get('message')}")
print(data){
"messageId": "3EB0A1B2C3D4E5F60718",
"status": "sent",
"type": "text",
"reference": "order-1042",
"interactionId": null,
"format": null,
"fallbackIncluded": null,
"estimatedDelayMs": null
}Under load a send may be "status": "queued" with estimatedDelayMs: the message id is reserved and the final result arrives as a message.status webhook. Interactive types also return interactionId, format and fallbackIncluded.
Media: a link or an upload
"media": { "url": "https://…" }— a public link we download once, safely (no private addresses, no redirects, 20 s, size limit of the type)."media": { "id": "med_…" }— a file uploaded first with the upload endpoint (kept privately for 24 hours, same number).- Or send
multipart/form-datawith the same fields plusfile(nested objects as JSON strings). - We check the real file type from its bytes, never from the name: a mismatch answers
415 MESSAGE_MEDIA_TYPE_NOT_ALLOWED.
curl -X POST "https://mursilat.com/api/v1/whatsapp/public/messages" \
-H "x-api-key: $WASLA_API_KEY" \
-H "Idempotency-Key: invoice-1042-doc" \
-F "to=201001234567" \
-F "type=document" \
-F "fileName=invoice-1042.pdf" \
-F "file=@./invoice-1042.pdf"Legacy endpoints keep working
message/send, media/send and interactive/send are legacy: they stay supported with exactly the same request and response, and go through the same sending path. New integrations should use POST /whatsapp/public/messages.
Text
"type": "text" — up to 4096 characters. WhatsApp formatting passes through: *bold* _italic_ ~strike~ and triple backticks for monospace.
| Field | Limits | Notes |
|---|---|---|
text | Limits1–4096 | NotesRequired. |
linkPreview | Limitsboolean | NotesDefault true: we build a preview (title, description, image) of the first https link, safely, within 3 seconds. No preview when the page can’t be read — the text is still sent. |
mentions | Limits— | NotesGroup chats only (400 MESSAGE_MENTIONS_GROUP_ONLY); sending to groups isn’t available yet. |
curl -X POST "https://mursilat.com/api/v1/whatsapp/public/messages" \
-H "Content-Type: application/json" \
-H "x-api-key: $WASLA_API_KEY" \
-H "Idempotency-Key: order-1042-shipped" \
-d '{
"to": "201001234567",
"type": "text",
"text": "Hi Mona, your order #1042 has shipped and arrives tomorrow.",
"reference": "order-1042"
}'// Node.js 18+ (built-in fetch)
const res = await fetch('https://mursilat.com/api/v1/whatsapp/public/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.WASLA_API_KEY,
'Idempotency-Key': 'order-1042-shipped',
},
body: JSON.stringify({
"to": "201001234567",
"type": "text",
"text": "Hi Mona, your order #1042 has shipped and arrives tomorrow.",
"reference": "order-1042"
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.code}: ${data.message}`);
console.log(data);<?php
$payload = [
'to' => '201001234567',
'type' => 'text',
'text' => 'Hi Mona, your order #1042 has shipped and arrives tomorrow.',
'reference' => 'order-1042',
];
$ch = curl_init('https://mursilat.com/api/v1/whatsapp/public/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . getenv('WASLA_API_KEY'),
'Idempotency-Key: order-1042-shipped',
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($status >= 400) {
throw new RuntimeException($data['code'] . ': ' . json_encode($data['message']));
}
print_r($data);import os
import requests
payload = {
"to": "201001234567",
"type": "text",
"text": "Hi Mona, your order #1042 has shipped and arrives tomorrow.",
"reference": "order-1042",
}
res = requests.post(
"https://mursilat.com/api/v1/whatsapp/public/messages",
headers={
"x-api-key": os.environ["WASLA_API_KEY"],
"Idempotency-Key": "order-1042-shipped",
},
json=payload,
timeout=30,
)
data = res.json()
if not res.ok:
raise RuntimeError(f"{data.get('code')}: {data.get('message')}")
print(data)On the phone: a normal chat bubble on every WhatsApp version. Link previews show on all recent apps.
Images, video, audio, voice notes & documents
Every media type takes media (a link or an upload id — see Media: a link or an upload).
| `type` | Formats & size | Extra fields |
|---|---|---|
image | Formats & sizeJPEG · PNG · WebP, ≤5 MB | Extra fieldscaption ≤1024 · viewOnce |
video | Formats & sizeMP4 · 3GPP, ≤16 MB | Extra fieldscaption ≤1024 · viewOnce · gif · videoNote |
audio | Formats & sizeOGG · MP3 · M4A · AAC · AMR, ≤16 MB | Extra fields— |
voice | Formats & sizeAny of the audio formats, ≤16 MB | Extra fieldsviewOnce |
document | Formats & sizeAny file except programs, ≤100 MB | Extra fieldsfileName ≤120 · caption ≤1024 |
Image
curl -X POST "https://mursilat.com/api/v1/whatsapp/public/messages" \
-H "Content-Type: application/json" \
-H "x-api-key: $WASLA_API_KEY" \
-H "Idempotency-Key: launch-photo-201001234567" \
-d '{
"to": "201001234567",
"type": "image",
"media": {
"url": "https://example.com/products/new.jpg"
},
"caption": "The new product is here 🎉"
}'// Node.js 18+ (built-in fetch)
const res = await fetch('https://mursilat.com/api/v1/whatsapp/public/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.WASLA_API_KEY,
'Idempotency-Key': 'launch-photo-201001234567',
},
body: JSON.stringify({
"to": "201001234567",
"type": "image",
"media": {
"url": "https://example.com/products/new.jpg"
},
"caption": "The new product is here 🎉"
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.code}: ${data.message}`);
console.log(data);<?php
$payload = [
'to' => '201001234567',
'type' => 'image',
'media' => [
'url' => 'https://example.com/products/new.jpg',
],
'caption' => 'The new product is here 🎉',
];
$ch = curl_init('https://mursilat.com/api/v1/whatsapp/public/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . getenv('WASLA_API_KEY'),
'Idempotency-Key: launch-photo-201001234567',
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($status >= 400) {
throw new RuntimeException($data['code'] . ': ' . json_encode($data['message']));
}
print_r($data);import os
import requests
payload = {
"to": "201001234567",
"type": "image",
"media": {
"url": "https://example.com/products/new.jpg",
},
"caption": "The new product is here 🎉",
}
res = requests.post(
"https://mursilat.com/api/v1/whatsapp/public/messages",
headers={
"x-api-key": os.environ["WASLA_API_KEY"],
"Idempotency-Key": "launch-photo-201001234567",
},
json=payload,
timeout=30,
)
data = res.json()
if not res.ok:
raise RuntimeError(f"{data.get('code')}: {data.get('message')}")
print(data)On the phone: a photo with its caption. With viewOnce: true it opens once and then disappears — view-once needs a recent WhatsApp version; older apps may not open it.
Video, GIF & round video note
curl -X POST "https://mursilat.com/api/v1/whatsapp/public/messages" \
-H "Content-Type: application/json" \
-H "x-api-key: $WASLA_API_KEY" \
-H "Idempotency-Key: howto-video-201001234567" \
-d '{
"to": "201001234567",
"type": "video",
"media": {
"id": "med_01J9ZK4A2B3C4D5E6F7G8H9J0K"
},
"caption": "How to use it in one minute"
}'// Node.js 18+ (built-in fetch)
const res = await fetch('https://mursilat.com/api/v1/whatsapp/public/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.WASLA_API_KEY,
'Idempotency-Key': 'howto-video-201001234567',
},
body: JSON.stringify({
"to": "201001234567",
"type": "video",
"media": {
"id": "med_01J9ZK4A2B3C4D5E6F7G8H9J0K"
},
"caption": "How to use it in one minute"
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.code}: ${data.message}`);
console.log(data);<?php
$payload = [
'to' => '201001234567',
'type' => 'video',
'media' => [
'id' => 'med_01J9ZK4A2B3C4D5E6F7G8H9J0K',
],
'caption' => 'How to use it in one minute',
];
$ch = curl_init('https://mursilat.com/api/v1/whatsapp/public/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . getenv('WASLA_API_KEY'),
'Idempotency-Key: howto-video-201001234567',
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($status >= 400) {
throw new RuntimeException($data['code'] . ': ' . json_encode($data['message']));
}
print_r($data);import os
import requests
payload = {
"to": "201001234567",
"type": "video",
"media": {
"id": "med_01J9ZK4A2B3C4D5E6F7G8H9J0K",
},
"caption": "How to use it in one minute",
}
res = requests.post(
"https://mursilat.com/api/v1/whatsapp/public/messages",
headers={
"x-api-key": os.environ["WASLA_API_KEY"],
"Idempotency-Key": "howto-video-201001234567",
},
json=payload,
timeout=30,
)
data = res.json()
if not res.ok:
raise RuntimeError(f"{data.get('code')}: {data.get('message')}")
print(data)gif: true— plays silently in a loop, like a GIF.videoNote: true— a round video note: at most 60 seconds (MESSAGE_VIDEO_NOTE_TOO_LONG), no caption, not view-once, not GIF. A square video looks best (we don’t re-encode). Older apps may show it as a normal video.
curl -X POST "https://mursilat.com/api/v1/whatsapp/public/messages" \
-H "Content-Type: application/json" \
-H "x-api-key: $WASLA_API_KEY" \
-H "Idempotency-Key: thanks-note-201001234567" \
-d '{
"to": "201001234567",
"type": "video",
"media": {
"id": "med_01J9ZK4A2B3C4D5E6F7G8H9J0L"
},
"videoNote": true
}'// Node.js 18+ (built-in fetch)
const res = await fetch('https://mursilat.com/api/v1/whatsapp/public/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.WASLA_API_KEY,
'Idempotency-Key': 'thanks-note-201001234567',
},
body: JSON.stringify({
"to": "201001234567",
"type": "video",
"media": {
"id": "med_01J9ZK4A2B3C4D5E6F7G8H9J0L"
},
"videoNote": true
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.code}: ${data.message}`);
console.log(data);<?php
$payload = [
'to' => '201001234567',
'type' => 'video',
'media' => [
'id' => 'med_01J9ZK4A2B3C4D5E6F7G8H9J0L',
],
'videoNote' => true,
];
$ch = curl_init('https://mursilat.com/api/v1/whatsapp/public/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . getenv('WASLA_API_KEY'),
'Idempotency-Key: thanks-note-201001234567',
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($status >= 400) {
throw new RuntimeException($data['code'] . ': ' . json_encode($data['message']));
}
print_r($data);import os
import requests
payload = {
"to": "201001234567",
"type": "video",
"media": {
"id": "med_01J9ZK4A2B3C4D5E6F7G8H9J0L",
},
"videoNote": True,
}
res = requests.post(
"https://mursilat.com/api/v1/whatsapp/public/messages",
headers={
"x-api-key": os.environ["WASLA_API_KEY"],
"Idempotency-Key": "thanks-note-201001234567",
},
json=payload,
timeout=30,
)
data = res.json()
if not res.ok:
raise RuntimeError(f"{data.get('code')}: {data.get('message')}")
print(data)Audio file
curl -X POST "https://mursilat.com/api/v1/whatsapp/public/messages" \
-H "Content-Type: application/json" \
-H "x-api-key: $WASLA_API_KEY" \
-H "Idempotency-Key: jingle-201001234567" \
-d '{
"to": "201001234567",
"type": "audio",
"media": {
"url": "https://example.com/audio/jingle.mp3"
}
}'// Node.js 18+ (built-in fetch)
const res = await fetch('https://mursilat.com/api/v1/whatsapp/public/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.WASLA_API_KEY,
'Idempotency-Key': 'jingle-201001234567',
},
body: JSON.stringify({
"to": "201001234567",
"type": "audio",
"media": {
"url": "https://example.com/audio/jingle.mp3"
}
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.code}: ${data.message}`);
console.log(data);<?php
$payload = [
'to' => '201001234567',
'type' => 'audio',
'media' => [
'url' => 'https://example.com/audio/jingle.mp3',
],
];
$ch = curl_init('https://mursilat.com/api/v1/whatsapp/public/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . getenv('WASLA_API_KEY'),
'Idempotency-Key: jingle-201001234567',
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($status >= 400) {
throw new RuntimeException($data['code'] . ': ' . json_encode($data['message']));
}
print_r($data);import os
import requests
payload = {
"to": "201001234567",
"type": "audio",
"media": {
"url": "https://example.com/audio/jingle.mp3",
},
}
res = requests.post(
"https://mursilat.com/api/v1/whatsapp/public/messages",
headers={
"x-api-key": os.environ["WASLA_API_KEY"],
"Idempotency-Key": "jingle-201001234567",
},
json=payload,
timeout=30,
)
data = res.json()
if not res.ok:
raise RuntimeError(f"{data.get('code')}: {data.get('message')}")
print(data)On the phone: an audio file with a play button and its name — not a voice note.
Voice note
curl -X POST "https://mursilat.com/api/v1/whatsapp/public/messages" \
-H "Content-Type: application/json" \
-H "x-api-key: $WASLA_API_KEY" \
-H "Idempotency-Key: voice-reply-1042" \
-d '{
"to": "201001234567",
"type": "voice",
"media": {
"id": "med_01J9ZK4A2B3C4D5E6F7G8H9J0M"
},
"reference": "order-1042"
}'// Node.js 18+ (built-in fetch)
const res = await fetch('https://mursilat.com/api/v1/whatsapp/public/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.WASLA_API_KEY,
'Idempotency-Key': 'voice-reply-1042',
},
body: JSON.stringify({
"to": "201001234567",
"type": "voice",
"media": {
"id": "med_01J9ZK4A2B3C4D5E6F7G8H9J0M"
},
"reference": "order-1042"
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.code}: ${data.message}`);
console.log(data);<?php
$payload = [
'to' => '201001234567',
'type' => 'voice',
'media' => [
'id' => 'med_01J9ZK4A2B3C4D5E6F7G8H9J0M',
],
'reference' => 'order-1042',
];
$ch = curl_init('https://mursilat.com/api/v1/whatsapp/public/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . getenv('WASLA_API_KEY'),
'Idempotency-Key: voice-reply-1042',
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($status >= 400) {
throw new RuntimeException($data['code'] . ': ' . json_encode($data['message']));
}
print_r($data);import os
import requests
payload = {
"to": "201001234567",
"type": "voice",
"media": {
"id": "med_01J9ZK4A2B3C4D5E6F7G8H9J0M",
},
"reference": "order-1042",
}
res = requests.post(
"https://mursilat.com/api/v1/whatsapp/public/messages",
headers={
"x-api-key": os.environ["WASLA_API_KEY"],
"Idempotency-Key": "voice-reply-1042",
},
json=payload,
timeout=30,
)
data = res.json()
if not res.ok:
raise RuntimeError(f"{data.get('code')}: {data.get('message')}")
print(data)On the phone: a voice note, like one recorded in the app. OGG/Opus and WebM keep the waveform; other formats are converted when possible, otherwise they still play as a voice note without a waveform.
Document
curl -X POST "https://mursilat.com/api/v1/whatsapp/public/messages" \
-H "Content-Type: application/json" \
-H "x-api-key: $WASLA_API_KEY" \
-H "Idempotency-Key: order-1042-invoice" \
-d '{
"to": "201001234567",
"type": "document",
"media": {
"url": "https://example.com/invoices/1042.pdf"
},
"fileName": "invoice-1042.pdf",
"caption": "Invoice for order #1042",
"reference": "order-1042"
}'// Node.js 18+ (built-in fetch)
const res = await fetch('https://mursilat.com/api/v1/whatsapp/public/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.WASLA_API_KEY,
'Idempotency-Key': 'order-1042-invoice',
},
body: JSON.stringify({
"to": "201001234567",
"type": "document",
"media": {
"url": "https://example.com/invoices/1042.pdf"
},
"fileName": "invoice-1042.pdf",
"caption": "Invoice for order #1042",
"reference": "order-1042"
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.code}: ${data.message}`);
console.log(data);<?php
$payload = [
'to' => '201001234567',
'type' => 'document',
'media' => [
'url' => 'https://example.com/invoices/1042.pdf',
],
'fileName' => 'invoice-1042.pdf',
'caption' => 'Invoice for order #1042',
'reference' => 'order-1042',
];
$ch = curl_init('https://mursilat.com/api/v1/whatsapp/public/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . getenv('WASLA_API_KEY'),
'Idempotency-Key: order-1042-invoice',
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($status >= 400) {
throw new RuntimeException($data['code'] . ': ' . json_encode($data['message']));
}
print_r($data);import os
import requests
payload = {
"to": "201001234567",
"type": "document",
"media": {
"url": "https://example.com/invoices/1042.pdf",
},
"fileName": "invoice-1042.pdf",
"caption": "Invoice for order #1042",
"reference": "order-1042",
}
res = requests.post(
"https://mursilat.com/api/v1/whatsapp/public/messages",
headers={
"x-api-key": os.environ["WASLA_API_KEY"],
"Idempotency-Key": "order-1042-invoice",
},
json=payload,
timeout=30,
)
data = res.json()
if not res.ok:
raise RuntimeError(f"{data.get('code')}: {data.get('message')}")
print(data)On the phone: a file card with its name, size and type. fileName defaults to the uploaded file’s name or the last part of the URL.
Stickers
"type": "sticker" with media.
- WebP (static or animated) up to 500 KB is sent as is.
- PNG, JPG and GIF (up to 5 MB) are converted to a 512×512 WebP sticker with a transparent background; an animated GIF becomes an animated sticker.
- Anything else answers
MESSAGE_STICKER_INVALID.
curl -X POST "https://mursilat.com/api/v1/whatsapp/public/messages" \
-H "Content-Type: application/json" \
-H "x-api-key: $WASLA_API_KEY" \
-H "Idempotency-Key: thanks-sticker-1042" \
-d '{
"to": "201001234567",
"type": "sticker",
"media": {
"url": "https://example.com/stickers/thanks.webp"
}
}'// Node.js 18+ (built-in fetch)
const res = await fetch('https://mursilat.com/api/v1/whatsapp/public/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.WASLA_API_KEY,
'Idempotency-Key': 'thanks-sticker-1042',
},
body: JSON.stringify({
"to": "201001234567",
"type": "sticker",
"media": {
"url": "https://example.com/stickers/thanks.webp"
}
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.code}: ${data.message}`);
console.log(data);<?php
$payload = [
'to' => '201001234567',
'type' => 'sticker',
'media' => [
'url' => 'https://example.com/stickers/thanks.webp',
],
];
$ch = curl_init('https://mursilat.com/api/v1/whatsapp/public/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . getenv('WASLA_API_KEY'),
'Idempotency-Key: thanks-sticker-1042',
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($status >= 400) {
throw new RuntimeException($data['code'] . ': ' . json_encode($data['message']));
}
print_r($data);import os
import requests
payload = {
"to": "201001234567",
"type": "sticker",
"media": {
"url": "https://example.com/stickers/thanks.webp",
},
}
res = requests.post(
"https://mursilat.com/api/v1/whatsapp/public/messages",
headers={
"x-api-key": os.environ["WASLA_API_KEY"],
"Idempotency-Key": "thanks-sticker-1042",
},
json=payload,
timeout=30,
)
data = res.json()
if not res.ok:
raise RuntimeError(f"{data.get('code')}: {data.get('message')}")
print(data)On the phone: a sticker without a bubble, on every recent WhatsApp version.
Location
"type": "location" — a static pin. Live location can’t be sent from a linked number.
| Field | Limits |
|---|---|
latitude | Limits−90 … 90 (required) |
longitude | Limits−180 … 180 (required) |
name | Limits≤100 |
address | Limits≤200 |
curl -X POST "https://mursilat.com/api/v1/whatsapp/public/messages" \
-H "Content-Type: application/json" \
-H "x-api-key: $WASLA_API_KEY" \
-H "Idempotency-Key: store-location-1042" \
-d '{
"to": "201001234567",
"type": "location",
"latitude": 30.0444,
"longitude": 31.2357,
"name": "Al Nour Store",
"address": "12 Tahrir St, Cairo"
}'// Node.js 18+ (built-in fetch)
const res = await fetch('https://mursilat.com/api/v1/whatsapp/public/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.WASLA_API_KEY,
'Idempotency-Key': 'store-location-1042',
},
body: JSON.stringify({
"to": "201001234567",
"type": "location",
"latitude": 30.0444,
"longitude": 31.2357,
"name": "Al Nour Store",
"address": "12 Tahrir St, Cairo"
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.code}: ${data.message}`);
console.log(data);<?php
$payload = [
'to' => '201001234567',
'type' => 'location',
'latitude' => 30.0444,
'longitude' => 31.2357,
'name' => 'Al Nour Store',
'address' => '12 Tahrir St, Cairo',
];
$ch = curl_init('https://mursilat.com/api/v1/whatsapp/public/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . getenv('WASLA_API_KEY'),
'Idempotency-Key: store-location-1042',
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($status >= 400) {
throw new RuntimeException($data['code'] . ': ' . json_encode($data['message']));
}
print_r($data);import os
import requests
payload = {
"to": "201001234567",
"type": "location",
"latitude": 30.0444,
"longitude": 31.2357,
"name": "Al Nour Store",
"address": "12 Tahrir St, Cairo",
}
res = requests.post(
"https://mursilat.com/api/v1/whatsapp/public/messages",
headers={
"x-api-key": os.environ["WASLA_API_KEY"],
"Idempotency-Key": "store-location-1042",
},
json=payload,
timeout=30,
)
data = res.json()
if not res.ok:
raise RuntimeError(f"{data.get('code')}: {data.get('message')}")
print(data)On the phone: a map preview with the name and address; tapping it opens the map app.
Contact cards
"type": "contact" — 1 to 5 cards. We build the vCard on the server (escaped, with a WhatsApp link per phone).
| Field | Limits |
|---|---|
contacts[] | Limits1–5 |
contacts[].name | Limits≤100 (required) |
contacts[].phones[] | Limits1–3, digits with the country code (+ and spaces allowed) |
contacts[].email | Limits≤120, a valid email |
contacts[].organization | Limits≤100 |
curl -X POST "https://mursilat.com/api/v1/whatsapp/public/messages" \
-H "Content-Type: application/json" \
-H "x-api-key: $WASLA_API_KEY" \
-H "Idempotency-Key: support-card-1042" \
-d '{
"to": "201001234567",
"type": "contact",
"contacts": [
{
"name": "Al Nour Store support",
"phones": [
"+20 100 302 2759"
],
"email": "support@example.com",
"organization": "Al Nour Store"
}
]
}'// Node.js 18+ (built-in fetch)
const res = await fetch('https://mursilat.com/api/v1/whatsapp/public/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.WASLA_API_KEY,
'Idempotency-Key': 'support-card-1042',
},
body: JSON.stringify({
"to": "201001234567",
"type": "contact",
"contacts": [
{
"name": "Al Nour Store support",
"phones": [
"+20 100 302 2759"
],
"email": "support@example.com",
"organization": "Al Nour Store"
}
]
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.code}: ${data.message}`);
console.log(data);<?php
$payload = [
'to' => '201001234567',
'type' => 'contact',
'contacts' => [
[
'name' => 'Al Nour Store support',
'phones' => [
'+20 100 302 2759',
],
'email' => 'support@example.com',
'organization' => 'Al Nour Store',
],
],
];
$ch = curl_init('https://mursilat.com/api/v1/whatsapp/public/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . getenv('WASLA_API_KEY'),
'Idempotency-Key: support-card-1042',
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($status >= 400) {
throw new RuntimeException($data['code'] . ': ' . json_encode($data['message']));
}
print_r($data);import os
import requests
payload = {
"to": "201001234567",
"type": "contact",
"contacts": [
{
"name": "Al Nour Store support",
"phones": [
"+20 100 302 2759",
],
"email": "support@example.com",
"organization": "Al Nour Store",
},
],
}
res = requests.post(
"https://mursilat.com/api/v1/whatsapp/public/messages",
headers={
"x-api-key": os.environ["WASLA_API_KEY"],
"Idempotency-Key": "support-card-1042",
},
json=payload,
timeout=30,
)
data = res.json()
if not res.ok:
raise RuntimeError(f"{data.get('code')}: {data.get('message')}")
print(data)On the phone: a contact card with “Message” and “Add” buttons; several cards show as one bubble.
Polls
"type": "poll" — a question with 2–12 different options. Votes come back to you.
| Field | Limits |
|---|---|
name | LimitsThe question, ≤255 (required) |
options[] | Limits2–12, unique, each ≤100 |
selectableCount | Limits1 (default) = one answer · up to the number of options · 0 = any number of answers |
curl -X POST "https://mursilat.com/api/v1/whatsapp/public/messages" \
-H "Content-Type: application/json" \
-H "x-api-key: $WASLA_API_KEY" \
-H "Idempotency-Key: survey-7-201001234567" \
-d '{
"to": "201001234567",
"type": "poll",
"name": "Which day suits you for delivery?",
"options": [
"Sunday",
"Monday",
"Tuesday"
],
"selectableCount": 1,
"reference": "survey-7"
}'// Node.js 18+ (built-in fetch)
const res = await fetch('https://mursilat.com/api/v1/whatsapp/public/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.WASLA_API_KEY,
'Idempotency-Key': 'survey-7-201001234567',
},
body: JSON.stringify({
"to": "201001234567",
"type": "poll",
"name": "Which day suits you for delivery?",
"options": [
"Sunday",
"Monday",
"Tuesday"
],
"selectableCount": 1,
"reference": "survey-7"
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.code}: ${data.message}`);
console.log(data);<?php
$payload = [
'to' => '201001234567',
'type' => 'poll',
'name' => 'Which day suits you for delivery?',
'options' => [
'Sunday',
'Monday',
'Tuesday',
],
'selectableCount' => 1,
'reference' => 'survey-7',
];
$ch = curl_init('https://mursilat.com/api/v1/whatsapp/public/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . getenv('WASLA_API_KEY'),
'Idempotency-Key: survey-7-201001234567',
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($status >= 400) {
throw new RuntimeException($data['code'] . ': ' . json_encode($data['message']));
}
print_r($data);import os
import requests
payload = {
"to": "201001234567",
"type": "poll",
"name": "Which day suits you for delivery?",
"options": [
"Sunday",
"Monday",
"Tuesday",
],
"selectableCount": 1,
"reference": "survey-7",
}
res = requests.post(
"https://mursilat.com/api/v1/whatsapp/public/messages",
headers={
"x-api-key": os.environ["WASLA_API_KEY"],
"Idempotency-Key": "survey-7-201001234567",
},
json=payload,
timeout=30,
)
data = res.json()
if not res.ok:
raise RuntimeError(f"{data.get('code')}: {data.get('message')}")
print(data)Votes (poll.vote)
poll.vote
One event each time a person votes or changes their vote, for polls your number sent. selectedOptions is their current choice ([] = vote removed). reference is what you sent. Votes also appear in the Mursilat inbox.
{
"id": "evt_01J9ZK3Q8V6Y2M4N7P1R5T0W9H",
"type": "poll.vote",
"apiVersion": "2026-10",
"createdAt": "2026-10-10T12:04:00.000Z",
"sessionId": "cm1abc2def3ghi4jkl5mno6p",
"number": "201100302759",
"data": {
"messageId": "3EB0A1B2C3D4E5F60718",
"reference": "survey-7",
"from": {
"phone": "201001234567",
"lid": null,
"name": "Mona Ali"
},
"selectedOptions": [
"Sunday"
],
"timestamp": "2026-10-10T12:04:00.000Z"
}
}On the phone: a native poll on all recent WhatsApp versions.
Events (experimental)
Experimental
Many phones show events only in groups and communities, so a 1:1 event may appear as “unsupported message” on some phones. Test with your own phone first.
| Field | Limits |
|---|---|
name | Limits≤100 (required) |
description | Limits≤2048 |
startAt | LimitsISO date, in the future, at most one year ahead (required) |
endAt | LimitsISO date after startAt |
locationName | Limits≤100 |
extraGuestsAllowed | Limitsboolean |
curl -X POST "https://mursilat.com/api/v1/whatsapp/public/messages" \
-H "Content-Type: application/json" \
-H "x-api-key: $WASLA_API_KEY" \
-H "Idempotency-Key: opening-201001234567" \
-d '{
"to": "201001234567",
"type": "event",
"name": "Our new branch opening",
"description": "20% off for the first 50 customers",
"startAt": "2026-11-01T17:00:00.000Z",
"endAt": "2026-11-01T21:00:00.000Z",
"locationName": "Al Nour Store — Nasr City"
}'// Node.js 18+ (built-in fetch)
const res = await fetch('https://mursilat.com/api/v1/whatsapp/public/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.WASLA_API_KEY,
'Idempotency-Key': 'opening-201001234567',
},
body: JSON.stringify({
"to": "201001234567",
"type": "event",
"name": "Our new branch opening",
"description": "20% off for the first 50 customers",
"startAt": "2026-11-01T17:00:00.000Z",
"endAt": "2026-11-01T21:00:00.000Z",
"locationName": "Al Nour Store — Nasr City"
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.code}: ${data.message}`);
console.log(data);<?php
$payload = [
'to' => '201001234567',
'type' => 'event',
'name' => 'Our new branch opening',
'description' => '20% off for the first 50 customers',
'startAt' => '2026-11-01T17:00:00.000Z',
'endAt' => '2026-11-01T21:00:00.000Z',
'locationName' => 'Al Nour Store — Nasr City',
];
$ch = curl_init('https://mursilat.com/api/v1/whatsapp/public/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . getenv('WASLA_API_KEY'),
'Idempotency-Key: opening-201001234567',
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($status >= 400) {
throw new RuntimeException($data['code'] . ': ' . json_encode($data['message']));
}
print_r($data);import os
import requests
payload = {
"to": "201001234567",
"type": "event",
"name": "Our new branch opening",
"description": "20% off for the first 50 customers",
"startAt": "2026-11-01T17:00:00.000Z",
"endAt": "2026-11-01T21:00:00.000Z",
"locationName": "Al Nour Store — Nasr City",
}
res = requests.post(
"https://mursilat.com/api/v1/whatsapp/public/messages",
headers={
"x-api-key": os.environ["WASLA_API_KEY"],
"Idempotency-Key": "opening-201001234567",
},
json=payload,
timeout=30,
)
data = res.json()
if not res.ok:
raise RuntimeError(f"{data.get('code')}: {data.get('message')}")
print(data)Send a list
"type": "list" — a menu button that opens up to 10 rows, grouped in sections. Rows can have a description.
| Field | Limits |
|---|---|
list.buttonText | Limits≤20 — the menu button label |
list.sections[] | Limits1–10 sections, title ≤24 (optional) |
rows[] | Limits1–10 rows in total · id ≤64 unique · title ≤24 · description ≤72 (optional) |
curl -X POST "https://mursilat.com/api/v1/whatsapp/public/messages" \
-H "Content-Type: application/json" \
-H "x-api-key: $WASLA_API_KEY" \
-H "Idempotency-Key: menu-201001234567-1" \
-d '{
"to": "201001234567",
"type": "list",
"body": {
"text": "How can we help?"
},
"list": {
"buttonText": "Choose",
"sections": [
{
"title": "Services",
"rows": [
{
"id": "prices",
"title": "Prices",
"description": "See our prices"
},
{
"id": "hours",
"title": "Opening hours"
}
]
},
{
"title": "Support",
"rows": [
{
"id": "agent",
"title": "Talk to a person",
"description": "Someone from the team replies"
}
]
}
]
},
"reference": "menu-main"
}'// Node.js 18+ (built-in fetch)
const res = await fetch('https://mursilat.com/api/v1/whatsapp/public/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.WASLA_API_KEY,
'Idempotency-Key': 'menu-201001234567-1',
},
body: JSON.stringify({
"to": "201001234567",
"type": "list",
"body": {
"text": "How can we help?"
},
"list": {
"buttonText": "Choose",
"sections": [
{
"title": "Services",
"rows": [
{
"id": "prices",
"title": "Prices",
"description": "See our prices"
},
{
"id": "hours",
"title": "Opening hours"
}
]
},
{
"title": "Support",
"rows": [
{
"id": "agent",
"title": "Talk to a person",
"description": "Someone from the team replies"
}
]
}
]
},
"reference": "menu-main"
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.code}: ${data.message}`);
console.log(data);<?php
$payload = [
'to' => '201001234567',
'type' => 'list',
'body' => [
'text' => 'How can we help?',
],
'list' => [
'buttonText' => 'Choose',
'sections' => [
[
'title' => 'Services',
'rows' => [
[
'id' => 'prices',
'title' => 'Prices',
'description' => 'See our prices',
],
[
'id' => 'hours',
'title' => 'Opening hours',
],
],
],
[
'title' => 'Support',
'rows' => [
[
'id' => 'agent',
'title' => 'Talk to a person',
'description' => 'Someone from the team replies',
],
],
],
],
],
'reference' => 'menu-main',
];
$ch = curl_init('https://mursilat.com/api/v1/whatsapp/public/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . getenv('WASLA_API_KEY'),
'Idempotency-Key: menu-201001234567-1',
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($status >= 400) {
throw new RuntimeException($data['code'] . ': ' . json_encode($data['message']));
}
print_r($data);import os
import requests
payload = {
"to": "201001234567",
"type": "list",
"body": {
"text": "How can we help?",
},
"list": {
"buttonText": "Choose",
"sections": [
{
"title": "Services",
"rows": [
{
"id": "prices",
"title": "Prices",
"description": "See our prices",
},
{
"id": "hours",
"title": "Opening hours",
},
],
},
{
"title": "Support",
"rows": [
{
"id": "agent",
"title": "Talk to a person",
"description": "Someone from the team replies",
},
],
},
],
},
"reference": "menu-main",
}
res = requests.post(
"https://mursilat.com/api/v1/whatsapp/public/messages",
headers={
"x-api-key": os.environ["WASLA_API_KEY"],
"Idempotency-Key": "menu-201001234567-1",
},
json=payload,
timeout=30,
)
data = res.json()
if not res.ok:
raise RuntimeError(f"{data.get('code')}: {data.get('message')}")
print(data)The numbered fallback numbers the rows in order across sections (1…n), so typed numbers always match.
Link button (CTA)
"type": "cta_url" — one button that opens a link. It expects no answer, so there is no interactive.reply for it.
| Field | Limits |
|---|---|
cta.title | Limits≤20 |
cta.url | LimitsPublic https:// URL |
curl -X POST "https://mursilat.com/api/v1/whatsapp/public/messages" \
-H "Content-Type: application/json" \
-H "x-api-key: $WASLA_API_KEY" \
-H "Idempotency-Key: order-1042-track" \
-d '{
"to": "201001234567",
"type": "cta_url",
"body": {
"text": "Your parcel is on its way 🚚"
},
"cta": {
"title": "Track shipment",
"url": "https://example.com/track/1042"
},
"reference": "order-1042"
}'// Node.js 18+ (built-in fetch)
const res = await fetch('https://mursilat.com/api/v1/whatsapp/public/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.WASLA_API_KEY,
'Idempotency-Key': 'order-1042-track',
},
body: JSON.stringify({
"to": "201001234567",
"type": "cta_url",
"body": {
"text": "Your parcel is on its way 🚚"
},
"cta": {
"title": "Track shipment",
"url": "https://example.com/track/1042"
},
"reference": "order-1042"
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.code}: ${data.message}`);
console.log(data);<?php
$payload = [
'to' => '201001234567',
'type' => 'cta_url',
'body' => [
'text' => 'Your parcel is on its way 🚚',
],
'cta' => [
'title' => 'Track shipment',
'url' => 'https://example.com/track/1042',
],
'reference' => 'order-1042',
];
$ch = curl_init('https://mursilat.com/api/v1/whatsapp/public/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . getenv('WASLA_API_KEY'),
'Idempotency-Key: order-1042-track',
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($status >= 400) {
throw new RuntimeException($data['code'] . ': ' . json_encode($data['message']));
}
print_r($data);import os
import requests
payload = {
"to": "201001234567",
"type": "cta_url",
"body": {
"text": "Your parcel is on its way 🚚",
},
"cta": {
"title": "Track shipment",
"url": "https://example.com/track/1042",
},
"reference": "order-1042",
}
res = requests.post(
"https://mursilat.com/api/v1/whatsapp/public/messages",
headers={
"x-api-key": os.environ["WASLA_API_KEY"],
"Idempotency-Key": "order-1042-track",
},
json=payload,
timeout=30,
)
data = res.json()
if not res.ok:
raise RuntimeError(f"{data.get('code')}: {data.get('message')}")
print(data)The fallback appends the link (🔗 https://…). Link rules apply to the URL: shorteners are blocked, and numbers younger than 7 days can’t send links to new contacts (see Limits & number protection).
Not possible from a linked number
These types answer 400 MESSAGE_TYPE_NOT_SUPPORTED with details.type and details.reason. They are features of the phone app or of the official Cloud API, not of a linked number.
| `type` | `reason` | Why |
|---|---|---|
live_location | `reason`LINKED_DEVICE_LIMITATION | WhyLive location is shared only from the phone itself. Send a location pin instead. |
payment | `reason`NOT_AVAILABLE | WhyWhatsApp Pay isn’t available in Egypt or Saudi Arabia, nor on linked devices. |
product · catalog · order | `reason`BUSINESS_CATALOG_ONLY | WhyCatalog messages need the WhatsApp Business catalog on the phone. Send an image + a link button instead. |
status · story | `reason`OUT_OF_SCOPE | WhyStatus updates aren’t chat messages. |
call | `reason`LINKED_DEVICE_LIMITATION | WhyCalls can’t be placed from a linked device. |
template | `reason`CLOUD_API_ONLY | WhyMessage templates exist only on the official Cloud API. |
album | `reason`SEND_ITEMS_SEPARATELY | WhySend each image or video as its own message. |
| anything else | `reason`UNKNOWN_TYPE | WhyNot a message type. |
Upload media
POST /whatsapp/public/media/upload — scope media, multipart/form-data with file (≤100 MB; the limit of the type applies when you send it). Upload once, then send "media": { "id": "…" } — useful for large files or sending the same file to many customers.
curl -X POST "https://mursilat.com/api/v1/whatsapp/public/media/upload" \
-H "x-api-key: $WASLA_API_KEY" \
-F "file=@./photo.jpg"
# → { "mediaId": "med_01J9ZK4A2B3C4D5E6F7G8H9J0K", "mimeType": "image/jpeg", "size": 184233,
# "fileName": "photo.jpg", "expiresAt": "2026-10-11T12:00:00.000Z" }| Response field | Notes |
|---|---|
mediaId | NotesUse it as media.id (same number only). |
mimeType · size · fileName | NotesWhat we detected. |
expiresAt | Notes24 hours after the upload. After that 410 MESSAGE_MEDIA_EXPIRED — upload again. |
Uploads are private (never public links) and deleted automatically after 24 hours. Idempotency-Key is optional here.
React, edit & delete
POST /whatsapp/public/messages/:messageId/react | edit | delete — :messageId is the id a send returned (or a Mursilat inbox message id) on your key’s number. Scope send, Idempotency-Key required. Actions don’t send message.status.
React
curl -X POST "https://mursilat.com/api/v1/whatsapp/public/messages/3EB0A1B2C3D4E5F60718/react" \
-H "Content-Type: application/json" \
-H "x-api-key: $WASLA_API_KEY" \
-H "Idempotency-Key: react-3EB0A1B2-1" \
-d '{
"emoji": "👍"
}'// Node.js 18+ (built-in fetch)
const res = await fetch('https://mursilat.com/api/v1/whatsapp/public/messages/3EB0A1B2C3D4E5F60718/react', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.WASLA_API_KEY,
'Idempotency-Key': 'react-3EB0A1B2-1',
},
body: JSON.stringify({
"emoji": "👍"
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.code}: ${data.message}`);
console.log(data);<?php
$payload = [
'emoji' => '👍',
];
$ch = curl_init('https://mursilat.com/api/v1/whatsapp/public/messages/3EB0A1B2C3D4E5F60718/react');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . getenv('WASLA_API_KEY'),
'Idempotency-Key: react-3EB0A1B2-1',
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($status >= 400) {
throw new RuntimeException($data['code'] . ': ' . json_encode($data['message']));
}
print_r($data);import os
import requests
payload = {
"emoji": "👍",
}
res = requests.post(
"https://mursilat.com/api/v1/whatsapp/public/messages/3EB0A1B2C3D4E5F60718/react",
headers={
"x-api-key": os.environ["WASLA_API_KEY"],
"Idempotency-Key": "react-3EB0A1B2-1",
},
json=payload,
timeout=30,
)
data = res.json()
if not res.ok:
raise RuntimeError(f"{data.get('code')}: {data.get('message')}")
print(data)One emoji ("" removes your reaction). Otherwise MESSAGE_REACTION_INVALID. Response: { messageId, action: "react", emoji }.
Edit
curl -X POST "https://mursilat.com/api/v1/whatsapp/public/messages/3EB0A1B2C3D4E5F60718/edit" \
-H "Content-Type: application/json" \
-H "x-api-key: $WASLA_API_KEY" \
-H "Idempotency-Key: edit-3EB0A1B2-1" \
-d '{
"text": "Hi Mona, your order #1042 has shipped and arrives the day after tomorrow."
}'// Node.js 18+ (built-in fetch)
const res = await fetch('https://mursilat.com/api/v1/whatsapp/public/messages/3EB0A1B2C3D4E5F60718/edit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.WASLA_API_KEY,
'Idempotency-Key': 'edit-3EB0A1B2-1',
},
body: JSON.stringify({
"text": "Hi Mona, your order #1042 has shipped and arrives the day after tomorrow."
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.code}: ${data.message}`);
console.log(data);<?php
$payload = [
'text' => 'Hi Mona, your order #1042 has shipped and arrives the day after tomorrow.',
];
$ch = curl_init('https://mursilat.com/api/v1/whatsapp/public/messages/3EB0A1B2C3D4E5F60718/edit');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . getenv('WASLA_API_KEY'),
'Idempotency-Key: edit-3EB0A1B2-1',
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($status >= 400) {
throw new RuntimeException($data['code'] . ': ' . json_encode($data['message']));
}
print_r($data);import os
import requests
payload = {
"text": "Hi Mona, your order #1042 has shipped and arrives the day after tomorrow.",
}
res = requests.post(
"https://mursilat.com/api/v1/whatsapp/public/messages/3EB0A1B2C3D4E5F60718/edit",
headers={
"x-api-key": os.environ["WASLA_API_KEY"],
"Idempotency-Key": "edit-3EB0A1B2-1",
},
json=payload,
timeout=30,
)
data = res.json()
if not res.ok:
raise RuntimeError(f"{data.get('code')}: {data.get('message')}")
print(data)Only text messages your number sent through the API or Mursilat, within 15 minutes (409 MESSAGE_EDIT_WINDOW_PASSED, 409 MESSAGE_NOT_EDITABLE). The new text passes the same content rules. The customer sees “Edited”.
Delete
curl -X POST "https://mursilat.com/api/v1/whatsapp/public/messages/3EB0A1B2C3D4E5F60718/delete" \
-H "Content-Type: application/json" \
-H "x-api-key: $WASLA_API_KEY" \
-H "Idempotency-Key: delete-3EB0A1B2-1" \
-d '{
"forEveryone": true
}'// Node.js 18+ (built-in fetch)
const res = await fetch('https://mursilat.com/api/v1/whatsapp/public/messages/3EB0A1B2C3D4E5F60718/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.WASLA_API_KEY,
'Idempotency-Key': 'delete-3EB0A1B2-1',
},
body: JSON.stringify({
"forEveryone": true
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.code}: ${data.message}`);
console.log(data);<?php
$payload = [
'forEveryone' => true,
];
$ch = curl_init('https://mursilat.com/api/v1/whatsapp/public/messages/3EB0A1B2C3D4E5F60718/delete');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . getenv('WASLA_API_KEY'),
'Idempotency-Key: delete-3EB0A1B2-1',
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($status >= 400) {
throw new RuntimeException($data['code'] . ': ' . json_encode($data['message']));
}
print_r($data);import os
import requests
payload = {
"forEveryone": True,
}
res = requests.post(
"https://mursilat.com/api/v1/whatsapp/public/messages/3EB0A1B2C3D4E5F60718/delete",
headers={
"x-api-key": os.environ["WASLA_API_KEY"],
"Idempotency-Key": "delete-3EB0A1B2-1",
},
json=payload,
timeout=30,
)
data = res.json()
if not res.ok:
raise RuntimeError(f"{data.get('code')}: {data.get('message')}")
print(data)forEveryone: true deletes your own message for everyone within 48 hours (409 MESSAGE_DELETE_WINDOW_PASSED, 409 MESSAGE_NOT_DELETABLE); the customer sees “This message was deleted”. false deletes it for you only (hidden on your phone and in the inbox).
Replies (interactive.reply)
When the customer answers a buttons or list message you receive interactive.reply — whether they tapped a button, picked a list row, typed the number or typed the option title. reply.source tells you how: button · list · number · title.
- First answer wins. The interaction becomes
ANSWERED; later answers arrive only asmessage.received. - Typed answers are matched to the customer’s latest open interaction on that number: digits
1…n(Latin or Arabic١ ٢ ٣) or the option title. - Text that doesn’t match is a normal
message.received— we never guess. - After
expiresInMinutesthe interaction isEXPIREDand no longer matched. - Opt-out words (e.g. “stop”, “إلغاء”) are always handled as an opt-out first, so option titles can’t be opt-out words, and titles must be distinct.
interactive.reply
Use reference (or interactionId) to find your order, and reply.id for the chosen option.
{
"id": "evt_01J9ZK3Q8V6Y2M4N7P1R5T0W9X",
"type": "interactive.reply",
"apiVersion": "2026-10",
"createdAt": "2026-10-10T12:00:03.000Z",
"sessionId": "cm1abc2def3ghi4jkl5mno6p",
"number": "201100302759",
"data": {
"interactionId": "int_8f2c1d9a7b3e4f56a1b2c3d4",
"messageId": "3EB0A1B2C3D4E5F60718",
"reference": "order-1042",
"from": {
"phone": "201001234567",
"lid": null,
"name": "Mona Ali"
},
"reply": {
"id": "confirm",
"title": "Confirm",
"index": 1,
"source": "button"
},
"replyMessageId": "3EB0C9D8E7F6A5B40312",
"timestamp": "2026-10-10T12:00:03.000Z"
}
}Check an interaction
GET /whatsapp/public/interactions/:interactionId — scope send. Useful as a fallback if a webhook was missed.
curl -X GET "https://mursilat.com/api/v1/whatsapp/public/interactions/int_8f2c1d9a7b3e4f56a1b2c3d4" \
-H "x-api-key: $WASLA_API_KEY"// Node.js 18+ (built-in fetch)
const res = await fetch('https://mursilat.com/api/v1/whatsapp/public/interactions/int_8f2c1d9a7b3e4f56a1b2c3d4', {
method: 'GET',
headers: {
'x-api-key': process.env.WASLA_API_KEY,
},
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.code}: ${data.message}`);
console.log(data);<?php
$ch = curl_init('https://mursilat.com/api/v1/whatsapp/public/interactions/int_8f2c1d9a7b3e4f56a1b2c3d4');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'x-api-key: ' . getenv('WASLA_API_KEY'),
],
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($status >= 400) {
throw new RuntimeException($data['code'] . ': ' . json_encode($data['message']));
}
print_r($data);import os
import requests
res = requests.get(
"https://mursilat.com/api/v1/whatsapp/public/interactions/int_8f2c1d9a7b3e4f56a1b2c3d4",
headers={
"x-api-key": os.environ["WASLA_API_KEY"],
},
timeout=30,
)
data = res.json()
if not res.ok:
raise RuntimeError(f"{data.get('code')}: {data.get('message')}")
print(data){
"interactionId": "int_8f2c1d9a7b3e4f56a1b2c3d4",
"messageId": "3EB0A1B2C3D4E5F60718",
"type": "buttons",
"reference": "order-1042",
"status": "ANSWERED",
"options": [
{
"id": "confirm",
"title": "Confirm",
"index": 1
},
{
"id": "cancel_order",
"title": "Cancel order",
"index": 2
}
],
"answer": {
"id": "confirm",
"title": "Confirm",
"index": 1,
"source": "number",
"replyMessageId": "3EB0C9D8E7F6A5B40312",
"answeredAt": "2026-10-10T12:00:03.000Z"
},
"expiresAt": "2026-10-11T12:00:00.000Z",
"createdAt": "2026-10-10T12:00:00.000Z"
}Webhooks
Webhooks push events to your server as they happen: incoming messages, delivery updates, interactive replies, opt-outs and number status. Version 2 has a stable, documented shape for every event.
Setup
- Numbers → your number → Developer: save your URL, choose version 2, and pick the events you want.
- Copy the signing secret (shown once — you can generate a new one at any time).
- Click Send a test event and check the result in the Webhook log.
- Public
https://(orhttp://) URL on port 80 or 443. Private and internal addresses are rejected. - Answer with any
2xxwithin 10 seconds — do slow work after responding. - Redirects are not followed.
POST /webhooks/mursilat HTTP/1.1
Content-Type: application/json
User-Agent: Wasla-Webhooks/2
X-Wasla-Event: interactive.reply
X-Wasla-Delivery: dlv_01J9ZK3R2B7C8D9E0F1G2H3J4K
X-Wasla-Webhook-Version: 2
X-Wasla-Signature: t=1760097603,v1=3f6c2b5e9a0d4c7f8e1b2a3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f70| Envelope field | Description |
|---|---|
id | DescriptionUnique event id (evt_…). The same on every retry and on manual resends — dedupe on it. |
type | DescriptionEvent type, e.g. interactive.reply. |
apiVersion | Description2026-10 |
createdAt | DescriptionWhen the event happened (ISO-8601 UTC). Use it to order events. |
sessionId · number | DescriptionWhich of your numbers it is about. |
data | DescriptionEvent-specific payload (below). |
Events
message.received
A customer sent a message. type is one of text · image · video · audio · voice · document · sticker · location · contact · reaction · interactive_reply · unsupported. We never send the raw WhatsApp object.
{
"id": "evt_01J9ZK3Q8V6Y2M4N7P1R5T0W9A",
"type": "message.received",
"apiVersion": "2026-10",
"createdAt": "2026-10-10T12:00:00.000Z",
"sessionId": "cm1abc2def3ghi4jkl5mno6p",
"number": "201100302759",
"data": {
"messageId": "3EB0F1E2D3C4B5A69788",
"from": {
"phone": "201001234567",
"lid": null,
"name": "Mona Ali"
},
"chat": {
"type": "direct"
},
"type": "text",
"text": "When will my order arrive?",
"caption": null,
"media": null,
"location": null,
"replyTo": null,
"timestamp": "2026-10-10T12:00:00.000Z"
}
}message.received(image)
Media messages include media.url: a signed download link valid for 24 hours. A group message has chat.type: "group".
{
"id": "evt_01J9ZK3Q8V6Y2M4N7P1R5T0W9B",
"type": "message.received",
"apiVersion": "2026-10",
"createdAt": "2026-10-10T12:01:00.000Z",
"sessionId": "cm1abc2def3ghi4jkl5mno6p",
"number": "201100302759",
"data": {
"messageId": "3EB0A9B8C7D6E5F40011",
"from": {
"phone": "201001234567",
"lid": null,
"name": "Mona Ali"
},
"chat": {
"type": "group",
"groupId": "120363025746123456@g.us",
"groupName": "Store customers"
},
"type": "image",
"text": null,
"caption": "Receipt photo",
"media": {
"id": "cm2media0001",
"mimeType": "image/jpeg",
"size": 184233,
"url": "https://mursilat.com/api/v1/whatsapp/public/media/cm2media0001?exp=1760184060&sig=5d41402abc4b2a76b9719d911017c592"
},
"location": null,
"replyTo": {
"messageId": "3EB0A1B2C3D4E5F60718"
},
"timestamp": "2026-10-10T12:01:00.000Z"
}
}# Signed link from the webhook (valid 24h) — no API key needed:
curl -o receipt.jpg "https://mursilat.com/api/v1/whatsapp/public/media/cm2media0001?exp=1760184060&sig=5d41…"
# After it expires: use your API key (scope: media)
curl -o receipt.jpg -H "x-api-key: $WASLA_API_KEY" "https://mursilat.com/api/v1/whatsapp/public/media/cm2media0001"message.status
Delivery updates for messages you sent (API and dashboard): sent · delivered · read · failed (with error). reference is what you sent.
{
"id": "evt_01J9ZK3Q8V6Y2M4N7P1R5T0W9C",
"type": "message.status",
"apiVersion": "2026-10",
"createdAt": "2026-10-10T12:00:02.000Z",
"sessionId": "cm1abc2def3ghi4jkl5mno6p",
"number": "201100302759",
"data": {
"messageId": "3EB0A1B2C3D4E5F60718",
"reference": "order-1042",
"to": "201001234567",
"status": "delivered",
"error": null,
"timestamp": "2026-10-10T12:00:02.000Z"
}
}interactive.reply
The customer answered your buttons or list (see Replies).
{
"id": "evt_01J9ZK3Q8V6Y2M4N7P1R5T0W9X",
"type": "interactive.reply",
"apiVersion": "2026-10",
"createdAt": "2026-10-10T12:00:03.000Z",
"sessionId": "cm1abc2def3ghi4jkl5mno6p",
"number": "201100302759",
"data": {
"interactionId": "int_8f2c1d9a7b3e4f56a1b2c3d4",
"messageId": "3EB0A1B2C3D4E5F60718",
"reference": "order-1042",
"from": {
"phone": "201001234567",
"lid": null,
"name": "Mona Ali"
},
"reply": {
"id": "confirm",
"title": "Confirm",
"index": 1,
"source": "button"
},
"replyMessageId": "3EB0C9D8E7F6A5B40312",
"timestamp": "2026-10-10T12:00:03.000Z"
}
}poll.vote
Someone voted (or changed their vote) on a poll your number sent (see Polls).
{
"id": "evt_01J9ZK3Q8V6Y2M4N7P1R5T0W9H",
"type": "poll.vote",
"apiVersion": "2026-10",
"createdAt": "2026-10-10T12:04:00.000Z",
"sessionId": "cm1abc2def3ghi4jkl5mno6p",
"number": "201100302759",
"data": {
"messageId": "3EB0A1B2C3D4E5F60718",
"reference": "survey-7",
"from": {
"phone": "201001234567",
"lid": null,
"name": "Mona Ali"
},
"selectedOptions": [
"Sunday"
],
"timestamp": "2026-10-10T12:04:00.000Z"
}
}contact.opted_out
The customer asked to stop (opt-out keyword). Further sends to them are blocked with SAFETY_SUPPRESSED.
{
"id": "evt_01J9ZK3Q8V6Y2M4N7P1R5T0W9D",
"type": "contact.opted_out",
"apiVersion": "2026-10",
"createdAt": "2026-10-10T12:05:00.000Z",
"sessionId": "cm1abc2def3ghi4jkl5mno6p",
"number": "201100302759",
"data": {
"phone": "201001234567",
"lid": null,
"keyword": "STOP",
"timestamp": "2026-10-10T12:05:00.000Z"
}
}contact.opted_in
The customer can receive messages again. keyword is null for manual changes.
{
"id": "evt_01J9ZK3Q8V6Y2M4N7P1R5T0W9E",
"type": "contact.opted_in",
"apiVersion": "2026-10",
"createdAt": "2026-10-10T12:06:00.000Z",
"sessionId": "cm1abc2def3ghi4jkl5mno6p",
"number": "201100302759",
"data": {
"phone": "201001234567",
"lid": null,
"keyword": null,
"timestamp": "2026-10-10T12:06:00.000Z"
}
}session.status
Your number’s connection changed: connected · disconnected · banned · logged_out.
{
"id": "evt_01J9ZK3Q8V6Y2M4N7P1R5T0W9F",
"type": "session.status",
"apiVersion": "2026-10",
"createdAt": "2026-10-10T12:07:00.000Z",
"sessionId": "cm1abc2def3ghi4jkl5mno6p",
"number": "201100302759",
"data": {
"status": "disconnected",
"reason": "connection_lost",
"timestamp": "2026-10-10T12:07:00.000Z"
}
}webhook.test
Sent by the Send a test event button. Always uses the v2 envelope and is delivered even if not in your event list.
{
"id": "evt_01J9ZK3Q8V6Y2M4N7P1R5T0W9G",
"type": "webhook.test",
"apiVersion": "2026-10",
"createdAt": "2026-10-10T12:08:00.000Z",
"sessionId": "cm1abc2def3ghi4jkl5mno6p",
"number": "201100302759",
"data": {
"message": "This is a test event from Mursilat",
"timestamp": "2026-10-10T12:08:00.000Z"
}
}Verify the signature
Every request carries X-Wasla-Signature: t=<unix seconds>,v1=<hex>, where v1 = HMAC-SHA256(secret, "<t>.<raw body>") in lowercase hex.
- Read the raw request body (bytes as received) — before any JSON parsing.
- Split the header on
,and=to gettandv1. - Reject if
tis more than 5 minutes from your clock (replay protection). - Compute the HMAC-SHA256 of
t + "." + rawBodywith your secret, as hex. - Compare with
v1using a constant-time comparison. Only then parse the JSON.
// Node.js + Express. Use the RAW body: JSON.parse → JSON.stringify changes bytes and breaks the signature.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.WASLA_WEBHOOK_SECRET;
const seen = new Set(); // use Redis/DB in production
function verifyWaslaSignature(rawBody, header, secret, toleranceSec = 300) {
if (!header || !secret) return false;
const parts = {};
for (const pair of header.split(',')) {
const i = pair.indexOf('=');
if (i > 0) parts[pair.slice(0, i).trim()] = pair.slice(i + 1).trim();
}
const t = parts.t;
const v1 = parts.v1;
if (!/^\d+$/.test(t || '') || !/^[0-9a-f]{64}$/i.test(v1 || '')) return false;
// Reject old (replayed) or far-future timestamps.
if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > toleranceSec) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(t + '.')
.update(rawBody) // the RAW bytes exactly as received, not re-serialized JSON
.digest();
const received = Buffer.from(v1, 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
app.post('/webhooks/mursilat', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyWaslaSignature(req.body, req.get('X-Wasla-Signature'), SECRET)) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
res.sendStatus(200); // answer fast (within 10s); do the work after
if (seen.has(event.id)) return; // retries reuse the same event id
seen.add(event.id);
if (event.type === 'interactive.reply') {
console.log(event.data.reference, '→', event.data.reply.id);
}
});
app.listen(3000);<?php
// Use the RAW body (php://input), never a re-encoded json_decode() result.
function wasla_verify_signature(string $rawBody, string $header, string $secret, int $toleranceSec = 300): bool
{
$parts = [];
foreach (explode(',', $header) as $pair) {
$kv = explode('=', trim($pair), 2);
if (count($kv) === 2) {
$parts[$kv[0]] = $kv[1];
}
}
$t = $parts['t'] ?? '';
$v1 = $parts['v1'] ?? '';
if ($secret === '' || !ctype_digit($t) || !preg_match('/^[0-9a-f]{64}$/i', $v1)) {
return false;
}
// Reject old (replayed) or far-future timestamps.
if (abs(time() - (int) $t) > $toleranceSec) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, strtolower($v1)); // constant-time
}
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_WASLA_SIGNATURE'] ?? '';
if (!wasla_verify_signature($rawBody, $header, getenv('WASLA_WEBHOOK_SECRET') ?: '')) {
http_response_code(401);
exit('invalid signature');
}
$event = json_decode($rawBody, true);
http_response_code(200); // answer fast (within 10s)
// Retries reuse the same event id — store it and skip duplicates.
if ($event['type'] === 'interactive.reply') {
error_log($event['data']['reference'] . ' -> ' . $event['data']['reply']['id']);
}# Python 3 + Flask. Use the RAW body (request.get_data()), not request.json re-encoded.
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["WASLA_WEBHOOK_SECRET"]
def verify_wasla_signature(raw_body: bytes, header: str, secret: str, tolerance_sec: int = 300) -> bool:
parts = {}
for pair in (header or "").split(","):
key, sep, value = pair.strip().partition("=")
if sep:
parts[key] = value
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not secret or not t.isdigit() or len(v1) != 64:
return False
# Reject old (replayed) or far-future timestamps.
if abs(time.time() - int(t)) > tolerance_sec:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1.lower()) # constant-time
@app.post("/webhooks/mursilat")
def mursilat_webhook():
raw = request.get_data() # bytes exactly as received
if not verify_wasla_signature(raw, request.headers.get("X-Wasla-Signature", ""), SECRET):
abort(401)
event = json.loads(raw)
# Retries reuse the same event id — store it and skip duplicates.
if event["type"] == "interactive.reply":
print(event["data"]["reference"], "->", event["data"]["reply"]["id"])
return "", 200 # answer fast (within 10s)# Recompute a signature by hand (debugging). body.json = the raw request body, byte for byte.
# Header: X-Wasla-Signature: t=1760097603,v1=<hex>
T=1760097603
printf '%s.' "$T" | cat - body.json | openssl dgst -sha256 -hmac "$WASLA_WEBHOOK_SECRET" -hex
# → compare the hex digest with v1 (and check that T is within 5 minutes of now)Most “invalid signature” bugs come from verifying a re-serialized body (JSON.stringify(req.body)) instead of the raw bytes, or from a body-parsing middleware that runs first.
Retries, ordering & idempotency
| Attempt | When |
|---|---|
| 1 | WhenImmediately |
| 2 | When+10 seconds |
| 3 | When+1 minute |
| 4 | When+5 minutes |
| 5 | When+15 minutes |
| 6 | When+30 minutes (≈51 minutes after the first) |
2xx= delivered. Other4xx/5xx, timeouts and connection errors are retried.410 Gone= “stop sending”: the webhook is turned off immediately and you’re notified. Save the URL again in the Developer tab to turn it back on.X-Wasla-Deliverystays the same across retries of one delivery; a manual resend gets a new one (same eventid).- Up to 4 requests at a time per URL, so events can arrive out of order. Order by
createdAtand make your handler idempotent onid.
Test event
The Send a test event button (Developer tab) sends webhook.test to your URL. Use it to check reachability and your signature code. It is disabled — with the reason shown — while no URL is saved or the webhook is turned off.
Delivery log
The Webhook log in the Developer tab keeps every delivery for 7 days: status, attempt n/6, HTTP status, duration and next retry time. Open a delivery to see the payload, request headers (signature hidden), the first 2 KB of your server’s response, every attempt — and to resend it.
Errors
Errors use one shape. Program against code (stable); message is English text for humans and may change. details gives the field or, for “try later” errors, retryAt / retryAfterSeconds.
{
"statusCode": 400,
"code": "INTERACTIVE_TOO_MANY_BUTTONS",
"message": [
"buttons: at most 3 buttons are allowed"
],
"error": "INTERACTIVE_TOO_MANY_BUTTONS",
"details": {
"field": "buttons"
},
"timestamp": "2026-10-10T12:00:00.000Z",
"path": "/api/v1/whatsapp/public/interactive/send"
}| Code | HTTP | Meaning |
|---|---|---|
INTERACTIVE_INVALID_TYPE | HTTP400 | Meaningtype must be buttons, list or cta_url. |
INTERACTIVE_BODY_REQUIRED | HTTP400 | Meaningbody.text is missing. |
INTERACTIVE_TEXT_TOO_LONG | HTTP400 | MeaningHeader, body or footer is over its limit. |
INTERACTIVE_TITLE_TOO_LONG | HTTP400 | MeaningA button / row / section / CTA title is over its limit. |
INTERACTIVE_TOO_MANY_BUTTONS | HTTP400 | MeaningMore than 3 buttons. |
INTERACTIVE_NO_OPTIONS | HTTP400 | MeaningNo buttons / rows / CTA given. |
INTERACTIVE_TOO_MANY_ROWS | HTTP400 | MeaningMore than 10 list rows. |
INTERACTIVE_TOO_MANY_SECTIONS | HTTP400 | MeaningMore than 10 list sections. |
INTERACTIVE_DUPLICATE_ID | HTTP400 | MeaningTwo options share an id. |
INTERACTIVE_DUPLICATE_TITLE | HTTP400 | MeaningTwo options share a title (typed titles must be distinguishable). |
INTERACTIVE_INVALID_ID | HTTP400 | MeaningAn id has characters outside A-Z a-z 0-9 _ . : - or is too long. |
INTERACTIVE_INVALID_URL | HTTP400 | MeaningThe CTA URL isn’t a public https:// URL. |
INTERACTIVE_INVALID_EXPIRY | HTTP400 | MeaningexpiresInMinutes outside 5–10080. |
INTERACTIVE_INVALID_FALLBACK | HTTP400 | Meaningfallback must be numbered or none. |
INTERACTIVE_INVALID_LOCALE | HTTP400 | Meaninglocale must be ar or en. |
INTERACTIVE_TITLE_REQUIRED | HTTP400 | MeaningA button / row / section / link title is missing. |
INTERACTIVE_REFERENCE_TOO_LONG | HTTP400 | Meaningreference over 128 characters. |
INTERACTIVE_RESERVED_TITLE | HTTP400 | MeaningAn option title is an opt-out word (e.g. “stop”). |
INTERACTIVE_UNEXPECTED_FIELD | HTTP400 | MeaningA field that doesn’t belong to this type (e.g. buttons on a list). |
MESSAGE_TYPE_REQUIRED · MESSAGE_TYPE_NOT_SUPPORTED | HTTP400 | Meaningtype is missing, or can’t be sent from a linked number (details.reason, see Not possible). |
MESSAGE_FIELD_REQUIRED · MESSAGE_FIELD_INVALID | HTTP400 | MeaningA required field is missing / has an invalid value (details.field, details.reason). |
MESSAGE_TEXT_TOO_LONG | HTTP400 | MeaningA text field is over its limit (details.limit). |
MESSAGE_UNEXPECTED_FIELD | HTTP400 | MeaningA field of another type, e.g. caption on audio or on a round video note. |
MESSAGE_MENTIONS_GROUP_ONLY | HTTP400 | MeaningMentions work in group chats only. |
MESSAGE_MEDIA_REQUIRED | HTTP400 | MeaningNo media (or file) for a media type. |
MESSAGE_MEDIA_NOT_FOUND | HTTP404 | MeaningUnknown media.id (or uploaded on another number). |
MESSAGE_MEDIA_EXPIRED | HTTP410 | MeaningThe upload is older than 24 hours — upload again. |
MESSAGE_MEDIA_TOO_LARGE | HTTP413 | MeaningOver the size limit of the type (details.limitBytes). |
MESSAGE_MEDIA_TYPE_NOT_ALLOWED | HTTP415 | MeaningThe file’s real type doesn’t fit the message type (details.detected). |
MESSAGE_MEDIA_FETCH_FAILED | HTTP422 | MeaningThe media URL couldn’t be downloaded safely (details.reason). |
MESSAGE_STICKER_INVALID | HTTP400 | MeaningThe image can’t become a sticker. |
MESSAGE_VIDEO_NOTE_TOO_LONG | HTTP400 | MeaningRound video notes are at most 60 seconds. |
MESSAGE_POLL_INVALID | HTTP400 | Meaningdetails.reason: QUESTION_REQUIRED · OPTIONS_COUNT · OPTIONS_DUPLICATE · OPTION_TOO_LONG · SELECTABLE_COUNT. |
MESSAGE_CONTACT_INVALID | HTTP400 | MeaningA contact card is invalid (details.reason, details.index). |
MESSAGE_LOCATION_INVALID | HTTP400 | MeaningLatitude/longitude out of range. |
MESSAGE_EVENT_INVALID | HTTP400 | MeaningEvent name or dates are invalid (details.reason). |
MESSAGE_REPLY_TARGET_NOT_FOUND | HTTP404 | MeaningreplyTo isn’t a message in this chat. |
MESSAGE_NOT_FOUND | HTTP404 | MeaningReact/edit/delete: unknown message on this number. |
MESSAGE_REACTION_INVALID | HTTP400 | MeaningUse exactly one emoji (or "" to remove). |
MESSAGE_NOT_EDITABLE · MESSAGE_EDIT_WINDOW_PASSED | HTTP409 | MeaningOnly your own text messages, within 15 minutes. |
MESSAGE_NOT_DELETABLE · MESSAGE_DELETE_WINDOW_PASSED | HTTP409 | MeaningDelete for everyone: your own messages, within 48 hours. |
INVALID_RECIPIENT | HTTP400 | MeaningThe phone number isn’t valid. |
API_KEY_REQUIRED · API_KEY_INVALID | HTTP401 | MeaningMissing or invalid x-api-key. |
API_ACCESS_NOT_IN_PLAN | HTTP403 | MeaningYour plan doesn’t include API access. |
MESSAGES_QUOTA_EXCEEDED · NO_ACTIVE_PLAN | HTTP403 | MeaningNo messages left in your plan / no active plan. |
API_SCOPE_DISABLED | HTTP403 | MeaningThe key doesn’t have the needed scope. |
IDEMPOTENCY_KEY_REQUIRED | HTTP409 | MeaningSend an Idempotency-Key header. |
IDEMPOTENCY_KEY_REUSED | HTTP409 | MeaningKey already used with a different body. |
IDEMPOTENCY_IN_PROGRESS | HTTP409 | MeaningThe first request with this key is still running. |
SESSION_NOT_CONNECTED | HTTP400 | MeaningThe number isn’t connected — re-link it in the dashboard. |
API_TEMPORARY_BAN · TOO_MANY_REQUESTS | HTTP429 | MeaningRate limit or temporary block (see retryAfterSeconds). |
SAFETY_DAILY_CAP_REACHED · SAFETY_HOURLY_CAP_REACHED | HTTP429 | MeaningNumber Shield cap for new contacts reached; retry at retryAt. |
SAFETY_PACING · SAFETY_NUMBER_BUSY | HTTP429 | MeaningSending too fast from this number; retry at retryAt. |
SAFETY_CONTENT_BLOCKED | HTTP422 | MeaningContent rule: URL shortener, link from a young number to a new contact, or the same text to too many new contacts (details.reasons). |
SAFETY_SUPPRESSED | HTTP422 | MeaningThe contact opted out or is on your suppression list. |
SAFETY_NUMBER_PAUSED | HTTP423 | MeaningThe number is paused for protection; replies to recent chats still work. |
SAFETY_NUMBER_BANNED | HTTP403 | MeaningWhatsApp restricted this number. |
Limits & number protection
These limits exist to lower the risk of WhatsApp restricting your number. No tool can promise a number will never be banned — sending only to people who expect your messages matters most.
API rate limits (defaults)
| Limit | Default |
|---|---|
| Text / interactive sends | Default60 per minute per key and per number |
| Media sends | Default30 per minute per key and per number |
| Bursts | DefaultMore than 3 sends in 5 seconds are queued (queued: true, 2–30 s delay) |
| Abuse protection | DefaultMore than 8 sends in 5 seconds → temporary block (429): 10 min, then 1 h, then 24 h |
Number Shield
- One message at a time per number. Messages to new contacts are spaced out (about 8–25 seconds, with short breaks); replies to customers who wrote in the last 24 hours go out in 1–3 seconds.
- New numbers warm up: the number of new contacts per day grows with the number’s age (table below). Replies to recent chats don’t count.
- If a limit is reached the API answers with a
SAFETY_*code andretryAt— retry then, don’t loop. - If WhatsApp signals rate-limiting, the number is paused for a while (
SAFETY_NUMBER_PAUSED); replies to recent chats keep working.
| Number age | New contacts / day | New contacts / hour |
|---|---|---|
| Days 1–3 | New contacts / day20 | New contacts / hour10 |
| Days 4–7 | New contacts / day50 | New contacts / hour15 |
| Week 2 | New contacts / day100 | New contacts / hour25 |
| Weeks 3–4 | New contacts / day200 | New contacts / hour40 |
| 30+ days (healthy) | New contacts / day400 | New contacts / hour60 |
Link & content rules
- URL shorteners (bit.ly …) are blocked — use your own domain.
- Numbers younger than 7 days can’t send links to new contacts.
- The same text to more than 50 new contacts in 24 hours is blocked — personalise it.
- Customers who opted out are never messaged (
SAFETY_SUPPRESSED).
Tip: buttons are a good way to start a conversation — when the customer answers, the chat becomes “recent” and later replies are faster and lower-risk.
Send a text message (legacy)
Legacy endpoint
Still supported and unchanged. New integrations: POST /whatsapp/public/messages with "type": "text" (Text).
POST /whatsapp/public/message/send — scope send.
| Field | Type | Notes |
|---|---|---|
phoneNumber | Typestring | NotesRequired. International digits, no +. |
content | Typestring | NotesRequired. The message text. |
reference | Typestring | NotesOptional, ≤128. Your own id, echoed in message.status. |
curl -X POST "https://mursilat.com/api/v1/whatsapp/public/message/send" \
-H "Content-Type: application/json" \
-H "x-api-key: $WASLA_API_KEY" \
-H "Idempotency-Key: order-1042-shipped" \
-d '{
"phoneNumber": "201001234567",
"content": "Hi Mona, your order #1042 has shipped and arrives tomorrow.",
"reference": "order-1042"
}'// Node.js 18+ (built-in fetch)
const res = await fetch('https://mursilat.com/api/v1/whatsapp/public/message/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.WASLA_API_KEY,
'Idempotency-Key': 'order-1042-shipped',
},
body: JSON.stringify({
"phoneNumber": "201001234567",
"content": "Hi Mona, your order #1042 has shipped and arrives tomorrow.",
"reference": "order-1042"
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.code}: ${data.message}`);
console.log(data);<?php
$payload = [
'phoneNumber' => '201001234567',
'content' => 'Hi Mona, your order #1042 has shipped and arrives tomorrow.',
'reference' => 'order-1042',
];
$ch = curl_init('https://mursilat.com/api/v1/whatsapp/public/message/send');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . getenv('WASLA_API_KEY'),
'Idempotency-Key: order-1042-shipped',
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($status >= 400) {
throw new RuntimeException($data['code'] . ': ' . json_encode($data['message']));
}
print_r($data);import os
import requests
payload = {
"phoneNumber": "201001234567",
"content": "Hi Mona, your order #1042 has shipped and arrives tomorrow.",
"reference": "order-1042",
}
res = requests.post(
"https://mursilat.com/api/v1/whatsapp/public/message/send",
headers={
"x-api-key": os.environ["WASLA_API_KEY"],
"Idempotency-Key": "order-1042-shipped",
},
json=payload,
timeout=30,
)
data = res.json()
if not res.ok:
raise RuntimeError(f"{data.get('code')}: {data.get('message')}")
print(data){
"success": true,
"message": "Message sent successfully",
"sessionId": "cm1abc2def3ghi4jkl5mno6p",
"phoneNumber": "201001234567",
"messageId": "3EB0A1B2C3D4E5F60718"
}During bursts a send may be queued instead of sent at once: the response then has queued: true and estimatedDelayMs. The final result arrives as a message.status webhook.
Send media (legacy)
Legacy endpoint
Still supported and unchanged. New integrations: POST /whatsapp/public/messages with a media type (Media), which adds stickers, voice notes, view-once, GIF and round video notes.
POST /whatsapp/public/media/send — scope media. Send JSON with a public mediaUrl, or multipart/form-data with a file.
| Field | Type | Notes |
|---|---|---|
phoneNumber | Typestring | NotesRequired. |
mediaType | Typestring | Notesimage · video · document · audio |
mediaUrl | Typestring | NotesPublic https:// URL (or upload file instead). |
caption | Typestring | NotesOptional. |
filename | Typestring | NotesOptional, for documents. |
voiceNote | Typeboolean | NotesOptional. Send audio as a voice note. |
reference | Typestring | NotesOptional, ≤128. |
curl -X POST "https://mursilat.com/api/v1/whatsapp/public/media/send" \
-H "Content-Type: application/json" \
-H "x-api-key: $WASLA_API_KEY" \
-H "Idempotency-Key: order-1042-invoice" \
-d '{
"phoneNumber": "201001234567",
"mediaType": "document",
"mediaUrl": "https://example.com/invoices/1042.pdf",
"filename": "invoice-1042.pdf",
"caption": "Invoice for order #1042",
"reference": "order-1042"
}'// Node.js 18+ (built-in fetch)
const res = await fetch('https://mursilat.com/api/v1/whatsapp/public/media/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.WASLA_API_KEY,
'Idempotency-Key': 'order-1042-invoice',
},
body: JSON.stringify({
"phoneNumber": "201001234567",
"mediaType": "document",
"mediaUrl": "https://example.com/invoices/1042.pdf",
"filename": "invoice-1042.pdf",
"caption": "Invoice for order #1042",
"reference": "order-1042"
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.code}: ${data.message}`);
console.log(data);<?php
$payload = [
'phoneNumber' => '201001234567',
'mediaType' => 'document',
'mediaUrl' => 'https://example.com/invoices/1042.pdf',
'filename' => 'invoice-1042.pdf',
'caption' => 'Invoice for order #1042',
'reference' => 'order-1042',
];
$ch = curl_init('https://mursilat.com/api/v1/whatsapp/public/media/send');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . getenv('WASLA_API_KEY'),
'Idempotency-Key: order-1042-invoice',
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($status >= 400) {
throw new RuntimeException($data['code'] . ': ' . json_encode($data['message']));
}
print_r($data);import os
import requests
payload = {
"phoneNumber": "201001234567",
"mediaType": "document",
"mediaUrl": "https://example.com/invoices/1042.pdf",
"filename": "invoice-1042.pdf",
"caption": "Invoice for order #1042",
"reference": "order-1042",
}
res = requests.post(
"https://mursilat.com/api/v1/whatsapp/public/media/send",
headers={
"x-api-key": os.environ["WASLA_API_KEY"],
"Idempotency-Key": "order-1042-invoice",
},
json=payload,
timeout=30,
)
data = res.json()
if not res.ok:
raise RuntimeError(f"{data.get('code')}: {data.get('message')}")
print(data)curl -X POST "https://mursilat.com/api/v1/whatsapp/public/media/send" \
-H "x-api-key: $WASLA_API_KEY" \
-H "Idempotency-Key: invoice-1042" \
-F "phoneNumber=201001234567" \
-F "mediaType=document" \
-F "filename=invoice-1042.pdf" \
-F "file=@./invoice-1042.pdf"Media URLs must be public. Private and internal addresses (localhost, 10.x, 192.168.x …) are rejected.
Migrating from v1 to v2
Webhook version 1 is legacy and deprecated; it will be removed in the future. Existing numbers stay on v1 until you switch; new numbers start on v2. The signature header is the same in both.
| v1 (legacy) | v2 | |
|---|---|---|
| Events | v1 (legacy)message.received only | v2All events, choose which |
| Body | v1 (legacy)Old format with the raw WhatsApp message (rawMessage) | v2Stable envelope + typed data |
| User-Agent | v1 (legacy)Wasla-Webhooks/1.0 | v2Wasla-Webhooks/2 |
| Version header | v1 (legacy)— | v2X-Wasla-Webhook-Version: 2 |
| Retries & log | v1 (legacy)— | v26 attempts over ≈51 min, 7-day delivery log, resend |
| Signature | v1 (legacy)X-Wasla-Signature (same) | v2X-Wasla-Signature (same) |
- Update your receiver to accept both: branch on the
X-Wasla-Webhook-Versionheader (missing = v1). - Handle
message.receivedfromdata(from,type,text,media…) instead of the raw message. - In the Developer tab switch to version 2, choose your events, and click Send a test event.
- Watch the Webhook log for a day, then remove the v1 code.
FAQ
Do I need Meta business verification or the Cloud API?
No. Mursilat works with your own number linked by QR (like WhatsApp Web). That is also why buttons may not render on every phone — the numbered fallback covers it.
Will my customers always see the buttons?
Not always. Some phones show interactive messages as plain text. With the default numbered fallback they can still answer by typing the number or the option title, and you get the same interactive.reply either way.
What if the customer types something else?
It arrives as a normal message.received. The interaction stays open until it is answered or expires.
How do I avoid processing an event twice?
Store the event id and skip ones you’ve seen — retries and manual resends reuse it. For sends, always pass an Idempotency-Key.
My webhook was turned off. Why?
Your URL answered 410 Gone. Fix the endpoint, then save the URL again in the Developer tab — it turns back on immediately.
Does Mursilat guarantee my number won’t be banned?
No tool can. Number Shield lowers the risk with pacing, warm-up and content rules — message people who expect to hear from you.
Which message types can I send?
Text, images, videos (also as GIF or round video note), audio files, voice notes, documents, stickers, locations, contact cards, polls, events (experimental), reply buttons, lists and link buttons — all through POST /whatsapp/public/messages. Live location, payments, catalog/order messages, status updates, calls and templates aren’t possible from a linked number.
Will every phone show every type?
Text, media, stickers, locations, contacts and polls show on all recent WhatsApp versions. Buttons and lists may show as plain text on some phones (the numbered fallback covers that); view-once and round video notes need a recent app; events often show only in groups.
How long are media links valid?
24 hours. After that download with your API key (scope media).
How can I test without bothering customers?
Use Send a test event for webhooks, and send API messages to your own phone number.