Designing checkout for a market where 85% of orders are cash on delivery
Most commerce platforms assume the customer pays and then you ship. Stripe charges the card, the webhook fires, the order moves to paid, and fulfilment starts from a settled transaction. In Bangladesh that assumption is
Most commerce platforms assume the customer pays and then you ship. Stripe charges the card, the webhook fires, the order moves to paid, and fulfilment starts from a settled transaction.
In Bangladesh that assumption is wrong for roughly 85% of orders. The customer pays the courier in cash at their door, often days after ordering. Sometimes they don't pay at all, because they refuse the parcel when it arrives.
I've been building StoreOS, a commerce platform for Bangladeshi sellers, and cash on delivery isn't a payment method we bolted on afterwards. It's the case the schema was designed around.
The order isn't a transaction, it's a promise
With card payments, paid is a fact. The money moved. With COD, creating an order gives you a promise that money will move, plus a real chance it won't.
So payment status and order status can't be the same field:
export type InvoicePaymentMethod = "COD" | "ONLINE";
export type InvoiceStatus =
| "PENDING"
| "PROCESSING"
| "REJECTED"
| "SHIPMENT_IN_PROGRESS"
| "SHIPMENT_IN_TRANSIT";
An order can be SHIPMENT_IN_TRANSIT and still unpaid. Here that's the normal case rather than an error state. If you model payment as a boolean on the order, you can't represent the most common order in the market.
Checkout has no payment step
This is what surprises people coming from Shopify or Stripe Checkout. The customer enters name, phone, and address, confirms, and the order exists. There's no card form, no redirect, no 3DS challenge, and nothing to wait on from a webhook.
In our SDK it's one call:
import { StoreFront } from "@storeos/storefront-client";
const store = new StoreFront({
tenant: process.env.NEXT_PUBLIC_SITE_API_TENANT!,
});
const order = await store.createOrder({
customer: {
name: "Rahim Uddin",
phoneNumber: "01712345678",
},
lineItems: [{ productId: "abc123", quantity: 2 }],
shippingAddress: {
area: "Uttara Sector 11",
city: "Dhaka",
street: "Road 10, Plot 4",
},
paymentMethod: "COD",
deliveryArea: "dhaka-city",
});
Look at what isn't there: no paymentIntentId, no token, no return URL. Setting paymentMethod: "COD" creates a real order while the money question stays open.
Phone number is the identity
Email-first signup is a Western default. Plenty of shoppers here don't use email regularly, but everyone has a phone number, and it's the only contact detail the courier actually needs.
So phone is the primary identifier, verified over OTP:
await store.verifyOtp({
phoneNumber: "01712345678",
otp: "123456",
});
There's a subtler consequence too. In our order input customer.email is optional and phoneNumber effectively isn't. Make email required at checkout and you lose orders.
Ordering without an account works as well, since createCustomerAccount is a flag rather than a precondition:
{
customer: { name: "Rahim Uddin", phoneNumber: "01712345678" },
createCustomerAccount: false,
// ...
}
Forcing signup before a first purchase costs you conversions. With COD there's no stored payment method to justify it.
The courier is part of your domain model
With prepaid orders, shipping happens downstream. You already have the money, so a delivery failure becomes a support ticket.
With COD, Pathao or Steadfast collects the cash and remits it to the merchant later, which makes the courier part of your payment path. Delivery status and payment status end up coupled, because delivered usually means paid. A refused parcel is a failed payment rather than just a failed delivery. Cash flow lags delivery by whatever the courier's remittance cycle happens to be. And since delivery area affects the price, deliveryArea belongs on the order itself.
Put courier integration in a plugin and none of that composes properly. It has to live in the core.
What I'd check
If you're building commerce for a COD-heavy market, start with whether your schema can represent shipped-and-unpaid. If it can't, you can't model the normal case.
Then check whether phone is a first-class identity, because email-required checkout will cost you orders. Check whether delivery area feeds into pricing, since flat-rate shipping doesn't survive contact with local courier rates. And work out what happens on refusal. You need a real state for a parcel that went out, came back, and brought no money with it. That's an ordinary outcome here.
None of this is exotic engineering. It's a different default, and the platforms most of us learned from encode the opposite one.
StoreOS is a commerce platform for Bangladeshi sellers. The Storefront API and TypeScript SDK are public if you want to build a custom frontend on it.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.