Shopify Order Webhook Email with Rovela (2026): HMAC + Idempotency-Key
Verify X-Shopify-Hmac-SHA256 on the raw body, handle orders/create (and optional fulfillment), and send a Rovela order confirmation with Idempotency-Key = Shopify webhook id or order id. Shopify owns commerce; Rovela delivers. Distinct from Stripe receipt and ecommerce-order-email.
Shopify owns the order — Rovela delivers the confirmation
Shopify notifies your app when an order is created or fulfillment progresses. Shopify’s own notification emails are a store setting; they are not a custom HTTP send from your verified product domain with your template and your Idempotency-Key. Rovela queues, DKIM-signs, and delivers that custom transactional mail when your webhook handler calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication, Templates).
This guide wires a Shopify HTTPS webhook that:
- Verifies
X-Shopify-Hmac-SHA256against the raw request body (Verify webhook deliveries) - Handles
orders/create(and optionally fulfillment topics such asorders/fulfilled/ fulfillment events you subscribe to) - Sends a custom order-confirmation (or shipped) email through Rovela
- Sets
Idempotency-Key=X-Shopify-Webhook-Id(or a stableshopify-order-{order.id}key) so Shopify retries never double-mail
Rovela does not create Shopify orders, register webhooks, or verify Shopify HMAC. Shopify owns commerce and event delivery. Your app owns mapping webhook → customer email → template variables. Rovela only delivers.
Distinct from:
- Stripe Webhook Receipt Email — Stripe-Signature + payment events, not Shopify order/fulfillment topics
- E-Commerce Order Emails — generic OMS/cart lifecycle triggers without Shopify HMAC or
X-Shopify-Webhook-Id - Welcome email from a template — onboarding copy, not order confirmation
What Shopify does vs what Rovela sends
| Concern | Owner |
|---|---|
| Checkout, carts, inventory, fulfillments | Shopify |
Webhook subscription + HMAC (X-Shopify-Hmac-SHA256) | Your app + Shopify |
| Choosing which topics trigger mail | Your app |
| Customer email, order number, line items, totals | Shopify webhook payload (your mapping) |
POST /emails, DKIM, queue, bounces | Rovela |
Idempotency-Key = webhook id / order id | Your webhook handler |
A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).
Prerequisites
- A verified sending domain in the Rovela dashboard. Unverified
fromdomains are refused with400(Quick Start). Prefer something likeorders.example.comormail.example.com. - An API key shaped like
re_…. Prefersending_accessfor the webhook worker (Authentication). - A Shopify app (or Admin webhook subscription) that delivers HTTPS webhooks to your server, and the app client secret used to sign HMAC for HTTPS deliveries (Verify webhook deliveries).
- Keep
SHOPIFY_API_SECRET(client secret),ROVELA_API_KEY, andROVELA_FROMonly on the server. Never in a theme liquid file, storefront bundle, or mobile app.
Environment
export SHOPIFY_API_SECRET=shpss_or_your_app_client_secret
export ROVELA_API_KEY=re_your_sending_access_key
export ROVELA_FROM='Acme Orders <orders@mail.example.com>'
# optional: dashboard template UUID for order confirmations
export ROVELA_ORDER_TEMPLATE_ID=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
Rotate Rovela keys by creating a new one, deploying, then revoking the old key (Authentication). If you rotate the Shopify app client secret, Shopify notes HMAC may take up to about an hour to use the new secret — plan for a short dual-secret window if your stack supports it (Verify webhook deliveries).
Why Idempotency-Key = Shopify webhook id (or order id)
Shopify retries failed HTTPS deliveries (non-2xx / timeouts). The same logical delivery can arrive more than once. Rovela’s Idempotency-Key header (up to 256 characters) makes a retry of the same send within 24 hours return the original response instead of mailing again (Send Email API).
Two stable choices:
| Key | Use when |
|---|---|
X-Shopify-Webhook-Id | You want one Rovela send per webhook delivery. Header is stable across retries of that delivery (Webhooks delivery structure). |
shopify-order-{order.id} (truncate to 256) | You want at most one confirmation mail per order, even if multiple topics or duplicate deliveries could fire the same product email. |
For “customer placed an order → one confirmation,” prefer shopify-order-{id} (or shopify-order-{id}-confirm). Still read X-Shopify-Webhook-Id for your own processed-delivery set so you can short-circuit before calling Rovela. For fulfillment mail, use a distinct key such as shopify-order-{id}-shipped so confirmation and shipped are different logical sends.
Shared Rovela sender (server-only)
// src/rovela.ts
const ROVELA_URL = "https://api.rovela.dev/emails";
export class RovelaError extends Error {
constructor(
public status: number,
public name: string,
message: string,
) {
super(message);
}
}
export async function sendViaRovela(opts: {
to: string;
subject?: string;
html?: string;
text?: string;
template?: { id: string; variables: Record<string, string> };
idempotencyKey: string;
replyTo?: string;
tags?: Record<string, string> | Array<{ name: string; value: string }>;
}) {
const apiKey = process.env.ROVELA_API_KEY;
const from = process.env.ROVELA_FROM;
if (!apiKey || !from) {
throw new Error("ROVELA_API_KEY and ROVELA_FROM must be set on the server");
}
const key = opts.idempotencyKey.slice(0, 256);
const body: Record<string, unknown> = {
from,
to: opts.to,
};
if (opts.template) {
// template is mutually exclusive with html/text — see Templates docs
body.template = opts.template;
} else {
if (!opts.html && !opts.text) {
throw new Error("html, text, or template is required");
}
if (opts.subject) body.subject = opts.subject;
if (opts.html) body.html = opts.html;
if (opts.text) body.text = opts.text;
}
if (opts.replyTo) body.reply_to = opts.replyTo;
if (opts.tags) body.tags = opts.tags;
const res = await fetch(ROVELA_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
throw new RovelaError(
res.status,
String((json as { name?: string }).name ?? "Error"),
String((json as { message?: string }).message ?? "send failed"),
);
}
return json as { object: "email"; id: string };
}
Always prefer both html and text when you are not using a dashboard template. Escape any customer-controlled strings before putting them in HTML or in template variables — template substitution is not HTML-escaped (Templates).
Verify Shopify HMAC on the raw body
HTTPS deliveries include a base64 HMAC in X-Shopify-Hmac-SHA256, computed over the raw body with your app client secret (Verify webhook deliveries). Capture the raw body before express.json() (or any parser) mutates it.
// src/shopify-hmac.ts
import crypto from "node:crypto";
export function verifyShopifyHmac(
rawBody: Buffer | string,
hmacHeader: string | undefined,
secret: string,
): boolean {
if (!hmacHeader) return false;
const digest = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("base64");
const a = Buffer.from(digest, "utf8");
const b = Buffer.from(hmacHeader, "utf8");
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
The length check before timingSafeEqual matters: Node throws when buffer lengths differ, which would turn a bad signature into a 500 instead of a clean 401.
Express: orders/create → Rovela confirmation
// src/routes/shopify-webhook.ts
import express from "express";
import { verifyShopifyHmac } from "../shopify-hmac";
import { sendViaRovela, RovelaError } from "../rovela";
export const shopifyWebhook = express.Router();
shopifyWebhook.post(
"/webhooks/shopify",
express.raw({ type: "application/json" }),
async (req, res) => {
const secret = process.env.SHOPIFY_API_SECRET;
if (!secret) {
return res.status(500).send("missing SHOPIFY_API_SECRET");
}
const hmac = req.headers["x-shopify-hmac-sha256"];
const hmacStr = Array.isArray(hmac) ? hmac[0] : hmac;
if (!verifyShopifyHmac(req.body, hmacStr, secret)) {
return res.status(401).send("invalid hmac");
}
const topicHeader = req.headers["x-shopify-topic"];
const topic = Array.isArray(topicHeader) ? topicHeader[0] : topicHeader;
const webhookIdHeader = req.headers["x-shopify-webhook-id"];
const webhookId = Array.isArray(webhookIdHeader)
? webhookIdHeader[0]
: webhookIdHeader;
const shopHeader = req.headers["x-shopify-shop-domain"];
const shop = Array.isArray(shopHeader) ? shopHeader[0] : shopHeader;
let order: ShopifyOrderPayload;
try {
order = JSON.parse(req.body.toString("utf8")) as ShopifyOrderPayload;
} catch {
return res.status(400).send("invalid json");
}
try {
await handleShopifyTopic({ topic, webhookId, shop, order });
// Acknowledge after the send (or enqueue then 200)
return res.status(200).json({ received: true });
} catch (err) {
console.error("shopify webhook handler failed", err);
// Non-2xx → Shopify retries. Prefer queueing mail and returning 200
// if your send path is slow; only fail when you need Shopify to retry.
return res.status(500).json({ error: "handler failed" });
}
},
);
type ShopifyOrderPayload = {
id?: number | string;
name?: string; // e.g. "#1042"
email?: string | null;
contact_email?: string | null;
total_price?: string;
currency?: string;
line_items?: Array<{ title?: string; quantity?: number }>;
order_status_url?: string;
fulfillment_status?: string | null;
};
async function handleShopifyTopic(opts: {
topic: string | undefined;
webhookId: string | undefined;
shop: string | undefined;
order: ShopifyOrderPayload;
}) {
switch (opts.topic) {
case "orders/create": {
await sendOrderEmail({
order: opts.order,
webhookId: opts.webhookId,
shop: opts.shop,
kind: "confirm",
});
break;
}
case "orders/fulfilled": {
// Subscribe only if you want a separate “shipped” mail.
await sendOrderEmail({
order: opts.order,
webhookId: opts.webhookId,
shop: opts.shop,
kind: "shipped",
});
break;
}
default:
// Ignore other topics you did not subscribe for mail
break;
}
}
async function sendOrderEmail(opts: {
order: ShopifyOrderPayload;
webhookId: string | undefined;
shop: string | undefined;
kind: "confirm" | "shipped";
}) {
const to = opts.order.email || opts.order.contact_email;
if (!to) return;
const orderId = String(opts.order.id ?? "");
if (!orderId) return;
// One confirmation (or shipped mail) per order — survives webhook retries.
const idempotencyKey = `shopify-order-${orderId}-${opts.kind}`.slice(0, 256);
// Optionally also short-circuit on opts.webhookId in your own processed set.
const orderName = opts.order.name || `#${orderId}`;
const total =
opts.order.total_price && opts.order.currency
? `${opts.order.total_price} ${opts.order.currency}`
: opts.order.total_price || "";
const lines = (opts.order.line_items || [])
.slice(0, 20)
.map((li) => `${li.quantity ?? 1}× ${li.title ?? "Item"}`)
.join(", ");
const subject =
opts.kind === "shipped"
? `Your order ${orderName} has shipped`
: `Order confirmed — ${orderName}`;
const html = `
<p>${opts.kind === "shipped" ? "Your order is on the way." : "Thanks for your order."}</p>
<p><b>Order:</b> ${escapeHtml(orderName)}</p>
${total ? `<p><b>Total:</b> ${escapeHtml(total)}</p>` : ""}
${lines ? `<p><b>Items:</b> ${escapeHtml(lines)}</p>` : ""}
${
opts.order.order_status_url
? `<p><a href="${escapeHtml(opts.order.order_status_url)}">View order status</a></p>`
: ""
}
<p>If you did not place this order, contact support.</p>
`;
const text = [
opts.kind === "shipped" ? "Your order is on the way." : "Thanks for your order.",
`Order: ${orderName}`,
total ? `Total: ${total}` : "",
lines ? `Items: ${lines}` : "",
opts.order.order_status_url
? `Status: ${opts.order.order_status_url}`
: "",
"If you did not place this order, contact support.",
]
.filter(Boolean)
.join("\n");
const templateId = process.env.ROVELA_ORDER_TEMPLATE_ID;
try {
if (templateId && opts.kind === "confirm") {
await sendViaRovela({
to,
template: {
id: templateId,
variables: {
order_name: orderName,
total: total || "—",
items: lines,
status_url: opts.order.order_status_url || "",
shop: opts.shop || "",
},
},
idempotencyKey,
tags: {
shopify_order_id: orderId,
shopify_webhook_id: opts.webhookId || "",
kind: opts.kind,
},
});
} else {
await sendViaRovela({
to,
subject,
html,
text,
idempotencyKey,
tags: {
shopify_order_id: orderId,
shopify_webhook_id: opts.webhookId || "",
kind: opts.kind,
},
});
}
} catch (err) {
if (err instanceof RovelaError) {
console.error("Rovela send failed", err.status, err.name, err.message);
}
throw err;
}
}
function escapeHtml(s: string) {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
Mounting tip
import express from "express";
import { shopifyWebhook } from "./routes/shopify-webhook";
const app = express();
app.use(shopifyWebhook); // raw body route first
app.use(express.json()); // JSON for the rest of the API
If a global JSON parser runs first, HMAC verification fails because the signature covers the exact bytes Shopify sent.
Next.js App Router route
// app/api/webhooks/shopify/route.ts
import { NextRequest, NextResponse } from "next/server";
import { verifyShopifyHmac } from "@/lib/shopify-hmac";
import { sendViaRovela } from "@/lib/rovela";
export const runtime = "nodejs";
export async function POST(req: NextRequest) {
const rawBody = Buffer.from(await req.arrayBuffer());
const hmac = req.headers.get("x-shopify-hmac-sha256") || undefined;
const secret = process.env.SHOPIFY_API_SECRET;
if (!secret || !verifyShopifyHmac(rawBody, hmac, secret)) {
return new NextResponse("invalid hmac", { status: 401 });
}
const topic = req.headers.get("x-shopify-topic") || "";
const webhookId = req.headers.get("x-shopify-webhook-id") || "";
const order = JSON.parse(rawBody.toString("utf8")) as {
id?: number | string;
name?: string;
email?: string | null;
contact_email?: string | null;
total_price?: string;
currency?: string;
};
if (topic === "orders/create") {
const to = order.email || order.contact_email;
const orderId = String(order.id ?? "");
if (to && orderId) {
const orderName = order.name || `#${orderId}`;
const total =
order.total_price && order.currency
? `${order.total_price} ${order.currency}`
: order.total_price || "";
await sendViaRovela({
to,
subject: `Order confirmed — ${orderName}`,
html: `<p>Thanks for your order.</p><p><b>Order:</b> ${orderName}</p><p><b>Total:</b> ${total || "—"}</p>`,
text: `Thanks for your order.\nOrder: ${orderName}\nTotal: ${total || "—"}`,
// Prefer order id for one confirmation; webhook id also works for per-delivery dedupe
idempotencyKey: `shopify-order-${orderId}-confirm`,
tags: { shopify_webhook_id: webhookId, shopify_order_id: orderId },
});
}
}
return NextResponse.json({ received: true });
}
Optional: dashboard template for order confirmations
Create a template once (needs full_access or the dashboard), publish it, then send with template.id + variables from the webhook (Templates):
curl -X POST https://api.rovela.dev/templates \
-H "Authorization: Bearer re_your_full_access_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Shopify order confirmation",
"subject": "Order confirmed — {{order_name}}",
"html": "<p>Thanks for your order <b>{{order_name}}</b>.</p><p>Total: {{total}}</p><p>{{items}}</p>",
"text": "Thanks for your order {{order_name}}. Total: {{total}}. {{items}}"
}'
Then POST /templates/:id/publish. Production webhook workers can keep using a sending_access key — they only call POST /emails.
Remember: template cannot be mixed with html/text on the same send, and POST /emails/batch rejects template (Send Email API).
Topic choices
| Topic | Typical use |
|---|---|
orders/create | Customer just placed an order — best single hook for branded confirmation |
orders/fulfilled (or related fulfillment subscriptions you enable) | Separate “shipped” mail with a different Idempotency-Key suffix |
| Other order topics | Only if your product copy depends on that state change — avoid double-mailing the same confirmation |
Shopify Admin notification emails (if enabled in the store) are separate from Rovela. Turn store notification templates off for the same moment if Rovela is your only branded channel, or accept that customers may see both.
Production checklist
- Verify HMAC on the raw body with the app client secret. Never trust a parsed JSON body for signature checks (Verify webhook deliveries).
- Return 2xx quickly. If Rovela or your DB is slow, enqueue
{ webhookId, orderId, topic }and send asynchronously; still use a stable Idempotency-Key. - Use
Idempotency-Key=X-Shopify-Webhook-Idorshopify-order-{id}-…on every Rovela send driven by the webhook (Send Email API). - Prefer
sending_accesskeys in the worker; never expose them to the client (Authentication). - Send html + text, or a published template — not an empty body.
- Escape buyer-influenced fields (names, line titles, URLs) before HTML / template variables (Templates).
- Confirm delivery with
GET /emails/:idor Rovela webhooks if you need ops visibility — a send200is queue acceptance only (Quick Start).
What this does not cover
- Building a Shopify app install / OAuth flow, or Admin GraphQL mutations — Shopify’s own docs cover that
- Payment capture with Stripe while selling on Shopify — see Stripe Webhook Receipt Email if the charge lives in Stripe
- Generic cart/OMS order emails without Shopify — see E-Commerce Order Emails
- Rovela inbound or delivery webhooks — separate from Shopify’s HMAC secret
Summary
Shopify verifies that the webhook is real and that an order (or fulfillment) changed. Your handler maps the payload to a recipient and order fields. Rovela’s POST /emails with Idempotency-Key = Shopify webhook id or order id delivers one confirmation even when Shopify retries. Keep commerce in Shopify, delivery in Rovela, and secrets on the server.