If you run a D2C brand in India, you already know the number that keeps you up at night: RTO — return to origin. COD orders that never get delivered. Industry-wide, 20–35% of COD orders bounce; for first-order-heavy fashion brands it's often worse. Each fake delivery costs you forward shipping + reverse logistics + packaging + dead ad spend. At ₹80 forward + ₹60 reverse, a ₹499 order that RTOs is a ₹140 loss before you count the customer acquisition cost you just set on fire.
COD accounts for a huge share of Indian e-commerce because of trust — customers want to see the product before paying. You can't kill COD. But you can confirm it before dispatch, and brands that do routinely cut RTO by 30–40%.
WhatsApp is the obvious confirmation channel: ~500M users in India, 90%+ read rates within minutes, and — thanks to utility templates — a confirmation message costs you about ₹0.115 (often free if the customer recently messaged you inside the 24-hour service window).
In this post we build the whole thing: Shopify COD order → WhatsApp message with ✅ Confirm / ❌ Cancel buttons → webhook parses the tap → auto-cancel or release for shipping. ~250 lines of Python, runs on a ₹300/month VPS.
Why button messages, not typed replies
Two designs are common:
- "Reply YES to confirm" — requires the customer to type; needs NLP/substring parsing; higher drop-off.
-
Interactive reply buttons — one tap, structured payload in the webhook (
interactive.button_reply.payload). No parsing ambiguity, ~2× the response rate in my experience, and the button tap opens the 24-hour service window, making every follow-up message you send free.
We're building option 2. The message the customer sees:
┌────────────────────────────────────┐
│ Order #1042 at FabThreads │
│ │
│ You placed a Cash-on-Delivery │
│ order of ₹1,299. Please confirm │
│ within 24 hours to avoid │
│ automatic cancellation. │
│ │
│ [ ✅ Yes, Confirm ] [ ❌ Cancel ] │
└────────────────────────────────────┘
Prerequisites
- WhatsApp Business API via Meta's Cloud API (or any BSP that supports interactive buttons — WATI/Interakt/AiSensy all expose them; I use raw Cloud API to avoid per-message markup)
- A permanent system-user token + phone number ID
- A Shopify store (or any e-commerce backend — the webhook section translates easily)
- A public HTTPS endpoint for webhooks (
ngrok http 8000while developing)
One Meta policy note that trips everyone up: business-initiated messages outside the 24h window must be approved templates — and templates cannot contain dynamic reply buttons... with one important exception pattern: you send an approved utility template announcing the order, and follow with the interactive buttons inside the service window the customer opens by replying. Modern workaround that keeps everything template-compliant and clickable: include the CTA in the template as a copy-code / deep-link URL button, OR — the approach we use — send the template with two CTA URL buttons pointing at signed confirm/cancel links on your server:
[ ✅ Confirm Order ] → https://bot.yourbrand.in/c/ORD1042?t=<hmac>
[ ❌ Cancel Order ] → https://bot.yourbrand.in/x/ORD1042?t=<hmac>
One tap opens a instant page that fires the action and replies on WhatsApp. Zero typing, fully template-compliant, works on day one with no service window. (Once the window opens, everything downstream is free-form and unlimited.)
Step 1: The confirmation template
Create in WhatsApp Manager → Message templates → category Utility (it's an order-confirmation message — categorizing it as Marketing would cost 7.5× more per send):
Name: cod_confirm_v1
Body: Hi {{1}}! You placed a COD order #{{2}} worth ₹{{3}} with FabThreads.
Please confirm within 24 hours so we can dispatch today.
If we don't hear from you, the order will be auto-cancelled — no charge to you.
Buttons: [URL] ✅ Confirm Order [URL] ❌ Cancel Order
The URL buttons use variables so the same approved template serves every order.
Step 2: Shopify → trigger
Add a webhook for orders/create (Admin → Settings → Custom webhooks, or via Admin API) pointing at your server. Filter COD in the handler:
# app.py (FastAPI)
import hmac, hashlib, time, os
from fastapi import FastAPI, Request, BackgroundTasks
app = FastAPI()
SECRET = os.environ["SHOPIFY_WEBHOOK_SECRET"]
def shopify_ok(body: bytes, header: str) -> bool:
digest = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(digest, header)
@app.post("/shopify/orders-create")
async def order_created(req: Request, bg: BackgroundTasks):
body = await req.body()
if not shopify_ok(body, req.headers.get("X-Shopify-Hmac-Sha256", "")):
return {"error": "bad hmac"}
import json
order = json.loads(body)
if order.get("financial_status") != "pending" or \
order.get("payment_method", "").lower() != "cash on delivery":
return {"skipped": "not COD"}
bg.add_task(start_cod_flow, order) # don't block Shopify's 30s webhook budget
return {"ok": True}
Step 3: Send the confirmation
# whatsapp.py
import requests, os, time, hmac
GRAPH = "https://graph.facebook.com/v23.0"
TOKEN, PHONE_ID = os.environ["WA_TOKEN"], os.environ["WA_PHONE_ID"]
BASE = "https://bot.yourbrand.in"
def sign(order_id: str, action: str) -> str:
"""Tamper-proof link so nobody curls /x/ORDER and cancels strangers' orders."""
exp = int(time.time()) + 86400
msg = f"{order_id}:{action}:{exp}"
sig = hmac.new(os.environ["LINK_SECRET"].encode(),
msg.encode(), hashlib.sha256).hexdigest()[:16]
return f"{sig}.{exp}"
def send_cod_confirm(order):
oid = order["name"].lstrip("#")
phone = order["customer"]["phone"].replace("+", "")
total = f"{float(order['total_price']):,.0f}"
name = order["customer"].get("first_name", "there")
payload = {
"messaging_product": "whatsapp",
"to": phone,
"type": "template",
"template": {
"name": "cod_confirm_v1",
"language": {"code": "en"},
"components": [
{"type": "body", "parameters": [
{"type": "text", "text": name},
{"type": "text", "text": oid},
{"type": "text", "text": total},
]},
{"type": "buttons", "buttons": [
{"type": "url", "url": f"{BASE}/c/{oid}?t={sign(oid,'confirm')}",
"text": "✅ Confirm Order"},
{"type": "url", "url": f"{BASE}/x/{oid}?t={sign(oid,'cancel')}",
"text": "❌ Cancel Order"},
]},
],
},
}
r = requests.post(f"{GRAPH}/{PHONE_ID}/messages", json=payload,
headers={"Authorization": f"Bearer {TOKEN}"}, timeout=15)
r.raise_for_status()
return r.json()["messages"][0]["id"]
Note the import hashlib — it's needed in both sign() and verify_token() below.
Step 4: The tap endpoints
Each button is a GET the customer's phone hits automatically:
# actions.py (continued in app.py — same imports as above)
import sqlite3
def db(): return sqlite3.connect("cod.db")
def verify_token(oid: str, action: str, t: str) -> bool:
try:
sig, exp = t.split(".")
if int(exp) < time.time(): return False
expected = hmac.new(os.environ["LINK_SECRET"].encode(),
f"{oid}:{action}:{exp}".encode(),
hashlib.sha256).hexdigest()[:16]
return hmac.compare_digest(sig, expected)
except Exception:
return False
@app.get("/c/{oid}")
def confirm(oid: str, t: str, bg: BackgroundTasks):
if not verify_token(oid, "confirm", t): return bad_link_page()
mark(oid, "confirmed")
bg.add_task(shopify_tag, oid, "COD-CONFIRMED") # release for packing
bg.add_task(send_whatsapp_text, oid,
f"🎉 Confirmed! Order #{oid} is being packed. Track here: {BASE}/t/{oid}")
return thanks_page("confirmed")
@app.get("/x/{oid}")
def cancel(oid: str, t: str, bg: BackgroundTasks):
if not verify_token(oid, "cancel", t): return bad_link_page()
mark(oid, "customer_cancelled")
bg.add_task(shopify_cancel, oid) # refund COD = just cancel; keeps RTO out of your stats
bg.add_task(send_whatsapp_text, oid,
f"Order #{oid} cancelled — no charge. Changing your mind? Redeem 10% off: {BASE}/10OFF")
return thanks_page("cancelled")
Why auto-cancel instead of shipping anyway? Because a customer-cancelled order never enters your RTO metrics and never pays reverse logistics. You've converted a ₹140 loss into a ₹0.115 message — and a discount-link follow-up recovers a meaningful slice of cancels into prepaid reorders.
Step 5: The reminder + expiry sweep
Confirmation rates look like this: ~55% reply within 2 hours, ~15% after an evening reminder, ~10% never. Automate both:
# sweep.py — run every 15 min via cron
import datetime as dt
def sweep():
con = db()
now = dt.datetime.utcnow()
# 1) reminder for orders unconfirmed after 3h (2nd utility template — ~₹0.115)
due = con.execute("""SELECT order_id FROM orders
WHERE status='awaiting' AND sent_at < ?""",
(now - dt.timedelta(hours=3),)).fetchall()
for (oid,) in due:
send_cod_reminder(oid); con.execute(
"UPDATE orders SET status='reminded' WHERE order_id=?", (oid,))
# 2) auto-cancel after 24h of silence
expired = con.execute("""SELECT order_id FROM orders
WHERE status IN ('awaiting','reminded')
AND sent_at < ?""",
(now - dt.timedelta(hours=24),)).fetchall()
for (oid,) in expired:
shopify_cancel(oid)
send_whatsapp_text(oid, f"Order #{oid} was auto-cancelled as we "
f"didn't hear from you. No charge was made.")
con.execute("UPDATE orders SET status='expired' WHERE order_id=?", (oid,))
con.commit()
The 24-hour auto-cancel is also a policy lever: showing "confirm within 24 hours or auto-cancel" in the message sets urgency and creates a fair, non-confrontational default. Customers who never respond were ~90% likely to RTO anyway.
Step 6: Free-form replies inside the window
When a customer types anything back ("price kitna tha?", "change address"), the service window opens — handle it cheaply:
@app.post("/wa-webhook")
async def wa(req: Request):
body = await req.json()
try:
msg = body["entry"][0]["changes"][0]["value"]["messages"][0]
except (KeyError, IndexError):
return {"ok": True} # statuses/deliveries — log separately
if "text" in msg:
phone = msg["from"]
reply = msg["text"]["body"].lower()
if any(w in reply for w in ["cancel", "रद्द", "nahi", "mat karo"]):
oid = latest_order_for(phone)
if oid: shopify_cancel(oid); send_whatsapp_text(
oid, f"Done — order #{oid} cancelled.")
elif any(w in reply for w in ["address", "change"]):
hand_to_human(phone) # don't trap people; escalate
else:
send_whatsapp_text_from(phone,
"Thanks! Our team will get back to you shortly. 👋")
return {"ok": True}
All of that is free — service messages cost nothing. Save it for an article on its own.
Making it multilingual
India converts on the customer's language, not yours. Shopify stores customer.locale-ish signals and you know your audience: map pincode clusters → language, pick template variants cod_confirm_hi_v1, cod_confirm_ta_v1, and send with the matching language.code. One brand I know ran EN vs HI confirmations A/B: Hindi confirmations lifted response rate ~22% in UP/Bihar Tier-2 clusters. Twenty minutes of work, compounding returns.
The unit economics
Assume 1,000 COD orders/month, 30% baseline RTO:
- Before: 300 RTOs × ₹140 net loss = ₹42,000/month burned
- After a 35% RTO reduction: 195 RTOs = ₹27,300 burned, plus confirmation cost (1,000 + 400 reminders × ₹0.115 ≈ ₹161/month — round up to ₹500 with GST and misc)
- Net: ~₹14,000/month recovered for the price of a movie ticket. At 10,000 orders/month it's ~₹1.4 lakh/month. The ROI conversation with a client (or your own board) writes itself — which is exactly why this is the single best first automation to build or to sell.
Production checklist
- [ ] HMAC-sign every confirm/cancel link — unauthenticated cancel endpoints will get abused (yours or a competitor's)
- [ ] Dedupe on
order_id— Shopify delivers webhooks more-than-once;INSERT OR IGNOREis your friend - [ ] Template category = Utility; keep a fallback if Meta re-categorizes
- [ ] Time the initial send to 9am–9pm IST; a 2am confirmation pings feel like spam and drag your quality rating
- [ ] Log
sent/delivered/readfrom status webhooks; measure response rate per template copy — small wording changes move 5–10 points - [ ] Honor opt-outs forever: a customer who clicks Cancel twice shouldn't get next month's campaign
Full runnable code (FastAPI app, SQLite layer, signed links, sweep cron, ngrok notes) is in my GitHub — repo: github.com/DawnofGenX/cod-confirm-bot. If you're building WhatsApp automation for D2C brands, the companion pieces are my broadcast-engine article and the BSP comparison — both linked from my profile.
Ship it this weekend. Every unconfirmed COD order in your queue right now is a ₹140 coin flip.
Top comments (0)