# เริ่มใช้งานใน 5 นาที

> ยิงคำขอแรกให้ผ่าน แล้วดึงรายการขายของหนึ่งวันออกมา

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

หน้านี้พาคุณจากเทอร์มินัลเปล่า ไปถึงข้อมูลยอดขายจริงหนึ่งวัน ภายในราวห้านาที
เริ่มจากดูว่ากุญแจของคุณเห็นสาขาไหนบ้าง ดึงบิลของสาขานั้น แล้วแปลงเป็นรอบดึงข้อมูลประจำ

## สิ่งที่ต้องเตรียม

ต้องมีสามอย่าง

- **กุญแจ API** จาก ScanFood ดูขั้นที่ 1 ด้านล่าง
- **ที่สำหรับรันคำขอ** เซิร์ฟเวอร์ งานตั้งเวลา หรือ backend service ของคุณ
  ไม่ใช่เบราว์เซอร์ และไม่ใช่แอปมือถือ เพราะคำขอข้ามโดเมนถูกปฏิเสธ
  และกุญแจที่ไปอยู่บนเครื่องผู้ใช้คือกุญแจที่หลุดแล้ว
- **ที่เก็บกุญแจให้เป็นความลับ** ตัวแปรสภาพแวดล้อม ระบบเก็บความลับ อะไรก็ได้ที่ไม่ใช่โค้ดใน repo

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

## ขั้นที่ 1 — ขอกุญแจ API

กุญแจ API ออกให้โดยทีมงาน ScanFood ติดต่อเราพร้อมข้อมูลสามข้อ

- **กุญแจใบนี้ใช้กับร้านไหน** ร้านเดียว หรือทั้งแฟรนไชส์
- **คุณกำลังทำอะไร** เพื่อให้เราตั้งโควตาต่อวันให้เหมาะสม
- **ต้องใช้เบอร์โทรหรืออีเมลของสมาชิกไหม** สองช่องนี้ปิดเป็นค่าตั้งต้น
  และจะเปิดให้หลังคุยเรื่องการคุ้มครองข้อมูลส่วนบุคคลแล้วเท่านั้น

คุณจะได้สตริงหนึ่งชุด หน้าตาแบบนี้

```
sf_live_XXXXXXXXXXXX_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
```

:::caution[กุญแจแสดงให้เห็นครั้งเดียว]
ScanFood เก็บเฉพาะค่าที่ผ่านการแฮชแล้ว จึงไม่มีใคร รวมทั้งเราเอง ที่เปิดกุญแจกลับมาให้คุณดูได้อีก
ถ้าทำหาย ทางเดียวคือออกใบใหม่แล้วยกเลิกใบเก่า เก็บเข้าระบบเก็บความลับทันทีที่ได้รับ
:::

## ขั้นที่ 2 — เรียก API

เริ่มที่ `GET /v1/shops` เส้นนี้ไม่รับพารามิเตอร์เลยสักตัว เพราะตัวกุญแจเป็นคนกำหนดว่า
สาขาไหนจะถูกส่งกลับมา คำขอนี้จึงบอกคุณด้วยว่ากุญแจของคุณเห็นอะไรได้บ้าง

<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": "ร้านตัวอย่าง สาขาสีลม",
      "franchiseId": null,
      "providerId": "bL4nQ0yR3cS8dU5eW7xZ",
      "timezone": "Asia/Bangkok",
      "categories": [
        { "id": "9369601f-fef5-4a61-bfb8-26f25eab3dad", "name": "อาหาร", "level": 1, "parentId": null, "hidden": false }
      ]
    }
  ],
  "serverTime": "2026-09-09T13:45:12.310Z"
}
```

เก็บ `shopId` ไว้ เพราะทุกคำขอฝั่งบิลต้องใช้ และเก็บ `providerId` ไว้ด้วย
เพราะนั่นคือรหัสที่เส้นฝั่งสมาชิกใช้

## ขั้นที่ 3 — อ่านผลลัพธ์

ต่อไปคือดึงบิลตามช่วงวัน `from` และ `to` คือวันตามปฏิทินในเขตเวลาของร้านเอง
เขียนเป็น `YYYYMMDD` ไม่มีขีด และนับรวมทั้งวันแรกและวันสุดท้าย

<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, 'บิล', nextCursor ? '(ยังมีต่อ)' : '(ครบแล้ว)');
```

</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"]), "บิล", "ยังมีต่อ" if body["nextCursor"] else "ครบแล้ว")
```

</TabItem>
</Tabs>

ตัวอย่างผลลัพธ์แบบย่อ ช่องข้อมูลครบทั้งหมดอยู่ที่
[เอกสารรายการขาย](/th/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": "โต๊ะ 3" },
      "memberId": "m1_7W3mBnrKtP3ieg3Zhve_Cj4OvSWX7FYAYCfZ3GDHT54n7KuhPMylFlhZE71FJb-gnMnqFgL7RL7dsPATXUZNTVN_698LdRA",
      "items": [
        {
          "id": "iS1uX7fY0jZ5kB2lD4eG",
          "name": "สปาเก็ตตี้ขี้เมา",
          "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": "เงินสด", "amount": 400 }],
      "status": "completed"
    }
  ],
  "nextCursor": null,
  "serverTime": "2026-09-01T06:30:00.000Z"
}
```

มีสี่จุดที่ควรสังเกต เพราะเกือบทุกคนสะดุดอย่างน้อยหนึ่งครั้ง

