# Build a COD confirmation tool

Find the order by phone, confirm or cancel it, schedule the call-back.

Most orders in Bangladesh are cash on delivery, and most merchants call the
customer before shipping to confirm. This guide builds that loop over the API:
pull the queue, find an order by the number that is calling, check the
customer's history, and record what they said.

**You need** a key with `orders:write` (which implies `orders:read`) and
`customers:read`. The customer history in step 3 needs `orders:read` as well,
which `orders:write` already covers.

## 1. Pull the queue

Two lists make up a call agent's day: new orders nobody has confirmed yet, and
call-backs that are now due.

New orders are `status=pending`. Due call-backs are orders in `follow_up`
whose follow-up time has passed — `status=follow_up` with the current time as
`follow_up_before`. That is the same list the dashboard's overdue follow-up
count and the MCP `follow_up_due` queue use:

**cURL**

```bash
export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY"

curl -sS "https://api.shopdeskbd.com/ext/v1/orders?status=follow_up&follow_up_before=2026-10-02T12%3A00%3A00.000Z&expand=customer" \
  -H "Authorization: Bearer $SHOPDESK_API_KEY"
```

**JavaScript**

```javascript
const url = new URL("https://api.shopdeskbd.com/ext/v1/orders");
Object.entries({
  status: "follow_up",
  follow_up_before: "2026-10-02T12:00:00.000Z",
  expand: "customer",
}).forEach(([key, value]) => url.searchParams.set(key, value));

const response = await fetch(url, {
  headers: {
    Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`,
  },
});

if (!response.ok) {
  const { error } = await response.json();
  throw new Error(`${error.code}: ${error.message} [${error.request_id}]`);
}

const { data, meta } = await response.json();
```

**Python**

```python
import os

import requests

response = requests.get(
    "https://api.shopdeskbd.com/ext/v1/orders",
    params={
        "status": "follow_up",
        "follow_up_before": "2026-10-02T12:00:00.000Z",
        "expand": "customer",
    },
    headers={
        "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}",
    },
    timeout=30,
)

if not response.ok:
    error = response.json()["error"]
    raise RuntimeError(f"{error['code']}: {error['message']}")

page = response.json()
rows, meta = page["data"], page["meta"]
```

**PHP**

```php
<?php

$key = getenv('SHOPDESK_API_KEY');

$ch = curl_init('https://api.shopdeskbd.com/ext/v1/orders?status=follow_up&follow_up_before=2026-10-02T12%3A00%3A00.000Z&expand=customer');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key],
    CURLOPT_TIMEOUT => 30,
]);

$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$response = json_decode($body, true);

if ($status >= 400) {
    throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}");
}

$rows = $response['data'];
$meta = $response['meta'];
```

`follow_up_before` and `follow_up_after` only ever match orders that have a
follow-up scheduled, so combine them for a window ("today") or use one alone
for "overdue". Keep `status=follow_up` in the query: an order that has since
been confirmed or cancelled keeps its old `follow_up_at`, and the status is
what takes it off the list. Every order carries its `follow_up_at`, `follow_up_note` and
`assignee` (`{ id, name }`, or `null`), and `assignee_id` narrows the list to
one agent's orders. Assigning orders is done in the dashboard; the API reads
the assignment.

## 2. Find the order by phone

When a customer calls in, all you have is their number, in whatever form your
phone system shows it. `search` matches order numbers, customer names and
customer phones, and a phone matches in **any** Bangladeshi form:
`+8801712345678`, `8801712345678`, `01712345678` and `+880 1712-345678` all
find the same orders, because phones are stored as `01XXXXXXXXX` and the search
also tries the term rewritten that way. Order numbers and names are matched as
typed, so `20260929-0001` still finds `ORD-20260929-0001`.

**cURL**

```bash
export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY"

curl -sS "https://api.shopdeskbd.com/ext/v1/orders?search=%2B8801712345678&status=pending&expand=customer" \
  -H "Authorization: Bearer $SHOPDESK_API_KEY"
