On this page

This is the legacy v1 reference. API v2 adds scoped account keys and every resource in one REST surface — open the v2 docs or see how to migrate.

Mursilat for developers

API & Webhooks

Send every message type — text, media, stickers, locations, contacts, polls, buttons and lists — from your linked number with one endpoint, and get every reply, vote, delivery update and opt-out on your server — signed, retried and logged.

  • REST + JSON
  • One endpoint, every type
  • Signed webhooks (HMAC-SHA256)
  • cURL · Node · PHP · Python
Base URL
https://mursilat.com/api/v1
Webhook API version
2026-10
On this page

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.

  1. Link your number in the dashboard: Numbers → Add number, then scan the QR code.
  2. Open Numbers → your number → Developer and generate an API key (shown once) and choose its permissions.
  3. In the same tab, save your webhook URL, choose version 2, and copy the signing secret (shown once).
  4. Send your first buttons message (below). Every send needs an Idempotency-Key.
  5. The customer taps a button — or types its number — and you receive interactive.reply on 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"
}'

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
sendAllowsEvery send on POST /messages, react/edit/delete, GET /interactions/:id
mediaAllowsMedia types (together with send), media uploads and downloads
webhookAllowsReceiving 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 isDetails
textWhat it isText with WhatsApp formatting and an optional link previewDetailsText
image · video · audio · voice · documentWhat it isPhotos, videos (GIF / round video note), audio files, voice notes, filesDetailsMedia
stickerWhat it isWebP sticker — PNG/JPG/GIF are convertedDetailsStickers
locationWhat it isA pin on the map (not live location)DetailsLocation
contactWhat it is1–5 contact cardsDetailsContact cards
pollWhat it isQuestion with 2–12 options; votes come back as poll.voteDetailsPolls
eventWhat it isEvent invitation — experimentalDetailsEvents
buttons · list · cta_urlWhat it isReply buttons, list menu, link button (with numbered fallback)DetailsButtons

Common fields

FieldNotes
toNotesRequired. International digits, no + (e.g. 201001234567).
typeNotesRequired. One of the types above. Others answer 400 MESSAGE_TYPE_NOT_SUPPORTED with a reason (see Not possible).
referenceNotesOptional, ≤128. Your own id, echoed in message.status, interactive.reply and poll.vote.
replyToNotesOptional. 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"
}'
Response
{
  "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-data with the same fields plus file (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.
Send a file in the same request (multipart)
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.

FieldLimitsNotes
textLimits1–4096NotesRequired.
linkPreviewLimitsbooleanNotesDefault 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.
mentionsLimits—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"
}'

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 & sizeExtra fields
imageFormats & sizeJPEG · PNG · WebP, ≤5 MBExtra fieldscaption ≤1024 · viewOnce
videoFormats & sizeMP4 · 3GPP, ≤16 MBExtra fieldscaption ≤1024 · viewOnce · gif · videoNote
audioFormats & sizeOGG · MP3 · M4A · AAC · AMR, ≤16 MBExtra fields—
voiceFormats & sizeAny of the audio formats, ≤16 MBExtra fieldsviewOnce
documentFormats & sizeAny file except programs, ≤100 MBExtra 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 🎉"
}'

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"
}'
  • 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
}'

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"
  }
}'

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"
}'

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"
}'

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"
  }
}'

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.

FieldLimits
latitudeLimits−90 … 90 (required)
longitudeLimits−180 … 180 (required)
nameLimits≤100
addressLimits≤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"
}'

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

FieldLimits
contacts[]Limits1–5
contacts[].nameLimits≤100 (required)
contacts[].phones[]Limits1–3, digits with the country code (+ and spaces allowed)
contacts[].emailLimits≤120, a valid email
contacts[].organizationLimits≤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"
    }
  ]
}'

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.

FieldLimits
nameLimitsThe question, ≤255 (required)
options[]Limits2–12, unique, each ≤100
selectableCountLimits1 (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"
}'

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.

poll.vote — Example
{
  "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.

FieldLimits
nameLimits≤100 (required)
descriptionLimits≤2048
startAtLimitsISO date, in the future, at most one year ahead (required)
endAtLimitsISO date after startAt
locationNameLimits≤100
extraGuestsAllowedLimitsboolean
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"
}'

Send buttons

POST /whatsapp/public/messages with "type": "buttons" — up to 3 reply buttons. Scope send. The legacy POST /whatsapp/public/interactive/send takes exactly the same body and keeps working.

Read this first: buttons on a linked number

Mursilat sends from your linked number (like WhatsApp Web), not from the Meta Cloud API. On some phones and app versions buttons may show as plain text without buttons. That’s why every interactive message includes a numbered fallback by default: the customer can always answer by typing the number (1, 2, 3 — Arabic digits work too) or the option title. However they answer, you receive the same interactive.reply event.

