Shopify 订单 Webhook 邮件 + Rovela(2026):HMAC 与幂等键
在原始请求体上校验 X-Shopify-Hmac-SHA256,处理 orders/create(及可选履约),并用 Idempotency-Key = Shopify webhook id 或 order id 通过 Rovela 发送订单确认。Shopify 负责商务,Rovela 负责投递。与 Stripe 收据、电商订单文区分。
Shopify 管订单,Rovela 投递确认信
Shopify 会在订单创建或履约推进时通知你的应用。店铺后台自带的通知邮件是商店设置,不是你用已验证产品域名、自有模板、自有幂等键发出的 HTTP 事务邮件。你的 webhook 处理函数调用 POST https://api.rovela.dev/emails 时,由 Rovela 入队、DKIM 签名并投递(发送邮件 API、快速开始、鉴权、模板)。
本文接入一条 Shopify HTTPS webhook,做到:
- 用原始请求体校验
X-Shopify-Hmac-SHA256(验证 webhook 投递) - 处理
orders/create(可选再处理履约相关 topic,例如orders/fulfilled) - 通过 Rovela 发送自定义订单确认(或已发货)邮件
- 设置
Idempotency-Key=X-Shopify-Webhook-Id(或稳定的shopify-order-{order.id}),避免 Shopify 重试导致重复发送
Rovela 不创建 Shopify 订单、不注册 webhook、不校验 Shopify HMAC。 商务与事件投递归 Shopify;把 webhook 映射到客户邮箱与模板变量归你的应用;Rovela 只负责投递。
与以下文章区分:
- Stripe Webhook 收据邮件 — Stripe-Signature 与支付事件,不是 Shopify 订单/履约 topic
- 电商订单邮件 — 通用 OMS/购物车生命周期,不含 Shopify HMAC 或
X-Shopify-Webhook-Id - 模板欢迎信 — 注册引导文案,不是订单确认
Shopify 做什么,Rovela 发什么
| 事项 | 负责方 |
|---|---|
| 结账、购物车、库存、履约 | Shopify |
Webhook 订阅 + HMAC(X-Shopify-Hmac-SHA256) | 你的应用 + Shopify |
| 哪些 topic 触发发信 | 你的应用 |
| 客户邮箱、订单号、行项目、金额 | Shopify webhook 载荷(由你映射) |
POST /emails、DKIM、队列、退信 | Rovela |
Idempotency-Key = webhook id / order id | 你的 webhook 处理函数 |
Rovela 返回 200 表示已接受并入队,不等于「已进收件箱」(快速开始)。
前置条件
- 在 Rovela 控制台完成发信域名验证。未验证域名的
from会以400拒绝(快速开始)。建议使用orders.example.com或mail.example.com一类子域。 - API key 形如
re_…。webhook worker 优先使用sending_access(鉴权)。 - 已有可向你服务器投递 HTTPS webhook 的 Shopify 应用(或 Admin webhook 订阅),以及用于 HTTPS 投递 HMAC 的应用 client secret(验证 webhook 投递)。
SHOPIFY_API_SECRET(client secret)、ROVELA_API_KEY、ROVELA_FROM只放在服务端。不要写进主题 Liquid、店面前端包或移动端。
环境变量
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>'
# 可选:订单确认模板 UUID
export ROVELA_ORDER_TEMPLATE_ID=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
轮换 Rovela key:新建 → 部署 → 再吊销旧 key(鉴权)。若轮换 Shopify 应用 client secret,文档说明 HMAC 可能需要最多约一小时才改用新密钥——若栈支持,可规划短暂双密窗口(验证 webhook 投递)。
为何 Idempotency-Key = Shopify webhook id(或 order id)
HTTPS 投递在非 2xx / 超时时会重试,同一逻辑投递可能到达多次。Rovela 的 Idempotency-Key(最长 256 字符)让 24 小时内相同发送的重试返回原响应,而不是再发一封(发送邮件 API)。
两种稳定选择:
| 键 | 适用 |
|---|---|
X-Shopify-Webhook-Id | 每次 webhook 投递至多发一封。该头在同一次投递的重试间保持稳定(投递结构)。 |
shopify-order-{order.id}(截断到 256) | 每个订单至多一封确认信,即使多个 topic 或重复投递都可能触发同类产品邮件。 |
「下单 → 一封确认」优先用 shopify-order-{id}(或 shopify-order-{id}-confirm)。仍可读 X-Shopify-Webhook-Id 做本地已处理集合,在调用 Rovela 前短路。履约邮件用不同键,例如 shopify-order-{id}-shipped,让确认与发货成为两次逻辑发送。
共用 Rovela 发送封装(仅服务端)
// 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 与 html/text 互斥 — 见 Templates 文档
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 };
}
未使用控制台模板时,尽量同时提供 html 与 text。写入 HTML 或模板 variables 前转义任何顾客可控字符串——模板替换不会做 HTML 转义(模板)。
在原始请求体上校验 Shopify HMAC
HTTPS 投递在 X-Shopify-Hmac-SHA256 中带有对原始请求体、用应用 client secret 计算的 base64 HMAC(验证 webhook 投递)。必须在 express.json()(或任何解析器)改写 body 之前截获原始字节。
// 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);
}
timingSafeEqual 前的长度检查很重要:长度不同时 Node 会抛错,坏签名会变成 500 而不是干净的 401。
Express:orders/create → Rovela 确认信
// 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 });
return res.status(200).json({ received: true });
} catch (err) {
console.error("shopify webhook handler failed", err);
return res.status(500).json({ error: "handler failed" });
}
},
);
type ShopifyOrderPayload = {
id?: number | string;
name?: string;
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": {
await sendOrderEmail({
order: opts.order,
webhookId: opts.webhookId,
shop: opts.shop,
kind: "shipped",
});
break;
}
default:
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;
const idempotencyKey = `shopify-order-${orderId}-${opts.kind}`.slice(0, 256);
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"
? `订单 ${orderName} 已发货`
: `订单已确认 — ${orderName}`;
const html = `
<p>${opts.kind === "shipped" ? "您的订单正在配送途中。" : "感谢您的订购。"}</p>
<p><b>订单:</b> ${escapeHtml(orderName)}</p>
${total ? `<p><b>合计:</b> ${escapeHtml(total)}</p>` : ""}
${lines ? `<p><b>商品:</b> ${escapeHtml(lines)}</p>` : ""}
${
opts.order.order_status_url
? `<p><a href="${escapeHtml(opts.order.order_status_url)}">查看订单状态</a></p>`
: ""
}
<p>如非本人下单,请联系客服。</p>
`;
const text = [
opts.kind === "shipped" ? "您的订单正在配送途中。" : "感谢您的订购。",
`订单:${orderName}`,
total ? `合计:${total}` : "",
lines ? `商品:${lines}` : "",
opts.order.order_status_url
? `状态:${opts.order.order_status_url}`
: "",
"如非本人下单,请联系客服。",
]
.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, """);
}
挂载顺序
import express from "express";
import { shopifyWebhook } from "./routes/shopify-webhook";
const app = express();
app.use(shopifyWebhook); // 先挂 raw body 路由
app.use(express.json()); // 其余 API 再用 JSON
若全局 JSON 解析器先跑,HMAC 会失败,因为签名覆盖的是 Shopify 发出的原始字节。
Next.js App Router 路由
// 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: `订单已确认 — ${orderName}`,
html: `<p>感谢您的订购。</p><p><b>订单:</b> ${orderName}</p><p><b>合计:</b> ${total || "—"}</p>`,
text: `感谢您的订购。\n订单:${orderName}\n合计:${total || "—"}`,
idempotencyKey: `shopify-order-${orderId}-confirm`,
tags: { shopify_webhook_id: webhookId, shopify_order_id: orderId },
});
}
}
return NextResponse.json({ received: true });
}
可选:控制台订单确认模板
先创建并发布模板(需 full_access 或控制台),再在 webhook 里用 template.id + variables 发送(模板):
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}}"
}'
然后 POST /templates/:id/publish。生产 worker 可继续只用 sending_access——它们只调用 POST /emails。
注意:同一次发送不能混用 template 与 html/text;POST /emails/batch 会拒绝 template(发送邮件 API)。
Topic 选择
| Topic | 典型用途 |
|---|---|
orders/create | 顾客刚下单 — 品牌确认信的最佳单一钩子 |
orders/fulfilled(或你启用的履约相关订阅) | 单独的「已发货」邮件,使用不同的 Idempotency-Key 后缀 |
| 其他订单 topic | 仅当文案依赖该状态变化时 — 避免同一确认发两次 |
店铺 Admin 通知邮件(若开启)与 Rovela 彼此独立。若 Rovela 是唯一品牌渠道,可关掉同一时刻的店铺通知模板,否则顾客可能收到两封。
生产清单
- 用应用 client secret 在原始请求体上校验 HMAC。切勿用已解析 JSON 做签名校验(验证 webhook 投递)。
- 尽快返回 2xx。 若 Rovela 或数据库偏慢,先入队
{ webhookId, orderId, topic }再异步发送;仍使用稳定 Idempotency-Key。 - 每次由 webhook 驱动的 Rovela 发送都带
Idempotency-Key=X-Shopify-Webhook-Id或shopify-order-{id}-…(发送邮件 API)。 - worker 优先
sending_access;切勿暴露给客户端(鉴权)。 - 发送 html + text,或已发布 template — 不要空正文。
- 买家可控字段(姓名、行标题、URL)写入 HTML / 模板变量前先转义(模板)。
- 需要运维可见性时用
GET /emails/:id或 Rovela webhook 确认投递 — 发送200只表示入队(快速开始)。
本文不覆盖
- Shopify 应用安装 / OAuth,或 Admin GraphQL mutation — 见 Shopify 官方文档
- 在 Shopify 售卖但用 Stripe 收款 — 若扣款在 Stripe,见 Stripe Webhook 收据邮件
- 不含 Shopify 的通用购物车/OMS 订单邮件 — 见 电商订单邮件
- Rovela 入站或投递 webhook — 与 Shopify HMAC 密钥无关
小结
Shopify 证明 webhook 真实且订单(或履约)已变化。你的处理函数把载荷映射到收件人与订单字段。Rovela 的 POST /emails 配合 Idempotency-Key = Shopify webhook id 或 order id,即使 Shopify 重试也只投递一封确认。商务留在 Shopify,投递交给 Rovela,密钥只放服务端。