Skip to content

Developer guideUpdates: see what's new →

Build with Vancely

Connect a WhatsApp number, send order updates, OTPs and reminders with one HTTP request, and receive replies and delivery receipts on your server. This guide covers the concepts; every endpoint and field is in the full API reference.

Privacy by design: we never store message content. Text and media pass through encrypted and are erased once delivered, so keep anything you need on your own server. Our privacy promise

Authentication

Create an API key in the dashboard under API keys. Keys look like wsk_abc123defg_… and are shown only once, so store them in your server's secrets. Send the key as a Bearer token on every request:

Authorization: Bearer wsk_your_key_here

Each key has one or more scopes. Give each integration only the scopes it needs:

ScopeAllows
messages:sendSend messages
messages:readRead messages
channels:readRead channels
channels:manageCreate, connect and delete channels
webhooks:manageManage webhooks

Never put an API key in a mobile app or browser code; call the API from your backend.

Base URL & format

https://your-domain/api/v1
  • Requests and responses are JSON. Send Content-Type: application/json and Accept: application/json.
  • Successful responses wrap the result in data; lists are paginated with links and meta.
  • Phone numbers use international format without + or spaces, e.g. 923001234567. A local 03001234567 is converted for you.
  • IDs are UUIDs. Times are ISO 8601.
  • Integrations built on the previous address, https://wa.bytevancer.com/api/v1, keep working unchanged (API calls and chat widgets already on your site). Use https://vancely.bytevancer.com/api/v1 for anything new.
  • Errors always have the same shape:
{
  "error": {
    "code": "consent_required",
    "message": "This recipient has never messaged this number. Pass consent.source ...",
    "details": { }
  }
}

Branch on error.code, not on the message text; the full list is under Error codes.

Quickstart

The examples use curl and two shell variables. Your base URL is https://your-domain/api/v1.

export WA_API="https://your-domain/api/v1"
export WA_KEY="wsk_your_key_here"

1. Create a channel

A channel is one WhatsApp number connected to your account. You can also do this in the dashboard.

curl -X POST "$WA_API/channels" \
  -H "Authorization: Bearer $WA_KEY" -H "Content-Type: application/json" \
  -d '{ "name": "Store orders" }'

# 201 Created
{ "data": { "id": "9f1c2e4a-...", "name": "Store orders", "status": "connecting", ... } }

2. Scan the QR code

Poll the QR endpoint every 3 to 5 seconds. While status is qr, show qr_svg (a data URI you can put in an <img>) and scan it from the phone: WhatsApp → Settings → Linked devices → Link a device. The code changes about every 20 seconds. Stop polling when status becomes connected.

curl "$WA_API/channels/9f1c2e4a-.../qr" -H "Authorization: Bearer $WA_KEY"

{ "data": { "status": "qr", "qr": "2@...", "qr_svg": "data:image/svg+xml;base64,...", "pairing_code": null } }

No second screen? Create the channel with "pairing_phone": "923001234567" and enter the 8-character pairing_code on the phone under Link with phone number instead.

3. Send a message

curl -X POST "$WA_API/messages" \
  -H "Authorization: Bearer $WA_KEY" -H "Content-Type: application/json" \
  -d '{
    "channel_id": "9f1c2e4a-...",
    "to": "923001234567",
    "text": "Hi Ayesha, your order #1042 is confirmed and will be delivered in 2-3 days.",
    "consent": { "source": "order", "reference": "1042" },
    "reference": "order-1042"
  }'

# 202 Accepted
{ "data": { "id": "c0a8...", "direction": "out", "to": "923001234567", "status": "queued", "reference": "order-1042", ... } }

The API answers immediately with status: queued. Delivery happens in the background and each change (sent, delivered, read, failed) is pushed to your webhook. You can also fetch GET /messages/{id}.

Sending messages

POST /messages accepts these type values (default text):

TypeFields
texttext (up to 4,096 characters)
image, videomedia_url, optional caption
documentmedia_url, optional filename, mimetype, caption
audiomedia_url
locationlatitude, longitude, optional location_name

