WhatsApp API
Send WhatsApp messages from any backend, and receive incoming ones as signed webhooks. One endpoint for every message type, idempotent sends, and errors designed to be handled by a program rather than read by a person.
Quickstart
Three steps. Create a key on the API page, make sure a WhatsApp account is paired, then send.
curl -X POST https://api.rasoul.site/api/v1/messages \
-H "Authorization: Bearer rsl_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+201001234567",
"type": "text",
"text": "Your verification code is 481920."
}'Authentication
Every request carries an API key as a bearer token. Keys are created on the API page and shown exactly once — we store only a hash, so a lost key must be revoked and replaced rather than recovered.
Authorization: Bearer rsl_live_7f3a9c2e...X-API-Key: rsl_live_... is also accepted, for tools that cannot set an Authorization header.
Scopes
Each key carries only the capabilities you grant it.
| Scope | Grants |
|---|---|
messages:send | Sending messages |
messages:read | Reading conversations and history |
session:read | Reading WhatsApp connection status |
webhooks:manage | Managing webhook endpoints |
A route called without its scope answers 403 insufficient_scope, naming the scope it needed in X-Required-Scope.
Restricting a key by IP
A key can be pinned to one or more addresses or CIDR blocks. Server-to-server integrations should always be pinned.
Sending messages
One endpoint, POST /api/v1/messages, and the type field decides the shape of the rest.
Recipients
A recipient is a phone number in international format, or a chat JID. A number is verified against WhatsApp before use — sending to an account that does not exist fails rather than disappearing.
Message types
{
"to": "+201001234567",
"type": "text",
"text": "Your order #8842 has shipped."
}Attachments
Three ways to supply a file, in the order most integrations want them:
- By URL —
media.url. Best when the file is already in object storage. It must be publicly reachable; private and loopback addresses are refused, because otherwise this field would be a way to make our servers read their own internal network. - Base64 —
media.data, with or without adata:prefix. Convenient for small files, but base64 inflates a payload by a third. - Multipart upload —
POST /api/v1/messages/media. Streams, so a 30 MB video costs the same memory as a small photo.
curl -X POST https://api.rasoul.site/api/v1/messages/media \
-H "Authorization: Bearer rsl_live_YOUR_KEY" \
-F "to=+201001234567" \
-F "type=document" \
-F "file=@invoice.pdf"Idempotency
Send an Idempotency-Key header and a repeat of the same request returns the original result instead of sending a second message. This is the single most useful header on the API: every job runner, queue and workflow tool eventually retries, and a duplicated OTP is a support ticket.
Idempotency-Key: order-8842-shipped-notificationA replay is answered with Idempotent-Replay: true. Reusing a key with a different payload returns 409 idempotency_conflict rather than the first response — that combination is always a bug, and hiding it behind a plausible success would be worse than failing.
Rate limits
Two independent budgets apply, and the distinction matters:
- Per key — How fast one credential may call the API. Raise it per key if an integration legitimately needs more.
- Per WhatsApp session — How fast the account may send, shared by every key on it. This one protects the WhatsApp account itself: WhatsApp restricts and ultimately bans accounts that send faster than a person plausibly could. This budget protects your number, not our servers.
A 429 carries Retry-After and names which budget fired in error.details.scope. Successful responses carry X-RateLimit-Limit; read it and pace yourself rather than waiting to be refused.
Webhooks
Register an endpoint on the API page and we POST events to it as they happen. Each endpoint gets its own signing secret, shown once.
| Event | Fires when |
|---|---|
message.received | Someone sends a message to your WhatsApp account |
message.sent | A message is sent from the account, by the API or the inbox |
message.status | A message is delivered, read or played |
session.status | The WhatsApp connection changes state |
Payload
{
"id": "evt_4f8a1c2e-6b3d-4a91-9c77-2e5f8d1b0a34",
"event": "message.received",
"timestamp": "2026-03-14T09:26:53Z",
"api_version": "v1",
"data": {
"id": "3EB0C767D26B8A3F1A2B",
"chat_id": "201001234567@s.whatsapp.net",
"from": "201001234567@s.whatsapp.net",
"from_number": "201001234567",
"sender_name": "Layla",
"direction": "inbound",
"type": "text",
"text": "Is my order on the way?",
"has_media": false,
"is_group": false,
"source": "app",
"timestamp": "2026-03-14T09:26:53Z"
}
}Verifying the signature
Each delivery carries X-Rasoul-Signature in the form t=<unix>,v1=<hex>. Compute HMAC-SHA256 over "<t>.<raw body>" using your endpoint's secret and compare in constant time.
public function handle(Request $request)
{
$header = $request->header('X-Rasoul-Signature', '');
$raw = $request->getContent(); // the RAW body, before any parsing
parse_str(str_replace(',', '&', $header), $parts);
$timestamp = $parts['t'] ?? '';
$signature = $parts['v1'] ?? '';
// Reject anything older than five minutes, or a replayed delivery
// captured from your logs stays valid indefinitely.
if (abs(time() - (int) $timestamp) > 300) {
abort(401);
}
$expected = hash_hmac('sha256', $timestamp . '.' . $raw, config('services.rasoul.webhook_secret'));
// Constant-time: a plain === leaks how much of the signature matched.
if (! hash_equals($expected, $signature)) {
abort(401);
}
$event = json_decode($raw, true);
// Ignore your own sends, or an autoresponder answers itself forever.
if (($event['data']['source'] ?? '') === 'api') {
return response()->noContent();
}
ProcessWhatsAppMessage::dispatch($event);
// Acknowledge fast. Non-2xx is retried with backoff, and an endpoint
// that keeps failing is disabled.
return response()->noContent();
}Delivery behaviour
- At least once. A retry after a timeout can arrive for an event you already processed. Deduplicate on the envelope's
id, which is stable across retries. - Retried with backoff. Non-2xx responses are retried several times with exponential backoff and jitter. A 4xx other than 408 or 429 is not retried — you understood us and said no.
- Auto-disabled after persistent failure. An endpoint that fails continuously is switched off, and the reason is shown on the API page.
- Acknowledge quickly. Queue the work and return 2xx. Doing the work inline makes a slow job look like a failed delivery.
- Filter on
source. Messages you sent come back asmessage.sentwithsource: "api". An integration that does not skip them is one that answers itself.
Errors
Every failure has the same shape.
{
"error": {
"code": "not_on_whatsapp",
"message": "whatsapp: this number is not on WhatsApp",
"retryable": false,
"request_id": "9f2c1e4a-7b83-4d15-9e60-3a5c8f1d2b47"
}
}- Switch on
code, never onmessage. Codes are stable; wording is not. - Trust
retryable. It says whether trying again could plausibly work. Retrying a permanent failure is how one bad request becomes a rate-limited loop. - Quote
request_idwhen reporting a problem. It turns “sending sometimes fails” into one log line.
Common codes
| Code | Status | Meaning |
|---|---|---|
unauthorized | 401 | Key missing, unknown, revoked, expired, or used from a blocked address |
insufficient_scope | 403 | Valid key, but it lacks the scope this route needs |
invalid_recipient | 400 | The to field was empty or unusable |
not_on_whatsapp | 404 | Valid number, but no WhatsApp account |
unsupported_message_type | 400 | Unknown type; the response lists what is supported |
not_paired | 409 | No WhatsApp account is linked yet |
not_connected | 503 | Session is reconnecting. Retryable — usually within seconds |
rate_limited | 429 | A budget was exhausted; details.scope says which |
idempotency_conflict | 409 | Same Idempotency-Key, different payload |
media_fetch_failed | 502 | Your media URL could not be downloaded |
Checking before you send
GET /api/v1/session reports whether the account can send. An integration that checks it before a batch fails fast with a clear reason instead of discovering the session is down one timeout at a time.
{
"state": "ready",
"can_send": true,
"connected": true,
"logged_in": true,
"phone_number": "201001234567",
"synced": true
}Integration notes
n8n, Make and Zapier
Use a generic HTTP request step with a Bearer credential. Point it at https://api.rasoul.site/api/v1/messages. For n8n, importing the OpenAPI document into the custom-node wizard generates the operations for you.
Set an Idempotency-Key from the workflow execution id. Low-code tools retry aggressively and often invisibly, and this is what stops a retried run from sending a second message.
OTP services
The idempotency key should be derived from what the code is for — the verification attempt id, not a fresh UUID per call. Then a retried job resends nothing, and a genuinely new code gets a genuinely new key.
Check can_send before generating a code. Generating one the user never receives starts a support conversation and burns an attempt.
AI agents and autoresponders
Subscribe to message.received, and skip anything where data.source is "api" — otherwise the agent replies to its own messages in a loop.
Inbound webhooks are held back until a session has finished replaying whatever arrived while the server was restarting, so a deploy does not deliver an overnight backlog as a burst of “new” messages for your agent to answer all at once.
CRM and ERP sync
GET /api/v1/chats and GET /api/v1/chats/{id}/messages are cursor-paginated. Pass next_cursor back unchanged; never construct one. Offsets are not offered on purpose — they skip and duplicate rows whenever the underlying data changes between calls, and a conversation is the most actively changing thing in this system.
Versioning and compatibility
- The API is versioned in the path:
/api/v1/…. A future/api/v2is mounted alongside v1, not in place of it. - New fields may be added to responses at any time. Ignore fields you do not recognise rather than failing on them.
- New message types and new webhook events are additive. Read
GET /api/v1/capabilitiesto discover them rather than hard-coding a list. - Error codes are only ever added. What an existing code means does not change.