PayPerWA API Documentation

Send WhatsApp messages, manage contacts and templates, run campaigns, and receive delivery webhooks through the PayPerWA REST API. Authenticate with your API key and start sending in minutes, with no BSP markup. Your wallet pays only our fee of ₹0.20 per message ($0.004 on USD wallets). Meta bills its own charges directly to your WhatsApp Business Account.

Getting Started

The PayPerWA API lets you send WhatsApp messages, manage contacts, templates, and campaigns programmatically. All endpoints use the /api/v1 prefix and require API key authentication.

Base URL

https://payperwa.com/api/v1

Response Format

All responses follow a consistent JSON format:

// Success
{
  "success": true,
  "data": { ... }
}

// Error
{
  "success": false,
  "error": "Human-readable error message",
  "code": "OUTSIDE_24H_WINDOW"   // on some errors only
}

Some errors add more fields, such as details (validation) or required / available (wallet). See Error Codes. POST /api/v1/contacts also returns a top-level created field (true when a new contact was made).

Authentication

Include your API key in the Authorization header as a Bearer token with every request.

Authorization: Bearer ppw_live_sk_your_key_here

Keys look like ppw_live_sk_ followed by 32 lowercase hex characters. There are no test or sandbox keys. Every key is live, so every message you send reaches WhatsApp and is charged to your wallet. To test, send to a phone you own (not the WhatsApp number you send from). Write the header exactly as shown, with Bearer followed by one space. Other headers such as X-API-Key are ignored.

Generate your API key in Dashboard → Settings → API tab. You can have up to 5 keys per account. Switched-off keys count toward the 5, so delete one to make room. You can reveal and copy a key again at any time from that list, which means anyone who can log in to your dashboard can read your keys. To revoke a key, switch it off (its requests then get 401 API key is deactivated) or delete it.

Quick Example — Check Balance

curl https://payperwa.com/api/v1/balance \
  -H "Authorization: Bearer ppw_live_sk_your_key_here"

Pick a language once — every other code sample on this page switches with you.

Call the API from your server, never from browser JavaScript. The API sends no CORS headers, so browsers block the response, and a key in front-end code is visible to anyone. The JavaScript samples use top-level await, so run them with Node.js 18+ as an ES module (for example a file named app.mjs).

Rate Limits

Each API key can make 100 requests per minute. The limit applies to each key, not to the whole account, and is shown on every key in Dashboard → Settings → API. You cannot change it yourself. Contact support if you need a higher limit.

  • The window is a fixed 60 seconds that starts with the key's first request. When it ends, the count starts again from zero.
  • Every request made with a valid key counts, including ones that end in 400, 403 (missing permission), 404 or 500. Requests rejected with 401 do not count.
  • Over the limit you get 429 until the window ends, which can take up to 60 seconds. No Retry-After or X-RateLimit-* headers are sent, so wait up to a minute before you retry.
  • The OTP / Verify API also has its own limits per recipient number. Those 429s carry a code such as OTP_COOLDOWN, a retry_after_seconds field and a Retry-After header.
// HTTP 429
{
  "success": false,
  "error": "Rate limit exceeded. Try again later."
}

Channels (Multi-WABA)

If your account has more than one connected WhatsApp Business Account (WABA) — for example, separate numbers for Sales and Support — you can scope a call to one channel by passing its ID in the X-Channel-Id header. Without it, each endpoint picks for you: a template send uses the number the template is approved on (your primary number's copy first), a text or media reply uses the number whose 24-hour window is open, the list endpoints return data from all your channels, and POST /api/v1/contacts uses your primary channel. The "Where it applies" table below has the details.

Where to find your channel ID

A channel ID is a UUID such as 3f2a9c1e-5b7d-4e8a-9c21-7d4e5f6a8b90. It is not the WABA ID or the phone number ID from Meta. You can get it in three places:

  1. Dashboard → Settings → API: the "Your channel IDs" list shows every connected number with its ID and a copy button.
  2. Dashboard → Settings → WhatsApp: each number card shows "Channel ID" next to the WABA ID.
  3. The API: GET /api/v1/channels (below) returns all your channels with their IDs.

If you have only one number connected you can skip the header entirely — every call uses your primary channel.

GET/api/v1/channels

Lists your connected numbers: active ones only, primary first, then in the order they were connected. Any valid API key can call it; no permission is needed.

{
  "success": true,
  "data": {
    "channels": [
      {
        "id": "3f2a9c1e-5b7d-4e8a-9c21-7d4e5f6a8b90",
        "label": "Sales line",
        "phone_number": "+91 98765 43210",
        "display_name": "Acme Stores",
        "display_name_status": "APPROVED",
        "waba_id": "118600000000000",
        "phone_number_id": "964190000000000",
        "is_primary": true,
        "coexistence": false,
        "quality_rating": null,
        "messaging_tier": null,
        "connected_at": "2026-09-24T07:34:30.000Z"
      }
    ],
    "usage": "Pass a channel's id as the X-Channel-Id header (or channelId in the body/query) to scope a request. Without it each endpoint chooses (template sends use the template's number, free-form sends the number with the open window, list endpoints return all numbers) — see the API docs, Channels."
  }
}
FieldDescription
idChannel ID (UUID). The value to send as X-Channel-Id
labelYour name for the number. At connection it defaults to the WhatsApp display name, or the phone number if that is unavailable; you can rename it in Settings
phone_numberThe number as Meta formats it, e.g. +91 98765 43210; can be null
display_name / display_name_statusWhatsApp display name and Meta's review status of it (APPROVED, PENDING_REVIEW, DECLINED …); null until checked
waba_id / phone_number_idMeta's IDs, for reference only. No PayPerWA endpoint accepts them as a channel
is_primarytrue for your primary number
coexistencetrue when the number also runs on the WhatsApp Business app
quality_rating / messaging_tierStored values that PayPerWA does not refresh from Meta, so they are usually null. Check the live quality rating and messaging limit in WhatsApp Manager
connected_atWhen the number was connected (ISO 8601, UTC)
curl https://payperwa.com/api/v1/channels \
  -H "Authorization: Bearer YOUR_API_KEY"

How to specify a channel

Three options, in resolution order:

  1. X-Channel-Id HTTP header (recommended)
  2. channelId in the request body (POST /api/v1/messages/send, POST /api/v1/otp/send and POST /api/v1/contacts only)
  3. ?channelId= query parameter (GET only)

Where it applies

EndpointWith X-Channel-IdWithout it
POST /api/v1/messages/send (template)Sends from that channel's number; the template must be approved on itThe template is looked up across all your channels (primary channel's copy first) and sent from the channel it belongs to
POST /api/v1/messages/send (text / media)Only that channel's conversation with the number countsWhichever channel has an open 24-hour window with the number
GET /api/v1/messages/windowChecks only that channel. The ID is not validated: an unknown ID returns open: falseChecks all active channels; an open window wins
POST /api/v1/otp/sendUses that channel's Authentication template and sends from its numberThe template is chosen across your channels (see OTP / Verify API) and sent from the channel it belongs to
POST /api/v1/otp/verifyNot used: a code belongs to your account and the recipient number
GET /api/v1/contactsContacts on that channel onlyContacts from all channels
POST /api/v1/contactsCreates / upserts the contact under that channelUses your primary channel
GET/PUT/DELETE /api/v1/contacts/:idNot used: the contact is addressed by its ID
GET /api/v1/templatesTemplates on that channel onlyTemplates from all channels
GET /api/v1/campaignsCampaigns on that channel onlyCampaigns from all channels
POST /api/v1/campaignsNot used: the campaign is sent from the channel its template belongs to
GET /api/v1/balanceNot used: the wallet is shared across all channels
GET /api/v1/channelsNot used: always lists all your active channels

Example — Send from a specific channel

curl -X POST https://payperwa.com/api/v1/messages/send \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "X-Channel-Id: 3f2a9c1e-5b7d-4e8a-9c21-7d4e5f6a8b90" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "919876543210",
    "template_name": "order_confirmation",
    "variables": ["Rahul", "ORD-4521"]
  }'

Find your channel IDs in Dashboard → Settings → API ("Your channel IDs") or with GET /api/v1/channels. A channel ID that is not one of yours returns 400 Invalid channelId on the send, contacts, templates and campaigns endpoints (sends also reject a disconnected number). GET /api/v1/messages/window does not check it and answers open: false.

Send Message

POST/api/v1/messages/send

Send a single WhatsApp message using an approved template — at any time. The PayPerWA fee (₹0.20 per message, $0.004 on USD wallets) is deducted from your wallet; Meta bills its own template fee to your WhatsApp Business Account. To reply with free-form text or media inside the 24-hour customer service window, see Text & Media.

Opt-out: a MARKETING or UTILITY template is refused with 403 CONTACT_OPTED_OUT, before anything is charged or sent, when one of your contacts (not deleted, on any of your channels) has that number and has opted out. Numbers are compared by their digits, so +91 98765-43210 and 919876543210 match. AUTHENTICATION templates are still sent, because the person asked for the code. A number with no opted-out contact is sent as usual, so only send to people who agreed to hear from you (WhatsApp requires opt-in).

Permission required: messages:send

Request Body

