DEV Community

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

Posted on Edited 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.

Full code and repo: https://github.com/DawnofGenX/vishwas

Top comments (1)

Collapse
 
savings_tracker_9455bff81 profile image
savings tracker •

Using a 2-button WhatsApp flow from the Shopify webhook before auto-canceling is super clean for cutting COD RTO. One thing that helps before firing that auto-cancel is sending a quick 12-hour reminder message that shows the exact item name, total price, and delivery pin code—lots of fake or accidental COD orders drop off or get fixed right there when buyers spot a typo in their address.