Connect your business WhatsApp number in the SnapAI app, generate an API key, and your own servers can send one-time codes and transactional messages from that number. Messages come from your WhatsApp account, under your business's name — this API is the front door to it, not a separate sender.
This is your WhatsApp number, and WhatsApp's rules apply to it. Only message people who have agreed to hear from you, keep to one-to-one transactional messages and one-time codes, and never use this API for marketing or bulk sending. A business that ignores that gets its number banned by WhatsApp — that ban comes from WhatsApp, not from us, and we cannot lift it. Use the Broadcast tool in the app for anything that goes to many people; it is built for it and it warms your number up properly.
On this page
Getting started
- Open SnapAI and connect your business WhatsApp number (WhatsApp → Numbers).
- Go to WhatsApp → Developer API and tap New key.
- Give the key a name, pick its scopes, and copy the key. It is shown once.
- Store it as a server-side secret. Never ship it in a mobile app or a browser.
Base URL:
https://snapai.reviewindia.org/api/v1
Every response uses the same envelope, on success and on failure alike:
{ "ok": true, "data": { … } }
{ "ok": false, "error": { "code": "…", "message": "…", "retryable": false } }
Authentication
Send your key as a bearer token. A key looks like
snap_live_<prefix>_<secret>; the prefix is safe to log, the
secret is not.
Authorization: Bearer snap_live_a1b2c3d4e5f6_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Each key carries scopes, and may only do what they name:
| Scope | Allows |
|---|---|
whatsapp.otp.send | Sending and verifying one-time codes |
whatsapp.messages.send | Sending transactional messages |
whatsapp.status.read | Reading delivery status and your connected numbers |
whatsapp.webhooks.write | Registering callback endpoints |
Keys can be rotated from the app. The old secret keeps working for one hour, so you can roll a deployment without downtime. They can be revoked instantly, and a revoked key can never be restored.
Your keys work while your SnapAI Business plan is active. If the plan
lapses, every call answers 403 feature_disabled with a message
saying so; renewing the plan makes the same keys work again.
Sending an OTP
POST/v1/whatsapp/otp/send
| Field | Type | |
|---|---|---|
to | string, required | Destination in international format, e.g. +919888877666 |
code | string | 4–8 digits. Omit it and we generate a 6-digit code. |
template | string | Message text. Must contain {code}. {brand} and {minutes} are also substituted. |
brandName | string | Your business name in the message. Up to 40 characters. |
expiresInSeconds | integer | 60–900. Default 300. |
connectionId | string | Which of your numbers to send from. Defaults to the first connected one. |
curl -X POST https://snapai.reviewindia.org/api/v1/whatsapp/otp/send \
-H "Authorization: Bearer $SNAPAI_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: signin-8f21c0" \
-d '{
"to": "+919888877666",
"brandName": "Acme",
"expiresInSeconds": 300
}'
const res = await fetch("https://snapai.reviewindia.org/api/v1/whatsapp/otp/send", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SNAPAI_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `signin-${sessionId}`,
},
body: JSON.stringify({ to: "+919888877666", brandName: "Acme" }),
});
const body = await res.json();
if (!body.ok) throw new Error(body.error.message);
const { requestId, expiresAt } = body.data;
$ch = curl_init('https://snapai.reviewindia.org/api/v1/whatsapp/otp/send');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer '.getenv('SNAPAI_API_KEY'),
'Content-Type: application/json',
'Idempotency-Key: signin-'.$sessionId,
],
CURLOPT_POSTFIELDS => json_encode(['to' => '+919888877666', 'brandName' => 'Acme']),
]);
$body = json_decode(curl_exec($ch), true);
if (! $body['ok']) {
throw new RuntimeException($body['error']['message']);
}
$requestId = $body['data']['requestId'];
202 Accepted
{
"ok": true,
"data": {
"requestId": "01a0cb83-c448-70f6-853b-4004b7aced92",
"to": "+91988••••666",
"status": "sent",
"messageId": "01a0cb83-c4ba-729a-8f54-b344b4e54ba0",
"expiresAt": "2026-09-23T10:05:00+00:00",
"attemptsAllowed": 5
}
}
Keep the requestId on the session you are signing in. The code
itself never comes back — we store only a hash of it, so neither we nor
anyone reading our database can complete your user's sign-in.
Verifying an OTP
POST/v1/whatsapp/otp/verify
Send requestId (preferred) or to, plus the code your user
typed.
curl -X POST https://snapai.reviewindia.org/api/v1/whatsapp/otp/verify \
-H "Authorization: Bearer $SNAPAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "requestId": "01a0cb83-…", "code": "482915" }'
{ "ok": true, "data": { "verified": true, "reason": null, "attemptsRemaining": 0,
"requestId": "01a0cb83-…" } }
A wrong code is 200 with verified: false, not an error
status — a mistyped code is a normal thing for a sign-in screen to handle.
Read reason:
reason | |
|---|---|
invalid_code | Wrong code. attemptsRemaining says how many tries are left. |
expired | The code is past its expiry. Send a new one. |
already_used | Codes are single use. |
too_many_attempts | Five wrong guesses destroy the code. Send a new one. |
not_found | No such request, or it belongs to a different key's account. |
Asking for a new code for the same number invalidates the previous one, so there is never more than one live code per phone.
Transactional messages
POST/v1/whatsapp/messages
One recipient per call. There is no list parameter. For anything that goes to many people, use Broadcasts in the app.
| Field | Type | |
|---|---|---|
to | string, required | International format. |
text | string | Up to 4096 characters. Required unless media is present. |
media.url | string | A public https:// URL we fetch. Max 16 MB. |
media.base64 | string | The file inline. A data: prefix is stripped for you. |
media.caption | string | Used as the message text when text is absent. |
media.fileName | string | The name the recipient sees. |
quotedMessageId | string | Reply to an earlier message. |
The file type is detected from the bytes, not from what you declare. Photos,
video, audio, PDF, DOCX, TXT and CSV are carried; anything else is refused
with unsupported_media_type.
curl -X POST https://snapai.reviewindia.org/api/v1/whatsapp/messages \
-H "Authorization: Bearer $SNAPAI_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042-shipped" \
-d '{
"to": "+919888877666",
"text": "Your order #1042 has shipped. Track it at acme.example/t/1042"
}'
{
"ok": true,
"data": {
"messageId": "01a0cb83-…",
"to": "+91988••••666",
"kind": "text",
"mediaType": null,
"status": "sent",
"sentAt": "2026-09-23T10:00:00+00:00",
"deliveredAt": null,
"readAt": null
}
}
Delivery status
GET/v1/whatsapp/messages/{id}
curl https://snapai.reviewindia.org/api/v1/whatsapp/messages/01a0cb83-… \
-H "Authorization: Bearer $SNAPAI_API_KEY"
status is one of pending, sent,
delivered, read, failed, and only ever moves
forward. Register a webhook rather than polling — it is
faster, and it does not spend your rate limit.
Your numbers
GET/v1/whatsapp/numbers
{
"ok": true,
"data": {
"numbers": [
{ "id": "01a0cb83-…", "number": "+919876543210", "displayName": "Acme Support",
"status": "connected", "canSend": true, "lastCheckedAt": "2026-09-23T09:58:00+00:00" }
],
"allowance": { "limit": 2000, "used": 128, "remaining": 1872,
"resetsAt": "2026-10-01T00:00:00+00:00", "period": "2026-09" }
}
}
One call that answers "is my integration going to work right now" without sending anybody a message to find out.
Webhooks
POST/v1/webhooks
curl -X POST https://snapai.reviewindia.org/api/v1/webhooks \
-H "Authorization: Bearer $SNAPAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://hooks.acme.example/snapai",
"events": ["whatsapp.message.status"] }'
The response carries a signing secret (whsec_…) once.
Store it; it cannot be read back. Use ["*"] for every event.
Each delivery is a POST to your URL:
POST /snapai HTTP/1.1
X-SnapAI-Timestamp: 1758617400
X-SnapAI-Signature: sha256=6b1f…
X-SnapAI-Event: whatsapp.message.status
X-SnapAI-Delivery: 01a0cb83-…
{ "event": "whatsapp.message.status",
"deliveryId": "01a0cb83-…",
"occurredAt": "2026-09-23T10:01:02+00:00",
"data": { "messageId": "01a0cb83-…", "to": "+91988••••666",
"status": "delivered", "deliveredAt": "2026-09-23T10:01:02+00:00",
"readAt": null } }
Verifying the signature
Compute HMAC-SHA256 over
<X-SnapAI-Timestamp> + "." + <raw request body> with your
webhook secret, and compare it to the hex after sha256= using a
constant-time comparison. Reject any request whose timestamp is more than five
minutes old — that is what stops a captured delivery being replayed at you.
import crypto from "node:crypto";
export function verify(rawBody, headers, secret) {
const ts = headers["x-snapai-timestamp"];
const sig = headers["x-snapai-signature"] ?? "";
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = "sha256=" +
crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
}
function snapaiWebhookIsValid(string $rawBody, array $headers, string $secret): bool
{
$timestamp = $headers['X-SnapAI-Timestamp'] ?? '';
$signature = $headers['X-SnapAI-Signature'] ?? '';
if (abs(time() - (int) $timestamp) > 300) {
return false;
}
$expected = 'sha256='.hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);
return hash_equals($expected, $signature);
}
Answer 2xx quickly. A non-2xx (or a timeout past 8 seconds) is
retried after 30 s, 2 m, 10 m and 1 h. After five consecutive failures the
delivery is dead-lettered and the endpoint is switched off — you
will see it as inactive in the app, and you re-enable it by registering it
again. Deliveries carry ids, statuses and masked numbers only: message text
and one-time codes are never sent to a webhook.
GET/v1/webhooks lists your endpoints.
DELETE/v1/webhooks/{id} removes one.
Idempotency
Send an Idempotency-Key header on any POST. If you retry with the same
key we return the first result instead of sending a second message, and set
Idempotent-Replayed: true. Use something derived from your own record —
an order id, a sign-in session id.
- Keys are remembered for 24 hours, per API key.
- The same key with a different body is
409. Use a new key when the request changes. - A failed call is not remembered, so you can fix the request and retry with the same key.
Rate limits & quota
| Limit | Default |
|---|---|
| All calls, per key | 60 per minute |
| Sends (OTP or message), per key | 30 per minute |
| OTP codes to one number | 5 per minute, 15 per hour |
| OTP verifications for one number | 15 per 10 minutes |
Over a limit is 429 rate_limited. On top of that, every message spends
one of your plan's monthly WhatsApp messages — the same allowance the app's own
replies spend, because it is the same WhatsApp number. Running out is
429 quota_exceeded, with the figures on the error object:
{ "ok": false,
"error": { "code": "quota_exceeded", "retryable": false,
"message": "Your plan includes 2000 WhatsApp messages a month and all of them are used. …",
"limit": 2000, "used": 2000, "remaining": 0,
"resetsAt": "2026-10-01T00:00:00+00:00" } }
Errors
| HTTP | error.code | What to do |
|---|---|---|
| 401 | unauthenticated | The key is wrong, revoked or expired. Generate a new one in the app. |
| 403 | feature_disabled | Missing scope, a lapsed plan, or a number that has opted out. The message says which. |
| 404 | not_found | No such message, webhook or chat. |
| 409 | not_configured | No connected WhatsApp number, or it needs reconnecting in the app. |
| 409 | unknown | An Idempotency-Key conflict — see above. |
| 422 | unknown | A field is missing or malformed. The message names it. |
| 422 | invalid_image / image_too_large / unsupported_media_type | The file cannot be carried. Max 16 MB. |
| 429 | rate_limited | Back off and retry. |
| 429 | quota_exceeded | The month's allowance is used. |
| 503 | feature_disabled / network | The API or WhatsApp is briefly unavailable. Retry shortly. |
error.retryable tells you whether repeating the identical request could ever succeed.
Acceptable use
- Consent first. Only message people who asked to hear from you.
- Transactional only. One-time codes, confirmations, reminders, receipts, support replies.
- No marketing through the OTP endpoint. It refuses links, offers and promotional wording — and that refusal is protecting your number, not ours.
- Honour opt-outs. Anyone you have blocked in the app, or who replied STOP, is refused by the API.
- Keep your key secret. Server-side only. If it leaks, rotate or revoke it in the app immediately.
You are the sender. WhatsApp's Business Policy and Commerce Policy apply to your number and your messages, and a business that breaks them loses its number. We cannot appeal that on your behalf.