Skip to content

Cash-on-delivery lifecycle

The states, how order.paid comes from the ledger, and the carrier path with real requests and responses.

Updated 2 Sept 20263 min read
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#

retry placed approved processing shipped delivered delivered_failed returned cancelled
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).
  • returned on 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 are SettableOrderStatus in the API reference.

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).

Shell
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
fulfillment-request.jsonJSON
{
  "provider": "aramex",
  "tracking_number": null,
  "status": "pending",
  "lines": [
    {
      "order_item_id": "a1b2c3d4-0001-4e8f-9b70-2d1c3e4f5a6b",
      "quantity": 1
    }
  ]
}
fulfillment-response.jsonJSON
{
  "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#

Shell
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#

Shell
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"}'
fulfillment-delivered-response.jsonJSON
{
  "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 delivered automatically (order_status: "delivered"). If the transition matrix refuses because of the order's current state, the fulfillment stays delivered and order_status_skipped: true.
  • cod_collected_minor is 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.jsonJSON
{
  "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.