Skip to content
Rviing by OneLedger

Developer API

Send messages from your own software with API keys and receive webhooks.

Send WhatsApp template and text messages from your own software (billing, ERP, CRM, e‑commerce) and receive delivery updates on your server.

  • Base URL: https://<your Rviing domain>/api/v1 (shown in Developer API inside the app)
  • Format: JSON request and response bodies, UTF‑8
  • Auth: API key in the Authorization header
  • Time zone: timestamps without an offset are India time (IST, UTC+05:30)

Contents

  1. Quick start
  2. Authentication
  3. Permissions
  4. Phone numbers
  5. Templates
  6. Send a template message
  7. Send a text message
  8. Message status
  9. Idempotency (safe retries)
  10. Stores and numbers (tenant mapping)
  11. Webhooks (status callbacks)
  12. Errors
  13. Rate limits
  14. Billing
  15. Changelog

Quick start

  1. In the app, open Developer API → API keys → Create API key.
  2. Pick the WhatsApp number, tick the permissions you need, and (optionally) add a webhook URL.
  3. Copy the API key and webhook secret. They are shown only once.
  4. Find an approved template:
curl https://<your Rviing domain>/api/v1/templates \
  -H "Authorization: Bearer rv_live_YOUR_KEY" \
  -H "Accept: application/json"
  1. Send it:
curl -X POST https://<your Rviing domain>/api/v1/messages/template \
  -H "Authorization: Bearer rv_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "9876543210",
    "template_uuid": "01J9Z8X7Y6W5V4T3S2R1Q0P9N8",
    "variables": ["Aarti", "INV-1024"],
    "idempotency_key": "invoice-1024-whatsapp"
  }'

Authentication

Every request needs your API key:

Authorization: Bearer rv_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

X-API-Key: rv_live_… is also accepted.

  • Keep the key on your server. Never put it in a website, mobile app or public repository.
  • We store only a fingerprint (SHA‑256 hash) of the key. If you lose it, regenerate it in the app — the old key stops working immediately.
  • A key can be disabled (requests are refused) and enabled again without changing it.
  • Optional IP allowlist: exact addresses (203.0.113.10) or CIDR ranges (203.0.113.0/24). Requests from other IPs get 403 ip_not_allowed.

Check your key

GET /client — works with any valid key.

{
  "status": true,
  "data": {
    "client_code": "billing-software-x7k2qa",
    "client_name": "Billing software",
    "account": {
      "uuid": "01KSW982MZKPVQ17QN2CFHM8F7",
      "name": "Shree Jewellers",
      "display_phone_number": "+91 85274 39569",
      "display_name": "Shree Jewellers",
      "quality_rating": "GREEN"
    },
    "permissions": ["templates.read", "messages.read", "messages.template.send"],
    "rate_limit_per_minute": 60,
    "webhook_configured": true
  }
}

Permissions

Each key only gets what you tick when you create it.

Permission Allows
templates.read GET /templates, GET /templates/{uuid}
messages.template.send POST /messages/template
messages.text.send POST /messages/text
messages.read GET /messages, GET /messages/{uuid}
accounts.read GET /client/accounts, GET /client/accounts/available, GET /client/accounts/{tenant_id}
accounts.manage POST /client/accounts, DELETE /client/accounts/{tenant_id}

A missing permission returns 403 with "code": "permission_denied".


Phone numbers

Send the customer number in to. We clean it up for you:

You send We use
9876543210 919876543210
09876543210 919876543210
+91 98765-43210 919876543210
0091 98765 43210 919876543210
447700900123 447700900123

Numbers with 10 digits (or 11 starting with 0) are treated as Indian (+91). For other countries always include the country code. After cleaning, the number must have 10–15 digits.


Templates

Only templates approved by Meta for the selected number are returned. Create and submit templates in the app (Templates).

List templates

GET /templates — permission templates.read

Query Description
tenant_id Optional store ID (see tenant mapping)
language e.g. en_US, hi
category MARKETING, UTILITY or AUTHENTICATION
{
  "status": true,
  "account": {
    "id": 12,
    "uuid": "01KSW982MZKPVQ17QN2CFHM8F7",
    "account_name": "Shree Jewellers",
    "display_name": "Shree Jewellers",
    "display_phone_number": "+91 85274 39569",
    "phone_number_id": "1234567890"
  },
  "data": [
    {
      "uuid": "01J9Z8X7Y6W5V4T3S2R1Q0P9N8",
      "template_name": "invoice_ready",
      "category": "UTILITY",
      "language": "en_US",
      "header_type": "DOCUMENT",
      "header_text": null,
      "body_text": "Dear {{1}}, your invoice {{2}} is attached.",
      "footer_text": "Shree Jewellers",
      "buttons_json": null,
      "status": "approved",
      "required_variables": 2,
      "required_header_variables": 0,
      "requires_media": "document"
    }
  ]
}
  • required_variables — how many values variables must have (null if the template uses named placeholders).
  • required_header_variables — 1 when the header text has a placeholder; send header_variables.
  • requires_media — image, video or document when the template has a media header; send media.