{
  "to": "919876543210",
  "template_name": "order_confirmation",
  "language": "en",
  "variables": ["Rahul", "ORD-4521", "₹1,299"]
}
FieldTypeRequiredDescription
tostringYesPhone number with country code, digits only (e.g. 919876543210). 10–15 characters; passed to WhatsApp as given
typestringNotemplate (the default when template_name is set). For text or media see Text & Media
template_namestringYesName of a template with status APPROVED
languagestringNoThe template's exact language code, sent to WhatsApp as given (default: en). It is not read from the template, so a template approved as en_US or hi needs that code, or Meta rejects the send with #132001. GET /api/v1/templates shows each template's language
variablesstring[]NoValues for the body placeholders {{1}}, {{2}} … in order, as strings; the count must match the template body. Only body variables are supported: a variable in a TEXT header or in a URL button cannot be filled here. For AUTHENTICATION templates see the OTP box below
channelIdstringNoChannel to send from. Same as the X-Channel-Id header, which wins if both are set. Without it the template's own channel is used
document_urlstringIf DOCUMENT headerPublic https:// URL of the file, e.g. an invoice PDF. Required for DOCUMENT header templates unless header media is stored on the template; an http:// URL is rejected
document_filenamestringNoFilename shown on WhatsApp (max 255 characters). Default: the file name in the URL (e.g. BILL-201.pdf), otherwise document.pdf
image_urlstringIf IMAGE headerPublic https:// URL for IMAGE header templates, unless header media is stored on the template. JPG or PNG under 5 MB; a landscape 1.91:1 image (1200 × 628 px) avoids cropping in the chat header.
video_urlstringIf VIDEO headerPublic https:// URL for VIDEO header templates, unless header media is stored on the template
otp_button_typestringNoAUTHENTICATION templates only: copy_code or url (one-tap / zero-tap autofill). Overrides the automatic button detection; leave it out unless WhatsApp rejects the button

Document header example (invoice / bill PDF)

{
  "to": "919876543210",
  "template_name": "bill",
  "language": "en",
  "variables": ["Rahul", "01 Jul - 31 Jul 2026", "₹1,250.00", "https://example.com/pay"],
  "document_url": "https://your-cdn.com/bills/BILL-201.pdf",
  "document_filename": "BILL-201.pdf"
}

🔐 Sending an OTP / login code

For an AUTHENTICATION template, pass the one-time code as a string in the first position of variables. PayPerWA fills both the message body and the copy-code (or autofill) button with that same code, so you never send a button parameter. Any further values are ignored. Always include the code: without variables the template reaches WhatsApp with no code and is rejected (the fee is refunded). Set language to the template's exact language code (default en).

{
  "to": "919876543210",
  "template_name": "login",
  "language": "en",
  "variables": ["483920"]
}

The response adds otp_button_detection (e.g. meta_otp_copy_code), which shows how the button type was read from Meta. If WhatsApp rejects the button, force the format with otp_button_type: copy_code or url.

To have PayPerWA create the code, send it and check what your user types, use the OTP / Verify API instead.

Response

{
  "success": true,
  "data": {
    "message_id": "wamid.HBgLMTIzNDU2Nzg5MA==",
    "status": "sent",
    "cost": {
      "meta_fee": 0,
      "platform_fee": 0.2,
      "wallet_deduction": 0.2,
      "total_outlay": 0.2,
      "billing_mode": "meta_direct",
      "breakdown": "Meta bills customer directly + PayPerWA ₹0.20 fee",
      "country": "IN",
      "pricing_source": "meta_direct"
    }
  }
}
FieldDescription
message_idWhatsApp message ID (wamid). Webhook events refer to it
statusAlways sent, meaning Meta accepted the message. Delivered, read and failed arrive later as webhooks
cost.meta_fee0. Meta bills its own template fee to your WhatsApp Business Account's payment method, never to your wallet
cost.platform_feePayPerWA's fee in your wallet's currency: 0.2 (INR) or 0.004 (USD)
cost.wallet_deductionAmount taken from your wallet for this message (equal to platform_fee). Refunded automatically if Meta rejects the send
cost.total_outlaymeta_fee + platform_fee as charged by PayPerWA; excludes what Meta bills your WhatsApp Business Account
cost.billing_mode / cost.pricing_sourcemeta_direct: Meta bills you directly for its fee
cost.breakdownReadable summary, e.g. "Meta bills customer directly + PayPerWA ₹0.20 fee" (USD wallets: "… $0.0040 fee")
cost.countryRecipient country detected from the number (e.g. IN), or null
delivery_warningPresent only when Meta reports your WhatsApp Business Account as blocked from starting conversations (for example a payment-method problem, code 141006). Meta accepted the message but will not deliver it
otp_button_detectionAUTHENTICATION templates only: how the OTP button type was chosen

Example delivery_warning (added next to status; the send is still charged):

"delivery_warning": {
  "code": 141006,
  "message": "There is an error with the payment method. This will block business initiated conversations.",
  "action": "This is a Meta account issue (not PayPerWA). Meta accepted this message but will NOT deliver it until you fix it — open Meta Business Settings > WhatsApp Accounts and check your payment method / account status. Messages sent within a 24-hour customer service window are unaffected."
}

Errors

Statuserror / codeWhen
400Validation failed + detailsA field is missing or invalid; details maps each field to its messages, e.g. {"to": ["Too small: expected string to have >=10 characters"]}
400Provide template_name for a template message, or type: "text" | "image" | "video" | "document" | "audio" for a free-form message inside the 24-hour window.Neither template_name nor type was sent
400Unknown type 'sticker'. Use template, text, image, video, document or audio.Unsupported type
400Invalid channelIdThe channel is not one of your connected numbers
400WhatsApp Business Account not connected. Connect it in Dashboard > Settings.No connected number to send from
400OWN_NUMBERto is the number you are sending from
400Template requires a DOCUMENT header. Pass "document_url" in the request body (public https URL), or upload header media on the Templates page.A media-header template with no media (IMAGE / VIDEO name image_url / video_url)
400document_url must be a public https:// URLAn http:// link (same for image_url / video_url)
401Missing or invalid Authorization header. Use: Bearer <api_key>, Invalid API key format, Invalid API key, API key is deactivated, API key has expiredProblem with the API key
402INSUFFICIENT_WALLET_BALANCEThe wallet cannot cover the PayPerWA fee (example below)
403Missing required permission: messages:send, Account is suspendedKey without the permission, or a suspended account
403CONTACT_OPTED_OUT"This contact has opted out of messages." A MARKETING or UTILITY template to a number one of your contacts opted out on. Nothing is charged or sent. AUTHENTICATION templates are not refused
404Template 'order_confirmation' not found or not approvedNo APPROVED template with that name; ends with " on this channel" when a channel was given
409META_API_OFFThe number's connection to Meta no longer works (expired token, removed permissions or WABA). Reconnect WhatsApp in Settings
429Rate limit exceeded. Try again later.More requests than your key's per-minute limit
Meta's status (usually 400)WhatsApp API error + detailsMeta rejected the send; details is Meta's error object (e.g. #132001 wrong language, #132000 wrong number of variables). The fee is refunded
500Failed to send messageUnexpected failure, including a request body that is not valid JSON
{
  "success": false,
  "error": "Insufficient wallet balance. Please recharge to send messages.",
  "code": "INSUFFICIENT_WALLET_BALANCE",
  "required": "0.20",
  "available": "0.10",
  "currency": "INR",
  "breakdown": "Meta bills customer directly + PayPerWA ₹0.20 fee"
}

required and available are strings in your wallet currency's decimals (2 for INR, 4 for USD), and currency names that currency. available is rounded down, so it never shows as much as required while it falls short. On a USD wallet with $0.003 the 402 has "required": "0.0040", "available": "0.0030", "currency": "USD" and the breakdown "Meta bills customer directly + PayPerWA $0.0040 fee". If another request uses up the balance in between, the 402 instead reads "Insufficient wallet balance. Required ₹0.20, available ₹0.05. Please recharge to continue." with required / available to 4 decimals, currency and breakdown.

Example

curl -X POST https://payperwa.com/api/v1/messages/send \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "919876543210",
    "template_name": "order_confirmation",
    "language": "en",
    "variables": ["Rahul", "ORD-4521", "₹1,299"]
  }'

Send Text & Media (24-hour window)

POST/api/v1/messages/send

Reply to a customer with free-form text, an image, a video, a document or an audio file — no template needed. The same endpoint is used; pick the message with type.

⏱ Only inside the 24-hour customer service window

WhatsApp delivers free-form messages only within 24 hours of the customer's last message to your number (every new customer message resets the clock). Outside that window, or if the number has never messaged you, the API returns 409 OUTSIDE_24H_WINDOW and you must send an approved template instead. Check the window first with GET /api/v1/messages/window (below).

💰 Pricing

PayPerWA fee: ₹0.20 per message on INR wallets, $0.004 per message on USD wallets — the same fee as a template send, deducted from your wallet and refunded automatically if WhatsApp rejects the send. Meta bills any charge for messages inside the customer service window directly to your WhatsApp Business Account, never to your PayPerWA wallet.

Permission required: messages:send

Text message

{
  "to": "919876543210",
  "type": "text",
  "text": "Hi Rahul, your order ORD-4521 has been packed and ships today. Track it here: https://example.com/t/4521",
  "preview_url": true
}

Image, video, document or audio

