Back to blog
Chatmaid Developers··14 min read

How to Build a WhatsApp Order Tracking Bot for E-Commerce

Build a WhatsApp order tracking bot that answers 'where is my order?' from Shopify, WooCommerce or your database, with shipping alerts and human handoff.

Chatmaid Team
How to Build a WhatsApp Order Tracking Bot for E-Commerce

To build a WhatsApp order tracking bot, have incoming WhatsApp messages delivered to your server as webhooks, pull the order number out of each message, look the order up in Shopify, WooCommerce or your own database, check that it belongs to the person asking, and send the status back as a reply. With the Chatmaid API, the bot runs on your store's own WhatsApp number, connected with a QR scan and without Meta Business verification. Incoming messages arrive as signed message.received webhooks, and each reply is one request to POST /v1/messages/send.

"Where is my order?" is a question a store answers over and over, and the answer is already in your order system. This guide builds the bot in Python with Flask, adds shipping notifications that go out on their own, and then shows the same flow in n8n for teams that would rather not run a Python server.

What the bot handles

The bot in this guide covers the common post-purchase questions:

  • Order status: confirmed, waiting for payment, shipped, in transit, out for delivery, delivered, cancelled or refunded.
  • Tracking details: the carrier, the tracking number and a tracking link, when the shipment has them.
  • Estimated delivery: shown when the carrier provides a date.
  • Delivery problems: delays, failed attempts and undelivered parcels that the carrier reports.
  • Returns, exchanges and refunds: handed to your team in a WhatsApp group, with a case number for the customer.
  • Escalation: the customer can ask for a person at any time, and the bot steps back when someone on your team replies from the phone.
  • Proactive updates: an order confirmation and a "your order has shipped" message, sent without the customer asking.

Architecture

Customer sends a WhatsApp message to your store's number
  -> Chatmaid posts a signed message.received webhook to your server (or n8n)
  -> your code finds the order number in the text
  -> it looks the order up in Shopify, WooCommerce or your database
  -> it checks the order belongs to the sender and writes the reply
  -> POST /v1/messages/send delivers the reply from your WhatsApp number

The key integration point is your order system. The bot needs to find an order by its number and read its status, tracking details and the phone number the customer gave at checkout.

What you need before you start

  • Python 3.10 or newer. The code uses str | None type hints.
  • A Chatmaid Developers account. Create one from the Chatmaid Developers page. Sandbox keys (sk_test_) are free and simulate sends. Real WhatsApp traffic needs a live key (sk_live_), a number connected with a QR scan and a subscription: as of October 2026, Chatmaid Developers costs $7.99/month flat, and the sandbox is free (pricing). New keys come with the messages:send and messages:read scopes this guide uses.
  • A webhook. In the dashboard, open Webhooks, paste your server's public HTTPS URL, tick Message Received and Message Sent From Phone, and click Configure Webhook. Copy the secret that starts with whsec_. The guide to receiving WhatsApp messages by webhook walks through this step by step, and the webhooks reference shows each event's payload.
  • A WhatsApp group for your support team, with the bot's number as a member. The bot posts handoffs there. GET /v1/groups?fromPhoneId=... lists the groups your number can post to, and each group's id (it ends in @g.us) is what you send to.
  • Access to your order data: a Shopify Admin API access token with the read_orders scope, WooCommerce REST API keys, or your own database.

Install the packages:

pip install flask requests phonenumbers

The finished project has these files. You only need one of the three lookup_ files:

order-bot/
├── chatmaid.py             # send messages, verify Chatmaid webhooks
├── parsing.py              # find the order number in a message
├── lookup_shopify.py       # or lookup_woocommerce.py, or lookup_sqlite.py
├── ownership.py            # check the order belongs to the sender
├── replies.py              # turn an order into a WhatsApp reply
├── handoff.py              # returns, problems and human takeover
├── notifications.py        # proactive Shopify notifications
└── app.py                  # the Flask server

Set your keys as environment variables:

export CHATMAID_API_KEY="sk_live_xxxxxxxxxxxxxxxxxxxxxxxx"
export CHATMAID_SENDER="+15551234567"
export CHATMAID_WEBHOOK_SECRET="whsec_xxxxxxxxxxxxxxxxxxxxxxxx"
export SUPPORT_GROUP_ID="120363043211234567@g.us"
export SHOPIFY_STORE="your-store"
export SHOPIFY_ACCESS_TOKEN="shpat_xxxxxxxxxxxxxxxxxxxxxxxx"
export SHOPIFY_WEBHOOK_SECRET="your_shopify_app_client_secret"

CHATMAID_SENDER is your connected number in E.164 format. Each key only sees the phones in its own environment, so a test key needs a number you register on the Phone Numbers page while the dashboard is in sandbox mode (sandbox numbers need no QR scan), and a live key needs your live number.

chatmaid.py holds the two calls everything else uses: sending a message and checking that a webhook really came from Chatmaid.

