Autonnel v0.1.0
Back to blog

The order state machine, and who owns each transition

Six states, all one-way. Autonnel's responsibility ends at PAID and the commerce backend owns everything after. Here is why, and what that costs you.

· 7 min read

The order state machine is the least glamorous part of a funnel builder and the part where being wrong is most expensive. A page that renders badly costs you a conversion. An order that ends up in the wrong state costs you a refund dispute, a duplicate charge, or a customer who paid and never got a shipping email.

Autonnel has six order states. All transitions are one-way. The interesting design decision is not the states, it is that we only own two of them.

The states

StateWho controls the transition
PENDINGAutonnel, on order creation
PAIDAutonnel, when the payment provider confirms capture
SHIPPEDYour commerce backend. Autonnel polls and reflects it
DELIVEREDYour commerce backend, or an external API call
PARTIALLY_REFUNDEDWhoever submitted the refund
REFUNDEDWhoever submitted the refund

Read the right-hand column rather than the left. Two rows say Autonnel. The rest say someone else.

Why the line is drawn at PAID

Autonnel’s job is the money path: get the visitor to the order form, take the payment, present the upsell, write the order to your store. Once the payment provider confirms capture, the useful work is done and the order becomes a fulfilment problem.

Fulfilment is not a problem we should be solving. Your warehouse, your 3PL, your carrier integration and your returns process already live in Shopify, WooCommerce or Picocart. Rebuilding a shadow copy of that inside a funnel builder would mean two systems with opinions about whether an order shipped, and the answer to “which one is right” would be “whichever one you looked at last”.

So after PAID, Autonnel is a mirror. It polls the connected platform every few minutes for each PAID order. When a tracking number appears and the platform has not reported delivery, the order moves to SHIPPED, the tracking fields update, and a shipped email is queued. When the platform reports delivery (Shopify’s displayStatus, WooCommerce’s status === 'completed', Picocart’s fulfillment_status === 'fulfilled'), it moves to DELIVERED.

One detail I like, because it is the kind of thing that produces a confused customer if you get it wrong: if a PAID order already shows delivered on its very first poll, it goes straight to DELIVERED and only the delivered email is sent. The shipped email is skipped. Nobody wants “your order has shipped” arriving after “your order was delivered”.

The cost of that decision, stated plainly

Drawing the line here has a consequence that will bite somebody, so it should be said out loud:

An order not connected to a commerce platform will sit in PAID forever. Autonnel only polls orders that have a backend to poll. If you are running without a connected store, nothing will ever advance those orders, and you need either a manual process or the external API.

That API exists for exactly this:

POST /api/v1.1/orders/{orderId}/deliver
Authorization: Bearer <api-key-with-writeAccess>

It enforces two conditions before it will do anything. The key must have writeAccess, or you get HTTP 403. The order must currently be in SHIPPED, or you get HTTP 409.

That 409 is the important one. It is what makes the endpoint safe to point a carrier webhook at: webhooks retry, they arrive out of order, and they fire twice more often than anyone expects. An endpoint that blindly sets DELIVERED would happily re-fire the delivered email every time. Gating the transition on the current state means a duplicate delivery webhook is a 409 and a no-op, not a second email.

The state machine is the deduplication mechanism. You do not need a separate idempotency key layer for a transition that is only legal from one state.

One-way is a feature

There is no admin action and no API endpoint that moves an order backwards. You cannot go from SHIPPED back to PAID.

This annoys people occasionally and I would not change it. An order’s state is a claim about something that happened in the physical or financial world. Money was captured. A parcel was scanned. Those events do not un-happen because someone clicked the wrong button, and a system that lets you rewrite them produces records that cannot be trusted for exactly the disputes they exist to settle.

If a state is wrong, the fix is a compensating action with its own record: refund it, or correct it upstream in the platform that owns the truth and let the poll reflect it back.

Refunds are where the arithmetic gets fussy

Refunds are the one place where a transition depends on a computation rather than an event, and the computation has edges.

Three ways to refund: the full remaining refundable balance, a fixed amount, or a percentage (the UI converts a percentage to a concrete amount before submitting, so what gets stored is always money, not a ratio). The refund routes to whichever provider took the original payment, Stripe’s Refunds API or PayPal’s capture refund.

Then the state depends on the cumulative total:

  • cumulative refunds less than the order total → PARTIALLY_REFUNDED
  • cumulative refunds equal to or exceeding the order total → REFUNDED, within a 1-cent tolerance

That tolerance is not sloppiness. Three partial refunds computed as percentages of a total that does not divide evenly will land a cent away from the total, and without the tolerance you get orders stuck in PARTIALLY_REFUNDED with one cent outstanding that nobody can clear. A REFUNDED order cannot be refunded further, and attempting it is an error rather than a silent no-op.

Two guardrails around the operation itself: refunding requires the ORDERS_REFUND feature permission, and the order must be in PAID, SHIPPED, DELIVERED or PARTIALLY_REFUNDED. PENDING and REFUNDED orders cannot be refunded at all, which closes the “refund something that was never captured” hole.

Every refund also writes a permanent transaction record with COMPLETED or FAILED status, linked to the original charge, and requires a reason selected from a leaf node of the reason tree. A reason that has been used on a refund can be deactivated but never deleted. Refund records are evidence; evidence you can delete is not evidence.

PENDING is doing two jobs, and that is a wart

Honest one to close on. PENDING covers two genuinely different situations: an order waiting for the customer to finish paying, and a PayPal payment that is authorised but not yet captured. Both render as PENDING in the UI.

A background job captures authorised-but-pending PayPal payments on a configurable schedule and moves them to PAID. So the second case resolves itself. But an operator looking at a PENDING order cannot tell from the badge alone whether it is waiting on a customer or waiting on a cron, and those call for completely different responses.

Splitting it would be the correct fix and it is a migration, so it has not happened yet.

The general lesson

The valuable idea here is not the specific six states. It is: write down who owns each transition before you write the transitions.

Once “the commerce backend owns everything after PAID” was explicit, several designs followed from it without further argument. Polling instead of a second source of truth. One-way transitions. A 409 instead of an idempotency key table. State-gated permissions on refunds.

The failure mode on the other side is a state machine where every component can set every state, which is not a state machine, it is a shared mutable variable with an enum type.

Full details in order states and refunds, and the API surface an agent or a webhook handler can drive is in the API overview.