```

**JavaScript**

```javascript
const url = new URL("https://api.shopdeskbd.com/ext/v1/orders");
Object.entries({
  search: "+8801712345678",
  status: "pending",
  expand: "customer",
}).forEach(([key, value]) => url.searchParams.set(key, value));

const response = await fetch(url, {
  headers: {
    Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`,
  },
});

if (!response.ok) {
  const { error } = await response.json();
  throw new Error(`${error.code}: ${error.message} [${error.request_id}]`);
}

const { data, meta } = await response.json();
```

**Python**

```python
import os

import requests

response = requests.get(
    "https://api.shopdeskbd.com/ext/v1/orders",
    params={
        "search": "+8801712345678",
        "status": "pending",
        "expand": "customer",
    },
    headers={
        "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}",
    },
    timeout=30,
)

if not response.ok:
    error = response.json()["error"]
    raise RuntimeError(f"{error['code']}: {error['message']}")

page = response.json()
rows, meta = page["data"], page["meta"]
```

**PHP**

```php
<?php

$key = getenv('SHOPDESK_API_KEY');

$ch = curl_init('https://api.shopdeskbd.com/ext/v1/orders?search=%2B8801712345678&status=pending&expand=customer');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key],
    CURLOPT_TIMEOUT => 30,
]);

$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$response = json_decode($body, true);

if ($status >= 400) {
    throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}");
}

$rows = $response['data'];
$meta = $response['meta'];
```

The same rule applies to `GET /customers?search=`, which also matches name and
email. To look up exactly one number, use `GET /customers?phone=` instead: it
matches the phone only, in any Bangladeshi form, so an email such as
`01712345678@gmail.com` never gets in the way.

## 3. Check the customer's history

Before you confirm a ৳5,000 COD order, it helps to know whether this customer
usually keeps what they order. Add `expand=stats` to the customer read:

**cURL**

```bash
export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY"

curl -sS "https://api.shopdeskbd.com/ext/v1/customers/REPLACE_WITH_CUSTOMER_ID?expand=stats" \
  -H "Authorization: Bearer $SHOPDESK_API_KEY"
```

**JavaScript**

```javascript
const url = new URL("https://api.shopdeskbd.com/ext/v1/customers/REPLACE_WITH_CUSTOMER_ID");
Object.entries({
  expand: "stats",
}).forEach(([key, value]) => url.searchParams.set(key, value));

const response = await fetch(url, {
  headers: {
    Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`,
  },
});

if (!response.ok) {
  const { error } = await response.json();
  throw new Error(`${error.code}: ${error.message} [${error.request_id}]`);
}

const object = await response.json();
```

**Python**

```python
import os

import requests

response = requests.get(
    "https://api.shopdeskbd.com/ext/v1/customers/REPLACE_WITH_CUSTOMER_ID",
    params={
        "expand": "stats",
    },
    headers={
        "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}",
    },
    timeout=30,
)

if not response.ok:
    error = response.json()["error"]
    raise RuntimeError(f"{error['code']}: {error['message']}")

obj = response.json()
```

**PHP**

```php
<?php

$key = getenv('SHOPDESK_API_KEY');

$ch = curl_init('https://api.shopdeskbd.com/ext/v1/customers/REPLACE_WITH_CUSTOMER_ID?expand=stats');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key],
    CURLOPT_TIMEOUT => 30,
]);

$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$response = json_decode($body, true);

if ($status >= 400) {
    throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}");
}
```

| Field            | What it counts                                                                                   |
| ---------------- | ------------------------------------------------------------------------------------------------ |
| `orders_count`   | Orders that are not cancelled, returned or spam — the count the dashboard's customer page shows. |
| `total_spent`    | The total of their **delivered** orders, delivery and tax included.                              |
| `last_order_at`  | When their latest order was placed (spam excluded).                                              |
| `returned_count` | Orders that came back whole.                                                                     |

> **Note — stats needs orders:read too**
>
> The stats summarise orders, so a key with `customers:read` alone gets the
> customer **without** `stats` — never a `403`. That keeps a CRM key that
> cannot read orders working unchanged.

## 4. Record the outcome

**The customer confirmed.** Move the order to `confirmed` with the status
route. The same transition rules as the dashboard apply:

**cURL**

```bash
export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY"

