Skip to content
Developer documentation

QuickWA API

Send WhatsApp messages and one-time passwords from your own website or app, and receive signed webhooks for delivery receipts, read receipts and customer replies.

Base URL https://quickwa.com/api

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 doQuickWA doesWhatsApp does
Call POST /api/send-message from your backendValidates, records it in your inbox, forwards to WhatsAppDelivers to the customer
Expose one webhook URLSigns and forwards every event to itReports sent / delivered / read / failed
Generate and verify your own OTP codesDelivers the code inside an approved templateShows a copy-code button
Call this API from your server only. An API key can send messages on your WhatsApp number, which costs real money on your Meta bill. Never put it in browser JavaScript, a mobile app bundle, or a public repository.

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.
Prefer to wire it yourself? Settings → WhatsApp also accepts a Phone Number ID, WABA ID and permanent access token directly.

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.

FieldWhat it does
NameFor you — one key per system you integrate, so you can revoke one without breaking the others.
ScopesDefaults to messages:send and contacts:read. A key without messages:send is refused with 403.
Rate limitRequests per minute for this key. Default 100, adjustable between 1 and 1000.
The key is shown once. Copy it straight into your server's environment variables. If you lose it, revoke it and create another — we cannot show it again.

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.

CategoryUse it for
AUTHENTICATIONOne-time passwords and login codes. WhatsApp writes the wording itself.
UTILITYOrder updates, appointment reminders, receipts — anything the customer expects.
MARKETINGOffers 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" }
    ]}
  ]
}
Template names are lowercase with underscores — 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
}
Store 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.

ChooseYou receive
WhatsApp messages & delivery status
whatsapp_webhook
Recommended. WhatsApp's own payload, unchanged — inbound messages and sent / delivered / read / failed.
Inbound messages only
message.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" }
        }]
      }
    }]
  }]
}
Reply 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.

  1. Generate the code on your server

    Six digits, random, stored against the phone number with an expiry that matches the code_expiration_minutes in your template. Hash it if you keep it at rest, and rate-limit requests per number so nobody can burn your WhatsApp budget.

  2. Send it — the code goes in two places

    An AUTHENTICATION template 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 use variables for an OTP. That shorthand only fills the body, so the button is left empty and WhatsApp answers 132000 — Number of parameters does not match.
  3. Watch the receipt

    Match the webhook's statuses[].id to the message_id you stored. delivered means it reached the phone. failed arrives with a reason — that is your cue to fall back to SMS rather than leaving the user waiting.

  4. 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.

Why WhatsApp OTP at all: it is delivered to an app the customer already trusts, the copy-code button removes the typing, and there is a real delivery receipt — none of which SMS gives you reliably.

Reference · Send a message

POST/api/send-messageAPI key
FieldRequiredNotes
phone_numberAlwaysWith 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_nameFor templatesMust be APPROVED. Required outside the 24-hour window.
languageOptionalTemplate language code, e.g. en_US. Defaults to en.
variablesOptionalBody-only shorthand: {"1":"Alex","2":"USD 99"} fills {{1}}, {{2}}.
componentsOptionalFull Meta component array. Needed for headers, buttons and OTPs.
messageFor textFree text. Inside the 24-hour window only.
typeOptionaltemplate, text, interactive_buttons, interactive_list, image, document.
contact_nameOptionalSaved against a newly created contact.
POST/api/templatesdashboard session

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

POST/api/developer/webhooksadmin

Register a URL. Body { "url": "https://…", "events": ["whatsapp_webhook"] }. Returns the signing secret once. Only public HTTPS URLs are accepted.

GET/api/developer/webhooksadmin

Lists your webhooks. The secret is never returned again — you get secret_set: true instead.

POST/api/developer/webhooks/{id}/rotate-secretadmin

Issues a fresh secret and shows it once. The old one stops working immediately, so deploy the new one to your endpoint first.

GET/api/developer/keysadmin

Lists your API keys — name, prefix, scopes, rate limit, last used. Never the key itself.

Reference · Event catalogue

EventFires when
whatsapp_webhookAny WhatsApp activity on your number — inbound messages and every status change.
message.receivedA customer sends you a message.
order.createdA new order arrives from a connected store.
order.paidAn order is paid.
order.readyAn order is marked ready.
order.fulfilledAn order ships.
order.cancelledAn order is cancelled.
cart.abandonedA checkout is left unfinished.
payment.receivedA connected gateway confirms a payment.
appointment.bookedA booking is made.
appointment.reminderA reminder is due.
lead.receivedA lead arrives from a portal, form or ad.
contact.createdA 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.

RuleWhat it means for you
The 24-hour windowFree 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 approvalOnly APPROVED templates send. You are emailed when Meta approves or rejects one.
Opt-out is enforcedA contact who replied STOP is refused with 403 — including on API sends.
100 requests / minutePer API key by default, adjustable 1–1000. Over it returns 429 with a Retry-After header.
IdempotencySend 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 tierA 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 chargesMeta bills your own WhatsApp account directly, per message. QuickWA adds 0% markup — you pay us only for the plan.

Errors

StatusMeaningWhat to do
400A field is missing, or WhatsApp rejected the messageRead message and details — they carry Meta's own reason.
401Missing, wrong or revoked API keyCheck the header; create a new key if it was revoked.
403Key lacks messages:send, the contact opted out, or you are outside the 24-hour windowThe message says which. Switch to a template for the window case.
429Rate limitThe 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" }
  }
}
The commonest OTP mistake. 132000 above almost always means the code was put in the body but not in the copy-code button. See Website login OTP.