# Quickstart

> Make your first authenticated call and read one day of transactions.

import { Tabs, TabItem } from '@astrojs/starlight/components';

This page takes you from an empty terminal to a day of real sales data in about
five minutes: list the shops your key can see, pull their transactions, then
turn that into a repeating sync.

## Before you start

You need three things:

- **An API key** from ScanFood — see [step 1](#step-1--get-an-api-key).
- **Somewhere to run the calls from.** A server, a scheduled job or a
  backend service. Not a browser, and not a mobile app: cross-origin requests
  are refused, and a key that reaches a user's device has leaked.
- **A way to keep the key secret** — an environment variable, a secrets
  manager, anything but source control.

```bash
export SCANFOOD_API_KEY="sf_live_XXXXXXXXXXXX_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
```

## Step 1 — Get an API key

API keys are issued by the ScanFood team. Contact us with:

- **Which shops the key is for** — a single shop, or a whole franchise.
- **What you are building**, so we can set a sensible daily quota.
- **Whether you need member phone numbers or e-mail addresses.** These are off
  by default and are only enabled after a conversation about data protection.

You will receive one string that looks like this:

```
sf_live_XXXXXXXXXXXX_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
```

:::caution[The key is shown once]
ScanFood stores only a hash of your key, so nobody — including us — can read it
back to you later. If you lose it, the only remedy is to issue a new key and
revoke the old one. Put it in your secrets manager the moment you receive it.
:::

## Step 2 — Call the API

Start with `GET /v1/shops`. It takes no parameters at all: the key itself
decides which branches come back, so this call also tells you exactly what your
key can see.

<Tabs syncKey="lang">
<TabItem label="curl">

```bash
curl -s https://api.scanfood.co/ext/v1/shops \
  -H "Authorization: Bearer $SCANFOOD_API_KEY"
```

</TabItem>
<TabItem label="JavaScript">

```js
const BASE = 'https://api.scanfood.co/ext/v1';
const KEY = process.env.SCANFOOD_API_KEY;

const res = await fetch(`${BASE}/shops`, {
  headers: { Authorization: `Bearer ${KEY}` },
});
const body = await res.json();
console.log(body.data);
```

</TabItem>
<TabItem label="Python">

```python
import os
import requests

BASE = "https://api.scanfood.co/ext/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['SCANFOOD_API_KEY']}"}

r = requests.get(f"{BASE}/shops", headers=HEADERS, timeout=30)
r.raise_for_status()
print(r.json()["data"])
```

</TabItem>
</Tabs>

```json
{
  "ok": true,
  "data": [
    {
      "shopId": "aK3mP9xQ2bR7cT4dV6wY",
      "shopName": "Example Restaurant — Silom",
      "franchiseId": null,
      "providerId": "bL4nQ0yR3cS8dU5eW7xZ",
      "timezone": "Asia/Bangkok",
      "categories": [
        { "id": "9369601f-fef5-4a61-bfb8-26f25eab3dad", "name": "Food", "level": 1, "parentId": null, "hidden": false }
      ]
    }
  ],
  "serverTime": "2026-09-09T13:45:12.310Z"
}
```

Keep the `shopId` — every transaction call needs one. Keep the `providerId`
too: that is the identifier the member endpoints use.

## Step 3 — Read the response

Now pull the bills for a date range. `from` and `to` are calendar days in the
shop's own time zone, written as `YYYYMMDD` with no dashes, and both ends are
included.

<Tabs syncKey="lang">
<TabItem label="curl">

```bash
curl -s -G https://api.scanfood.co/ext/v1/transactions \
  -H "Authorization: Bearer $SCANFOOD_API_KEY" \
  --data-urlencode "shopId=aK3mP9xQ2bR7cT4dV6wY" \
  --data-urlencode "from=20260901" \
  --data-urlencode "to=20260901" \
  --data-urlencode "limit=200"
```

</TabItem>
<TabItem label="JavaScript">

```js
const params = new URLSearchParams({
  shopId: 'aK3mP9xQ2bR7cT4dV6wY',
  from: '20260901',
  to: '20260901',
  limit: '200',
});

const res = await fetch(`${BASE}/transactions?${params}`, {
  headers: { Authorization: `Bearer ${KEY}` },
});
const { data, nextCursor } = await res.json();
console.log(data.length, 'bills', nextCursor ? '(more to come)' : '(complete)');
```

</TabItem>
<TabItem label="Python">

```python
params = {
    "shopId": "aK3mP9xQ2bR7cT4dV6wY",
    "from": "20260901",
    "to": "20260901",
    "limit": 200,
}

r = requests.get(f"{BASE}/transactions", headers=HEADERS, params=params, timeout=30)
r.raise_for_status()
body = r.json()
print(len(body["data"]), "bills", "more" if body["nextCursor"] else "complete")
```

</TabItem>
</Tabs>

A shortened response — the full field list is in the
[transactions reference](/core/transactions/):

```json
{
  "ok": true,
  "data": [
    {
      "id": "dN6pS2aT5eU0fW7gY9zB",
      "receiptNumber": "RC-001",
      "taxNumber": null,
      "timestamp": "2026-09-01T13:30:00.000+07:00",
      "businessDate": "2026-09-01",
      "billDate": "2026-09-01",
      "updatedAt": "2026-09-01T13:30:00.000+07:00",
      "shopId": "aK3mP9xQ2bR7cT4dV6wY",
      "channel": { "code": "dine_in", "name": "Table 3" },
      "memberId": "m1_7W3mBnrKtP3ieg3Zhve_Cj4OvSWX7FYAYCfZ3GDHT54n7KuhPMylFlhZE71FJb-gnMnqFgL7RL7dsPATXUZNTVN_698LdRA",
      "items": [
        {
          "id": "iS1uX7fY0jZ5kB2lD4eG",
          "name": "Drunken noodles",
          "qty": 3,
          "unitPrice": 137.55,
          "lineTotal": 412.65,
          "cancelled": false
        }
      ],
      "amounts": {
        "subtotal": 412.65,
        "discountTotal": 20,
        "vat": 0,
        "vatType": "Include",
        "tip": 0,
        "net": 392.65
      },
      "payments": [{ "name": "Cash", "amount": 400 }],
      "status": "completed"
    }
  ],
  "nextCursor": null,
  "serverTime": "2026-09-01T06:30:00.000Z"
}
```

Four things to notice, because they catch almost everyone once:

| In the response | What it means |
| --- | --- |
| `nextCursor` | `null` means you have the whole result. Anything else is the `cursor` for the next page — see [Pagination & sync](/get-started/pagination-and-sync/). |
| `billDate` vs `businessDate` | `from`/`to` filter on `billDate`, the plain calendar day. `businessDate` is the day the shop counts the money in. They differ for shops that close after midnight — [details](/get-started/timezone-and-business-date/). |
| `amounts.net` | What the customer actually paid, **tip included**. Subtract `amounts.tip` before you call it shop revenue. |
| `status` | Voided bills are still returned, as `"void"`. Exclude them yourself if your report should not count them. |

## Step 4 — Poll on a schedule

Do not re-download the same days forever. Once you hold a first full copy,
switch to asking only for what changed:

- **Transactions** — call with `updatedSince` (an ISO-8601 timestamp) instead of
  `from`/`to`, and pass the `serverTime` of your previous successful run.
- **Members** — `updatedSince` is not available yet, so re-read the brand in
  full with the cursor. Once a day is plenty.

<Tabs syncKey="lang">
<TabItem label="curl">

```bash
curl -s -G https://api.scanfood.co/ext/v1/transactions \
  -H "Authorization: Bearer $SCANFOOD_API_KEY" \
  --data-urlencode "shopId=aK3mP9xQ2bR7cT4dV6wY" \
  --data-urlencode "updatedSince=2026-09-01T06:30:00.000Z" \
  --data-urlencode "limit=200"
```

</TabItem>
<TabItem label="JavaScript">

```js
async function pull(shopId, since) {
  let cursor = null;
  const rows = [];
  do {
    const params = new URLSearchParams({ shopId, updatedSince: since, limit: '200' });
    if (cursor) params.set('cursor', cursor);
    const res = await fetch(`${BASE}/transactions?${params}`, {
      headers: { Authorization: `Bearer ${KEY}` },
    });
    const body = await res.json();
    if (!body.ok) throw new Error(body.error);
    rows.push(...body.data);
    cursor = body.nextCursor;
  } while (cursor);
  return rows;
}
```

</TabItem>
<TabItem label="Python">

```python
def pull(shop_id, since):
    rows, cursor = [], None
    while True:
        params = {"shopId": shop_id, "updatedSince": since, "limit": 200}
        if cursor:
            params["cursor"] = cursor
        r = requests.get(f"{BASE}/transactions", headers=HEADERS, params=params, timeout=30)
        r.raise_for_status()
        body = r.json()
        rows.extend(body["data"])
        cursor = body["nextCursor"]
        if not cursor:
            return rows
```

</TabItem>
</Tabs>

:::caution[Backfill before you switch]
`updatedSince` only sees bills that carry a change stamp. Bills closed before
this API existed do not have one and will never appear in that mode. Take a
full copy with `from`/`to` first, then switch. Full recipe in
[Pagination & sync](/get-started/pagination-and-sync/).
:::

## Troubleshooting

| What you see | What it usually is | Fix |
| --- | --- | --- |
| `401` `invalid_key` | Missing or malformed `Authorization` header, or a key that is not a production key. The same answer is given for an unknown key and a wrong secret, on purpose. | Send `Authorization: Bearer sf_live_…`. The key goes in the header only — never in the query string or the body. |
| `401` `key_revoked` | The key was revoked. | Ask for a new one; revocation cannot be undone. |
| `403` `shop_not_in_scope` | The `shopId` is real but outside this key's reach. | Call `GET /v1/shops` and use one of the ids it returns. |
| `403` `provider_not_in_scope` | Same, for `providerId` on the member endpoints. | Take `providerId` from `GET /v1/shops`. |
| `400` `bad_query` — *choose exactly one mode* | You sent both `from`/`to` and `updatedSince`, or neither. | Pick one mode per request. |
| `400` `bad_query` — *from/to must be YYYYMMDD* | You sent `2026-09-01`. | Requests use `YYYYMMDD` with no dashes; only responses use dashes. |
| `400` `bad_query` — *range too wide* | More than 31 days between `from` and `to`. | Split the backfill into windows of 31 days or fewer. |
| `429` `rate_limited` | Too many requests in one minute. | Wait for the `Retry-After` header, then retry. |
| `429` `quota_exceeded` | The key's daily quota is used up; it resets at midnight Thailand time. | Poll less often, or ask for a higher quota. |
| `503` `auth_unavailable` | Our key check is temporarily unavailable. Your key is fine. | Retry with backoff; do not re-issue the key. |
| `500` `internal` | A transient failure on our side. | Retry with backoff; report it if it persists. |
| Nothing at all, from a web page | Browser calls are refused on purpose. | Call from your server. |

Every code we return, and what to do about it, is on the
[Errors](/get-started/errors/) page.
