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.
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.
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:
- The endpoint does real work, like calling a payment gateway or sending an email, before it answers Shopify, so it misses the short window Shopify allows for a response and Shopify retries.
- A deploy or a crash restarts the server after the handler finished its work but before the HTTP response was sent, so Shopify sees a failure and resends.
- The handler checks something like "has this order already been marked paid" instead of the delivery id itself, and a duplicate delivery for a different reason, such as a topic resubscription, slips past that check.
- Two delivery attempts land close enough together that a read-then-write check reads "not processed yet" for both before either one writes a record, so both proceed.
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 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.
Build it step by step
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.
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
// 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
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.
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
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
}
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.
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"]
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;
}
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.
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"]
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;
}
}
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.
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
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;
}
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.
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.
"""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()
/**
* 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.
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
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
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.
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.
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:
- Shopify.dev: webhooks overview, including delivery retries and at-least-once behavior. shopify.dev/docs/apps/build/webhooks
- Shopify.dev: troubleshooting webhooks, including duplicate and delayed deliveries. shopify.dev/docs/apps/build/webhooks/troubleshooting
- Shopify Community: handling duplicate webhook events and idempotency in app code. community.shopify.com webhooks and events
On the solution:
- Shopify.dev: webhook headers, including X-Shopify-Webhook-Id and X-Shopify-Order-Id. shopify.dev/docs/api/webhooks#headers
- Shopify Admin GraphQL: the
tagsAddmutation. shopify.dev/docs/api/admin-graphql/latest/mutations/tagsAdd - Shopify Admin GraphQL: the
ordersquery 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.
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