← 返回博客

Shopify 订单 Webhook 邮件 + Rovela(2026):HMAC 与幂等键

在原始请求体上校验 X-Shopify-Hmac-SHA256,处理 orders/create(及可选履约),并用 Idempotency-Key = Shopify webhook id 或 order id 通过 Rovela 发送订单确认。Shopify 负责商务,Rovela 负责投递。与 Stripe 收据、电商订单文区分。

更新于 2026年11月4日
Shopify 订单 Webhook 邮件 + Rovela(2026):HMAC 与幂等键

Shopify 管订单,Rovela 投递确认信

Shopify 会在订单创建或履约推进时通知你的应用。店铺后台自带的通知邮件是商店设置,不是你用已验证产品域名、自有模板、自有幂等键发出的 HTTP 事务邮件。你的 webhook 处理函数调用 POST https://api.rovela.dev/emails 时,由 Rovela 入队、DKIM 签名并投递(发送邮件 API、快速开始、鉴权、模板)。

本文接入一条 Shopify HTTPS webhook,做到:

  1. 用原始请求体校验 X-Shopify-Hmac-SHA256(验证 webhook 投递)
  2. 处理 orders/create(可选再处理履约相关 topic,例如 orders/fulfilled)
  3. 通过 Rovela 发送自定义订单确认(或已发货)邮件
  4. 设置 Idempotency-Key = X-Shopify-Webhook-Id(或稳定的 shopify-order-{order.id}),避免 Shopify 重试导致重复发送

Rovela 不创建 Shopify 订单、不注册 webhook、不校验 Shopify HMAC。 商务与事件投递归 Shopify;把 webhook 映射到客户邮箱与模板变量归你的应用;Rovela 只负责投递。

与以下文章区分:

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 表示已接受并入队,不等于「已进收件箱」(快速开始)。

前置条件

  1. 在 Rovela 控制台完成发信域名验证。未验证域名的 from 会以 400 拒绝(快速开始)。建议使用 orders.example.com 或 mail.example.com 一类子域。
  2. API key 形如 re_…。webhook worker 优先使用 sending_access(鉴权)。
  3. 已有可向你服务器投递 HTTPS webhook 的 Shopify 应用(或 Admin webhook 订阅),以及用于 HTTPS 投递 HMAC 的应用 client secret(验证 webhook 投递)。
  4. 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, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

挂载顺序

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 是唯一品牌渠道,可关掉同一时刻的店铺通知模板,否则顾客可能收到两封。

生产清单

  1. 用应用 client secret 在原始请求体上校验 HMAC。切勿用已解析 JSON 做签名校验(验证 webhook 投递)。
  2. 尽快返回 2xx。 若 Rovela 或数据库偏慢,先入队 { webhookId, orderId, topic } 再异步发送;仍使用稳定 Idempotency-Key。
  3. 每次由 webhook 驱动的 Rovela 发送都带 Idempotency-Key = X-Shopify-Webhook-Id 或 shopify-order-{id}-…(发送邮件 API)。
  4. worker 优先 sending_access;切勿暴露给客户端(鉴权)。
  5. 发送 html + text,或已发布 template — 不要空正文。
  6. 买家可控字段(姓名、行标题、URL)写入 HTML / 模板变量前先转义(模板)。
  7. 需要运维可见性时用 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,密钥只放服务端。