import hashlib
import hmac
import os
import time

import requests

API_BASE = "https://developers-api.chatmaid.net/v1"
API_KEY = os.environ["CHATMAID_API_KEY"]
SENDER = os.environ["CHATMAID_SENDER"]  # your connected number, e.g. +15551234567
WEBHOOK_SECRET = os.environ["CHATMAID_WEBHOOK_SECRET"]
TOLERANCE_SECONDS = 15 * 60  # retried deliveries keep the original timestamp


def send_whatsapp(to: str, text: str, idempotency_key: str) -> dict:
    """Send a text from your number. `to` is an E.164 number or a group ID."""
    response = requests.post(
        f"{API_BASE}/messages/send",
        headers={"Authorization": f"Bearer {API_KEY}"},
        json={
            "fromPhoneId": SENDER,
            "to": to,
            "content": text,
            "idempotencyKey": idempotency_key[:64],
        },
        timeout=30,
    )
    if not response.ok:
        raise RuntimeError(
            f"Chatmaid send failed ({response.status_code}): {response.text[:300]}"
        )
    return response.json()["data"]


def is_valid_signature(raw_body: bytes, header: str) -> bool:
    """Check the X-Chatmaid-Signature header, which looks like t=<unix time>,v1=<hex>."""
    try:
        parts = dict(item.strip().split("=", 1) for item in header.split(","))
        timestamp = int(parts["t"])
        provided = parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
        return False
    signed = parts["t"].encode() + b"." + raw_body
    expected = hmac.new(WEBHOOK_SECRET.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(provided.encode(), expected.encode())

The idempotencyKey matters here. Chatmaid retries a webhook delivery that fails or times out, so the same message can reach you twice. If the bot sends its reply with a key built from the incoming message ID, the second attempt returns the message that already went out instead of sending another one.

Part 1: The basic order lookup flow

Identifying the order

Customers ask in different ways:

  • "Where is order #4892?"
  • "My order hasn't arrived" (no order number)
  • "I ordered last week and need tracking"

A few regular expressions catch most order numbers. When there is no number, the bot asks for one. Save this as parsing.py:

import re

ORDER_PATTERNS = [
    r"#\s*(\d{3,10})\b",  # "#4892"
    r"\border\s*(?:number|no\.?)?\s*:?\s*(\d{3,10})\b",  # "order 4892", "order number: 4892"
    r"\b(\d{4,10})\b",  # a bare number, as a last resort
]


def extract_order_number(text: str) -> str | None:
    for pattern in ORDER_PATTERNS:
        match = re.search(pattern, text, re.IGNORECASE)
        if match:
            return match.group(1)
    return None

The last pattern also catches numbers that are not order numbers, such as a phone number typed into the chat. That is safe here, because the ownership check below refuses to show an order that doesn't belong to the sender.

Querying order status

Each lookup file has one function, find_order(order_number), that returns the order in the same shape, or None when there is no such order. The rest of the bot never needs to know which store you use.

Shopify. This uses the GraphQL Admin API (version 2026-10) with your access token in the X-Shopify-Access-Token header. Save it as lookup_shopify.py:

import os

import requests

SHOP = os.environ["SHOPIFY_STORE"]  # the part before .myshopify.com
TOKEN = os.environ["SHOPIFY_ACCESS_TOKEN"]
API_VERSION = "2026-10"

QUERY = """
query FindOrder($search: String!) {
  orders(first: 5, query: $search) {
    nodes {
      name
      phone
      cancelledAt
      shippingAddress { phone countryCodeV2 }
      lineItems(first: 3) { nodes { title quantity } }
      fulfillments(first: 10) {
        createdAt
        displayStatus
        estimatedDeliveryAt
        trackingInfo(first: 1) { company number url }
      }
    }
  }
}
"""

# Shopify's fulfillment display status -> the bot's status
STATUS = {
    "DELIVERED": "delivered",
    "PICKED_UP": "delivered",
    "OUT_FOR_DELIVERY": "out_for_delivery",
    "IN_TRANSIT": "in_transit",
    "CARRIER_PICKED_UP": "in_transit",
    "READY_FOR_PICKUP": "ready_for_pickup",
    "ATTEMPTED_DELIVERY": "problem",
    "DELAYED": "problem",
    "FAILURE": "problem",
    "NOT_DELIVERED": "problem",
}


def find_order(order_number: str) -> dict | None:
    response = requests.post(
        f"https://{SHOP}.myshopify.com/admin/api/{API_VERSION}/graphql.json",
        headers={"X-Shopify-Access-Token": TOKEN},
        json={"query": QUERY, "variables": {"search": f'name:"#{order_number}"'}},
        timeout=15,
    )
    response.raise_for_status()
    payload = response.json()
    if not payload.get("data"):
        raise RuntimeError(f"Shopify error: {payload.get('errors')}")

    # Keep only an exact match, never a near match from the search
    wanted = f"#{order_number}"
    order = next(
        (o for o in payload["data"]["orders"]["nodes"] if o["name"] == wanted), None
    )
    if order is None:
        return None

    shipments = [
        f for f in order["fulfillments"]
        if f["displayStatus"] not in ("CANCELED", "LABEL_VOIDED")
    ]
    latest = max(shipments, key=lambda f: f["createdAt"], default=None)
    tracking = (latest["trackingInfo"] or [{}])[0] if latest else {}
    address = order["shippingAddress"] or {}

    if order["cancelledAt"]:
        status = "cancelled"
    elif latest is None:
        status = "processing"
    else:
        status = STATUS.get(latest["displayStatus"], "shipped")

    return {
        "number": order["name"],
        "status": status,
        "items": [f"{i['title']} x{i['quantity']}" for i in order["lineItems"]["nodes"]],
        "carrier": tracking.get("company"),
        "tracking_number": tracking.get("number"),
        "tracking_url": tracking.get("url"),
        "eta": latest.get("estimatedDeliveryAt") if latest else None,
        "phones": [p for p in (address.get("phone"), order["phone"]) if p],
        "country": address.get("countryCodeV2"),
    }

A few Shopify details matter:

  • Order names include the #. By default, Shopify order names look like #1042. The search value is quoted, and the code keeps only the order whose name matches exactly, so a loose search result can never answer for a different order.
  • Custom order prefixes. If you changed the order ID prefix or suffix in Shopify's general settings, for example to SO-1042, use the same prefix and suffix in the search value and in wanted (and in the n8n Build Reply node in Part 6).
  • Phone numbers are protected customer data. If your app hasn't been granted access to the phone and address fields, Shopify returns them as null with an explanation in errors, and the bot will refuse to share details. See Shopify's protected customer data page.
  • Tokens depend on how the app was made. An app you create in the Shopify Dev Dashboard for your own store can get its token through the client credentials grant, and that token expires after about 24 hours, so refresh it on a schedule.
  • Older orders need extra access. By default the API returns the last 60 days of orders. Shopify has to grant read_all_orders for older ones.

WooCommerce. The WooCommerce REST API takes your consumer key and secret as HTTP basic auth over HTTPS. Save this as lookup_woocommerce.py and set WOO_STORE_URL, WOO_CONSUMER_KEY and WOO_CONSUMER_SECRET:

import os

import requests

STORE_URL = os.environ["WOO_STORE_URL"]  # e.g. https://your-store.com
AUTH = (os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])