media_url must be a public HTTPS URL we can download. Use reference for your own ID (order number, ticket ID); it is echoed back in every webhook for that message. Use POST /contacts/check to see which numbers have WhatsApp before sending.

This API is for notifications and conversations with people who expect your messages. The rules below are enforced by the API, not just written in a policy; they exist to keep your number from being banned by WhatsApp.

  • Consent on first contact. If a number has never written to your channel, the first message must include consent.source, one of order, otp, signup or support, and optionally consent.reference (such as the order number). Consent is stored per number and channel, so later messages don't need it. You can also record consent in advance with POST /contacts/opt-in, for example at checkout.
  • Opt-out. When a recipient replies STOP, UNSUBSCRIBE, CANCEL, OPTOUT, OPT OUT or BAND KARO, they are opted out and further messages are refused with recipient_opted_out. They can opt back in by replying START or SUBSCRIBE. You can record opt-outs from your own system with POST /contacts/opt-out.
  • No broadcasts. Sending the same text to more than 20 different people within an hour is refused. Real notifications contain a name, an order number or a time, so they differ naturally.
  • No link shorteners. Links through shorteners such as bit.ly or tinyurl.com are refused. Link to your own domain. Your account can also restrict links to an allow-list of your domains (dashboard → Settings).
  • Warm-up. New numbers have a lower daily limit that grows over the first week, and cannot send links or media until they have been connected for 3 days. See the schedule.
  • Health checks. A number with many failed sends or very few replies is paused automatically (channel_paused). Fix the cause, then resume it from the dashboard or with POST /channels/{id}/resume.

Please also read the Acceptable Use Policy.

Error codes

Every error returns the JSON shape above. These are the codes you can receive:

CodeHTTPMeaning / what to do
unauthenticated401The Authorization header is missing, malformed, or the key was revoked.
insufficient_scope403The API key does not have the scope this endpoint needs (for example messages:send).
rate_limited429Too many API requests per minute for your plan. Wait and retry with backoff.
validation_failed422The request body is invalid. error.details lists the problems per field.
not_found404The endpoint, channel, message or webhook does not exist in your account.
account_suspended403Your account is suspended. Contact support.
subscription_inactive402No active subscription. Pay your invoice or choose a plan to keep sending.
monthly_quota_exceeded429Your plan's monthly message allowance is used up.
channel_limit_reached403Your plan does not allow another number (channel). Delete one or upgrade.
webhook_limit_reached403Your plan does not allow another webhook endpoint.
channel_paused409The number was paused, usually by health checks after many failures or few replies. Review your sending, then resume it.
channel_not_connected409The number is not connected to WhatsApp right now. Reconnect it by scanning a new QR code.
daily_limit_reached429The daily limit for this number is reached. New numbers ramp up over their first week (see the warm-up schedule).
recipient_opted_out422The recipient replied STOP (or you recorded an opt-out). Do not message them until they opt in again.
media_blocked_warmup422Media is not allowed until the number has been connected for 3 days.
broadcast_detected422The same text went to more than 20 different people in the last hour. Personalise each message.
media_not_available404The inbound message has no stored media to download.

Errors from the send rules (consent_required, broadcast_detected and so on) are returned when you call POST /messages, so nothing is queued. A message that is queued but later fails on WhatsApp's side arrives as a message.failed webhook with an error object.

Webhooks

Add an HTTPS endpoint in the dashboard or with POST /webhooks, choose events, and optionally limit it to one channel. We send a POST with a JSON body to your URL for each event. Respond with any 2xx status within 10 seconds; do slow work in a background job.

Webhooks are the only place you get message content. We don't store it: text, locations and file links reach you in the webhook and are erased once it is delivered. GET /messages returns metadata only (numbers, type, status, timestamps, your reference), and its search filter matches your reference or the message ID. A delivery we give up on, or retry by hand afterwards, carries "content_removed": true instead of the content. Status events for your own messages (message.sent, delivered, read, failed) don't repeat your text; match them by id or reference.

Events