{
  "to": "919876543210",
  "type": "image",
  "media_url": "https://your-cdn.com/orders/ORD-4521.jpg",
  "caption": "Your order is packed"
}
{
  "to": "919876543210",
  "type": "document",
  "media_url": "https://your-cdn.com/invoices/INV-4521.pdf",
  "filename": "INV-4521.pdf",
  "caption": "Invoice for order ORD-4521"
}
FieldTypeRequiredDescription
tostringYesPhone number with country code (e.g. 919876543210), 10–15 characters
typestringYestext, image, video, document or audio (omit or use template for template sends)
textstringtype=textMessage body, up to 4,096 characters
preview_urlbooleanNoShow a link preview for the first http(s) URL in the text (default false)
media_urlstringmedia types*Public URL of the file; WhatsApp downloads it (cached by Meta for 10 minutes). PayPerWA does not check the file type or size first. See Media limits
media_idstringmedia types*Alternative to media_url: the ID of a file you uploaded to Meta's media endpoint for the same phone number (valid 30 days). PayPerWA has no upload endpoint. If both are sent, media_id is used
captionstringNoUp to 1,024 characters; image, video and document only. A caption on audio is rejected with 400
filenamestringNoDocument only (other types are rejected with 400), max 255 characters: the filename with extension shown on WhatsApp. Applied with media_url only; it is ignored when you send media_id
channelIdstringNoOr the X-Channel-Id header (the header wins). Without it the number's conversation with an open window is used automatically. With it, only that channel's conversation counts; if it has no open window you get 409 OUTSIDE_24H_WINDOW

* one of media_url or media_id is required for image, video, document and audio.

Media limits (set by WhatsApp)

TypeFormatsMax sizeCaption
imageJPEG, PNG5 MBYes
videoMP4, 3GP (H.264 video, AAC audio)16 MBYes
documentPDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT100 MBYes, plus filename
audioAAC, AMR, MP3, M4A, OGG (Opus)16 MBNo

Response

{
  "success": true,
  "data": {
    "message_id": "wamid.HBgLMTIzNDU2Nzg5MA==",
    "inbox_message_id": "5ceb6e10-af90-4702-b690-df31c11d8412",
    "status": "sent",
    "type": "text",
    "window": { "open": true, "expires_at": "2026-10-14T09:12:40.000Z" },
    "cost": {
      "meta_fee": 0,
      "platform_fee": 0.2,
      "wallet_deduction": 0.2,
      "currency": "INR",
      "billing_mode": "meta_direct",
      "breakdown": "Service / inbound reply (free from Meta) + PayPerWA ₹0.20 fee",
      "note": "Free-form messages inside the 24-hour window are charged only the PayPerWA fee here; any Meta charge is billed by Meta to your WhatsApp Business Account."
    }
  }
}

inbox_message_id is the message's ID in your PayPerWA Inbox (null if it could not be recorded). On a USD wallet platform_fee and wallet_deduction are 0.004 and currency is USD.

Window closed (409)

{
  "success": false,
  "error": "The 24-hour customer service window for this number has closed. Send an approved template instead; free-form messages are possible again once the customer replies.",
  "code": "OUTSIDE_24H_WINDOW",
  "window": { "open": false, "expires_at": "2026-10-13T07:30:00.000Z" },
  "hint": "POST /api/v1/messages/send with template_name, or check GET /api/v1/messages/window?to=<number> first."
}

If you have never exchanged any message with the number, error reads "No conversation with this number yet. Free-form text and media can only be sent within 24 hours of the customer's last message — send an approved template instead." and window.expires_at is null. If you have only sent to the number and it has never replied, you get the "window has closed" text above, also with window.expires_at null. The same 409 is returned (and the fee refunded) when WhatsApp itself reports the window closed (Meta error 131047).

Other errors

Statuserror / codeWhen
400Validation failed + detailse.g. {"media_url": ["media_url or media_id is required"]}, {"caption": ["audio messages do not support a caption"]}, {"filename": ["filename applies to document messages only"]}, {"text": ["text is required"]}
400Invalid channelIdThe channel is not one of your connected numbers
400MEDIA_REJECTEDMeta could not fetch or accept the file (error 131053): check the URL is public and the type and size fit the table above. The fee is refunded
400This conversation is not attached to a connected WhatsApp number. Reconnect WhatsApp in Settings.The open conversation has no connected number
402INSUFFICIENT_WALLET_BALANCEe.g. "Insufficient wallet balance. Required ₹0.20, available ₹0.05. Please recharge to continue.", with required, available (4 decimals), currency and breakdown
403CONTACT_OPTED_OUT"This contact has opted out of messages."
409META_API_OFFThe number's connection to Meta no longer works; reconnect WhatsApp in Settings. A template will fail too
Meta's status (usually 400)WhatsApp API error + detailsMeta rejected the send for another reason; the fee is refunded

401, 403 (permission), 429 and 500 are the same as for template sends.

Example

curl -X POST https://payperwa.com/api/v1/messages/send \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "919876543210",
    "type": "text",
    "text": "Hi Rahul, your order ORD-4521 ships today."
  }'

Check the 24-hour window

GET/api/v1/messages/window?to=919876543210

Tells you whether free-form messages can be sent to a number right now, and until when. Use it to decide between a text reply and a template before sending. The window is the customer's last message to you plus 24 hours, as recorded by PayPerWA.

Permission required: messages:send

ParameterRequiredDescription
toYesPhone number with country code. Spaces and symbols are ignored; 10–15 digits must remain
channelIdNoOr the X-Channel-Id header: check only that channel. Without it every connected channel is checked and an open window wins. The ID is not validated, so an unknown ID returns open: false
{
  "success": true,
  "data": {
    "to": "+919876543210",
    "open": true,
    "expires_at": "2026-10-14T09:12:40.000Z",
    "channel_id": "3f2a9c1e-5b7d-4e8a-9c21-7d4e5f6a8b90",
    "last_customer_message_at": "2026-10-13T09:12:40.000Z",
    "can_send": ["template", "text", "image", "video", "document", "audio"]
  }
}

When the window has closed, open is false, can_send is ["template"] and expires_at shows when it closed. If you have never exchanged any message with the number, expires_at, channel_id and last_customer_message_at are null (if you have only sent to it, channel_id is set and the other two are null):

{
  "success": true,
  "data": {
    "to": "+919899999999",
    "open": false,
    "expires_at": null,
    "channel_id": null,
    "last_customer_message_at": null,
    "can_send": ["template"]
  }
}

A to without 10–15 digits returns 400 with Query parameter 'to' must be a phone number with country code (10–15 digits). 401, 403 and 429 are as for every endpoint; an unexpected failure returns 500 Failed to check the messaging window.

OTP / Verify API

Let PayPerWA create, send and check one-time codes for logins, sign-ups and other confirmations. PayPerWA generates a random numeric code, sends it to the person's WhatsApp with your approved Authentication template, and later tells you whether the code your user typed is right. The code is never returned to you, so you do not generate or store it yourself.

  1. Your server calls POST /api/v1/otp/send with the person's number and gets back a verification_id.
  2. The person receives the code on WhatsApp, in the message and on its copy-code button, and types it into your app or site.
  3. Your server calls POST /api/v1/otp/verify with that code and the verification_id (or the number). data.valid is true only when the code is right.

Both endpoints use your API key (see Authentication) and need the permission messages:send. Each request also counts toward your key's rate limit.

Before you start: an approved Authentication template

WhatsApp allows one-time codes only in Authentication templates. Create one in Dashboard → Templates → New → Authentication and wait for Meta to approve it. Templates created in PayPerWA get a copy-code button; PayPerWA does not create one-tap or zero-tap autofill templates. If your template has a code-expiry footer, the minutes it shows are part of the approved template and do not follow expiry_minutes, so use the same number in both.

Codes go out over WhatsApp only. There is no SMS or other fallback: a person who cannot receive the WhatsApp message does not get the code.

Pricing per code sent

  • Meta's authentication fee is billed by Meta to the payment method of your WhatsApp Business Account, never to your PayPerWA wallet. In India it is ₹0.115 per message on Meta's current rate card; other countries have their own rates (see Meta rates). Meta sets this fee and can change it.
  • PayPerWA's fee is ₹0.20 per code sent, deducted from your wallet ($0.004 on USD wallets). So a code to an Indian number costs Meta ₹0.115 + PayPerWA ₹0.20. Every resend is a new code and is charged again.
  • Verifying is free. POST /api/v1/otp/verify never charges your wallet.
  • A send that PayPerWA refuses (validation, template, per-number limits, wallet, WhatsApp connection) is refused before the charge, so nothing is taken. If WhatsApp rejects the send, the ₹0.20 is refunded automatically.

Send a code

POST/api/v1/otp/send
{
  "to": "+91 98765 43210",
  "code_length": 6,
  "expiry_minutes": 10
}

Only to is required. The body is checked strictly: a field that is not in this table (for example variables or otp_button_type) is refused with 400.