Get one template

GET /templates/{uuid} — same shape, single object in data.


Send a template message

POST /messages/template — permission messages.template.send

Templates can be sent at any time (no 24‑hour window).

Field Type Required Description
to string yes Customer number (format)
template_uuid string (26) yes From GET /templates
variables array of strings when the body has {{1}}… Values for {{1}}, {{2}}… in order. Must match required_variables. Empty values are not allowed
header_variables array (1 item) when the header has {{1}} Value for the header placeholder
media object for media‑header templates { "type": "image" | "video" | "document", "url": "https://…", "filename": "Invoice.pdf" } — the URL must be publicly reachable
idempotency_key string (≤191) yes Your unique key for this send. Or send an Idempotency-Key header
client_reference_id string (≤191) no Your own ID (invoice no., order no.). Returned in responses and callbacks
tenant_id string no Store ID to send from that store's number

Authentication (OTP) templates: send the code as the only variable: "variables": ["482913"]. We fill both the body and the copy‑code button.

Example

curl -X POST https://<your Rviing domain>/api/v1/messages/template \
  -H "Authorization: Bearer rv_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "919876543210",
    "template_uuid": "01J9Z8X7Y6W5V4T3S2R1Q0P9N8",
    "variables": ["Aarti", "INV-1024"],
    "media": { "type": "document", "url": "https://example.com/inv-1024.pdf", "filename": "INV-1024.pdf" },
    "client_reference_id": "INV-1024",
    "idempotency_key": "invoice-1024-whatsapp"
  }'

Success — 200

{
  "status": true,
  "duplicate": false,
  "data": {
    "uuid": "01JA0B1C2D3E4F5G6H7J8K9M0N",
    "client_reference_id": "INV-1024",
    "meta_message_id": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgS...",
    "status": "sent",
    "error_message": null,
    "sent_at": "2026-10-03 11:51:21",
    "delivered_at": null,
    "read_at": null,
    "failed_at": null,
    "to": "919876543210",
    "message_type": "template",
    "template_name": "invoice_ready",
    "error_code": null,
    "meta_error_code": null,
    "error_description": null,
    "created_at": "2026-10-03 11:51:20"
  }
}

status: "sent" means WhatsApp accepted the message. Delivery and read updates arrive later by webhook or via message status.

Not sent — 422

When WhatsApp (or our checks) refuse the message, it is saved with status failed and returned with HTTP 422:

{
  "status": false,
  "duplicate": false,
  "code": "recipient_opted_out",
  "message": "The customer is unsubscribed (replied STOP, or the number is not on WhatsApp). Messages are blocked until they send START or are opted in again.",
  "data": {
    "uuid": "01JA0B1C2D3E4F5G6H7J8K9M0P",
    "status": "failed",
    "error_code": "recipient_opted_out",
    "error_message": "Not sent: this contact has opted out (replied STOP).",
    "...": "..."
  }
}

Input problems (wrong variable count, missing media, invalid number) are rejected before anything is saved — nothing is charged and you can retry with the same idempotency_key after fixing the request:

{
  "status": false,
  "message": "This template needs 2 variable(s) in \"variables\" but 1 were sent.",
  "code": "template_variables_mismatch",
  "requirements": { "body_variables": 2, "header_variables": 0, "media_type": "document" }
}

Send a text message

POST /messages/text — permission messages.text.send

WhatsApp allows free text only within 24 hours after the customer last messaged your number (the customer service window). Outside the window, use a template.

Field Type Required Description
to string yes Customer number
message string (≤4096) yes Text to send
idempotency_key string yes Or Idempotency-Key header
client_reference_id string no Your own ID
tenant_id string no Store ID
curl -X POST https://<your Rviing domain>/api/v1/messages/text \
  -H "Authorization: Bearer rv_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "to": "919876543210", "message": "Your order is ready for pickup.", "idempotency_key": "order-778-ready" }'

The response has the same shape as a template send, plus data.service_window_open (true if we have seen a customer message in the last 24 hours). We still pass the message to WhatsApp either way; if the window is closed WhatsApp refuses it with code: "outside_24h_window" (Meta error 131047) — immediately, or later as a message.failed callback.


