Send Shopify order updates on WhatsApp
This guide is the build companion to WhatsApp order updates for ecommerce, which covers which updates are worth sending.
What you need
- A WhatsApp inbox. Copy its
idfrom List inboxes. - Two approved templates.
order_confirmedtakes the first name and order name as body placeholders, andorder_shippedtakes the first name, order name and tracking link. Find names and languages with List WhatsApp templates. - A service account API key. Store it outside the code, as described in Authentication.
- A Shopify app. It needs the
read_ordersaccess scope, and you need its client secret. - A web server. For Node.js 18 or later, install
expressandlibphonenumber-js. For Python, installflask,requestsandphonenumbers. For PHP, installguzzlehttp/guzzleandgiggsey/libphonenumber-for-php.
Request access to customer phone numbers
Shopify treats phone numbers as protected customer data. Without approved access to the phone field, Shopify redacts it from webhook payloads, and there's no number to message. See Shopify's protected customer data requirements.
Order updates usually reach customers outside the 24-hour reply window, so they need approved templates. Send a template's name and language exactly as listed, and only for templates with status APPROVED.
Which Shopify webhooks should I subscribe to?
Each topic should match a moment the customer cares about: the order is confirmed, and then it's on its way.
Order placed → orders/create → Confirmation template
Order fulfilled → orders/fulfilled → Shipping template
orders/fulfilled arrives once every item in the order is fulfilled. Add orders/partially_fulfilled if you ship orders in parts.
Point the subscriptions at your endpoint, for example https://shop-api.example.com/webhooks/shopify. Shopify's webhook guide covers subscribing in the app configuration or through the Admin API. Each delivery names its topic in the X-Shopify-Topic header.
How do I verify the X-Shopify-Hmac-Sha256 header?
Your endpoint is public, so anyone can post an order to it. Checking the signature makes sure only Shopify can make you message a customer.
Shopify signs each delivery with HMAC-SHA256 over the raw request body, using your app's client secret as the key. It sends the base64-encoded result in X-Shopify-Hmac-Sha256.
// shopify-hmac.js
import crypto from "node:crypto";
export function isFromShopify(rawBody, hmacHeader) {
const expected = crypto
.createHmac("sha256", process.env.SHOPIFY_CLIENT_SECRET)
.update(rawBody)
.digest();
const received = Buffer.from(hmacHeader ?? "", "base64");
// timingSafeEqual throws on buffers of different length.
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}Use the raw body
Compute the digest over the exact bytes you received. A body that was parsed and serialized again won't match the signature.
Receive Shopify order webhooks
Shopify delivers the webhook → Signature checked → Template sent → 200 to Shopify
The endpoint reads the raw body, rejects unsigned requests, and sends a template for each order topic. It reads FLOWNALLY_API_TOKEN, FLOWNALLY_INBOX_ID and SHOPIFY_CLIENT_SECRET from the environment.
// server.js
import express from "express";
import { parsePhoneNumberFromString } from "libphonenumber-js";
import { isFromShopify } from "./shopify-hmac.js";
const FLOWNALLY_API = "https://api.flownally.com/v1";
// Template and body parameters for each Shopify topic.
const UPDATES = {
"orders/create": (order) => ({
template: "order_confirmed",
parameters: [firstName(order), order.name],
}),
"orders/fulfilled": (order) => ({
template: "order_shipped",
parameters: [
firstName(order),
order.name,
order.fulfillments?.[0]?.tracking_url || order.order_status_url,
],
}),
};
const app = express();
app.post("/webhooks/shopify", express.raw({ type: "application/json" }), async (req, res) => {
if (!isFromShopify(req.body, req.get("X-Shopify-Hmac-Sha256"))) {
return res.sendStatus(401);
}
try {
await sendOrderUpdate(req.get("X-Shopify-Topic"), JSON.parse(req.body));
res.sendStatus(200);
} catch (error) {
// Any status other than 200 makes Shopify deliver the webhook again.
res.sendStatus(error.retryable === false ? 200 : 503);
}
});
app.listen(3000);
async function sendOrderUpdate(topic, order) {
const update = UPDATES[topic]?.(order);
const phoneNumber = orderPhone(order);
if (!update || !phoneNumber) return;
await flownally(
"/messages",
{
inboxId: process.env.FLOWNALLY_INBOX_ID,
recipient: { type: "phone", phoneNumber },
content: {
type: "template",
template: {
name: update.template,
language: "en",
components: [
{
type: "body",
parameters: update.parameters.map((text) => ({ type: "text", text })),
},
],
},
},
},
`shopify-order-${order.id}-${update.template}`,
);
}
// Shopify phone numbers can be in national format, so parse them with the order's country.
function orderPhone(order) {
const raw =
order.phone ||
order.customer?.phone ||
order.shipping_address?.phone ||
order.billing_address?.phone;
const country = order.shipping_address?.country_code || order.billing_address?.country_code;
const phone = raw ? parsePhoneNumberFromString(raw, country) : undefined;
return phone?.isValid() ? phone.number : null;
}
function firstName(order) {
return order.customer?.first_name || "there";
}
async function flownally(path, body, idempotencyKey) {
const response = await fetch(`${FLOWNALLY_API}${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.FLOWNALLY_API_TOKEN}`,
"Content-Type": "application/json",
...(idempotencyKey && { "Idempotency-Key": idempotencyKey }),
},
body: JSON.stringify(body),
});
if (!response.ok) {
throw Object.assign(new Error(`Flownally ${path} returned ${response.status}`), {
status: response.status,
retryable: response.status === 429 || response.status >= 500,
});
}
return response.json();
}A 202 from Flownally means the message was accepted for delivery. Keep the response's message.id if you want to check delivery later.
How should the endpoint handle Shopify retries?
Shopify delivers a webhook again whenever it doesn't get 200, and it can deliver the same webhook more than once even without errors. Without a stable Idempotency-Key, every repeat would be another WhatsApp message to the customer.
- Answer
200only after Flownally accepts the message. Any other status makes Shopify try again later. - Send the same key on every repeat. The key combines the Shopify order ID and the template, so a repeated delivery returns the original message instead of sending a new one.
- Let Shopify retry
429and5xx. The endpoint answers503, which also covers503with codemessaging_messaging_send_result_pending, a response that must be retried with the same key. - Stop retries on other
4xxerrors. Answer200and log thecode, because a retry won't help until the template, inbox or phone number is fixed. - Stay under five seconds. Shopify waits five seconds for the response. If you do more work per order, queue the payload, answer
200, and send from a worker.
When can I send free-form text instead?
If the customer messaged you within the last 24 hours, plain text works. Replace content with:
{
"type": "text",
"text": {
"body": "Hi Anna, order #1001 is packed and leaves the warehouse today."
}
}Outside the window, a text message fails with failureCode messaging_window_closed. Check the window first as shown in Handle the WhatsApp 24-hour reply window.
Let a journey send Shopify order updates
Templates in code mean a deploy for every wording change. With events, your marketing team changes the template, its variables or the timing in the Flownally app on their own.
Shopify webhook → Event recorded → Journey sends the template
Create one event type per update with Create event type. payloadSchema lists the payload fields by name, each with a type. It's a simplified format, not a JSON Schema document.
curl -X POST "https://api.flownally.com/v1/event-types" \
-H "Authorization: Bearer $FLOWNALLY_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "order.shipped",
"description": "Every item in the order was fulfilled.",
"payloadSchema": {
"orderNumber": { "type": "string" },
"firstName": { "type": "string" },
"trackingUrl": { "type": "string" }
}
}'Create order.confirmed the same way, with orderNumber, firstName, total (number) and currency. Then replace sendOrderUpdate (send_order_update in Python) with a function that finds the contact by phone number and records the event with Create contact event:
const EVENTS = {
"orders/create": (order) => ({
name: "order.confirmed",
payload: {
orderNumber: order.name,
firstName: firstName(order),
total: Number(order.total_price),
currency: order.currency,
},
}),
"orders/fulfilled": (order) => ({
name: "order.shipped",
payload: {
orderNumber: order.name,
firstName: firstName(order),
trackingUrl: order.fulfillments?.[0]?.tracking_url || order.order_status_url,
},
}),
};
async function sendOrderUpdate(topic, order) {
const event = EVENTS[topic]?.(order);
const phone = orderPhone(order);
if (!event || !phone) return;
const contactId = await findOrCreateContact(phone, order);
try {
await flownally(`/contacts/${contactId}/events`, {
...event,
idempotencyKey: `shopify-order-${order.id}-${event.name}`,
});
} catch (error) {
// 409 means an event with this idempotency key was already recorded.
if (error.status !== 409) throw error;
}
}
async function findOrCreateContact(phone, order) {
const { contacts } = await flownally("/contacts:search", {
query: {
match: "all",
conditions: [
{ type: "customFieldText", fieldId: "cf_phone", operator: "equals", value: phone },
{ type: "archiveStatus", status: "all" },
],
},
pageSize: 1,
});
if (contacts.length > 0) return contacts[0].id;
const name = [order.customer?.first_name, order.customer?.last_name].filter(Boolean).join(" ");
const contact = await flownally("/contacts", { name, phone });
return contact.id;
}Create contact event fails with 400 when the payload doesn't match the event type's fields, and with 404 when the contact or event type doesn't exist.
Then build one journey per event type in the Flownally app, following How to create a journey. Start it when the event happens, add a Send message step with the template, and fill the template variables from the event details.
Allow contacts to enter again
A journey lets each contact in once by default. Allow contacts to enter again in the journey's Start block, or each customer gets only their first order update.
How do I know the customer got the update?
An accepted message can still fail, for example with failureCode template_unavailable when the template is missing or its parameters don't match, or with recipient_opted_out.
- Poll the message. Call Get message with the
message.idfrom the send response, and readdeliveryStatusandfailureCode. - Subscribe to webhooks. Flownally sends
message.updatedwebhooks, signed with Standard Webhooks rather than Shopify's HMAC scheme. Verify them as described in Verify webhook signatures.
FAQ
Why does every Shopify webhook fail signature verification?
The body was most likely parsed before the check, so the digest covers serialized JSON instead of the bytes Shopify sent. Read the raw body before anything parses it: express.raw({ type: "application/json" }) in Express, request.get_data() in Flask, or file_get_contents('php://input') in PHP. Use the app's client secret as the key. After you rotate the client secret, Shopify can take up to an hour to sign with the new one.
Why is the phone number missing from the order?
Shopify redacts protected customer fields when your app has no approved access to them. Some customers also don't leave a number at checkout. The endpoint skips orders without a valid number, so check the payload before you look for errors in Flownally.
Will a repeated webhook send the template twice?
No. A repeated request with the same Idempotency-Key returns the original message instead of sending it again. Reusing a key for a different request returns 409 with code messaging_messaging_idempotency_conflict.
Can I send an update for each partial shipment?
Yes, subscribe to orders/partially_fulfilled and add a template for it. Build its key from the fulfillment ID, not only the order ID, because an order can be partially fulfilled more than once and each shipment needs its own key.