FieldTypeRequiredDescription
tostringYesThe person's WhatsApp number with country code, at most 40 characters. See How to is read
code_lengthintegerNoDigits in the code, 4 to 8. Default 6. A JSON number, not a string. Codes can start with 0
expiry_minutesintegerNoMinutes the code stays valid, 1 to 30. Default 10
template_namestringNoThe Authentication template to send (1 to 512 characters). Default: chosen for you, see Which template is used
languagestringNoLanguage code to send the template in, 2 to 15 characters (e.g. en_US, hi). Default: the language stored with the chosen template (not en, unlike /messages/send). Meta must have the template approved in that language, or it rejects the send (#132001) and the fee is refunded
channelIdstringNoChannel to send from (1 to 64 characters). Same as the X-Channel-Id header, which wins if both are set. It must be one of your connected numbers

How to is read

PayPerWA trims the value, removes one leading +, every space and dash, and then one leading 00 (the international dialling prefix). What remains must be 10 to 15 digits and must not start with 0.

Written asResult
919876543210, +91 98765-43210, 0091 98765 43210, +0091-98765-43210All read as 919876543210
09876543210, 000919876543210Refused: still starts with 0 (a national trunk prefix, not a country code)
(91) 9876543210, 91.9876543210Refused: brackets, dots and letters are not allowed
91+9876543210, ++919876543210Refused: one + only, at the start
98765 4321, 1234567890123456Refused: fewer than 10 or more than 15 digits
919876543210 as a JSON numberRefused: to must be a string

A refused to returns 400 with {"to": ["to must be 10–15 digits starting with the country code, not 0 (a leading + or 00, spaces and dashes are allowed)"]} in details. The response gives the number in the form it was read (data.to), and every way of writing one number shares one set of limits.

PayPerWA cannot tell whether a country code is there. A 10-digit Indian mobile number written without 91 (9876543210) is accepted and sent as written, and WhatsApp reads its first digits as a country code, so it is a different number. Always include the country code.

Which template is used

  • Without template_name: your most recently updated APPROVED Authentication template that is not archived. With a channel (X-Channel-Id or channelId) only that channel's templates count; without one, templates on any connected number or with no number. None found: 400 OTP_TEMPLATE_REQUIRED. If you have more than one Authentication template, pass template_name so the choice does not depend on which one was updated last.
  • With template_name: the APPROVED, not archived template of that name (on the given channel, if any). If the name exists more than once, an Authentication copy is used, your primary number's first. No approved template of that name: 404. Only a template of another category (for example MARKETING): 400 OTP_TEMPLATE_NOT_AUTHENTICATION.
  • Language: the one stored with the chosen template, unless you pass language.
  • Number it is sent from: the given channel; without one, the number the template belongs to (your primary number if the template has none or its number is disconnected), as for template sends.

PayPerWA puts the code in the message body and in the copy-code button; you do not pass variables. Opt-out does not block a code: Authentication templates are sent even when a contact with that number has opted out, because the person asked for the code.

Response

{
  "success": true,
  "data": {
    "verification_id": "6f1c2b9e-4d3a-4f7e-9b21-0c8d5e7a1f42",
    "to": "919876543210",
    "status": "pending",
    "expires_at": "2026-11-09T10:10:00.000Z",
    "code_length": 6,
    "template_name": "login_otp",
    "message_id": "wamid.HBgLMTIzNDU2Nzg5MA==",
    "cost": {
      "meta_fee": 0,
      "platform_fee": 0.2,
      "wallet_deduction": 0.2,
      "total_outlay": 0.2,
      "billing_mode": "meta_direct",
      "breakdown": "Meta bills customer directly + PayPerWA ₹0.20 fee",
      "country": "IN",
      "pricing_source": "meta_direct"
    }
  }
}
FieldDescription
verification_idID of this code (a UUID). Pass it to /otp/verify. It stops working once the code is approved, ended, expired or replaced by a newer code
toThe number as it was read (digits only, country code first)
statusAlways pending: the code is waiting to be checked. It says nothing about delivery, which arrives later as webhooks
expires_atWhen the code stops being valid (ISO 8601, UTC): the time the request was processed plus expiry_minutes
code_lengthDigits in the code that was sent. The code itself is never in the response
template_nameThe Authentication template that was used
message_idWhatsApp message ID (wamid). Webhook events refer to it
costThe same object as a template send returns. meta_fee is 0 because Meta bills its authentication fee to your WhatsApp Business Account; wallet_deduction is PayPerWA's fee taken from your wallet
delivery_warningAdded after cost only when Meta reports your WhatsApp Business Account as blocked from starting conversations (for example a payment-method problem, code 141006). The code was sent and charged, but Meta will not deliver it until you fix the account

On a USD wallet platform_fee, wallet_deduction and total_outlay are 0.004 and breakdown reads "Meta bills customer directly + PayPerWA $0.0040 fee". This cost object has no currency field: the amounts are in your wallet's currency. country is detected from the number and can be null.

  • PayPerWA keeps only a keyed hash (HMAC-SHA256) of the code with its expiry and attempt count. The code is not stored, logged or returned.
  • Like every API send, the message appears in your Inbox under the template name ("Template: login_otp"), never the code; the number is added to your contacts if it is not there yet; and the message.sent webhook fires with sentFrom: "api" and the templateName, without the code.
  • A new code to the same number replaces the earlier one once it has gone out. The earlier verification_id then answers not_found, and the earlier code checked by number counts as a wrong code against the new one. The new code starts with 5 attempts. Ask your user for the newest code.
  • If the send request fails (any error response), no new code is created and any earlier code for the number stays valid. If Meta accepts the message and later reports it failed (a message.failed webhook), the new code has already replaced the earlier one and still counts toward the cooldown and the hourly limit; PayPerWA's fee is refunded. Send a new code.

Limits per number

These limits apply per recipient number within your account (another account sending to the same number has its own). The send limits are checked before anything is charged or sent, so a refused send costs nothing.

LimitValueWhen it is reached
Resend cooldown30 seconds between codes to one numberSend: 429 OTP_COOLDOWN
Codes per hour5 codes to one number in any rolling 60 minutesSend: 429 OTP_TOO_MANY_SENDS until the oldest of the 5 is an hour old
Wrong codes per 24 hours15 wrong codes for one number in any rolling 24 hours, across all its codesSend: 429 OTP_TOO_MANY_FAILED_CHECKS until the oldest of those wrong codes is 24 hours old. The code already sent can still be checked until its own attempts run out
Attempts per code5 wrong codesVerify: the 5th wrong code answers max_attempts and ends the code; send a new one
  • The cooldown and the hourly count include only codes that went out. A send refused after the check (for example 402, 409, an immediate WhatsApp API error or 503) gives its slot back, so an immediate retry is not refused with 429. A message Meta accepts and later reports as failed still counts. Requests refused for validation or the template never reach the check.
  • Wrong codes are the ones answered incorrect, plus the one that causes max_attempts. Right codes, expired codes and not_found checks do not count.
  • Every 429 from these limits has retry_after_seconds (whole seconds, at least 1) and the same value in a Retry-After header. When more than one limit applies, the 429 names the first of them in this order: wrong codes per 24 hours, codes per hour, cooldown.
// HTTP 429
{
  "success": false,
  "error": "A code was sent to this number less than 30 seconds ago. Wait before sending another.",
  "code": "OTP_COOLDOWN",
  "retry_after_seconds": 30
}

Errors

Every error has "success": false and error; most add a code. The send itself runs through the same code as template sends, so those errors come back with the same status and body.

Statuserror / codeWhen
400Validation failed + detailsA field is missing, of the wrong type or out of range, e.g. {"code_length": ["Too big: expected number to be <=8"]}. Problems with the body as a whole are listed under _errors, e.g. {"_errors": ["Unrecognized key: \"variables\""]}
400The request body must be JSONThe body could not be parsed as JSON
400Invalid channelIdX-Channel-Id or channelId is not one of your connected numbers
400OTP_TEMPLATE_REQUIREDNo template_name and no approved Authentication template: "No approved Authentication template. One-time passwords can only be sent with a WhatsApp Authentication template. Create one in Dashboard → Templates → New → Authentication, wait for Meta to approve it, then send again." ("template on this channel." when a channel was given)
400OTP_TEMPLATE_NOT_AUTHENTICATIONtemplate_name is an approved template of another category: "Template 'spring_sale' is a MARKETING template. WhatsApp only allows one-time passwords in Authentication templates. Create one in Dashboard → Templates → New → Authentication, wait for Meta to approve it, then send again."
404Template 'login_otp' not found or not approvedNo approved, not archived template with that template_name; ends with " on this channel" when a channel was given
400WhatsApp Business Account not connected. Connect it in Dashboard > Settings.No connected number to send from
400OWN_NUMBERto is the number you send from. WhatsApp does not deliver a message to it; test with another number
401Missing or invalid Authorization header. Use: Bearer <api_key>, Invalid API key format, Invalid API key, API key is deactivated, API key has expiredProblem with the API key
402INSUFFICIENT_WALLET_BALANCEThe wallet cannot cover PayPerWA's fee. Same body as a template send: required and available in your wallet currency's decimals (4 decimals if another request used the balance in between), currency and breakdown (example)
403Missing required permission: messages:send, Account is suspendedKey without the permission, or a suspended account
409META_API_OFFThe number's connection to Meta no longer works (expired token, removed permissions or WABA). Reconnect WhatsApp in Settings
429OTP_COOLDOWN"A code was sent to this number less than 30 seconds ago. Wait before sending another."
429OTP_TOO_MANY_SENDS"At most 5 codes can be sent to this number per hour. Try again later."
429OTP_TOO_MANY_FAILED_CHECKS"15 wrong codes were entered for this number in the last 24 hours. No new code can be sent to it yet."
429Rate limit exceeded. Try again later.Your key's per-minute limit (no code, no Retry-After)
503OTP_UNAVAILABLE"Verification is temporarily unavailable. Nothing was sent or charged — try again shortly." Verification could not run on PayPerWA's side, most often because the store that keeps codes and limits did not answer in time. Retry after a short wait
Meta's status (usually 400)WhatsApp API error + detailsMeta rejected the send; details is Meta's error object (e.g. #132001 wrong language). The fee is refunded, no new code is created and an earlier code stays valid
500Failed to send message, Failed to send the verification codeUnexpected failure

CONTACT_OPTED_OUT is never returned here: Authentication templates are not blocked by opt-out.

Check a code

POST/api/v1/otp/verify

Checks the code your user typed. Free: it never charges your wallet. A valid request gets 200 whether or not the code is right, and data.status says what happened. A wrong code is not an HTTP error.

{
  "code": "483920",
  "verification_id": "6f1c2b9e-4d3a-4f7e-9b21-0c8d5e7a1f42"
}
FieldTypeRequiredDescription
codestringYesThe code your user typed: 4 to 8 digits, as a JSON string (a number is refused, and would lose leading zeros). Spaces around it are trimmed
verification_idstringOne of the twoThe verification_id from /otp/send. Checks exactly that code
tostringOne of the twoThe number, read as described in How to is read. Checks the newest code sent to it

Send exactly one of verification_id and to; any other field is refused with 400. Each check is a single atomic step: a code is approved at most once, even when several checks arrive together, and never takes more than 5 wrong codes.

Response

// Right code
{
  "success": true,
  "data": {
    "valid": true,
    "status": "approved",
    "verification_id": "6f1c2b9e-4d3a-4f7e-9b21-0c8d5e7a1f42"
  }
}

// Wrong code
{
  "success": true,
  "data": {
    "valid": false,
    "status": "incorrect",
    "verification_id": "6f1c2b9e-4d3a-4f7e-9b21-0c8d5e7a1f42",
    "attempts_remaining": 3
  }
}

valid is true only for approved. Sign the user in on that alone and treat every other status as a failure.

statusOther fieldsMeaning
approvedverification_idThe right code, in time. The code is used up: checking it again answers not_found
incorrectverification_id, attempts_remaining (4 to 1)A wrong code. The code stays valid for the attempts left. It also counts toward the number's 15 wrong codes per 24 hours
max_attemptsverification_id, attempts_remaining: 0The 5th wrong code. The code is ended, so even the right one now answers not_found. Send a new code
expiredverification_idChecked after expires_at, with the right or a wrong code (expiry is checked first, so no attempt is used). The code is deleted. This answer is given within 2 minutes after expiry; later checks answer not_found
not_foundverification_id only when you checked by itNo code to check: none was sent to the number, its send failed, it was already approved, it was ended by max_attempts or answered expired, it expired more than 2 minutes ago, a newer code replaced it (when checked by its verification_id), or the verification_id is unknown. A code sent by another PayPerWA account is also not_found for your key, and the check uses none of its attempts

Errors

Statuserror / codeWhen
400Validation failed + detailse.g. {"code": ["code must be 4–8 digits"]}, {"code": ["code must be a string of 4–8 digits"]}, {"verification_id": ["Provide exactly one of to or verification_id"]}, {"verification_id": ["verification_id must be the id returned by /api/v1/otp/send"]}, a refused to, or an unknown field under _errors
400The request body must be JSONThe body could not be parsed as JSON
401 / 403 / 429As for sendingAPI key problems, missing messages:send, a suspended account, or your key's per-minute limit. The per-number limits apply to sending only
503OTP_UNAVAILABLEThe same body as on sending (nothing is charged for a check either). Retry after a short wait
500Failed to verify the codeUnexpected failure

Example: send a code, then check it

# 1. Send a code
curl -X POST https://payperwa.com/api/v1/otp/send \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+91 98765 43210",
    "code_length": 6,
    "expiry_minutes": 10
  }'

# 2. Check the code your user typed, with the verification_id from step 1
curl -X POST https://payperwa.com/api/v1/otp/verify \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "code": "483920",
    "verification_id": "6f1c2b9e-4d3a-4f7e-9b21-0c8d5e7a1f42"
  }'

