DEV Community

Cover image for Building a COD Confirmation Bot for Indian D2C Brands with WhatsApp (Full Code)
Priyansh Kansara
Priyansh Kansara

Posted on

Building a COD Confirmation Bot for Indian D2C Brands with WhatsApp (Full Code)

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:

  1. "Reply YES to confirm" — requires the customer to type; needs NLP/substring parsing; higher drop-off.
  2. 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 ] │
└────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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 8000 while 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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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 IGNORE is 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/read from 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)