curl -sS -X PATCH "https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ORDER_ID/status" \
  -H "Authorization: Bearer $SHOPDESK_API_KEY" \
  -H "Idempotency-Key: REPLACE_WITH_A_UUID" \
  -H "Content-Type: application/json" \
  -d '{
  "status": "confirmed",
  "note": "Confirmed on the phone."
}'
```

**JavaScript**

```javascript
const url = new URL("https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ORDER_ID/status");

const response = await fetch(url, {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`,
    "Idempotency-Key": "REPLACE_WITH_A_UUID",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "status": "confirmed",
    "note": "Confirmed on the phone."
  }),
});

if (!response.ok) {
  const { error } = await response.json();
  throw new Error(`${error.code}: ${error.message} [${error.request_id}]`);
}

const object = await response.json();
```

**Python**

```python
import os

import requests

payload = {
    "status": "confirmed",
    "note": "Confirmed on the phone.",
}

response = requests.patch(
    "https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ORDER_ID/status",
    json=payload,
    headers={
        "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}",
        "Idempotency-Key": "REPLACE_WITH_A_UUID",
    },
    timeout=30,
)

if not response.ok:
    error = response.json()["error"]
    raise RuntimeError(f"{error['code']}: {error['message']}")

obj = response.json()
```

**PHP**

```php
<?php

$key = getenv('SHOPDESK_API_KEY');

$payload = json_encode([
    'status' => 'confirmed',
    'note' => 'Confirmed on the phone.',
]);

$ch = curl_init('https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ORDER_ID/status');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST => 'PATCH',
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: REPLACE_WITH_A_UUID', 'Content-Type: application/json'],
    CURLOPT_POSTFIELDS => $payload,
    CURLOPT_TIMEOUT => 30,
]);

$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$response = json_decode($body, true);

if ($status >= 400) {
    throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}");
}
```

**The customer cancelled.** Use `POST /orders/{id}/cancel`, which releases the
reserved stock.

**No answer, or "call me after 4pm".** Move the order to `follow_up` with the
status route (as for a confirmation, with `"status": "follow_up"`), then set
the call-back time with `PATCH /orders/{id}`:

**cURL**

```bash
export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY"

curl -sS -X PATCH "https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ORDER_ID" \
  -H "Authorization: Bearer $SHOPDESK_API_KEY" \
  -H "Idempotency-Key: REPLACE_WITH_A_UUID" \
  -H "Content-Type: application/json" \
  -d '{
  "follow_up_at": "2026-10-02T10:00:00.000Z",
  "follow_up_note": "No answer at 11am — try after 4pm."
}'
```

**JavaScript**

```javascript
const url = new URL("https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ORDER_ID");

const response = await fetch(url, {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.SHOPDESK_API_KEY}`,
    "Idempotency-Key": "REPLACE_WITH_A_UUID",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "follow_up_at": "2026-10-02T10:00:00.000Z",
    "follow_up_note": "No answer at 11am — try after 4pm."
  }),
});

if (!response.ok) {
  const { error } = await response.json();
  throw new Error(`${error.code}: ${error.message} [${error.request_id}]`);
}

const object = await response.json();
```

**Python**

```python
import os

import requests

payload = {
    "follow_up_at": "2026-10-02T10:00:00.000Z",
    "follow_up_note": "No answer at 11am — try after 4pm.",
}

response = requests.patch(
    "https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ORDER_ID",
    json=payload,
    headers={
        "Authorization": f"Bearer {os.environ['SHOPDESK_API_KEY']}",
        "Idempotency-Key": "REPLACE_WITH_A_UUID",
    },
    timeout=30,
)

if not response.ok:
    error = response.json()["error"]
    raise RuntimeError(f"{error['code']}: {error['message']}")

obj = response.json()
```

**PHP**

```php
<?php

$key = getenv('SHOPDESK_API_KEY');

