I’m Badii Boutar. I build online stores for clients, mostly in Tunisia and the Gulf, and almost every one of them turns on cash on delivery first. I also built The Merchant Engine, an open-source store you run on your own server, so the examples below come from it. The ideas work on any stack.
A cash order looks simple. There is no card form, no payment page and no webhook. That is exactly why it goes wrong: nothing outside your system checks the order, so every mistake ends up in front of a courier or a customer.
What makes cash on delivery different
With a card, the money moves before you ship. With cash, you ship first and hope the money comes back. That changes three things.
- The order is a promise, not a payment. It starts unpaid and stays unpaid until the courier hands you the cash.
- A fake or doubled order costs real money. You pay the courier for the trip whether or not anyone opens the door.
- The customer has no receipt from a bank. Your confirmation page, your email and your tracking page are all they have.
So a good cash checkout does three jobs well: it never creates the same order twice, it never sells stock you do not have, and it keeps the customer informed until the parcel arrives.
Tell the storefront which methods exist
The storefront should not hard-code payment methods. In The Merchant Engine, GET /v1/checkout/settings returns the methods the store offers, each with a code, a translated name and a description. The demo answers with one method, code cod, described as “Pay the courier in cash when your order arrives.”
That route is sent with Cache-Control: no-store on purpose. When the operator turns cash on delivery off for a weekend, the next checkout page that opens has to know. Read it when the checkout opens, not at build time.
Ask for the phone number
For a cash order, the phone number matters more than the email. The courier calls before the trip, and in my client work a quick call from the shop catches most fake orders before anything ships. The shipping address on an order in The Merchant Engine requires a full name, a phone, an address line, a city and a country. Keep the phone required.
Send each order once
This is the part most cash checkouts get wrong. The customer taps “Place order”, the network is slow, nothing happens, so they tap again. Or the browser retries on its own. Without protection, you now have two orders and the courier makes two trips.
The fix is an idempotency key. The storefront creates one random UUID when the checkout page renders and sends it on the order request as X-Idempotency-Key. If the request is retried with the same key and the same body, the API returns the first order instead of creating a second one. A few rules make it work:
- Create the key once, outside the click handler, so every retry reuses it.
- Create a new key when the cart changes. A different body with the same key is refused with
409 IDEMPOTENCY_KEY_CONFLICT. - If a second request arrives while the first is still running, it gets
409 ORDER_IDEMPOTENCY_IN_PROGRESS. Wait and retry with the same key.
I wrote a full post on how this works inside the API: Idempotent checkout in NestJS. The storefront side, with the exact requests, is on Checkout and orders.
Take the stock at order time
With cash, there is a gap of days between the order and the payment. If you wait for the payment to take stock, you will sell the last item three times. Take it when the order is created.
In The Merchant Engine, each line of the order lowers the stock with a guarded update: the row only changes if there is enough left. If one line fails, the lines already taken are put back and the order is refused. When an order is cancelled before it ships, its stock is restored, once.
Keep the order and the payment apart
A cash order has two states, and they move at different times.
- The order status starts at
PENDING. The operator moves it toCONFIRMEDwhen they have checked the order, then on through shipping. - The payment status also starts at
PENDING. It moves toCAPTUREDwhen the operator records that the cash arrived. There is no step before that, because no money is held.
Keeping them apart answers the question a shop owner asks every evening: which orders went out but are not paid yet.
Write the invoice after confirmation
An invoice for an order nobody has checked yet is noise, and in some countries a legal document you then have to cancel. In The Merchant Engine, the invoice is written when the operator confirms the order. Before that, asking for it answers ORDER_INVOICE_NOT_READY, and the storefront can say “Your invoice will be ready once we confirm your order.”
Let the customer cancel early
People change their minds, and with cash it costs them nothing to leave the door closed. It is cheaper for you if they cancel online first. A signed-in customer can cancel while the order is PENDING or CONFIRMED and nothing has shipped yet. The order response carries a cancellable flag computed by the same rule the cancel route enforces, so the storefront shows the button only when it will work. Once a shipment exists, the customer has to contact the store.
Give guests a way back
Many cash buyers check out as guests and close the tab. POST /v1/orders/track takes the order number and the email on the order and returns the status and the timeline. It is a POST, so the email never lands in an access log, and a wrong number and a wrong email get the same answer, so the page does not tell anyone which orders exist.
Snapshot the names
If the customer ordered in Arabic, the confirmation, the email and the invoice should say “الدفع عند الاستلام” a month later, even if someone renames the method in the admin. The Merchant Engine saves the shipping and payment method names on the order in the locale it was placed in.
Get the tax right
Tax applies to the goods after the discount. Shipping sits outside that base. The cart total is an estimate. Show the customer the total from the order response, because that is what they will hand the courier.
What it does not do
To be honest about the limits:
- There is no automatic fraud score. The phone call is still the best filter I know for cash orders.
- There is no courier integration that marks the cash as collected. The operator records it in the admin.
- Stripe is the only card provider so far. If you need another card provider on launch day, this is not the store for you today. The full list is on What you get.
What I’d do
If you are building a cash checkout on any stack, start with three things: an idempotency key on the order request, stock taken at order time with a guarded update, and order and payment states kept apart. Those three remove most of the problems I see in client stores.
If you want to see them working, place a cash order on the storefront demo, then open it in the admin demo. The code is on GitHub under MIT.