# WooCommerce order status -> the bot's status
STATUS = {
    "pending": "awaiting_payment",
    "on-hold": "awaiting_payment",
    "failed": "awaiting_payment",
    "processing": "processing",
    "completed": "shipped",
    "cancelled": "cancelled",
    "refunded": "refunded",
}


def find_order(order_number: str) -> dict | None:
    response = requests.get(
        f"{STORE_URL}/wp-json/wc/v3/orders/{order_number}",
        auth=AUTH,
        timeout=15,
    )
    if response.status_code == 404:
        return None
    response.raise_for_status()
    order = response.json()
    billing = order.get("billing") or {}
    return {
        "number": f"#{order['number']}",
        "status": STATUS.get(order["status"], order["status"]),
        "items": [f"{i['name']} x{i['quantity']}" for i in order["line_items"][:3]],
        "carrier": None,  # WooCommerce core has no tracking fields, see below
        "tracking_number": None,
        "tracking_url": None,
        "eta": None,
        "phones": [billing["phone"]] if billing.get("phone") else [],
        "country": billing.get("country") or None,
    }

By default, the order number a WooCommerce customer sees is the order ID, which is what orders/<id> expects. If a plugin gives your orders different numbers, call orders with the search parameter instead and keep the result whose number matches exactly. WooCommerce core also has no tracking fields. Shipment tracking extensions usually store them in the order's meta_data, so read them from there and fill in carrier, tracking_number and tracking_url.

Your own database. If orders live in your own system, return the same shape from a query. This version uses SQLite and a table like this:

CREATE TABLE orders (
  number TEXT PRIMARY KEY,
  status TEXT NOT NULL,   -- processing, shipped, in_transit, delivered, ...
  items TEXT,             -- "Linen shirt x2;Canvas tote x1"
  carrier TEXT,
  tracking_number TEXT,
  tracking_url TEXT,
  eta TEXT,               -- ISO date, e.g. 2026-10-24
  phone TEXT,             -- as the customer typed it at checkout
  country TEXT            -- two-letter country code, e.g. US
);

Save the lookup as lookup_sqlite.py:

import os
import sqlite3
from contextlib import closing

DB_PATH = os.environ.get("ORDERS_DB", "orders.db")


