Webhook Idempotency for Atoms: Tracking a Physical Order
An order webhook for a physical product is the same idea as a webhook for a payment — an event pushed to your server when state changes — with one difference that rewrites every design decision downstream, webhook idempotency included: atoms do not emit events. A printer finishing a plate is not an HTTP request. It is a machine that stopped moving. Something or someone has to notice, and that noticing is the entire engineering problem. Here is how we model it on our farm — internally, since the X3D print API itself does not push webhooks and is followed by polling (/docs/api/webhooks): five order stages, a hard rule about which of them a customer hears about, and handlers written on the assumption they will be called twice.
Payment vs Order Webhooks: Where Idempotency Gets Expensive
Start with why you cannot just port your Stripe handler. A payment event and a print-stage event have almost nothing in common except the shape of the JSON.
| Payment webhook | Print stage event | |
|---|---|---|
| Emitted by | The system that owns the truth | Nothing — the truth is an object on a plate |
| Latency | Milliseconds to seconds | Minutes to hours |
| Cost of a duplicate | A double email, at worst | A reprinted part: grams, hours, a plate slot |
| Cost of a miss | The sender retries | Silence, then a customer emails you |
| Ordering | Rarely guaranteed | Physically monotonic — nothing un-prints |
| Reversal | Refund or chargeback | Cancelled, which is off the pipeline entirely |
That last row about ordering deserves a caveat, because it is the one that catches people. The physical world is monotonic: a part cannot become unprinted, and a box cannot become unboxed. The record of the physical world is not monotonic, because operators fat-finger things. Our stage model has an explicit isForward check for exactly this reason — moving an order onward is a different operation from correcting a mistake backwards, and the two should not share a code path or an email template.
Where a Physical Status Change Actually Comes From
Three sources, with very different trust properties. Printer telemetry comes off the Bambu H2S and P2S machines through a LAN gateway: plate finished, paused, error, idle. It is fast and it is honest about the machine, but it knows nothing about the part — a printer reports success on a plate of spaghetti just as cheerfully as on a good print. The live camera plus a human on the floor in Austin covers that gap. The live camera feeds are part of the farm console at /farm, which is also where an operator advances a stage. Carrier scans come from the shipping label and are the only signal nobody at the farm can see happen.
The design rule that falls out of this: never infer a stage. Every write to the status field is deliberate, made by code that knows what it observed or by an operator who looked at the thing. Nothing advances on a timer, and nothing degrades because a signal went quiet. If no signal arrives, the order stays exactly where it is, which is the correct behaviour — it is also where it really is. The rest of what happens between the print button and the doorstep is laid out in 3D Printing Process: What Happens After You Click Print.
The Four Stages, and Who Sets Each One
| Stage | What changed | Signal | Customer email |
|---|---|---|---|
| placed | Payment cleared | Stripe checkout session, written by code | No |
| printed | Parts are off the plate | Printer done, confirmed on the live camera | Yes |
| shipped | Carrier has it | Label bought, tracking number issued | Yes |
| delivered | It arrived | Carrier scan at the destination | Yes |
| cancelled | Order pulled, payment refunded | Operator plus the refund | No |
The no-email rows are deliberate rather than unimplemented. Placed does not email because checkout already sent a receipt; a second message thirty seconds later reads as a bug. Boxing the part is not a row at all: it happens between printed and shipped without a status of its own, so the next email after printed is the shipped one. Shipped is the opposite: it is the one stage carrying something actionable, the tracking number, so it always goes out.
The Bug That Made Us Write This Down
The first version of our stage handling picked an email subject with a two-test ternary. Is it shipped? Send the shipped copy. Is it printed? Send the printed copy. Otherwise — and this is the part — send the delivered copy. It worked, in the sense that it produced correct output for every stage that existed when it was written. The moment a stage was added, that fallback branch would have told customers their order had arrived while it was still sitting on a plate.
The fix is not a better ternary. It is making the stage list the single source of truth and the copy an exhaustive record keyed by it, so that adding a stage is a compile error everywhere it has to be handled:
export const ORDER_STAGES = [
"placed", "printed", "shipped", "delivered",
] as const;
export type OrderStage = (typeof ORDER_STAGES)[number];
/** Terminal and off-pipeline — not a fifth stage. */
export type OrderStatus = OrderStage | "cancelled";
// Record<OrderStatus, …> is the point: add a stage above and this
// object stops compiling until someone decides what to say about it.
export const STAGE_META: Record<OrderStatus, {
customerLabel: string;
emailsByDefault: boolean;
subject: string;
}> = {
placed: { customerLabel: "Order placed", emailsByDefault: false, subject: "We've got your order" },
printed: { customerLabel: "Printed", emailsByDefault: true, subject: "Your order has been printed" },
shipped: { customerLabel: "Shipped", emailsByDefault: true, subject: "Your order is on its way" },
delivered: { customerLabel: "Delivered", emailsByDefault: true, subject: "Your order has been delivered" },
cancelled: { customerLabel: "Cancelled", emailsByDefault: false, subject: "Your order has been cancelled" },
};Cancelled Is Not a Stage
It is tempting to append cancelled to the end of the pipeline and be done. Do not. A cancelled order has no position on the track: it is not further along than printed, and it is not behind it. In our model stageIndex returns -1 for it, isForward refuses to treat any transition into or out of it as moving the order onward, and the customer's order page renders a cancelled panel instead of the progress bar rather than drawing a bar that is somehow both finished and wrong.
The general shape of this rule: terminal off-pipeline states get their own type branch and their own UI, never a fifth position in an ordered list. Refunded, lost-in-transit, and returned all behave the same way. A progress bar is a claim about how far along something is, and these states are a claim that the question no longer applies.
Handlers That Survive Being Called Twice
Payment providers deliver at-least-once, which means your endpoint will see the same event more than once — on a retry after a timeout, on a redelivery, or because two instances of your worker picked it up at the same moment. The standard answer is to claim the event by id before doing any work, with a lease so that a handler crashing mid-flight does not wedge that event forever:
// Claim before work. Three outcomes, all of them real.
const claim = await claimStripeEvent(event.id, event.type);
if (claim === "completed") return; // already done — ack and move on
if (claim === "processing") { // another worker has it right now
throw new StripeEventInProgressError(); // 5xx, so the sender retries later
}
await handleCheckoutSession(event.data.object);
// ... then mark the event completed
// The lease expires on its own so a crashed handler self-heals:
const processingLeaseMs = 10 * 60 * 1000;Order creation gets the same treatment one layer down. The order document is keyed by the order id and written only if it does not already exist; if it does, the existing record is returned untouched. Two writes produce one order, which is the only acceptable outcome when the second write would otherwise mean a second part on a printer.
This is where the software analogy stops being an analogy and starts costing money. A duplicated charge is embarrassing and refundable. A duplicated print is grams of PLA at $0.12 each, machine hours on a plate another order wanted, a box, and $7 of shipping ($15 if it went express) — none of which comes back. Idempotency is a nice property in a payments handler and a hard requirement in a manufacturing one. 3D Printing API: Manufacturing as Code covers what the rest of that API surface looks like.
Polling Is Fine, Actually
Webhook purity is a habit from a world where events arrive in milliseconds and a poll wastes real time. Physical stages take hours. A sixty-second poll against an order that spends four hours in printed is indistinguishable, from the customer's point of view, from an instant push. So build the consumer to accept both: the webhook is a latency optimisation, and the poll is your correctness backstop for the day the webhook endpoint is down and nobody notices for six hours.
- Set timeout budgets in hours, not seconds. Our Normal print ships in 4–5 business days; an alert that fires at ten minutes is noise.
- Never treat "no event yet" as failure. Absence of a signal means the object has not moved, which is a completely normal state for a physical thing.
- Reconcile on a schedule. Ask for the current status of every open order once an hour and trust that answer over your event log — the record is the source of truth, the events are notifications about it.
- Log the transition, not just the state. "printed → shipped at 14:02" answers questions that a status field alone cannot.
- Expect backwards corrections and handle them quietly. An operator fixing a mis-click should not send a customer an email that unships their order.
If you want to see the customer-facing half of this — what the stages look like on an order page, and what actually lands in an inbox — How to Order 3D Printing Online — No CAD Skills Needed walks through it from the other side, and What Is a 3D Print Farm? How Ours Runs in Austin covers the machinery that produces the signals in the first place.
FAQ
What is a webhook for a physical product?
An HTTP callback fired when a manufacturing or fulfilment stage changes — printed, shipped, delivered — carrying the order id, the new stage, and anything actionable such as a tracking number. The difference from a software webhook is that the event is generated by a system observing the physical world, not by the object itself.
How do I make an order webhook handler idempotent?
Claim the event by its provider-assigned id before doing any work, and record three states: unclaimed, processing, completed. Return immediately on completed, fail with a 5xx on processing so the sender retries, and put a lease on the processing state — ten minutes works well — so a crashed handler heals itself instead of blocking that event forever.
Should I poll or use webhooks for print order status?
Both. Physical stages take hours, so a one-minute poll is functionally as fast as a push and it keeps working when your endpoint is down. Treat the webhook as a latency optimisation and an hourly reconciliation sweep as the thing that guarantees you eventually have the truth.
What happens if an order is cancelled mid-print?
It leaves the pipeline rather than advancing along it. The status becomes cancelled, which is terminal and carries no position, the payment is refunded, and the order page shows a cancelled panel instead of a progress bar. In code, treat it as a separate branch of the status type — never as a fifth entry in an ordered stage list.
Price it now
What would yours cost?
A real example, priced by the same engine our checkout uses: a 50 g PLA part comes to $6.00 printed, plus $7.00 US shipping. Change the weight, material or quantity below and the price follows.
- 50 g × $0.12/g, per part
- $6.00
- Shipping
- $7.00
The same through the cart, where postage is free on orders of $49.50 or more.
Upload a file for the exact priceAn estimate from the weight you entered. The real quote weighs your file.
Two ways to your real price
Have a file?
Upload it. See the exact price.
Drop in your STL or 3MF. We slice it the way our printers will and price the grams it really uses. No account needed to see it.
Only an idea?
Describe it. We’ll email you a price.
No file, no problem. A few words on what it is and how big is plenty to start.
Or write to contact@x3dstudios.com