# Webhook Idempotency for Atoms: Tracking a Physical Order

> Webhook idempotency for physical orders: five stages, where each signal comes from, handlers that survive a retry, and why our 3D printing API is polled.

Source: https://x3dstudios.com/blog/webhooks-for-atoms  ·  2026-09-24  ·  Updated: 2026-10-23  ·  X3D Studios  ·  9 min

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

![Diagram of three signal sources feeding one order record: Bambu H2S and P2S printer telemetry over the LAN gateway, a live camera plus an operator in Austin, and carrier scans from the shipping label; the record then drives the order page and the email or webhook](https://x3dstudios.com/blog-images/webhooks-for-atoms/physical-event-sources.svg)

*Three signals, one record. None of them is the object itself.*

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 /blog/what-happens-after-you-click-print.

## The Four Stages, and Who Sets Each One

![The four order stages placed, printed, shipped and delivered, each with its signal source, who sets it, and whether a customer email is sent (three of four do), plus cancelled shown as a terminal state off the pipeline](https://x3dstudios.com/blog-images/webhooks-for-atoms/stage-ladder.svg)

*Four stages, three emails. The box still gets closed; it just has no stage of its own.*

| 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.

> **TIP** — Record every stage. Notify a person only when the stage carries something they can act on. Those are two different decisions, and collapsing them into one is why status emails feel like spam.

## 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:

```typescript
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" },
};
```

> **WARNING** — Never give a status-to-copy lookup a default branch. A fallback turns "we added a stage" into "we told a customer their order arrived." Make the compiler force the conversation instead.

## 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:

```typescript
// 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. /blog/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 — /blog/how-to-order-3d-prints-online walks through it from the other side, and /blog/what-is-a-3d-print-farm 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.

[Read the API docs](https://x3dstudios.com/docs/api)
