Developer Reference & Guides

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.

API base URL
https://relaywa-api-production.relaywa-ogemsync.workers.dev
  1. Create your account and confirm your email. Activation grants ₹10.00 wallet credit once.
  2. Open WhatsApp connection, wait for allocation and scan the QR code. Your account must be connected before sending.
  3. Create an API key with messages:send and messages:read. Add media scopes for attachments, and session scopes only when needed.
  4. Set RELAYWA_API_KEY on your server. Choose a message example, replace the fictional recipient and media ID, and choose a new idempotency key for each logical message.
Examples are copyable code. Requests run on your own server; this page does not send messages or accept your secret API key.

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

Required message headers
Authorization: Bearer YOUR_CUSTOMER_API_KEY
Content-Type: application/json
Idempotency-Key: example-text-001
ScopeAllows
messages:sendAccept one outgoing message
messages:readRead your jobs, delivery state and history
media:writeCreate upload tickets, upload parts, complete uploads and request imports
media:readRead your media inspection state and metadata
session:readRead allocation and WhatsApp connection state
session:manageStart 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.

MethodPathScopeResponse
POST/v1/messagesmessages:send202 · Accepted job and stored charge
GET/v1/messages/:jobIdmessages:read200 · { job: { … } }
GET/v1/messages?limit=25&cursor=…messages:read200 · { data: […], nextCursor }
POST/v1/media/uploadsmedia:write201 · Upload ticket
PUT/v1/media/uploads/:mediaId/parts/:partNumbermedia:write200 · { partNumber, etag }
POST/v1/media/uploads/:mediaId/completemedia:write202 · { mediaId, status }
POST/v1/media/importsmedia:write202 · { mediaId, status: import_pending }
GET/v1/media/:mediaIdmedia:read200 · { media: { … } }
GET/v1/sessionsession:read200 · Allocation and connection state
POST/v1/session/pairingsession:manage200 · Start pairing or request fresh QR
GET/v1/session/qrsession:manage200 · Current QR snapshot, if available
POST/v1/session/disconnectsession:manage200 · Unlink and release the connection
GET/api/public/plansPublic200 · Current INR rates and activation credit
GET/openapi.jsonPublic200 · OpenAPI 3.1 for this environment
GET/livePublic200 · Gateway liveness
GET/readyPublic200 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.

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

Text JSON payload
{
  "phone": "+12025550123",
  "type": "text",
  "message": "Your order is ready for collection."
}
Text request · cURL
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.

202 accepted response · illustrative job
{
  "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.

  1. Request a ticket with type and exact byte size. mimetype defaults to application/octet-stream; fileName is optional.
  2. Read the returned mode. Multipart tickets return mediaId, uploadId, partSize and expiresAt.
  3. PUT raw binary parts numbered from 1. The path uses mediaId; the X-Upload-Id header uses uploadId. Set the exact Content-Length for each part.
  4. After every part succeeds, POST {} to /v1/media/uploads/:mediaId/complete. The server tracks part ETags; no parts array is needed.
  5. Poll GET /v1/media/:mediaId until response.media.status === "ready". An uploaded or imported file is not ready until inspection completes.
201 multipart ticket · example
{
  "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.

Complete file upload · Node.js fetch
// 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 typeMaximumFormat
image10 MiBJPEG, PNG, WebP
video50 MiBMP4
audio16 MiBMP3, Ogg, MP4 audio, AAC, WAV
voice16 MiBOgg/Opus
document100 MiBFile content must agree with its filename and MIME
sticker1 MiBWebP; 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.

200 media inspection response · example
{
  "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.

Import and inspect media · Node.js fetch
// 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 });
202 import response · example
{
  "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.

Read a job · cURL
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"
200 job response · example
{
  "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
  }
}
StatusMeaning
queuedAccepted and waiting for processing
processingWork is leased for processing; this alone does not prove a WhatsApp send started
retry_waitA safe pre-send retry is scheduled; later messages wait behind it
sentThe WhatsApp send returned successfully; recipient delivery is not confirmed
deliveredA delivery receipt has been received
readA read receipt has been received
failed / cancelledProcessing ended; inspect the error and refundedPaise instead of assuming a refund
unknownThe send outcome is uncertain; never automatically resend with a new key
Read message history · cURL
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"
200 history response · abbreviated
{
  "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.

Session request · cURL
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"
200 session response · example
{
  "assignment": {
    "id": "ASSIGNMENT_ID",
    "status": "active"
  },
  "connection": {
    "connected": true,
    "state": "connected"
  },
  "waiting": false
}
200 pairing / QR response · example
{
  "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 typePrice per messageAvailability

Loading current prices…

Read public message prices · cURL
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 response format · example
{
  "error": {
    "code": "INSUFFICIENT_WALLET_BALANCE",
    "message": "INSUFFICIENT_WALLET_BALANCE"
  }
}
HTTP statusCommon codesWhat to do
400INVALID_JSON, INVALID_INPUT, IDEMPOTENCY_KEY_REQUIRED, MEDIA_NOT_READY, INVALID_UPLOAD_PARTFix the payload, header or media readiness; inspect error.message.
401AUTHENTICATION_REQUIRED, INVALID_API_KEYProvide an active Bearer key; revoked or malformed keys are rejected.
402INSUFFICIENT_WALLET_BALANCEAdd wallet credit. This failure creates no job or partial charge.
403INSUFFICIENT_SCOPE, ACCOUNT_UNAVAILABLE, MESSAGE_TYPE_DISABLEDUse the required scope and a verified, active account; check enabled message types.
404JOB_NOT_FOUND, MEDIA_NOT_FOUNDUse an existing resource owned by your account.
409IDEMPOTENCY_CONFLICT, NO_ACTIVE_ASSIGNMENT, INVALID_UPLOADKeep payload/key consistent; obtain allocation or a valid upload ticket.
410UPLOAD_EXPIREDCreate a new ticket and upload again; do not reuse expired upload authorization.
413REQUEST_TOO_LARGE, PLAN_MEDIA_LIMITStay within 32 KiB JSON requests and the effective media/storage limits.
429QUEUE_FULL, MESSAGE_RATE_LIMITED, UPLOAD_RATE_LIMITED, MEDIA_STORAGE_OR_UPLOAD_LIMITRespect Retry-After when present; use bounded backoff. Reuse a message’s idempotency key.
503WHATSAPP_NOT_READY, WORKER_UNAVAILABLE, RESET_NOT_CONFIRMEDRead 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.