--- title: Order Notifications for an Online Store: Confirmation Email and WhatsApp Shipping Updates from Your Store's Webhooks description: Build an order confirmation email API flow and WhatsApp shipping notifications from Shopify or WooCommerce webhooks, with email fallback, in Node. date: 2026-10-19 author: Zavu Team locale: en source: https://www.zavu.dev/en/blog/order-notifications-ecommerce tags: Tutorial, API v2, E-commerce, Email, WhatsApp, Webhooks --- # Order Notifications for an Online Store: Confirmation Email and WhatsApp Shipping Updates from Your Store's Webhooks ## TL;DR - **The confirmation email goes out when the order is paid.** Your store's webhook triggers `POST /v2/messages` from an address on a verified domain. - **No message is sent twice.** Each send carries an `idempotencyKey` built from the order id and the event, so a retried webhook gets `409 conflict` and sends nothing. - **The shipping update is an approved WhatsApp utility template** with the tracking number, and Meta has to approve it before you send it. - **Email is the fallback.** When the customer has no WhatsApp number, the send is refused, or it fails later as `message.failed`, your code sends the update by email. - **Contacts stay in sync.** Every order updates the customer's contact in Zavu. A customer pays, and the first thing they want is proof: an order confirmation email, sent through an API the moment the order is paid, with what they bought. A few days later they want to know where the box is, and a shipping notification on WhatsApp gets read where a second email often does not. Both should come out of your store on their own, from the webhooks Shopify or WooCommerce already sends. This tutorial builds that with the Zavu API v2, in one small Node/TypeScript service. At the end you have: - A verified email domain and an address like `orders@yourstore.com` connected as a channel. - An endpoint that receives your store's "order paid" and "order fulfilled" webhooks. - An order confirmation email, sent through the API, that is never sent twice for the same order. - A shipping update as an approved WhatsApp utility template, with the tracking number. - A fallback to email when the customer has no WhatsApp number or the send fails. - Contacts in Zavu kept in sync with your orders. ## What you need - **A Zavu account and an API key.** v2 uses the same key as v1, as a Bearer token. Base URL: `https://api.zavu.dev/v2`. - **An email channel.** It needs a domain you control and access to its DNS. Publishing the records takes minutes; propagation can take hours, so start here. - **WhatsApp, if you want the shipping update there.** This is the channel with the most friction: it connects through Meta's signup from **Channels** in the dashboard, and business-initiated messages need a template Meta has approved. If you skip it, the same code sends the shipping update by email. - **A store that sends webhooks.** Shopify and WooCommerce both do. The code below maps Shopify's order payload; the mapping is one function, so WooCommerce or anything else is a change in one place. A note for US stores: SMS and one-way SMS to +1 numbers need an approved 10DLC campaign, and without one a send is refused with `403 ten_dlc_required`. Email and WhatsApp need no 10DLC, which is one reason this tutorial uses them. If you want to add SMS later, read [what A2P 10DLC is](/en/blog/what-is-a2p-10dlc) first. The SDKs (`@zavudev/sdk`, `zavudev`) call v1 today. Everything here calls v2 over HTTP with `fetch`. If you are new to v2, [Zavu 2.0](/en/blog/zavu-2-0) explains what changed: you send `from` an address, and an error tells you what to connect. ## 1. Verify your email domain and connect the address Add the domain: ```bash curl -X POST https://api.zavu.dev/v2/email-domains \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"domain": "yourstore.com"}' ``` The answer carries the DNS records to publish: ```json { "domain": { "id": "emd_abc123", "domain": "yourstore.com", "status": "pending", "verifiable": true, "dnsRecords": [ { "type": "CNAME", "name": "...", "value": "...", "purpose": "dkim", "required": true }, { "type": "TXT", "name": "...", "value": "...", "purpose": "spf", "required": false }, { "type": "TXT", "name": "...", "value": "...", "purpose": "dmarc", "required": false } ] } } ``` Publish every record exactly as returned. The `dkim` records are required; SPF, DMARC and MAIL FROM are recommended, and worth publishing for deliverability. Adding a domain the project already has answers `200` instead of `201` and returns the same domain. If `verifiable` is `false`, the domain came back without DKIM records and cannot verify whatever you publish. `setupError` says so: delete the domain and add it again. Then ask Zavu to check the DNS: ```bash curl -X POST https://api.zavu.dev/v2/email-domains/emd_abc123/verify \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` It returns the domain with its current `status`. Repeat until it is `verified`, or subscribe your webhook to `domain.verified`. A `503 verification_unavailable` means the check itself could not run; nothing changed, retry in a few minutes. Now connect the address as a channel: ```bash curl -X POST https://api.zavu.dev/v2/channels \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"type": "email", "address": "orders@yourstore.com", "fromName": "Your Store"}' ``` The `201` lists the entries of that address, with `active`. If the domain is not verified yet, the answer is `409 email_domain_not_verified`, with the domain's `status` and the calls that fix it in `details.recommendation`. A domain the project does not have at all answers `409 email_domain_missing`. If `active` is `false`, switch it on with `POST /v2/channels/orders@yourstore.com/email/activate`. ## 2. Receive the store's webhooks Your store calls you when something happens to an order. In Shopify that is a webhook subscription to `orders/paid` and `orders/fulfilled`; in WooCommerce, the order webhooks (`order.created`, `order.updated`), where you look at the order `status`. Both sign the body with a secret: Shopify in `X-Shopify-Hmac-Sha256`, WooCommerce in `X-WC-Webhook-Signature`, each a base64 HMAC-SHA256 of the raw body. Verify it before you trust the payload. Keep the platform out of the rest of the code. One function turns its payload into your own `Order`: ```ts // order.ts export interface Order { id: string number: string // what the customer sees, e.g. "#1042" firstName: string email?: string phone?: string // E.164, e.g. "+14155550123" total: string currency: string items: { name: string; quantity: number }[] shipment?: { id: string; carrier?: string; trackingNumber?: string; trackingUrl?: string } } // Shopify order payload -> Order. Swap this function for another platform. export function fromShopify(o: any): Order { const f = o.fulfillments?.[o.fulfillments.length - 1] return { id: String(o.id), number: o.name, firstName: o.customer?.first_name ?? "", email: o.email || undefined, phone: o.phone || o.shipping_address?.phone || undefined, total: o.total_price, currency: o.currency, items: (o.line_items ?? []).map((i: any) => ({ name: i.name, quantity: i.quantity })), shipment: f ? { id: String(f.id), carrier: f.tracking_company ?? undefined, trackingNumber: f.tracking_number ?? undefined, trackingUrl: f.tracking_url ?? undefined } : undefined, } } ``` The endpoint: ```ts // server.ts import express from "express" import crypto from "node:crypto" import { fromShopify } from "./order" import { sendConfirmation, sendShippingUpdate, upsertContact } from "./notify" const app = express() function shopifySignatureOk(raw: Buffer, header: string | undefined) { if (!header) return false const digest = crypto.createHmac("sha256", process.env.SHOPIFY_WEBHOOK_SECRET!).update(raw).digest("base64") const a = Buffer.from(digest), b = Buffer.from(header) return a.length === b.length && crypto.timingSafeEqual(a, b) } app.post("/webhooks/store", express.raw({ type: "application/json" }), async (req, res) => { if (!shopifySignatureOk(req.body, req.get("X-Shopify-Hmac-Sha256"))) return res.sendStatus(401) const topic = req.get("X-Shopify-Topic") const order = fromShopify(JSON.parse(req.body.toString("utf8"))) try { await upsertContact(order) if (topic === "orders/paid") await sendConfirmation(order) if (topic === "orders/fulfilled") await sendShippingUpdate(order) res.sendStatus(200) } catch (err) { console.error(err) res.sendStatus(500) // the store retries; idempotency keys make the retry safe } }) app.listen(3000) ``` Stores retry webhooks they consider failed, and sometimes deliver the same one twice. That is fine here, because every send below carries an `idempotencyKey`. A small helper for the Zavu calls: ```ts // zavu.ts export async function zavu(path: string, method = "POST", body?: unknown) { const res = await fetch(`https://api.zavu.dev/v2${path}`, { method, headers: { Authorization: `Bearer ${process.env.ZAVUDEV_API_KEY}`, "Content-Type": "application/json" }, body: body ? JSON.stringify(body) : undefined, }) const json = res.status === 204 ? null : await res.json() return { status: res.status, json } } ``` Every v2 error is flat, `{code, message, details}`, so you branch on `json.code`. ## 3. Send the order confirmation email through the API ```ts // notify.ts import { zavu } from "./zavu" import type { Order } from "./order" const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]!)) export async function sendConfirmation(order: Order) { if (!order.email) return null const lines = order.items.map((i) => `${i.quantity} x ${i.name}`) const { status, json } = await zavu("/messages", "POST", { from: process.env.ZAVU_EMAIL_FROM, // "orders@yourstore.com" to: order.email, subject: `Order ${order.number} confirmed`, text: `Hi ${order.firstName}, we received your order ${order.number}.\n\n${lines.join("\n")}\n\nTotal: ${order.total} ${order.currency}`, htmlBody: `
Hi ${esc(order.firstName)}, we received your order ${esc(order.number)}.
Total: ${esc(order.total)} ${esc(order.currency)}
`, replyTo: "support@yourstore.com", idempotencyKey: `${order.id}:confirmed`, metadata: { orderId: order.id, event: "confirmed" }, }) if (status === 202) return json.message.id if (status === 409 && json.code === "conflict") return json.details.message.id // already sent throw new Error(`${json.code}: ${json.message}`) } ``` `subject` is required on an email; `text` is the plain-text body and `htmlBody` the HTML one. The `202` says how the send was routed: ```json { "message": { "id": "jd7x...", "to": "jane@example.com", "from": "orders@yourstore.com", "channel": "email", "status": "queued" }, "routing": { "channel": "email", "from": "orders@yourstore.com", "reason": "recipient_type", "estimatedCost": null } } ``` `reason: "recipient_type"`: `to` is an email address, so email. `estimatedCost` is `null` because email is not priced per message at send time. The second time the same key arrives (a retried webhook), nothing is sent. The answer is `409 conflict` and the original message is in `details.message`. A key is remembered for the project, so build it from the order id and the event, never from a timestamp. ## 4. Send the shipping update on WhatsApp, with email as the fallback A message you start outside the 24-hour customer service window has to be a template. Create a utility template for the shipping update: ```bash curl -X POST https://api.zavu.dev/v2/templates \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "order_shipped", "language": "en_US", "whatsappCategory": "UTILITY", "body": "Hi {{1}}, your order {{2}} is on its way with {{3}}. Tracking number: {{4}}. We will let you know if anything changes." }' ``` WhatsApp does not accept a variable as the first or last thing in the body, and Zavu refuses the template with `400` if you try. A template name the project already uses is `409 conflict`. Submit it to Meta, naming the WhatsApp number whose Business Account receives it: ```bash curl -X POST https://api.zavu.dev/v2/templates/tmpl_abc123/submit \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"from": "+14155550100", "category": "UTILITY"}' ``` The `202` moves it to `pending`. If `from` exists but has no WhatsApp, the answer is `400 no_whatsapp`, with what that address does have in `details.types`. Meta's decision arrives as `template.status_changed`, or read `GET /v2/templates/{templateId}`. Send only once it is `approved`. The send. `channel: "whatsapp"` is explicit on purpose: with `channel` omitted, a template follows the automatic order, and on a number that also carries SMS it could go out as an SMS. `fallbackEnabled: false` keeps Zavu's own WhatsApp-to-SMS fallback out of the way, so your code decides what happens when WhatsApp fails. ```ts // notify.ts (continued) const FALL_BACK_TO_EMAIL = new Set(["no_channel", "channel_inactive", "not_found"]) export async function sendShippingUpdate(order: Order) { const s = order.shipment if (!s) return null const key = `${order.id}:shipped:${s.id}` if (order.phone && s.trackingNumber && process.env.ZAVU_WHATSAPP_FROM) { const { status, json } = await zavu("/messages", "POST", { from: process.env.ZAVU_WHATSAPP_FROM, // "+14155550100" to: order.phone, channel: "whatsapp", messageType: "template", content: { templateId: process.env.ZAVU_SHIPPED_TEMPLATE_ID, templateVariables: { "1": order.firstName || "there", "2": order.number, "3": s.carrier ?? "our carrier", "4": s.trackingNumber }, }, fallbackEnabled: false, idempotencyKey: `${key}:whatsapp`, metadata: { orderId: order.id, event: "shipped", shipmentId: s.id }, }) if (status === 202) return json.message.id if (status === 409 && json.code === "conflict") return json.details.message.id if (!FALL_BACK_TO_EMAIL.has(json.code)) throw new Error(`${json.code}: ${json.message}`) } return sendShippingEmail(order, `${key}:email`) } export async function sendShippingEmail(order: Order, idempotencyKey: string) { const s = order.shipment! if (!order.email) return null const tracking = s.trackingUrl ? `\nTrack it: ${s.trackingUrl}` : "" const { status, json } = await zavu("/messages", "POST", { from: process.env.ZAVU_EMAIL_FROM, to: order.email, subject: `Order ${order.number} has shipped`, text: `Hi ${order.firstName}, your order ${order.number} is on its way${s.carrier ? ` with ${s.carrier}` : ""}.${s.trackingNumber ? ` Tracking number: ${s.trackingNumber}.` : ""}${tracking}`, idempotencyKey, metadata: { orderId: order.id, event: "shipped", shipmentId: s.id }, }) if (status === 202) return json.message.id if (status === 409 && json.code === "conflict") return json.details.message.id throw new Error(`${json.code}: ${json.message}`) } ``` The WhatsApp attempt and the email fallback use different keys. They have to: a key that already sent the failed WhatsApp message would answer the email with `409 conflict` and send nothing. What triggers the fallback before anything is sent: | Code | Meaning | |---|---| | `409 no_channel` | The `from` address has no WhatsApp. `details.connect` says how to connect it. | | `403 channel_inactive` | WhatsApp is connected but switched off. | | `404 not_found` | No channel of the project has that `from` address. | The other case is a WhatsApp send that is accepted with `202` and fails afterwards: the number is not on WhatsApp, or Meta refuses it. That arrives as a `message.failed` event on the project webhook. Subscribe to it once: ```bash curl -X PUT https://api.zavu.dev/v2/webhook \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://yourstore-notify.example.com/webhooks/zavu", "events": ["message.failed", "message.delivered", "template.status_changed", "domain.verified"]}' ``` The `201` returns `webhook.secret` once; keep it. Every delivery is signed in `X-Zavu-Signature` as `t=...,v2=...`, where `v2` is the hex HMAC-SHA256 of `"{t}.{body}"`. The handler checks it, then reads the message to find the order through the `metadata` you attached: ```ts // server.ts (continued) import { zavu } from "./zavu" import { sendShippingEmail } from "./notify" import { loadOrder } from "./db" // your own store of orders function zavuSignatureOk(raw: Buffer, header: string | undefined) { const parts = Object.fromEntries((header ?? "").split(",").map((p) => p.split("=") as [string, string])) if (!parts.t || !parts.v2) return false const expected = crypto.createHmac("sha256", process.env.ZAVU_WEBHOOK_SECRET!).update(`${parts.t}.${raw.toString("utf8")}`).digest("hex") const a = Buffer.from(expected), b = Buffer.from(parts.v2) return a.length === b.length && crypto.timingSafeEqual(a, b) } app.post("/webhooks/zavu", express.raw({ type: "application/json" }), async (req, res) => { if (!zavuSignatureOk(req.body, req.get("X-Zavu-Signature"))) return res.sendStatus(401) const evt = JSON.parse(req.body.toString("utf8")) res.sendStatus(200) if (evt.event !== "message.failed" || evt.type !== "whatsapp") return const { json } = await zavu(`/messages/${evt.data.messageId}`, "GET") const m = json.message if (m.metadata?.event !== "shipped") return const order = await loadOrder(m.metadata.orderId) await sendShippingEmail(order, `${m.metadata.orderId}:shipped:${m.metadata.shipmentId}:email`) }) ``` `GET /v2/messages/{messageId}` also gives you `errorCode` in Zavu's words (for example `WHATSAPP_REJECTED`, with the provider's own code in `errorDetails.providerCode`) and `attemptedChannels`, which is where a WhatsApp-to-SMS fallback would show if you left it on. Zavu retries a webhook delivery that does not get a `2xx`, up to 5 attempts; the fallback key makes a repeated event harmless. ## 5. Keep contacts in sync Each order is also a chance to keep the customer's contact current, so the Conversations inbox shows a name and you can segment later. ```ts // notify.ts (continued) export async function upsertContact(order: Order) { const channels = [ ...(order.email ? [{ channel: "email", identifier: order.email, isPrimary: true }] : []), ...(order.phone ? [{ channel: "whatsapp", identifier: order.phone, isPrimary: true }] : []), ] if (channels.length === 0) return null const metadata = { source: "shopify", lastOrderId: order.id, lastOrderNumber: order.number } const { status, json } = await zavu("/contacts", "POST", { displayName: order.firstName || undefined, channels, metadata }) if (status === 201) return json.id if (status === 400 && json.code === "duplicate_identifier") { const contactId = json.details.conflicts[0].contactId await zavu(`/contacts/${contactId}`, "PATCH", { metadata }) return contactId } throw new Error(`${json.code}: ${json.message}`) } ``` Three things to know. `metadata` values are strings. A phone identifier on `whatsapp` or `sms` must be E.164, and v2 refuses anything else with `400 invalid_request`, suggesting the `+` form when the digits look like a number. And `PATCH` replaces the whole `metadata` object, so send every key you want to keep. v2 does not add a new channel to an existing contact yet; `POST /v1/contacts/{contactId}/channels` does, with the same API key. ## Testing it, and what can go wrong Test with a live key and your own email address and phone. A test-mode key always sends from the WhatsApp sandbox, so it cannot exercise the email path. Point your store's webhooks at a tunnel to your machine and place a test order, or use the platform's test notification button, which sends a sample payload. | You see | It means | Do this | |---|---|---| | `409 email_domain_not_verified` | The domain's DNS is not verified yet | Publish the records, `POST /v2/email-domains/{id}/verify`, retry | | `400 invalid_request` with `path: ["subject"]` | Email without a subject | Always send `subject` | | `400 invalid_request` on `to` | A phone without its `+`, read as a chat id | Store E.164 numbers | | `409 no_channel` | Nothing connected can reach `to` | Read `details.connect` | | `409 conflict` | The key was used before | Expected on retries; the original is in `details.message` | | `402 insufficient_balance` | The balance cannot cover the email | Add funds from the dashboard | | `429 daily_limit_exceeded` | A channel's daily ceiling | Wait for 00:00 UTC, or raise it | | `message.failed` with `EMAIL_RECIPIENT_SUPPRESSED` | That address bounced or complained before | Ask the customer for another address | Email recipients are checked before dispatch. A malformed address, a domain with no mail records, or an address on your suppression list fails the message with `EMAIL_INVALID_RECIPIENT`, `EMAIL_DOMAIN_NOT_FOUND` or `EMAIL_RECIPIENT_SUPPRESSED` instead of sending a guaranteed bounce. ## Going to production **Email billing.** On current plans email is charged from your prepaid balance in blocks of 1,000 transactional emails, at $0.40 per block; at zero balance sends stop with `402 insufficient_balance`. The Free plan also caps email at 100 a day and 3,000 a month. Teams on earlier plans keep their original email terms. Every plan has a rolling ceiling by account age, from 200 an hour and 1,000 per 24 hours in the first week up to 5,000 an hour and 50,000 per 24 hours after day 30; past it, the message fails with `EMAIL_RATE_LIMIT_HOURLY` or `EMAIL_RATE_LIMIT_DAILY`. Outbound email also passes Zavu's risk review, and a held message stays `queued` until a person approves it. **WhatsApp limits and billing.** On Free, WhatsApp shares a monthly allowance of 2,000 messages with Telegram, Instagram and Messenger, counting both directions, and a daily ceiling of 250 once the account is verified (100 before). Paid plans have no cap on either. Meta bills utility templates to your WhatsApp Business Account at its rate for the customer's country; Zavu adds no markup. [WhatsApp per-message pricing](/en/blog/whatsapp-per-message-pricing-explained) has the detail. **Template approval.** Approval is Meta's, not Zavu's, and it can take from minutes to longer. Keep the email path working from day one, so a template that is still `pending` or gets `rejected` costs you nothing but the channel. **Links.** Links in email and WhatsApp messages are not URL-verified; Zavu checks URLs only in SMS, one-way SMS and voice, and blocks link shorteners there. If you later send the shipping update by SMS, pre-verify your tracking domain with `POST /v1/urls`, use the full tracking URL, and remember the 10DLC requirement for +1 numbers. **Opt-in.** Meta requires the customer's opt-in before you message them on WhatsApp. Collect it at checkout, and keep the email fallback for those who did not give it. The full reference is in the [API v2 overview](https://docs.zavu.dev/api-v2/overview), the [webhook guide](https://docs.zavu.dev/api-v2/webhook) and the [error list](https://docs.zavu.dev/api-v2/errors). If your project still runs on v1, [migrate from v1](https://docs.zavu.dev/api-v2/migrate-from-v1) maps each call; both versions work with the same key.