def find_order(order_number: str) -> dict | None:
    with closing(sqlite3.connect(DB_PATH)) as conn:
        conn.row_factory = sqlite3.Row
        row = conn.execute(
            "SELECT * FROM orders WHERE number = ?", (order_number,)
        ).fetchone()
    if row is None:
        return None
    return {
        "number": f"#{row['number']}",
        "status": row["status"],
        "items": row["items"].split(";") if row["items"] else [],
        "carrier": row["carrier"],
        "tracking_number": row["tracking_number"],
        "tracking_url": row["tracking_url"],
        "eta": row["eta"],
        "phones": [row["phone"]] if row["phone"] else [],
        "country": row["country"],
    }

Checking the order belongs to the sender

Order numbers are easy to guess. Shopify order numbers usually go up one at a time, so anyone could send #1043 and read a stranger's order. Before sharing details, the bot compares the sender's WhatsApp number with the phone number on the order.

Customers type phone numbers in every format at checkout, so ownership.py converts them to E.164 first with the phonenumbers library, using the order's country for numbers written without a country code:

import re

import phonenumbers


def to_e164(raw: str | None, country: str | None) -> str | None:
    """Turn a phone number as typed at checkout into E.164, or None."""
    if not raw:
        return None
    try:
        number = phonenumbers.parse(raw, country or None)
    except phonenumbers.NumberParseException:
        return None
    if not phonenumbers.is_possible_number(number):
        return None
    return phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164)


def belongs_to(order: dict, whatsapp_number: str) -> bool:
    """True when one of the order's phone numbers is the sender's WhatsApp number."""
    sender = re.sub(r"\D", "", whatsapp_number)
    for raw in order["phones"]:
        e164 = to_e164(raw, order["country"])
        if e164 and e164.lstrip("+") == sender:
            return True
    return False

When the numbers don't match, the bot gives the same answer as for an order that doesn't exist, so it never confirms that a guessed number is real. A customer writing from a different phone can still ask for a person.

WhatsApp reports some numbers differently from how customers type them: Argentine mobiles arrive with a 9 after +54, and some Mexican numbers with a 1 after +52. If you sell there, compare the last 8 digits of both numbers instead of the whole number.

Formatting the reply

Map each status to a short message. WhatsApp shows text between single asterisks in bold. Save this as replies.py:

TEMPLATES = {
    "processing": "Order {number} is confirmed and being prepared. We'll message you here as soon as it ships.",
    "awaiting_payment": "Order {number} is waiting for payment. Once it's confirmed, we'll start preparing it.",
    "shipped": "Order {number} has shipped.{tracking}{eta}",
    "in_transit": "Order {number} is on its way.{tracking}{eta}",
    "out_for_delivery": "Order {number} is out for delivery today.{tracking}",
    "ready_for_pickup": "Order {number} is ready for pickup.",
    "delivered": (
        "Order {number} was delivered. If it hasn't reached you or something is wrong, "
        "reply *PROBLEM* and someone from our team will help."
    ),
    "problem": (
        "The carrier reports a delivery issue with order {number}.{tracking}\n\n"
        "Reply *PROBLEM* and someone from our team will sort it out with you."
    ),
    "cancelled": "Order {number} was cancelled. Questions about a refund? Reply *REFUND*.",
    "refunded": "Order {number} was refunded. Questions? Reply *AGENT* to reach our team.",
}
FALLBACK = "Order {number} status: {status}. Reply *AGENT* to reach our team."


def format_order_reply(order: dict) -> str:
    tracking = ""
    if order.get("tracking_number"):
        tracking = (
            f"\n\nCarrier: {order.get('carrier') or 'see link'}"
            f"\nTracking number: {order['tracking_number']}"
        )
        if order.get("tracking_url"):
            tracking += f"\nTrack it: {order['tracking_url']}"
    eta = f"\nExpected by: {order['eta'][:10]}" if order.get("eta") else ""

    template = TEMPLATES.get(order["status"], FALLBACK)
    text = template.format(
        number=order["number"], status=order["status"], tracking=tracking, eta=eta
    )
    if order["items"]:
        text += "\n\nItems: " + ", ".join(order["items"][:3])
    return text

A shipped Shopify order with tracking comes out like this:

Order #1042 is on its way.

Carrier: UPS
Tracking number: 1Z999AA10123456784
Track it: https://www.ups.com/track?tracknum=1Z999AA10123456784
Expected by: 2026-10-24

Items: Linen shirt x2, Canvas tote x1

Part 2: Handling returns, problems and handoff

Returns, damaged items and delivery problems need a person. When a customer writes AGENT, PROBLEM or REFUND, or mentions a return, the bot posts the case to your team's WhatsApp group, gives the customer a case number and stays quiet in that chat for two hours. Save this as handoff.py:

import os
import re
import time

from chatmaid import send_whatsapp

SUPPORT_GROUP = os.environ["SUPPORT_GROUP_ID"]  # e.g. 120363043211234567@g.us
PAUSE_SECONDS = 2 * 60 * 60

