Rasoul/API

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.

Session cookies are never accepted here. That is deliberate: a browser attaches cookies to cross-site requests automatically, so an API that honoured them would be reachable from any page a logged-in user happened to visit. A bearer token has to be attached on purpose.

Scopes

Each key carries only the capabilities you grant it.

ScopeGrants
messages:sendSending messages
messages:readReading conversations and history
session:readReading WhatsApp connection status
webhooks:manageManaging 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 a data: 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-notification

A 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 keyHow 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.

EventFires when
message.receivedSomeone sends a message to your WhatsApp account
message.sentA message is sent from the account, by the API or the inbox
message.statusA message is delivered, read or played
session.statusThe 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.

Verify before you act. Anyone who learns your endpoint URL can post to it. Check the signature and the age of the timestamp — the timestamp is inside the signed material precisely so that a captured delivery cannot be replayed forever.
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 as message.sent with source: "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 on message. 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_id when reporting a problem. It turns “sending sometimes fails” into one log line.

Common codes

CodeStatusMeaning
unauthorized401Key missing, unknown, revoked, expired, or used from a blocked address
insufficient_scope403Valid key, but it lacks the scope this route needs
invalid_recipient400The to field was empty or unusable
not_on_whatsapp404Valid number, but no WhatsApp account
unsupported_message_type400Unknown type; the response lists what is supported
not_paired409No WhatsApp account is linked yet
not_connected503Session is reconnecting. Retryable — usually within seconds
rate_limited429A budget was exhausted; details.scope says which
idempotency_conflict409Same Idempotency-Key, different payload
media_fetch_failed502Your 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/v2 is 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/capabilities to discover them rather than hard-coding a list.
  • Error codes are only ever added. What an existing code means does not change.