EventSent when
message.receivedInbound message received
message.sentOutbound message sent
message.deliveredOutbound message delivered
message.readOutbound message read
message.failedOutbound message failed
channel.connectedChannel connected
channel.disconnectedChannel disconnected
channel.logged_outChannel logged out
channel.pausedChannel paused by health checks
flow.startedA bot flow started for a contact
flow.completedA bot flow finished (with the answers it collected)
flow.abandonedA contact stopped answering a bot flow
flow.failedA bot flow stopped on an error
flow.eventA bot flow sent an event (a "Send event" step, e.g. order.placed)
conversation.handoffA bot handed a contact to a person (with the answers so far)
conversation.human_repliedSomeone replied from the business phone; the bot paused
conversation.releasedA contact was given back to the bot
chat.startedA website visitor started a chat (widget)
chat.messageA message in a website chat (visitor, bot or agent)
chat.endedA website chat ended
chat.continued_on_whatsappA website visitor moved the chat to WhatsApp

Payload

Every delivery has the same envelope {event, created_at, data}. For message.* events, data is the message:

{
  "event": "message.received",
  "created_at": "2026-09-23T10:15:02+05:00",
  "data": {
    "id": "c0a8f5e2-...",
    "channel_id": "9f1c2e4a-...",
    "direction": "in",
    "from": "923001234567",
    "type": "text",
    "text": "1",
    "status": "received",
    "reference": null,
    "push_name": "Ayesha",
    "is_group": false,
    "media": null,
    "location": null,
    "error": null,
    "timestamp": "2026-09-23T10:15:01+05:00"
  }
}
  • Outbound messages have "direction": "out" and to instead of from, and carry your reference.
  • media, when present, is { mimetype, filename, size, url }; download it from url with your API key (scope messages:read) when the webhook arrives. We don't keep files: a file is deleted a few minutes after its first complete download, and after 24 hours at the latest.
  • location holds the coordinates of a shared location; error is { code, message } on message.failed.

For channel.* events, data is the channel:

{
  "event": "channel.paused",
  "created_at": "2026-09-23T10:20:00+05:00",
  "data": {
    "id": "9f1c2e4a-...",
    "name": "Store orders",
    "status": "connected",
    "phone": "923001112233",
    "paused": true,
    "paused_reason": "High failure rate in the last 24 hours",
    "reason": null
  }
}

Headers

HeaderValue
X-Webhook-IdUnique delivery ID. The same ID is reused on retries, so use it to ignore duplicates.
X-Webhook-EventThe event name, e.g. message.delivered.
X-Webhook-Signaturet=<unix time>,v1=<hex signature>
X-Webhook-Event: message.delivered
X-Webhook-Id: 5b1d7c52-...            # same value on every retry of this delivery
X-Webhook-Signature: t=1758620102,v1=5f2b...e9  # v1 = hex HMAC-SHA256(secret, "<t>.<raw body>")

Retries

If your endpoint times out or returns anything other than 2xx, the delivery is retried 7 more times (8 attempts in total), waiting 10s, 30s, 2m, 10m, 30m, 1h, 2h between attempts. After the last attempt it is marked dead and can be replayed from the dashboard or with POST /webhooks/{id}/deliveries/{delivery}/retry. An endpoint that keeps failing is disabled automatically. Deliveries can arrive out of order; use timestamp and the message status to reconcile.

Verifying signatures

Each endpoint has a secret (whsec_…) shown once in the dashboard when you create it or rotate it. The signature is v1 = HMAC-SHA256(secret, "<t>.<raw body>") in lowercase hex, where t is the Unix timestamp from the same header. Always verify against the raw request body (before JSON parsing), compare in constant time, and reject timestamps older than a few minutes to stop replays.

PHP

webhook.php
<?php
$secret  = getenv('WA_WEBHOOK_SECRET');           // whsec_...
$payload = file_get_contents('php://input');       // raw body, before json_decode
$header  = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';

parse_str(str_replace(',', '&', $header), $sig);   // ['t' => ..., 'v1' => ...]
$expected = hash_hmac('sha256', ($sig['t'] ?? '') . '.' . $payload, $secret);

