Open API
REST API แบบ อ่านอย่างเดียว ให้ดึงข้อมูล ของบัญชีคุณเอง (คลิป / บูธ / สถิติ / พนักงานแพค) ไปใช้ในระบบของคุณ — แยกจาก API ที่บูธใช้
- เปิด /swagger
- กดปุ่ม Authorize (รูปกุญแจ) → ใส่ API key (
ecbk_…) ในช่องX-Api-Key→ Authorize - เลือก endpoint → Try it out → ใส่พารามิเตอร์ → Execute
- เห็นคำสั่ง
curl, URL จริง และ JSON ที่ตอบกลับได้เลย
/swagger/open/swagger.json
1. การยืนยันตัวตน (API key)
- สร้าง API key ที่หน้า /me/api (แสดงคีย์ครั้งเดียว — เก็บไว้ให้ดี)
- ส่งคีย์มาทุกคำขอเป็น header
X-Api-Key: ecbk_… - คีย์นี้ อ่านได้อย่างเดียว และเห็นเฉพาะข้อมูลของบัญชีคุณ — ควบคุมบูธไม่ได้ (คนละตัวกับ token ของบูธ)
- มีได้หลายคีย์ (หมุน/เพิกถอนได้); ปิด/ลบคีย์แล้วระบบที่ใช้คีย์นั้นจะเข้าไม่ได้ทันที
2. ข้อกำหนดทั่วไป
| Base URL | /api/open/v1 |
|---|---|
| Method | GET เท่านั้น (read-only) |
| รูปแบบ | JSON (UTF-8), key เป็น camelCase |
| Response | ครอบด้วย status เสมอ — สำเร็จ: { "status": "ok", "data": … } · ผิดพลาด: { "status": "error", "error": { code, message, errorId } }. ดูความหมายที่ รหัสข้อผิดพลาด |
| วันเวลา | ISO 8601 + timezone offset เช่น 2026-06-11T09:15:30.482+07:00 |
| หน้า (paging) |
Offset: page (เริ่ม 1) + pageSize (สูงสุด 100)Cursor (แนะนำสำหรับ sync): ส่ง after=nextCursor ที่ได้จาก response ก่อนหน้า —
เสถียรกว่าเมื่อมีคลิปใหม่เข้ามาระหว่างดึง (ไม่ซ้ำ/ไม่ตกหล่น). ทุก response มี
nextCursor (เป็น null เมื่อหมดแล้ว)
|
| Rate limit | 120 คำขอ/นาที ต่อคีย์ (ค่าเริ่มต้น — ปรับต่อบัญชีได้โดยผู้ดูแลระบบ) — เกินแล้วได้ 429 พร้อม header Retry-After (วินาที) |
3. Endpoints
| Endpoint | คืน | Query params |
|---|---|---|
GET /booths |
บูธของคุณ + จำนวนคลิป | — |
GET /clips |
คลิป (แบ่งหน้า) | from, to (วันที่อัด), boothGuid, barcode, packedBy, status (uploaded/pending), page, pageSize, after (cursor) |
GET /clips/{videoId} |
คลิปรายตัว (404 ถ้าไม่ใช่ของคุณ) | — |
GET /stats |
สรุปยอด + แยกบูธ/พนักงาน | from, to, boothGuid |
GET /staff |
พนักงานแพคของคุณ | — |
4. ตัวอย่าง Response
GET /clips — คลิปอยู่ใน data.items
{
"status": "ok",
"data": {
"items": [
{
"videoId": 987,
"boothGuid": "f7364bed-40c7-42a7-a302-39e64cb8def1",
"boothNickname": "เคาน์เตอร์แพ็คหน้าร้าน",
"source": "https://static.e2app.co/987.mp4",
"barcode": "TH1234567890",
"weight": 1.23,
"sizeInMb": 42.7,
"packedByEmployeeCode": "A01",
"recordedAt": "2026-06-11T09:14:55.482+07:00",
"uploadedAt": "2026-06-11T09:15:30.917+07:00"
}
],
"page": 1,
"pageSize": 50,
"total": 128,
"nextCursor": "NjM4ODQ1MTQ0OTUwMDAwMDAwXzk4Nw"
}
}
GET /booths — array อยู่ใน data
{
"status": "ok",
"data": [
{
"guid": "f7364bed-40c7-42a7-a302-39e64cb8def1",
"nickname": "เคาน์เตอร์แพ็คหน้าร้าน",
"lastHeartBeat": "2026-06-11T09:20:00+07:00",
"clipCount": 128
}
]
}
GET /stats
{
"status": "ok",
"data": {
"from": null,
"to": null,
"totalClips": 128,
"totalWeight": 256.5,
"perBooth": [
{ "guid": "f7364bed-…", "nickname": "หน้าร้าน", "clips": 128, "weight": 256.5 }
],
"perStaff": [
{ "code": "A01", "name": "สมชาย", "clips": 80, "weight": 160.0 }
]
}
}
GET /staff — array อยู่ใน data
{
"status": "ok",
"data": [
{ "code": "A01", "name": "สมชาย", "isActive": true }
]
}
5. ตัวอย่างเรียกใช้ (curl)
# คลิปที่อัปโหลดแล้วในเดือนมิถุนายน หน้าที่ 1
curl -H "X-Api-Key: ecbk_xxxxxxxxxxxxxxxx" \
"https://YOUR_SERVER/api/open/v1/clips?from=2026-06-01&to=2026-07-01&status=uploaded&page=1&pageSize=50"
# สรุปสถิติของบูธหนึ่ง
curl -H "X-Api-Key: ecbk_xxxxxxxxxxxxxxxx" \
"https://YOUR_SERVER/api/open/v1/stats?boothGuid=f7364bed-40c7-42a7-a302-39e64cb8def1"
6. Error
เมื่อผิดพลาด status = "error" และรายละเอียดอยู่ใน error
— มี code (รหัสคงที่ให้โปรแกรมเช็ก), message (ข้อความสำหรับคน)
และ errorId (มีเฉพาะ error 500 ไว้แจ้งผู้ดูแลให้ตามรอยได้)
| HTTP | code | เมื่อไร |
|---|---|---|
401 | unauthorized | ไม่มี/คีย์ผิด/คีย์ถูกปิด (header X-Api-Key) |
404 | not_found | ไม่พบ หรือไม่ใช่ของบัญชีคุณ จึงเข้าถึงไม่ได้ |
400 | bad_cursor | ค่า cursor (after) ไม่ถูกต้อง |
429 | rate_limited | เรียกถี่เกินลิมิต (120/นาที/คีย์) — ดู header Retry-After |
500 | internal_error | ข้อผิดพลาดภายในระบบ — มี errorId มาด้วย |
{ "status": "error", "error": { "code": "unauthorized", "message": "API key required (X-Api-Key header).", "errorId": null } }
ดูคำอธิบายรหัสข้อผิดพลาด (error codes) แบบเต็ม + errorId คืออะไร →
7. ตัวอย่างโค้ด (C# · PHP · Python · Rust)
ตัวอย่างย่อ: ยืนยันตัวตนด้วย X-Api-Key, จัดการ rate-limit (429),
แล้วไล่ดึงคลิปทั้งหมดแบบ cursor (รูปแบบที่แนะนำสำหรับ sync).
โค้ดเต็มที่ รันได้จริง + วิธีรันแต่ละภาษา อยู่ในโฟลเดอร์
examples/open-api/ ของโปรเจกต์
using System.Net.Http.Json;
using System.Text.Json;
var http = new HttpClient { BaseAddress = new Uri("https://cambooth.e2app.co/api/open/v1/") };
http.DefaultRequestHeaders.Add("X-Api-Key", "ecbk_your_key_here");
var json = new JsonSerializerOptions(JsonSerializerDefaults.Web);
// GET, unwrap the { status, data } envelope, with 429 (Retry-After) back-off.
async Task<T> Get<T>(string path) {
while (true) {
var resp = await http.GetAsync(path);
if ((int)resp.StatusCode == 429) {
await Task.Delay(resp.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5));
continue;
}
var env = (await resp.Content.ReadFromJsonAsync<Env<T>>(json))!;
if (env.Status != "ok")
throw new Exception($"{env.Error?.Code}: {env.Error?.Message} (errorId={env.Error?.ErrorId})");
return env.Data;
}
}
// Page through every uploaded clip with the cursor (recommended for sync).
string? cursor = null;
do {
var path = "clips?status=uploaded&pageSize=100"
+ (cursor is null ? "" : $"&after={Uri.EscapeDataString(cursor)}");
var page = await Get<ClipPage>(path);
foreach (var c in page.Items) { /* use c.VideoId, c.Barcode, c.Source … */ }
cursor = page.NextCursor;
} while (cursor is not null);
record Env<T>(string Status, T Data, ApiError? Error);
record ApiError(string Code, string Message, string? ErrorId);
record ClipPage(List<Clip> Items, string? NextCursor);
record Clip(int VideoId, string? Barcode, string? Source, float Weight);import os, time, requests
BASE = "https://cambooth.e2app.co/api/open/v1"
s = requests.Session()
s.headers["X-Api-Key"] = os.environ.get("ECB_API_KEY", "ecbk_your_key_here")
def get(path, params=None):
while True:
r = s.get(f"{BASE}/{path}", params=params, timeout=30)
if r.status_code == 429: # rate limited
time.sleep(int(r.headers.get("Retry-After", "5")))
continue
env = r.json() # { "status": …, "data"/"error": … }
if env.get("status") != "ok":
e = env.get("error", {})
raise RuntimeError(f"{e.get('code')}: {e.get('message')} (errorId={e.get('errorId')})")
return env["data"] # unwrap the envelope
# Page through every uploaded clip with the cursor (recommended for sync).
cursor = None
while True:
params = {"status": "uploaded", "pageSize": 100}
if cursor:
params["after"] = cursor
page = get("clips", params)
for c in page["items"]:
pass # use c["videoId"], c["barcode"], c["source"], …
cursor = page.get("nextCursor")
if not cursor:
break<?php
const BASE = 'https://cambooth.e2app.co/api/open/v1';
$key = getenv('ECB_API_KEY') ?: 'ecbk_your_key_here';
// GET with automatic 429 (Retry-After) back-off.
function api_get(string $key, string $path, array $q = []): mixed {
$url = BASE . '/' . $path . ($q ? '?' . http_build_query($q) : '');
while (true) {
$ch = curl_init($url);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => true,
CURLOPT_HTTPHEADER => ['X-Api-Key: ' . $key]]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$hs = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
if ($code === 429) { // rate limited
preg_match('/^retry-after:\s*(\d+)/im', substr($resp, 0, $hs), $m);
sleep((int)($m[1] ?? 5));
continue;
}
$env = json_decode(substr($resp, $hs), true);
if (($env['status'] ?? '') !== 'ok')
throw new RuntimeException(($env['error']['code'] ?? 'error') . ': ' . ($env['error']['message'] ?? ''));
return $env['data']; // unwrap the envelope
}
}
// Page through every uploaded clip with the cursor (recommended for sync).
$cursor = null;
do {
$q = ['status' => 'uploaded', 'pageSize' => 100];
if ($cursor) $q['after'] = $cursor;
$page = api_get($key, 'clips', $q);
foreach ($page['items'] as $c) { /* use $c['videoId'], $c['barcode'] … */ }
$cursor = $page['nextCursor'] ?? null;
} while ($cursor);// Cargo.toml: reqwest = { version = "0.12", features = ["blocking", "json"] }
// serde = { version = "1", features = ["derive"] }
use serde::Deserialize;
use std::{thread, time::Duration};
const BASE: &str = "https://cambooth.e2app.co/api/open/v1";
#[derive(Deserialize)]
struct Env<T> { status: String, data: Option<T> } // (error fields omitted here)
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Clip { video_id: i64, barcode: Option<String>, source: Option<String> }
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ClipPage { items: Vec<Clip>, next_cursor: Option<String> }
// GET, unwrap the { status, data } envelope, with 429 (Retry-After) back-off.
fn get(c: &reqwest::blocking::Client, key: &str, path: &str, q: &[(&str, String)])
-> Result<ClipPage, Box<dyn std::error::Error>> {
loop {
let r = c.get(format!("{BASE}/{path}")).header("X-Api-Key", key).query(q).send()?;
if r.status().as_u16() == 429 {
let w = r.headers().get("retry-after").and_then(|v| v.to_str().ok())
.and_then(|s| s.parse().ok()).unwrap_or(5);
thread::sleep(Duration::from_secs(w));
continue;
}
let env: Env<ClipPage> = r.error_for_status()?.json()?;
return env.data.ok_or_else(|| format!("status={}", env.status).into());
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let key = std::env::var("ECB_API_KEY").unwrap_or("ecbk_your_key_here".into());
let c = reqwest::blocking::Client::new();
let mut cursor: Option<String> = None;
loop {
let mut q = vec![("status", "uploaded".into()), ("pageSize", "100".into())];
if let Some(cur) = &cursor { q.push(("after", cur.clone())); }
let page = get(&c, &key, "clips", &q)?;
// for clip in &page.items { /* use clip.video_id, clip.barcode … */ }
match page.next_cursor { Some(n) => cursor = Some(n), None => break }
}
Ok(())
}ติดปัญหาการใช้งาน หรืออยากสอบถาม?
ทักได้ที่ LINE @happym หรือโทร 085-926-9797 (คุณพล) ได้เลย