Cash-on-delivery lifecycle
The states, how order.paid comes from the ledger, and the carrier path with real requests and responses.
On this page
This page is for anyone building a carrier, notifier or accounting app. Our market buys with cash on delivery, and platforms cloned from the West treat "delivered" and "paid" as one event; Dukkan separates them because reality does. By the end you know every state, when each event fires, and how to write as a carrier.
States#
Mermaid
stateDiagram-v2
direction LR
[*] --> placed
placed --> approved
approved --> processing
processing --> shipped
shipped --> delivered
shipped --> delivered_failed
delivered_failed --> delivered: retry
delivered_failed --> returned
placed --> cancelled
approved --> cancelled
processing --> cancelled
delivered --> [*]
returned --> [*]
cancelled --> [*]delivered_failed: the customer did not receive (refused at the door, unreachable). Not the end of the road: retry (delivered) or return (returned).returnedon an unpaid COD order is terminal with no refund; no money was ever collected.- Money-bearing statuses (
completed,partially_refunded,refunded) are never written through a status change; they come from the refund and ledger writers. The writable values areSettableOrderStatusin the API reference.
order.paid fires from money, not from words#
payment_status projects the payments ledger: order.paid emits only when actually-collected funds cross the order total (a cod_collection ledger row). A delivered parcel alone does not mean the merchant has the cash; courier remittance and merchant confirmation are a separate step.
For notifier apps: subscribe to all three of order.created, order.status_changed and order.paid, and treat them as three different customer stories:
shipped→ "your order is on the way"delivered_failed→ "we couldn't deliver and will retry"order.paid→ the payment receipt
The carrier path#
Required scopes: orders:read for events and reads, fulfillments:write for fulfillments, and orders:write if you will change order status directly. Every write requires an Idempotency-Key; your courier will retry, and the system is built for that.
1. Book the pickup#
Create a fulfillment as pending: it reserves the quantities without shipping the order (booked before pickup from the store).
curl -X POST "https://dukkan.one/platform-api/v1/orders/ORDER_ID/fulfillments" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Idempotency-Key: booking-ORDER_ID-1" \
-H "Content-Type: application/json" \
-d @fulfillment.json{
"provider": "aramex",
"tracking_number": null,
"status": "pending",
"lines": [
{
"order_item_id": "a1b2c3d4-0001-4e8f-9b70-2d1c3e4f5a6b",
"quantity": 1
}
]
}{
"data": {
"id": "f0f0f0f0-0001-4e8f-9b70-2d1c3e4f5a6b",
"order_id": "6f1d2c3b-4a59-4e8f-9b70-2d1c3e4f5a6b",
"status": "pending",
"order_status": "placed",
"lines": [
{
"order_item_id": "a1b2c3d4-0001-4e8f-9b70-2d1c3e4f5a6b",
"quantity": 1
}
],
"created_at": "2026-08-18T16:05:00.000Z"
}
}fulfillment.created fires, then fulfillment.requested (because the status is pending). If you create it directly with status: "shipped" and it covers every line, the order moves to shipped automatically.
2. The parcel leaves#
curl -X PATCH "https://dukkan.one/platform-api/v1/orders/ORDER_ID/fulfillments/FULFILLMENT_ID" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Idempotency-Key: shipped-FULFILLMENT_ID" \
-H "Content-Type: application/json" \
-d '{"status":"shipped","tracking_number":"ARX-778812"}'Legal transitions: pending → shipped → delivered, and pending|shipped → cancelled. Cancelling releases the quantity the fulfillment consumed, so the line can be fulfilled again. A terminal fulfillment (delivered or cancelled) rejects further edits with 409.
3. Delivery and the collection proposal#
curl -X PATCH "https://dukkan.one/platform-api/v1/orders/ORDER_ID/fulfillments/FULFILLMENT_ID" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Idempotency-Key: delivered-FULFILLMENT_ID" \
-H "Content-Type: application/json" \
-d '{"status":"delivered","cod_collected_minor":275000,"cod_collected_note":"Collected in full at the door"}'{
"data": {
"id": "f0f0f0f0-0001-4e8f-9b70-2d1c3e4f5a6b",
"order_id": "6f1d2c3b-4a59-4e8f-9b70-2d1c3e4f5a6b",
"status": "delivered",
"tracking_number": "ARX-778812",
"created_at": "2026-08-18T16:05:00.000Z",
"updated_at": "2026-08-20T11:40:00.000Z",
"order_status": "delivered",
"order_status_skipped": false,
"cod_collection_proposal_id": "c0d00001-0001-4e8f-9b70-2d1c3e4f5a6b"
}
}- When the last outstanding fulfillment is delivered and every line is covered, the order moves to
deliveredautomatically (order_status: "delivered"). If the transition matrix refuses because of the order's current state, the fulfillment stays delivered andorder_status_skipped: true. cod_collected_minoris a collection proposal: the cash the courier reports collecting, in minor units (it may be less than the total). It moves no money. One open proposal per order; re-sending updates it.
4. The merchant confirms#
The merchant confirms the collection in the admin; that confirmation writes the cod_collection ledger row, which updates payment_status and fires order.paid with the collected amount_minor. That is the event that means "the merchant has the cash".
Handling 409#
A status change through PATCH /orders/{id}/status goes through a server-side transition matrix with row locking. A conflict (the merchant cancelled while you deliver) returns 409:
{
"error": {
"code": "conflict",
"message": "Order cannot be fulfilled in its current status",
"request_id": "7d0c2b1a-5e4f-4a3b-8c9d-0e1f2a3b4c5d"
}
}Re-read the order with GET /orders/{id} and decide from its current state; do not retry the same payload. Reusing an Idempotency-Key with a different payload also returns 409; with the same payload it returns the original result with the header Idempotency-Replayed: true.
Returns and goods#
A return is two independent events: money (a refund through POST /orders/{id}/refunds, only if paid) and goods (an inventory movement with reason return). Never infer one from the other: damaged goods return without restocking, and unpaid orders return without refunds. See Inventory movements.