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.
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. Customer replies land in your Avox Inbox, and message forwarding can send them to your server too.
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.
https:// (Node.js 18 or later for the code below, or Python 3.10 or later).# .env
SHOPIFY_WEBHOOK_SECRET=your_shopify_webhook_signing_secret
AVOX_API_KEY=avx_live_your_key
Create these on Create and wait for approval. Use the Utility category - they're about an order the customer placed. See Template categories.
Open API Keys
Pick the key type
Choose the number
Create and copy it
AVOX_API_KEY. It's shown only once.Check it
Use a dedicated key for the store, so you can rotate or revoke it on its own. See API keys.
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.
// 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);
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.
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.
Create and approve the templates
Create the Avox API key
AVOX_API_KEY.Deploy the server
https://your-server.example.com/shopify/orders/create reachable.Add the Shopify webhook
SHOPIFY_WEBHOOK_SECRET.Handle replies
"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.Place a test order
Check your server
"ok": true with a message_id from Avox.Check the phone
Check Avox
200. The API logs page (Sent tab) has the full history - search it by request_id or message_id.Check delivery
biz_opaque_callback_data.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:
Open Message forwarding
Save your URL
https://your-server.example.com/avox/messages, in Endpoint URL and click Save URL.Turn it on and copy the secret
AVOX_WEBHOOK_SECRET - it's shown only once.Send a test event
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 below and put your own logic in
handleEvent. The full payload for every message type is on
Receive messages.
Every code, with what to do about it, is on API errors.
Avox-Signature on forwarded messages too, before acting on them.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.
# Environment
AVOX_API_KEY=avx_live_your_key
SHOPIFY_WEBHOOK_SECRET=your_shopify_webhook_signing_secret
// 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"));
// 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 });
}
# 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.
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.
# Environment of the receiver
AVOX_WEBHOOK_SECRET=whsec_your_signing_secret
// 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"));
// 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 });
}
# 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.
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.
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.
For messages your store starts, yes. If the customer replies, you can answer with any message type for 24 hours. See Messaging window.
Last updated