Skip to content

Send your first message in five minutes.

The REST API works on the numbers you've linked in the inbox. List them, find a conversation, send, and follow every status through signed webhooks.

POST /v1/messages
Idempotency-Key: 3f1c9a52-7d43-4bb8-9a3e-1e2f5c6d7a80
{ "chat_id": "chat_…", "type": "text",
"text": "Friday works, your order is updated." }
HTTP/1.1 202 Accepted
{ "id": "msg_…", "status": "pending", "chat_id": "chat_…" }
Then, on your webhook
message.sentsent09:12:41
message.statusdelivered09:12:44
message.statusread09:14:10
Base URL
https://api.wrelay.site/v1
Authentication
Bearer rl_live_…
Errors
application/problem+json
Specification
OpenAPI 3.1

Quickstart

Five steps from an API key to a delivered message. Pick a language once and every example follows.

  1. Create an API key

    In the dashboard, open Settings › Developers › API keys and create a key. It acts for the whole organization and is shown once: we only keep its hash.

    Scopes aren't cumulative. This quickstart needs read and send; registering a webhook endpoint by API needs admin.

    Authorization: Bearer rl_live_…
    # Settings › Developers › API keys › Create key
    export RELAY_API_KEY="rl_live_…"
    With a missing, revoked or expired key401
    {
    "type": "urn:relay:problem:unauthorized",
    "title": "Authentication required",
    "status": 401,
    "code": "unauthorized",
    "detail": "Send a valid API key as `Authorization: Bearer rl_live_…`."
    }
  2. List your numbers

    GET /v1/numbers returns the organization's numbers, oldest first. Keep the id of a number whose status is working: can_send says it's ready.

    Lists are paged with limit and cursor: pass next_cursor back until it's null.

    GET/v1/numbers
    curl "https://api.wrelay.site/v1/numbers?status=working" \
    -H "Authorization: Bearer $RELAY_API_KEY"
    Response200
    {
    "data": [
    {
    "id": "num_01a1a1c6c2007a3f9c41e2b7d5a60813",
    "label": "Sales",
    "phone": "+212600000001",
    "display_name": "Atelier Exemple",
    "avatar_url": null,
    "status": "working",
    "status_reason": null,
    "status_changed_at": "2026-11-10T08:02:11.000Z",
    "first_connected_at": "2026-11-03T14:20:05.000Z",
    "connected_at": "2026-11-10T08:02:11.000Z",
    "last_event_at": "2026-11-12T09:11:57.000Z",
    "can_send": true,
    "daily_send_cap": 400,
    "history_import": {
    "status": "completed",
    "started_at": "2026-11-03T14:20:09.000Z",
    "finished_at": "2026-11-03T14:31:52.000Z",
    "chats_total": 214,
    "chats_done": 214,
    "messages": 18342,
    "error_code": null
    },
    "created_at": "2026-11-03T14:18:40.000Z"
    }
    ],
    "has_more": false,
    "next_cursor": null
    }
  3. Find or start a chat

    Search one number's chats by name or by phone digits, and keep the chat's id.

    No conversation yet? Skip this step and send with number_id and to instead of chat_id: the chat opens with the first message.

    GET/v1/chats
    curl "https://api.wrelay.site/v1/chats?number_id=num_01a1a1c6c2007a3f9c41e2b7d5a60813&search=Maison" \
    -H "Authorization: Bearer $RELAY_API_KEY"
    Response200
    {
    "data": [
    {
    "id": "chat_01a1a1d0f4a87c128e5d4b9a2f61c37e",
    "number_id": "num_01a1a1c6c2007a3f9c41e2b7d5a60813",
    "type": "dm",
    "jid": "212612345678@s.whatsapp.net",
    "phone": "+212612345678",
    "name": "Maison Exemple",
    "description": null,
    "avatar_url": null,
    "unread_count": 1,
    "last_message_at": "2026-11-12T09:10:03.000Z",
    "archived": false,
    "pinned": false,
    "pinned_at": null,
    "muted": false,
    "muted_until": null,
    "left_at": null,
    "only_admins_send": false,
    "only_admins_edit": false,
    "group_created_at": null,
    "created_at": "2026-11-03T14:24:17.000Z"
    }
    ],
    "has_more": false,
    "next_cursor": null
    }
  4. Send a message

    POST /v1/messages records the message as pending and queues it. It leaves WhatsApp within seconds, a few seconds apart per number.

    Retry a failed or timed-out call with the same Idempotency-Key and the same body: you get the same message back with its current status, never a second send.

    POST/v1/messages
    curl https://api.wrelay.site/v1/messages \
    -H "Authorization: Bearer $RELAY_API_KEY" \
    -H "Idempotency-Key: $(uuidgen)" \
    -H "Content-Type: application/json" \
    -d '{"chat_id": "chat_01a1a1d0f4a87c128e5d4b9a2f61c37e", "type": "text",
    "text": "Friday works, your order is updated."}'
    Response202
    {
    "id": "msg_01a1cf07d7407d84b2a619f3c8e05d47",
    "status": "pending",
    "chat_id": "chat_01a1a1d0f4a87c128e5d4b9a2f61c37e"
    }
  5. Receive webhooks

    Add an endpoint for message.received and message.status, in the dashboard or with a key that has the admin scope. The answer carries the signing secret, once.

    Every event then arrives as a signed POST. Answer any 2xx within 10 seconds, and check the signature before you trust the body.

    POST/v1/webhooks/endpoints
    curl https://api.wrelay.site/v1/webhooks/endpoints \
    -H "Authorization: Bearer $RELAY_ADMIN_KEY" \
    -H "Idempotency-Key: $(uuidgen)" \
    -H "Content-Type: application/json" \
    -d '{"url": "https://example.com/hooks/relay",
    "events": ["message.received", "message.status"]}'
    Response201
    {
    "id": "whk_01a1c5e0a9407e8db0c27f4a19d3e65b",
    "url": "https://example.com/hooks/relay",
    "description": null,
    "events": [
    "message.received",
    "message.status"
    ],
    "number_ids": [],
    "enabled": true,
    "disabled_at": null,
    "disabled_reason": null,
    "consecutive_failures": 0,
    "last_delivery_at": null,
    "last_success_at": null,
    "created_at": "2026-11-12T09:05:31.000Z",
    "secret": "whsec_…"
    }
    On your endpoint
    POST /hooks/relay
    Content-Type: application/json
    User-Agent: relay-webhooks/1
    X-Relay-Event: message.status
    X-Relay-Delivery: dlv_01a1cf07f1a27b6e9d20c3a85e17f4b9
    X-Relay-Timestamp: 1794474764
    X-Relay-Signature: v1=…
    {
    "id": "evt_01a1cf07f1a27b6e9d20c3a85e17f4b9",
    "event": "message.status",
    "created_at": "2026-11-12T09:12:44.120Z",
    "data": {
    "id": "msg_01a1cf07d7407d84b2a619f3c8e05d47",
    "number_id": "num_01a1a1c6c2007a3f9c41e2b7d5a60813",
    "chat_id": "chat_01a1a1d0f4a87c128e5d4b9a2f61c37e",
    "wa_message_id": "3EB0A7C2F51D9E84B6C3",
    "from_me": true,
    "client_key": "3f1c9a52-7d43-4bb8-9a3e-1e2f5c6d7a80",
    "status": "delivered",
    "error_code": null,
    "sent_at": "2026-11-12T09:12:41.000Z"
    }
    }
