TutorialAPI v2E-commerceEmailWhatsAppWebhooks

Order Notifications for an Online Store: Confirmation Email and WhatsApp Shipping Updates from Your Store's Webhooks

Build an order confirmation email API flow and WhatsApp shipping notifications from Shopify or WooCommerce webhooks, with email fallback, in Node.

Written by: Zavu TeamReviewed by: Jennifer VillalobosOctober 19, 202614 min read
View as Markdown

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 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 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:

terminal
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:

terminal
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:

terminal
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:

TypeScript
// 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:

TypeScript
// 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:

TypeScript
// 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

TypeScript
// notify.ts import { zavu } from "./zavu" import type { Order } from "./order" const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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: <p>Hi ${esc(order.firstName)}, we received your order <strong>${esc(order.number)}</strong>.</p><ul>${lines.map((l) => <li>${esc(l)}</li>).join("")}</ul><p>Total: ${esc(order.total)} ${esc(order.currency)}</p>, 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:

terminal
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:

terminal
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.

TypeScript
// 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:

CodeMeaning
409 no_channelThe from address has no WhatsApp. details.connect says how to connect it.
403 channel_inactiveWhatsApp is connected but switched off.
404 not_foundNo 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:

terminal
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:

TypeScript
// 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.

TypeScript
// 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 seeIt meansDo this
409 email_domain_not_verifiedThe domain's DNS is not verified yetPublish the records, POST /v2/email-domains/{id}/verify, retry
400 invalid_request with path: ["subject"]Email without a subjectAlways send subject
400 invalid_request on toA phone without its +, read as a chat idStore E.164 numbers
409 no_channelNothing connected can reach toRead details.connect
409 conflictThe key was used beforeExpected on retries; the original is in details.message
402 insufficient_balanceThe balance cannot cover the emailAdd funds from the dashboard
429 daily_limit_exceededA channel's daily ceilingWait for 00:00 UTC, or raise it
message.failed with EMAIL_RECIPIENT_SUPPRESSEDThat address bounced or complained beforeAsk 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 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, the webhook guide and the error list. If your project still runs on v1, migrate from v1 maps each call; both versions work with the same key.

Frequently asked questions

How do I send an order confirmation email through an API?
Send POST /v2/messages with the customer's email in `to`, your verified address in `from`, a `subject`, a `text` body and optionally `htmlBody`. The 202 reports routing.reason recipient_type. Add an idempotencyKey built from the order id, so a retried store webhook never sends the confirmation twice.
Do I need 10DLC to send shipping notifications in the US?
Only for SMS. SMS and one-way SMS to +1 numbers need an approved 10DLC campaign, and without one Zavu refuses the send with 403 ten_dlc_required. Email and WhatsApp need no 10DLC, which is why this tutorial sends the confirmation by email and the shipping update by WhatsApp.
Why does the WhatsApp shipping update have to be a template?
WhatsApp only allows free-form messages inside the 24-hour window that opens when the customer writes to you. A shipping update usually starts the conversation, so it must be a template Meta has approved. Create it as a UTILITY template, submit it with from set to your WhatsApp number, and send once approved.
What happens if the customer has no WhatsApp?
The send is accepted and then fails, arriving as a message.failed event on your project webhook. Read the message with GET /v2/messages/{messageId}, find the order through its metadata, and send the update by email with a different idempotencyKey. Refusals before sending, like 409 no_channel, are handled in the same fallback.
What does a repeated idempotencyKey return?
A 409 with code conflict, and nothing is sent again. The original message is in details.message, so your code can treat it as success and return its id. Keys are remembered for the project, so build them from the order id and the event, and use a separate key for a fallback send.
How much does transactional email cost on Zavu?
On current plans, email is charged from your prepaid balance in blocks of 1,000 transactional emails at $0.40 per block, and sends stop with 402 insufficient_balance at zero balance. The Free plan also caps email at 100 a day and 3,000 a month. Teams on earlier plans keep their original terms.

Need help? Contact us or join our Discord community for support.

Get started

Ready to get started?

Start building for free, or schedule a call to discuss your specific use case.

Order Confirmation Email and Shipping Updates | Zavu Blog