# Sales and reports

The sales summary, comparisons and top products, and what each figure counts.

"How were sales this week?" is the first thing a merchant asks an assistant,
and the first number a BI tool wants. This guide covers the two report
endpoints, what each figure counts, and which plan each needs.

**You need** a key with `reports:read`. It is flagged sensitive when you create
the key, because it reveals the store's sales.

| Endpoint                                             | Plan                       |
| ---------------------------------------------------- | -------------------------- |
| `GET /reports/sales-summary`                         | Every plan with API access |
| `GET /reports/sales-summary?compare=previous_period` | Advanced Reports           |
| `GET /reports/top-products`                          | Advanced Reports           |

A plan without Advanced Reports gets `403` with the code
`PLAN_FEATURE_REQUIRED` on the last two, and the summary still works. That
code means the plan, not the key, is missing something: a key without
`reports:read` gets `FORBIDDEN_SCOPE` instead. See
[Errors](https://www.shopdeskbd.com/developers/errors.md).

Each call to either endpoint counts as **5 reads** against the key's read
limit, because a report is several queries. See
[Rate limits](https://www.shopdeskbd.com/developers/rate-limits.md).

## Windows

Both endpoints take `from` and `to`: the first and last day of the window,
inclusive, as the **store's** calendar days (`2026-09-01`). With neither, the
window is the current month so far. A window spans at most **366 days**. The
store's timezone is echoed as `time_zone`, and `GET /store` returns it as
`timezone`.

Instead of `from` and `to`, you can name the window with `period`: `today`,
`yesterday`, `last_7_days` (today and the six days before), `last_30_days`,
`this_month` (the 1st to today) or `last_month`. It is read on the store's
calendar, so "yesterday" is the store's yesterday, and the response echoes the
concrete `from` and `to`. Sending `period` with `from` or `to` is a `422`.

A company-scoped key must name the store with `store_id`. A report answers for
one store.

Answers are cached for **5 minutes** per store and window, so polling faster
than that returns the same figures.

## The sales summary

**cURL**

```bash
export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY"

curl -sS "https://api.shopdeskbd.com/ext/v1/reports/sales-summary?from=2026-09-01&to=2026-09-30" \
  -H "Authorization: Bearer $SHOPDESK_API_KEY"
```

**JavaScript**

```javascript
const url = new URL("https://api.shopdeskbd.com/ext/v1/reports/sales-summary");
Object.entries({
  from: "2026-09-01",
  to: "2026-09-30",
}).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/reports/sales-summary",
    params={
        "from": "2026-09-01",
        "to": "2026-09-30",
    },
    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/reports/sales-summary?from=2026-09-01&to=2026-09-30');
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']}");
}
```

The figures are the same numbers the dashboard's Reports pages show for the
same window. They run on three different clocks, and `basis` says which:

| Field                 | Counts                                                                                          | `basis`                        |
| --------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------ |
| `orders`              | Orders **delivered** in the window                                                              | `sales: delivered`             |
| `gross_sales`         | Their line value after markdowns, before coupons and delivery                                   | `sales: delivered`             |
| `net_sales`           | What those customers were billed for goods and delivery, excluding tax                          | `sales: delivered`             |
| `net_revenue`         | `net_sales` less returned goods, refunds, whole-order returns and courier concessions           | `sales: delivered`             |
| `average_order_value` | `net_sales ÷ orders`                                                                            | `sales: delivered`             |
| `money_received`      | Payments **settled** in the window, whichever order they belong to                              | `money_received: settled`      |
| `cod_outstanding`     | What open orders still owe **as of today**: not shipped, with the courier, delivered but unpaid | `cod_outstanding: as_of_today` |

An order placed on the 30th and delivered on the 2nd counts in the next
month's sales, which is when the sale became final. Don't add
`money_received` to `net_sales`: they measure different things.

`money_received` leaves out payments on orders that are cancelled or returned
**now**. If an order paid in a past window is cancelled or returned later,
that window's `money_received` goes down, exactly as the dashboard's Payments
report does. Store a closed month's figure yourself if you need it frozen.

**By channel.** Pass `channel` (a slug from `GET /channels`) to count only
that channel's sales. `money_received` and `cod_outstanding` are then `null`,
because payments are not split by channel.

## Comparing with the previous period

**cURL**

```bash
export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY"

curl -sS "https://api.shopdeskbd.com/ext/v1/reports/sales-summary?from=2026-09-22&to=2026-09-28&compare=previous_period" \
  -H "Authorization: Bearer $SHOPDESK_API_KEY"
```

**JavaScript**

```javascript
const url = new URL("https://api.shopdeskbd.com/ext/v1/reports/sales-summary");
Object.entries({
  from: "2026-09-22",
  to: "2026-09-28",
  compare: "previous_period",
}).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/reports/sales-summary",
    params={
        "from": "2026-09-22",
        "to": "2026-09-28",
        "compare": "previous_period",
    },
    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/reports/sales-summary?from=2026-09-22&to=2026-09-28&compare=previous_period');
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']}");
}
```

`compare=previous_period` adds `previous_period`: the same figures for the
window of equal length that ends the day before `from`. A week compares with
the week before it. A 30-day month compares with the 30 days before it, not
with the previous calendar month.

## Top products

**cURL**

```bash
export SHOPDESK_API_KEY="shopdesk_sk_YOUR_API_KEY"

curl -sS "https://api.shopdeskbd.com/ext/v1/reports/top-products?from=2026-09-01&to=2026-09-30&limit=10" \
  -H "Authorization: Bearer $SHOPDESK_API_KEY"
```

**JavaScript**

```javascript
const url = new URL("https://api.shopdeskbd.com/ext/v1/reports/top-products");
Object.entries({
  from: "2026-09-01",
  to: "2026-09-30",
  limit: "10",
}).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/reports/top-products",
    params={
        "from": "2026-09-01",
        "to": "2026-09-30",
        "limit": "10",
    },
    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/reports/top-products?from=2026-09-01&to=2026-09-30&limit=10');
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']}");
}
```

Product variants ranked by the revenue of the units delivered in the window
that customers **kept**. Returned units are netted out and shown as
`units_returned`. `limit` is 1–50, default 10.

> **Note — Revenue and units only**
>
> The ranking never includes cost, profit or margin, at any scope. The API
> does not return cost data anywhere.

## With the store MCP server

An assistant connected to the [store MCP server](https://www.shopdeskbd.com/developers/ai.md#the-store-mcp-server)
answers "how were sales this week?" with one tool call:
`get_sales_summary` takes `period` (or `from`/`to`), `compare_to:
"previous_period"` and `channel`, and returns the same figures as
`GET /reports/sales-summary` with the same `basis`. It answers concisely by
default — the headline figures and the COD total — and with
`response_format: "detailed"` it returns the full REST body, stage split
included. The same plan rules apply: comparisons need Advanced Reports.

A call counts as 5 reads against the key's read limit, the same as the REST
endpoint. A comparison on a plan without Advanced Reports comes back as an
error with the code `PLAN_FEATURE_REQUIRED`. The ranking has no tool: a BI
sync reads `GET /reports/top-products` over REST.
