# WhatsApp Messages from Shopify Orders

> Send WhatsApp order confirmations and shipping updates from Shopify webhooks through the Avox API, and receive customer replies on your server - setup, code, checks and FAQ.

Source: https://docs.theavoxai.com/integrations/shopify

Send a WhatsApp message when something happens in your Shopify store - an order is placed, an
order ships - using Shopify's own webhooks, a small server you run, and the
[Avox API](https://docs.theavoxai.com/integrations/avox-api). Customer replies land in your Avox [Inbox](https://docs.theavoxai.com/inbox), and
[message forwarding](https://docs.theavoxai.com/connections/webhook-override) can send them to your server too.

## What you'll build

```
Send:     Shopify (order events) → your server → Avox API → customer's WhatsApp
Receive:  customer's reply → Avox (Inbox, Automation) → message forwarding → your server (optional)
```

Your server decides "this order event means this template to this customer". Avox handles the
WhatsApp side: the connection, the templates and the sending.

## Requirements

- A Shopify store where you can add webhooks (in the Shopify admin's notification settings, or
  through Shopify's Admin API).
- A WhatsApp number connected to Avox. See [Getting started](https://docs.theavoxai.com/getting-started).
- An Avox API key for that number. Only an **Admin** can create one. See
  [Create the API key](#create-the-api-key).
- Approved WhatsApp templates for each event you message on. Order messages start a conversation,
  so they must be templates. See [Templates](https://docs.theavoxai.com/templates).
- Your customers' opt-in to WhatsApp messages - WhatsApp's policies require it, and having an
  order isn't the same as opting in.
- A server reachable over `https://` (Node.js 18 or later for the code below, or Python 3.10 or later).

```bash
# .env
SHOPIFY_WEBHOOK_SECRET=your_shopify_webhook_signing_secret
AVOX_API_KEY=avx_live_your_key
```

## Templates to create first

Create these on [Create](https://docs.theavoxai.com/templates/create) and wait for approval. Use the **Utility** category -
they're about an order the customer placed. See [Template categories](https://docs.theavoxai.com/whatsapp/template-categories).

| Template name | Body |
| --- | --- |
| `order_confirmation` | Hi! Your order `{{1}}` for `{{2}}` has been confirmed. We'll let you know when it ships. |
| `order_shipped` | Good news! Your order `{{1}}` has shipped. Track it here: `{{2}}` |

## Create the API key

**1. Open API Keys**

In the dashboard, go to **Settings > API Keys**.

**2. Pick the key type**

Under **Create a new key**, choose **Test key** while you build (it never sends anything) or **Live key** for your store.

**3. Choose the number**

Pick the WhatsApp number in the **Choose a connection** list.

**4. Create and copy it**

Click **Create test key** or **Create live key**, then copy the key from **Your new API key** into `AVOX_API_KEY`. It's shown only once.

**5. Check it**

Click **Test** next to the key. "Working - routes to" followed by your connection's name means it's ready.

Use a dedicated key for the store, so you can rotate or revoke it on its own. See [API keys](https://docs.theavoxai.com/connections/api-keys).

## Receiving Shopify's webhooks

Shopify signs every webhook with an HMAC-SHA256 signature of the raw body in the
`X-Shopify-Hmac-Sha256` header. Verify it before trusting the payload, and reply quickly - Shopify
expects a fast response and retries otherwise.

```js
// server.js
import express from "express";
import crypto from "node:crypto";

const app = express();

function verifyShopifySignature(rawBody, signature) {
  if (!signature) return false;
  const digest = crypto.createHmac("sha256", process.env.SHOPIFY_WEBHOOK_SECRET).update(rawBody).digest("base64");
  const a = Buffer.from(signature);
  const b = Buffer.from(digest);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

function toInternational(phone) {
  const digits = (phone ?? "").replace(/\D/g, "");
  return digits.length >= 7 ? digits : null; // Shopify stores numbers with the country code
}

// express.raw keeps the exact bytes Shopify signed.
app.post("/shopify/orders/create", express.raw({ type: "application/json", limit: "2mb" }), (req, res) => {
  if (!verifyShopifySignature(req.body, req.get("X-Shopify-Hmac-Sha256"))) return res.sendStatus(401);
  res.sendStatus(200); // acknowledge first

  const order = JSON.parse(req.body.toString("utf8"));
  const phone = toInternational(order.customer?.phone ?? order.shipping_address?.phone);
  if (phone) sendOrderConfirmation(phone, order).catch((err) => console.error(err.message));
});

app.listen(3000);
```

## Sending the WhatsApp template

```js
async function sendOrderConfirmation(phone, order) {
  const res = await fetch("https://api.avox.com/api/messages/send", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.AVOX_API_KEY}`,
      "Content-Type": "application/json",
      // Shopify can deliver the same webhook more than once - one key per
      // order means a repeat delivery never sends a second message.
      "Idempotency-Key": `shopify-order-${order.id}-confirmation`,
    },
    body: JSON.stringify({
      to: phone,
      type: "template",
      biz_opaque_callback_data: `shopify-order-${order.id}`,
      template: {
        name: "order_confirmation",
        language: { code: "en" },
        components: [
          {
            type: "body",
            parameters: [
              { type: "text", text: order.name },
              { type: "text", text: `${order.total_price} ${order.currency}` },
            ],
          },
        ],
      },
    }),
  });

  const data = await res.json().catch(() => null);
  if (!data?.ok) {
    // error.code is stable - see /api/errors. Log request_id for support.
    throw new Error(`Avox send failed: ${data?.error?.code ?? res.status} (${data?.error?.request_id ?? "-"})`);
  }
  return data.message_id;
}
```

The `Idempotency-Key` makes Shopify's retries safe: the same order always produces the same key, so
a repeated delivery within 24 hours returns the first result (with the header
`Idempotent-Replayed: true`) instead of sending again. See [Idempotency](https://docs.theavoxai.com/avox-api/idempotency).
`biz_opaque_callback_data` (up to 512 characters) is your own reference: WhatsApp includes it on
the message's status events, so you can tie them to the order without a lookup.

## Setup

**1. Create and approve the templates**

See the table above.

**2. Create the Avox API key**

See [Create the API key](#create-the-api-key), and store it as `AVOX_API_KEY`.

**3. Deploy the server**

Make `https://your-server.example.com/shopify/orders/create` reachable.

**4. Add the Shopify webhook**

In Shopify, add a webhook for order creation pointing at that URL in JSON format, and copy the signing secret into `SHOPIFY_WEBHOOK_SECRET`.

**5. Handle replies**

Answer customers from the [Inbox](https://docs.theavoxai.com/inbox), set up [Automation](https://docs.theavoxai.com/automation) for common questions such as "where is my order?", or [receive replies on your server](#receive-customer-replies).

> **Tip**
>
> Build with a test key first. It runs every check a live key does and answers with a pretend result - `"test": true`, the header `Avox-Test-Mode: true`, and a `message_id` starting `wamid.TEST` - so test orders never message a real customer. See [Testing](https://docs.theavoxai.com/avox-api/testing).

## Check it's working

**1. Place a test order**

Use a phone number that has WhatsApp and has opted in.

**2. Check your server**

It logs the Shopify webhook and gets `"ok": true` with a `message_id` from Avox.

**3. Check the phone**

The confirmation arrives on WhatsApp.

**4. Check Avox**

In **Settings > API Keys**, open the key's **Recent requests**: the send is listed as **Send** with status `200`. The **API logs** page (**Sent** tab) has the full history - search it by `request_id` or `message_id`.

**5. Check delivery**

The message's delivery statuses are on [Logs](https://docs.theavoxai.com/logs). Open an event to see its payload, including your `biz_opaque_callback_data`.

## Receive customer replies

To handle replies on your own server as well as in the Inbox - for example, a customer tapping a
quick reply button on your shipping template - turn on message forwarding for the number:

**1. Open Message forwarding**

Open [Connections](https://docs.theavoxai.com/connections), click **Settings** on the connection, and scroll to **Message forwarding**. You need the **Admin** role to change it.

**2. Save your URL**

Enter your receiver's address, such as `https://your-server.example.com/avox/messages`, in **Endpoint URL** and click **Save URL**.

**3. Turn it on and copy the secret**

Switch **Message forwarding** on. Copy the signing secret from **Your signing secret** into `AVOX_WEBHOOK_SECRET` - it's shown only once.

**4. Send a test event**

Click **Send test event**. "Delivered - your endpoint answered 200" means your receiver accepted it.

Each new customer message arrives as a signed `message.received` event. A tap on a template's quick
reply button arrives as `"type": "button"`, with the button's `text` and `payload` under
`data.button`. Use the receiver code in [Receiver](#receiver) below and put your own logic in
`handleEvent`. The full payload for every message type is on
[Receive messages](https://docs.theavoxai.com/avox-api/webhooks#receive-messages).

## Common errors

| Symptom | Fix |
| --- | --- |
| Your server answers Shopify with `401` | `SHOPIFY_WEBHOOK_SECRET` is wrong, or a body parser changed the raw body before verification. |
| [`invalid_api_key`](https://docs.theavoxai.com/api/errors#invalid_api_key) | `AVOX_API_KEY` is missing, cut off or quoted. Copy it again or create a new key. |
| [`template_not_found`](https://docs.theavoxai.com/api/errors#template_not_found) | The template name or language (`en` vs `en_US`) doesn't match the approved one on [Templates](https://docs.theavoxai.com/templates). |
| [`template_unavailable`](https://docs.theavoxai.com/api/errors#template_unavailable) | WhatsApp paused or disabled the template. Check its status on [Templates](https://docs.theavoxai.com/templates). |
| [`template_params_invalid`](https://docs.theavoxai.com/api/errors#template_params_invalid) | Send exactly as many parameters as the template has placeholders. |
| [`invalid_recipient`](https://docs.theavoxai.com/api/errors#invalid_recipient) | The order's phone number has no country code. Skip it, or collect numbers in international format at checkout. |
| [`recipient_unreachable`](https://docs.theavoxai.com/api/errors#recipient_unreachable) | WhatsApp couldn't deliver to the number. Fall back to email or SMS for that order. |
| [`idempotency_conflict`](https://docs.theavoxai.com/api/errors#idempotency_conflict) | The same order id was sent with a different body. Keep the body identical on retries. |
| [`meta_rate_limited`](https://docs.theavoxai.com/api/errors#meta_rate_limited) | WhatsApp is limiting how fast the number sends. Slow down, and retry after `Retry-After` with a new `Idempotency-Key` - the refusal is stored against the old one. |
| Forwarding test shows "Your endpoint answered 401" | The signature check failed. Verify over the raw body with the whole `whsec_...` secret. |

Every code, with what to do about it, is on [API errors](https://docs.theavoxai.com/api/errors).

## Limits

- WhatsApp's messaging limit caps how many different customers your number can message with
  templates each day. Big sales days can reach it - check [Health](https://docs.theavoxai.com/health). See
  [Quality and limits](https://docs.theavoxai.com/whatsapp/quality-and-limits).
- Avox rate limits apply per key and per account. See [Rate limits](https://docs.theavoxai.com/avox-api/rate-limits).
- Meta charges per template message. See [Pricing](https://docs.theavoxai.com/whatsapp-platform).

## Security

- Always verify Shopify's signature, and keep both secrets in environment variables.
- Verify the `Avox-Signature` on forwarded messages too, before acting on them.
- Message only customers who opted in, and give them an easy way to stop.
- Use a dedicated Avox API key for the store so you can revoke it on its own.

## Build your own server for Shopify

A complete server for the order-confirmation flow in your choice of framework. It verifies Shopify's `X-Shopify-Hmac-Sha256` signature over the raw request body (Shopify's own requirement), answers Shopify quickly, and sends your approved `order_confirmation` template through the Avox API with one `Idempotency-Key` per order.

```bash
# Environment
AVOX_API_KEY=avx_live_your_key
SHOPIFY_WEBHOOK_SECRET=your_shopify_webhook_signing_secret
```

**Express**

```js
// server.js - Node.js 18+ (npm install express). Run: AVOX_API_KEY=... SHOPIFY_WEBHOOK_SECRET=... node server.js
import express from "express";
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";

const app = express();

// Avox client: one Idempotency-Key per message, retries only what's safe to retry.
const RETRY_SAME_KEY = new Set(["rate_limited", "idempotency_in_progress", "internal_error", "network_error"]);
const RETRY_NEW_KEY = new Set(["meta_rate_limited"]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const backoffMs = (attempt) => Math.min(30_000, 1_000 * 2 ** (attempt - 1)) * (0.5 + Math.random() / 2);

async function sendWhatsApp(message, idempotencyKey = randomUUID(), maxAttempts = 5) {
  let key = idempotencyKey;
  for (let attempt = 1; ; attempt++) {
    const res = await fetch("https://api.avox.com/api/messages/send", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.AVOX_API_KEY}`,
        "Content-Type": "application/json",
        "Idempotency-Key": key,
      },
      body: JSON.stringify(message),
      signal: AbortSignal.timeout(30_000),
    }).catch(() => null);

    const data = res ? await res.json().catch(() => null) : null;
    const requestId = res?.headers.get("X-Request-Id") ?? null;
    if (data?.ok) {
      console.info(`[avox] sent message_id=${data.message_id} request_id=${requestId}${data.test ? " (test key)" : ""}`);
      return data;
    }

    const code = data?.error?.code ?? (res ? `http_${res.status}` : "network_error");
    console.warn(`[avox] ${code} request_id=${requestId} attempt=${attempt}`);
    const retryable = RETRY_SAME_KEY.has(code) || RETRY_NEW_KEY.has(code);
    if (!retryable || attempt >= maxAttempts) {
      throw Object.assign(new Error(`Avox send failed: ${code}`), {
        code,
        status: res?.status ?? 502,
        requestId: data?.error?.request_id ?? requestId,
        details: data?.error?.details,
      });
    }
    if (RETRY_NEW_KEY.has(code)) key = randomUUID(); // WhatsApp's refusal is stored against the old key
    const retryAfter = Number(res?.headers.get("Retry-After"));
    await sleep(retryAfter > 0 ? retryAfter * 1000 : backoffMs(attempt));
  }
}

function safeEqual(a, b) {
  const x = Buffer.from(a ?? "");
  const y = Buffer.from(b ?? "");
  return x.length > 0 && x.length === y.length && timingSafeEqual(x, y);
}

// Shopify "Order creation" webhook. Shopify signs the raw body with your webhook secret
// (X-Shopify-Hmac-Sha256, base64) - verify it before trusting anything in the order.
app.post("/shopify/orders/create", express.raw({ type: "application/json", limit: "2mb" }), async (req, res) => {
  const expected = createHmac("sha256", process.env.SHOPIFY_WEBHOOK_SECRET ?? "").update(req.body).digest("base64");
  if (!safeEqual(req.get("X-Shopify-Hmac-Sha256"), expected)) return res.sendStatus(401);
  res.sendStatus(200); // Shopify expects a fast answer - send after acknowledging

  const order = JSON.parse(req.body.toString("utf8"));
  const to = String(order.customer?.phone ?? order.shipping_address?.phone ?? "").replace(/\D/g, "");
  if (to.length < 7) return; // no usable WhatsApp number on this order

  try {
    await sendWhatsApp(
      {
        to,
        type: "template",
        template: {
          name: "order_confirmation", // your approved template
          language: { code: "en" },
          components: [
            { type: "body", parameters: [{ type: "text", text: order.name }, { type: "text", text: `${order.total_price} ${order.currency}` }] },
          ],
        },
        biz_opaque_callback_data: `shopify-order-${order.id}`,
      },
      `shopify-order-${order.id}-confirmation` // Shopify can redeliver: one key per order means one message
    );
  } catch (err) {
    console.error(`[shopify] order ${order.id}: ${err.code} request_id=${err.requestId}`);
  }
});

app.listen(process.env.PORT ?? 3000, () => console.log("Listening"));
```

**Next.js**

```ts
// app/api/shopify/orders-create/route.ts - Next.js 15+ (App Router)
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
import { after, type NextRequest } from "next/server";

export const runtime = "nodejs";

// Avox client: one Idempotency-Key per message, retries only what's safe to retry.
const RETRY_SAME_KEY = new Set(["rate_limited", "idempotency_in_progress", "internal_error", "network_error"]);
const RETRY_NEW_KEY = new Set(["meta_rate_limited"]);
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const backoffMs = (attempt: number) => Math.min(30_000, 1_000 * 2 ** (attempt - 1)) * (0.5 + Math.random() / 2);

type AvoxFailure = Error & { code: string; status: number; requestId: string | null; details?: unknown };

async function sendWhatsApp(message: Record<string, unknown>, idempotencyKey: string = randomUUID(), maxAttempts = 5) {
  let key = idempotencyKey;
  for (let attempt = 1; ; attempt++) {
    const res = await fetch("https://api.avox.com/api/messages/send", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.AVOX_API_KEY}`,
        "Content-Type": "application/json",
        "Idempotency-Key": key,
      },
      body: JSON.stringify(message),
      cache: "no-store",
      signal: AbortSignal.timeout(30_000),
    }).catch(() => null);

    const data = res ? await res.json().catch(() => null) : null;
    const requestId = res?.headers.get("X-Request-Id") ?? null;
    if (data?.ok) {
      console.info(`[avox] sent message_id=${data.message_id} request_id=${requestId}${data.test ? " (test key)" : ""}`);
      return data as { ok: true; request_id: string; message_id: string | null; test?: true; result: unknown };
    }

    const code: string = data?.error?.code ?? (res ? `http_${res.status}` : "network_error");
    console.warn(`[avox] ${code} request_id=${requestId} attempt=${attempt}`);
    const retryable = RETRY_SAME_KEY.has(code) || RETRY_NEW_KEY.has(code);
    if (!retryable || attempt >= maxAttempts) {
      throw Object.assign(new Error(`Avox send failed: ${code}`), {
        code,
        status: res?.status ?? 502,
        requestId: data?.error?.request_id ?? requestId,
        details: data?.error?.details,
      }) as AvoxFailure;
    }
    if (RETRY_NEW_KEY.has(code)) key = randomUUID(); // WhatsApp's refusal is stored against the old key
    const retryAfter = Number(res?.headers.get("Retry-After"));
    await sleep(retryAfter > 0 ? retryAfter * 1000 : backoffMs(attempt));
  }
}

function safeEqual(a: string | null, b: string | undefined) {
  const x = Buffer.from(a ?? "");
  const y = Buffer.from(b ?? "");
  return x.length > 0 && x.length === y.length && timingSafeEqual(x, y);
}

// Shopify "Order creation" webhook -> https://your-app.example.com/api/shopify/orders-create
// Shopify signs the raw body with your webhook secret (X-Shopify-Hmac-Sha256, base64) - verify it first.
export async function POST(request: NextRequest) {
  const raw = Buffer.from(await request.arrayBuffer());
  const expected = createHmac("sha256", process.env.SHOPIFY_WEBHOOK_SECRET ?? "").update(raw).digest("base64");
  if (!safeEqual(request.headers.get("x-shopify-hmac-sha256"), expected)) {
    return new Response(null, { status: 401 });
  }

  const order = JSON.parse(raw.toString("utf8"));
  const to = String(order.customer?.phone ?? order.shipping_address?.phone ?? "").replace(/\D/g, "");
  if (to.length < 7) return new Response(null, { status: 200 }); // no usable WhatsApp number

  // Shopify expects a fast answer, so the send runs after the response.
  after(async () => {
    try {
      await sendWhatsApp(
        {
          to,
          type: "template",
          template: {
            name: "order_confirmation", // your approved template
            language: { code: "en" },
            components: [
              { type: "body", parameters: [{ type: "text", text: order.name }, { type: "text", text: `${order.total_price} ${order.currency}` }] },
            ],
          },
          biz_opaque_callback_data: `shopify-order-${order.id}`,
        },
        `shopify-order-${order.id}-confirmation` // Shopify can redeliver: one key per order means one message
      );
    } catch (err) {
      const e = err as AvoxFailure;
      console.error(`[shopify] order ${order.id}: ${e.code} request_id=${e.requestId}`);
    }
  });
  return new Response(null, { status: 200 });
}
```

**FastAPI**

```python
# main.py - pip install fastapi uvicorn httpx ; run: uvicorn main:app --port 3000
import asyncio
import base64
import hashlib
import hmac
import json
import logging
import os
import random
import uuid

import httpx
from fastapi import BackgroundTasks, FastAPI, Request
from fastapi.responses import Response

log = logging.getLogger("avox")
app = FastAPI()
http = httpx.AsyncClient(timeout=30)

# Avox client: one Idempotency-Key per message, retries only what's safe to retry.
RETRY_SAME_KEY = {"rate_limited", "idempotency_in_progress", "internal_error", "network_error"}
RETRY_NEW_KEY = {"meta_rate_limited"}


class AvoxError(Exception):
    def __init__(self, code, status, request_id, details=None):
        super().__init__(f"Avox send failed: {code}")
        self.code, self.status, self.request_id, self.details = code, status, request_id, details


def backoff(attempt):
    return min(30.0, 2 ** (attempt - 1)) * (0.5 + random.random() / 2)


async def send_whatsapp(message: dict, idempotency_key: str | None = None, max_attempts: int = 5) -> dict:
    key = idempotency_key or str(uuid.uuid4())
    attempt = 0
    while True:
        attempt += 1
        res, data = None, None
        try:
            res = await http.post(
                "https://api.avox.com/api/messages/send",
                headers={"Authorization": f"Bearer {os.environ['AVOX_API_KEY']}", "Idempotency-Key": key},
                json=message,
            )
            data = res.json()
        except (httpx.TransportError, ValueError):
            pass

        request_id = res.headers.get("X-Request-Id") if res is not None else None
        if data and data.get("ok"):
            log.info("sent message_id=%s request_id=%s", data.get("message_id"), request_id)
            return data

        error = (data or {}).get("error") or {}
        code = error.get("code") or (f"http_{res.status_code}" if res is not None else "network_error")
        log.warning("%s request_id=%s attempt=%s", code, request_id, attempt)
        if code not in RETRY_SAME_KEY | RETRY_NEW_KEY or attempt >= max_attempts:
            raise AvoxError(code, res.status_code if res is not None else 502,
                            error.get("request_id", request_id), error.get("details"))
        if code in RETRY_NEW_KEY:
            key = str(uuid.uuid4())  # WhatsApp's refusal is stored against the old key
        retry_after = int(res.headers.get("Retry-After") or 0) if res is not None else 0
        await asyncio.sleep(retry_after if retry_after > 0 else backoff(attempt))


async def send_order_confirmation(to: str, order: dict) -> None:
    try:
        await send_whatsapp(
            {
                "to": to,
                "type": "template",
                "template": {
                    "name": "order_confirmation",  # your approved template
                    "language": {"code": "en"},
                    "components": [{"type": "body", "parameters": [
                        {"type": "text", "text": order["name"]},
                        {"type": "text", "text": f"{order['total_price']} {order['currency']}"},
                    ]}],
                },
                "biz_opaque_callback_data": f"shopify-order-{order['id']}",
            },
            idempotency_key=f"shopify-order-{order['id']}-confirmation",  # one key per order means one message
        )
    except AvoxError as err:
        log.error("order %s: %s request_id=%s", order["id"], err.code, err.request_id)


# Shopify "Order creation" webhook. Shopify signs the raw body with your webhook secret
# (X-Shopify-Hmac-Sha256, base64) - verify it before trusting anything in the order.
@app.post("/shopify/orders/create")
async def shopify_order_created(request: Request, background: BackgroundTasks):
    raw = await request.body()
    digest = hmac.new(os.environ["SHOPIFY_WEBHOOK_SECRET"].encode(), raw, hashlib.sha256).digest()
    expected = base64.b64encode(digest)
    if not hmac.compare_digest(request.headers.get("x-shopify-hmac-sha256", "").encode(), expected):
        return Response(status_code=401)

    order = json.loads(raw)
    phone = (order.get("customer") or {}).get("phone") or (order.get("shipping_address") or {}).get("phone") or ""
    to = "".join(ch for ch in phone if ch.isdigit())
    if len(to) >= 7:
        background.add_task(send_order_confirmation, to, order)  # Shopify expects a fast answer: send afterwards
    return Response(status_code=200)
```

Each version is a complete file: the Avox client (an `Idempotency-Key` per message, retries with backoff that honour `Retry-After`, and `error.code` and `request_id` in every log line) plus the Shopify route, which answers Shopify first and sends afterwards. Run Express with Node.js 18 or later, the Next.js route in an App Router project on Next.js 15 or later (it uses `after`), and FastAPI with Python 3.10 or later. For the full walkthrough - key setup, local testing with a tunnel, a deployment checklist - see [Connect your server](https://docs.theavoxai.com/avox-api/connect-your-server).

### Receiver

Run this next to your Shopify server (or add its route to it) to receive customer replies. Put your own logic - for example, matching `data.from` to a recent order - in `handleEvent`.

```bash
# Environment of the receiver
AVOX_WEBHOOK_SECRET=whsec_your_signing_secret
```

**Express**

```js
// receiver.js - Node.js 18+ (npm install express). Run: AVOX_WEBHOOK_SECRET=whsec_... node receiver.js
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

// Avox-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>"> (two v1 values right after a rotation).
// The key is the whole signing secret, whsec_ included, as plain text.
function verifyAvoxSignature(rawBody, header, secret) {
  if (!header || !secret) return false;
  let timestamp = null;
  const signatures = [];
  for (const part of header.split(",")) {
    const i = part.indexOf("=");
    if (i <= 0) continue;
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
    else if (key === "v1" && /^[0-9a-f]{64}$/i.test(value)) signatures.push(value);
  }
  if (timestamp === null || Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
  const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
  return signatures.some((sig) => timingSafeEqual(Buffer.from(sig, "hex"), expected));
}

// Retries and Resend keep the same event id. Keep handled ids in your database in production.
const seen = new Set();

async function handleEvent(event) {
  if (seen.has(event.id)) return;
  seen.add(event.id);
  if (seen.size > 10_000) seen.delete(seen.values().next().value);

  // Optional: pass the verified event on unchanged - for example to a webhook trigger in your automation tool.
  if (process.env.FORWARD_TO_URL) {
    const res = await fetch(process.env.FORWARD_TO_URL, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(event),
      signal: AbortSignal.timeout(10_000),
    });
    if (!res.ok) console.warn(`[avox] passing on ${event.id} failed: HTTP ${res.status}`);
    return;
  }

  if (event.test) return console.info(`[avox] test event ${event.id} received`);
  const { from, contact_name, type, text } = event.data;
  console.info(`[avox] ${type} from ${contact_name ?? from}${text ? `: ${text.body}` : ""}`);
}

const app = express();

// express.raw keeps the exact bytes the signature covers - don't parse JSON on this route before checking it.
app.post("/avox/messages", express.raw({ type: "application/json", limit: "1mb" }), (req, res) => {
  if (!Buffer.isBuffer(req.body) || !verifyAvoxSignature(req.body, req.get("Avox-Signature"), process.env.AVOX_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(req.body.toString("utf8"));
  res.sendStatus(200); // answer within 10 seconds, then do the work
  handleEvent(event).catch((err) => console.error("[avox] handling failed:", err.message));
});

app.listen(process.env.PORT ?? 3000, () => console.log("Listening"));
```

**Next.js**

```ts
// app/avox/messages/route.ts - Next.js 15+ (App Router)
import { createHmac, timingSafeEqual } from "node:crypto";
import { after } from "next/server";

export const runtime = "nodejs";
const TOLERANCE_SECONDS = 5 * 60;

type AvoxEvent = {
  id: string;
  type: "message.received";
  test?: true;
  connection_id: string;
  data: { message_id: string; from: string; contact_name: string | null; type: string; text?: { body: string } };
};

// Avox-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>"> (two v1 values right after a rotation).
function verifyAvoxSignature(rawBody: string, header: string | null, secret: string | undefined): boolean {
  if (!header || !secret) return false;
  let timestamp: number | null = null;
  const signatures: string[] = [];
  for (const part of header.split(",")) {
    const i = part.indexOf("=");
    if (i <= 0) continue;
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
    else if (key === "v1" && /^[0-9a-f]{64}$/i.test(value)) signatures.push(value);
  }
  if (timestamp === null || Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
  const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`, "utf8").digest();
  return signatures.some((sig) => timingSafeEqual(Buffer.from(sig, "hex"), expected));
}

// Retries and Resend keep the same event id. Per instance only - keep handled ids in your database in production.
const seen = new Set<string>();

async function handleEvent(event: AvoxEvent) {
  if (seen.has(event.id)) return;
  seen.add(event.id);
  if (seen.size > 10_000) seen.delete(seen.values().next().value!);

  // Optional: pass the verified event on unchanged - for example to a webhook trigger in your automation tool.
  if (process.env.FORWARD_TO_URL) {
    const res = await fetch(process.env.FORWARD_TO_URL, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(event),
      signal: AbortSignal.timeout(10_000),
    });
    if (!res.ok) console.warn(`[avox] passing on ${event.id} failed: HTTP ${res.status}`);
    return;
  }

  if (event.test) return console.info(`[avox] test event ${event.id} received`);
  const { from, contact_name, type, text } = event.data;
  console.info(`[avox] ${type} from ${contact_name ?? from}${text ? `: ${text.body}` : ""}`);
}

export async function POST(request: Request) {
  const rawBody = await request.text(); // the exact body, before any parsing
  if (!verifyAvoxSignature(rawBody, request.headers.get("avox-signature"), process.env.AVOX_WEBHOOK_SECRET)) {
    return new Response(null, { status: 401 });
  }
  const event = JSON.parse(rawBody) as AvoxEvent;
  after(() => handleEvent(event).catch((err) => console.error("[avox] handling failed:", err))); // runs after the answer
  return new Response(null, { status: 200 });
}
```

**FastAPI**

```python
# receiver.py - pip install fastapi uvicorn httpx ; run: uvicorn receiver:app --port 3000
import hashlib
import hmac
import json
import logging
import os
import re
import time
from collections import OrderedDict

import httpx
from fastapi import BackgroundTasks, FastAPI, Request, Response

log = logging.getLogger("avox")
app = FastAPI()
TOLERANCE_SECONDS = 5 * 60
HEX64 = re.compile(r"[0-9a-fA-F]{64}")

# Retries and Resend keep the same event id. Per process only - keep handled ids in your database in production.
seen: "OrderedDict[str, None]" = OrderedDict()


def verify_avox_signature(raw: bytes, header: str | None, secret: str) -> bool:
    # Avox-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>"> (two v1 values right after a rotation).
    if not header or not secret:
        return False
    timestamp, signatures = None, []
    for part in header.split(","):
        key, _, value = part.strip().partition("=")
        if key == "t" and value.isdigit():
            timestamp = int(value)
        elif key == "v1" and HEX64.fullmatch(value):
            signatures.append(value.lower())
    if timestamp is None or abs(time.time() - timestamp) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, sig) for sig in signatures)


async def handle_event(event: dict) -> None:
    if event["id"] in seen:
        return
    seen[event["id"]] = None
    if len(seen) > 10_000:
        seen.popitem(last=False)

    # Optional: pass the verified event on unchanged - for example to a webhook trigger in your automation tool.
    forward_to = os.environ.get("FORWARD_TO_URL")
    if forward_to:
        try:
            async with httpx.AsyncClient(timeout=10) as client:
                res = await client.post(forward_to, json=event)
            if res.status_code >= 300:
                log.warning("passing on %s failed: HTTP %s", event["id"], res.status_code)
        except httpx.HTTPError as err:
            log.warning("passing on %s failed: %s", event["id"], err)
        return

    if event.get("test"):
        log.info("test event %s received", event["id"])
        return
    data = event["data"]
    log.info("%s from %s %s", data["type"], data.get("contact_name") or data["from"], (data.get("text") or {}).get("body", ""))


@app.post("/avox/messages")
async def receive(request: Request, background: BackgroundTasks):
    raw = await request.body()  # the exact bytes, before any parsing
    if not verify_avox_signature(raw, request.headers.get("avox-signature"), os.environ.get("AVOX_WEBHOOK_SECRET", "")):
        return Response(status_code=401)
    background.add_task(handle_event, json.loads(raw))  # runs after the answer
    return Response(status_code=200)
```

Each receiver checks the `Avox-Signature` header over the raw body before trusting anything, refuses a timestamp more than 5 minutes from your clock, accepts either signature during the 24 hours after you rotate the secret, answers `200` straight away and does the work afterwards, and skips repeats by event id. With `FORWARD_TO_URL` set it passes every verified event on unchanged - including test events, which carry `"test": true` so you can filter them. The full payload for every message type, the retry schedule and auto-pause are on [Receive messages](https://docs.theavoxai.com/avox-api/webhooks#receive-messages).

## FAQ

**Do I need to install a Shopify app?**

No. Shopify's own webhooks and the code on this page connect the two, and you stay in control of
which events send which message.

**Can I send shipping updates too?**

Yes. Add a webhook for order fulfillment, and send `order_shipped` with the tracking link. Use a
different `Idempotency-Key`, such as `shopify-order-<id>-shipped`.

**Do I need a template for every message?**

For messages your store starts, yes. If the customer replies, you can answer with any message type
for 24 hours. See [Messaging window](https://docs.theavoxai.com/whatsapp/messaging-window).

**Where do customer replies go?**

To the Avox [Inbox](https://docs.theavoxai.com/inbox), where your team can answer them, and to your own server too if you
turn on message forwarding. Delivery statuses for each order message are on [Logs](https://docs.theavoxai.com/logs) - the
status payload carries your `biz_opaque_callback_data`, so you can see which order each one belongs to.
