Open API

REST API แบบ อ่านอย่างเดียว ให้ดึงข้อมูล ของบัญชีคุณเอง (คลิป / บูธ / สถิติ / พนักงานแพค) ไปใช้ในระบบของคุณ — แยกจาก API ที่บูธใช้

ลองยิง API ได้ทันทีในเบราว์เซอร์ผ่าน Swagger UI
  1. เปิด /swagger
  2. กดปุ่ม Authorize (รูปกุญแจ) → ใส่ API key (ecbk_…) ในช่อง X-Api-Key → Authorize
  3. เลือก endpoint → Try it out → ใส่พารามิเตอร์ → Execute
  4. เห็นคำสั่ง curl, URL จริง และ JSON ที่ตอบกลับได้เลย
ไฟล์สเปก OpenAPI (เอาไป generate client ต่อได้): /swagger/open/swagger.json

1. การยืนยันตัวตน (API key)

  • สร้าง API key ที่หน้า /me/api (แสดงคีย์ครั้งเดียว — เก็บไว้ให้ดี)
  • ส่งคีย์มาทุกคำขอเป็น header X-Api-Key: ecbk_…
  • คีย์นี้ อ่านได้อย่างเดียว และเห็นเฉพาะข้อมูลของบัญชีคุณ — ควบคุมบูธไม่ได้ (คนละตัวกับ token ของบูธ)
  • มีได้หลายคีย์ (หมุน/เพิกถอนได้); ปิด/ลบคีย์แล้วระบบที่ใช้คีย์นั้นจะเข้าไม่ได้ทันที

2. ข้อกำหนดทั่วไป

Base URL/api/open/v1
MethodGET เท่านั้น (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 limit120 คำขอ/นาที ต่อคีย์ (ค่าเริ่มต้น — ปรับต่อบัญชีได้โดยผู้ดูแลระบบ) — เกินแล้วได้ 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 ไว้แจ้งผู้ดูแลให้ตามรอยได้)

HTTPcodeเมื่อไร
401unauthorizedไม่มี/คีย์ผิด/คีย์ถูกปิด (header X-Api-Key)
404not_foundไม่พบ หรือไม่ใช่ของบัญชีคุณ จึงเข้าถึงไม่ได้
400bad_cursorค่า cursor (after) ไม่ถูกต้อง
429rate_limitedเรียกถี่เกินลิมิต (120/นาที/คีย์) — ดู header Retry-After
500internal_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 (คุณพล) ได้เลย