Reconciler Orders, payments, and webhooks

Duplicate webhook deliveries run twice

You set up a webhook, it fires when an order is paid, and everything works, until one day it fires twice for the same order. Store credit gets granted twice. A confirmation email goes out twice. A fulfillment gets created twice. Nothing in your app crashed, Shopify just did exactly what it is supposed to do: retry a delivery it was not sure you received. Here is why the same event arrives more than once and a small script that finds the orders a duplicate delivery hit so you can clean them up and stop it happening again.

Python and Node.js Admin GraphQL API Safe by default (dry run)
Blue network wires connected to each other
Photo by Scott Rodgerson on Unsplash
The short answer

Shopify retries a webhook whenever your endpoint answers slowly, returns a non-2xx status, or the connection drops, and every delivery carries a unique X-Shopify-Webhook-Id header so a retry can be told apart from a new event. Dedupe on that id before your handler does anything with side effects, not after. As a safety net, run a small Python or Node.js script that reads the Admin GraphQL API, finds orders where the same webhook id was recorded more than once, and tags them for review with tagsAdd. Full code, tests, and a dry run guard are below.

The problem in plain words

A webhook subscription tells Shopify to call your endpoint whenever something happens, an order gets paid, a fulfillment is created, a customer updates their email. Shopify sends the event once it happens and expects your endpoint to answer quickly with a 2xx status.

The trouble is that "sends once" is not the same as "you receive it exactly once." If your endpoint is slow, or a deploy restarts mid request, or the network hiccups after your handler finished but before Shopify saw the response, Shopify has no way to know the event was actually handled. So it retries. That is the correct, expected behavior on Shopify's side. The problem only shows up if your handler assumed every delivery was new and repeated whatever it does, twice.

Order paid webhook id A1 Handler runs grants credit slow, no fast 2xx Shopify cannot confirm delivery so it resends webhook id A1 Handler runs again same webhook id A1 Credit granted twice
Shopify did nothing wrong. It could not confirm the first delivery landed, so it retried the same event, and the handler repeated a side effect because it never checked whether it had already seen that webhook id.

Why it happens

Shopify's webhook delivery is at-least-once, not exactly-once, by design. A few common ways stores end up with a handler that runs twice:

This is a well known category of bug, not a Shopify outage. The fix on Shopify's side is documented, and the fix on your side is a standard idempotency pattern. See the citations at the end for the exact docs.

The key insight

The unique thing about a retried delivery is the X-Shopify-Webhook-Id header, not the order id or the topic. Two deliveries of the same event share that id. So the fix is not "only handle paid orders once," it is "only run this handler once per webhook id," recorded before the side effect runs, not after. Everything below either prevents that at the source or finds where it already slipped through.

The fix, as a flow

The real fix lives in the webhook handler itself: record the webhook id and check it before doing anything with a side effect, and answer Shopify fast so it never has a reason to retry. The script here is the safety net for deliveries that already ran twice before that check was in place. It reads recent orders, looks at the webhook ids each handler is expected to stamp onto the order as it processes them, finds any order where the same id shows up more than once, and tags that order for review. Nothing it finds gets touched beyond the review tag, since only a human, or a companion repair job written for the specific side effect, should decide how to undo a duplicate.

Scheduled job runs on a timer List recent orders last N days Read webhook id tags wh-<id> per delivery Same id twice? yes no, skip tagsAdd review flag for a human
The script only flags orders where the same webhook id was recorded more than once, and it leaves everything else alone.

Build it step by step

1

Get an Admin API access token

Create a custom app in your Shopify admin under Settings, Apps and sales channels, Develop apps. Give it the read_orders and write_orders scopes and install it to get an Admin API access token that starts with shpat_. Keep the token and the shop domain in environment variables, never in the file.

setup (shell)
pip install requests

export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export REVIEW_TAG="duplicate-webhook"
export WEBHOOK_TAG_PREFIX="wh-"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export REVIEW_TAG="duplicate-webhook"
export WEBHOOK_TAG_PREFIX="wh-"
export DRY_RUN="true"   // start safe, change to false to write
2

In the webhook handler, stamp the id before you act