if (! hash_equals($expected, $sig['v1'] ?? '') || abs(time() - (int) ($sig['t'] ?? 0)) > 300) {
    http_response_code(401);
    exit;
}

$event = json_decode($payload, true);
// Deduplicate on $_SERVER['HTTP_X_WEBHOOK_ID'], then handle $event['event'] and $event['data'] ...
http_response_code(200);

In Laravel, use $request->getContent() for the raw body and $request->header('X-Webhook-Signature').

Node.js (Express)

server.mjs
import crypto from 'node:crypto';
import express from 'express';

const app = express();

// Keep the raw body: the signature is computed over the exact bytes we sent.
app.post('/webhooks/whatsapp', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('X-Webhook-Signature') || '';
  const sig = Object.fromEntries(header.split(',').map((part) => part.split('=')));

  const expected = crypto
    .createHmac('sha256', process.env.WA_WEBHOOK_SECRET)
    .update(`${sig.t}.${req.body}`)
    .digest('hex');

  const valid =
    typeof sig.v1 === 'string' &&
    sig.v1.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sig.v1), Buffer.from(expected)) &&
    Math.abs(Date.now() / 1000 - Number(sig.t)) < 300;

  if (!valid) return res.sendStatus(401);

  const event = JSON.parse(req.body);
  // Deduplicate on req.get('X-Webhook-Id'), then handle event.event and event.data ...
  res.sendStatus(200);
});

Warm-up schedule

New numbers that suddenly send hundreds of messages are a common reason for bans. Each number's daily limit therefore grows with the number of days since it was first connected. Your plan's own daily limit per number still applies; the lower of the two wins.

Days connectedMax messages that dayLinksMedia
Day 1–250BlockedBlocked
Day 3100BlockedBlocked
Day 4200AllowedAllowed
Day 5–7400AllowedAllowed
Day 8 onwardsYour plan's daily limit per numberAllowedAllowed

The current day is returned as warmup_day on the channel. Replies from customers count in your favour: numbers with healthy conversations are what WhatsApp expects to see.

Bot flows API

Build flows in the dashboard (Bot flows). They answer customers who write to you, call your own system for products, prices or free slots ("Call your system" steps), wait for events such as a payment, schedule reminders, and hand over to a person. Three API scopes control them: flows:read, flows:run and conversations:manage.

Start a flow for a contact

Useful for order confirmations or booking offers. It is not a reply, so normal rules apply: consent for someone who never wrote to you, quotas and broadcast detection (personalise the first message). One start per contact every 10 minutes.

curl -X POST "$WA_API/flows/FLOW_ID/start" \
  -H "Authorization: Bearer $WA_KEY" -H "Content-Type: application/json" \
  -d '{
    "channel_id": "9f1c2e4a-...",
    "to": "923001234567",
    "variables": { "name": "Ayesha", "order": "1042" },
    "consent": { "source": "order", "reference": "1042" }
  }'

# 202 Accepted — the flow runs in the background; results arrive as flow.* webhooks

Continue a waiting flow, cancel reminders

# From your payment webhook: continue the flow waiting for "payment.confirmed"
curl -X POST "$WA_API/conversations/9f1c2e4a-.../923001234567/events" \
  -H "Authorization: Bearer $WA_KEY" -H "Content-Type: application/json" \
  -d '{ "event": "payment.confirmed", "data": { "amount": "USD 30.00" } }'

# Cancel reminders a flow scheduled (e.g. the booking was cancelled)
curl -X POST "$WA_API/reminders/cancel" -H "Authorization: Bearer $WA_KEY" \
  -H "Content-Type: application/json" -d '{ "cancel_key": "booking-B77" }'

GET /conversations/{channel}/{phone} shows whether a bot or a person is handling the contact and which event a flow waits for; POST …/handoff and …/release switch between them.

Verify requests from "Call your system" steps