COMMANDS = {"AGENT", "PROBLEM", "REFUND"}
RETURN_WORDS = re.compile(r"\b(return|exchange|refund|damaged|defective|wrong item)\b", re.I)

_paused_until: dict[str, float] = {}  # customer number -> unix time


def wants_handoff(text: str) -> bool:
    return text.strip().strip("*").upper() in COMMANDS or bool(RETURN_WORDS.search(text))


def pause_bot(customer: str) -> None:
    _paused_until[customer] = time.time() + PAUSE_SECONDS


def bot_is_paused(customer: str) -> bool:
    return _paused_until.get(customer, 0) > time.time()


def hand_off(customer: str, text: str, order_number: str | None, message_id: str) -> str:
    case_id = message_id[-6:].upper()
    send_whatsapp(
        SUPPORT_GROUP,
        f"Case {case_id} from {customer}\n"
        f"Order: {order_number or 'not given'}\n"
        f"Message: {text}",
        idempotency_key=f"case-{message_id}",
    )
    pause_bot(customer)
    return (
        f"Thanks. I've passed this to our team (case {case_id}). "
        "A person will reply here as soon as they can."
    )

Your team answers from the store's phone, in the customer's chat. That is where the Message Sent From Phone event comes in: when someone types into the connected phone's WhatsApp, Chatmaid sends a message.outgoing webhook, and the server below pauses the bot for that customer. Messages the bot sends through the API never raise that event, so it can't pause itself by accident.

The pause list lives in memory to keep the example short. If you run more than one server process, or restart often, keep it in Redis or your database instead.

Part 3: The webhook server

app.py ties the pieces together. It answers every webhook right away and does the work in a background thread, because the order lookup and the reply can take a few seconds. Change the lookup_shopify import if you use WooCommerce or your own database:

import json
import threading

from flask import Flask, abort, request

from chatmaid import is_valid_signature, send_whatsapp
from handoff import bot_is_paused, hand_off, pause_bot, wants_handoff
from lookup_shopify import find_order  # or lookup_woocommerce / lookup_sqlite
from ownership import belongs_to
from parsing import extract_order_number
from replies import format_order_reply

app = Flask(__name__)
seen_messages: set[str] = set()

ASK_FOR_NUMBER = (
    "Hi! I can check your order for you. Please send your order number. "
    "It's in your confirmation email, for example #1042."
)
NOT_FOUND = (
    "I couldn't find order #{number} for this WhatsApp number. "
    "Please check the number, or reply *AGENT* to reach our team."
)
UNAVAILABLE = (
    "Sorry, I can't check orders right now. "
    "Please try again in a few minutes, or reply *AGENT* to reach our team."
)


@app.post("/webhooks/chatmaid")
def chatmaid_webhook():
    raw_body = request.get_data()
    if not is_valid_signature(raw_body, request.headers.get("X-Chatmaid-Signature", "")):
        abort(401)
    event = json.loads(raw_body)
    threading.Thread(target=handle_event, args=(event,), daemon=True).start()
    return "", 200


def handle_event(event: dict) -> None:
    data = event.get("data") or {}
    if data.get("isGroup"):
        return  # only answer direct chats
    if event.get("event") == "message.outgoing":
        pause_bot(data["to"])  # a person replied from the phone, so step back
        return
    if event.get("event") != "message.received" or data["messageId"] in seen_messages:
        return
    seen_messages.add(data["messageId"])
    if bot_is_paused(data["from"]):
        return

    try:
        reply = build_reply(data)
    except Exception:
        app.logger.exception("Could not build a reply")
        reply = UNAVAILABLE
    sent = send_whatsapp(data["from"], reply, idempotency_key=f"reply-{data['messageId']}")
    print("reply sent:", sent["id"], sent["status"])


def build_reply(data: dict) -> str:
    customer = data["from"]
    text = (data.get("content") or "").strip()
    order_number = extract_order_number(text)

    if wants_handoff(text):
        return hand_off(customer, text, order_number, data["messageId"])
    if order_number is None:
        return ASK_FOR_NUMBER

    order = find_order(order_number)
    if order is None or not belongs_to(order, customer):
        return NOT_FOUND.format(number=order_number)
    return format_order_reply(order)


if __name__ == "__main__":
    app.run(port=3000)

What each check does:

  • The signature check rejects anything not signed with your webhook secret, so nobody can make the bot leak order data by posting fake messages to your URL.
  • The group check keeps the bot out of WhatsApp groups. Inbound group messages arrive as message.received too, with isGroup set to true.
  • The event check ignores the other events you subscribe to, including the sample event from the dashboard's Test Webhook button.
  • The message ID stops a retried delivery from being answered twice. The reply- idempotency key covers the same case after a restart, when the in-memory set is empty.

A thread is enough to get started. In production, hand events to a task queue such as Celery or RQ. The guide to WhatsApp notifications from a Python Flask app shows that setup, along with error handling for failed sends.