One request, one message.There's no bulk or broadcast endpoint: one request sends one message to one conversation, and each number sends a few seconds apart. Message people who expect to hear from you.Read the Acceptable Use Policy

How the API behaves

The rules every endpoint shares. The reference documents each endpoint in full.

Public ids

Every id is a resource prefix and 32 hex characters. An id from another organization, malformed or with the wrong prefix answers 404 <resource>_not_found, never a separate error.

Anatomy of a message id
msg_Resource prefix01a1cf07d7407d84b2a619f3c8e05d4732 hex characters
  • num_Number
  • chat_Chat or group
  • msg_Message
  • ct_Contact
  • upl_Upload
  • media_Received attachment
  • whk_Webhook endpoint
  • dlv_Webhook delivery
  • evt_Webhook event
  • op_Operation

Scopes

A key carries one or more scopes. A call outside them answers 403 insufficient_scope; GET /v1/operations/{id} accepts any key.

readEvery GET: numbers, chats, messages, groups, contacts and media downloads.
sendSending, reactions, edits and revokes, chat state (archive, pin, mute, read), groups, uploads and number checks.
adminConnecting, pairing, logging out and deleting numbers; webhook endpoints and the delivery log.

Scopes aren't cumulative: give a key every scope it needs.

Idempotency

Every POST, PATCH and DELETE needs an Idempotency-Key header: 1 to 150 visible ASCII characters, a UUID is ideal.

  • Same key and same body within 24 hours: the original answer comes back with Idempotent-Replayed: true, and nothing happens twice.
  • Same key for a different request: 422 idempotency_key_reused. The first call still running: 409 idempotency_key_in_use.
  • Only successful answers are kept: after an error, the same key runs the request again.
  • For sends, the key is also the message's client_key: a retry of POST /v1/messages, even after 24 hours, answers the same message with its current status.
  • One exception: POST /v1/numbers/{id}/pairing-code takes no key, since its code is never stored.

Rate limits

Each key has a burst cap and a per-minute budget. Each number and each organization have their own limits on top.

HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
RateLimit-Limit: 60
RateLimit-Remaining: 0
RateLimit-Reset: 23
RateLimit-Policy: 20;w=1, 60;w=60
Retry-After: 23
{
"type": "urn:relay:problem:rate_limited",
"title": "Too many requests",
"status": 429,
"code": "rate_limited",
"detail": "This API key is over its rate limit: retry after the number of seconds in Retry-After."
}
LimitValueAnswer
Burst, per key20 requests per secondrate_limited
Sustained, per keyYour plan's per-minute limit, or the key's ownrate_limited
Per number30 send requests per minuterate_limited
API requests, per organizationThe plan's monthly quota, reset on the 1st (UTC)quota_exceeded
Messages sent, per organizationThe plan's monthly send quotaquota_exceeded
Daily sends, per numberRamps up over a new number's first two weekssend_cap_reached
New conversations, per number and dayCapped, for people who never wrote to the number firstrecipient_never_wrote
PlanRequests / minute / keyAPI requests / monthMessages sent / month
Starter6010,00010,000
Growth300100,00060,000
Business1,2001,000,000300,000

