Webhooks for Atoms: Tracking a Physical Product
A 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: 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: 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.
Software Events vs Physical Events
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 /blog/what-happens-after-you-click-print.
The Five 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 |
| packed | Boxed, waiting on pickup | The operator, on the floor | No |
| 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 two no-email rows are the interesting ones, because both 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. Packed does not email because there is nothing in it a customer can act on — it is an internal milestone that matters to the floor and to nobody else. 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", "packed", "shipped", "delivered",
] as const;
export type OrderStage = (typeof ORDER_STAGES)[number];
/** Terminal and off-pipeline — not a sixth 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" },
packed: { customerLabel: "Packed", emailsByDefault: false, subject: "Your order is packed" },
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 packed, 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 sixth 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 — 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. Most of our orders print in 24 to 48 hours; 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 → packed 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 sixth entry in an ordered stage list.
Ready to get started?
Upload a 3D model for instant pricing, or generate one with AI.