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

POST /api/v1/messages

Request fields

FieldTypeNotes
tostringRequired. Full international number, digits only — "237670000000". Spaces, dashes and a leading + are accepted and stripped.
typestringRequired. "template" or "text".
templateobjectRequired when type is "template". See Templates.
textstringRequired when type is "text". Max 4000 characters.
acknowledge_session_windowboolRequired 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

GET /api/v1/messages/:id

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"
statusMeaning
acceptedWhatsApp took the message. Nothing more is known yet.
sentLeft WhatsApp's servers, on its way to the handset.
deliveredArrived on the recipient's device.
readOpened by the recipient. Only if they have read receipts enabled.
failedRejected 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

GET /api/v1/me

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_variables fill {{1}}, {{2}} … in order. The count must match the approved template exactly, or WhatsApp rejects the send.
  • language must 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
    A template approved as en will not send as en_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.
  • 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 in button_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 reconcileGET /api/v1/messages?status=accepted in a background job sweeps everything still outstanding in one request.
  • Poll one idGET /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-Delivery is 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.

PlanAPI messages / month
Mini500
Starter2,000
Professional20,000
BusinessUnlimited

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.

PlanAPI messages / day
Mini150
Starter400
Professional3,000
Business10,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…" } }
HTTPcodeWhat to do
400invalid_requestA field is missing or malformed. The message names it. Don't retry unchanged.
400session_window_requiredYou sent text outside the 24-hour window. Use a template.
401unauthorizedKey missing, malformed, or revoked. Don't retry.
404not_foundNo message with that id for your business.
409number_not_connectedThe business must finish number verification in the dashboard.
429rate_limitedSlow down. Honour Retry-After.
429quota_exceededMonthly allowance spent. Upgrade or wait for the reset.
429daily_cap_exceededDaily ceiling reached. Resumes 00:00 UTC. If it was not deliberate, revoke the key.
429recipient_rate_limitedToo many messages to one number this hour. Back off; check for a retry loop.
422upstream_rejectedWhatsApp refused the message — usually a template name, language or variable-count mismatch. The message quotes WhatsApp's own reason. Fix before retrying.
424upstream_unavailableWe could not reach WhatsApp. Nothing is known to be wrong with your request — retry with the same Idempotency-Key.
503unavailableTemporary backend problem on our side. Retry with backoff; this is not a problem with your key.

Questions, or need a higher quota? [email protected]