Every authenticated answer carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds) for the tightest window, and RateLimit-Policy for all of them. A 429 adds Retry-After.

Errors

Every non-2xx answer is an RFC 9457 problem, served as application/problem+json. Branch on code: codes are never renamed or removed, and new ones may appear. errors lists the fields to fix.

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{
"type": "urn:relay:problem:validation_failed",
"title": "Unprocessable request",
"status": 422,
"code": "validation_failed",
"detail": "One or more fields are invalid.",
"errors": {
"text": "A text message needs text"
}
}
StatusCodeWhat to do
400idempotency_key_requiredAdd an Idempotency-Key header to the write.
401unauthorizedCheck the key: unknown, revoked and expired keys all answer this.
403insufficient_scopeUse a key that has the endpoint's scope.
404chat_not_foundThe id isn't in this organization, or has the wrong prefix.
409number_not_workingThe number isn't connected: wait for number.status to read working.
422validation_failedFix the fields listed in errors.
429rate_limitedWait Retry-After seconds, then retry.
502gateway_<reason>WhatsApp didn't answer an in-request call: retry shortly.
503enqueue_failedSaved but not queued: retry with the same Idempotency-Key.
See every code in the reference

Long operations

Calls that wait on WhatsApp (connecting or deleting a number, group commands, number checks) answer 202 with an operation_id and a Location header.

  • Poll GET /v1/operations/{id} every 1 to 2 seconds until status is succeeded (read result) or failed (read error.code), then read the resource again.
  • An operation that doesn't settle within an hour reads failed, with the code expired.
The call is accepted
POST /v1/contacts/check
Idempotency-Key: 3f1c9a52-7d43-4bb8-9a3e-1e2f5c6d7a80
{ "phone": "+212612345678" }
HTTP/1.1 202 Accepted
Location: /v1/operations/op_01a1cf07e6e07f028b3d6a1c94e2f507
{
"operation_id": "op_01a1cf07e6e07f028b3d6a1c94e2f507",
"operation": {
"id": "op_01a1cf07e6e07f028b3d6a1c94e2f507",
"kind": "number_check",
"status": "queued",
"progress": null,
"result": null,
"error": null,
"created_at": "2026-11-12T09:12:44.000Z",
"started_at": null,
"finished_at": null
}
}
A few seconds later
GET /v1/operations/op_01a1cf07e6e07f028b3d6a1c94e2f507
HTTP/1.1 200 OK
{
"id": "op_01a1cf07e6e07f028b3d6a1c94e2f507",
"kind": "number_check",
"status": "succeeded",
"progress": null,
"result": {
"phone": "+212612345678",
"on_whatsapp": true
},
"error": null,
"created_at": "2026-11-12T09:12:44.000Z",
"started_at": "2026-11-12T09:12:45.000Z",
"finished_at": "2026-11-12T09:12:46.000Z"
}

Sending files

Files never travel through the API: they go straight to storage, then the message points at them.

Images and audio up to 16 MB, video up to 64 MB, documents up to 100 MB. A received attachment downloads with GET /v1/media/{id}, which answers a signed URL valid for 5 minutes.

  1. POST /v1/media/uploads with mime_type and size_bytes: you get an upl_ id and a presigned upload.url.
  2. PUT the raw bytes to upload.url with exactly upload.headers and no Authorization header, within 15 minutes.
  3. POST /v1/media/uploads/{id}/finalize: the upload turns ready.
  4. POST /v1/messages with type, upload_id and an optional text caption, within 24 hours.
POST /v1/media/uploads
Idempotency-Key: 3f1c9a52-7d43-4bb8-9a3e-1e2f5c6d7a80
{ "mime_type": "image/jpeg", "size_bytes": 284512, "file_name": "receipt.jpg" }
HTTP/1.1 201 Created
{
"id": "upl_01a1cf0901bc7a558f612d0c7e9b3a48",
"state": "pending",
"file_name": "receipt.jpg",
"mime_type": "image/jpeg",
"declared_bytes": 284512,
"size_bytes": null,
"expires_at": "2026-11-13T09:20:05.000Z",
"created_at": "2026-11-12T09:20:05.000Z",
"upload": {
"method": "PUT",
"url": "https://…",
"headers": {
"Content-Type": "image/jpeg"
},
"max_bytes": 284512,
"expires_at": "2026-11-12T09:35:05.000Z"
}
}

Every endpoint, in the reference.

The hosted reference and the OpenAPI 3.1 file come from the same schemas that check your requests, so they can't drift from what the API accepts.