Message status

Status flow: queued → sent → delivered → read, or failed at any step.

One message

GET /messages/{uuid} — permission messages.read

Returns the same data object as the send response. A failed message is returned with HTTP 422 and status: false (kept for compatibility); read data.status for the message state.

Search messages

GET /messages — permission messages.read

Query Description
client_reference_id Your reference
status queued, sent, delivered, read, failed
to Customer number
from_date, to_date YYYY-MM-DD (IST)
per_page 1–100 (default 20)
page Page number
{
  "status": true,
  "data": [ { "uuid": "01JA0B1C2D3E4F5G6H7J8K9M0N", "status": "read", "client_reference_id": "INV-1024", "...": "..." } ],
  "meta": { "current_page": 1, "last_page": 1, "per_page": 20, "total": 1 }
}

Only messages sent with your API key are visible.


Idempotency (safe retries)

Networks fail. To make retries safe, every send needs an idempotency key:

  • If a request with the same key was already received by the same API key, we do not send again. We return the original message with "duplicate": true.
  • This also holds when two identical requests arrive at the same moment.
  • Use one key per business event, e.g. invoice-1024-whatsapp, otp-<user>-<timestamp>. Do not reuse a key for a different message — you will get the old one back.
  • Requests rejected by validation (HTTP 422 with validation_failed or a template_* code) are not saved, so the key stays free.
  • A message that WhatsApp refused (data.status: "failed") keeps its key. To try again after fixing the cause, use a new key.

You can send the key in the body (idempotency_key) or as a header:

Idempotency-Key: invoice-1024-whatsapp

Stores and numbers (tenant mapping)

If your software serves many stores, each store can send from its own WhatsApp number. Send tenant_id with each request:

  1. If tenant_id is mapped (and active) → that store's number is used.
  2. Otherwise, the mapping STORE-1 (common number) is used if it exists.
  3. Otherwise, the API key's default number is used.

Tenant IDs are case‑insensitive and saved in capitals (store-226 → STORE-226). One number can be mapped to one store per API key.

Mappings can be managed in the app (Developer API → key → Map store) or by API:

Method Endpoint Permission Description
GET /client/accounts accounts.read List mappings
GET /client/accounts/available accounts.read Your numbers that can be mapped
GET /client/accounts/{tenant_id} accounts.read One mapping
POST /client/accounts accounts.manage Create or update a mapping
DELETE /client/accounts/{tenant_id} accounts.manage Disable a mapping (STORE-1 cannot be removed)

Create or update a mapping

curl -X POST https://<your Rviing domain>/api/v1/client/accounts \
  -H "Authorization: Bearer rv_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "tenant_id": "STORE-226", "whatsapp_account_uuid": "01M1G7W6XNGXQ5S0AN1DCG47ZC", "external_user_id": "226" }'

Send whatsapp_account_uuid (or the numeric whatsapp_account_id from /client/accounts/available). Mapping a number that is already active for another store returns 422 account_already_mapped; disable the old mapping first.

{
  "status": true,
  "message": "Store WhatsApp account mapped successfully.",
  "data": {
    "mapping_id": 2,
    "tenant_id": "STORE-226",
    "whatsapp_account_id": 14,
    "whatsapp_account_uuid": "01M1G7W6XNGXQ5S0AN1DCG47ZC",
    "display_phone_number": "+91 89192 70145"
  }
}

POST /client/accounts only maps numbers of your own Rviing account. To let a store owner connect their own WhatsApp number to their store, ask for a one-time connect link and send the owner to it:

POST /api/v1/client/connect-links          (permission: accounts.manage)
{ "tenant_id": "STORE-226", "external_user_id": "336",
  "return_url": "https://your-app.example/whatsapp?x=1",
  "name": "Kamlesh", "company_name": "Kamlesh Jewellers", "email": "k@example.com", "phone": "9876543210" }

→ 201 { "status": true, "data": { "id", "uuid", "url": "https://rviing.com/connect/…", "expires_at" } }

The owner opens url (valid 24 h, one time; a new link replaces the older pending one of the same tenant), creates a Rviing account or logs in (name… pre-fill the sign-up form), then picks one of their numbers or connects a new one with Facebook. The number is mapped to tenant_id (it stays owned by — and billed to — the owner) and the owner goes back to return_url?rviing=connected&tenant=STORE-226 (rviing=cancelled when they cancel).