# ...or check against the number's newest code instead
curl -X POST https://payperwa.com/api/v1/otp/verify \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "code": "483920", "to": "919876543210" }'

Contacts

GET/api/v1/contacts

List your contacts, newest first, with pagination and search. Deleted contacts are not listed; opted-out contacts are (check optedIn).

Permission: contacts:read

Query Parameters

ParamDefaultDescription
page1Page number (a whole number)
pageSize50Items per page, 1–100 (larger values are capped at 100)
search-Part of the name (case-insensitive) or part of the phone number. Encode a + as %2B; a raw + in a query string is read as a space and matches nothing
channelId-Only contacts on this channel (or send the X-Channel-Id header). Without it, contacts from all your channels are listed
// Response
{
  "success": true,
  "data": {
    "contacts": [
      {
        "id": "550e8400-e29b-41d4-a716-446655440000",
        "name": "Rahul Sharma",
        "phone": "+919876543210",
        "email": "rahul@example.com",
        "optedIn": true,
        "tags": ["customer", "delhi"],
        "createdAt": "2026-01-15T10:30:00.000Z"
      }
    ],
    "total": 1250,
    "page": 1,
    "pageSize": 50
  }
}

Example

curl "https://payperwa.com/api/v1/contacts?page=1&pageSize=50" \
  -H "Authorization: Bearer YOUR_API_KEY"
POST/api/v1/contacts

Create a contact, or update it if the phone already exists on the same channel (upsert). Tags are auto-created if they don't exist. Returns 201 on create and 200 on update; the response includes a created boolean so you can branch.

Permission: contacts:write

Body Fields

FieldTypeRequiredDescription
namestringYesContact display name (1-100 chars)
phonestringYesFull number including the country code, 10–15 characters as sent (spaces and dashes count), e.g. 919876543210 or +919876543210. Spaces, dashes, dots and brackets are removed and a leading + is added. The country code is not added for you: 9876543210 is saved as +9876543210, and a leading 0 is dropped
emailstringNoA valid email address. On update, leave it out to keep the current email or send "" to clear it
optedInbooleanNoDefault true, on create and on update. If you upsert an existing contact who opted out and leave out "optedIn": false, they are opted back in. To change other fields of an opted-out contact, send "optedIn": false or use PUT /api/v1/contacts/{id}, which leaves fields you omit unchanged
tagsstring[]NoTag names. New names are auto-created on your account
upsertbooleanNoDefault true. Set false to fail with 409 if the phone already exists
tagsModestringNo"merge" (default) keeps existing tags and adds new ones; "replace" wipes existing tags first
channelIdstringNoChannel to save the contact on. Same as the X-Channel-Id header, which wins if you send both. Omit both to use your primary channel. A phone number can exist once per channel

A contact you deleted is reactivated automatically (same ID) when you post the same phone again on the same channel with the default upsert: true. With upsert: false a deleted contact still counts as existing and returns 409.

// Request — create or update by phone, merge tags
{
  "name": "Priya Patel",
  "phone": "919123456789",
  "email": "priya@example.com",
  "tags": ["lead", "mumbai"]
}

// Response — 201 Created (new) or 200 OK (updated)
{
  "success": true,
  "created": true,
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440001",
    "name": "Priya Patel",
    "phone": "+919123456789",
    "email": "priya@example.com",
    "optedIn": true,
    "tags": ["lead", "mumbai"],
    "createdAt": "2026-03-20T14:00:00.000Z",
    "updatedAt": "2026-03-20T14:00:00.000Z"
  }
}

Errors

// 400 — a field failed validation (details lists each field)
{
  "success": false,
  "error": "Validation failed",
  "details": { "phone": ["Too big: expected string to have <=15 characters"] }
}

// 400 — phone is not all digits after cleaning (this one has no "success" field)
{ "error": "Invalid phone number format. Use E.164 format: +919876543210" }

// 400 — X-Channel-Id / channelId is not one of your channels
{ "success": false, "error": "Invalid channelId" }

// 409 — only with "upsert": false
{ "success": false, "error": "A contact with this phone number already exists on this channel" }

Example — Create or update (upsert + merge tags)

# Default: upsert + merge tags
curl -X POST https://payperwa.com/api/v1/contacts \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Priya Patel","phone":"919123456789","tags":["lead","mumbai"]}'

# Replace tags entirely instead of merging
curl -X POST https://payperwa.com/api/v1/contacts \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Priya Patel","phone":"919123456789","tags":["customer"],"tagsMode":"replace"}'

# Strict create-only — return 409 if the phone already exists
curl -X POST https://payperwa.com/api/v1/contacts \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Priya Patel","phone":"919123456789","upsert":false}'
GET/api/v1/contacts/{id}

Get one contact by ID, with its notes, tag names and group names (names, not IDs). A deleted contact, or an ID that is not yours, returns 404 Contact not found.

Permission: contacts:read

// Response
{
  "success": true,
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440001",
    "name": "Priya Patel",
    "phone": "+919123456789",
    "email": "priya@example.com",
    "optedIn": true,
    "notes": null,
    "tags": ["lead", "mumbai"],
    "groups": ["Mumbai customers"],
    "createdAt": "2026-03-20T14:00:00.000Z",
    "updatedAt": "2026-03-20T14:00:00.000Z"
  }
}

Example

curl https://payperwa.com/api/v1/contacts/CONTACT_ID \
  -H "Authorization: Bearer YOUR_API_KEY"
PUT/api/v1/contacts/{id}

Update a contact. Send only the fields you want to change: name, phone, email, optedIn, tags. Fields you leave out stay as they are. tags always replaces the contact's tags (there is no merge mode here); send [] to remove them all. Groups, notes and the channel cannot be changed through the API.

Phone works differently here than on POST. Send it in E.164 with a leading + and no spaces, e.g. +919876543210. A value without + is treated as an Indian number and gets +91 put in front (9876543210 → +919876543210, but 919876543210 → +91919876543210), and spaces or dashes are not removed. If another of your contacts, on any channel, already has the number, you get 409 Another contact with this phone number exists. An unknown or deleted ID returns 404.

Permission: contacts:write

// Request
{
  "name": "Priya P.",
  "tags": ["customer", "vip"]
}

