RelayWA API Documentation
Automate WhatsApp messaging, rich media uploads, interactive polls, and session controls with our developer-first REST API.
Get started
Use the gateway URL below for every customer API request.
https://relaywa-api-production.relaywa-ogemsync.workers.dev- Create your account and confirm your email. Activation grants ₹10.00 wallet credit once.
- Open WhatsApp connection, wait for allocation and scan the QR code. Your account must be connected before sending.
- Create an API key with
messages:sendandmessages:read. Add media scopes for attachments, and session scopes only when needed. - Set
RELAYWA_API_KEYon your server. Choose a message example, replace the fictional recipient and media ID, and choose a new idempotency key for each logical message.
cURL examples use a POSIX shell and a modern curl. Node.js examples use Node.js 24+ and native fetch. Python examples use Python 3 with requests. Keep API keys out of browser code and public repositories.
In the Postman collection, set baseUrl to the API base URL above, apiKey to your customer key, phone to the intended international recipient and requestId to your message’s idempotency key. Keep that request ID for retries and change it for a new send.
Authentication and safe retries
Authorization: Bearer YOUR_CUSTOMER_API_KEY
Content-Type: application/json
Idempotency-Key: example-text-001| Scope | Allows |
|---|---|
messages:send | Accept one outgoing message |
messages:read | Read your jobs, delivery state and history |
media:write | Create upload tickets, upload parts, complete uploads and request imports |
media:read | Read your media inspection state and metadata |
session:read | Read allocation and WhatsApp connection state |
session:manage | Start pairing, read QR snapshots and disconnect |
POST /v1/messages requires an Idempotency-Key of 8–128 characters matching ^[A-Za-z0-9._:-]{8,128}$. Use a unique key for a new logical message. After a timeout or lost response, retry the same payload with the same key.
A replay returns the original job and stored charge; it does not create another charge. Reusing a key with changed content returns 409 IDEMPOTENCY_CONFLICT. Do not automatically resend an unknown job with a new key.
Keys are shown once. Rotate or revoke them in the dashboard. Normal messaging keys do not need permission to manage pairing.
Customer API endpoints
All private resources belong to the account that owns the key.
| Method | Path | Scope | Response |
|---|---|---|---|
| POST | /v1/messages | messages:send | 202 · Accepted job and stored charge |
| GET | /v1/messages/:jobId | messages:read | 200 · { job: { … } } |
| GET | /v1/messages?limit=25&cursor=… | messages:read | 200 · { data: […], nextCursor } |
| POST | /v1/media/uploads | media:write | 201 · Upload ticket |
| PUT | /v1/media/uploads/:mediaId/parts/:partNumber | media:write | 200 · { partNumber, etag } |
| POST | /v1/media/uploads/:mediaId/complete | media:write | 202 · { mediaId, status } |
| POST | /v1/media/imports | media:write | 202 · { mediaId, status: import_pending } |
| GET | /v1/media/:mediaId | media:read | 200 · { media: { … } } |
| GET | /v1/session | session:read | 200 · Allocation and connection state |
| POST | /v1/session/pairing | session:manage | 200 · Start pairing or request fresh QR |
| GET | /v1/session/qr | session:manage | 200 · Current QR snapshot, if available |
| POST | /v1/session/disconnect | session:manage | 200 · Unlink and release the connection |
| GET | /api/public/plans | Public | 200 · Current INR rates and activation credit |
| GET | /openapi.json | Public | 200 · OpenAPI 3.1 for this environment |
| GET | /live | Public | 200 · Gateway liveness |
| GET | /ready | Public | 200 or 503 · Gateway/database readiness |
Sending uses POST. Health endpoints describe the gateway; use GET /v1/session to check your WhatsApp connection.
Send a message
Choose from all 10 supported types. Optional fields are included in the per-message price.
Text. message: 1–4,096 characters; must contain visible text.
- Required
phone, type, message- Optional
None
phone is a JSON string with the full international number and country calling code, such as +12025550123. It is normalized to E.164. Send one recipient per request; recipient arrays and WhatsApp group identifiers are not accepted.
{
"phone": "+12025550123",
"type": "text",
"message": "Your order is ready for collection."
}curl --fail-with-body --silent --show-error -X POST 'https://relaywa-api-production.relaywa-ogemsync.workers.dev/v1/messages' \
-H "Authorization: Bearer $RELAYWA_API_KEY" \
-H 'Idempotency-Key: example-text-001' \
-H "Content-Type: application/json" \
--data '{
"phone": "+12025550123",
"type": "text",
"message": "Your order is ready for collection."
}'Omit optional fields you do not need; do not send JSON null. Booleans are JSON booleans, not strings. Unknown fields, malformed Unicode and unsupported control characters are rejected; normal text newlines are allowed. Examples include type; omitting it defaults to text. If supplied, mimetype must match inspected bytes; it is usually best to omit it.
{
"jobId": "22222222-2222-4222-8222-222222222222",
"status": "queued",
"replayed": false,
"statusUrl": "/v1/messages/22222222-2222-4222-8222-222222222222",
"chargePaise": 80,
"refundedPaise": 0,
"currency": "INR",
"pricingRevision": 1
}The response contains the actual charge in paise. 202 means durable acceptance, not recipient delivery. A replay also returns 202 and may already have a final status. Resolve the relative statusUrl and Location header against the API base URL.
Upload a file
Requires media:write to upload and media:read to wait for inspection.
- Request a ticket with
typeand exact bytesize.mimetypedefaults toapplication/octet-stream;fileNameis optional. - Read the returned
mode. Multipart tickets returnmediaId,uploadId,partSizeandexpiresAt. - PUT raw binary parts numbered from 1. The path uses
mediaId; theX-Upload-Idheader usesuploadId. Set the exactContent-Lengthfor each part. - After every part succeeds, POST
{}to/v1/media/uploads/:mediaId/complete. The server tracks part ETags; no parts array is needed. - Poll
GET /v1/media/:mediaIduntilresponse.media.status === "ready". An uploaded or imported file is not ready until inspection completes.
{
"mediaId": "11111111-1111-4111-8111-111111111111",
"uploadId": "33333333-3333-4333-8333-333333333333",
"mode": "multipart",
"partSize": 8388608,
"expiresAt": 1794096900000
}Multipart parts are 8 MiB (8,388,608 bytes), except the last part, which is the exact remainder. Tickets expire after 15 minutes. Upload parts with Content-Type: application/octet-stream and --data-binary or binary buffers; do not send JSON, base64 or form-data parts.
// Node.js 24+. This prepares media without sending a message.
const apiBase = "https://relaywa-api-production.relaywa-ogemsync.workers.dev";
const apiKey = process.env.RELAYWA_API_KEY;
if (!apiKey) throw new Error("Set RELAYWA_API_KEY first.");
async function api(method, path, body) {
const response = await fetch(apiBase + path, {
method, headers: { Authorization: `Bearer ${apiKey}`, ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(30000),
});
const data = await response.json();
if (!response.ok) throw new Error(`${response.status}: ${JSON.stringify(data.error)}`);
return data;
}
async function waitForMedia(mediaId) {
for (let attempt = 0; attempt < 120; attempt++) {
const { media } = await api("GET", "/v1/media/" + encodeURIComponent(mediaId));
if (media.status === "ready") return media;
if (["failed", "expired"].includes(media.status)) throw new Error(JSON.stringify(media.error || { status: media.status }));
await new Promise(resolve => setTimeout(resolve, 2000));
}
throw new Error("Inspection is still pending. Poll this mediaId again later.");
}
const { open, stat } = await import("node:fs/promises");
const filePath = "./photo.jpg"; // Change path, type, MIME and filename together.
const type = "image", mimetype = "image/jpeg";
const size = (await stat(filePath)).size;
const ticket = await api("POST", "/v1/media/uploads", { type, size, mimetype, fileName: "photo.jpg" });
if (ticket.mode === "direct") {
// Use the returned signed headers. Do not send your API key to R2.
const { createReadStream } = await import("node:fs");
const response = await fetch(ticket.url, { method: "PUT", headers: { ...ticket.headers, "Content-Length": String(size) }, body: createReadStream(filePath), duplex: "half", signal: AbortSignal.timeout(120000) });
if (!response.ok) throw new Error("Direct upload failed: " + response.status);
} else if (ticket.mode === "multipart") {
const file = await open(filePath, "r");
try {
for (let offset = 0, part = 1; offset < size; offset += ticket.partSize, part++) {
const length = Math.min(ticket.partSize, size - offset);
const bytes = Buffer.alloc(length);
let filled = 0;
while (filled < length) {
const { bytesRead } = await file.read(bytes, filled, length - filled, offset + filled);
if (!bytesRead) throw new Error("The file changed during upload.");
filled += bytesRead;
}
const response = await fetch(apiBase + "/v1/media/uploads/" + ticket.mediaId + "/parts/" + part, {
method: "PUT", headers: { Authorization: `Bearer ${apiKey}`, "X-Upload-Id": ticket.uploadId, "Content-Type": "application/octet-stream", "Content-Length": String(length) },
body: bytes, signal: AbortSignal.timeout(120000),
});
if (!response.ok) throw new Error(JSON.stringify(await response.json()));
}
} finally { await file.close(); }
} else { throw new Error("Unrecognized upload mode."); }
await api("POST", "/v1/media/uploads/" + ticket.mediaId + "/complete", {});
const media = await waitForMedia(ticket.mediaId);
console.log({ mediaId: media.id, status: media.status });
// Use this ready mediaId in the matching message-type example.Direct upload mode and signed headers
If the ticket explicitly returns mode: "direct", PUT the exact file bytes to its returned url, using all returned headers, including Content-Type and If-None-Match: *, and the exact byte length. These grants expire after five minutes. Do not send your API key to the signed storage URL. Complete and poll through the gateway afterward. The Node/Python examples support both modes.
| Media type | Maximum | Format |
|---|---|---|
image | 10 MiB | JPEG, PNG, WebP |
video | 50 MiB | MP4 |
audio | 16 MiB | MP3, Ogg, MP4 audio, AAC, WAV |
voice | 16 MiB | Ogg/Opus |
document | 100 MiB | File content must agree with its filename and MIME |
sticker | 1 MiB | WebP; at most 512 × 512 pixels |
Storage and operational limits may be lower. Up to three pending uploads/imports are allowed at once, with limits of 12 tickets and 120 parts per minute. Unsupported media is not transcoded. Uploading or importing does not charge a message.
{
"media": {
"id": "11111111-1111-4111-8111-111111111111",
"type": "image",
"status": "ready",
"size": 204800,
"mimetype": "image/jpeg",
"fileName": "photo.jpg",
"expiresAt": 1794182400000,
"error": null
}
}Media may be pending, import_pending, uploaded, inspecting, ready, failed or expired. Stop on failed/expired and read media.error. expiresAt on media and upload tickets is an epoch-millisecond number. Keep polling the same media ID if your local wait times out; do not assume it failed.
Import a public HTTPS URL
Prepare remote media without downloading it through your application.
POST type, url and an optional fileName. Use a public HTTPS direct-file URL without credentials, fragments or a nonstandard port. Private networks and unsafe redirects are blocked. A webpage or a URL that requires login is not a media file.
// Node.js 24+. This prepares media without sending a message.
const apiBase = "https://relaywa-api-production.relaywa-ogemsync.workers.dev";
const apiKey = process.env.RELAYWA_API_KEY;
if (!apiKey) throw new Error("Set RELAYWA_API_KEY first.");
async function api(method, path, body) {
const response = await fetch(apiBase + path, {
method, headers: { Authorization: `Bearer ${apiKey}`, ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(30000),
});
const data = await response.json();
if (!response.ok) throw new Error(`${response.status}: ${JSON.stringify(data.error)}`);
return data;
}
async function waitForMedia(mediaId) {
for (let attempt = 0; attempt < 120; attempt++) {
const { media } = await api("GET", "/v1/media/" + encodeURIComponent(mediaId));
if (media.status === "ready") return media;
if (["failed", "expired"].includes(media.status)) throw new Error(JSON.stringify(media.error || { status: media.status }));
await new Promise(resolve => setTimeout(resolve, 2000));
}
throw new Error("Inspection is still pending. Poll this mediaId again later.");
}
const imported = await api("POST", "/v1/media/imports", {
"type": "image",
"url": "https://cdn.example.com/photo.jpg",
"fileName": "photo.jpg"
});
const media = await waitForMedia(imported.mediaId);
console.log({ mediaId: media.id, status: media.status });{
"mediaId": "11111111-1111-4111-8111-111111111111",
"status": "import_pending"
}URL imports reserve the type’s maximum size until inspection determines the actual bytes. This can limit parallel imports when storage is nearly full. Once media.status is ready, use the returned ID in the matching message example.
Message status and paginated history
Requires messages:read. Read the last known state rather than assuming delivery.
curl --fail-with-body --silent --show-error -X GET 'https://relaywa-api-production.relaywa-ogemsync.workers.dev/v1/messages/22222222-2222-4222-8222-222222222222' \
-H "Authorization: Bearer $RELAYWA_API_KEY"{
"job": {
"id": "22222222-2222-4222-8222-222222222222",
"jobId": "22222222-2222-4222-8222-222222222222",
"status": "sent",
"type": "text",
"phone": "+12025550123",
"preview": "Your order is ready for collection.",
"attempts": 1,
"currency": "INR",
"chargePaise": 80,
"refundedPaise": 0,
"pricingRevision": 1,
"billingType": "text",
"messageId": "WHATSAPP_MESSAGE_ID",
"createdAt": "2026-11-08T00:00:00.000Z",
"updatedAt": "2026-11-08T00:00:02.000Z",
"sentAt": "2026-11-08T00:00:02.000Z",
"deliveredAt": null,
"readAt": null,
"nextAttemptAt": null,
"error": null
}
}| Status | Meaning |
|---|---|
queued | Accepted and waiting for processing |
processing | Work is leased for processing; this alone does not prove a WhatsApp send started |
retry_wait | A safe pre-send retry is scheduled; later messages wait behind it |
sent | The WhatsApp send returned successfully; recipient delivery is not confirmed |
delivered | A delivery receipt has been received |
read | A read receipt has been received |
failed / cancelled | Processing ended; inspect the error and refundedPaise instead of assuming a refund |
unknown | The send outcome is uncertain; never automatically resend with a new key |
curl --fail-with-body --silent --show-error -X GET 'https://relaywa-api-production.relaywa-ogemsync.workers.dev/v1/messages?limit=25' \
-H "Authorization: Bearer $RELAYWA_API_KEY"{
"data": [
{
"id": "22222222-2222-4222-8222-222222222222",
"jobId": "22222222-2222-4222-8222-222222222222",
"status": "sent",
"chargePaise": 80,
"refundedPaise": 0
}
],
"nextCursor": "OPAQUE_CURSOR_FROM_SERVER"
}The history wrapper is { data, nextCursor }. The single-job wrapper is { job }. For the next page, send the returned opaque cursor URL-encoded as ?limit=25&cursor=…. Stop when it is null. Only limit and cursor are supported; limit defaults to 25 and is capped at 100.
Job dates are ISO 8601 strings. Status and idempotency records normally remain for 30 days. Message content, phone and preview are cleared around 24 hours after completion; archived jobs can show type: "archived" without phone or preview. Missing receipts leave the last known state unchanged.
Pairing, QR snapshots and disconnect
Use a separate key with session permissions when your integration manages pairing.
curl --fail-with-body --silent --show-error -X GET 'https://relaywa-api-production.relaywa-ogemsync.workers.dev/v1/session' \
-H "Authorization: Bearer $RELAYWA_API_KEY"{
"assignment": {
"id": "ASSIGNMENT_ID",
"status": "active"
},
"connection": {
"connected": true,
"state": "connected"
},
"waiting": false
}{
"state": "pairing",
"connected": false,
"qr": {
"text": "QR_TEXT_FROM_SERVER",
"imageDataUrl": "data:image/png;base64,…",
"expiresAt": "2026-11-08T00:01:00.000Z",
"generation": "0123456789abcdef01234567"
}
}GET /v1/session needs session:read. Pairing, reading QR snapshots and disconnecting all need session:manage. QR reads do not start pairing; POST {"fresh":true} only when pairing is requested. The pairing JSON accepts only the optional fresh boolean, which defaults to true.
Show qr.imageDataUrl directly or generate an image from qr.text. While your pairing screen is open, poll the QR snapshot. qr may be null while connecting, after expiry or when already linked. Use Date.parse(qr.expiresAt) for its ISO expiry and compare the opaque string generation to discard stale images. Request a fresh QR after expiry; do not send generation as a request field.
A fresh QR request never logs out an already linked account. QR text/images are private: do not log them, publish them or put them in shared caches. Successful disconnect returns { disconnected: true, requiresPairing: true }; capacity may need to be allocated again.
Wallet credit and current prices
The dashboard and API use the same INR wallet and per-message rates.
| Message type | Price per message | Availability |
|---|
Loading current prices…
curl --fail-with-body --silent --show-error -X GET 'https://relaywa-api-production.relaywa-ogemsync.workers.dev/api/public/plans'The public catalogue returns billingMode, currency, activationCreditPaise and rates with each type’s pricePaise, enabled and revision. Integer paise means 80 is ₹0.80. The price is fixed when a job is accepted, even if an admin updates rates later.
One message to one recipient is one charge. Captions, optional fields, contact cards bundled into one message, safe retries and receipts do not add charges. Documents are charged per file message, not per page. Audio with ptt: true uses the voice-note rate.
Only confirmed final failure or cancellation before send authorization refunds once. Sent, delivered, read and uncertain outcomes remain charged. Read the job’s chargePaise and refundedPaise for the actual billing result.
For more credit, open Wallet, pay the displayed UPI ID and submit the amount and transaction reference for admin approval. Wallet top-ups and balance changes require a signed-in dashboard session; there is no API-key money-management scope.
Errors and recovery
{
"error": {
"code": "INSUFFICIENT_WALLET_BALANCE",
"message": "INSUFFICIENT_WALLET_BALANCE"
}
}| HTTP status | Common codes | What to do |
|---|---|---|
| 400 | INVALID_JSON, INVALID_INPUT, IDEMPOTENCY_KEY_REQUIRED, MEDIA_NOT_READY, INVALID_UPLOAD_PART | Fix the payload, header or media readiness; inspect error.message. |
| 401 | AUTHENTICATION_REQUIRED, INVALID_API_KEY | Provide an active Bearer key; revoked or malformed keys are rejected. |
| 402 | INSUFFICIENT_WALLET_BALANCE | Add wallet credit. This failure creates no job or partial charge. |
| 403 | INSUFFICIENT_SCOPE, ACCOUNT_UNAVAILABLE, MESSAGE_TYPE_DISABLED | Use the required scope and a verified, active account; check enabled message types. |
| 404 | JOB_NOT_FOUND, MEDIA_NOT_FOUND | Use an existing resource owned by your account. |
| 409 | IDEMPOTENCY_CONFLICT, NO_ACTIVE_ASSIGNMENT, INVALID_UPLOAD | Keep payload/key consistent; obtain allocation or a valid upload ticket. |
| 410 | UPLOAD_EXPIRED | Create a new ticket and upload again; do not reuse expired upload authorization. |
| 413 | REQUEST_TOO_LARGE, PLAN_MEDIA_LIMIT | Stay within 32 KiB JSON requests and the effective media/storage limits. |
| 429 | QUEUE_FULL, MESSAGE_RATE_LIMITED, UPLOAD_RATE_LIMITED, MEDIA_STORAGE_OR_UPLOAD_LIMIT | Respect Retry-After when present; use bounded backoff. Reuse a message’s idempotency key. |
| 503 | WHATSAPP_NOT_READY, WORKER_UNAVAILABLE, RESET_NOT_CONFIRMED | Read session state, reconnect if needed and inspect the existing job before retrying. |
Keep the response’s X-Request-Id when contacting support. Retry network failures with bounded backoff and the same message key/payload. Do not turn an uncertain send into a new logical message automatically.