The label that printed twice: idempotent printing and the mutable-id trap
Duplicate webhooks and mutable item IDs caused repeat labels, showing why print workflows need idempotency keyed to stable business data.
The label that printed twice: how one webhook fired again and doubled our output
A collectibles retailer's back office runs on printed shelf labels — one SKU, one price, one label. So when the same label started sliding out of the printer twice, it wasn't a cosmetic bug. It was staff second-guessing the price, re-labeling shelves, and losing trust in the automation that was supposed to save them the work.
The cause turned out to be two different things wearing the same costume. One was a store that fired the same change webhook more than once. The other was a store item that quietly changed its internal id when someone retitled it. Both ended with a duplicate label, and both taught the same lesson: if you print on every event you receive, you are trusting the event source to be exact-once. It never is.
The problem
The workflow was simple on paper. The store emits a webhook when a product changes. Our automation picks it up, resolves the SKU and its current price, and sends a label to the printer. One event in, one label out.
In production we watched two labels come out for a single price change. Sometimes three. The data on each label was identical — same SKU, same price — so this wasn't a race that produced conflicting values. It was the exact same print, repeated.
For the store staff, a duplicate label is worse than a missing one. A missing label is obviously missing; someone reprints it. A duplicate implies something changed twice, so people go looking for a second price update that never happened. The automation was manufacturing doubt.
Why the obvious fix didn't work
The first instinct is to deduplicate on the webhook itself. Most platforms attach an event id, so you keep a set of seen ids and drop repeats. Clean in theory.
It didn't hold up here for two reasons. First, we were reacting to a change to a product, not consuming a strict once-only event feed with an identity we could trust as a true dedup key. Second, and more important, event-id dedup answers the wrong question. We didn't care whether we'd seen a particular event before. We cared whether we'd already printed this label — this SKU at this price — a moment ago.
Those aren't the same thing. A genuine reprice should print. A duplicate fire of the same reprice should not. Any dedup keyed on the transport (the event) instead of the outcome (the label) either drops labels it should print or prints labels it should drop. We needed the dedup to live at the point of printing, in terms the print cares about.
What we did
We made the print step idempotent using a small log-and-settle pattern. Right before printing, the workflow does four things in order:
- Log the intent. Write the SKU, the price, and a timestamp to a data table — one row per print attempt — before sending anything to the printer.
- Pause a few seconds. Give any duplicate webhook fires time to arrive and write their own rows.
- Read the log back. Query the rows for this same SKU and price within the recent window.
- Print only if you're the earliest. If this run's timestamp is the earliest for that SKU+price, it proceeds to print. Every later run for the same SKU+price sees an earlier row and skips.
The key design choice is what we key on: SKU plus price, not the event. That's the whole trick. Two fires of the same reprice share a SKU and a price, so only the first prints. But a real, new reprice carries a different price, which creates a different log entry, which has no earlier twin — so it prints normally. The pattern suppresses duplicates without ever suppressing a legitimate change, because it's defined in the same terms the label is.
We accepted one explicit tradeoff: latency. Every print now waits a few seconds so duplicates can settle before we decide who's earliest. For shelf labels that delay is invisible and worth it. For a latency-critical path it wouldn't be, and we'd reach for a different mechanism.
How it works
The mental model is a starting line with a short grace period, not a lock.
Instead of trying to grab an exclusive lock the instant an event arrives — which is fragile when you don't know how many duplicates are coming or when — every run announces itself by writing a row first, then waits, then looks around. Because all duplicates of the same change write the same SKU+price, they all land in the same little group. The earliest timestamp in that group wins the right to print. Everyone else defers to it.
That ordering is why the pause matters. Without it, the first run might read the log before its duplicate siblings have written their rows, decide it's alone, and print — and then a duplicate arrives late and does the same. The few-second settle window makes sure that by the time anyone evaluates "am I the earliest?", all the contenders are already on the board. Log first, settle, then decide.
And the reason a real reprice survives this filter is structural, not a special case: a new price is a new row with no earlier match. The design doesn't have to detect legitimate changes. It just keys on the thing that makes a change legitimate — a different price — so legitimate changes fall through the filter for free.
What broke / what surprised us
Then the same store printed a duplicate label for an item that had only changed once. No repeated webhook, no second price. The idempotency log showed a single print. By its own logic, the workflow was right.
The duplicate came from further upstream. An item had been created in the store, passed to the ERP as one record, and printed one label — all correct. Later, someone recreated it with a changed title. The store assigned the recreated item a different internal item id. The ERP keyed on that store item id to decide whether a product was new or existing. New id, so as far as the ERP could tell this was a brand-new product: it created a second record and, downstream, that second record drove a second label.
This one surprised us because it defeats idempotency at the printing layer entirely. Our log correctly saw one print for the new record — the record really was new. The duplication happened one level up, in identity resolution, before printing ever entered the picture. We had made printing exact-once and still printed twice, because "the same product" had silently become two products.
The root cause was keying on a mutable id. The store's internal item id is not stable across a recreate; it's an implementation detail that can change while the product it names stays the same. Treating it as the product's identity means any recreate — a retitle, a rebuild, a re-import — looks like a birth.
Results
The webhook-driven duplicates stopped. With log-and-settle in place, repeated fires of one change collapse to a single print: the earliest run prints, later runs find its row and skip. Genuine repricings still print every time, because each carries a distinct price and therefore a distinct log entry with no earlier twin. We verified this by watching the log rows accumulate against the labels that actually emitted — multiple rows for a duplicated change, one label; one row per genuine reprice, one label each.
The recreate-driven duplicate is a separate fix living at the identity layer, not the print layer: identity has to be anchored to something that survives a recreate — the SKU or another stable business key — rather than the store's mutable internal item id. Idempotent printing was necessary and it was not sufficient; a stable identity key is the other half.
Takeaways
- Deduplicate at the outcome, not the transport. We didn't need to know if we'd seen an event before; we needed to know if we'd already printed this label. Keying dedup on SKU+price put the check where the duplication actually mattered.
- Log first, settle, then act. Writing intent before the side effect, pausing for stragglers, and letting only the earliest run proceed turns an unordered burst of duplicates into a clear winner — no distributed lock required.
- Make the legitimate case fall through for free. Because a real reprice is a new price and therefore a new log entry, the filter passes it without any special detection logic. Design the key so correct behavior is the default.
- Never key identity on a mutable id. The store's internal item id changed on recreate, so the ERP saw a new product and printed again. Anchor identity to a stable business key like the SKU; treat platform-internal ids as disposable.
- Idempotency has layers. Exact-once printing didn't stop a duplicate born in identity resolution. Fix the layer where the duplication is actually created, and don't assume one guarantee covers another.
Ready to Implement These Strategies?
Let's discuss how to apply these insights to your specific business challenges.
Schedule Consultation