// "Call your system" steps sign every request with the connection's signing secret:
//   X-Flow-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>.<METHOD>.<path+query>.<raw body>")>
const [t, v1] = req.get('X-Flow-Signature').split(',').map((p) => p.split('=')[1]);
const target = req.originalUrl;                 // path + query exactly as received
const expected = crypto.createHmac('sha256', process.env.WA_FLOW_SECRET)
  .update(`${t}.${req.method}.${target}.${req.rawBody ?? ''}`).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected)) || Math.abs(Date.now() / 1000 - Number(t)) > 300) {
  return res.sendStatus(401);
}
// Idempotency-Key repeats on retries of the same step; X-Flow-Contact is the contact's number.

Answer with JSON (at most 64 KB) within the connection's timeout (≤ 8 s). Any non-2xx answer, a timeout or a redirect follows the step's "Failed" path. We never store your answers; only the fields you map are kept, encrypted, while the chat runs.

Website chat widget

Add live chat to your website with one line. Visitors chat in a bubble; you answer from the dashboard Inbox, a bot flow can answer for you, and visitors can move the chat to WhatsApp. Create a widget under Chat widget in the dashboard, list the websites that may use it, and copy its snippet.

Install

Paste the snippet just before </head> (or </body>) on every page where the chat should appear. On WordPress, use a plugin such as "Insert Headers and Footers" or your theme's header. The site key is public; the chat only works on the websites you list (https://example.com or https://*.example.com for every subdomain).

<script async src="https://vancely.bytevancer.com/chat/v1.js" data-site-key="pk_your_site_key"></script>

Content Security Policy

If your site sends a CSP header, allow our domain in script-src and connect-src (and img-src if your widget uses an uploaded launcher image or avatar). The widget needs nothing inline: no unsafe-inline, no unsafe-eval.

Content-Security-Policy: script-src 'self' https://vancely.bytevancer.com; connect-src 'self' https://vancely.bytevancer.com; img-src 'self' https://vancely.bytevancer.com

JavaScript API

Open the chat from your own buttons with window.BVChat:

<button onclick="BVChat.open()">Chat with us</button>

<script>
  // BVChat exists once v1.js has loaded (it is async).
  BVChat.open();   // open the chat panel
  BVChat.close();  // close it
  BVChat.toggle(); // open or close
</script>

Bots on your website

