How it works
QuickWA sits between your website and the official WhatsApp Business Platform. You call one REST endpoint to send; we handle the WhatsApp connection, the template rules, the 24-hour window, opt-outs and the inbox record. Everything that comes back — delivery receipts, read receipts, replies — is pushed to a webhook URL you own, signed so you can trust it.
| You do | QuickWA does | WhatsApp does |
|---|---|---|
Call POST /api/send-message from your backend | Validates, records it in your inbox, forwards to WhatsApp | Delivers to the customer |
| Expose one webhook URL | Signs and forwards every event to it | Reports sent / delivered / read / failed |
| Generate and verify your own OTP codes | Delivers the code inside an approved template | Shows a copy-code button |
Step 1 · Connect your WhatsApp number
Open Settings → WhatsApp in your dashboard and press Connect. Sign in with the Facebook Business account that owns the number and pick it.
- A number already running on the WhatsApp Business app can be kept — choose Coexistence. Do that part on a computer, with the phone in your hand for the QR code.
- If the number has two-step verification, you will be asked for its PIN. Lost it? It cannot be switched off, but you can change it: WhatsApp Manager → your number → Settings → Two-step verification → Change PIN.
- After connecting, press Activate number if the dashboard asks. Until it says connected, API sends will fail.
Step 2 · Create an API key
Go to Developer API in the left menu and press + New API key. Only account admins can do this.
| Field | What it does |
|---|---|
| Name | For you — one key per system you integrate, so you can revoke one without breaking the others. |
| Scopes | Defaults to messages:send and contacts:read. A key without messages:send is refused with 403. |
| Rate limit | Requests per minute for this key. Default 100, adjustable between 1 and 1000. |
Step 3 · Create a message template
WhatsApp only lets a business start a conversation with a template Meta has approved. Build one in Templates → New template, or submit it over the API. You are emailed the moment Meta approves or rejects it.
| Category | Use it for |
|---|---|
AUTHENTICATION | One-time passwords and login codes. WhatsApp writes the wording itself. |
UTILITY | Order updates, appointment reminders, receipts — anything the customer expects. |
MARKETING | Offers and announcements. Priced higher and easier to get blocked. |
Submitting an OTP template over the API
POST https://quickwa.com/api/templates
Authorization: Bearer <your dashboard session>
Content-Type: application/json
{
"name": "login_code",
"category": "AUTHENTICATION",
"language": "en_US",
"components": [
{ "type": "BODY", "add_security_recommendation": true },
{ "type": "FOOTER", "code_expiration_minutes": 5 },
{ "type": "BUTTONS", "buttons": [
{ "type": "OTP", "otp_type": "COPY_CODE", "text": "Copy code" }
]}
]
}
login_code,
order_shipped. Each language is a separate approval.Step 4 · Send your first message
One endpoint handles every outbound message. The contact and the conversation are created for you, and the message shows up in your shared inbox like any other.
cURL
curl -X POST https://quickwa.com/api/send-message \
-H "X-API-Key: $QUICKWA_KEY" \
-H "Content-Type: application/json" \
-d '{
"phone_number": "919876543210",
"template_name": "order_shipped",
"language": "en_US",
"variables": { "1": "Amit", "2": "ORD-4821" }
}'
Node.js
const res = await fetch('https://quickwa.com/api/send-message', {
method: 'POST',
headers: {
'X-API-Key': process.env.QUICKWA_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({
phone_number: '919876543210',
template_name: 'order_shipped',
language: 'en_US',
variables: { '1': 'Amit', '2': 'ORD-4821' }
})
});
const out = await res.json();
if (!res.ok) throw new Error(JSON.stringify(out));
console.log(out.message_id); // keep this to match the delivery receipt
PHP
$ch = curl_init('https://quickwa.com/api/send-message');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'X-API-Key: ' . getenv('QUICKWA_KEY'),
'Content-Type: application/json'
],
CURLOPT_POSTFIELDS => json_encode([
'phone_number' => '919876543210',
'template_name' => 'order_shipped',
'language' => 'en_US',
'variables' => ['1' => 'Amit', '2' => 'ORD-4821']
])
]);
$out = json_decode(curl_exec($ch), true);
Python
import os, requests
r = requests.post('https://quickwa.com/api/send-message',
headers={'X-API-Key': os.environ['QUICKWA_KEY']},
json={
'phone_number': '919876543210',
'template_name': 'order_shipped',
'language': 'en_US',
'variables': {'1': 'Amit', '2': 'ORD-4821'},
}, timeout=20)
r.raise_for_status()
Retrying safely
If a request times out you cannot tell whether the message went out.
Send an Idempotency-Key header with a value of your own — an order id, a login
attempt id — and a repeat of the same request within 24 hours returns the original response
instead of sending a second time. Essential for OTPs, where a double send costs money and
confuses the customer.
curl -X POST https://quickwa.com/api/send-message \
-H "X-API-Key: $QUICKWA_KEY" \
-H "Idempotency-Key: login-4821-otp" \
-H "Content-Type: application/json" \
-d '{ ... }'
Response
{
"success": true,
"message_id": "wamid.HBgMOTE5ODc2NTQzMjEwFQ...",
"phone_number": "919876543210",
"type": "template",
"status": "sent",
"conversation_id": 4812,
"contact_id": 9107
}
message_id. It is the only way to match a later delivery
or read receipt back to the message you sent.Step 5 · Receive webhooks
Go to Developer API → Webhooks → + Add webhook, paste a public HTTPS URL on your site and choose what you want to receive. The signing secret is shown once — copy it into your environment now.
| Choose | You receive |
|---|---|
WhatsApp messages & delivery statuswhatsapp_webhook | Recommended. WhatsApp's own payload, unchanged — inbound messages and sent / delivered / read / failed. |
Inbound messages onlymessage.received | Replies only. No delivery or read receipts. |
Everything* | The above plus orders, payments, leads and appointments. |
Every delivery carries these headers
Content-Type: application/json
X-Event-Type: whatsapp_webhook
X-Webhook-Signature: sha256=<HMAC-SHA256 of the raw body, keyed with your secret>
A delivery receipt, as it arrives
{
"object": "whatsapp_business_account",
"entry": [{
"changes": [{
"field": "messages",
"value": {
"statuses": [{
"id": "wamid.HBgMOTE5...", // the message_id you stored
"status": "delivered", // sent | delivered | read | failed
"timestamp": "1789714000",
"recipient_id": "919876543210"
}]
}
}]
}]
}
An inbound reply, as it arrives
{
"object": "whatsapp_business_account",
"entry": [{
"changes": [{
"field": "messages",
"value": {
"contacts": [{ "profile": { "name": "Amit" }, "wa_id": "919876543210" }],
"messages": [{
"from": "919876543210",
"id": "wamid.HBgM...",
"timestamp": "1789714050",
"type": "text",
"text": { "body": "Yes, please" }
}]
}
}]
}]
}
200 fast, then work. A slow endpoint makes deliveries
pile up and count as failures. Acknowledge first, process afterwards.Website login OTP — the complete flow
The division of labour matters: your site generates, stores and verifies the code. QuickWA only delivers it. That keeps the secret on your side and means you can fall back to SMS or e-mail whenever you like.
Generate the code on your server
Six digits, random, stored against the phone number with an expiry that matches the
code_expiration_minutesin your template. Hash it if you keep it at rest, and rate-limit requests per number so nobody can burn your WhatsApp budget.Send it — the code goes in two places
An
AUTHENTICATIONtemplate has a copy-code button, so the code must appear in the body parameter and in the button component. This is the one thing people get wrong.POST https://quickwa.com/api/send-message X-API-Key: <your key> { "phone_number": "919876543210", "template_name": "login_code", "language": "en_US", "components": [ { "type": "body", "parameters": [ { "type": "text", "text": "483920" } ] }, { "type": "button", "sub_type": "url", "index": "0", "parameters": [ { "type": "text", "text": "483920" } ] } ] }Do not usevariablesfor an OTP. That shorthand only fills the body, so the button is left empty and WhatsApp answers132000 — Number of parameters does not match.Watch the receipt
Match the webhook's
statuses[].idto themessage_idyou stored.deliveredmeans it reached the phone.failedarrives with a reason — that is your cue to fall back to SMS rather than leaving the user waiting.Verify what the user types
Compare against your stored code, in constant time, and delete it once used or expired. QuickWA is never asked and never knows the answer.
Reference · Send a message
| Field | Required | Notes |
|---|---|---|
phone_number | Always | With the country code. Spaces, dashes, brackets and a leading + are all accepted — 919876543210, +91 98765 43210 and +91-98765-43210 are the same number and the same contact. 8–15 digits. |
template_name | For templates | Must be APPROVED. Required outside the 24-hour window. |
language | Optional | Template language code, e.g. en_US. Defaults to en. |
variables | Optional | Body-only shorthand: {"1":"Alex","2":"USD 99"} fills {{1}}, {{2}}. |
components | Optional | Full Meta component array. Needed for headers, buttons and OTPs. |
message | For text | Free text. Inside the 24-hour window only. |
type | Optional | template, text, interactive_buttons, interactive_list, image, document. |
contact_name | Optional | Saved against a newly created contact. |
Submit a template to Meta. Body:
{ name, category, language, components }. Most people use
Templates → New template instead.
Reference · Message types
Same endpoint and key. Everything except templates is free-form, so it only works inside the 24-hour window.
// plain text
{ "phone_number": "919876543210",
"message": "Your order has shipped." }
// tappable buttons (max 3)
{ "phone_number": "919876543210",
"type": "interactive_buttons",
"body": "Confirm your order?",
"buttons": [ { "id": "yes", "title": "Confirm" },
{ "id": "no", "title": "Cancel" } ] }
// a scrollable list menu
{ "phone_number": "919876543210",
"type": "interactive_list",
"body": "Pick a service", "button_text": "View",
"sections": [ { "title": "Services",
"rows": [ { "id": "s1", "title": "Site visit" } ] } ] }
// image or document, by public URL
{ "phone_number": "919876543210",
"type": "document",
"media_url": "https://your-site.com/invoice.pdf",
"filename": "Invoice.pdf",
"caption": "Your invoice" }
When a customer taps a button, the reply arrives on your webhook with the
id you chose, so you can act on it without parsing text.
Reference · Webhook endpoints
Register a URL. Body
{ "url": "https://…", "events": ["whatsapp_webhook"] }. Returns the signing
secret once. Only public HTTPS URLs are accepted.
Lists your webhooks. The secret is never returned again — you
get secret_set: true instead.
Issues a fresh secret and shows it once. The old one stops working immediately, so deploy the new one to your endpoint first.
Lists your API keys — name, prefix, scopes, rate limit, last used. Never the key itself.
Reference · Event catalogue
| Event | Fires when |
|---|---|
whatsapp_webhook | Any WhatsApp activity on your number — inbound messages and every status change. |
message.received | A customer sends you a message. |
order.created | A new order arrives from a connected store. |
order.paid | An order is paid. |
order.ready | An order is marked ready. |
order.fulfilled | An order ships. |
order.cancelled | An order is cancelled. |
cart.abandoned | A checkout is left unfinished. |
payment.received | A connected gateway confirms a payment. |
appointment.booked | A booking is made. |
appointment.reminder | A reminder is due. |
lead.received | A lead arrives from a portal, form or ad. |
contact.created | A new contact is added. |
* | Everything above. |
Business events arrive wrapped as
{ "event": "order.paid", "timestamp": "…", "data": { … } }.
whatsapp_webhook arrives as WhatsApp's own raw payload, unchanged.
Reference · Verify signatures
Compute the HMAC over the raw request body, before any JSON parsing — re-serialising changes the bytes and the signature will never match. Compare in constant time.
Node.js / Express
const express = require('express'), crypto = require('crypto');
const SECRET = process.env.QUICKWA_WEBHOOK_SECRET;
app.post('/webhooks/whatsapp',
express.raw({ type: 'application/json' }), // raw, NOT express.json()
(req, res) => {
const expected = 'sha256=' +
crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
const got = req.get('X-Webhook-Signature') || '';
if (got.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) {
return res.sendStatus(401);
}
res.sendStatus(200); // acknowledge first
handle(JSON.parse(req.body.toString())); // then do the work
});
PHP
$raw = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $raw, getenv('QUICKWA_WEBHOOK_SECRET'));
$got = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
if (!hash_equals($expected, $got)) { http_response_code(401); exit; }
http_response_code(200);
$event = json_decode($raw, true);
Python / Flask
import hmac, hashlib, os
from flask import request, abort
SECRET = os.environ['QUICKWA_WEBHOOK_SECRET'].encode()
@app.post('/webhooks/whatsapp')
def hook():
expected = 'sha256=' + hmac.new(SECRET, request.get_data(),
hashlib.sha256).hexdigest()
if not hmac.compare_digest(
expected, request.headers.get('X-Webhook-Signature', '')):
abort(401)
return '', 200
Limits & rules
Most of these come from WhatsApp, not from us. They decide when a send is allowed, so they are worth knowing before you build.
| Rule | What it means for you |
|---|---|
| The 24-hour window | Free text, images and documents are allowed only within 24 hours of the customer's last message to you. Outside it, use an approved template. OTPs are templates, so they always work. |
| Templates need approval | Only APPROVED templates send. You are emailed when Meta approves or rejects one. |
| Opt-out is enforced | A contact who replied STOP is refused with 403 — including on API sends. |
| 100 requests / minute | Per API key by default, adjustable 1–1000. Over it returns 429 with a Retry-After header. |
| Idempotency | Send an Idempotency-Key header to make a retry safe — the same key within 24 hours replays the original response instead of sending again. |
| Meta's messaging tier | A newly created WhatsApp Business portfolio starts at 250 unique customers per 24 hours and rises automatically with good quality. Check yours in WhatsApp Manager rather than assuming. |
| Message charges | Meta bills your own WhatsApp account directly, per message. QuickWA adds 0% markup — you pay us only for the plan. |
Errors
| Status | Meaning | What to do |
|---|---|---|
400 | A field is missing, or WhatsApp rejected the message | Read message and details — they carry Meta's own reason. |
401 | Missing, wrong or revoked API key | Check the header; create a new key if it was revoked. |
403 | Key lacks messages:send, the contact opted out, or you are outside the 24-hour window | The message says which. Switch to a template for the window case. |
429 | Rate limit | The response carries a Retry-After header in seconds — wait that long. Raise the key's limit if you genuinely need more. |
{
"detail": {
"error": "WhatsApp API error",
"code": 132000,
"message": "Number of parameters does not match the expected number",
"details": { "messaging_product": "whatsapp" }
}
}
132000 above almost always means
the code was put in the body but not in the copy-code button. See
Website login OTP.