Order confirmation before dispatch
Holding a cash-on-delivery parcel until the buyer confirms they still want it.
Module: packages/api/src/modules/order-confirmation Tests: bun run test:confirmation (40 checks)
Why
RTO (return to origin) runs 20-40% on Pakistani cash on delivery, and the seller pays freight in both directions. A returned parcel has cost them two legs of shipping and earned nothing. Asking the buyer “do you still want this?” before the box moves is the cheapest lever against that, and it is worth more to a seller than several points of commission.
It protects us too. An RTO parcel produces no commission but still consumes a courier booking, a support ticket and a slot in the settlement ledger.
The one decision that matters
Most buyers will not reply to anything. Any flow that treats silence as refusal destroys far more good orders than the RTO it prevents.
So silence is not refusal by default. Each order gets a policy at creation:
| Policy | Outreach | If the buyer never answers |
|---|---|---|
auto | none | n/a, confirmed on creation |
verify | yes | ships anyway |
strict | yes | cancelled |
Only when risk is high enough does shipping become the more expensive mistake. That threshold is the whole design, and it is the number to revisit first once real RTO data exists.
Risk scoring
packages/api/src/modules/order-confirmation/risk.ts, a pure function so it is testable without a database.
Prepaid always returns auto: the money is already ours, an undelivered parcel is a refund rather than a loss, and delaying someone who has paid to ask whether they meant it looks amateur.
For COD, 0-100, medium at 15 and high at 45:
| Signal | Effect |
|---|---|
| Previous returns on this number | up to +50, scaled by return rate |
| 3+ deliveries kept | -25 |
| 1-2 deliveries kept | -12 |
| First order from this number | +18 |
| 3+ open orders and nothing delivered | +20 |
| Order value above typical | up to +30, log-scaled |
| Address under 12 characters | +15 |
| No city | +10 |
| Guest checkout, number not OTP-verified | +8 |
| 6+ items | +6 |
Two rules are load-bearing:
- History beats value. A buyer with eight deliveries placing a Rs 45,000 order is asked, not blocked. Treating good customers like fraud is how you lose them.
- Returns and deliveries are one branch, not two. An earlier version added a return penalty and then subtracted a delivery discount, which let a buyer with three returns and four deliveries score as safe. The return rate already prices the good orders.
A COD order above Rs 25,000 is never auto-confirmed regardless of score. The score can be wrong; the size of the loss cannot.
Flow
order.placed
└─ subscriber opens a confirmation, scores it, picks a policy
├─ auto → confirmed immediately, dispatch released
└─ verify/strict
└─ sweep job (every 5 min)
├─ WhatsApp template, SMS fallback
├─ retry after 45 min
├─ escalate to the call queue after 2 attempts
└─ after 6 more hours, policy decides
Buyer taps Yes or No on /confirm/<token>. An agent can close it out from the admin queue. Any of those three paths runs the same lib/confirmation/resolve.ts helpers, so “cancelled” means the same thing however it happened.
The dispatch gate
POST /vendor/orders/:id/fulfillments returns 409 until the confirmation is confirmed. Enforced in src/api/middlewares.ts rather than the vendor UI, because the seller mobile app and the Shopify connector hit the same route. A rule that lives in a React component is a suggestion.
It fails open. If the module throws or the record is missing, dispatch proceeds. An RTO costs a seller one parcel’s freight; a gate that jams costs them every order they have, and they would be right to leave over it.
Tokens
The one-tap link is a bearer credential, so it is never stored. It is derived as HMAC(JWT_SECRET, confirmationId:salt) from a random salt on the row, and only a lookup digest is written. A dumped table yields nothing usable, because the secret is in the environment.
Deriving rather than randomising also keeps the link stable across retries. A buyer who ignores the WhatsApp, gets the SMS, then scrolls back to the first message still lands on a working page.
The link is deliberately not revoked when the order resolves. Revoking meant a buyer who confirmed and then re-opened the message was told the link was invalid, which reads as though their confirmation failed. Both confirm and cancel no-op on a terminal record, so a live token there is inert.
The page shows items, total and city. Not the street address, not the phone, not the buyer’s name: the realistic exposure is a link forwarded into a group chat, and limiting the payload is what keeps that from being a leak.
Config
| Variable | Default | Meaning |
|---|---|---|
PK_CONFIRM_MEDIUM_AT | 15 | Score at which we start asking |
PK_CONFIRM_HIGH_AT | 45 | Score at which silence cancels |
PK_CONFIRM_HIGH_VALUE | 25000 | Never auto-confirm above this |
PK_CONFIRM_TYPICAL_VALUE | 3500 | Baseline for the value component |
PK_CONFIRM_RETRY_MINUTES | 45 | Gap between attempts |
PK_CONFIRM_MAX_ATTEMPTS | 2 | Automated attempts before escalation |
PK_CONFIRM_ESCALATION_HOURS | 6 | Time an agent has before policy decides |
PK_CONFIRM_TOKEN_TTL_HOURS | 48 | Link lifetime |
PK_WHATSAPP_DRIVER | console | none forces the SMS fallback |
PK_DEFAULT_PAYMENT_METHOD | cod | Used when no provider identifies itself |
Endpoints
| Route | Auth | Purpose |
|---|---|---|
GET /store/order-confirmation/:token | none | What the buyer sees |
POST /store/order-confirmation/:token/confirm | none | Yes |
POST /store/order-confirmation/:token/cancel | none | No, with a reason |
GET /vendor/orders/:id/confirmation | seller | May I dispatch? |
GET /admin/order-confirmations | admin | The call queue |
POST /admin/order-confirmations/:id/resolve | admin | Close out a call |
Confirm and cancel are POST, not GET. Link previewers, WhatsApp’s crawler and mail scanners all fetch URLs they are shown, so a GET would confirm a meaningful share of orders before the buyer read anything.
A trap worth knowing
Medusa recomputes an order’s total from whichever item fields you requested. Asking for items.id reported a PKR 156,050 order as PKR 250 (the shipping, and nothing else) with no error. items.quantity plus items.unit_price is still not enough. Only items.*, or no item fields at all, gives the real number.
It was silently disabling every value-based risk rule and would have shown the buyer a total of Rs 250 to confirm. reconciledTotal() in lib/confirmation/open-for-order.ts now cross-checks the total against the sum of its own lines and logs loudly when they disagree.
Verified
bun run test:confirmation — 35 checks, each proven able to fail by deliberately breaking the code it covers (the gate, token storage, the policy split).
End to end against a running stack: a real PKR 156,050 checkout opened a confirmation on order.placed; the seller got 409 on dispatch; the buyer confirmed and dispatch was released; a second order was cancelled from the page, which cancelled the Medusa order and left the seller a 409 saying not to ship.
Not built
- Inbound WhatsApp replies. The buyer taps a link; typing “yes” does nothing. Needs an inbound webhook and it needs the BSP contract first.
- Delivery receipts. We log what we sent, not what arrived. Until the gateway reports back, “sent” means “handed to the gateway”.
- Real RTO outcomes.
rtoOrderscurrently counts buyers who cancelled at the confirmation step, which understates the real thing: the buyer who never answers and then refuses at the door is the expensive one and does not appear here at all. Wire courier outcomes into this once remittance files are flowing. - Urdu. The copy is plain English. This page more than any other should be translated first.