Part 4: Proactive order notifications (outbound)

You don't have to wait for customers to ask. Shopify can call your server when an order is created (the orders/create webhook topic) and when a shipment is created (fulfillments/create), and the bot can turn each one into a WhatsApp message.

Shopify signs app webhooks with your app's client secret: the X-Shopify-Hmac-SHA256 header holds a base64 HMAC-SHA256 of the raw body. It also expects a 200 within five seconds, so the send happens in the background. See Shopify's guide to HTTPS webhook delivery for subscribing your app to these topics. Save this as notifications.py:

import base64
import hashlib
import hmac
import json
import os
import threading

from flask import Blueprint, abort, request

from chatmaid import send_whatsapp
from ownership import to_e164

SHOPIFY_WEBHOOK_SECRET = os.environ["SHOPIFY_WEBHOOK_SECRET"]

shopify_bp = Blueprint("shopify", __name__)


def verified_shopify_json() -> dict:
    raw_body = request.get_data()
    digest = hmac.new(SHOPIFY_WEBHOOK_SECRET.encode(), raw_body, hashlib.sha256).digest()
    header = request.headers.get("X-Shopify-Hmac-SHA256", "")
    if not hmac.compare_digest(base64.b64encode(digest), header.encode()):
        abort(401)
    return json.loads(raw_body)


def send_in_background(to: str | None, text: str, key: str) -> None:
    if to:  # skip orders without a usable phone number
        threading.Thread(target=send_whatsapp, args=(to, text, key), daemon=True).start()


@shopify_bp.post("/webhooks/shopify/orders-create")
def order_created():
    order = verified_shopify_json()
    address = order.get("shipping_address") or {}
    phone = to_e164(address.get("phone") or order.get("phone"), address.get("country_code"))
    items = ", ".join(f"{i['title']} x{i['quantity']}" for i in order["line_items"][:3])
    send_in_background(
        phone,
        f"Thanks for your order! {order['name']} is confirmed.\n"
        f"Items: {items}\n\n"
        f"We'll message you here when it ships. Send {order['name']} anytime to check on it.",
        f"shopify-order-{order['id']}",
    )
    return "", 200


@shopify_bp.post("/webhooks/shopify/fulfillments-create")
def fulfillment_created():
    shipment = verified_shopify_json()
    address = shipment.get("destination") or {}
    phone = to_e164(address.get("phone"), address.get("country_code"))
    order_name = shipment["name"].rsplit(".", 1)[0]  # "#1042.1" -> "#1042"

    text = f"Good news: your order {order_name} has shipped."
    if shipment.get("tracking_number"):
        text += (
            f"\n\nCarrier: {shipment.get('tracking_company') or 'see link'}"
            f"\nTracking number: {shipment['tracking_number']}"
        )
    if shipment.get("tracking_url"):
        text += f"\nTrack it: {shipment['tracking_url']}"
    text += f"\n\nSend {order_name} here anytime for an update."

    send_in_background(phone, text, f"shopify-fulfillment-{shipment['id']}")
    return "", 200

Register the routes by adding two lines to app.py, right after app = Flask(__name__):

from notifications import shopify_bp

app.register_blueprint(shopify_bp)

The idempotency keys use Shopify's order and fulfillment IDs, so a webhook Shopify delivers twice still produces one WhatsApp message.

Only send these to customers who agreed to get order updates on WhatsApp. Unsolicited messages to people who don't expect them are what gets WhatsApp numbers restricted. If a send fails with RECIPIENT_NOT_REACHABLE, the API's explanation is that the customer may need to message you first, or isn't reachable on WhatsApp.

Part 5: Test the bot before going live

Inbound messages only exist on live numbers, so the sandbox never produces a message.received event. You can still test the whole reply path locally with a free test key, by signing a fake event yourself.

  1. Set CHATMAID_API_KEY to your sk_test_ key and CHATMAID_SENDER to a number you registered while the dashboard was in sandbox mode. Sends are then simulated. SUPPORT_GROUP_ID can be a group from the sandbox's GET /v1/groups list.
  2. Switch the import in app.py to lookup_sqlite and create a test order whose phone matches the fake sender:
python -c "
import sqlite3
conn = sqlite3.connect('orders.db')
conn.execute('CREATE TABLE IF NOT EXISTS orders (number TEXT PRIMARY KEY, status TEXT NOT NULL, items TEXT, carrier TEXT, tracking_number TEXT, tracking_url TEXT, eta TEXT, phone TEXT, country TEXT)')
conn.execute('INSERT OR REPLACE INTO orders VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', ('1042', 'in_transit', 'Linen shirt x2;Canvas tote x1', 'UPS', '1Z999AA10123456784', 'https://www.ups.com/track?tracknum=1Z999AA10123456784', '2026-10-24', '(555) 765-4321', 'US'))
conn.commit()
"
  1. Start the server with python app.py, then post a signed message.received event from another terminal. Use the same webhook secret the server was started with:
SECRET="whsec_xxxxxxxxxxxxxxxxxxxxxxxx"  # the same value as CHATMAID_WEBHOOK_SECRET on the server
BODY='{"event":"message.received","timestamp":"2026-10-22T12:00:00.000Z","data":{"messageId":"inmsg_localtest0001","from":"+15557654321","to":"+15551234567","content":"Where is order #1042?","type":"text","isGroup":false,"groupId":null,"receivedAt":"2026-10-22T12:00:00.000Z"}}'
T=$(date +%s)
SIG=$(printf '%s' "$T.$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')

curl -X POST http://localhost:3000/webhooks/chatmaid \
  -H "Content-Type: application/json" \
  -H "X-Chatmaid-Event: message.received" \
  -H "X-Chatmaid-Signature: t=$T,v1=$SIG" \
  -d "$BODY"

The server prints reply sent: with the new message ID, and GET /v1/messages/<id> shows the reply text. Run the same request again and nothing new goes out. To try other paths, give each test a new messageId (the server ignores repeats), then change content to I want to return it to test the handoff, or change from to another number to see the ownership check refuse. A handoff pauses the bot for that sender for two hours, so test it last, or restart the server to clear the pause.

To go live, switch to your sk_live_ key and live number, deploy the server behind HTTPS, and message the store's number from another phone.

Part 6: Building it in n8n (low-code version)

If you'd rather not host a Python server, the same flow fits in an n8n workflow. The names in quotes are node names that the expressions refer to.

  1. "Webhook" (Webhook node): method POST, respond Immediately. Paste its Production URL into the Chatmaid Webhooks page with Message Received ticked.
  2. "Verify Signature" (Code node): checks X-Chatmaid-Signature before anything else runs. The code is in the guide to building a WhatsApp customer service agent with n8n.
  3. "Direct Messages Only" (If node): the expression {{ $json.body.event === 'message.received' && $json.body.data.isGroup === false }} with the Boolean operator is true.
  4. "Find Order Number" (Code node): the same regular expressions as parsing.py, shown below.
  5. "Has Order Number" (If node): {{ $json.orderNumber !== null }}. The false branch goes to an HTTP Request node that sends the "please send your order number" reply.
  6. "Get Order" (HTTP Request node): POST to https://your-store.myshopify.com/admin/api/2026-10/graphql.json with the X-Shopify-Access-Token header and the body shown below.
  7. "Build Reply" (Code node): the exact-name and phone checks, and the reply text. You can split this into a Switch node on the status with an Edit Fields (Set) node per branch, but one Code node keeps the ownership check in one place.
  8. "Send Reply" (HTTP Request node): POST to https://developers-api.chatmaid.net/v1/messages/send, with the fields in the table below.
  9. Google Sheets (optional): append a row per conversation to review what customers ask.

The "Find Order Number" code, with the mode left on Run Once for All Items:

const data = $input.first().json.body.data;
const text = data.content || '';
const match =
  text.match(/#\s*(\d{3,10})\b/) ||
  text.match(/\border\s*(?:number|no\.?)?\s*:?\s*(\d{3,10})\b/i) ||
  text.match(/\b(\d{4,10})\b/);

return [{ json: { ...data, orderNumber: match ? match[1] : null } }];

The "Get Order" body. Set Specify Body to Using JSON and paste this; it contains an expression, so the field switches to expression mode:

{
  "query": "query FindOrder($search: String!) { orders(first: 5, query: $search) { nodes { name phone cancelledAt shippingAddress { phone } fulfillments(first: 10) { createdAt displayStatus trackingInfo(first: 1) { company number url } } } } }",
  "variables": { "search": {{ JSON.stringify('name:"#' + $json.orderNumber + '"') }} }
}

The "Build Reply" code. n8n has no phone library, so it compares digits: the sender's number must end with the digits of the phone on the order, ignoring leading zeros.

const msg = $('Find Order Number').first().json;
const wanted = '#' + msg.orderNumber;
const orders = $input.first().json.data?.orders?.nodes || [];
const order = orders.find((o) => o.name === wanted);

const sender = msg.from.replace(/\D/g, '');
const phones = order ? [order.phone, order.shippingAddress?.phone].filter(Boolean) : [];
const owns = phones.some((p) => {
  const digits = p.replace(/\D/g, '').replace(/^0+/, '');
  return digits.length >= 7 && sender.endsWith(digits);
});

let reply;
if (!order || !owns) {
  reply = `I couldn't find order ${wanted} for this WhatsApp number. Please check the number, or reply *AGENT* to reach our team.`;
} else {
  const shipments = order.fulfillments
    .filter((f) => !['CANCELED', 'LABEL_VOIDED'].includes(f.displayStatus))
    .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
  const latest = shipments[shipments.length - 1];
  const t = latest?.trackingInfo?.[0];
  const tracking = t?.number
    ? `\n\nCarrier: ${t.company || 'see link'}\nTracking number: ${t.number}` + (t.url ? `\nTrack it: ${t.url}` : '')
    : '';

  if (order.cancelledAt) {
    reply = `Order ${wanted} was cancelled. Questions about a refund? Reply *AGENT*.`;
  } else if (!latest) {
    reply = `Order ${wanted} is confirmed and being prepared. We'll message you here as soon as it ships.`;
  } else if (latest.displayStatus === 'DELIVERED') {
    reply = `Order ${wanted} was delivered. If something is wrong, reply *AGENT* and someone from our team will help.`;
  } else {
    reply = `Order ${wanted} has shipped.${tracking}`;
  }
}

return [{ json: { reply } }];

The "Send Reply" node uses Header Auth with the name Authorization and the value Bearer followed by your key, a JSON body, and these fields:

NameValue
fromPhoneId+15551234567
to{{ $('Find Order Number').item.json.from }}
content{{ $json.reply }}
idempotencyKeyreply-{{ $('Find Order Number').item.json.messageId }}

To send AGENT requests to your team, add an If node before "Find Order Number" that checks the text and sends a message to your support group's ID instead.

This version doesn't pause when a person takes over. To add that, tick Message Sent From Phone too and follow the escalation steps in the n8n customer service guide, which store body.data.to from each message.outgoing event in a paused list and check it before replying.

Part 7: The AI upgrade

Once the basic flow works, an AI model can handle the messages that keyword rules miss:

  • Vague messages ("my stuff hasn't arrived") where the customer needs a friendly nudge for the number
  • Several questions at once ("where is my order and can I change the address?")
  • Upset customers ("this is taking way too long, I need it for an event tomorrow")
  • Languages other than English

Keep the lookup and the ownership check in code, and give the model two narrow jobs. First, read the message and return the intent and the order number. Second, write the reply from the fields find_order returned for that one order. Tell it to use only those facts, never to invent a tracking number or a date, and to answer with a fixed word such as ESCALATE when it can't help, which your code routes to hand_off. Any current model from OpenAI, Anthropic or Google handles both jobs.

The guide to connecting an AI agent to WhatsApp covers the model side, and conversation memory for WhatsApp AI agents shows how to keep context across messages, so a customer can say "and the other order?" without repeating themselves.

Metrics to track

Log one line per conversation with the outcome (answered, asked for a number, not found, handed off) and you can measure what matters:

  • Containment rate: the share of order questions the bot answered without a handoff.
  • Time to reply: the gap between receivedAt in the webhook and the sentAt of your reply, which GET /v1/messages/<id> returns.
  • Handoff rate and reasons: returns, delivery problems and customers asking for a person. A rising "not found" count often means customers write order numbers in a format your patterns miss.
  • Failed sends: GET /v1/messages?status=failed lists them, and each message carries an errorCode such as NOT_CONNECTED or RECIPIENT_NOT_REACHABLE.

Frequently asked questions

Do I need the WhatsApp Business API or Meta verification for this bot?

No. Chatmaid connects your store's existing WhatsApp number with a QR scan and gives you an API and webhooks on top of it, with no Meta Business verification. Customers message the same number they already know. Using the WhatsApp API without Meta Business verification explains how this works.

Can I test the bot for free?

Yes, mostly. Sandbox keys are free and simulate sends, and the test section above shows how to post a signed fake message.received event to your server. Real inbound messages only arrive on a live number, which needs a paid plan.

Can someone check another customer's order by guessing order numbers?

Not with the ownership check in place. The bot compares the sender's WhatsApp number with the phone on the order and gives the same "couldn't find it" answer when they don't match, so a guessed number reveals nothing.

Can the bot send order updates to customers who never messaged the store?

Yes, Part 4 sends them from Shopify webhooks, but only send them to customers who agreed to WhatsApp updates. A send can fail with RECIPIENT_NOT_REACHABLE when the customer needs to message you first, so a WhatsApp link on the order confirmation page, inviting them to start the chat, helps.

What happens when someone on my team replies by hand?

If they reply from the store's phone, Chatmaid sends a message.outgoing webhook and the bot pauses for that customer for two hours. Handoffs pause it too, so the bot stays out of the way while a person handles the case.

Can I build this in Node.js instead of Python?

Yes. The API is plain HTTPS, and the @chatmaid/sdk package for JavaScript and TypeScript wraps it: messages.send sends the reply and verifyWebhook checks the signature. Pass toleranceSeconds: 900 to verifyWebhook: retried deliveries keep their original timestamp, and the last retry can arrive several minutes after the first attempt. As of October 2026, the SDK's current release (0.3.0) also rejects message.outgoing as an unknown event, so check those deliveries with a manual HMAC check like the Python one in this guide. The order lookups and the ownership check work the same way.