← Back to blog

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.

Updated November 4, 2026
Shopify Order Webhook Email with Rovela (2026): HMAC + Idempotency-Key

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:

  1. Verifies X-Shopify-Hmac-SHA256 against the raw request body (Verify webhook deliveries)
  2. Handles orders/create (and optionally fulfillment topics such as orders/fulfilled / fulfillment events you subscribe to)
  3. Sends a custom order-confirmation (or shipped) email through Rovela
  4. Sets Idempotency-Key = X-Shopify-Webhook-Id (or a stable shopify-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:

What Shopify does vs what Rovela sends

ConcernOwner
Checkout, carts, inventory, fulfillmentsShopify
Webhook subscription + HMAC (X-Shopify-Hmac-SHA256)Your app + Shopify
Choosing which topics trigger mailYour app
Customer email, order number, line items, totalsShopify webhook payload (your mapping)
POST /emails, DKIM, queue, bouncesRovela
Idempotency-Key = webhook id / order idYour webhook handler

A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).

Prerequisites

  1. A verified sending domain in the Rovela dashboard. Unverified from domains are refused with 400 (Quick Start). Prefer something like orders.example.com or mail.example.com.
  2. An API key shaped like re_…. Prefer sending_access for the webhook worker (Authentication).
  3. 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).
  4. Keep SHOPIFY_API_SECRET (client secret), ROVELA_API_KEY, and ROVELA_FROM only 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:

KeyUse when
X-Shopify-Webhook-IdYou 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, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

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

TopicTypical use
orders/createCustomer 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 topicsOnly 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

  1. Verify HMAC on the raw body with the app client secret. Never trust a parsed JSON body for signature checks (Verify webhook deliveries).
  2. Return 2xx quickly. If Rovela or your DB is slow, enqueue { webhookId, orderId, topic } and send asynchronously; still use a stable Idempotency-Key.
  3. Use Idempotency-Key = X-Shopify-Webhook-Id or shopify-order-{id}-… on every Rovela send driven by the webhook (Send Email API).
  4. Prefer sending_access keys in the worker; never expose them to the client (Authentication).
  5. Send html + text, or a published template — not an empty body.
  6. Escape buyer-influenced fields (names, line titles, URLs) before HTML / template variables (Templates).
  7. Confirm delivery with GET /emails/:id or Rovela webhooks if you need ops visibility — a send 200 is 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.