เริ่มใช้งานใน 5 นาที
หน้านี้พาคุณจากเทอร์มินัลเปล่า ไปถึงข้อมูลยอดขายจริงหนึ่งวัน ภายในราวห้านาที เริ่มจากดูว่ากุญแจของคุณเห็นสาขาไหนบ้าง ดึงบิลของสาขานั้น แล้วแปลงเป็นรอบดึงข้อมูลประจำ
สิ่งที่ต้องเตรียม
หัวข้อที่มีชื่อว่า “สิ่งที่ต้องเตรียม”ต้องมีสามอย่าง
- กุญแจ API จาก ScanFood ดูขั้นที่ 1 ด้านล่าง
- ที่สำหรับรันคำขอ เซิร์ฟเวอร์ งานตั้งเวลา หรือ backend service ของคุณ ไม่ใช่เบราว์เซอร์ และไม่ใช่แอปมือถือ เพราะคำขอข้ามโดเมนถูกปฏิเสธ และกุญแจที่ไปอยู่บนเครื่องผู้ใช้คือกุญแจที่หลุดแล้ว
- ที่เก็บกุญแจให้เป็นความลับ ตัวแปรสภาพแวดล้อม ระบบเก็บความลับ อะไรก็ได้ที่ไม่ใช่โค้ดใน repo
export SCANFOOD_API_KEY="sf_live_XXXXXXXXXXXX_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"ขั้นที่ 1 — ขอกุญแจ API
หัวข้อที่มีชื่อว่า “ขั้นที่ 1 — ขอกุญแจ API”กุญแจ API ออกให้โดยทีมงาน ScanFood ติดต่อเราพร้อมข้อมูลสามข้อ
- กุญแจใบนี้ใช้กับร้านไหน ร้านเดียว หรือทั้งแฟรนไชส์
- คุณกำลังทำอะไร เพื่อให้เราตั้งโควตาต่อวันให้เหมาะสม
- ต้องใช้เบอร์โทรหรืออีเมลของสมาชิกไหม สองช่องนี้ปิดเป็นค่าตั้งต้น และจะเปิดให้หลังคุยเรื่องการคุ้มครองข้อมูลส่วนบุคคลแล้วเท่านั้น
คุณจะได้สตริงหนึ่งชุด หน้าตาแบบนี้
sf_live_XXXXXXXXXXXX_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXขั้นที่ 2 — เรียก API
หัวข้อที่มีชื่อว่า “ขั้นที่ 2 — เรียก API”เริ่มที่ GET /v1/shops เส้นนี้ไม่รับพารามิเตอร์เลยสักตัว เพราะตัวกุญแจเป็นคนกำหนดว่า
สาขาไหนจะถูกส่งกลับมา คำขอนี้จึงบอกคุณด้วยว่ากุญแจของคุณเห็นอะไรได้บ้าง
curl -s https://api.scanfood.co/ext/v1/shops \ -H "Authorization: Bearer $SCANFOOD_API_KEY"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);import osimport 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"]){ "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 — อ่านผลลัพธ์
หัวข้อที่มีชื่อว่า “ขั้นที่ 3 — อ่านผลลัพธ์”ต่อไปคือดึงบิลตามช่วงวัน from และ to คือวันตามปฏิทินในเขตเวลาของร้านเอง
เขียนเป็น YYYYMMDD ไม่มีขีด และนับรวมทั้งวันแรกและวันสุดท้าย
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"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 ? '(ยังมีต่อ)' : '(ครบแล้ว)');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 "ครบแล้ว")ตัวอย่างผลลัพธ์แบบย่อ ช่องข้อมูลครบทั้งหมดอยู่ที่ เอกสารรายการขาย
{ "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 ของหน้าถัดไป ดู การแบ่งหน้าและการซิงก์ |
billDate กับ businessDate |
from/to กรองที่ billDate ซึ่งเป็นวันตามปฏิทินธรรมดา ส่วน businessDate คือวันที่ร้านนับยอดนั้นเข้ารายได้ สองค่านี้ต่างกันในร้านที่ปิดหลังเที่ยงคืน อ่านต่อ |
amounts.net |
ยอดที่ลูกค้าจ่ายจริง รวมทิปแล้ว ถ้าจะเรียกว่ายอดขายของร้าน ต้องหัก amounts.tip ออกก่อน |
status |
บิลที่ถูกยกเลิกยังถูกส่งมาด้วย เป็น "void" ถ้ารายงานของคุณไม่ควรนับ ต้องคัดออกเอง |
ขั้นที่ 4 — ตั้งรอบดึงข้อมูล
หัวข้อที่มีชื่อว่า “ขั้นที่ 4 — ตั้งรอบดึงข้อมูล”อย่าดึงวันเดิมซ้ำไปตลอด เมื่อมีสำเนาชุดแรกครบแล้ว ให้เปลี่ยนไปถามเฉพาะสิ่งที่เปลี่ยน
- บิล เรียกด้วย
updatedSince(เวลาแบบ ISO-8601) แทนfrom/toโดยใช้ค่าserverTimeของรอบที่สำเร็จครั้งก่อน - สมาชิก ยังใช้
updatedSinceไม่ได้ ต้องอ่านทั้งแบรนด์ใหม่ด้วย cursor วันละครั้งก็เพียงพอ
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"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;}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แก้ปัญหาที่พบบ่อย
หัวข้อที่มีชื่อว่า “แก้ปัญหาที่พบบ่อย”| สิ่งที่เห็น | มักเป็นเพราะ | วิธีแก้ |
|---|---|---|
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 |
ความล้มเหลวชั่วคราวฝั่งเรา | ลองใหม่แบบถอยเวลาเพิ่มขึ้น ถ้าเป็นต่อเนื่องให้แจ้งเรา |
| ไม่ได้อะไรเลย เมื่อเรียกจากหน้าเว็บ | คำขอจากเบราว์เซอร์ถูกปฏิเสธโดยเจตนา | เรียกจากเซิร์ฟเวอร์ของคุณ |
รหัสข้อผิดพลาดทุกตัวและวิธีรับมือ อยู่ที่หน้า ข้อผิดพลาด