$payload = json_encode([
    'follow_up_at' => '2026-10-02T10:00:00.000Z',
    'follow_up_note' => 'No answer at 11am — try after 4pm.',
]);

$ch = curl_init('https://api.shopdeskbd.com/ext/v1/orders/REPLACE_WITH_ORDER_ID');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST => 'PATCH',
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key, 'Idempotency-Key: REPLACE_WITH_A_UUID', 'Content-Type: application/json'],
    CURLOPT_POSTFIELDS => $payload,
    CURLOPT_TIMEOUT => 30,
]);

$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

$response = json_decode($body, true);

if ($status >= 400) {
    throw new RuntimeException("{$response['error']['code']}: {$response['error']['message']}");
}
```

`PATCH /orders/{id}` changes the order `note`, `follow_up_at` and
`follow_up_note`, and nothing else. It works on an order in any status, runs
the same code as the dashboard's order-details edit, and cannot touch prices,
lines or stock. Send `null` to clear a field and leave out a field to keep it.
Send at least one field.

Both `pending` and `confirmed` orders can move to `follow_up`. Once the order
is confirmed or cancelled, its status takes it off the call list; the old
`follow_up_at` stays on the order, as it does in the dashboard. Send
`{"follow_up_at": null, "follow_up_note": null}` if you want it cleared too.

> **Note — Send an Idempotency-Key on every outcome**
>
> Both routes accept an `Idempotency-Key`. A call agent's double-click, or a
> retry after a timeout, then records the outcome once. See
> [Writes & idempotency](https://www.shopdeskbd.com/developers/idempotency.md).

## 5. Stay in sync

If other staff work the same orders in the dashboard, subscribe to
`order.updated`. It fires when the status moves (`data.previous.status`) and
when the note, follow-up or delivery address changes. On those changes
`data.previous` holds the old `note`, `follow_up_at`, `follow_up_note` or
`shipping_address`, named as in `data.object`. See the
[event catalog](https://www.shopdeskbd.com/developers/webhook-events.md).

## With the store MCP server

The same loop runs from an AI assistant connected to the
[store MCP server](https://www.shopdeskbd.com/developers/ai.md#the-store-mcp-server) with a key holding
`orders:write` and `customers:read`. Each step above is one tool call:

| Step                    | Tool                                                                                                                                                                   |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Pull the queue          | `list_orders_needing_attention` with `kind: "unconfirmed"` (pending orders) or `"follow_up_due"` (orders in `follow_up` whose call-back time has passed), oldest first |
| Find the order by phone | `find_orders` with the number in any form, an order number or a name                                                                                                   |
| Check the history       | `get_customer_summary` with the `phone` or `customer_id`: the stats and the latest five orders                                                                         |
| Record the outcome      | `record_call_outcome`                                                                                                                                                  |

`record_call_outcome` takes the order id, an `outcome` and an optional `note`:

| `outcome`   | What it does                                                                                                 |
| ----------- | ------------------------------------------------------------------------------------------------------------ |
| `confirmed` | Moves the order to `confirmed`.                                                                              |
| `cancelled` | Cancels it through the cancel route; reserved stock is released.                                             |
| `call_back` | Needs `call_back_at`. Moves the order to `follow_up` and sets the call-back time, with `note` as the reason. |
| `no_answer` | Moves the order to `follow_up`; sets the call-back time if you send `call_back_at`.                          |

The note also goes on the order timeline with the status change. The status
rules are the dashboard's: an order that cannot move to the new status is
refused before anything changes. Repeating the same outcome changes nothing
more, and an `idempotency_key` makes a retried call return the first answer.
The result lists the `steps` taken and the order as it now stands.

`update_order_details` edits the note and call-back directly, like
`PATCH /orders/{id}`. The order note it sets replaces the whole note, and
notes these tools show are shortened to 500 characters — so an assistant
should never rebuild a note from a value it read. Notes, addresses and names
come back in labelled fields with links and markup removed; see
[MCP tools](https://www.shopdeskbd.com/developers/mcp-tools.md).
