Build notes · engineering
WhatsApp payments with Razorpay: what the docs leave out
Short answer. You can collect payments inside a WhatsApp thread in India using Meta's
order_details interactive message. It is INR only. total_amount uses an offset
of 100, reference_id caps at 35 characters, and payment_settings must be an
array containing a payment link generated by your own gateway. A bare UPI VPA is rejected. The customer
pays your gateway directly, so the money never passes through your messaging vendor.
We built this for Klaros and then used it to sell Klaros. Everything below is from that implementation, including the parts that cost us an afternoon. The vendor documentation is accurate but written to describe a happy path. These are the failure states.
On this page
- The payload that actually sends
- Why a bare UPI VPA is rejected
- The INR-only rule, and how it fails
- Payment links or subscriptions in a thread
- Do your notes reach the webhook
- The signature check that rejects everything
- Do you need a BSP for any of this
- What the flow costs to run
- Questions we had to answer
1. The payload that actually sends
A payment request in WhatsApp is an interactive message of type order_details. It renders as
a review-and-pay card: line items, a total, and a button. This is the shape that passes validation:
{
"type": "order_details",
"body": { "text": "Payment for your order (2 items)" },
"action": {
"name": "review_and_pay",
"parameters": {
"reference_id": "ord_9f2c1a",
"type": "digital-goods",
"payment_type": "upi",
"payment_settings": [
{ "type": "payment_link", "payment_link": { "uri": "https://rzp.io/i/xxxxx" } }
],
"currency": "INR",
"total_amount": { "value": 249900, "offset": 100 },
"order": {
"status": "pending",
"items": [
{ "retailer_id": "sku_1", "name": "Growth Cloud", "amount": { "value": 249900, "offset": 100 }, "quantity": 1 }
],
"subtotal": { "value": 249900, "offset": 100 }
}
}
}
}
value: 249900 with offset: 100 is ₹2,499.
Every amount in the payload uses the same convention, including each line item and the subtotal. If your
totals are out by a factor of a hundred, this is why.
2. Why a bare UPI VPA is rejected
The intuitive version of this API does not exist. You cannot hand Meta a UPI VPA and ask it to collect.
action.parameters.payment_settings must be an array, and each entry names a mechanism. The
practical one is a payment link minted by your gateway:
"payment_settings": [
{ "type": "payment_link", "payment_link": { "uri": "<checkout URL from your PSP>" } }
]
A payload carrying payment_type or payment_configuration and no
payment_settings array will fail validation on every send. This is worth writing a schema
check for on your own side, because the error you get back does not point at the missing field with much
enthusiasm.
The consequence is architectural and worth sitting with: the payment link comes from your gateway, so the money moves between your customer and your merchant account. Your messaging platform is carrying an instruction, not custody. That is the correct shape, and it is worth checking that whoever you are building on has not inserted themselves into it.
3. The INR-only rule, and how it fails
The India payments flow supports one currency. Not "supports INR best", not "defaults to INR". The only supported value is INR.
The failure mode is unkind. A product priced in dollars will build into a completely well-formed
order_details payload. Your code will be pleased with itself. Meta will then reject it, and
the customer sees nothing useful. We hit this the moment we listed a USD product alongside rupee ones.
Validate before you assemble, and say something a human can act on:
const currency = order.currency || 'INR';
if (currency !== 'INR') {
return {
ok: false,
error: `WhatsApp payment requests (UPI) support INR only. This order is in ${currency}. `
+ 'Send a payment link as a normal message instead.',
};
}
4. Payment links or subscriptions in a thread
Two different primitives, and picking wrong is expensive later. A Razorpay order is not one of them: an order expects a browser-side checkout to attach to, which you do not have in a chat.
| You are selling | Use | Because |
|---|---|---|
| A one-off or flat annual price | Payment Links API | Returns a self-contained short_url that works pasted into a thread. Your notes come back on the webhook. |
| A recurring plan | Subscriptions API | Also returns a short_url, and establishes a real mandate instead of a single charge. Requires a plan created in advance. |
| Anything not in INR | A plain link, not order_details |
The in-chat payment card cannot carry it. Send the link as a normal message. |
5. Do your notes reach the webhook
This is the question that decides whether provisioning can be automatic. You need to know, on payment, which customer bought which thing, and the only thing travelling with the money is whatever you attached at creation.
Attach identifiers as notes when you create the link or subscription, then read them back
off whichever entity the webhook delivers. Do not assume a single location: depending on the event, the
notes ride on the payment link entity, the payment entity, or the subscription entity.
const notes =
body?.payload?.payment_link?.entity?.notes ||
body?.payload?.payment?.entity?.notes ||
body?.payload?.subscription?.entity?.notes ||
null;
if (notes?.sale_id) { /* provision now */ }
Get this right and a purchase can complete itself. Ours does: a self-hosted licence bought over WhatsApp is signed and delivered into the same thread, with no account and no human, because the webhook knows exactly what was bought and by whom.
6. The signature check that rejects everything
Verify the webhook with HMAC-SHA256 over the raw request body, keyed by the webhook secret, and
compare to the x-razorpay-signature header. Two traps.
The first is verifying against a re-serialized object rather than the exact bytes you received. Any difference in key order or whitespace changes the digest, and nothing about the failure tells you why.
The second is argument order, and it is the one that cost us. A helper declared
(payload, signature, secret) called as (secret, body, signature) compiles,
runs, returns a clean boolean, and rejects every legitimate event forever. There is no error, no alert,
and no traffic in your database to suggest anything is wrong. It looks exactly like nobody has bought
anything yet.
If signatures are failing and your argument order is right, the causes are almost always one of these, in rough order of how often they catch people:
| Symptom | Cause |
|---|---|
| Every event fails, from day one | The webhook secret is not the API key secret. They are different values, and Razorpay's webhook secret is whatever you set when creating the webhook. |
| Every event fails, starting recently | Somebody rotated the webhook secret. Retries of older events must be validated against the secret that was active when they were generated. |
| Fails intermittently, or only for some payloads | You are hashing a re-serialized object rather than the raw body. Key order and whitespace change the digest, so it works until a payload's shape differs. |
| Passes locally, fails in production | A framework or proxy consumed and re-encoded the body before your handler saw it. Capture the raw text first, then parse. |
7. Do you need a BSP for any of this?
No, and it is worth being precise about why, because "you need a provider" is repeated often enough to feel like a technical fact.
The Cloud API is Meta-hosted and open to businesses directly. A Business Solution Provider is a commercial choice, not a technical prerequisite. What a BSP genuinely sells you is the work: webhook infrastructure, template lifecycle, retry and error handling, consent records, and somebody to call at two in the morning. That is real work, and for many teams paying for it is the right call.
What you are also buying, on most of them, is a position between you and Meta's billing. That is the part worth deciding deliberately rather than by default, particularly for payment flows, where the message count per rupee collected is high. We built directly against the Cloud API and our own gateway, which is why every payload above is from a working implementation rather than a docs page.
8. What the flow costs to run
Worth doing the arithmetic before you build, because a payment flow is not one message. It is the request, usually a reminder, a confirmation, and often a receipt. Four messages per collection, and every one is billable.
Meta charges for the conversation. Some platforms then charge you again for carrying it. Twilio, for example, publishes a fee of $0.005 per message, inbound or outbound, applied on top of Meta's rates. On a payment sequence that fee lands four times per collection, on the flow most directly attached to your revenue. At a million messages a month the markup alone is five thousand dollars.
There is nothing hidden about it, and for many teams it is a fair trade for the abstraction. It is worth knowing which model you are on before your payment volume makes the decision expensive. We wrote up the comparison in detail, including where Twilio is the better answer.
Klaros takes the other position, structurally rather than promotionally: you connect your own WhatsApp Business Account, Meta bills you directly at Meta's rate, and we are never in the payment path. Your gateway link carries the money, your WABA carries the message, and we carry neither. Flat licence, no per-message fee.
9. Questions we had to answer
Can you collect payments inside a WhatsApp chat in India?
Yes, with the order_details interactive message. INR only, total_amount at an
offset of 100, reference_id up to 35 characters, and the customer pays your gateway, not
your messaging platform.
Why does my order_details message fail with a UPI VPA?
Meta's schema does not accept one. payment_settings must be an array containing an entry
such as { type: 'payment_link', payment_link: { uri } }, where the URI comes from your
payment provider.
Can a WhatsApp payment request be in USD?
No. The flow is India-only and INR-only. Validate currency in your own code, because a non-INR payload builds successfully and is rejected only at Meta.
Payment link or subscription for recurring billing?
Subscription, so you get a mandate rather than a single charge. Payment links suit one-off and flat annual purchases, and their notes survive to the webhook.
Do I need a BSP to do any of this?
No. This is the Cloud API and your own gateway. We implemented it directly against both. A BSP can be worth paying for support and abstraction, but it is not a technical requirement, and it is worth knowing that before it becomes a per-message line item.
That is the thread running through all of it. A rejected payload, a payment that never provisions, a webhook that silently drops every event: none of these produce a complaint. They produce a customer who assumes the business is disorganised, and who does not come back. The distance was created by an integration detail, and it is felt as a judgement about you.
See the whole flow working, from the buying side
We built this and then sold with it. Message our line and type pricing: you get the live catalog as a WhatsApp list, real remaining slots, and a payment link on whatever you tap. Buy the self-hosted licence and the signed key arrives in the same thread. You are watching the implementation in this article do its job, from the customer's seat.
+91 97893 77634 · you message first, so nothing reaches you without your say-so.
Written 31 July 2026 against WhatsApp Cloud API v25.0 and the Razorpay API. We append when the platforms change. Related: what we found auditing our own billing, Klaros compared with Twilio, the REST API, all build notes.
