< Developer Docs · v1 >
Build on Wque
Send WhatsApp messages from your own backend: session messages (custom text) or templates you define in the dashboard (OTP, utility, marketing). One POST /v1/send, optional media URL, poll or webhook for delivery.
Overview
What you get
Wque runs your linked WhatsApp Business number and exposes a small JSON API. You choose per request: free-form text inside an active chat session, or a named template with filled-in variables.
One endpoint
POST /v1/sendwithfrom_account_id(WA-…public id or legacy UUID),toin E.164, then eitherCUSTOM_TEXT+bodyorTEMPLATE+template_name+variables. Optionalrequest_id(UUID) makes retries safe.Signed webhooks
Inbound and outbound lifecycle events use Standard Webhooks signing (HMAC-SHA256,
whsec_…secrets).Credits
Each send is quoted from your plan: custom text, OTP-class templates, utility templates, marketing-class templates bill at different per-message rates. Insufficient wallet →
402. See pricing.
Production API base
https://api.wque.chat
Dashboard
https://app.wque.chat
Templates, API keys, webhooks, and pairing live here.
Quickstart
Ship your first message
- Sign up and complete KYC so you can link a WhatsApp Business number.
- Pair your number in the dashboard and copy its
WA-…id (or useGET /api/v1/accountswith a dashboard JWT). - For anything that is not plain session text (OTP codes, order updates, promos), create a template in the dashboard first — see below.
- Create an API key (
wq_live_…) and callPOST https://api.wque.chat/v1/sendwithX-Api-Key.
Prerequisites
Set up in the Wque dashboard first
The public API only sends — every other knob lives in the dashboard. Before any /v1/send call will succeed, the dashboard owner must:
- Sign up at wque.chat/signup and complete KYC (Indian DPDP requirement).
- Pair a WhatsApp Business number. Copy its public id from the dashboard — format
WA-AAAA1234567(4 letters + 7 digits). This is the value offrom_account_idon every send. The legacy internal UUID is also accepted but theWA-…id is preferred and stable. - Create message templates for anything that is not free-form session text — see the next section for body / category / variable rules.
- Mint an API key at
app.wque.chat/settings/api-keys. Format iswq_live_<6-hex-prefix>_<48-hex-secret>. Plaintext is shown once. Optional expiry:1_week,1_month,6_months,1_year,never; expired keys return401. - Optional — configure a webhook endpoint at Developer → Webhook if you want inbound replies POSTed to your URL instead of polling.
Missing any prerequisite makes /v1/send fail with 401 UNAUTHORIZED, 403 WABA_NOT_CONNECTED, or 400 TEMPLATE_INVALID_BODY.
Authentication
API keys
Public routes use X-Api-Key: wq_live_…. Dashboard REST uses Authorization: Bearer <jwt> after WhatsApp OTP login. Read the key from a server-side env var — never ship it to a browser, mobile app, or git-tracked file.
Recommended env names: WQUE_API_KEY, WQUE_ACCOUNT_ID, WQUE_API_BASE, WQUE_WEBHOOK_SECRET. Revoked, malformed, or expired keys return 401 — never auto-retry on 401, alert the operator instead.
Session message
Custom text (CUSTOM_TEXT)
Use this when you are inside an active customer chat window and WhatsApp allows a normal text message. Set "channel": "CUSTOM_TEXT" and put the full message in body. Billing uses the custom text rate on your plan (not the OTP/utility/marketing template rates).
This path does not use template_name. It is the right choice for human-style replies and simple notifications when policy allows session messages.
curl -sS -X POST "https://api.wque.chat/v1/send" \
-H "Content-Type: application/json" \
-H "X-Api-Key: wq_live_YOUR_KEY" \
-d '{
"from_account_id": "WA-ABCD1234567",
"to": "+919876543210",
"channel": "CUSTOM_TEXT",
"body": "Hi! Your order is ready for pickup."
}'Templates
Create in dashboard → send by name + variables
For OTP codes, order updates, marketing copy, and most outbound starts, you must define a template in the Wque dashboard first. Each template has:
- name — unique per account; this exact string is
template_namein the API (matching is case-insensitive on the server). - body — text with placeholders. Two syntaxes are supported (regex
{{(\d+|[a-zA-Z_][a-zA-Z0-9_]*)}}):Named —{{code}},{{first_name}},{{tracking_url}}— or numeric{{1}},{{2}}, …. - category —
OTP,UTILITY,MARKETING, orCUSTOM.CUSTOMtemplates are auto-APPROVED; the rest start inDRAFT→ submit for Meta review →APPROVED. - language — defaults to
en_IN. - Maximum 6 distinct placeholders per template body (server-enforced).
When you call the API, pass "channel": "TEMPLATE" (or omit channel and only set template_name — the server treats that as a template send). Send a JSON object variables whose keys match the names inside the {{…}} placeholders. Empty values are skipped; unknown keys in the template body stay as literal text until you define them.
If template_name does not match any non-deleted template for your user, the API returns 400 with TEMPLATE_INVALID_BODY / “template not found”. Create and sync the template in the dashboard before calling /v1/send.
OTP-style template (body in dashboard like: Your code is {{code}})
curl -sS -X POST "https://api.wque.chat/v1/send" \
-H "Content-Type: application/json" \
-H "X-Api-Key: wq_live_YOUR_KEY" \
-d '{
"from_account_id": "WA-ABCD1234567",
"to": "+919876543210",
"channel": "TEMPLATE",
"template_name": "app_login_otp",
"variables": {
"code": "482193"
}
}'Utility-style template
curl -sS -X POST "https://api.wque.chat/v1/send" \
-H "Content-Type: application/json" \
-H "X-Api-Key: wq_live_YOUR_KEY" \
-d '{
"from_account_id": "WA-ABCD1234567",
"to": "+919876543210",
"channel": "TEMPLATE",
"template_name": "order_shipped",
"variables": {
"name": "Aarav",
"tracking": "WQ-48291"
}
}'Marketing-style template
curl -sS -X POST "https://api.wque.chat/v1/send" \
-H "Content-Type: application/json" \
-H "X-Api-Key: wq_live_YOUR_KEY" \
-d '{
"from_account_id": "WA-ABCD1234567",
"to": "+919876543210",
"channel": "TEMPLATE",
"template_name": "weekend_sale",
"variables": {
"discount": "20",
"code": "WQSAVE20"
}
}'Rendering
How variables render — and why every message has a REF footer
The worker (internal/templates.Render) applies two transformations to every outbound message that customers can preview locally:
- Substituted values are wrapped in WhatsApp bold
*value*. Asterisks inside the value are stripped first so the bold delimiters stay well-formed. Static template text is sent as-is. - A 14-character reference footer (
REF - XXXXXXXXXXXXXX) is appended server-side, separated by a blank line. It links inbound replies back to the specific outbound send and cannot be overridden.
Mirror this in any UI that previews outbound messages so users see what recipients actually receive.
Template body in dashboard
Hi {{name}}, your tracking id is {{tracking}}.API call
{
"channel": "TEMPLATE",
"template_name": "order_shipped",
"variables": {
"name": "Aarav",
"tracking": "WQ-48291"
}
}What recipients see
Hi *Aarav*, your tracking id is *WQ-48291*. REF - 1A7K9C2X3B4N5M
Billing category
OTP, utility, marketing — how it maps
The API does not take a separate “bill this as OTP” flag on /v1/send. For template sends, the template row’s category in your account decides the rate. ForCUSTOM_TEXT, the custom-text rate always applies.
| What you send | How | Billed as |
|---|---|---|
| Free-form session text | CUSTOM_TEXT + body | Custom text |
| OTP / login codes to your users | TEMPLATE + template with category OTP | OTP rate |
| Order / account updates | TEMPLATE + category UTILITY | Utility rate |
| Promotions, broadcasts | TEMPLATE + category MARKETING | Marketing rate |
| Template category “CUSTOM” in dashboard | TEMPLATE | Same bucket as marketing for pricing |
Note: Signing in to wque.chat / the dashboard uses the platform WhatsApp OTP flow — that is separate from /v1/send. To deliver OTPs to your end-users via the API, create an OTP-category template on your linked number and call /v1/send with variables as shown above.
Request body
POST /v1/send — fields
| Field | Required | Notes |
|---|---|---|
| from_account_id | yes | Your connected WhatsApp account (WA-… or UUID). |
| to | yes | Recipient E.164, e.g. +9198…. |
| channel | conditional | CUSTOM_TEXT or TEMPLATE. If you set template_name, behaviour is template even if channel is omitted. |
| body | CUSTOM_TEXT | Plain message text for session sends. |
| template_name | TEMPLATE | Must match a template name in your dashboard. |
| variables | if placeholders | Map of string → string; keys align with {{key}} in the template body. |
| request_id | no | UUID for idempotency; same id → same logical send. |
| media_kind | no | NONE | IMAGE | AUDIO | VIDEO | FILE (default NONE). |
| media_url | with media | HTTPS URL WhatsApp servers can fetch; must respect size limits below. |
| media_caption | no | Caption for image / video / document when supported. |
Response shape
Success & error envelope
Every Wque endpoint returns the same envelope: { "data": ..., "error": null } on success or { "data": null, "error": { "code": "...", "message": "..." } } on failure.
200 — successful enqueue
{
"data": {
"request_id": "5f1c2d3e-4a8b-46e0-9b91-1c3a2b4c5d6e",
"message_id": "9b1c8a2d-7c1e-4d3a-90b1-1a2b3c4d5e6f",
"status": "queued",
"idempotent": false,
"estimated_charge_paise": 12
},
"error": null
}status is always "queued" here — the final state lands via webhook (when the recipient replies) or polled with /v1/status/{id}. idempotent: true means the server matched a prior call with the same request_id and returned that earlier row.
4xx / 5xx — error envelope
{
"data": null,
"error": {
"code": "INSUFFICIENT_CREDITS",
"message": "need 25 paise, have 12"
}
}Attachments
Send image, audio, video, or a document
Set media_kind to one of IMAGE, AUDIO, VIDEO, FILE and provide media_url as a direct HTTPS link to the bytes (WhatsApp will download from that URL). Optional media_caption adds a caption where the channel allows it. You can combine media with CUSTOM_TEXT or TEMPLATE the same way as a text-only send.
Hard limits enforced server-side (raw download size): IMAGE 5 MiB, AUDIO 16 MiB, VIDEO 16 MiB, FILE 100 MiB.
IMAGE
curl -sS -X POST "https://api.wque.chat/v1/send" \
-H "Content-Type: application/json" \
-H "X-Api-Key: wq_live_YOUR_KEY" \
-d '{
"from_account_id": "WA-ABCD1234567",
"to": "+919876543210",
"channel": "CUSTOM_TEXT",
"body": "Here is your invoice preview.",
"media_kind": "IMAGE",
"media_url": "https://cdn.example.com/invoices/inv-2026-001.png",
"media_caption": "Invoice #2026-001"
}'AUDIO
curl -sS -X POST "https://api.wque.chat/v1/send" \
-H "Content-Type: application/json" \
-H "X-Api-Key: wq_live_YOUR_KEY" \
-d '{
"from_account_id": "WA-ABCD1234567",
"to": "+919876543210",
"channel": "CUSTOM_TEXT",
"body": "Voice note attached.",
"media_kind": "AUDIO",
"media_url": "https://cdn.example.com/audio/reminder.m4a"
}'VIDEO
curl -sS -X POST "https://api.wque.chat/v1/send" \
-H "Content-Type: application/json" \
-H "X-Api-Key: wq_live_YOUR_KEY" \
-d '{
"from_account_id": "WA-ABCD1234567",
"to": "+919876543210",
"channel": "CUSTOM_TEXT",
"body": "Product demo video.",
"media_kind": "VIDEO",
"media_url": "https://cdn.example.com/video/demo.mp4",
"media_caption": "Watch: new feature tour"
}'FILE (document)
Use FILE for PDFs and other documents. Use media_caption if you want a visible label; the public /v1/send body does not include a separate filename field — the worker sends the file bytes from media_url.
curl -sS -X POST "https://api.wque.chat/v1/send" \
-H "Content-Type: application/json" \
-H "X-Api-Key: wq_live_YOUR_KEY" \
-d '{
"from_account_id": "WA-ABCD1234567",
"to": "+919876543210",
"channel": "CUSTOM_TEXT",
"body": "Your PDF is attached.",
"media_kind": "FILE",
"media_url": "https://cdn.example.com/docs/terms.pdf",
"media_caption": "terms.pdf"
}'Observability
GET /v1/status/{id}
{id} is the request_id you supplied (or the one returned at send time). The response data.status is one of queued, sent, delivered, read, failed. Polling is fine for low volume — prefer the inbound webhook for production. Don't poll faster than once per second per request, and ownership is enforced server-side (unknown / unauth ids return 404 NOT_FOUND).
curl -sS "https://api.wque.chat/v1/status/REQUEST_UUID" \ -H "X-Api-Key: wq_live_YOUR_KEY"
Sample response (live state from Redis)
{
"data": {
"request_id": "5f1c2d3e-4a8b-46e0-9b91-1c3a2b4c5d6e",
"status": "delivered",
"message_id": "9b1c8a2d-7c1e-4d3a-90b1-1a2b3c4d5e6f",
"error": "",
"updated_at": 1746826150,
"attempts": 1
},
"error": null
}Webhooks
Inbound events
Configure your endpoint under Developer → Webhook. Wque follows Standard Webhooks v1.0.0: HMAC-SHA256 over webhook-id.webhook-timestamp.rawBody, sent in the webhook-signature header as v1,<base64>. Reject anything outside a ±5 minute timestamp window.
Headers Wque sends
webhook-id: msg_<random> webhook-timestamp: <unix seconds> webhook-signature: v1,<base64-hmac-sha256> X-Wque-Event-Type: message.received X-Wque-Attempt: 1 Content-Type: application/json
Event types delivered today
| Event | Fires on |
|---|---|
| message.received | A WhatsApp user replied to your number |
| webhook.test | "Send test event" button in the dashboard |
Outbound delivery state (sent/delivered/read/failed) is not yet delivered to user-configured webhooks — poll GET /v1/status/{id} for outbound state.
Endpoint contract
Respond 2xx within 10 seconds. Wque retries on 408, 429, 5xx and transport errors with ±25% jitter; 11 retry slots (+10s, +30s, +1m, +5m, +15m, +30m, +1h, +2h, +4h, +8h, +12h ≈ ~28h to DLQ). Endpoints with 24 h of consecutive failures are auto-disabled. Other 4xx codes are treated as permanent failures.
Idempotency
Always pass a stable request_id
Supply your own UUIDv4 as request_id on every /v1/send. Replaying the same id returns the existing send (response "idempotent": true) — never duplicates the WhatsApp message, never double-bills. If you don't supply one, Wque generates a UUID and returns it; persist that value alongside your domain row so retries from your side stay safe.
Rate limits
Per-recipient send ceiling
A per-recipient rate limit applies to the same (api-key-user, to) pair. Default ceiling is 60 sends per recipient per 60 s, admin-tunable via the WQUE_PUBLIC_RATE_PER_MIN env var. Exceeding it returns 429 RATE_LIMITED — back off exponentially (e.g. 1s → 2s → 4s, max 30s) and retry the same request_id so the deduper kicks in once you cross the window.
Billing
Why a send returned 402
- Each plan grants a fixed monthly/yearly bag of credits (
included_credits_paise). 1 credit = 100 paise = ₹1 of send budget. - Per-message rates are set per plan, per category by the admin (see
plans.per_marketing_after_paise,per_utility_after_paise,per_otp_after_paise,per_custom_text_paise). Read the live rates fromGET /api/v1/billing/packageon the dashboard — do not hardcode rates client-side. - Public sends are quoted before enqueue. If wallet balance < quoted charge, the API returns
402 INSUFFICIENT_CREDITSwithneed {paise}andhave {paise}in the message. - Recovery: top up via the dashboard, then re-call with the same
request_id. Idempotency means no double-charge.
Errors
HTTP semantics & what you should do
| HTTP | code | When | What to do |
|---|---|---|---|
| 200 | — | Send accepted & queued | Persist request_id |
| 400 | BAD_REQUEST | Bad JSON, missing from_account_id/to, invalid media_kind, malformed request_id | Surface to user; do not retry |
| 400 | TEMPLATE_INVALID_BODY | template_name doesn't exist (or is deleted) | Create / re-sync template in dashboard |
| 401 | UNAUTHORIZED | Missing / invalid / revoked / expired API key | Stop; alert operator |
| 402 | INSUFFICIENT_CREDITS | Wallet can't cover the quoted charge | Show "top up" UI; do not silently retry |
| 403 | WABA_NOT_CONNECTED | from_account_id not owned by this API key's user | Re-check WA id; do not blindly retry |
| 404 | NOT_FOUND | /v1/status/{id} with unknown / un-owned id | Check the request_id |
| 429 | RATE_LIMITED | Per-recipient send rate exceeded | Exponential back-off; replay same request_id |
| 5xx | INTERNAL | Wque-side error | Retry with the same request_id |
For AI coding agents
Integrate Wque using Cursor, Claude Code or Antigravity
We publish a single, opinionated AGENTS.md that lays out everything an AI coding agent needs to integrate Wque end-to-end: prerequisites, auth, every /v1/send field, the OTP / utility / marketing template flow, media limits, idempotency, rate limits, billing model, the inbound webhook contract, plus production-grade Node, Python and Go client recipes. Drop it at the root of your project — modern agents auto-load AGENTS.md from there.
Auto-detected by
--read AGENTS.md) GitHub Copilot Workspace Continue.dev OpenAI Codex CLITwo-second install (run in your project root)
curl -fsSL https://wque.chat/AGENTS.md -o AGENTS.md git add AGENTS.md && git commit -m "docs: add Wque AGENTS.md"
Then prompt your agent: “Integrate Wque WhatsApp into this app — read AGENTS.md, store the API key as WQUE_API_KEY, and add a /login/otp endpoint that sends an OTP via the app_login_otp template.” Agents will follow the file end-to-end without further context.