// Response
{
  "success": true,
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440001",
    "name": "Priya P.",
    "phone": "+919123456789",
    "email": "priya@example.com",
    "optedIn": true,
    "tags": ["customer", "vip"],
    "groups": ["Mumbai customers"],
    "updatedAt": "2026-03-21T09:15:00.000Z"
  }
}

Example

curl -X PUT https://payperwa.com/api/v1/contacts/CONTACT_ID \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Priya P.","tags":["customer","vip"]}'
DELETE/api/v1/contacts/{id}

Soft-delete a contact. It disappears from the contact list and from campaign audiences, and GET/PUT/DELETE on its ID return 404. To bring it back, POST /api/v1/contacts the same phone on the same channel; the original contact and its ID are reactivated.

Permission: contacts:write

// Response
{
  "success": true,
  "data": { "message": "Contact deleted" }
}

Example

curl -X DELETE https://payperwa.com/api/v1/contacts/CONTACT_ID \
  -H "Authorization: Bearer YOUR_API_KEY"

Templates

GET/api/v1/templates

List your message templates: approved ones by default, or one other status. Every match comes back in one list (no pagination), newest first. Archived templates are not listed.

Permission: templates:read

Query Parameters

ParamDefaultDescription
statusAPPROVEDOne of DRAFT, PENDING, APPROVED, REJECTED, in uppercase exactly as written. Any other value, including lowercase approved, fails with 500
category-One of MARKETING, UTILITY, AUTHENTICATION (uppercase). Omit for all categories
channelId-Only templates on this channel (or send the X-Channel-Id header). Without it, templates from all your channels are listed
// Response
{
  "success": true,
  "data": {
    "templates": [
      {
        "id": "550e8400-e29b-41d4-a716-446655440002",
        "name": "order_confirmation",
        "category": "UTILITY",
        "language": "en",
        "status": "APPROVED",
        "header": { "type": "TEXT", "value": "Order confirmed" },
        "body": "Hi {{1}}, your order {{2}} of {{3}} has been confirmed!",
        "footer": "Thank you for shopping with us",
        "buttons": [
          { "type": "URL", "text": "View order", "url": "https://example.com/orders" }
        ],
        "metaTemplateId": "1234567890123456",
        "createdAt": "2026-02-01T12:00:00.000Z"
      }
    ]
  }
}

header is null or { type, value } with type TEXT, IMAGE, VIDEO or DOCUMENT. For media headers value is an internal Meta media handle (or null), not a URL you can download. buttons is the template's button list or null.

Example

curl "https://payperwa.com/api/v1/templates?status=APPROVED" \
  -H "Authorization: Bearer YOUR_API_KEY"

Campaigns

GET/api/v1/campaigns

List your campaigns, newest first, with status and delivery stats.

Permission: campaigns:read

Query Parameters

ParamDefaultDescription
status-One of DRAFT, SCHEDULED, SENDING, COMPLETED, CANCELLED, FAILED (uppercase; any other value fails with 500). Omit for all
page1Page number (a whole number)
pageSize20Items per page, 1–100 (larger values are capped at 100)
channelId-Only campaigns on this channel (or send the X-Channel-Id header). Without it, campaigns from all your channels are listed
// Response
{
  "success": true,
  "data": {
    "campaigns": [
      {
        "id": "550e8400-e29b-41d4-a716-446655440003",
        "name": "Diwali Offer 2026",
        "status": "COMPLETED",
        "template": "promo_offer",
        "templateCategory": "MARKETING",
        "totalMessages": 5000,
        "sentCount": 5000,
        "deliveredCount": 4850,
        "readCount": 3200,
        "failedCount": 150,
        "estimatedCost": "1000",
        "actualCost": "970",
        "scheduledAt": null,
        "startedAt": "2026-03-10T09:00:05.000Z",
        "completedAt": "2026-03-10T09:12:40.000Z",
        "createdAt": "2026-03-10T09:00:00.000Z"
      }
    ],
    "total": 42,
    "page": 1,
    "pageSize": 20
  }
}

estimatedCost and actualCost are decimal strings without trailing zeros (e.g. "1000", "0.2"), in your wallet's currency. They are what your PayPerWA wallet pays: the PayPerWA fee only (₹0.20 per message on INR wallets, $0.004 on USD wallets). Meta bills its own fee to your WhatsApp Business Account's payment method, so it is not included. actualCost is filled in when the campaign completes (messages sent, delivered or read; failed ones are refunded) and is "0" until then. scheduledAt, startedAt and completedAt are ISO 8601 (UTC) or null.

POST/api/v1/campaigns

Create a one-time campaign from an approved template and, with send: true, send it right away. Without send it is saved as a DRAFT, which you send or schedule from the dashboard. The API has no endpoint to send a draft later and no scheduling field: unknown fields such as scheduledAt are ignored, so send: true always sends immediately.

The campaign sends from the template's channel, to contacts on that channel. X-Channel-Id / channelId are ignored here.

Permission: campaigns:send

Body Fields

FieldTypeRequiredDescription
namestringYesCampaign name (1–200 chars)
templateIdstring (UUID)Yesid of an APPROVED template from GET /api/v1/templates
contactIdsstring[] (UUIDs)One of the threeContact ids from GET /api/v1/contacts
groupIdsstring[] (UUIDs)One of the threeContact group IDs: everyone in any of these groups
tagIdsstring[] (UUIDs)One of the threeTag IDs: everyone with any of these tags
variablesobjectNoTemplate variables. String values only (a number fails validation); see below
sendbooleanNoDefault false (save as DRAFT). true charges your wallet and queues every message now

Audience. Only one list is used, in this order: contactIds, then groupIds, then tagIds. If you send contactIds, any groupIds/tagIds in the same request are ignored. Only contacts that are opted in, not deleted and on the template's channel are messaged; totalContacts in the response is that count. No /api/v1 endpoint returns group or tag IDs (contacts show tag and group names), so to build an audience through the API, collect IDs from GET /api/v1/contacts and pass contactIds.

Variables. Map each placeholder number to a value. The values "name", "phone", "email" and "cf:<key>" (a custom field) are filled in per contact. An empty field becomes "Customer". Any other string is sent as-is to everyone. In the example below, {{1}} is each contact's name and {{2}}/{{3}} are fixed text. For a {{1}} in a TEXT header use the key "header_1". A variable inside a URL button cannot be filled in through this endpoint.

// Request
{
  "name": "March Sale",
  "templateId": "550e8400-e29b-41d4-a716-446655440005",
  "contactIds": [
    "550e8400-e29b-41d4-a716-446655440000",
    "550e8400-e29b-41d4-a716-446655440001"
  ],
  "variables": {
    "1": "name",
    "2": "20%",
    "3": "MARCH20"
  },
  "send": true
}

// Response (201) — sent (marketing template, INR wallet)
{
  "success": true,
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440004",
    "name": "March Sale",
    "status": "SENDING",
    "totalContacts": 2,
    "estimatedCost": {
      "meta_fee": "1.72",
      "platform_fee": "0.40",
      "wallet_deduction": "0.40",
      "total_outlay": "2.12",
      "note": "meta_fee is charged separately by Meta. Only platform_fee is deducted from PayPerWA wallet."
    },
    "message": "Campaign queued. 2 messages will be sent."
  }
}

// Response (201) — send omitted/false: same, with "status": "DRAFT" and no "message"
  • meta_fee: estimate of Meta's charge at India rates (₹0.86 marketing, ₹0.115 utility/authentication per message). Meta bills it to your WhatsApp Business Account, not your wallet.
  • platform_fee: PayPerWA's fee at ₹0.20 per message.
  • wallet_deduction: what is (or, for a draft, would be) taken from your PayPerWA wallet, in your wallet's currency.
  • total_outlay: meta_fee + platform_fee.
  • On USD wallets meta_fee, platform_fee and total_outlay are still INR figures, and wallet_deduction is rounded to 2 decimals. The exact charge is $0.004 × totalContacts.

Charging. With send: true, the whole wallet_deduction is taken from your wallet in one go before any message is queued. It shows as one "API Campaign" entry in Billing. Every message that fails is refunded automatically. Your wallet pays only the PayPerWA fee (₹0.20 per message on INR wallets, $0.004 on USD wallets); Meta bills its own fee to your WhatsApp Business Account's payment method.

Not enough balance does not return 402. The campaign is saved as a DRAFT, nothing is sent or charged, and you still get 201 with success: true. Always check data.status === "SENDING" to know it was sent.

// Response (201) — not enough balance: saved as DRAFT, not sent, not charged
{
  "success": true,
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440004",
    "name": "March Sale",
    "status": "DRAFT",
    "totalContacts": 2,
    "estimatedCost": {
      "meta_fee": "1.72",
      "platform_fee": "0.40",
      "wallet_deduction": "0.40",
      "total_outlay": "2.12",
      "note": "meta_fee is charged separately by Meta to your WhatsApp Business account. Only platform_fee is deducted from your PayPerWA wallet."
    },
    "error": "Insufficient wallet balance to send. Campaign created as DRAFT.",
    "required": "0.40",
    "available": "0.15"
  }
}

Every call creates a new campaign, and there is no idempotency key. Large audiences take a while to answer. Use a generous timeout, and if a request times out, check GET /api/v1/campaigns before retrying, or you may send twice.

Errors

// 400 — no WhatsApp number connected to the account
{ "success": false, "error": "WhatsApp Business Account not connected" }

// 400 — a field failed validation (e.g. an ID that is not a UUID)
{ "success": false, "error": "Validation failed", "details": { "groupIds": ["Invalid UUID"] } }