GET /api/v1/client/connect-links/{uuid} (accounts.read) → status: pending | connected | cancelled | expired, plus display_phone_number, display_name, whatsapp_account_uuid once connected. When connected, your webhook also gets event: "account.connected" (header X-Rviing-Event, signed like status callbacks) with tenant_id, external_user_id, whatsapp_account_uuid, display_phone_number, display_name.


Webhooks (status callbacks)

Add a webhook URL (https) to your API key. Whenever one of your messages changes status we send:

POST <your webhook URL>
Content-Type: application/json
X-Rviing-Signature: 5f2b7c…  (hex HMAC-SHA256)
{
  "event": "message.delivered",
  "data": {
    "message_uuid": "01JA0B1C2D3E4F5G6H7J8K9M0N",
    "client_reference_id": "INV-1024",
    "status": "delivered",
    "meta_message_id": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgS...",
    "error_message": null,
    "occurred_at": "2026-10-03T11:51:23+05:30"
  }
}

Events: message.sent, message.delivered, message.read, message.failed. The Send test event button in the app sends {"event": "ping", …} signed the same way.

Delivery: reply with any 2xx within 15 seconds. Otherwise we retry up to 4 more times (after about 30 seconds, 2 minutes, 10 minutes and 30 minutes). Updates can arrive out of order or more than once — use status and timestamps, and make your handler idempotent on message_uuid + status.

Verify the signature

X-Rviing-Signature = lowercase hex of HMAC‑SHA256(raw request body, your webhook secret). Always compute it over the raw body bytes, before parsing JSON, and compare in constant time.

PHP

<?php
$body = file_get_contents('php://input');
$expected = hash_hmac('sha256', $body, getenv('RVIING_WEBHOOK_SECRET'));
$received = $_SERVER['HTTP_X_RVIING_SIGNATURE'] ?? '';

if (!hash_equals($expected, $received)) {
    http_response_code(401);
    exit('Invalid signature');
}

$event = json_decode($body, true);
// update your record by $event['data']['client_reference_id'] or ['message_uuid']
http_response_code(200);

Laravel

Route::post('/webhooks/rviing', function (Illuminate\Http\Request $request) {
    $expected = hash_hmac('sha256', $request->getContent(), config('services.rviing.webhook_secret'));
    abort_unless(hash_equals($expected, (string) $request->header('X-Rviing-Signature')), 401);

    $event = $request->json()->all();
    // ...
    return response()->noContent();
});

Node.js (Express)

const crypto = require('crypto');
const express = require('express');
const app = express();

app.post('/webhooks/rviing', express.raw({ type: 'application/json' }), (req, res) => {
  const expected = crypto
    .createHmac('sha256', process.env.RVIING_WEBHOOK_SECRET)
    .update(req.body) // raw Buffer
    .digest('hex');
  const received = req.get('X-Rviing-Signature') || '';

  const valid =
    received.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
  if (!valid) return res.status(401).send('Invalid signature');

  const event = JSON.parse(req.body.toString('utf8'));
  // ...
  res.sendStatus(200);
});

Rotating the secret: use Rotate secret in the app. Callbacks are signed with the new secret immediately, so update your server right away.


Errors

Every error has the same shape:

{
  "status": false,
  "message": "Readable explanation you can log or show to staff.",
  "code": "machine_readable_code",
  "errors": { "to": ["The recipient phone number (to) is required."] }
}

errors is present only for field validation problems. Use code in your logic, not message (wording may improve over time).

HTTP code Meaning / what to do
401 missing_api_key No key sent. Add the Authorization header
401 invalid_api_key Wrong, deleted or regenerated key
401 api_key_disabled The key is disabled in the app
403 account_disabled The Rviing account is paused. Contact support
403 ip_not_allowed Request IP is not in the key's allowlist
403 permission_denied The key lacks the permission for this endpoint
404 account_unavailable No active WhatsApp number for this key / tenant
404 template_not_found Template not approved, or belongs to another number
404 message_not_found Message does not exist or was sent with another key
404 mapping_not_found, account_not_found Tenant mapping / number not found
409 mapping_conflict Two mapping requests collided. Retry
422 validation_failed A field is missing or invalid. See errors
422 template_variables_mismatch Wrong number of variables (see requirements)
422 template_variable_empty A variable is empty or only spaces
422 template_header_variable_required Send header_variables
422 template_media_required / template_media_mismatch Send media with the template's header type
422 account_already_mapped, common_mapping_locked Mapping rules (see tenant mapping)
422 recipient_opted_out Customer replied STOP. Do not retry until they opt back in
422 outside_24h_window Text outside the 24‑hour window. Send a template
422 recipient_unavailable Number not on WhatsApp or cannot receive (Meta 131026)
422 marketing_limit_reached Meta limited marketing messages to this user (131049). Try later
422 pair_rate_limited / throughput_limited Too many messages too fast to this number / from your number
422 template_paused / template_disabled Meta paused the template for low quality
422 payment_issue Meta billing problem on the WhatsApp Business account
422 account_token_invalid The number must be reconnected in the app
422 meta_error Other WhatsApp error. See data.meta_error_code and message
422 send_failed Not sent for another reason. See message
429 rate_limited Too many requests. Wait Retry-After seconds
500 — Our error. Safe to retry with the same idempotency_key

