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
Authorizationheader - Time zone: timestamps without an offset are India time (IST, UTC+05:30)
Contents
- Quick start
- Authentication
- Permissions
- Phone numbers
- Templates
- Send a template message
- Send a text message
- Message status
- Idempotency (safe retries)
- Stores and numbers (tenant mapping)
- Webhooks (status callbacks)
- Errors
- Rate limits
- Billing
- Changelog
Quick start
- In the app, open Developer API → API keys → Create API key.
- Pick the WhatsApp number, tick the permissions you need, and (optionally) add a webhook URL.
- Copy the API key and webhook secret. They are shown only once.
- Find an approved template:
curl https://<your Rviing domain>/api/v1/templates \
-H "Authorization: Bearer rv_live_YOUR_KEY" \
-H "Accept: application/json"
- 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 get403 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 valuesvariablesmust have (nullif the template uses named placeholders).required_header_variables—1when the header text has a placeholder; sendheader_variables.requires_media—image,videoordocumentwhen the template has a media header; sendmedia.
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_failedor atemplate_*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:
- If
tenant_idis mapped (and active) → that store's number is used. - Otherwise, the mapping
STORE-1(common number) is used if it exists. - 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"
}
}
Let a store owner connect their own number (connect links)
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-LimitandX-RateLimit-Remaining. - Over the limit: HTTP
429,"code": "rate_limited"and aRetry-Afterheader (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