เช็กลิสต์ก่อนขึ้นจริง
ไล่ทีละข้อ ทุกข้อในนี้คือของที่ถ้าตอบผิดวันนี้ยังแก้ได้ถูก ๆ แต่ถ้าไปเจอตอนร้านกำลังขายจะแพงมาก
ข้อไหนยังไม่ได้ลองจริง อย่าเพิ่งติ๊ก
ก่อนสลับ
หัวข้อที่มีชื่อว่า “ก่อนสลับ”- กุญแจอยู่ใน secret manager หรือ environment variable ที่ฉีดตอนรัน — ไม่ใช่ ในโค้ดที่ commit ไม่ใช่ในไฟล์ config ที่ขึ้น repo ไม่ใช่ในเอกสารที่แชร์กัน
- เรียก API จากเซิร์ฟเวอร์ของคุณเท่านั้น · เราปิดการเรียกข้ามโดเมนไว้โดยเจตนา ⇒ เบราว์เซอร์หรือแอปมือถือเรียกตรงไม่ได้ และไม่ควรถือกุญแจที่อ่านยอดขายของร้านอยู่แล้ว
- ไม่เอากุญแจไปใส่ใน URL, query string หรือ body · เราอ่านจาก
Authorization: Bearer <key>ที่เดียวเท่านั้น - ระบบแต่ละตัวมีกุญแจของตัวเองพร้อมป้ายชื่อ ⇒ เพิกถอนใบหนึ่งได้โดยไม่ทำให้ตัวอื่นหยุด
- ขอบเขตของกุญแจแคบที่สุดเท่าที่ทำงานได้ — ต่อสาขาเดียวก็ใช้กุญแจร้านเดียว · ใช้กุญแจแฟรนไชส์เฉพาะตอนที่ต้องการทุกสาขาจริง ๆ
- ยิงไปที่
https://api.scanfood.co/ext/v1/…เท่านั้น · ไม่มีโฮสต์อื่นที่รองรับ และโฮสต์ที่เคยได้ไปตอนพัฒนาไม่ใช่ตัวสำรอง - มีคนอื่นนอกจากคนเขียนโค้ดที่รู้ว่ากุญแจเก็บไว้ที่ไหน และต้องติดต่อใครเวลาจะขอเปลี่ยน
ตรวจความถูกต้อง
หัวข้อที่มีชื่อว่า “ตรวจความถูกต้อง”- วันที่ขาออกไม่มีขีด ขากลับมีขีด — คำขอใช้
from=20260901ส่วนคำตอบคืนbillDate: "2026-09-01"· อย่าเอารูปหนึ่งไปป้อนอีกรูปหนึ่ง - ทุกอย่างเป็นเวลาไทย (ICT) —
from/toเป็นวันปฏิทินของโซนร้าน ·businessDate/billDateเป็นวันบนผนังนาฬิกาของร้าน · โควตาวันรีเซ็ต 00:00 ICT · มีค่าเดียวที่เป็น UTC คือserverTimeซึ่งลงท้ายด้วยZ - กระทบยอดด้วย
businessDateไม่ใช่billDate— ร้านที่ปิดยอดหลังเที่ยงคืน บิลตี 2 สังกัดวันขายก่อนหน้า · การกรองเกิดบนbillDateแต่การกระทบยอดใช้businessDateซึ่งทุกแถวส่งมาให้ด้วยเหตุผลนี้พอดี - ซอยช่วงวันไม่เกิน 31 วัน — กว้างกว่านั้นถูกปัดด้วย
400 bad_queryไม่ได้ตัดให้เงียบ ๆ - ไล่หน้าจน
nextCursorเป็นnullโดยส่งnextCursorของรอบก่อนกลับมาเป็นcursor· ถ้าหน้าสุดท้ายเต็มพอดี คุณจะได้ cursor ที่หน้าถัดไปว่าง — เป็นเรื่องปกติ ไม่ใช่บั๊ก - จัดการ cursor ที่ตายแล้ว — ถ้าแถวที่ cursor ชี้หายไป จะได้
400 bad_cursor⇒ ให้เริ่มหน้านั้นใหม่โดยไม่ส่ง cursor แทนที่จะล้มทั้งรอบ - บิล: ดึงย้อนหลังด้วย
from/toให้ครบก่อน แล้วค่อยสลับไปupdatedSince— บิลที่ปิดก่อนระบบติดตามการเปลี่ยนแปลงจะไม่มีupdatedAtและไม่มีวันโผล่ในโหมดนั้น · ตั้งโหมดนี้บนประวัติที่ว่างเปล่า = บิลหายถาวร - สมาชิก: full sync เท่านั้น —
updatedSinceบน/v1/membersตอบ400 not_supportedโดยเจตนา ⇒ ไล่หน้าด้วยcursorแล้วไปเทียบเอาฝั่งคุณ - ดึงซ้ำแล้วผลไม่เพี้ยน — ทุกเส้นเป็น
GETยิงซ้ำช่วงเดิมได้ปลอดภัย ตราบใดที่ฝั่งคุณ upsert ด้วยid(บิล) หรือmemberId(สมาชิก) ไม่ใช่ insert ซ้ำ - แตกสาขาที่
errorไม่ใช่ที่message—errorคือสัญญา ส่วนถ้อยคำในmessageเปลี่ยนได้โดยไม่แจ้ง - รองรับบิลที่ถูกยกเลิก —
statusมีcompletedกับvoid· บิลที่ยกเลิกยังอยู่และมีvoidReasonเพิ่มมา · ไม่มีก้อนข้อมูลคืนเงินแยก เพราะการคืนเงินหน้าร้านคือการยกเลิกบิล - อ่านส่วนลดที่ระดับบิล —
amounts.discount,campaign,coupon,discountTotalเป็นของทั้งบิล · รายการสินค้าไม่มีช่องส่วนลดต่อบรรทัด - เจอช่องที่ไม่รู้จักให้ข้าม อย่าตีตก — ช่องใหม่โผล่มาในคำตอบได้ตลอด · parser ที่ throw ใส่ช่องแปลกจะพังในรุ่นที่ไม่ทำให้ใครพังเลย
ตรวจความเสถียร
หัวข้อที่มีชื่อว่า “ตรวจความเสถียร”- ลองใหม่แบบถอยเป็นทวีคูณ และเคารพ
Retry-After— หัวนี้โผล่กับ429 rate_limitedและบอกตรง ๆ ว่าให้รอกี่วินาที · นอน 1 วินาทีคงที่แล้วยิงรัวต่อ คือวิธีเปลี่ยนการรอ 1 นาทีให้กลายเป็น 1 ชั่วโมง - แยก
429 quota_exceededออกจาก429 rate_limited— โควตาวันไม่ฟื้นด้วยการรอไม่กี่วินาที มันรีเซ็ต 00:00 ICT ⇒ หยุดรอบ แจ้งคน แล้วค่อยไปต่อพรุ่งนี้ หรือขอเพิ่มโควตา -
503 auth_unavailableให้ลองใหม่ ไม่ใช่ไปไล่ดูกุญแจ — แปลว่าตัวตรวจกุญแจฝั่งเราไม่พร้อมชั่วคราว กุญแจคุณไม่ได้มีปัญหา ⇒ ลองใหม่แบบถอย อย่าปลุกคนมาออกกุญแจใหม่ตอนตี 2 ฟรี ๆ -
500 internalลองใหม่ 2-3 ครั้งแล้วค่อยรายงาน — เป็นฝั่งเรา และมักหายเอง -
401ให้หยุดรอบแล้วปลุกคน — กุญแจที่ถูกเพิกถอนหรือพิมพ์ผิดไม่มีวันหายเองจากการลองใหม่ · ลูปลองใหม่ถี่ ๆ บน401มีแต่จะเผาโควตาวันทิ้ง - ทุกคำขอมี timeout และมีเพดานจำนวนครั้งที่ลองใหม่ · ลองใหม่ไม่จำกัด + มีเพดานอัตรา = คุณทำระบบตัวเองล่มเอง
- บันทึก
X-RateLimit-Remainingและตั้งเตือนตั้งแต่ยังเหลือที่ว่าง ไม่ใช่ตอนเหลือศูนย์ - รู้ว่าคำขอที่ล้มก็ถูกนับ — มิเตอร์ทั้ง 2 ตัวนับคำขอที่รับเข้ามา ไม่ใช่คำขอที่สำเร็จ ⇒ พายุลองใหม่กินโควตาเท่างานจริง
- อย่าพึ่งพาว่าเพดานต่อนาทีจะเป๊ะ — มันนับแยกตามเครื่องที่กำลังทำงาน และถ้ากลไกของมันเองมีปัญหาจะปล่อยผ่าน ⇒ ปล่อยทะลุได้เล็กน้อยกว่าตัวเลขที่ประกาศ · ให้ถือว่าเป็นเบรก ไม่ใช่ตัวคุมจังหวะของคุณ
const BASE = 'https://api.scanfood.co/ext/v1';
async function apiGet(path, { attempts = 5 } = {}) { for (let attempt = 1; ; attempt++) { const res = await fetch(BASE + path, { headers: { Authorization: `Bearer ${process.env.SCANFOOD_API_KEY}` }, signal: AbortSignal.timeout(30_000), });
if (res.ok) return res.json(); const body = await res.json().catch(() => ({}));
// ไม่ลองใหม่: คำตอบไม่เปลี่ยนถ้าไม่มีคนมาแก้ if (res.status === 401 || res.status === 403 || res.status === 400) { throw new Error(`${res.status} ${body.error}: ${body.message}`); } // โควตาวันหมดจนถึงเที่ยงคืน ICT — หยุด อย่าวน if (body.error === 'quota_exceeded') { throw new Error('โควตาวันหมด · เริ่มใหม่ 00:00 ICT'); } if (attempt >= attempts) throw new Error(`ยอมแพ้หลัง ${attempts} ครั้ง: ${body.error}`);
const retryAfter = Number(res.headers.get('Retry-After')); const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 // เซิร์ฟเวอร์บอกมาแล้ว ทำตาม : Math.min(2 ** attempt * 500, 30_000); // ไม่บอก = ถอยเอง await new Promise((r) => setTimeout(r, waitMs + Math.random() * 250)); }}import os, random, time, requests
BASE = "https://api.scanfood.co/ext/v1"SESSION = requests.Session()SESSION.headers["Authorization"] = f"Bearer {os.environ['SCANFOOD_API_KEY']}"
NO_RETRY = {400, 401, 403}
def api_get(path, attempts=5): for attempt in range(1, attempts + 1): res = SESSION.get(BASE + path, timeout=30) if res.ok: return res.json()
body = res.json() if res.headers.get("content-type", "").startswith("application/json") else {} if res.status_code in NO_RETRY: raise RuntimeError(f"{res.status_code} {body.get('error')}: {body.get('message')}") if body.get("error") == "quota_exceeded": raise RuntimeError("โควตาวันหมด · เริ่มใหม่ 00:00 ICT") if attempt == attempts: raise RuntimeError(f"ยอมแพ้หลัง {attempts} ครั้ง: {body.get('error')}")
retry_after = res.headers.get("Retry-After") wait = int(retry_after) if retry_after and retry_after.isdigit() else min(2 ** attempt * 0.5, 30) time.sleep(wait + random.random() * 0.25)# ดูว่าคำตอบตอนโดนเบรกหน้าตาเป็นยังไง พร้อมหัวทั้งหมดcurl -s -D - -o /dev/null \ -H "Authorization: Bearer $SCANFOOD_API_KEY" \ "https://api.scanfood.co/ext/v1/shops"
# HTTP/2 429# retry-after: 37# {"ok":false,"error":"rate_limited","message":"Too many requests, retry in about 37 seconds","retryAfterSec":37,"limit":60,"windowSec":60}ตรวจงานปฏิบัติการ
หัวข้อที่มีชื่อว่า “ตรวจงานปฏิบัติการ”- บันทึกทุกคำขอไว้ฝั่งคุณ — path, query, สถานะ HTTP, รหัส
errorถ้ามี, จำนวนแถวที่ได้ และเวลา · อีก 3 สัปดาห์ตอนของหาย นี่คือบันทึกชุดเดียวที่ผูกรอบดึงของคุณเข้ากับของเรา - เก็บ
idของบิล และmemberIdไว้กับทุกแถวที่นำเข้า — 2 ค่านี้คือมือจับที่เสถียรสำหรับ/v1/transactions/{id}กับ/v1/members/{memberToken}และเป็นทางเดียวที่จะถามเราถึงรายการใดรายการหนึ่งได้ - บอก
keyIdกับเวลาแบบ ICT ได้เวลาติดต่อเรา — สมุดการใช้งานของเราเรียงตามกุญแจ path และเวลา ·keyIdคือท่อนกลางของsf_live_<keyId>_<secret>และบอกเราได้อย่างปลอดภัย ส่วนท่อนความลับห้ามบอก - รอบที่ได้ 0 แถวต้องมีสัญญาณ — คำตอบว่างเป็นเรื่องถูกต้อง (สาขาปิด · คืนที่เงียบ) แต่รอบที่เงียบติดกัน 3 วันไม่ควรมารู้เอาตอนดูรายงานสิ้นเดือน
- มีคนได้รับแจ้งเมื่อรอบดึงหยุดเดิน ไม่ใช่เฉพาะตอนที่มันล้มแบบเสียงดัง
- ยืนยันแล้วว่าการเพิกถอนทำงานจริงตลอดเส้น — ขอให้เราเพิกถอนกุญแจที่ใช้ลอง แล้วดูระบบตัวเอง · ภายใน 5 นาทีมันต้องเริ่มเห็น
401 key_revokedและต้องแจ้งเตือน ไม่ใช่ลองใหม่ไปเรื่อย ๆ - รู้เพดานของกุญแจตัวเอง — ยืนยันตัวเลขต่อนาทีและต่อวันที่ออกให้กุญแจใบนั้น แล้ววางรอบดึงตามตัวเลขนั้น ไม่ใช่ตามค่าตั้งต้นในเว็บนี้
หลังสลับ
หัวข้อที่มีชื่อว่า “หลังสลับ”- ให้รอบแรกวิ่งตอนมีคนเฝ้า ไม่ใช่ปล่อยข้ามคืน
- กระทบยอด 1 วันขายเต็ม ๆ กับรายงานปิดวันของร้าน โดยจัดกลุ่มด้วย
businessDateก่อนจะเชื่อทั้งท่อ - ดู
X-RateLimit-Remainingตอนจบวันแรก — ตัวเลขนั้นบอกว่าคุณมีที่ว่างพอจะเพิ่มความถี่ทีหลังแค่ไหน - ขอให้เพิกถอนกุญแจที่ใช้ลอง เมื่อกุญแจของระบบจริงพิสูจน์ตัวเองแล้ว
- ตามอ่าน บันทึกการเปลี่ยนแปลง — ช่องใหม่ ๆ ลงที่นั่น และเป็นหน้าที่จะบอกคุณเมื่อของที่คุณพึ่งพากำลังจะเปลี่ยน