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
Verify your key works and see your remaining allowance, without sending anything:
curl https://api.whatsappinstareply.com/api/v1/me \
-H "Authorization: Bearer $INSTAREPLY_KEY"
{
"business": { "id": "vzs633oocee1dlp", "name": "Acme Ltd", "plan": "professional",
"number_connected": true },
"key": { "id": "lbn45ye330p4871", "name": "OTP service", "prefix": "ir_live_a1b2c3d4" },
"quota": { "used": 412, "limit": 20000, "remaining": 19588 }
}
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.
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
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. If you are unsure, open the template in WhatsApp Manager: the language is shown next to its name.- “English” →
- Up to 10 values per array.
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.
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.
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. Fix before retrying. |
| 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]