This is the real fix, and it happens outside this script, in the endpoint that receives the webhook. Read X-Shopify-Webhook-Id from the headers, and before running any side effect, add a tag like wh-<id> to the order in the same request that performs the side effect, or check a dedicated table with a unique constraint on the id. If the id is already recorded, answer 200 and do nothing else. Answer fast, since a slow response is the single biggest cause of retries.

handler_sketch.py
def handle_webhook(headers, order_id, tags_on_order, side_effect):
    webhook_id = headers["X-Shopify-Webhook-Id"]
    already_seen = any(t == f"wh-{webhook_id}" for t in tags_on_order)
    if already_seen:
        return  # ack fast, do nothing, this is a retry
    side_effect()
    tags_on_order.append(f"wh-{webhook_id}")  # persist before returning 200
handler-sketch.js
function handleWebhook(headers, orderId, tagsOnOrder, sideEffect) {
  const webhookId = headers["x-shopify-webhook-id"];
  const alreadySeen = tagsOnOrder.some((t) => t === `wh-${webhookId}`);
  if (alreadySeen) return; // ack fast, do nothing, this is a retry
  sideEffect();
  tagsOnOrder.push(`wh-${webhookId}`); // persist before returning 200
}
3

Talk to the Admin GraphQL API

Every call for the reconciler goes to one GraphQL endpoint with your token in the X-Shopify-Access-Token header. A small helper sends a query and returns the data, and raises if Shopify reports an error.

step3.py
import os, requests

SHOP = os.environ["SHOPIFY_SHOP"]
TOKEN = os.environ["SHOPIFY_ACCESS_TOKEN"]
API_VERSION = os.environ.get("SHOPIFY_API_VERSION", "2025-01")
ENDPOINT = f"https://{SHOP}/admin/api/{API_VERSION}/graphql.json"