FieldLimitsNotes
toLimits—NotesRequired. International digits.
typeLimitsbuttons · list · cta_urlNotesRequired.
header.textLimits≤60NotesOptional.
body.textLimits1–1024NotesRequired.
footer.textLimits≤60NotesOptional.
buttons[]Limits1–3Notesid ≤64 (A-Z a-z 0-9 _ . : -, unique) · title ≤20 (unique).
fallbackLimitsnumbered · noneNotesDefault numbered.
localeLimitsar · enNotesLanguage of the fallback hint line. Default ar.
referenceLimits≤128NotesYour own id, echoed in webhooks.
expiresInMinutesLimits5–10080NotesHow long replies are matched. Default 1440 (24h).
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"
}'
Response
{
  "messageId": "3EB0A1B2C3D4E5F60718",
  "interactionId": "int_8f2c1d9a7b3e4f56a1b2c3d4",
  "status": "sent",
  "format": "native_flow_quick_reply",
  "fallbackIncluded": true
}

What the customer sees without buttons

With fallback: "numbered" the options are appended to the body, followed by a one-line hint (in your account language):

Message text
Do you confirm your order?

1) Confirm
2) Cancel order

Reply with the number of your choice

fallback: "none" sends the body only. We don’t recommend it: customers whose phone hides the buttons won’t see the options (typing an exact title still matches).

Send a list

"type": "list" — a menu button that opens up to 10 rows, grouped in sections. Rows can have a description.

FieldLimits
list.buttonTextLimits≤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"
}'

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.

FieldLimits
cta.titleLimits≤20
cta.urlLimitsPublic 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"
}'

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_LIMITATIONWhyLive location is shared only from the phone itself. Send a location pin instead.
payment`reason`NOT_AVAILABLEWhyWhatsApp Pay isn’t available in Egypt or Saudi Arabia, nor on linked devices.
product · catalog · order`reason`BUSINESS_CATALOG_ONLYWhyCatalog messages need the WhatsApp Business catalog on the phone. Send an image + a link button instead.
status · story`reason`OUT_OF_SCOPEWhyStatus updates aren’t chat messages.
call`reason`LINKED_DEVICE_LIMITATIONWhyCalls can’t be placed from a linked device.
template`reason`CLOUD_API_ONLYWhyMessage templates exist only on the official Cloud API.
album`reason`SEND_ITEMS_SEPARATELYWhySend each image or video as its own message.
anything else`reason`UNKNOWN_TYPEWhyNot 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.

Upload a file
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 fieldNotes
mediaIdNotesUse it as media.id (same number only).
mimeType · size · fileNameNotesWhat we detected.
expiresAtNotes24 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": "👍"
}'

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

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

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 as message.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 expiresInMinutes the interaction is EXPIRED and 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.