Any bot flow can answer website visitors: pick it on the widget's Bot tab. Only the flow attached to the widget answers on the website; your WhatsApp triggers don't fire there, and a flow can't be started for a visitor from the API. A few things work differently on the website:

  • {{contact.phone}} is empty: visitors have no phone number. {{contact.name}} and the new {{contact.email}} come from the pre-chat form.
  • {{contact.channel}} is web or whatsapp, and {{contact.id}} is the visitor id on the website or the phone number on WhatsApp. Use them to tell the two apart in conditions or in "Call your system" steps.
  • Reminders are skipped (a website visitor can't be messaged later), and questions that ask for a location can only be answered by typing. The builder warns about both when a flow is attached to a widget.
  • Menus appear as quick-reply buttons; tapping one sends the option's number, just like on WhatsApp.
  • A handoff step, or the visitor asking for a person (your flow's handoff keywords, such as human), passes the chat to the Inbox and tells the visitor they are being connected.
Hi {{contact.name}}! We'll reply on {{contact.channel}}.
// Website chat:  contact.channel = "web",      contact.id = the visitor id (v...),  contact.phone = ""
// WhatsApp:      contact.channel = "whatsapp", contact.id = the phone number

Inbox

The dashboard Inbox shows website chats and WhatsApp conversations in one list. Reply, take over from the bot, hand back to it, and close website chats.

  • WhatsApp threads show only the messages that arrive while the Inbox is open; the thread starts with "Earlier messages are on your phone", because we don't keep message history.
  • A visitor who writes while nobody is in the Inbox and no bot is answering becomes a waiting chat. You can still answer it from the Inbox; waiting chats close after about a day.
  • Messages aren't kept: website messages exist only while the chat is open and are deleted when it closes (after an hour without activity, or up to 24 hours for a chat waiting for your reply), unless you turn on transcripts and the visitor agrees.

Continue on WhatsApp and alerts

Continue on WhatsApp. Choose a connected number on the widget's WhatsApp tab and the chat shows a "Continue on WhatsApp" button. It opens WhatsApp with a short reference prefilled; when the visitor sends it to your number, the website chat closes and the conversation carries on in WhatsApp: with the same person if one was handling the chat, otherwise with your bot.

WhatsApp alerts. On the Alerts tab, choose the number that sends alerts and your own phone number, then verify it: the dashboard shows a code such as ALERT-1234, which you send from your phone to the business number within 30 minutes. After that you get an alert when a chat starts or a bot hands a visitor over, while nobody is in the Inbox. Alerts contain the widget name, the time and the visitor's name, never the chat itself.

Website chat messages are free. Alerts and replies you send to WhatsApp contacts are normal WhatsApp messages: they follow the sending rules and count toward your message quota.

Limits and error codes

How many widgets, open chats, Inbox seats and transcript days you get depends on your plan; see pricing. The widget handles these errors itself. You only meet them if you build your own client or read the browser's network log:

CodeHTTPMeaning
chats_busy409Your plan's limit of open chats is reached. The visitor can try again shortly.
chat_rate_limited429The visitor is sending too fast (messages under 0.7 s apart, too many a minute or an hour, or too many new chats from one address). Wait a moment before retrying.
duplicate_message429The same message was sent more than three times in a row.
chat_closed409The chat ended while the visitor was writing (for example an agent closed it). Send the message again to start a new chat.
empty_message422The message was empty after trimming.
whatsapp_unavailable409Continue on WhatsApp is off or its number isn't connected, or the chat already used its WhatsApp links.
transcripts_off409The visitor answered the save-a-copy question, but the widget doesn't save transcripts.
invalid_visitor401The visitor's token is missing, invalid or expired. The widget starts a new session on the next message.
origin_not_allowed403The page's website isn't in the widget's list of websites.
widget_unavailable403The account is suspended or its plan doesn't include live chat.
widget_not_found404The site key doesn't exist or the widget is turned off (for example the key was rotated or the widget was deleted).

Privacy and transcripts

Chats are not saved unless two things happen: you turn on transcripts for the widget and confirm you understand they will be stored, and the visitor answers Yes when the chat asks to save a copy. Only messages after the Yes are saved, the Yes covers one chat, and saved transcripts are encrypted and deleted after the number of days you choose (up to your plan's maximum). You can read, download or delete them under the widget's Transcripts tab.

Chat webhooks

Subscribe a webhook to the chat.* events to send website chats to your CRM or helpdesk. They go to webhooks for all numbers (a webhook limited to one WhatsApp number doesn't receive them) and use the same envelope, headers, signature and retries as every other event.

  • chat.started: {conversation_id, widget_id, visitor_id, name?, email?, origin_host}. Name and email are what the visitor typed in the pre-chat form.
  • chat.message: {conversation_id, widget_id, role (visitor|bot|agent|system), text, seq, at}.
  • chat.ended: {conversation_id, widget_id, reason (visitor|agent|idle|widget_deleted|continued_on_whatsapp), message_count, transcript_saved}.
  • chat.continued_on_whatsapp: {conversation_id, widget_id, whatsapp_conversation_id, channel_id, contact}.
{
  "event": "chat.message",
  "created_at": "2026-10-04T10:15:02+05:00",
  "data": {
    "conversation_id": "9b1c6f9e-6a0f-4a51-9f5e-0c8f2d1e7a33",
    "widget_id": "4f0e2a8c-2d55-4f0b-a1b7-8e3c9d6a1f20",
    "role": "visitor",
    "text": "Do you deliver to my area?",
    "seq": 3,
    "at": "2026-10-04T10:15:02+05:00"
  }
}

As with messages, we don't keep chat content: once a delivery succeeds or is given up, its text, name and email are removed and it carries "content_removed": true. Chats are only saved when you turn on transcripts for the widget and the visitor agrees in the chat (see Privacy and transcripts).

Full API reference

Every endpoint, parameter and response schema is documented in the interactive API reference, generated from the code so it is always current. The OpenAPI document is at /docs/api.json for Postman, Insomnia or code generators.

Questions? Contact support.