API quickstart
Send messages, queue campaigns and receive replies from your own software. Everything is JSON over HTTPS. The full machine-readable spec is at /openapi.json.
Authentication
Create an API key in your dashboard under Developer → API keys. Keys start with vr_ and are shown once. Send it on every request:
Authorization: Bearer vr_your_key_here
Replace https://your-domain below with your panel address and SESSION with the name of a linked number, which you’ll find on the Numbers page (for example u3-main).
9876543210 gets 91 added automatically. You can also send to a group (…@g.us), a channel (…@newsletter) or a saved contact (contact:12).Send a text message
POST /api/sessions/{session}/send
curl -X POST https://your-domain/api/sessions/SESSION/send \
-H "Authorization: Bearer $VR_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "9876543210",
"text": "Namaste {{name}}, your OTP is {{code}}",
"vars": { "name": "Asha", "code": "4821" }
}'
Response:
{ "ok": true, "message": { "id": "3EB0…", "to": "919876543210@s.whatsapp.net", "timestamp": 1792214875, "new_contact": false } }
Send an image, video or document by URL
The file is fetched into memory, sent, and forgotten. Use type image, video, audio or document; add filename for documents.
curl -X POST https://your-domain/api/sessions/SESSION/send \
-H "Authorization: Bearer $VR_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "9876543210",
"text": "Your invoice for October",
"media": { "type": "document", "url": "https://example.com/inv-2041.pdf", "filename": "Invoice-2041.pdf" }
}'
Queue a campaign
Campaigns go through the safe queue: random gaps, typing, breaks, daily caps, sending hours and STOP handling all apply. Without schedule.at the campaign starts now.
POST /api/campaigns
curl -X POST https://your-domain/api/campaigns \
-H "Authorization: Bearer $VR_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Diwali offer",
"sessions": ["SESSION"],
"message": { "text": "{Namaste|Hello} {{name}} ji, 20% off till Friday. Reply YES to book. Reply STOP to opt out." },
"audience": { "tags": ["vip"], "exclude_tags": ["unpaid"] },
"schedule": { "at": 1792310400, "repeat": "none" },
"speed": "safe"
}'
Track it with GET /api/campaigns/{id}, and pause, resume or cancel with POST /api/campaigns/{id}/pause, /resume or /cancel. A quick one-off list without saved contacts still works through the older POST /api/sessions/{session}/queue endpoint.
Webhooks
Add a webhook URL under Developer → Webhooks and choose events. Each event is a POST like this:
{ "event": "message", "session": "u3-main", "owner_id": 3, "timestamp": 1792214875,
"data": { "id": "3EB0…", "chat_phone": "919876543210", "push_name": "Asha", "type": "text", "text": "YES" } }
Events include message, message.receipt, poll.vote, queue.status, campaign.status, session.status, session.health, contact.optout and contact.optin. If your endpoint does not return 2xx, delivery is retried a few times with increasing delays.
Verify webhook signatures
Each request carries X-Timestamp and X-Signature: sha256=<hex>, an HMAC-SHA256 of timestamp + "." + raw body using your webhook secret. Reject anything older than five minutes.
PHP
<?php
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$secret = getenv('VR_WEBHOOK_SECRET');
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $raw, $secret);
if (!hash_equals($expected, $sig) || abs(time() - (int)$ts) > 300) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
// handle $event['event'] …
http_response_code(200);
Node.js (Express)
const crypto = require('crypto');
const express = require('express');
const app = express();
app.post('/vr-webhook', express.raw({ type: 'application/json' }), (req, res) => {
const ts = req.get('X-Timestamp') || '';
const sig = req.get('X-Signature') || '';
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.VR_WEBHOOK_SECRET)
.update(ts + '.' + req.body)
.digest('hex');
const ok = sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)) &&
Math.abs(Date.now() / 1000 - Number(ts)) < 300;
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body);
// handle event.event …
res.sendStatus(200);
});
Errors
Errors look like {"ok": false, "code": "…", "error": "…"} with a matching HTTP status. Codes worth handling:
not_connected(503): the number is offline or reconnecting.not_on_whatsapp(422): the recipient doesn’t use WhatsApp.daily_limit,new_contact_limit,timelocked(429): a safety limit was reached; try later.limit_reached(429),feature_locked(403): your plan doesn’t allow this.unauthorized(401): missing or wrong API key.
Need help integrating? Contact us or grab the OpenAPI spec for Postman or code generators.