// 404 — templateId is not one of your templates
{ "success": false, "error": "Template not found" }

// 400 — the template is not approved yet
{ "success": false, "error": "Template must be approved. Current status: PENDING" }

// 400 — no contactIds / groupIds / tagIds (or all empty)
{ "success": false, "error": "Provide at least one of: groupIds, tagIds, or contactIds" }

// 400 — nobody in the audience is opted in, not deleted and on the template's channel
{ "success": false, "error": "No opted-in contacts found in the selected audience" }

// 409 — send: true, but WhatsApp API access is off for the number (the campaign is still saved as a DRAFT)
{
  "success": false,
  "error": "WhatsApp API is OFF for this number. The connected token cannot access this WhatsApp Business Account (expired token, missing permissions, or the WABA was removed). Reconnect WhatsApp from Settings → WhatsApp Channels, then try again. You can still save drafts.",
  "code": "META_API_OFF"
}

Example

curl -X POST https://payperwa.com/api/v1/campaigns \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  --max-time 120 \
  -d '{
    "name": "March Sale",
    "templateId": "YOUR_TEMPLATE_ID",
    "contactIds": ["CONTACT_ID_1", "CONTACT_ID_2"],
    "variables": { "1": "name", "2": "20%", "3": "MARCH20" },
    "send": true
  }'
# Sent only if the response has "status": "SENDING"

Wallet / Balance

GET/api/v1/balance

Check your current wallet balance. Amounts are in your wallet's currency, INR or USD, given in currency. The wallet pays only the PayPerWA fee (₹0.20 per message on INR wallets, $0.004 on USD wallets). Meta bills its own fee to your WhatsApp Business Account's payment method, so that fee never comes out of this balance.

Permission: balance:read

// Response
{
  "success": true,
  "data": {
    "balance": 4520.6,
    "currency": "INR"
  }
}

balance is a JSON number with up to 4 decimal places (e.g. 12.3456 on a USD wallet). An account with no wallet yet returns 0 and "INR".

Example

curl https://payperwa.com/api/v1/balance \
  -H "Authorization: Bearer YOUR_API_KEY"

Webhooks

PayPerWA can POST a signed JSON event to your server when a customer messages you, when WhatsApp reports a message as delivered, read or failed, when you send a message through the API, and when a campaign finishes.

Add an Endpoint

  • Go to Dashboard → Settings → API → Webhook Events, enter your endpoint URL and tick the events it should receive. Webhooks are managed in the dashboard only; there is no API endpoint for them.
  • The URL must start with https://. You can add up to 5 endpoints, and each one receives its events for all of your WhatsApp numbers.
  • Each endpoint has its own signing secret: whsec_ followed by 48 hex characters. Reveal or copy it on the Webhooks page at any time.
  • You can pause, resume or delete an endpoint. Its URL and events can't be edited: to change them, or to get a new secret, delete the endpoint and add it again.

Webhook Payload

Every event is a POST with the same JSON envelope: event (the event name), timestamp (when PayPerWA sent it, ISO 8601 UTC) and data, whose fields are camelCase. To match an event to a message you sent, compare data.wamid with the message_id returned by POST /api/v1/messages/send.

// message.sent: a template sent with POST /api/v1/messages/send
{
  "event": "message.sent",
  "timestamp": "2026-11-09T10:15:00.214Z",
  "data": {
    "messageId": "c644e8be-cf13-4b2f-887a-76e6ad45ac2d",
    "campaignId": null,
    "contactPhone": "+919876543210",
    "contactName": "Asha",
    "wamid": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgSNEQ5RTI3QjA3RjE0RDBBNzAA",
    "status": "sent",
    "templateName": "otp_login",
    "sentFrom": "api",
    "sentAt": "2026-11-09T10:15:00.198Z"
  }
}

// message.delivered: the same message, delivered
{
  "event": "message.delivered",
  "timestamp": "2026-11-09T10:15:02.418Z",
  "data": {
    "messageId": "c644e8be-cf13-4b2f-887a-76e6ad45ac2d",
    "campaignId": null,
    "contactPhone": "+919876543210",
    "contactName": "Asha",
    "wamid": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgSNEQ5RTI3QjA3RjE0RDBBNzAA",
    "status": "delivered",
    "sentFrom": "api",
    "errorCode": null,
    "errorMessage": null,
    "sentAt": null,
    "deliveredAt": "2026-11-09T10:15:01.000Z",
    "readAt": null,
    "failedAt": null
  }
}

// message.failed: a campaign message WhatsApp could not deliver
{
  "event": "message.failed",
  "timestamp": "2026-11-09T10:40:12.903Z",
  "data": {
    "messageId": "05cf4de6-9776-46be-8320-8881182cc050",
    "campaignId": "9724bf78-6e9f-4872-b7c4-e0da86c325e2",
    "contactPhone": "+919812345678",
    "contactName": "Ravi",
    "wamid": "wamid.HBgMOTE5ODEyMzQ1Njc4FQIAERgSMjQxQUIzQzRENUU2RjcwODkA",
    "status": "failed",
    "errorCode": "131049",
    "errorMessage": "This message was not delivered to maintain healthy ecosystem engagement.",
    "failedAt": "2026-11-09T10:40:11.000Z"
  }
}

// message.received: a customer messaged you
{
  "event": "message.received",
  "timestamp": "2026-11-09T10:20:44.031Z",
  "data": {
    "from": "+919876543210",
    "contactName": "Asha",
    "type": "text",
    "content": "Is my order shipped?",
    "wamid": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAEhgUM0E4RkQ2QjYyQjVGMzQ5MkE0RDQA",
    "messageId": "84c440c1-f964-4ee3-aa72-e396931d884c",
    "conversationId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
    "channelId": "1e2d3c4b-5a69-4788-9a0b-c1d2e3f4a5b6"
  }
}

// campaign.completed
{
  "event": "campaign.completed",
  "timestamp": "2026-11-09T11:02:10.775Z",
  "data": {
    "campaignId": "9724bf78-6e9f-4872-b7c4-e0da86c325e2",
    "campaignName": "Diwali Offer",
    "totalMessages": 1000,
    "sentCount": 980,
    "deliveredCount": 951,
    "readCount": 402,
    "failedCount": 20
  }
}

Events

EventSent when
message.receivedA customer sends a message to any of your WhatsApp numbers (text, media, a button or list reply, a location, a reaction and so on).
message.sentWhatsApp accepts a template, text or media message you sent with POST /api/v1/messages/send, or someone types a message in the WhatsApp Business app on a coexistence number. Campaign, Inbox and chatbot messages don't trigger it; follow those with the delivered, read and failed events.
message.deliveredWhatsApp reports the message delivered to the recipient's phone. Covers API, campaign, Inbox and chatbot messages.
message.readThe recipient opened the message. WhatsApp reports this only when the recipient has read receipts turned on.
message.failedWhatsApp accepted the message but then couldn't deliver it. The PayPerWA fee charged for it (₹0.20 or $0.004) goes back to your wallet. A send that fails straight away (the API call returns an error, or WhatsApp rejects a campaign message as it is sent) doesn't trigger this event: check the API response or the campaign report instead.
campaign.completedShortly after a campaign finishes sending. The counts are a snapshot from that moment, so deliveries and reads that arrive later aren't in them. It fires again for the same campaign after failed messages are re-sent. A campaign whose contacts were all skipped as recent duplicates completes without this event.

Event Data Fields

Status events are message.sent, message.delivered, message.read and message.failed. Which fields appear depends on where the message came from, so read them defensively and ignore fields you don't know.

FieldEventsDescription
messageIdStatus events, message.receivedPayPerWA's ID for the message. For a text or media message sent through the API it equals inbox_message_id in the send response.
wamidStatus events, message.receivedWhatsApp's message ID. For messages you send it is the message_id returned by the send API.
campaignIdStatus events, campaign.completedThe campaign's ID, or null when the message isn't part of a campaign.
contactPhoneStatus eventsThe recipient's number in E.164 format, e.g. +919876543210.
contactNameStatus events, message.receivedThe contact's name in PayPerWA (the number itself if no name was saved), or null.
statusStatus eventssent, delivered, read or failed.
sentFromStatus eventsWhere the message came from: api, campaign, inbox, chatbot, or whatsapp_app (typed in the WhatsApp Business app on a coexistence number). Left out of message.failed for campaign messages; campaignId identifies those.
sentAt, deliveredAt, readAt, failedAtStatus eventsWhen the status happened (ISO 8601 UTC). Only the field for the current event is filled; the others are null or left out.
errorCode, errorMessagemessage.failedWhatsApp's error code, as a string such as 131026, and its error title. null or left out on other events.
templateNamemessage.sentThe template name, for a template sent through the API.
type, text, captionmessage.sentFor a text or media message sent through the API: type is text, image, video, document or audio; text is the message text and caption the media caption (null when not used). Messages typed in the WhatsApp Business app carry type and text.
frommessage.receivedThe customer's number in E.164 format.
typemessage.receivedtext, image, video, document, audio, sticker, location, contacts, button, interactive, flow_response, reaction, order, referral, system, request_welcome or unsupported (other WhatsApp types are passed through).
contentmessage.receivedThe message text, or a summary for other types: the button or list title for a reply, the WhatsApp media ID for media, the coordinates for a location. At most 4,000 characters.
conversationIdmessage.receivedThe PayPerWA Inbox conversation ID.
channelIdmessage.receivedThe ID of the WhatsApp number that received it (the id from GET /api/v1/channels), or null on accounts set up before multi-number support.
campaignNamecampaign.completedThe campaign's name.
totalMessages, sentCount, deliveredCount, readCount, failedCountcampaign.completedThe campaign's counters when it completed.

