API Reference
Send WhatsApp messages from your own application — one-time passwords, alerts, order updates — through a business already connected to WhatsApp InstaReply.
Overview
The API is a small, deliberately narrow surface: send a message, ask what happened to it, ask who you are. Everything is JSON over HTTPS.
https://api.whatsappinstareply.com/api/v1
An API key belongs to one business and inherits that business's plan and connected WhatsApp number. Messages you send arrive from that business's number and appear in its dashboard inbox, so the team can see what their integrations are sending on their behalf.
Server-side only. An API key carries the full authority of the business it belongs to. Never ship one in a browser, mobile app, or any client you don't control — browser requests from other domains are blocked by CORS, and that is on purpose. Call this API from your backend.
Authentication
Every request carries a bearer token:
Authorization: Bearer ir_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json
Keys start with ir_live_ — a deliberate marker, so automated secret scanners can spot one that leaks into a repository or a pasted log. Only a hash of your key is stored on our side: we cannot show it to you again, and a database dump cannot be replayed against the API.
Getting a key
An Admin on the business creates keys from the dashboard: API Keys → + New key. The secret is shown once, at creation. If you lose it, revoke that key and mint another — there is no recovery path by design.
Revoking takes effect on the very next request, so it is the right first move if a key is exposed.
Before your first send: the business must have connected its own WhatsApp number (Settings → number verification). API messages are never sent from the shared demo number — a business without its own number gets number_not_connected.
Quickstart
Two calls before you send anything, both free — neither touches your quota.
1. Check your key
curl https://api.whatsappinstareply.com/api/v1/me \
-H "Authorization: Bearer $INSTAREPLY_KEY"
{
"business": { "id": "vzs633oocee1dlp", "name": "Acme Ltd", "plan": "professional",
"number_connected": true, "templates_listable": true },
"key": { "id": "lbn45ye330p4871", "name": "OTP service", "prefix": "ir_live_a1b2c3d4" },
"quota": { "used": 412, "limit": 20000, "remaining": 19588 }
}
number_connected: false means sends will be refused with 409 until the business finishes number verification in its dashboard.
2. Read your templates — do not hard-code them
Any business-initiated message, including every OTP, needs a template approved by Meta, and the name and language code must match what was approved exactly. Copying either from an example — including the examples on this page — is the single most common reason a working integration sends nothing.
curl https://api.whatsappinstareply.com/api/v1/templates \
-H "Authorization: Bearer $INSTAREPLY_KEY"
Every template on your account comes back with its exact name, its language code, the variable counts, and a paste-ready example request body. Read the values from there and the send in step 3 works first time. See List templates.
3. Send
Using the values from step 2 — see Send a message, or One-time passwords if you are sending codes.
Send a message
Request fields
| Field | Type | Notes |
|---|---|---|
to | string | Required. Full international number, digits only — "237670000000". Spaces, dashes and a leading + are accepted and stripped. |
type | string | Required. "template" or "text". |
template | object | Required when type is "template". See Templates. |
text | string | Required when type is "text". Max 4000 characters. |
acknowledge_session_window | bool | Required for "text" — see below. |
Template send
curl https://api.whatsappinstareply.com/api/v1/messages \
-H "Authorization: Bearer $INSTAREPLY_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "237670000000",
"type": "template",
"template": {
"name": "order_shipped",
"language": "en",
"body_variables": ["Ada", "TRK-4471"]
}
}'
Responds 202 Accepted:
{
"id": "gn1vn91jafg0685",
"wamid": "wamid.HBgMMjM3NjcwMDAwMDAwFQIAERgS...",
"to": "237670000000",
"type": "template",
"template": "order_shipped",
"status": "accepted",
"error": null,
"sent_at": "2026-08-05 20:31:04",
"status_at": null
}
accepted does not mean delivered. It means WhatsApp took the message from us. Actual delivery arrives later — see Delivery receipts.
Text send and the 24-hour window
WhatsApp only permits free-form text to someone who messaged the business in the last 24 hours. Outside that window text is silently dropped by WhatsApp — accepted, never delivered, no error.
Because that failure is invisible and expensive to debug, a text send is rejected unless you state that you know the contact is inside the window:
{ "to": "237670000000", "type": "text", "text": "Your table is ready.",
"acknowledge_session_window": true }
For anything business-initiated — including every OTP — use a template instead.
Message status
Pass the id from the send response. Returns the same object, with status reflecting the latest delivery receipt.
curl https://api.whatsappinstareply.com/api/v1/messages/gn1vn91jafg0685 \
-H "Authorization: Bearer $INSTAREPLY_KEY"
| status | Meaning |
|---|---|
accepted | WhatsApp took the message. Nothing more is known yet. |
sent | Left WhatsApp's servers, on its way to the handset. |
delivered | Arrived on the recipient's device. |
read | Opened by the recipient. Only if they have read receipts enabled. |
failed | Rejected or undeliverable. error explains why. |
Status only ever moves forward. WhatsApp sometimes batches receipts out of order; a late delivered will never overwrite a read you have already seen.
List templates
Every template this business can send, with the exact strings a send needs — name, language code, and how many variables each part takes. Call this before you hard-code anything. The name and language must match Meta byte-for-byte, and this endpoint is the authority on what they are, so you never have to ask someone to read them off a WhatsApp Manager screen you may not have access to.
curl https://api.whatsappinstareply.com/api/v1/templates \
-H "Authorization: Bearer $INSTAREPLY_KEY"
{
"templates": [
{
"name": "otp_login",
"language": "en", // the CODE to send — not the label
"status": "APPROVED",
"category": "AUTHENTICATION",
"body_variables": 1,
"button_variables": 1,
"button_type": "OTP/COPY_CODE",
"text": null, // Meta generates authentication copy
"example": { // paste-ready request body
"to": "237670000000",
"type": "template",
"template": {
"name": "otp_login", "language": "en",
"body_variables": ["123456"], "button_variables": ["123456"]
}
}
}
],
"count": 1,
"status_filter": "approved"
}
| Query | Meaning |
|---|---|
status | approved (default) or all. Use all to see templates still PENDING or REJECTED — they cannot be sent, but seeing them tells you the template exists and is simply not ready. |
name | Exact name, to check one template. |
fresh | true bypasses the 60-second cache — for the minute after an approval comes through. |
This endpoint does not consume your monthly quota; it sends nothing. It does count against the per-minute rate limit. A template with a warning field has a header variable, which sends through this API do not fill — pick a different template rather than one whose header will go out blank.
The list is always the account your own number sends from: we verify that before answering, and return 409 templates_unavailable rather than a list belonging to a different account. A wrong list would be worse than none — every send built from it would fail.
Two related behaviours follow from having this list available:
- Omit
template.languageand we resolve it, provided the template is approved in exactly one language. Approved in several, and the send is rejected with a400naming each one, rather than us picking for you. - A rejected template send names the fix. When WhatsApp returns
(#132001), the error tells you the languages that template is approved in — or, if the name is wrong, lists the names that exist.
The business owner sees the same list in the dashboard under API Keys → Your approved templates, with a copy button for each example.
Key & quota
Returns the calling key, its business, and the month's usage. Useful as a health check in your deploy pipeline: it confirms the key is live and the number is connected without spending a message.
Templates & variables
A template is a message shape approved in advance by Meta. The business creates and submits them in WhatsApp Manager; approval typically takes anywhere from a few minutes to a couple of days. You reference an approved template by its exact name and language.
"template": {
"name": "order_shipped", // exact name as approved
"language": "en", // must match the approved language EXACTLY
"body_variables": ["Ada", "TRK-4471"], // fills {{1}}, {{2}} in order
"button_variables": [] // only for OTP / dynamic-URL buttons
}
body_variablesfill{{1}},{{2}}… in order. The count must match the approved template exactly, or WhatsApp rejects the send.languagemust match the approved template's language code exactly. This is the single most common cause of a rejected send, because the code is not the label you picked in WhatsApp Manager:- “English” →
en - “English (US)” →
en_US - “English (UK)” →
en_GB - “French” →
fr - “Spanish” →
es - “German” →
de
enwill not send asen_US, and the error you get back —(#132001) Template name does not exist in the translation— reads as though the template is missing rather than mismatched. Never guess this value:GET /api/v1/templatesreturns the exact code for every template on the account, and if you omitlanguagewe resolve it for you when it is unambiguous.- “English” →
- Up to 10 values per array.
Everything above can be read from the API rather than assumed. GET /api/v1/templates gives you each template's name, language code, variable counts and a paste-ready example — one call, no dashboard access needed, no quota spent.
One-time passwords
OTP has requirements the general template path does not, and getting any of them wrong produces a message that looks fine but doesn't work.
- The template must be created in the AUTHENTICATION category with a one-time-password button (copy-code or one-tap autofill). A marketing or utility template will not function as an OTP.
- The code goes in twice: once in
body_variables, and again inbutton_variables. Send only the body half and the message arrives with a dead button — the code renders but won't copy or autofill. - One-tap autofill additionally requires your Android package name and signature hash, registered when the template is created.
- Do not hard-code the template name or language from an example.
otp_loginbelow is just a name; yours is whatever was approved. Read both fromGET /api/v1/templates— filtercategorytoAUTHENTICATION— or you get a422on every send while your fallback channel quietly carries the traffic.
Sending the code in the user's language
The same template name is usually approved in several languages, each a separate entry in GET /api/v1/templates with its own language code. Pick the entry matching your user and send that code — a login prompt in the wrong language is the last place to surprise someone.
# the languages `otp_login` is approved in, as codes you can send
curl -s https://api.whatsappinstareply.com/api/v1/templates?name=otp_login \
-H "Authorization: Bearer $INSTAREPLY_KEY" | jq -r '.templates[].language'
- Map your user's locale to an approved code, don't assume one exists. Keep a fallback (usually
en) for a locale that is not approved yet, and prefer delivering the code in the wrong language over not delivering it — an OTP that never arrives is a user who cannot log in. - With more than one language approved,
languageis effectively required. Omit it and the send is rejected with400listing every candidate, because guessing one would mean messaging someone in a language you did not choose. - Codes are the ones Meta stores, not the labels in WhatsApp Manager: “French” is
fr, “Spanish”es, “German”de, “English”en. A translation added later appears in the list as soon as Meta approves it — no change needed at your end, which is the reason to read the list rather than pin a value.
curl https://api.whatsappinstareply.com/api/v1/messages \
-H "Authorization: Bearer $INSTAREPLY_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: login-8821-2026-08-05T20:31" \
-d '{
"to": "237670000000",
"type": "template",
"template": {
"name": "otp_login",
"language": "en",
"body_variables": ["483920"],
"button_variables": ["483920"]
}
}'
Generate and verify the code in your application. This API delivers the message; it does not create, store, or check codes, and never sees whether verification succeeded.
What happens when someone replies to a code
People answer OTP messages — “ok”, “thanks”, the code pasted straight back, “who is this?”. Those replies arrive on your WhatsApp number like any other message, and the AI auto-responder deliberately does not answer them.
A number your backend has only ever pushed templates at has no conversation behind it, so an auto-reply would open by introducing your business — a marketing message sitting beside a login code. That is the pattern that gets a WhatsApp Business number reported, and a reported number takes your authentication template's approval down with it.
- The reply is recorded and appears in your dashboard inbox, so nothing is lost and a human can answer.
- The moment anyone on your team replies by hand, the contact becomes an ordinary conversation and the AI picks it up from there.
- A number in this state is also excluded from re-engagement campaigns, for the same reason.
- Contacts who message you first are unaffected — they get an instant AI reply as always.
Delivery receipts
Receipts are asynchronous. The send response tells you only that WhatsApp queued the message; it cannot tell you the message arrived, because at that moment nobody knows.
Three ways to find out what happened, best first:
- A webhook — we POST each status change to your endpoint, signed. See Webhooks. Use this if a receipt is evidence for you: it tells you when you were told, which polling cannot.
- List and reconcile —
GET /api/v1/messages?status=acceptedin a background job sweeps everything still outstanding in one request. - Poll one id —
GET /api/v1/messages/:id. Fine for a "did the OTP land?" check a few seconds after sending. Polling does not consume your monthly quota (only sends do), but it does count against the per-minute rate limit.
Either way, persist the id from the send alongside your own record — it is the only handle that ties a WhatsApp message back to your data.
Receipts typically arrive within seconds, but a handset that is off or out of coverage can delay delivered indefinitely. Do not block a user's login on delivered — send the code, let them type it, and treat delivery state as diagnostics.
Webhooks
Register one HTTPS endpoint and we POST every status change to it as it happens, so you learn a message was delivered at the moment we do rather than whenever you next look.
Set it up in the dashboard: API Keys → Delivery webhook. You get a signing secret, and a Send test event button that exercises your endpoint and your signature check before a real receipt depends on them.
What we send
POST https://your-app.example.com/whatsapp/receipts
X-InstaReply-Event: message.status
X-InstaReply-Timestamp: 1785970000
X-InstaReply-Signature: sha256=9f2c…
X-InstaReply-Delivery: d_7a1f9c2
X-InstaReply-Attempt: 1
{
"event": "message.status",
"id": "gn1vn91jafg0685",
"wamid": "wamid.HBgMMjM3…",
"to": "237670000000",
"type": "template",
"template": "otp_login",
"status": "delivered",
"error": null,
"sent_at": "2026-08-05 22:29:47",
"status_at": "2026-08-05 22:29:52"
}
id is the same id the send returned, so it joins straight onto your own record.
Verifying the signature
The signature is HMAC-SHA256 over timestamp + "." + rawBody, using your signing secret. Sign the raw body bytes, before any JSON parsing — a re-serialised object will not match.
const crypto = require('crypto');
function verify(rawBody, headers, secret) {
const ts = headers['x-instareply-timestamp'];
const sig = headers['x-instareply-signature'];
// Reject anything older than five minutes: the timestamp is inside the
// signed material precisely so a captured payload cannot be replayed later.
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(`${ts}.${rawBody}`, 'utf8')
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}
Responding, and retries
- Answer 2xx to acknowledge. Anything else is treated as a failure.
- Answer quickly and do your work afterwards. We give up on a response after 10 seconds and count it as a failure.
- Failures are retried at 30s, 2m, 10m, then 1h — five attempts in total, after which the delivery is marked failed and we stop.
- Retries are persisted, so they survive a restart on our side.
- Expect duplicates. A response we never saw is retried, so the same event can arrive twice.
X-InstaReply-Deliveryis stable per delivery — key on it, or make your handler idempotent.
The webhook is a convenience over the truth, not a replacement for it. GET /api/v1/messages/:id always returns the current state, so if your endpoint was down for an hour you can reconcile rather than wait.
Rotating the secret
Rotating issues a new secret immediately; deliveries already in flight are signed with whichever secret was current when they were attempted. If you cannot tolerate a gap, accept either secret across the changeover, then drop the old one.
Idempotency
Send an Idempotency-Key header on any send you might retry:
Idempotency-Key: login-8821-2026-08-05T20:31
If a request with the same key has already been processed for your business, the original result is returned with Idempotent-Replay: true and no second message is sent. A network timeout on an OTP send is exactly the case this exists for: without it, a retry sends a second code and invalidates the first in your own system.
Use a key derived from the thing you are doing — a login attempt id, an order id — not a random value per attempt, or retries won't match.
A replay is never rate-limited. It is checked before every quota and limit, because it sends nothing — so a retry returns the original result even if the monthly quota, the daily cap or the per-recipient limit has since been reached. This matters most for OTP resends, where the retry and the original go to the same number.
Quotas & rate limits
Two independent limits apply.
Monthly quota
Counted per calendar month (UTC), by plan. This is separate from the conversation cap — API traffic is transactional and doesn't consume the allowance meant for customer chats.
| Plan | API messages / month |
|---|---|
| Mini | 500 |
| Starter | 2,000 |
| Professional | 20,000 |
| Business | Unlimited |
Successful sends carry X-Quota-Limit and X-Quota-Remaining. Exhausting the quota returns 429 quota_exceeded.
Daily ceiling
Every plan also has a daily limit — including the unlimited one. It is a circuit breaker, not a meter: it bounds what a leaked key or a runaway loop can do to one day instead of one month. It is set well above a busy day, so ordinary traffic never meets it.
| Plan | API messages / day |
|---|---|
| Mini | 150 |
| Starter | 400 |
| Professional | 3,000 |
| Business | 10,000 |
Sends carry X-Daily-Limit and X-Daily-Remaining. Hitting it returns 429 daily_cap_exceeded; sending resumes at 00:00 UTC.
Per-recipient limit
At most 10 messages per hour to the same number. Repeatedly messaging one person is the shape of both harassment and a retry loop with no backoff, and neither should be able to run all the way to the daily cap. Exceeding it returns 429 recipient_rate_limited with Retry-After.
If you are resending an OTP because the first did not arrive, reuse the same Idempotency-Key for the same login attempt — a replay does not count against this limit, because no second message is sent.
Rate limit
60 requests per minute per key, as a burst guard. Responses carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; exceeding it returns 429 rate_limited with Retry-After in seconds. Back off and retry — but never retry a send without an Idempotency-Key.
Keeping a key safe
A key can send messages that cost money and carry the business's name. Treat it like a password.
- Backend only. Never in a browser, mobile app, or anything shipped to a user. Cross-origin browser requests are blocked, deliberately.
- Out of source control. Read it from an environment variable or a secret manager. The
ir_live_prefix exists so scanners can catch it if it does leak. - One key per application. Then a compromise of one integration is revoked without taking down the others, and last used tells you which is which.
- Rotate by overlap: mint the new key, deploy it, confirm traffic, then revoke the old one. Revocation is immediate, so revoking first means downtime.
If a key is exposed, revoke it in the dashboard — that takes effect on the very next request. Then check API messages this month against what you expect; every send is recorded and retrievable by id, so unexpected traffic is visible.
We can never show you a key again after it is created — only a hash is stored. That is deliberate: it means a breach of our database does not expose your credential. It also means a lost key must be replaced, not recovered.
Errors
Every error is the same shape, with a stable machine-readable code. Branch on code, never on message — the prose may be reworded.
{ "error": { "code": "number_not_connected",
"message": "This business has not connected its own WhatsApp number yet…" } }
| HTTP | code | What to do |
|---|---|---|
| 400 | invalid_request | A field is missing or malformed. The message names it. Don't retry unchanged. |
| 400 | session_window_required | You sent text outside the 24-hour window. Use a template. |
| 401 | unauthorized | Key missing, malformed, or revoked. Don't retry. |
| 404 | not_found | No message with that id for your business. |
| 409 | number_not_connected | The business must finish number verification in the dashboard. |
| 429 | rate_limited | Slow down. Honour Retry-After. |
| 429 | quota_exceeded | Monthly allowance spent. Upgrade or wait for the reset. |
| 429 | daily_cap_exceeded | Daily ceiling reached. Resumes 00:00 UTC. If it was not deliberate, revoke the key. |
| 429 | recipient_rate_limited | Too many messages to one number this hour. Back off; check for a retry loop. |
| 422 | upstream_rejected | WhatsApp refused the message — usually a template name, language or variable-count mismatch. The message quotes WhatsApp's own reason, and for a template mismatch it also names the languages that template is approved in (or the names that exist, if the name was wrong). Fix before retrying; GET /api/v1/templates has the full picture. |
| 409 | templates_unavailable | Only from GET /api/v1/templates: either no WhatsApp Business Account is on file for this business, or the one on file does not contain its number — in which case any list would belong to the wrong account, so none is returned. Sending is unaffected. Contact support. |
| 424 | upstream_unavailable | We could not reach WhatsApp. Nothing is known to be wrong with your request — retry with the same Idempotency-Key. |
| 503 | unavailable | Temporary backend problem on our side. Retry with backoff; this is not a problem with your key. |
Questions, or need a higher quota? [email protected]