← Back to blog

Make delivery a consequence of payment, not a task someone performs

Published on
4 mins read
--- views

Manual digital delivery has a shape you can recognise from across the room. Someone watches the order list, finds a free code in a spreadsheet, pastes it into an email, and crosses it off.

Every property of that system follows from the person in the middle. Delivery takes as long as it takes them to notice. It stops when they sleep. And the same code cannot go out twice only because they are paying attention, which is not a guarantee. It is a hope with a good track record.

Events, not polling

The fix is to hang delivery off the events the shop already emits:

add_action('woocommerce_order_status_changed', [$this, 'handle'], 10, 4);

Now nothing has to be watched. Payment lands, the handler resolves a code, the code attaches to the order. Three in the morning works the same as three in the afternoon.

The reason to prefer events over a cron job that scans for unfulfilled orders is latency and honesty. A scanner has a period, and the period is a delay you are choosing to impose on every customer. An event handler runs when the thing happens. Keep a scanner as a safety net for orders whose event was missed, but do not make it the primary path.

Store state, not a strikethrough

Codes need to live in a table with per-code state, and the reason is not tidiness. Issuing a code and activating it are different facts. A code you handed to a customer who never redeemed it is not the same as one that has been consumed, and a business that cannot tell them apart cannot answer "did this customer actually get what they paid for".

A spreadsheet cell with a line through it collapses both into one bit. Worse, it collapses them destructively: the previous state is gone.

One implementation, two callers

Orders rarely arrive from one place for long. A marketplace channel shows up, and it expects delivery in minutes.

The temptation is to write a second fulfilment path for it, because the marketplace integration lives elsewhere and does not have a WordPress session. Resist that. Expose the same logic over an authenticated API instead:

POST /wp-json//activate-product-code     Authorization: Bearer <jwt>

The delivery logic then has exactly one implementation with two callers. The alternative, two implementations, means every future change to how delivery works has to be made twice, and the day someone makes it once is the day the two channels start behaving differently. That bug is maddening, because both paths look correct in isolation.

A token rather than an account, incidentally, because handing an admin account to an integration gives it every capability in WordPress to solve a problem that needs one.

Make the external channel queryable

If orders come from a marketplace, the system needs to answer "which orders came from there and what happened to them" as a query. Tag them at creation with their source and their origin id.

Without that, reconciling the two systems becomes a periodic manual chore, which is exactly the work you removed from delivery reappearing in accounting. The pattern repeats: anything you will later need to ask about, record at the moment it happens. Reconstructing it afterwards from timestamps and amounts is guesswork wearing a spreadsheet.

Where this leaves the operator

Automating delivery does not remove people. It changes what they do: instead of performing every delivery, they handle the ones that failed. That is a better use of a person and a much smaller job, but it only works if failures are visible. An automated pipeline that fails silently is worse than a manual one, because nobody is watching the thing that used to be watched by definition.

So the same commit that automates delivery should make failure loud: a note on the order, a status that stands out in the list, a log line with the order id in it.

Open for contract collaboration

I am available for contract-based collaboration. If you have an interesting project idea, schedule a call via Calendly.

Schedule a 30-min call