Failed sends also include data.meta_error_code (Meta's numeric code, when known) and data.error_description.


Rate limits

  • Each API key has its own limit per minute (default 60, adjustable in the app up to 1000).
  • Every response includes X-RateLimit-Limit and X-RateLimit-Remaining.
  • Over the limit: HTTP 429, "code": "rate_limited" and a Retry-After header (seconds). Wait and retry.
  • WhatsApp has its own limits per number (messaging tier and quality). Those come back as send failures (throughput_limited, pair_rate_limited).

Billing

Messages are paid from your prepaid Rviing wallet (see Wallet & billing in the app):

  • Each delivered, billable template costs Meta's rate for its category (marketing, utility, authentication) plus a small Rviing platform fee. Current prices are listed in the app.
  • Service conversations (replies inside the 24‑hour window) are not charged by Meta.
  • Messages that fail are not charged; if Meta later marks a message non‑billable, the charge is refunded.
  • Keep your wallet topped up — use Add money in the app.

Store credit (platform tenants)

A platform can give its stores (tenants) WhatsApp credit that is used only for messages the platform sends for that store through this API. Rviing keeps the ledger.

Who pays a message sent with tenant_id:

Store has credit? Number used Paid from
No any as before – the wallet of the number's owner
Yes your (platform's) shared number the store's credit only
Yes the store's own number the store's credit, then the store owner's Rviing wallet

Messages sent from the Rviing app itself never use store credit. When a store has credit and it cannot pay for a message, the send is refused with 422 and code: insufficient_balance (needed, available, credit_balance), so you can fall back to another channel. A refund (failed / non‑billable) goes back to where it was taken from.

Live balance and rates — GET /api/v1/client/wallet?tenant_id=STORE-226 (permission accounts.read)

{"status": true, "data": {
  "tenant_id": "STORE-226", "currency": "INR",
  "billing_mode": "credit_only",
  "credit": {"exists": true, "balance": "50.000000", "status": "active"},
  "number": {"own": false, "display_phone_number": "+91 90000 00000"},
  "wallet": null,
  "usable_balance": "50.000000",
  "rates": [{"category": "utility", "message_type": "template", "meta_rate": "0.115000", "fee": "0.020000", "total": "0.135000"}]
}}

billing_mode: wallet (no credit), credit_only (shared number), credit_then_wallet (store's own number). wallet is shown only for the store's own number.

Add or take credit — POST /api/v1/client/wallet/credits (permission accounts.manage)

{"tenant_id": "STORE-226", "amount": 50, "type": "grant", "reference": "welcome-50-STORE-226", "note": "Welcome credit"}

type is grant (default) or deduct (never below zero). The same reference again returns the first transaction with "duplicate": true and changes nothing – use it for recharges so a retry never adds twice. Answer 201 (200 for a duplicate) with transaction_id, balance_after and the wallet summary above.

Credit history — GET /api/v1/client/wallet/transactions?tenant_id=STORE-226&per_page=25 (permission accounts.read): type (grant, deduct, whatsapp_billing, whatsapp_adjustment, refund), direction, amount, balance_after, template_name, category, recipient_number, reference, created_at.


Changelog

Date Change
2026‑10‑21 Added: store credit for platform tenants – GET /client/wallet, GET /client/wallet/transactions, POST /client/wallet/credits; template / text sends answer insufficient_balance when a store with credit cannot pay.
2026‑10‑07 Added: connect links (POST/GET /client/connect-links) so a store owner connects their own number to a tenant; webhook event account.connected.
2026‑10 Added: code on every error, errors on validation failures, Idempotency-Key header, X-API-Key header, CIDR IP allowlist, X-RateLimit-* and Retry-After headers, GET /messages search, header_variables, template variable / media validation, required_variables on templates, whatsapp_account_uuid for mappings, message fields to, message_type, template_name, error_code, meta_error_code, error_description, created_at. All existing fields and endpoints are unchanged.
2026‑08 First version: templates, template and text sends, message status, store mappings, signed status callbacks.

Need a hand? Our team can walk you through it.

Contact support