interactive.reply — Example
{
  "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"
Response
{
  "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

  1. Numbers → your number → Developer: save your URL, choose version 2, and pick the events you want.
  2. Copy the signing secret (shown once — you can generate a new one at any time).
  3. Click Send a test event and check the result in the Webhook log.
  • Public https:// (or http://) URL on port 80 or 443. Private and internal addresses are rejected.
  • Answer with any 2xx within 10 seconds — do slow work after responding.
  • Redirects are not followed.
Request headers
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 fieldDescription
idDescriptionUnique event id (evt_…). The same on every retry and on manual resends — dedupe on it.
typeDescriptionEvent type, e.g. interactive.reply.
apiVersionDescription2026-10
createdAtDescriptionWhen the event happened (ISO-8601 UTC). Use it to order events.
sessionId · numberDescriptionWhich of your numbers it is about.
dataDescriptionEvent-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.

message.received — Example
{
  "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".

message.received — Example
{
  "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"
  }
}
Download media
# 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.

message.status — Example
{
  "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).

interactive.reply — Example
{
  "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).

poll.vote — Example
{
  "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.

contact.opted_out — Example
{
  "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.

contact.opted_in — Example
{
  "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.

session.status — Example
{
  "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.

webhook.test — Example
{
  "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.

  1. Read the raw request body (bytes as received) — before any JSON parsing.
  2. Split the header on , and = to get t and v1.
  3. Reject if t is more than 5 minutes from your clock (replay protection).
  4. Compute the HMAC-SHA256 of t + "." + rawBody with your secret, as hex.
  5. Compare with v1 using 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);

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

AttemptWhen
1WhenImmediately
2When+10 seconds
3When+1 minute
4When+5 minutes
5When+15 minutes
6When+30 minutes (≈51 minutes after the first)
  • 2xx = delivered. Other 4xx/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-Delivery stays the same across retries of one delivery; a manual resend gets a new one (same event id).
  • Up to 4 requests at a time per URL, so events can arrive out of order. Order by createdAt and make your handler idempotent on id.

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.

Error response
{
  "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"
}
CodeHTTPMeaning
INTERACTIVE_INVALID_TYPEHTTP400Meaningtype must be buttons, list or cta_url.
INTERACTIVE_BODY_REQUIREDHTTP400Meaningbody.text is missing.
INTERACTIVE_TEXT_TOO_LONGHTTP400MeaningHeader, body or footer is over its limit.
INTERACTIVE_TITLE_TOO_LONGHTTP400MeaningA button / row / section / CTA title is over its limit.
INTERACTIVE_TOO_MANY_BUTTONSHTTP400MeaningMore than 3 buttons.
INTERACTIVE_NO_OPTIONSHTTP400MeaningNo buttons / rows / CTA given.
INTERACTIVE_TOO_MANY_ROWSHTTP400MeaningMore than 10 list rows.
INTERACTIVE_TOO_MANY_SECTIONSHTTP400MeaningMore than 10 list sections.
INTERACTIVE_DUPLICATE_IDHTTP400MeaningTwo options share an id.
INTERACTIVE_DUPLICATE_TITLEHTTP400MeaningTwo options share a title (typed titles must be distinguishable).
INTERACTIVE_INVALID_IDHTTP400MeaningAn id has characters outside A-Z a-z 0-9 _ . : - or is too long.
INTERACTIVE_INVALID_URLHTTP400MeaningThe CTA URL isn’t a public https:// URL.
INTERACTIVE_INVALID_EXPIRYHTTP400MeaningexpiresInMinutes outside 5–10080.
INTERACTIVE_INVALID_FALLBACKHTTP400Meaningfallback must be numbered or none.
INTERACTIVE_INVALID_LOCALEHTTP400Meaninglocale must be ar or en.
INTERACTIVE_TITLE_REQUIREDHTTP400MeaningA button / row / section / link title is missing.
INTERACTIVE_REFERENCE_TOO_LONGHTTP400Meaningreference over 128 characters.
INTERACTIVE_RESERVED_TITLEHTTP400MeaningAn option title is an opt-out word (e.g. “stop”).
INTERACTIVE_UNEXPECTED_FIELDHTTP400MeaningA field that doesn’t belong to this type (e.g. buttons on a list).
MESSAGE_TYPE_REQUIRED · MESSAGE_TYPE_NOT_SUPPORTEDHTTP400Meaningtype is missing, or can’t be sent from a linked number (details.reason, see Not possible).
MESSAGE_FIELD_REQUIRED · MESSAGE_FIELD_INVALIDHTTP400MeaningA required field is missing / has an invalid value (details.field, details.reason).
MESSAGE_TEXT_TOO_LONGHTTP400MeaningA text field is over its limit (details.limit).
MESSAGE_UNEXPECTED_FIELDHTTP400MeaningA field of another type, e.g. caption on audio or on a round video note.
MESSAGE_MENTIONS_GROUP_ONLYHTTP400MeaningMentions work in group chats only.
MESSAGE_MEDIA_REQUIREDHTTP400MeaningNo media (or file) for a media type.
MESSAGE_MEDIA_NOT_FOUNDHTTP404MeaningUnknown media.id (or uploaded on another number).
MESSAGE_MEDIA_EXPIREDHTTP410MeaningThe upload is older than 24 hours — upload again.
MESSAGE_MEDIA_TOO_LARGEHTTP413MeaningOver the size limit of the type (details.limitBytes).
MESSAGE_MEDIA_TYPE_NOT_ALLOWEDHTTP415MeaningThe file’s real type doesn’t fit the message type (details.detected).
MESSAGE_MEDIA_FETCH_FAILEDHTTP422MeaningThe media URL couldn’t be downloaded safely (details.reason).
MESSAGE_STICKER_INVALIDHTTP400MeaningThe image can’t become a sticker.
MESSAGE_VIDEO_NOTE_TOO_LONGHTTP400MeaningRound video notes are at most 60 seconds.
MESSAGE_POLL_INVALIDHTTP400Meaningdetails.reason: QUESTION_REQUIRED · OPTIONS_COUNT · OPTIONS_DUPLICATE · OPTION_TOO_LONG · SELECTABLE_COUNT.
MESSAGE_CONTACT_INVALIDHTTP400MeaningA contact card is invalid (details.reason, details.index).
MESSAGE_LOCATION_INVALIDHTTP400MeaningLatitude/longitude out of range.
MESSAGE_EVENT_INVALIDHTTP400MeaningEvent name or dates are invalid (details.reason).
MESSAGE_REPLY_TARGET_NOT_FOUNDHTTP404MeaningreplyTo isn’t a message in this chat.
MESSAGE_NOT_FOUNDHTTP404MeaningReact/edit/delete: unknown message on this number.
MESSAGE_REACTION_INVALIDHTTP400MeaningUse exactly one emoji (or "" to remove).
MESSAGE_NOT_EDITABLE · MESSAGE_EDIT_WINDOW_PASSEDHTTP409MeaningOnly your own text messages, within 15 minutes.
MESSAGE_NOT_DELETABLE · MESSAGE_DELETE_WINDOW_PASSEDHTTP409MeaningDelete for everyone: your own messages, within 48 hours.
INVALID_RECIPIENTHTTP400MeaningThe phone number isn’t valid.
API_KEY_REQUIRED · API_KEY_INVALIDHTTP401MeaningMissing or invalid x-api-key.
API_ACCESS_NOT_IN_PLANHTTP403MeaningYour plan doesn’t include API access.
MESSAGES_QUOTA_EXCEEDED · NO_ACTIVE_PLANHTTP403MeaningNo messages left in your plan / no active plan.
API_SCOPE_DISABLEDHTTP403MeaningThe key doesn’t have the needed scope.
IDEMPOTENCY_KEY_REQUIREDHTTP409MeaningSend an Idempotency-Key header.
IDEMPOTENCY_KEY_REUSEDHTTP409MeaningKey already used with a different body.
IDEMPOTENCY_IN_PROGRESSHTTP409MeaningThe first request with this key is still running.
SESSION_NOT_CONNECTEDHTTP400MeaningThe number isn’t connected — re-link it in the dashboard.
API_TEMPORARY_BAN · TOO_MANY_REQUESTSHTTP429MeaningRate limit or temporary block (see retryAfterSeconds).
SAFETY_DAILY_CAP_REACHED · SAFETY_HOURLY_CAP_REACHEDHTTP429MeaningNumber Shield cap for new contacts reached; retry at retryAt.
SAFETY_PACING · SAFETY_NUMBER_BUSYHTTP429MeaningSending too fast from this number; retry at retryAt.
SAFETY_CONTENT_BLOCKEDHTTP422MeaningContent rule: URL shortener, link from a young number to a new contact, or the same text to too many new contacts (details.reasons).
SAFETY_SUPPRESSEDHTTP422MeaningThe contact opted out or is on your suppression list.
SAFETY_NUMBER_PAUSEDHTTP423MeaningThe number is paused for protection; replies to recent chats still work.
SAFETY_NUMBER_BANNEDHTTP403MeaningWhatsApp 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)

LimitDefault
Text / interactive sendsDefault60 per minute per key and per number
Media sendsDefault30 per minute per key and per number
BurstsDefaultMore than 3 sends in 5 seconds are queued (queued: true, 2–30 s delay)
Abuse protectionDefaultMore 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 and retryAt — 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 ageNew contacts / dayNew contacts / hour
Days 1–3New contacts / day20New contacts / hour10
Days 4–7New contacts / day50New contacts / hour15
Week 2New contacts / day100New contacts / hour25
Weeks 3–4New contacts / day200New contacts / hour40
30+ days (healthy)New contacts / day400New contacts / hour60
  • 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.

FieldTypeNotes
phoneNumberTypestringNotesRequired. International digits, no +.
contentTypestringNotesRequired. The message text.
referenceTypestringNotesOptional, ≤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"
}'
Response
{
  "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.

FieldTypeNotes
phoneNumberTypestringNotesRequired.
mediaTypeTypestringNotesimage · video · document · audio
mediaUrlTypestringNotesPublic https:// URL (or upload file instead).
captionTypestringNotesOptional.
filenameTypestringNotesOptional, for documents.
voiceNoteTypebooleanNotesOptional. Send audio as a voice note.
referenceTypestringNotesOptional, ≤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"
}'
Upload a file (multipart)
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
Eventsv1 (legacy)message.received onlyv2All events, choose which
Bodyv1 (legacy)Old format with the raw WhatsApp message (rawMessage)v2Stable envelope + typed data
User-Agentv1 (legacy)Wasla-Webhooks/1.0v2Wasla-Webhooks/2
Version headerv1 (legacy)—v2X-Wasla-Webhook-Version: 2
Retries & logv1 (legacy)—v26 attempts over ≈51 min, 7-day delivery log, resend
Signaturev1 (legacy)X-Wasla-Signature (same)v2X-Wasla-Signature (same)
  1. Update your receiver to accept both: branch on the X-Wasla-Webhook-Version header (missing = v1).
  2. Handle message.received from data (from, type, text, media …) instead of the raw message.
  3. In the Developer tab switch to version 2, choose your events, and click Send a test event.
  4. 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.

↑ Back to top