TL;DR
- The confirmation email goes out when the order is paid. Your store's webhook triggers
POST /v2/messagesfrom an address on a verified domain. - No message is sent twice. Each send carries an
idempotencyKeybuilt from the order id and the event, so a retried webhook gets409 conflictand 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.comconnected 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:
terminalcurl -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:
terminalcurl -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:
terminalcurl -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(, { method, headers: { Authorization:https://api.zavu.dev/v2${path}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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]!)) export async function sendConfirmation(order: Order) { if (!order.email) return null const lines = order.items.map((i) =>) const { status, json } = await zavu("/messages", "POST", { from: process.env.ZAVU_EMAIL_FROM, // "orders@yourstore.com" to: order.email, subject:${i.quantity} x ${i.name}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:
terminalcurl -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:
terminalcurl -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 =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:${order.id}:shipped:${s.id}${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:
terminalcurl -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().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(${parts.t}.${raw.toString("utf8")}/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(, "PATCH", { metadata }) return contactId } throw new Error(/contacts/${contactId}${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 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.