Request Headers

HeaderValue
Content-Typeapplication/json
User-AgentPayPerWA-Webhooks/1.0
X-PayPerWA-EventThe event name, the same as event in the body
X-PayPerWA-TimestampWhen the request was sent, in Unix seconds (e.g. 1794219302). Not covered by the signature.
X-PayPerWA-Signaturesha256= followed by the lowercase hex HMAC-SHA256 of the raw request body

Verifying Webhooks

Compute an HMAC-SHA256 of the raw request body (the exact bytes received, before any JSON parsing) with your endpoint's secret as the key, put sha256= in front of the hex digest, and compare it with the X-PayPerWA-Signature header using a constant-time comparison. Use the whole secret, including whsec_, as the key; don't strip or decode it. Reply 401 if it doesn't match. Only the body is signed, so to stop replayed requests, check the body's timestamp (for example, refuse events older than 5 minutes) and skip events you have already handled.

# Test your endpoint: send it a correctly signed sample event.
SECRET='whsec_your_webhook_secret'
BODY='{"event":"message.delivered","timestamp":"2026-11-09T10:15:02.418Z","data":{"messageId":"c644e8be-cf13-4b2f-887a-76e6ad45ac2d","campaignId":null,"contactPhone":"+919876543210","contactName":"Asha","wamid":"wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgSNEQ5RTI3QjA3RjE0RDBBNzAA","status":"delivered","sentFrom":"api","errorCode":null,"errorMessage":null,"sentAt":null,"deliveredAt":"2026-11-09T10:15:01.000Z","readAt":null,"failedAt":null}}'
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')"

curl -i https://your-server.com/webhooks/payperwa \
  -H "Content-Type: application/json" \
  -H "User-Agent: PayPerWA-Webhooks/1.0" \
  -H "X-PayPerWA-Event: message.delivered" \
  -H "X-PayPerWA-Timestamp: $(date +%s)" \
  -H "X-PayPerWA-Signature: $SIG" \
  --data-binary "$BODY"

The cURL tab signs a sample event with your secret and sends it to your endpoint, so you can test your receiver.

Delivery, Timeouts and Retries

  • Each event is sent once to every active endpoint subscribed to it. Any 2xx response within 8 seconds counts as delivered.
  • Failed deliveries are not retried. If your endpoint times out, returns another status or can't be reached, that event is lost, and the dashboard keeps no delivery log. Reply 200 first and do slow work in the background. Campaign totals can be read again at any time with GET /api/v1/campaigns.
  • Register the exact final URL. A 301 or 302 redirect turns the request into a GET without a body, and the event is lost.
  • Events can arrive out of order, and a status can be skipped: if WhatsApp's read report is processed before its delivered report, you get only message.read. Treat sent → delivered → read as moving forward only, and failed as final.
  • Events that happen while an endpoint is paused are not queued or sent later.

Error Codes

Errors return "success": false and a human-readable error. Many also carry a fixed code. Base your handling on the HTTP status and code, not on the wording of error. Validation errors add a details object keyed by field.

{
  "success": false,
  "error": "Validation failed",
  "details": {
    "to": ["Too small: expected string to have >=10 characters"]
  }
}
StatuscodeWhen it happens (error text)
400—Validation failed: a required field is missing or has the wrong type or length. details names the field. On /otp/send and /otp/verify, problems with the body as a whole (such as an unknown field) are listed under details._errors.
400—The request body must be JSON: POST /otp/send or POST /otp/verify with a body that is not valid JSON.
400OTP_TEMPLATE_REQUIREDPOST /otp/send without template_name, and you have no approved Authentication template (on that channel, if you gave one). Create one in Dashboard → Templates → New → Authentication.
400OTP_TEMPLATE_NOT_AUTHENTICATIONPOST /otp/send with a template_name whose approved template is not an Authentication one. WhatsApp allows one-time codes only in Authentication templates.
400—Invalid channelId: the X-Channel-Id header (or channelId) is not one of your connected numbers.
400—POST /messages/send with neither template_name nor type (Provide template_name for a template message, or type: …), or with an unsupported type (Unknown type '…'. Use template, text, image, video, document or audio.).
400OWN_NUMBERThe recipient is the WhatsApp number you are sending from. Test with a different number.
400MEDIA_REJECTEDMeta could not fetch or accept the file of a free-form image, video, document or audio message. The URL may not be public, or the file type or size is not supported.
400—The template has an IMAGE, VIDEO or DOCUMENT header and no media was given (Template requires a … header. Pass "image_url" …), or the URL is not https (image_url must be a public https:// URL). The same applies to document_url and video_url.
400—No WhatsApp number is connected (WhatsApp Business Account not connected…), or a free-form message's conversation is not attached to a connected number.
400—POST /campaigns: Template must be approved. Current status: …, Provide at least one of: groupIds, tagIds, or contactIds, No opted-in contacts found in the selected audience or Pricing unavailable for N/M recipients — please contact support before sending.
400—GET /messages/window: Query parameter 'to' must be a phone number with country code (10–15 digits).
400—POST /contacts: Invalid phone number format. Use E.164 format: +919876543210. This response has no success field.
401—Missing or invalid Authorization header. Use: Bearer <api_key>, Invalid API key format, Invalid API key, API key is deactivated or API key has expired.
402INSUFFICIENT_WALLET_BALANCEPOST /messages/send or POST /otp/send: your wallet cannot cover PayPerWA's fee (₹0.20, or $0.004 on USD wallets). The response adds required and available (strings in your wallet currency's decimals: 2 for INR, 4 for USD; always 4 if another request used the balance between the check and the charge), currency and breakdown. Recharge at Dashboard → Billing. Meta's fee is billed to your WhatsApp Business Account and never taken from the wallet, so a problem with your Meta payment method does not cause a 402. A template send then still succeeds, with a delivery_warning in the response. POST /campaigns with "send": true never returns 402. It answers 201 with "success": true, "status": "DRAFT" and the reason in data.error.
403—Missing required permission: <permission> (see Permissions) or Account is suspended.
403CONTACT_OPTED_OUTThis contact has opted out of messages.: a free-form message to a contact who has opted out, or a MARKETING or UTILITY template (POST /messages/send) to a number that one of your contacts opted out on. Refused before anything is charged or sent. AUTHENTICATION templates and POST /otp/send are not refused.
404—Contact not found, Template '<name>' not found or not approved (plus on this channel when you passed a channel) or Template not found (POST /campaigns). An unknown URL returns an HTML 404 page, not JSON.
405—The endpoint exists but not with this HTTP method. The body is empty.
409OUTSIDE_24H_WINDOWFree-form text or media sent outside the 24-hour customer service window. Send a template instead. The response adds window and hint.
409META_API_OFFYour WhatsApp connection can no longer reach your WhatsApp Business Account (expired token, missing permissions or a removed WABA). Reconnect in Dashboard → Settings → WhatsApp. On POST /campaigns with "send": true, the campaign has already been saved as a DRAFT.
409—A contact with this phone number already exists on this channel (POST /contacts with "upsert": false; by default it updates the existing contact instead) or Another contact with this phone number exists (PUT /contacts/:id).
429—Rate limit exceeded. Try again later. See Rate Limits.
429OTP_COOLDOWNPOST /otp/send: a code went to this number less than 30 seconds ago. With retry_after_seconds and a Retry-After header, like the next two. Nothing is charged. See Limits per number.
429OTP_TOO_MANY_SENDSPOST /otp/send: 5 codes already went to this number in the last hour.
429OTP_TOO_MANY_FAILED_CHECKSPOST /otp/send: 15 wrong codes were entered for this number in the last 24 hours, so no new code is sent to it yet.
503OTP_UNAVAILABLEPOST /otp/send or POST /otp/verify: Verification is temporarily unavailable. Nothing was sent or charged — try again shortly. Retry after a short wait.
Meta's status—WhatsApp API error: Meta rejected the message. The HTTP status is Meta's (usually 400), and details holds Meta's own error object (for example details.error.code). The wallet charge for that message is refunded automatically. A 401 or 403 with this error is about your WhatsApp connection, not your API key.
500—Internal server error (or Failed to send message, Failed to list channels, Failed to check the messaging window, Failed to send the verification code, Failed to verify the code). On the other endpoints a request body that is not valid JSON also returns 500 today; the OTP endpoints answer 400 instead. Contact support if it persists.

Permissions

Every key created in Dashboard → Settings → API has all seven permissions below. The dashboard has no option to narrow them. If a key lacks the permission an endpoint needs, the request gets 403 with Missing required permission: <permission>.

PermissionGrants Access To
contacts:readGET /api/v1/contacts, GET /api/v1/contacts/:id
contacts:writePOST /api/v1/contacts, PUT /api/v1/contacts/:id, DELETE /api/v1/contacts/:id
templates:readGET /api/v1/templates
messages:sendPOST /api/v1/messages/send (templates and free-form text/media), GET /api/v1/messages/window, POST /api/v1/otp/send, POST /api/v1/otp/verify
campaigns:readGET /api/v1/campaigns
campaigns:sendPOST /api/v1/campaigns. Needed both to create a draft ("send": false) and to send ("send": true).
balance:readGET /api/v1/balance
NoneGET /api/v1/channels (any valid key)

Ready to Integrate?

Sign up, grab your API key, and start sending WhatsApp messages in minutes.

Get Your API Key
Chat with us