def gql(query, variables=None):
    r = requests.post(
        ENDPOINT,
        json={"query": query, "variables": variables or {}},
        headers={"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()
    if body.get("errors"):
        raise RuntimeError(body["errors"])
    return body["data"]
step3.js
const SHOP = process.env.SHOPIFY_SHOP;
const TOKEN = process.env.SHOPIFY_ACCESS_TOKEN;
const API_VERSION = process.env.SHOPIFY_API_VERSION || "2025-01";
const ENDPOINT = `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`;

async function gql(query, variables = {}) {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: { "X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Shopify ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}
4

List recent orders and their webhook id tags

Ask for orders created in the lookback window, and read back the tags and the amount received in shopMoney. We page through with a cursor so the job handles a large window without missing anything.

step4.py
ORDERS_QUERY = """
query($cursor: String, $q: String!) {
  orders(first: 50, after: $cursor, query: $q) {
    pageInfo { hasNextPage endCursor }
    nodes {
      id
      name
      tags
      totalReceivedSet { shopMoney { amount currencyCode } }
    }
  }
}"""

def recent_orders(lookback_days):
    q = f"created_at:>-{lookback_days}d"
    cursor = None
    while True:
        data = gql(ORDERS_QUERY, {"cursor": cursor, "q": q})["orders"]
        for node in data["nodes"]:
            yield node
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]
step4.js
const ORDERS_QUERY = `
query($cursor: String, $q: String!) {
  orders(first: 50, after: $cursor, query: $q) {
    pageInfo { hasNextPage endCursor }
    nodes {
      id
      name
      tags
      totalReceivedSet { shopMoney { amount currencyCode } }
    }
  }
}`;

async function* recentOrders(lookbackDays) {
  const q = `created_at:>-${lookbackDays}d`;
  let cursor = null;
  while (true) {
    const data = (await gql(ORDERS_QUERY, { cursor, q })).orders;
    for (const node of data.nodes) yield node;
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}
5

Decide, with pure functions

Keep the decision in its own functions that take an order's tags and return a plain answer. Pure functions like these are easy to read and easy to test, which we do later. A duplicate is any webhook id tag that shows up more than once on the same order, and we only flag an order that is not already flagged, so re-running the job never adds the same tag twice.

decide.py
def webhook_ids_seen(tags, prefix):
    return [t[len(prefix):] for t in (tags or []) if t.startswith(prefix)]

def find_duplicate_webhook_id(tags, prefix):
    seen = set()
    for wid in webhook_ids_seen(tags, prefix):
        if wid in seen:
            return wid
        seen.add(wid)
    return None

def should_flag_for_review(order, prefix, review_tag):
    if review_tag in (order.get("tags") or []):
        return False
    return find_duplicate_webhook_id(order.get("tags"), prefix) is not None
decide.js
export function webhookIdsSeen(tags, prefix) {
  return (tags || []).filter((t) => t.startsWith(prefix)).map((t) => t.slice(prefix.length));
}

export function findDuplicateWebhookId(tags, prefix) {
  const seen = new Set();
  for (const wid of webhookIdsSeen(tags, prefix)) {
    if (seen.has(wid)) return wid;
    seen.add(wid);
  }
  return null;
}

export function shouldFlagForReview(order, prefix, reviewTag) {
  if ((order.tags || []).includes(reviewTag)) return false;
  return findDuplicateWebhookId(order.tags, prefix) !== null;
}
6

Flag the order and wire it together with a dry run guard

When an order is flagged, call tagsAdd with the review tag and always read back userErrors. Notice the dry run guard in the loop. On the first few runs, leave DRY_RUN on so the script only reports which orders it would flag. Read the output, confirm it matches what you expect, then switch it off to let it write. Run it on a schedule, for example once a day, as a backstop while you fix the handler itself.

Run it safe

Always start with DRY_RUN=true. This script only ever adds a review tag, it never reverses a side effect on its own, because undoing a duplicate store credit or a duplicate fulfillment needs a decision specific to that side effect. Treat the tag as a worklist, not an automatic repair.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only tags orders that are not already flagged.

View this code on GitHub Full runnable folder with tests in the shopify-fixes repo.

dedupe_webhook_deliveries.py
"""Detect and repair Shopify orders that were double-processed by a duplicate webhook delivery.

Shopify retries a webhook when your endpoint is slow or returns a non-2xx status, and the
same delivery can also arrive twice over the network. Every delivery carries a unique
X-Shopify-Webhook-Id header. If a handler does not check that id before acting, a retried
"orders/paid" delivery can double-apply a side effect, such as granting store credit twice.

This script does not sit in the webhook path. It reconciles after the fact: it reads the
ledger of processed webhook ids each handler is expected to stamp onto the order (as tags
in the form wh-<webhook id>), finds orders where the same webhook id shows up more than
once, and tags those orders for review with tagsAdd. It never guesses which side effect
ran twice, it only flags the order so a human (or a companion repair job) can look at it.
Read only apart from the tag. Run on a schedule. Safe to run again and again.
"""
import os
import logging
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("dedupe_webhook_deliveries")

SHOP = os.environ["SHOPIFY_SHOP"]
TOKEN = os.environ["SHOPIFY_ACCESS_TOKEN"]
API_VERSION = os.environ.get("SHOPIFY_API_VERSION", "2025-01")
ENDPOINT = f"https://{SHOP}/admin/api/{API_VERSION}/graphql.json"
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "7"))
REVIEW_TAG = os.environ.get("REVIEW_TAG", "duplicate-webhook")
WEBHOOK_TAG_PREFIX = os.environ.get("WEBHOOK_TAG_PREFIX", "wh-")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

ORDERS_QUERY = """
query($cursor: String, $q: String!) {
  orders(first: 50, after: $cursor, query: $q) {
    pageInfo { hasNextPage endCursor }
    nodes {
      id
      name
      tags
      totalReceivedSet { shopMoney { amount currencyCode } }
    }
  }
}"""

TAGS_ADD = """
mutation($id: ID!, $tags: [String!]!) {
  tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } }
}"""


def gql(query, variables=None):
    r = requests.post(
        ENDPOINT,
        json={"query": query, "variables": variables or {}},
        headers={"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()
    if body.get("errors"):
        raise RuntimeError(body["errors"])
    return body["data"]


def to_cents(amount):
    return round(float(amount) * 100)


def webhook_ids_seen(tags, prefix):
    """Pull the webhook ids a handler stamped onto the order, in order of appearance."""
    return [t[len(prefix):] for t in (tags or []) if t.startswith(prefix)]


def find_duplicate_webhook_id(tags, prefix):
    """Pure decision function, no I/O.

    Given an order's tags, return the first webhook id that was stamped more than once,
    or None if every delivery the order has seen so far was processed exactly once.
    A duplicate here means the same X-Shopify-Webhook-Id ran the handler twice, which is
    the signature of a retried or duplicated delivery slipping past dedupe.
    """
    seen = set()
    for wid in webhook_ids_seen(tags, prefix):
        if wid in seen:
            return wid
        seen.add(wid)
    return None


def already_flagged(tags, review_tag):
    return review_tag in (tags or [])


def should_flag_for_review(order, prefix, review_tag):
    """Pure decision function, no I/O. Decides whether an order needs a review tag."""
    if already_flagged(order.get("tags"), review_tag):
        return False
    return find_duplicate_webhook_id(order.get("tags"), prefix) is not None


def flag_for_review(order_id, review_tag):
    result = gql(TAGS_ADD, {"id": order_id, "tags": [review_tag]})["tagsAdd"]
    if result["userErrors"]:
        raise RuntimeError(result["userErrors"])


def recent_orders():
    q = f"created_at:>-{LOOKBACK_DAYS}d"
    cursor = None
    while True:
        data = gql(ORDERS_QUERY, {"cursor": cursor, "q": q})["orders"]
        for node in data["nodes"]:
            yield node
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]


def run():
    flagged = 0
    for order in recent_orders():
        if not should_flag_for_review(order, WEBHOOK_TAG_PREFIX, REVIEW_TAG):
            continue
        dup_id = find_duplicate_webhook_id(order.get("tags"), WEBHOOK_TAG_PREFIX)
        received_cents = to_cents((order.get("totalReceivedSet") or {}).get("shopMoney", {}).get("amount", "0"))
        log.warning(
            "Order %s saw webhook id %s more than once (received %s cents). %s",
            order["name"], dup_id, received_cents, "would tag" if DRY_RUN else "tagging",
        )
        if not DRY_RUN:
            flag_for_review(order["id"], REVIEW_TAG)
        flagged += 1
    log.info("Done. %d order(s) %s.", flagged, "to tag" if DRY_RUN else "tagged")


if __name__ == "__main__":
    run()
dedupe-webhook-deliveries.js
/**
 * Detect and repair Shopify orders that were double-processed by a duplicate webhook delivery.
 *
 * Shopify retries a webhook when your endpoint is slow or returns a non-2xx status, and the
 * same delivery can also arrive twice over the network. Every delivery carries a unique
 * X-Shopify-Webhook-Id header. If a handler does not check that id before acting, a retried
 * "orders/paid" delivery can double-apply a side effect, such as granting store credit twice.
 *
 * This script does not sit in the webhook path. It reconciles after the fact: it reads the
 * ledger of processed webhook ids each handler is expected to stamp onto the order (as tags
 * in the form wh-<webhook id>), finds orders where the same webhook id shows up more than
 * once, and tags those orders for review with tagsAdd. Run on a schedule.
 *
 * Guide: https://www.allanninal.dev/shopify/duplicate-webhook-deliveries-run-twice/
 */
import { pathToFileURL } from "node:url";

const SHOP = process.env.SHOPIFY_SHOP || "example.myshopify.com";
const TOKEN = process.env.SHOPIFY_ACCESS_TOKEN || "shpat_dummy";
const API_VERSION = process.env.SHOPIFY_API_VERSION || "2025-01";
const ENDPOINT = `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`;
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 7);
const REVIEW_TAG = process.env.REVIEW_TAG || "duplicate-webhook";
const WEBHOOK_TAG_PREFIX = process.env.WEBHOOK_TAG_PREFIX || "wh-";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function toCents(amount) {
  return Math.round(parseFloat(amount) * 100);
}

export function webhookIdsSeen(tags, prefix) {
  return (tags || []).filter((t) => t.startsWith(prefix)).map((t) => t.slice(prefix.length));
}

/**
 * Pure decision function, no I/O.
 *
 * Given an order's tags, return the first webhook id that was stamped more than once,
 * or null if every delivery the order has seen so far was processed exactly once.
 */
export function findDuplicateWebhookId(tags, prefix) {
  const seen = new Set();
  for (const wid of webhookIdsSeen(tags, prefix)) {
    if (seen.has(wid)) return wid;
    seen.add(wid);
  }
  return null;
}

export function alreadyFlagged(tags, reviewTag) {
  return (tags || []).includes(reviewTag);
}

/** Pure decision function, no I/O. Decides whether an order needs a review tag. */
export function shouldFlagForReview(order, prefix, reviewTag) {
  if (alreadyFlagged(order.tags, reviewTag)) return false;
  return findDuplicateWebhookId(order.tags, prefix) !== null;
}

async function gql(query, variables = {}) {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: { "X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Shopify ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}

const ORDERS_QUERY = `
query($cursor: String, $q: String!) {
  orders(first: 50, after: $cursor, query: $q) {
    pageInfo { hasNextPage endCursor }
    nodes {
      id
      name
      tags
      totalReceivedSet { shopMoney { amount currencyCode } }
    }
  }
}`;

const TAGS_ADD = `
mutation($id: ID!, $tags: [String!]!) {
  tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } }
}`;

async function* recentOrders() {
  const q = `created_at:>-${LOOKBACK_DAYS}d`;
  let cursor = null;
  while (true) {
    const data = (await gql(ORDERS_QUERY, { cursor, q })).orders;
    for (const node of data.nodes) yield node;
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}

async function flagForReview(orderId, reviewTag) {
  const result = (await gql(TAGS_ADD, { id: orderId, tags: [reviewTag] })).tagsAdd;
  if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
}

export async function run() {
  let flagged = 0;
  for await (const order of recentOrders()) {
    if (!shouldFlagForReview(order, WEBHOOK_TAG_PREFIX, REVIEW_TAG)) continue;
    const dupId = findDuplicateWebhookId(order.tags, WEBHOOK_TAG_PREFIX);
    const receivedCents = toCents(order.totalReceivedSet?.shopMoney?.amount ?? "0");
    console.warn(
      `Order ${order.name} saw webhook id ${dupId} more than once (received ${receivedCents} cents). ${DRY_RUN ? "would tag" : "tagging"}`
    );
    if (!DRY_RUN) await flagForReview(order.id, REVIEW_TAG);
    flagged++;
  }
  console.log(`Done. ${flagged} order(s) ${DRY_RUN ? "to tag" : "tagged"}.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The decision rule is the part most worth testing, because it decides which orders get flagged for a human to review. Because we kept find_duplicate_webhook_id and should_flag_for_review pure, the tests need no network and no Shopify account. They just feed in plain tag lists and check the answer.

test_duplicate_webhook_dedupe.py
from dedupe_webhook_deliveries import (
    find_duplicate_webhook_id,
    should_flag_for_review,
    already_flagged,
    to_cents,
)

PREFIX = "wh-"
REVIEW_TAG = "duplicate-webhook"


def order(tags):
    return {"tags": tags}


def test_to_cents_rounds():
    assert to_cents("19.99") == 1999


def test_no_duplicate_when_each_webhook_id_seen_once():
    tags = ["wh-abc123", "wh-def456"]
    assert find_duplicate_webhook_id(tags, PREFIX) is None


def test_finds_duplicate_webhook_id():
    tags = ["wh-abc123", "wh-def456", "wh-abc123"]
    assert find_duplicate_webhook_id(tags, PREFIX) == "abc123"


def test_should_flag_when_duplicate_and_not_yet_flagged():
    o = order(["wh-abc123", "wh-abc123"])
    assert should_flag_for_review(o, PREFIX, REVIEW_TAG) is True


def test_should_not_flag_when_already_flagged():
    o = order(["wh-abc123", "wh-abc123", "duplicate-webhook"])
    assert should_flag_for_review(o, PREFIX, REVIEW_TAG) is False
dedupe.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import {
  toCents,
  findDuplicateWebhookId,
  shouldFlagForReview,
} from "./dedupe-webhook-deliveries.js";

const PREFIX = "wh-";
const REVIEW_TAG = "duplicate-webhook";
const order = (tags) => ({ tags });

test("toCents rounds", () => {
  assert.equal(toCents("19.99"), 1999);
});

test("no duplicate when each webhook id seen once", () => {
  assert.equal(findDuplicateWebhookId(["wh-abc123", "wh-def456"], PREFIX), null);
});

test("finds duplicate webhook id", () => {
  assert.equal(findDuplicateWebhookId(["wh-abc123", "wh-def456", "wh-abc123"], PREFIX), "abc123");
});

test("should flag when duplicate and not yet flagged", () => {
  assert.equal(shouldFlagForReview(order(["wh-abc123", "wh-abc123"]), PREFIX, REVIEW_TAG), true);
});

test("should not flag when already flagged", () => {
  assert.equal(
    shouldFlagForReview(order(["wh-abc123", "wh-abc123", "duplicate-webhook"]), PREFIX, REVIEW_TAG),
    false
  );
});

Case studies

Store credit

The loyalty app that paid out twice

A store ran a loyalty app that granted store credit whenever an orders/paid webhook fired. During a brief deploy, the app took a few extra seconds to answer, Shopify did not see a fast 2xx, and it retried the same delivery. The handler had no idea it had already run and granted credit a second time on dozens of orders that week.

The team added the wh-<id> tag check to the handler so a repeat delivery is a no-op, then ran the reconciler against the past thirty days to find every order that already had a duplicate tag. Each flagged order got a manual credit correction, and the loyalty balance matched the order history again.

Email and fulfillment

Two confirmation emails and a duplicate fulfillment

A fulfillment app subscribed to orders/create and created a fulfillment request with a third party carrier every time it fired. A brief network blip caused Shopify to redeliver an event the app had, in fact, already processed a second earlier, and the carrier received two requests for one order, plus the customer got two confirmation emails.

Once the team recognized the pattern in their logs by webhook id, they used the reconciler in dry run to see how many orders across the last week carried a repeated id. It was a short list. They cancelled the extra carrier requests by hand and shipped the idempotency check the same day.

What good looks like

Once the handler checks the webhook id before it does anything with a side effect, a retried delivery becomes a harmless no-op instead of a doubled charge, email, or fulfillment. The reconciler keeps running on a schedule as a backstop, and in a healthy store it should almost never find anything, which is exactly the point.

FAQ

Why does Shopify send the same webhook more than once?

Shopify retries a webhook delivery whenever your endpoint is slow to answer, returns a non-2xx status, or the connection drops mid response. Shopify cannot tell whether your handler actually finished, so it resends the same event, and rare network duplication can deliver it twice even when nothing failed.

How do I tell two deliveries of the same event apart from two different events?

Every delivery carries a unique X-Shopify-Webhook-Id header. Two deliveries of the same event share the same webhook id and the same X-Shopify-Order-Id or resource id, while two different events get two different webhook ids. Recording the webhook id before you act is what lets you tell a retry from a new event.

Is it safe to dedupe webhooks with a database check instead of a queue?

Yes, as long as the check and the write happen together, for example an insert on a unique webhook id column that fails on a repeat, or a Shopify tag you write before running the side effect. A read-then-write check without that guarantee still lets two near simultaneous deliveries both pass, since they both read no record before either writes one.

Related field notes

Citations

On the problem:

  1. Shopify.dev: webhooks overview, including delivery retries and at-least-once behavior. shopify.dev/docs/apps/build/webhooks
  2. Shopify.dev: troubleshooting webhooks, including duplicate and delayed deliveries. shopify.dev/docs/apps/build/webhooks/troubleshooting
  3. Shopify Community: handling duplicate webhook events and idempotency in app code. community.shopify.com webhooks and events

On the solution:

  1. Shopify.dev: webhook headers, including X-Shopify-Webhook-Id and X-Shopify-Order-Id. shopify.dev/docs/api/webhooks#headers
  2. Shopify Admin GraphQL: the tagsAdd mutation. shopify.dev/docs/api/admin-graphql/latest/mutations/tagsAdd
  3. Shopify Admin GraphQL: the orders query and its search syntax. shopify.dev/docs/api/admin-graphql/latest/queries/orders

Stuck on a tricky one?

If you have a problem in Shopify orders, payments, subscriptions, inventory, or fulfillment that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this catch a duplicate for you?

If this saved you from a doubled charge, a doubled email, or a confusing support ticket, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all Shopify field notes