| ในผลลัพธ์ | หมายความว่า |
| --- | --- |
| `nextCursor` | เป็น `null` แปลว่าได้ครบแล้ว ถ้าไม่ใช่ `null` ให้ส่งค่านี้กลับไปเป็น `cursor` ของหน้าถัดไป ดู [การแบ่งหน้าและการซิงก์](/th/get-started/pagination-and-sync/) |
| `billDate` กับ `businessDate` | `from`/`to` กรองที่ `billDate` ซึ่งเป็นวันตามปฏิทินธรรมดา ส่วน `businessDate` คือวันที่ร้านนับยอดนั้นเข้ารายได้ สองค่านี้ต่างกันในร้านที่ปิดหลังเที่ยงคืน [อ่านต่อ](/th/get-started/timezone-and-business-date/) |
| `amounts.net` | ยอดที่ลูกค้าจ่ายจริง **รวมทิปแล้ว** ถ้าจะเรียกว่ายอดขายของร้าน ต้องหัก `amounts.tip` ออกก่อน |
| `status` | บิลที่ถูกยกเลิกยังถูกส่งมาด้วย เป็น `"void"` ถ้ารายงานของคุณไม่ควรนับ ต้องคัดออกเอง |

## ขั้นที่ 4 — ตั้งรอบดึงข้อมูล

อย่าดึงวันเดิมซ้ำไปตลอด เมื่อมีสำเนาชุดแรกครบแล้ว ให้เปลี่ยนไปถามเฉพาะสิ่งที่เปลี่ยน

- **บิล** เรียกด้วย `updatedSince` (เวลาแบบ ISO-8601) แทน `from`/`to`
  โดยใช้ค่า `serverTime` ของรอบที่สำเร็จครั้งก่อน
- **สมาชิก** ยังใช้ `updatedSince` ไม่ได้ ต้องอ่านทั้งแบรนด์ใหม่ด้วย cursor
  วันละครั้งก็เพียงพอ

<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[ดึงย้อนหลังให้ครบก่อนค่อยสลับโหมด]
`updatedSince` เห็นเฉพาะบิลที่มีตราประทับเวลาที่แก้ล่าสุด บิลที่ปิดไปก่อนจะมี API นี้
ไม่มีตราประทับนั้น และจะไม่โผล่ในโหมดนี้เลย ให้ดึงสำเนาชุดแรกด้วย `from`/`to` ให้ครบก่อน
แล้วค่อยสลับ วิธีเต็มอยู่ที่ [การแบ่งหน้าและการซิงก์](/th/get-started/pagination-and-sync/)
:::

## แก้ปัญหาที่พบบ่อย

| สิ่งที่เห็น | มักเป็นเพราะ | วิธีแก้ |
| --- | --- | --- |
| `401` `invalid_key` | ไม่มีส่วนหัว `Authorization` หรือรูปแบบผิด หรือไม่ใช่กุญแจของระบบจริง กุญแจที่ไม่มีอยู่กับรหัสลับผิด ตอบเหมือนกันโดยเจตนา | ส่ง `Authorization: Bearer sf_live_…` กุญแจอยู่ในส่วนหัวเท่านั้น ไม่ใส่ใน query string และไม่ใส่ใน body |
| `401` `key_revoked` | กุญแจถูกยกเลิกแล้ว | ขอกุญแจใบใหม่ การยกเลิกย้อนกลับไม่ได้ |
| `403` `shop_not_in_scope` | `shopId` มีอยู่จริง แต่อยู่นอกขอบเขตของกุญแจใบนี้ | เรียก `GET /v1/shops` แล้วใช้รหัสที่ได้จากที่นั่น |
| `403` `provider_not_in_scope` | แบบเดียวกัน แต่เป็น `providerId` ของเส้นฝั่งสมาชิก | เอา `providerId` มาจาก `GET /v1/shops` |
| `400` `bad_query` — *choose exactly one mode* | ส่งทั้ง `from`/`to` และ `updatedSince` หรือไม่ส่งเลยสักโหมด | เลือกโหมดเดียวต่อหนึ่งคำขอ |
| `400` `bad_query` — *from/to must be YYYYMMDD* | ส่ง `2026-09-01` มา | ฝั่งคำขอใช้ `YYYYMMDD` ไม่มีขีด ที่มีขีดคือฝั่งผลลัพธ์ |
| `400` `bad_query` — *range too wide* | ช่วง `from` ถึง `to` เกิน 31 วัน | ซอยการดึงย้อนหลังเป็นช่วงละไม่เกิน 31 วัน |
| `429` `rate_limited` | ยิงถี่เกินไปในหนึ่งนาที | รอตามส่วนหัว `Retry-After` แล้วลองใหม่ |
| `429` `quota_exceeded` | โควตาของวันหมดแล้ว รีเซ็ตเที่ยงคืนเวลาไทย | ลดความถี่ หรือขอเพิ่มโควตา |
| `503` `auth_unavailable` | ระบบตรวจกุญแจฝั่งเราไม่พร้อมชั่วคราว กุญแจของคุณไม่ได้มีปัญหา | ลองใหม่แบบถอยเวลาเพิ่มขึ้น อย่าออกกุญแจใหม่ |
| `500` `internal` | ความล้มเหลวชั่วคราวฝั่งเรา | ลองใหม่แบบถอยเวลาเพิ่มขึ้น ถ้าเป็นต่อเนื่องให้แจ้งเรา |
| ไม่ได้อะไรเลย เมื่อเรียกจากหน้าเว็บ | คำขอจากเบราว์เซอร์ถูกปฏิเสธโดยเจตนา | เรียกจากเซิร์ฟเวอร์ของคุณ |

รหัสข้อผิดพลาดทุกตัวและวิธีรับมือ อยู่ที่หน้า [ข้อผิดพลาด](/th/get-started/errors/)
