# Linkable Order API — Full Reference for AI Agents > This document contains everything needed to integrate with the Linkable Order API. > It is generated from the OpenAPI spec and should be used instead of the raw JSON spec. --- ## Table of Contents 1. [Overview](#overview) 2. [Authentication](#authentication) 3. [Error Format](#error-format) 4. [Pagination](#pagination) 5. [Integration Flow](#integration-flow) 6. [Integration Guide](#integration-guide) 7. [API Endpoints](#api-endpoints) 8. [Webhooks Guide](#webhooks-guide) 9. [Webhook Events](#webhook-events) --- ## Overview Push orders to Shopify through the Linkable Order Queue. Authenticate with the Bearer token issued to your account. **Base URL:** https://api.eod-infinitee.com **Version:** 1.0.0 --- ## Authentication All API requests require a Bearer token in the Authorization header: ``` Authorization: Bearer ``` Tokens are issued by Linkable and have prefix `sqok_`. Contact support to get your token. **Important:** The client token (`sqok_*`) is different from the webhook signing secret (`whsec_*`). The signing secret is only for verifying webhook payloads, not for API authentication. --- ## Error Format All errors follow this structure: ```json { "error": "ERROR_CODE", "message": "Human-readable description", "details": { } } ``` Common error codes: - `VALIDATION_ERROR` (400): Invalid request body - `UNAUTHORIZED` (401): Missing or invalid token - `NOT_FOUND` (404): Resource does not exist - `DUPLICATE_REFERENCE_ID` (409): Order reference_id already exists - `POTENTIAL_DUPLICATE` (409): Similar order detected (use `force: true` to override) --- ## Pagination List endpoints use cursor-based pagination: - `limit`: Number of items per page (default 20, max 100) - `cursor`: Opaque cursor from previous response Response includes `next_cursor` which is `null` when no more pages. ``` GET https://api.eod-infinitee.com/v1/orders/pod?limit=20&cursor=abc123 ``` --- ## Integration Flow Typical POD integration follows this sequence: 1. **Get variant_id**: `GET /v1/stores/{store_id}/products` → find variant 2. **Check yarn_type**: If variant has `requires_yarn_type` array, pick one value 3. **Create POD order**: `POST /v1/orders/pod` with variant_id, sides, etc. 4. **Track status**: Poll `GET /v1/orders/pod/{order_id}` or receive webhooks 5. **Receive webhook**: System sends `order.status_updated`, `tracking.created`, etc. --- ## Integration Guide ### Download OpenAPI Spec ### Overview API tạo đơn POD hỗ trợ 2 loại đơn: - **Đơn thường** — Hệ thống tính phí ship - **Đơn có label** — Bạn cung cấp sẵn shipping label (TikTok, Etsy, v.v.) ### Authentication ```bash Authorization: Bearer ``` --- ### Ví dụ nhanh #### Đơn thường (không có label) ```json POST /v1/orders/pod { "reference_id": "MY-ORDER-001", "customer": { "email": "customer@example.com", "first_name": "John", "last_name": "Doe", "phone": "+14155551234" }, "shipping_address": { "address1": "123 Main St", "address2": "Apt 4B", "city": "Los Angeles", "province": "CA", "country_code": "US", "zip": "90001" }, "shipping_service": "eco", "line_items": [ { "variant_id": "3c371069-9ff6-4930-ab3a-3f130fe77afe", "quantity": 2, "sides": { "front_large": "https://cdn.example.com/design-front.png", "back": "https://cdn.example.com/design-back.png" }, "printing_technique": "DTG" } ], "note": "Please prioritize", "tags": "rush,vip" } ``` #### Đơn có label (TikTok/Etsy) ```json POST /v1/orders/pod { "reference_id": "TIKTOK-ORDER-001", "customer": { "email": "customer@example.com", "first_name": "Jane", "last_name": "Smith" }, "label_link": "https://labels.tiktok.com/your-label.pdf", "tracking_number": "9400111899223456789012", "shipping_service": "eco", "line_items": [ { "variant_id": "3c371069-9ff6-4930-ab3a-3f130fe77afe", "quantity": 1, "sides": { "front_large": "https://cdn.example.com/design.png" }, "printing_technique": "DTF" } ] } ``` --- ### Hai loại đơn > **Warning:** Bạn chỉ được chọn **1 trong 2** loại. Không được truyền cả `shipping_address` lẫn `label_link`. #### Loại 1: Đơn thường Truyền `shipping_address`. Hệ thống sẽ tính phí ship dựa trên `shipping_service` và `country_code`. | Field | Bắt buộc | Mô tả | |-------|:--------:|--------| | `shipping_address.address1` | Có | Địa chỉ dòng 1 | | `shipping_address.address2` | Không | Địa chỉ dòng 2 (optional) | | `shipping_address.city` | Có | Thành phố | | `shipping_address.province` | Có | Tỉnh/Bang (viết tắt: "CA", "TX"...) | | `shipping_address.country_code` | Có | Mã quốc gia 2 ký tự ("US", "VN"...) | | `shipping_address.zip` | Có | Mã bưu chính | #### Loại 2: Đơn có label Truyền `label_link` + `tracking_number`. **Không truyền** `shipping_address`. | Field | Bắt buộc | Mô tả | |-------|:--------:|--------| | `label_link` | Có | URL file shipping label (PDF) | | `tracking_number` | Có | Mã tracking từ nhà vận chuyển | > **Important:** `label_link` và `tracking_number` **phải đi đôi** — truyền 1 thiếu 1 sẽ bị reject. --- ### Tìm product / variant Mỗi line item cần một `variant_id`. Lấy nó từ API Products, có thể lọc bằng `q` + `search_type`: | `search_type` | Cách so khớp `q` | Ví dụ | |---------------|------------------|-------| | `name` (mặc định) | Một phần tên product, không phân biệt hoa thường | `?q=hoodie` | | `product_id` | Khớp chính xác `product_id` (UUID) | `?q=3f2a…&search_type=product_id` | | `sku` | **Một phần** SKU của variant, không phân biệt hoa thường, **tối thiểu 3 ký tự** | `?q=MAROON-L&search_type=sku` | ```json GET /v1/stores/{store_id}/products?q=HOODIE&search_type=sku // Trả về mọi product có ít nhất 1 variant mà SKU chứa "HOODIE" (rút gọn): { "data": [ { "product_id": "…", "title": "Embroidered Hoodie", "variants": [ { "variant_id": "abc-123", "title": "Maroon / L", "sku": "emb - HOODIE - MAROON-L" }, { "variant_id": "abc-124", "title": "Black / S", "sku": "emb - HOODIE - BLACK-S" } ] } ], "next_cursor": null } ``` - `HOODIE`, `hoodie`, `MAROON-L` hay `emb - HOODIE` đều khớp SKU `emb - HOODIE - MAROON-L`. - `q` có 1–2 ký tự với `search_type=sku` → **400** (`q: SKU search requires at least 3 characters`). - Kết quả là **product** (kèm toàn bộ variant của nó), không phải từng variant. Nếu bạn đang có SKU đầy đủ và cần đúng 1 `variant_id`, hãy lọc lại trong `variants` theo `sku` khớp chính xác. > **Note:** Trước đây `search_type=sku` so khớp **chính xác** cả chuỗi SKU. Nay là khớp một phần, nên một > SKU đầy đủ có thể trả về nhiều product hơn (ví dụ `TS-BLK-L` cũng khớp `TS-BLK-LG`). Code đang giả định > "kết quả đầu tiên là đúng" cần đổi sang lọc theo `sku` như trên. --- ### Sides (vị trí in/thêu) Mỗi line item **bắt buộc** có ít nhất 1 side. Truyền dạng object `{ "side_code": "url_ảnh" }`. #### Side codes hợp lệ | Code | Vị trí | |------|--------| | `front_side` | Mặt trước nhỏ | | `front_large` | Mặt trước lớn (full) | | `back` | Mặt sau | | `pocket_chest_left` | Túi ngực trái | | `pocket_chest_right` | Túi ngực phải | | `left_sleeve` | Tay áo trái | | `right_sleeve` | Tay áo phải | | `special_location` | Vị trí đặc biệt | | `mockup_url` | Ảnh mockup sản phẩm | > **Note:** URL phải là link ảnh hợp lệ (bắt đầu bằng `http://` hoặc `https://`). --- ### Printing (In ấn) Dùng cho đơn in trên vải. ```json { "sides": { "front_large": "https://..." }, "printing_technique": "DTG" } ``` | Giá trị | Mô tả | |---------|--------| | `DTG` | Direct-to-Garment — in trực tiếp lên vải | | `DTF` | Direct-to-Film — in lên film rồi ép nhiệt | --- ### Embroidery (Thêu) Dùng cho đơn thêu. Có 2 trường hợp: #### Trường hợp 1: Có file thêu sẵn Bạn đã có file thêu định dạng chuyên dụng (.dst, .pes, .jef...) và gửi kèm. ```json { "sides": { "front_large": "https://..." }, "emb_file": "Available" } ``` Chỉ cần truyền `emb_file: "Available"`. Không cần các field khác. #### Trường hợp 2: Không có file thêu (cần digitize) Bạn chỉ có hình design, cần bên mình chuyển đổi sang file thêu. ```json { "sides": { "front_large": "https://..." }, "emb_file": "No EMB file", "design_emb_type": "vector / cartoon artwork" } ``` Khi `emb_file = "No EMB file"`, **bắt buộc** truyền `design_emb_type`: | Giá trị | Mô tả | |---------|--------| | `text only` | Design chỉ có chữ | | `vector / cartoon artwork` | Hình vector hoặc cartoon | | `Photo to Embroidery` | Ảnh chụp thực (chân dung, thú cưng...) | #### Photo to Embroidery Nếu chọn `"Photo to Embroidery"`, **bắt buộc** truyền thêm `number_of_pets`: ```json { "emb_file": "No EMB file", "design_emb_type": "Photo to Embroidery", "number_of_pets": 2 } ``` | Field | Range | Mô tả | |-------|-------|--------| | `number_of_pets` | 1–4 (cap/hat: 1–2) | Số lượng pet/người trong ảnh | > **Warning:** **Không mix in và thêu** trong cùng 1 line item. Chọn `printing_technique` **HOẶC** `emb_file`, không cả hai. --- ### Yarn Type (Loại chỉ thêu) > **Note:** Field này **phụ thuộc vào product variant**. Không phải tất cả sản phẩm đều cần. #### Cách biết variant nào cần yarn_type Gọi API lấy products, mỗi variant có field `requires_yarn_type` là **mảng các `yarn_type` hợp lệ** cho variant đó: ```json GET /v1/stores/{store_id}/products // Response: { "variant_id": "abc-123", "title": "Black / M", "category": "hoodie", "requires_yarn_type": ["yarn_6_7mm", "yarn_8_10mm", "yarn_20mm"] // ← chọn 1 giá trị trong mảng } ``` - **Mảng rỗng `[]`** → **KHÔNG ĐƯỢC** truyền `yarn_type` (truyền vào sẽ bị reject). - **Mảng có phần tử** → **BẮT BUỘC** truyền `yarn_type`, và giá trị phải nằm **trong mảng** đó. > Đừng đoán theo tên sản phẩm — luôn đọc mảng `requires_yarn_type` của chính variant rồi chọn 1 giá trị. #### Mảng hợp lệ theo category | Category | `requires_yarn_type` | |----------|----------------------| | `hoodie`, `sweatshirt` | `["yarn_6_7mm", "yarn_8_10mm", "yarn_20mm"]` | | `cap` | `["plat_emb", "emb_3d_puff"]` | | `tshirt`, `tote` | `[]` (không truyền `yarn_type`) | #### Ý nghĩa các giá trị | Giá trị | Mô tả | |---------|--------| | `yarn_6_7mm` | Chỉ thêu 6-7mm | | `yarn_8_10mm` | Chỉ thêu 8-10mm | | `yarn_20mm` | Chỉ thêu 20mm | | `plat_emb` | Thêu phẳng (flat embroidery, cap) | | `emb_3d_puff` | Thêu nổi 3D (3D puff, cap) | #### Ví dụ ```json { "variant_id": "variant-requires-yarn", "quantity": 1, "sides": { "front_large": "https://..." }, "emb_file": "Available", "yarn_type": "yarn_8_10mm" } ``` --- ### Add-on Service Dịch vụ bổ sung cấp **line-item** (optional), tính phí theo **quantity**: | Giá trị | Mô tả | Phí | |---------|--------|-----| | `fabric_applique` | Fabric applique / glitter embroidery | $1.50/item | | `lace_applique_emb` | Lace applique embroidery | $2.00/item | ```json { "addon_service": "lace_applique_emb" } ``` > **Warning:** Không khả dụng cho sản phẩm **tote**, **cap/hat**, hoặc sản phẩm yêu cầu **yarn type**. --- ### Shipping Service | Giá trị | Mô tả | |---------|--------| | `eco` | Giao hàng tiết kiệm (mặc định) | | `premium` | Giao hàng nhanh | --- ### Dedup (Chống trùng đơn) Hệ thống kiểm tra trùng đơn theo `reference_id`: - Cùng `reference_id` + cùng store → bị reject `409 DUPLICATE_REFERENCE_ID` - Nếu muốn bỏ qua soft dedup (content hash), truyền `"force": true` --- ### Order Flow ``` Tạo đơn → PENDING → QUEUED → PROCESSING → SUCCESS ↓ FAILED (auto retry tối đa 3 lần) ``` #### Kiểm tra trạng thái ```bash GET /v1/orders/pod/{order_id} ``` #### Retry đơn FAILED ```bash POST /v1/orders/pod/{order_id}/retry ``` #### Hủy đơn ```bash POST /v1/orders/pod/{order_id}/cancel ``` Chỉ hủy được đơn ở trạng thái `PENDING`, `QUEUED`, hoặc `FAILED`. --- ### Nhận cập nhật đơn hàng qua webhook Thay vì poll `GET /v1/orders/pod/{order_id}` liên tục để biết đơn đổi trạng thái hay có tracking mới, bạn có thể đăng ký **webhook**: hệ thống sẽ chủ động `POST` sự kiện (`order.created`, `order.status_updated`, `order.cancelled`, `tracking.created`, `tracking.updated`) tới URL của bạn ngay khi có thay đổi. Xem [hướng dẫn Webhooks](/vi/api-reference/webhooks) để biết quickstart, receiver mẫu (Node/Express, Python/FastAPI, PHP), cách xác thực chữ ký và troubleshooting. --- ### Errors thường gặp | HTTP | Error | Nguyên nhân | Fix | |------|-------|-------------|-----| | 400 | `shipping_address is required...` | Thiếu địa chỉ, không có label | Thêm `shipping_address` hoặc dùng `label_link` + `tracking_number` | | 400 | `shipping_address must not be provided for label orders` | Truyền cả address lẫn label | Bỏ `shipping_address` khi dùng label | | 400 | `label_link and tracking_number must both be provided` | Truyền 1 thiếu 1 | Phải truyền cả 2 hoặc bỏ cả 2 | | 400 | `yarn_type is required for this product` | Variant có `requires_yarn_type` không rỗng mà không truyền | Chọn 1 giá trị trong mảng `requires_yarn_type` | | 400 | `yarn_type is not allowed for this product` | Variant có `requires_yarn_type: []` mà lại truyền | Bỏ `yarn_type` | | 400 | `yarn_type "X" not allowed for ` | Giá trị không nằm trong mảng | Chỉ dùng giá trị có trong `requires_yarn_type` | | 400 | `At least one side design is required` | `sides` rỗng | Thêm ít nhất 1 side | | 400 | `Invalid side code` | Key sai | Dùng đúng side code (xem bảng trên) | | 400 | `variant_id not found` | ID không tồn tại | Check lại từ GET /products | | 400 | `q: SKU search requires at least 3 characters` | `search_type=sku` với `q` 1–2 ký tự | Truyền ít nhất 3 ký tự, hoặc bỏ `q` | | 409 | `DUPLICATE_REFERENCE_ID` | ref_id đã dùng | Đổi reference_id hoặc `"force": true` | --- ### Checklist trước khi gửi đơn 1. Lấy `variant_id` từ API Products (tìm theo SKU: `?q=<≥3 ký tự>&search_type=sku`, xem [Tìm product / variant](#tìm-product--variant)) 2. Check `requires_yarn_type` — nếu mảng không rỗng thì chọn 1 giá trị trong mảng làm `yarn_type` 3. Chọn loại đơn: `shipping_address` HOẶC `label_link` + `tracking_number` 4. Truyền ít nhất 1 side trong `sides` với URL ảnh hợp lệ 5. Chọn technique: `printing_technique` HOẶC `emb_file` (không cả hai) 6. Nếu `emb_file = "No EMB file"` → truyền `design_emb_type` 7. Nếu `design_emb_type = "Photo to Embroidery"` → truyền `number_of_pets` --- ## API Endpoints ### GET /v1/stores **List stores** Returns all active stores available to your account. Use the `store_id` to browse products. **Authentication:** Bearer token required **Example Request:** ```bash curl -X GET "https://api.eod-infinitee.com/v1/stores" \ -H "Authorization: Bearer " ``` **Success Response:** 200: List of stores. - data: array of objects (optional) - store_id: string (uuid) (optional) - name: string (optional) (example: "My Store") - shop_domain: string (optional) (example: "my-store.myshopify.com") - status: enum: ACTIVE | INACTIVE | CREDENTIAL_ERROR (optional) **Example Response:** ```json { "data": [ { "store_id": "11111111-2222-3333-4444-555555555555", "name": "My Store", "shop_domain": "my-store.myshopify.com", "status": "ACTIVE" } ] } ``` --- ### GET /v1/stores/{store_id}/products **List products** Returns products and their variants for a store. Use `variant_id` from a variant when creating an order. **Authentication:** Bearer token required **Path Parameters:** - store_id: string (uuid) (required) **Query Parameters:** - q: string (optional) — Search query. Interpreted according to `search_type`: product title (default), product_id (UUID), shopify_id, or variant SKU. Example: `?q=HOODIE&search_type=sku` finds products with a variant SKU containing "HOODIE" (e.g. "emb - HOODIE - MAROON-L"). SKU search needs at least 3 characters. - search_type: enum: name | product_id | shopify_id | sku (optional) (default: name) — How to interpret `q`: `name` (ILIKE on title), `product_id` (exact UUID), `shopify_id` (exact Shopify product ID), `sku` (partial, case-insensitive match on variant SKU; `q` must be at least 3 characters, otherwise 400). - status: enum: ACTIVE | ARCHIVED | DRAFT (optional) - limit: integer (optional) (default: 20) - cursor: string (optional) — Pagination cursor from previous response. **Example Request:** ```bash curl -X GET "https://api.eod-infinitee.com/v1/stores/11111111-2222-3333-4444-555555555555/products?q=value" \ -H "Authorization: Bearer " ``` **Success Response:** 200: Paginated list of products with variants. - data: array of objects (optional) - product_id: string (uuid) (optional) - title: string (optional) (example: "Classic T-Shirt") - status: enum: ACTIVE | ARCHIVED | DRAFT (optional) - variants: array of objects (optional) - variant_id: string (uuid) (optional) — Use this as `variant_id` when creating an order. - title: string (optional) (example: "Black / L") - sku: string (optional) (example: "TS-BLK-L") - price: string (optional) (example: "199000") - next_cursor: string | null (optional) **Example Response:** ```json { "data": [ { "product_id": "11111111-2222-3333-4444-555555555555", "title": "Classic T-Shirt", "status": "ACTIVE", "variants": [ { "variant_id": "11111111-2222-3333-4444-555555555555", "title": "Black / L", "sku": "TS-BLK-L", "price": "199000" } ] } ], "next_cursor": "string_value" } ``` --- ### GET /v1/stores/{store_id}/products/{product_id} **Get product detail** Returns a single product with all its variants. Use `variant_id` from a variant when creating an order. **Authentication:** Bearer token required **Path Parameters:** - store_id: string (uuid) (required) - product_id: string (uuid) (required) **Example Request:** ```bash curl -X GET "https://api.eod-infinitee.com/v1/stores/11111111-2222-3333-4444-555555555555/products/11111111-2222-3333-4444-555555555555" \ -H "Authorization: Bearer " ``` **Success Response:** 200: Product detail with variants. - product_id: string (uuid) (optional) - title: string (optional) (example: "Classic T-Shirt") - status: enum: ACTIVE | ARCHIVED | DRAFT (optional) - variants: array of objects (optional) - variant_id: string (uuid) (optional) — Use this as `variant_id` when creating an order. - title: string (optional) (example: "Black / L") - sku: string (optional) (example: "TS-BLK-L") - price: string (optional) (example: "199000") **Example Response:** ```json { "product_id": "11111111-2222-3333-4444-555555555555", "title": "Classic T-Shirt", "status": "ACTIVE", "variants": [ { "variant_id": "11111111-2222-3333-4444-555555555555", "title": "Black / L", "sku": "TS-BLK-L", "price": "199000" } ] } ``` **Error Responses:** - 404: Product not found. --- ### POST /v1/orders **Create an order** Push a new order into the queue. The order will be delivered to Shopify automatically with rate-limit protection. Use `idempotency_key` to safely retry without creating duplicates. **Authentication:** Bearer token required **Request Body:** - idempotency_key: string (uuid) (required) — Unique key per order — reuse the same key to safely retry without creating a duplicate. - customer: object (required) - email: string (email) (required) (example: "nguyen@example.com") - first_name: string (required) (example: "Van") - last_name: string (required) (example: "Nguyen") - phone: string (optional) (example: "+84901234567") - shipping_address: object (required) - address1: string (required) (example: "123 Le Loi") - address2: string (optional) (example: "Apt 4B") - city: string (required) (example: "Ho Chi Minh City") - province: string (required) (example: "Ho Chi Minh") - country_code: string (required) (example: "VN") - zip: string (required) (example: "700000") - line_items: array of objects (required) - variant_id: string (uuid) (required) — Variant UUID provided by Linkable. - quantity: integer (required) (example: 2) - note: string (optional) — Optional order note. - tags: string (optional) — Comma-separated Shopify order tags. - force: boolean (optional) — Set `true` to bypass soft duplicate detection based on content hash. **Example Request:** ```bash curl -X POST "https://api.eod-infinitee.com/v1/orders" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"idempotency_key":"11111111-2222-3333-4444-555555555555","customer":{"email":"nguyen@example.com","first_name":"Van","last_name":"Nguyen","phone":"+84901234567"},"shipping_address":{"address1":"123 Le Loi","address2":"Apt 4B","city":"Ho Chi Minh City","province":"Ho Chi Minh","country_code":"VN","zip":"700000"},"line_items":[{"variant_id":"11111111-2222-3333-4444-555555555555","quantity":2}],"note":"string_value","tags":"string_value","force":true}' ``` **Success Response:** 200: Order accepted and queued for delivery to Shopify. - order_id: string (uuid) (optional) - status: enum: PENDING | PROCESSING | SUCCESS | FAILED (optional) - idempotency_key: string (uuid) (optional) - shopify_order_id: string | null (optional) - shopify_order_number: string | null (optional) - duplicate: boolean (optional) - queued_at: string (date-time) (optional) **Example Response:** ```json { "order_id": "11111111-2222-3333-4444-555555555555", "status": "PENDING", "idempotency_key": "11111111-2222-3333-4444-555555555555", "shopify_order_id": "string_value", "shopify_order_number": "string_value", "duplicate": true, "queued_at": "2026-01-01T00:00:00.000Z" } ``` **Error Responses:** - 401: Missing or invalid Bearer token. - 409: Duplicate: `DUPLICATE_REFERENCE_ID` (reference_id đã tồn tại) hoặc `POTENTIAL_DUPLICATE` (nội dung giống — pass `force: true` để bỏ qua). - 422: Validation error — check request body fields. --- ### GET /v1/orders **List orders** Retrieve a paginated list of your orders. Results are scoped to your client token. **Authentication:** Bearer token required **Query Parameters:** - status: enum: PENDING | PROCESSING | SUCCESS | FAILED (optional) - from: string (optional) - to: string (optional) - limit: integer (optional) (default: 20) - cursor: string (optional) — Pagination cursor from previous response. **Example Request:** ```bash curl -X GET "https://api.eod-infinitee.com/v1/orders?status=PENDING" \ -H "Authorization: Bearer " ``` **Success Response:** 200: Paginated list of orders. - data: array of objects (optional) - order_id: string (uuid) (optional) - status: enum: PENDING | PROCESSING | SUCCESS | FAILED (optional) - idempotency_key: string (uuid) (optional) - shopify_order_id: string | null (optional) - shopify_order_number: string | null (optional) - duplicate: boolean (optional) - queued_at: string (date-time) (optional) - next_cursor: string | null (optional) **Example Response:** ```json { "data": [ { "order_id": "11111111-2222-3333-4444-555555555555", "status": "PENDING", "idempotency_key": "11111111-2222-3333-4444-555555555555", "shopify_order_id": "string_value", "shopify_order_number": "string_value", "duplicate": true, "queued_at": "2026-01-01T00:00:00.000Z" } ], "next_cursor": "string_value" } ``` --- ### GET /v1/orders/{order_id} **Get order status** Retrieve the current status and details of a specific order including line items and delivery attempts. **Authentication:** Bearer token required **Path Parameters:** - order_id: string (uuid) (required) **Example Request:** ```bash curl -X GET "https://api.eod-infinitee.com/v1/orders/11111111-2222-3333-4444-555555555555" \ -H "Authorization: Bearer " ``` **Success Response:** 200: Order detail with line items and delivery attempts. - order_id: string (uuid) (optional) - status: enum: PENDING | PROCESSING | SUCCESS | FAILED (optional) - idempotency_key: string (uuid) (optional) - shopify_order_id: string | null (optional) - shopify_order_number: string | null (optional) - duplicate: boolean (optional) - queued_at: string (date-time) (optional) **Example Response:** ```json { "order_id": "11111111-2222-3333-4444-555555555555", "status": "PENDING", "idempotency_key": "11111111-2222-3333-4444-555555555555", "shopify_order_id": "string_value", "shopify_order_number": "string_value", "duplicate": true, "queued_at": "2026-01-01T00:00:00.000Z" } ``` **Error Responses:** - 404: Order not found. --- ### POST /v1/orders/pod **Create POD order** Tạo đơn hàng Print-on-Demand. Hệ thống tự validate, tính pricing, và push sang Shopify. `reference_id` là mã đơn **duy nhất trong mỗi store** — gửi trùng ref sẽ bị từ chối với lỗi `409 DUPLICATE_REFERENCE_ID` (không tạo mới, không trả order cũ). **Authentication:** Bearer token required **Request Body:** - reference_id: string (optional) — Mã đơn hàng của bạn (tuỳ chọn, tối đa 100 ký tự). Bỏ trống thì đơn không có ref và không bị so trùng. Có ref thì duy nhất trong store — gửi trùng sẽ bị từ chối (409 DUPLICATE_REFERENCE_ID). (example: "ORD-20250624-001") - shipping_service: enum: eco | premium (optional) - label_link: string (uri) (optional) — URL label tự cung cấp (TikTok, Etsy, ...). Có field này thì bỏ shipping_address. Phải đi kèm tracking_number. - tracking_number: string (optional) — Tracking number đi kèm label_link. - customer: object (required) - email: string (email) (required) (example: "john@gmail.com") - first_name: string (required) (example: "John") - last_name: string (required) (example: "Doe") - phone: string (optional) (example: "+14155551234") - shipping_address: object (optional) - address1: string (required) (example: "123 Main St") - address2: string (optional) (example: "Apt 4B") - city: string (required) (example: "Los Angeles") - province: string (required) (example: "CA") - country_code: string (required) (example: "US") - zip: string (required) (example: "90001") - line_items: array of objects (required) - variant_id: string (uuid) (required) — UUID sản phẩm (lấy từ Products API). - quantity: integer (required) (example: 2) - sides: object (required) — Design URL cho mỗi mặt in. Keys: front_side, front_large, back, pocket_chest_left, pocket_chest_right, left_sleeve, right_sleeve, special_location. (example: {"front_side":"https://cdn.example.com/design-front.png","back":"https://cdn.example.com/design-back.png"}) - printing_technique: enum: DTG | DTF (optional) — Bắt buộc cho printing. - no_printing_file: boolean (optional) — Khách không có file in: +$2/sản phẩm. Không áp dụng cho nón (cap-like) và tote. - emb_file: enum: Available | No EMB file (optional) — Bắt buộc cho embroidery. - design_emb_type: enum: text only | vector / cartoon artwork | Photo to Embroidery (optional) - number_of_pets: integer (optional) — Bắt buộc khi Photo to Embroidery. - yarn_type: enum: yarn_6_7mm | yarn_8_10mm | yarn_20mm | plat_emb | emb_3d_puff (optional) - addon_service: enum: fabric_applique | lace_applique_emb (optional) — Dịch vụ bổ sung cấp line-item, tính theo quantity: fabric_applique ($1.50/item) hoặc lace_applique_emb ($2.00/item). - note: string (optional) — Ghi chú cho sản phẩm. - addon_service: array of string (optional) - note: string (optional) — Ghi chú đơn hàng. - tags: string (optional) — Tags (comma-separated). - force: boolean (optional) — Set `true` để bỏ qua kiểm tra trùng nội dung. **Example Request:** ```bash curl -X POST "https://api.eod-infinitee.com/v1/orders/pod" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"reference_id":"ORD-20250624-001","shipping_service":"eco","label_link":"https://example.com/resource","tracking_number":"string_value","customer":{"email":"john@gmail.com","first_name":"John","last_name":"Doe","phone":"+14155551234"},"shipping_address":{"address1":"123 Main St","address2":"Apt 4B","city":"Los Angeles","province":"CA","country_code":"US","zip":"90001"},"line_items":[{"variant_id":"11111111-2222-3333-4444-555555555555","quantity":2,"sides":{"front_side":"https://cdn.example.com/design-front.png","back":"https://cdn.example.com/design-back.png"},"printing_technique":"DTG","no_printing_file":true,"emb_file":"Available","design_emb_type":"text only","number_of_pets":1,"yarn_type":"yarn_6_7mm","addon_service":"fabric_applique","note":"string_value"}],"addon_service":["carton_box","scan_label"],"note":"string_value","tags":"string_value","force":true}' ``` **Success Response:** 200: Order accepted and queued for delivery to Shopify. - order_id: string (uuid) (optional) - reference_id: string (optional) (example: "REF-001") - status: enum: PENDING | PROCESSING | SUCCESS | SYNCED | FULFILLED | FAILED | CANCELLED (optional) - duplicate: boolean (optional) - queue_position: integer | null (optional) - addon_service: array of string (optional) - pricing: object (optional) - subtotal: number (optional) (example: 40.97) - shipping_total: number (optional) (example: 8.39) - discount_amount: number (optional) (example: 2.05) - grand_total: number (optional) (example: 47.31) - created_at: string (date-time) (optional) - created_via: enum: api | portal_form | portal_import | ops_import (optional) — Nguồn tạo đơn: api (token tích hợp), portal_form (form trên Portal), portal_import (import file trên Portal), ops_import (import nội bộ). Chỉ có ở GET /v1/orders/pod và /v1/orders/pod/{order_id}. **Example Response:** ```json { "order_id": "11111111-2222-3333-4444-555555555555", "reference_id": "REF-001", "status": "PENDING", "duplicate": true, "queue_position": 1, "addon_service": [ "carton_box" ], "pricing": { "subtotal": 40.97, "shipping_total": 8.39, "discount_amount": 2.05, "grand_total": 47.31 }, "created_at": "2026-01-01T00:00:00.000Z", "created_via": "api" } ``` **Error Responses:** - 400: Validation error — kiểm tra request body. - 401: Missing or invalid Bearer token. - 409: Trùng đơn: - `DUPLICATE_REFERENCE_ID` — `reference_id` đã tồn tại trong store (đổi ref khác để tạo đơn mới). - `POTENTIAL_DUPLICATE` — nội dung giống một đơn trong dedup window; gửi lại với `force: true` để bỏ qua. Body có `error` (code) và `details.existing_order`. --- ### GET /v1/orders/pod **List POD orders** Danh sách đơn POD của bạn, hỗ trợ cursor pagination và filter theo status. **Authentication:** Bearer token required **Query Parameters:** - status: enum: PENDING | PROCESSING | SUCCESS | SYNCED | FULFILLED | FAILED | CANCELLED (optional) - from: string (optional) - to: string (optional) - limit: integer (optional) (default: 20) - cursor: string (optional) — Pagination cursor from previous response. **Example Request:** ```bash curl -X GET "https://api.eod-infinitee.com/v1/orders/pod?status=PENDING" \ -H "Authorization: Bearer " ``` **Success Response:** 200: Paginated list of POD orders. - data: array of objects (optional) - order_id: string (uuid) (optional) - reference_id: string (optional) (example: "REF-001") - status: enum: PENDING | PROCESSING | SUCCESS | SYNCED | FULFILLED | FAILED | CANCELLED (optional) - duplicate: boolean (optional) - queue_position: integer | null (optional) - addon_service: array of string (optional) - pricing: object (optional) - subtotal: number (optional) (example: 40.97) - shipping_total: number (optional) (example: 8.39) - discount_amount: number (optional) (example: 2.05) - grand_total: number (optional) (example: 47.31) - created_at: string (date-time) (optional) - created_via: enum: api | portal_form | portal_import | ops_import (optional) — Nguồn tạo đơn: api (token tích hợp), portal_form (form trên Portal), portal_import (import file trên Portal), ops_import (import nội bộ). Chỉ có ở GET /v1/orders/pod và /v1/orders/pod/{order_id}. - next_cursor: string | null (optional) **Example Response:** ```json { "data": [ { "order_id": "11111111-2222-3333-4444-555555555555", "reference_id": "REF-001", "status": "PENDING", "duplicate": true, "queue_position": 1, "addon_service": [ "carton_box" ], "pricing": { "subtotal": 40.97, "shipping_total": 8.39, "discount_amount": 2.05, "grand_total": 47.31 }, "created_at": "2026-01-01T00:00:00.000Z", "created_via": "api" } ], "next_cursor": "string_value" } ``` --- ### GET /v1/orders/pod/{order_id} **Get POD order** Chi tiết đơn POD bao gồm line items, pricing breakdown, và delivery attempts. **Authentication:** Bearer token required **Path Parameters:** - order_id: string (uuid) (required) **Example Request:** ```bash curl -X GET "https://api.eod-infinitee.com/v1/orders/pod/11111111-2222-3333-4444-555555555555" \ -H "Authorization: Bearer " ``` **Success Response:** 200: POD order detail. - order_id: string (uuid) (optional) - reference_id: string (optional) (example: "REF-001") - status: enum: PENDING | PROCESSING | SUCCESS | SYNCED | FULFILLED | FAILED | CANCELLED (optional) - duplicate: boolean (optional) - queue_position: integer | null (optional) - addon_service: array of string (optional) - pricing: object (optional) - subtotal: number (optional) (example: 40.97) - shipping_total: number (optional) (example: 8.39) - discount_amount: number (optional) (example: 2.05) - grand_total: number (optional) (example: 47.31) - created_at: string (date-time) (optional) - created_via: enum: api | portal_form | portal_import | ops_import (optional) — Nguồn tạo đơn: api (token tích hợp), portal_form (form trên Portal), portal_import (import file trên Portal), ops_import (import nội bộ). Chỉ có ở GET /v1/orders/pod và /v1/orders/pod/{order_id}. - customer: object (optional) - email: string (optional) - first_name: string (optional) - last_name: string (optional) - phone: string (optional) - shipping_address: object (optional) - address1: string (optional) - address2: string (optional) - city: string (optional) - province: string (optional) - country_code: string (optional) - zip: string (optional) - line_items: array of objects (optional) - line_item_id: string (uuid) (optional) - quantity: integer (optional) - sides: object | null (optional) - line_total: number | null (optional) - sku: string | null (optional) - title: string | null (optional) - image_url: string | null (optional) — Ảnh sản phẩm của line item: ảnh riêng của variant, fallback về ảnh product, null nếu cả hai đều không có. - addon_service: string | null (optional) — Dịch vụ bổ sung cấp line (fabric_applique, lace_applique_emb). - line_addon_surcharge: number | null (optional) — Phụ phí add-on cấp line (tính = đơn giá × quantity). **Example Response:** ```json { "order_id": "11111111-2222-3333-4444-555555555555", "reference_id": "REF-001", "status": "PENDING", "duplicate": true, "queue_position": 1, "addon_service": [ "carton_box" ], "pricing": { "subtotal": 40.97, "shipping_total": 8.39, "discount_amount": 2.05, "grand_total": 47.31 }, "created_at": "2026-01-01T00:00:00.000Z", "created_via": "api", "customer": { "email": "string_value", "first_name": "string_value", "last_name": "string_value", "phone": "string_value" }, "shipping_address": { "address1": "string_value", "address2": "string_value", "city": "string_value", "province": "string_value", "country_code": "string_value", "zip": "string_value" }, "line_items": [ { "line_item_id": "11111111-2222-3333-4444-555555555555", "quantity": 1, "sides": {}, "line_total": 1, "sku": "string_value", "title": "string_value", "image_url": "string_value", "addon_service": "string_value", "line_addon_surcharge": 1 } ] } ``` **Error Responses:** - 404: Order not found. --- ### POST /v1/orders/pod/{order_id}/retry **Retry failed POD order** Retry đơn FAILED — đưa lại vào queue để push sang Shopify. Tối đa 5 lần retry. **Authentication:** Bearer token required **Path Parameters:** - order_id: string (uuid) (required) **Example Request:** ```bash curl -X POST "https://api.eod-infinitee.com/v1/orders/pod/11111111-2222-3333-4444-555555555555/retry" \ -H "Authorization: Bearer " ``` **Success Response:** 200: Order queued for retry. - order_id: string (uuid) (optional) - reference_id: string (optional) (example: "REF-001") - status: enum: PENDING | PROCESSING | SUCCESS | SYNCED | FULFILLED | FAILED | CANCELLED (optional) - duplicate: boolean (optional) - queue_position: integer | null (optional) - addon_service: array of string (optional) - pricing: object (optional) - subtotal: number (optional) (example: 40.97) - shipping_total: number (optional) (example: 8.39) - discount_amount: number (optional) (example: 2.05) - grand_total: number (optional) (example: 47.31) - created_at: string (date-time) (optional) - created_via: enum: api | portal_form | portal_import | ops_import (optional) — Nguồn tạo đơn: api (token tích hợp), portal_form (form trên Portal), portal_import (import file trên Portal), ops_import (import nội bộ). Chỉ có ở GET /v1/orders/pod và /v1/orders/pod/{order_id}. **Example Response:** ```json { "order_id": "11111111-2222-3333-4444-555555555555", "reference_id": "REF-001", "status": "PENDING", "duplicate": true, "queue_position": 1, "addon_service": [ "carton_box" ], "pricing": { "subtotal": 40.97, "shipping_total": 8.39, "discount_amount": 2.05, "grand_total": 47.31 }, "created_at": "2026-01-01T00:00:00.000Z", "created_via": "api" } ``` **Error Responses:** - 400: Order is not in FAILED status or max retries exceeded. - 404: Order not found. --- ### POST /v1/orders/pod/{order_id}/cancel **Cancel POD order** Hủy đơn đang ở trạng thái PENDING hoặc FAILED. **Authentication:** Bearer token required **Path Parameters:** - order_id: string (uuid) (required) **Example Request:** ```bash curl -X POST "https://api.eod-infinitee.com/v1/orders/pod/11111111-2222-3333-4444-555555555555/cancel" \ -H "Authorization: Bearer " ``` **Success Response:** 200: Order cancelled. `warnings` is present only when the cancel left something outstanding — most often a shipping label the carrier refused to void, which stays live and billable. - order_id: string (uuid) (required) - status: enum: CANCELLED (required) - warnings: array of string (optional) **Example Response:** ```json { "order_id": "11111111-2222-3333-4444-555555555555", "status": "CANCELLED", "warnings": [ "string_value" ] } ``` **Error Responses:** - 400: Order cannot be cancelled (not PENDING or FAILED). - 404: Order not found. --- ### GET /v1/orders/pod/{order_id}/shipping **Get shipping label for a POD order** Nhãn vận chuyển đã mua cho đơn. Trả `null` khi chưa có nhãn còn hiệu lực — đơn chưa mua, mua hỏng, hoặc nhãn đã bị huỷ. **Authentication:** Bearer token required **Path Parameters:** - order_id: string (uuid) (required) **Example Request:** ```bash curl -X GET "https://api.eod-infinitee.com/v1/orders/pod/11111111-2222-3333-4444-555555555555/shipping" \ -H "Authorization: Bearer " ``` **Success Response:** 200: Shipping label, or null when the order has none. - carrier: string | null (optional) - tracking_number: string | null (optional) - label_url: string (optional) — Link tới storage của carrier. Ưu tiên dùng `/shipping/file` thay cho link này — link carrier có thể hết hạn hoặc sập, còn nhãn thì không cấp lại được, chỉ mua lại được. - label_format: string | null (optional) (example: "PDF") - label_purchased_at: string (date-time) | null (optional) - fulfilled_at: string (date-time) | null (optional) - is_voided: boolean (optional) **Example Response:** ```json { "carrier": "string_value", "tracking_number": "string_value", "label_url": "string_value", "label_format": "PDF", "label_purchased_at": "2026-01-01T00:00:00.000Z", "fulfilled_at": "2026-01-01T00:00:00.000Z", "is_voided": true } ``` **Error Responses:** - 404: Order not found. --- ### GET /v1/orders/pod/{order_id}/shipping/file **Download the shipping label file** Chính file nhãn. Trả bản sao trong storage của hệ thống khi có, và chỉ lấy từ carrier khi chưa có bản sao — nên endpoint này vẫn chạy sau khi link carrier hết hạn. Header `X-Label-Source` cho biết bản nào đã trả lời: `archive` hoặc `provider`. **Authentication:** Bearer token required **Path Parameters:** - order_id: string (uuid) (required) **Example Request:** ```bash curl -X GET "https://api.eod-infinitee.com/v1/orders/pod/11111111-2222-3333-4444-555555555555/shipping/file" \ -H "Authorization: Bearer " ``` **Success Response:** 200: The label file. **Error Responses:** - 404: Order not found, or it has no live shipping label. --- ### GET /v1/config/pricing-sides **Get pricing sides config** Bảng phụ phí mặt in theo category. Dùng để tính trước giá đơn hàng. **Authentication:** Bearer token required **Example Request:** ```bash curl -X GET "https://api.eod-infinitee.com/v1/config/pricing-sides" \ -H "Authorization: Bearer " ``` **Success Response:** 200: Pricing sides configuration. - data: array of objects (optional) - category: string (optional) - side_code: string (optional) - surcharge: number (optional) **Example Response:** ```json { "data": [ { "category": "string_value", "side_code": "string_value", "surcharge": 1 } ] } ``` --- ### GET /v1/config/shipping-rates **Get shipping rates** Bảng phí ship theo category và service type (eco/premium). **Authentication:** Bearer token required **Example Request:** ```bash curl -X GET "https://api.eod-infinitee.com/v1/config/shipping-rates" \ -H "Authorization: Bearer " ``` **Success Response:** 200: Shipping rates. - data: array of objects (optional) - category: string (optional) - service_type: string (optional) - base_rate: number (optional) - additional_rate: number (optional) **Example Response:** ```json { "data": [ { "category": "string_value", "service_type": "string_value", "base_rate": 1, "additional_rate": 1 } ] } ``` --- ### GET /v1/config/yarn-types **Get yarn types** Loại chỉ thêu và phí bổ sung. **Authentication:** Bearer token required **Example Request:** ```bash curl -X GET "https://api.eod-infinitee.com/v1/config/yarn-types" \ -H "Authorization: Bearer " ``` **Success Response:** 200: Yarn types configuration. - data: array of objects (optional) - category: string (optional) - type_code: string (optional) - extra_cost: number (optional) **Example Response:** ```json { "data": [ { "category": "string_value", "type_code": "string_value", "extra_cost": 1 } ] } ``` --- ### GET /v1/config/country-codes **Get valid country codes** Danh sách quốc gia hợp lệ cho shipping_address.country_code. **Authentication:** Bearer token required **Example Request:** ```bash curl -X GET "https://api.eod-infinitee.com/v1/config/country-codes" \ -H "Authorization: Bearer " ``` **Success Response:** 200: Country codes list. - data: array of objects (optional) - code: string (optional) (example: "US") - name: string (optional) (example: "United States") **Example Response:** ```json { "data": [ { "code": "US", "name": "United States" } ] } ``` --- ### GET /v1/config/special-surcharges **Get special surcharges** Bảng phụ phí đặc biệt (DTG, EMB, addon services). **Authentication:** Bearer token required **Example Request:** ```bash curl -X GET "https://api.eod-infinitee.com/v1/config/special-surcharges" \ -H "Authorization: Bearer " ``` **Success Response:** 200: Special surcharges. - data: array of objects (optional) - surcharge_type: string (optional) - condition_key: string (optional) - amount: number (optional) **Example Response:** ```json { "data": [ { "surcharge_type": "string_value", "condition_key": "string_value", "amount": 1 } ] } ``` --- ### GET /v1/config/carton-box-config **Get carton box capacity config** Cấu hình số items/box theo category và store. Dùng để tính phí carton box add-on: $4 (box đầu) + $1/box thêm. Số box = ceil(items / items_per_box) tính riêng cho mỗi category. **Authentication:** Bearer token required **Query Parameters:** - store_id: string (optional) — Filter theo store (optional). Nếu không truyền, trả tất cả stores. **Example Request:** ```bash curl -X GET "https://api.eod-infinitee.com/v1/config/carton-box-config?store_id=value" \ -H "Authorization: Bearer " ``` **Success Response:** 200: Carton box capacity config. - data: array of objects (optional) - id: string (uuid) (optional) - store_id: string (uuid) (optional) - store_name: string (optional) - category: string (optional) (example: "tshirt") - items_per_box: integer (optional) — Số sản phẩm tối đa mỗi box cho category này. (example: 5) **Example Response:** ```json { "data": [ { "id": "11111111-2222-3333-4444-555555555555", "store_id": "11111111-2222-3333-4444-555555555555", "store_name": "string_value", "category": "tshirt", "items_per_box": 5 } ] } ``` --- ### GET /v1/me/webhooks/topics **List available topics** List the event topics you can subscribe a webhook to, with a short description of each. **Authentication:** Bearer token required **Example Request:** ```bash curl -X GET "https://api.eod-infinitee.com/v1/me/webhooks/topics" \ -H "Authorization: Bearer " ``` **Success Response:** 200: The subscribable topics. - data: array of objects (optional) - topic: enum: order.created | order.status_updated | order.cancelled | tracking.created | tracking.updated (required) (example: "order.created") - description: string (required) (example: "An order was created.") **Example Response:** ```json { "data": [ { "topic": "order.created", "description": "An order was created." } ] } ``` **Error Responses:** - 401: Missing or invalid Bearer token (`UNAUTHORIZED`). --- ### GET /v1/me/webhooks **List your webhooks** List every webhook endpoint registered for your account. Signing secrets are never returned here. **Authentication:** Bearer token required **Example Request:** ```bash curl -X GET "https://api.eod-infinitee.com/v1/me/webhooks" \ -H "Authorization: Bearer " ``` **Success Response:** 200: Your webhook endpoints. - data: array of objects (optional) - id: string (uuid) (required) - url: string (uri) (required) — HTTPS endpoint we POST events to. (example: "https://example.com/hooks/linkable") - description: string | null (required) (example: "Production order sync") - topics: array of string (required) - is_active: boolean (required) — False when disabled — either by you or auto-disabled after 10 consecutive delivery failures. - consecutive_failures: integer (required) — Deliveries that have failed in a row; reset to 0 on any success. (example: 0) - disabled_reason: string | null (required) — Why the endpoint was disabled, when `is_active` is false. (example: "too_many_failures") - created_at: string (date-time) (required) - updated_at: string (date-time) (required) **Example Response:** ```json { "data": [ { "id": "11111111-2222-3333-4444-555555555555", "url": "https://example.com/hooks/linkable", "description": "Production order sync", "topics": [ "order.created", "order.status_updated" ], "is_active": true, "consecutive_failures": 0, "disabled_reason": "too_many_failures", "created_at": "2026-01-01T00:00:00.000Z", "updated_at": "2026-01-01T00:00:00.000Z" } ] } ``` **Error Responses:** - 401: Missing or invalid Bearer token (`UNAUTHORIZED`). --- ### POST /v1/me/webhooks **Register a webhook** Register a new webhook endpoint. The response includes the plaintext signing `secret` **once** — store it now; it is never returned again. A client may register at most 5 webhooks. **Authentication:** Bearer token required **Request Body:** - url: string (uri) (required) — HTTPS endpoint (must resolve to a public address). (example: "https://example.com/hooks/linkable") - topics: array of string (required) - description: string (optional) (example: "Production order sync") **Example Request:** ```bash curl -X POST "https://api.eod-infinitee.com/v1/me/webhooks" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"url":"https://example.com/hooks/linkable","topics":["order.created","order.status_updated"],"description":"Production order sync"}' ``` **Success Response:** 201: Webhook created. `secret` is present here only. - id: string (uuid) (required) - url: string (uri) (required) — HTTPS endpoint we POST events to. (example: "https://example.com/hooks/linkable") - description: string | null (required) (example: "Production order sync") - topics: array of string (required) - is_active: boolean (required) — False when disabled — either by you or auto-disabled after 10 consecutive delivery failures. - consecutive_failures: integer (required) — Deliveries that have failed in a row; reset to 0 on any success. (example: 0) - disabled_reason: string | null (required) — Why the endpoint was disabled, when `is_active` is false. (example: "too_many_failures") - created_at: string (date-time) (required) - updated_at: string (date-time) (required) - secret: string (required) — HMAC-SHA256 signing secret. Use it to verify the `X-Webhook-Signature` header. (example: "whsec_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4") **Example Response:** ```json { "id": "11111111-2222-3333-4444-555555555555", "url": "https://example.com/hooks/linkable", "description": "Production order sync", "topics": [ "order.created", "order.status_updated" ], "is_active": true, "consecutive_failures": 0, "disabled_reason": "too_many_failures", "created_at": "2026-01-01T00:00:00.000Z", "updated_at": "2026-01-01T00:00:00.000Z", "secret": "" } ``` **Error Responses:** - 400: Bad request — one of `VALIDATION_ERROR` (invalid body/params), `INVALID_WEBHOOK_URL` (scheme/SSRF check failed), `WEBHOOK_LIMIT_REACHED` (max 5 webhooks per client), or `WEBHOOK_INACTIVE` (test send on a disabled endpoint). - 401: Missing or invalid Bearer token (`UNAUTHORIZED`). --- ### PATCH /v1/me/webhooks/{id} **Update a webhook** Update a webhook. Provide at least one field. Setting `is_active: true` re-activates an auto-disabled endpoint and clears its failure counter. **Authentication:** Bearer token required **Path Parameters:** - id: string (uuid) (required) **Request Body:** - url: string (uri) (optional) - topics: array of string (optional) - description: string | null (optional) - is_active: boolean (optional) **Example Request:** ```bash curl -X PATCH "https://api.eod-infinitee.com/v1/me/webhooks/11111111-2222-3333-4444-555555555555" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"url":"https://example.com/resource","topics":["order.created"],"description":"string_value","is_active":true}' ``` **Success Response:** 200: The updated webhook. - id: string (uuid) (required) - url: string (uri) (required) — HTTPS endpoint we POST events to. (example: "https://example.com/hooks/linkable") - description: string | null (required) (example: "Production order sync") - topics: array of string (required) - is_active: boolean (required) — False when disabled — either by you or auto-disabled after 10 consecutive delivery failures. - consecutive_failures: integer (required) — Deliveries that have failed in a row; reset to 0 on any success. (example: 0) - disabled_reason: string | null (required) — Why the endpoint was disabled, when `is_active` is false. (example: "too_many_failures") - created_at: string (date-time) (required) - updated_at: string (date-time) (required) **Example Response:** ```json { "id": "11111111-2222-3333-4444-555555555555", "url": "https://example.com/hooks/linkable", "description": "Production order sync", "topics": [ "order.created", "order.status_updated" ], "is_active": true, "consecutive_failures": 0, "disabled_reason": "too_many_failures", "created_at": "2026-01-01T00:00:00.000Z", "updated_at": "2026-01-01T00:00:00.000Z" } ``` **Error Responses:** - 400: Bad request — one of `VALIDATION_ERROR` (invalid body/params), `INVALID_WEBHOOK_URL` (scheme/SSRF check failed), `WEBHOOK_LIMIT_REACHED` (max 5 webhooks per client), or `WEBHOOK_INACTIVE` (test send on a disabled endpoint). - 401: Missing or invalid Bearer token (`UNAUTHORIZED`). - 404: Webhook or delivery not found (`NOT_FOUND`). --- ### DELETE /v1/me/webhooks/{id} **Delete a webhook** Permanently delete a webhook endpoint. **Authentication:** Bearer token required **Path Parameters:** - id: string (uuid) (required) **Example Request:** ```bash curl -X DELETE "https://api.eod-infinitee.com/v1/me/webhooks/11111111-2222-3333-4444-555555555555" \ -H "Authorization: Bearer " ``` **Success Response:** 204 No Content — Deleted. **Error Responses:** - 401: Missing or invalid Bearer token (`UNAUTHORIZED`). - 404: Webhook or delivery not found (`NOT_FOUND`). --- ### POST /v1/me/webhooks/{id}/rotate-secret **Rotate signing secret** Issue a new signing secret for a webhook, invalidating the old one. The new plaintext `secret` is returned **once**. **Authentication:** Bearer token required **Path Parameters:** - id: string (uuid) (required) **Example Request:** ```bash curl -X POST "https://api.eod-infinitee.com/v1/me/webhooks/11111111-2222-3333-4444-555555555555/rotate-secret" \ -H "Authorization: Bearer " ``` **Success Response:** 200: The webhook with its new `secret`. - id: string (uuid) (required) - url: string (uri) (required) — HTTPS endpoint we POST events to. (example: "https://example.com/hooks/linkable") - description: string | null (required) (example: "Production order sync") - topics: array of string (required) - is_active: boolean (required) — False when disabled — either by you or auto-disabled after 10 consecutive delivery failures. - consecutive_failures: integer (required) — Deliveries that have failed in a row; reset to 0 on any success. (example: 0) - disabled_reason: string | null (required) — Why the endpoint was disabled, when `is_active` is false. (example: "too_many_failures") - created_at: string (date-time) (required) - updated_at: string (date-time) (required) - secret: string (required) — HMAC-SHA256 signing secret. Use it to verify the `X-Webhook-Signature` header. (example: "whsec_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4") **Example Response:** ```json { "id": "11111111-2222-3333-4444-555555555555", "url": "https://example.com/hooks/linkable", "description": "Production order sync", "topics": [ "order.created", "order.status_updated" ], "is_active": true, "consecutive_failures": 0, "disabled_reason": "too_many_failures", "created_at": "2026-01-01T00:00:00.000Z", "updated_at": "2026-01-01T00:00:00.000Z", "secret": "" } ``` **Error Responses:** - 401: Missing or invalid Bearer token (`UNAUTHORIZED`). - 404: Webhook or delivery not found (`NOT_FOUND`). --- ### POST /v1/me/webhooks/{id}/test **Send a test event** Send a one-off `webhook.test` event to this endpoint (regardless of its subscribed topics) so you can confirm your endpoint receives and verifies our signature. The endpoint must be active. **Authentication:** Bearer token required **Path Parameters:** - id: string (uuid) (required) **Example Request:** ```bash curl -X POST "https://api.eod-infinitee.com/v1/me/webhooks/11111111-2222-3333-4444-555555555555/test" \ -H "Authorization: Bearer " ``` **Success Response:** 202: Test delivery queued. - id: string (uuid) (required) - webhook_id: string (uuid) (required) - event_id: string (uuid) (required) — The event id — matches `X-Webhook-Id` and the envelope `id`. - topic: string (required) — The event topic (or `webhook.test` for a manual test ping). (example: "order.created") - payload: object (required) - id: string (uuid) (required) — Event id — identical across every webhook that receives this event; use it to dedupe retries. - topic: string (required) (example: "order.created") - created_at: string (date-time) (required) — ISO-8601 timestamp of when the event was emitted. - data: object (required) (oneOf: this is one of 2 possible schemas) - order: object (required) - id: string (uuid) (required) - reference_id: string (required) (example: "REF-10023") - store_id: string (uuid) (required) - client_status: string | null (required) — Client-facing status key, or null when none is set yet. (example: "shipping") - shipment_status: string | null (required) — Shipment status derived from carrier tracking, or null when not shipping yet. (example: "in_transit") - created_at: string (date-time) (required) - updated_at: string (date-time) (required) - tracking: object (required) - tracking_number: string | null (required) (example: "1Z999AA10123456784") - carrier: string | null (required) (example: "UPS") - tracking_url: string (uri) | null (required) (example: "https://www.ups.com/track?tracknum=1Z999AA10123456784") - shipment_status: string | null (required) (example: "in_transit") - previous: object (optional) — Only present on `*_updated` topics — the subset of fields that changed. - status: enum: pending | success | failed (required) - attempts: integer (required) — Number of delivery attempts made so far. (example: 1) - last_status_code: integer | null (required) — HTTP status of the most recent attempt, or null if none completed. (example: 200) - last_error: string | null (required) — Error detail from the most recent failed attempt. (example: null) - next_retry_at: string (date-time) | null (required) — When the next retry is scheduled, or null when not pending a retry. - delivered_at: string (date-time) | null (required) — When the delivery first succeeded, or null. - created_at: string (date-time) (required) **Example Response:** ```json { "id": "11111111-2222-3333-4444-555555555555", "webhook_id": "11111111-2222-3333-4444-555555555555", "event_id": "11111111-2222-3333-4444-555555555555", "topic": "order.created", "payload": { "id": "11111111-2222-3333-4444-555555555555", "topic": "order.created", "created_at": "2026-01-01T00:00:00.000Z", "data": { "order": { "id": "11111111-2222-3333-4444-555555555555", "reference_id": "REF-10023", "store_id": "11111111-2222-3333-4444-555555555555", "client_status": "shipping", "shipment_status": "in_transit", "created_at": "2026-01-01T00:00:00.000Z", "updated_at": "2026-01-01T00:00:00.000Z" }, "tracking": { "tracking_number": "1Z999AA10123456784", "carrier": "UPS", "tracking_url": "https://www.ups.com/track?tracknum=1Z999AA10123456784", "shipment_status": "in_transit" }, "previous": {} } }, "status": "pending", "attempts": 1, "last_status_code": 200, "last_error": null, "next_retry_at": "2026-01-01T00:00:00.000Z", "delivered_at": "2026-01-01T00:00:00.000Z", "created_at": "2026-01-01T00:00:00.000Z" } ``` **Error Responses:** - 400: Bad request — one of `VALIDATION_ERROR` (invalid body/params), `INVALID_WEBHOOK_URL` (scheme/SSRF check failed), `WEBHOOK_LIMIT_REACHED` (max 5 webhooks per client), or `WEBHOOK_INACTIVE` (test send on a disabled endpoint). - 401: Missing or invalid Bearer token (`UNAUTHORIZED`). - 404: Webhook or delivery not found (`NOT_FOUND`). --- ### GET /v1/me/webhooks/{id}/deliveries **List recent deliveries** List recent delivery attempts for one webhook, most recent first. **Authentication:** Bearer token required **Path Parameters:** - id: string (uuid) (required) **Query Parameters:** - page: integer (optional) (default: 1) — 1-based page number. - limit: integer (optional) (default: 20) **Example Request:** ```bash curl -X GET "https://api.eod-infinitee.com/v1/me/webhooks/11111111-2222-3333-4444-555555555555/deliveries?page=1" \ -H "Authorization: Bearer " ``` **Success Response:** 200: Paginated delivery log. - data: array of objects (optional) - id: string (uuid) (required) - webhook_id: string (uuid) (required) - event_id: string (uuid) (required) — The event id — matches `X-Webhook-Id` and the envelope `id`. - topic: string (required) — The event topic (or `webhook.test` for a manual test ping). (example: "order.created") - payload: object (required) - id: string (uuid) (required) — Event id — identical across every webhook that receives this event; use it to dedupe retries. - topic: string (required) (example: "order.created") - created_at: string (date-time) (required) — ISO-8601 timestamp of when the event was emitted. - data: object (required) (oneOf: this is one of 2 possible schemas) - order: object (required) - id: string (uuid) (required) - reference_id: string (required) (example: "REF-10023") - store_id: string (uuid) (required) - client_status: string | null (required) — Client-facing status key, or null when none is set yet. (example: "shipping") - shipment_status: string | null (required) — Shipment status derived from carrier tracking, or null when not shipping yet. (example: "in_transit") - created_at: string (date-time) (required) - updated_at: string (date-time) (required) - tracking: object (required) - tracking_number: string | null (required) (example: "1Z999AA10123456784") - carrier: string | null (required) (example: "UPS") - tracking_url: string (uri) | null (required) (example: "https://www.ups.com/track?tracknum=1Z999AA10123456784") - shipment_status: string | null (required) (example: "in_transit") - previous: object (optional) — Only present on `*_updated` topics — the subset of fields that changed. - status: enum: pending | success | failed (required) - attempts: integer (required) — Number of delivery attempts made so far. (example: 1) - last_status_code: integer | null (required) — HTTP status of the most recent attempt, or null if none completed. (example: 200) - last_error: string | null (required) — Error detail from the most recent failed attempt. (example: null) - next_retry_at: string (date-time) | null (required) — When the next retry is scheduled, or null when not pending a retry. - delivered_at: string (date-time) | null (required) — When the delivery first succeeded, or null. - created_at: string (date-time) (required) - page: integer (optional) - limit: integer (optional) **Example Response:** ```json { "data": [ { "id": "11111111-2222-3333-4444-555555555555", "webhook_id": "11111111-2222-3333-4444-555555555555", "event_id": "11111111-2222-3333-4444-555555555555", "topic": "order.created", "payload": { "id": "11111111-2222-3333-4444-555555555555", "topic": "order.created", "created_at": "2026-01-01T00:00:00.000Z", "data": { "order": { "id": null, "reference_id": null, "store_id": null, "client_status": null, "shipment_status": null, "created_at": null, "updated_at": null }, "tracking": { "tracking_number": null, "carrier": null, "tracking_url": null, "shipment_status": null }, "previous": {} } }, "status": "pending", "attempts": 1, "last_status_code": 200, "last_error": null, "next_retry_at": "2026-01-01T00:00:00.000Z", "delivered_at": "2026-01-01T00:00:00.000Z", "created_at": "2026-01-01T00:00:00.000Z" } ], "page": 1, "limit": 1 } ``` **Error Responses:** - 400: Bad request — one of `VALIDATION_ERROR` (invalid body/params), `INVALID_WEBHOOK_URL` (scheme/SSRF check failed), `WEBHOOK_LIMIT_REACHED` (max 5 webhooks per client), or `WEBHOOK_INACTIVE` (test send on a disabled endpoint). - 401: Missing or invalid Bearer token (`UNAUTHORIZED`). - 404: Webhook or delivery not found (`NOT_FOUND`). --- ### POST /v1/me/webhooks/deliveries/{delivery_id}/redeliver **Re-queue a delivery** Reset a delivery to a fresh attempt cycle and re-enqueue it. **Authentication:** Bearer token required **Path Parameters:** - delivery_id: string (uuid) (required) **Example Request:** ```bash curl -X POST "https://api.eod-infinitee.com/v1/me/webhooks/deliveries/11111111-2222-3333-4444-555555555555/redeliver" \ -H "Authorization: Bearer " ``` **Success Response:** 202: Delivery re-queued. - id: string (uuid) (required) - webhook_id: string (uuid) (required) - event_id: string (uuid) (required) — The event id — matches `X-Webhook-Id` and the envelope `id`. - topic: string (required) — The event topic (or `webhook.test` for a manual test ping). (example: "order.created") - payload: object (required) - id: string (uuid) (required) — Event id — identical across every webhook that receives this event; use it to dedupe retries. - topic: string (required) (example: "order.created") - created_at: string (date-time) (required) — ISO-8601 timestamp of when the event was emitted. - data: object (required) (oneOf: this is one of 2 possible schemas) - order: object (required) - id: string (uuid) (required) - reference_id: string (required) (example: "REF-10023") - store_id: string (uuid) (required) - client_status: string | null (required) — Client-facing status key, or null when none is set yet. (example: "shipping") - shipment_status: string | null (required) — Shipment status derived from carrier tracking, or null when not shipping yet. (example: "in_transit") - created_at: string (date-time) (required) - updated_at: string (date-time) (required) - tracking: object (required) - tracking_number: string | null (required) (example: "1Z999AA10123456784") - carrier: string | null (required) (example: "UPS") - tracking_url: string (uri) | null (required) (example: "https://www.ups.com/track?tracknum=1Z999AA10123456784") - shipment_status: string | null (required) (example: "in_transit") - previous: object (optional) — Only present on `*_updated` topics — the subset of fields that changed. - status: enum: pending | success | failed (required) - attempts: integer (required) — Number of delivery attempts made so far. (example: 1) - last_status_code: integer | null (required) — HTTP status of the most recent attempt, or null if none completed. (example: 200) - last_error: string | null (required) — Error detail from the most recent failed attempt. (example: null) - next_retry_at: string (date-time) | null (required) — When the next retry is scheduled, or null when not pending a retry. - delivered_at: string (date-time) | null (required) — When the delivery first succeeded, or null. - created_at: string (date-time) (required) **Example Response:** ```json { "id": "11111111-2222-3333-4444-555555555555", "webhook_id": "11111111-2222-3333-4444-555555555555", "event_id": "11111111-2222-3333-4444-555555555555", "topic": "order.created", "payload": { "id": "11111111-2222-3333-4444-555555555555", "topic": "order.created", "created_at": "2026-01-01T00:00:00.000Z", "data": { "order": { "id": "11111111-2222-3333-4444-555555555555", "reference_id": "REF-10023", "store_id": "11111111-2222-3333-4444-555555555555", "client_status": "shipping", "shipment_status": "in_transit", "created_at": "2026-01-01T00:00:00.000Z", "updated_at": "2026-01-01T00:00:00.000Z" }, "tracking": { "tracking_number": "1Z999AA10123456784", "carrier": "UPS", "tracking_url": "https://www.ups.com/track?tracknum=1Z999AA10123456784", "shipment_status": "in_transit" }, "previous": {} } }, "status": "pending", "attempts": 1, "last_status_code": 200, "last_error": null, "next_retry_at": "2026-01-01T00:00:00.000Z", "delivered_at": "2026-01-01T00:00:00.000Z", "created_at": "2026-01-01T00:00:00.000Z" } ``` **Error Responses:** - 401: Missing or invalid Bearer token (`UNAUTHORIZED`). - 404: Webhook or delivery not found (`NOT_FOUND`). --- ### GET /v1/storage-plans **List storage plans** Active storage plans for comparison (no prices). **Authentication:** Bearer token required **Example Request:** ```bash curl -X GET "https://api.eod-infinitee.com/v1/storage-plans" \ -H "Authorization: Bearer " ``` **Success Response:** 200: Active plans, ordered by `sort_order`. - data: array of objects (optional) - code: string (optional) - name: string (optional) - description: string | null (optional) - storage_limit_bytes: integer | null (optional) — null = unlimited. - max_file_size_bytes: integer (optional) - sort_order: integer (optional) **Example Response:** ```json { "data": [ { "code": "string_value", "name": "string_value", "description": "string_value", "storage_limit_bytes": 1, "max_file_size_bytes": 1, "sort_order": 1 } ] } ``` --- ### GET /v1/design-files **List your design files** Design files stored by the authenticated client, newest first, with storage usage. Never includes other clients' files. **Authentication:** Bearer token required **Query Parameters:** - limit: integer (optional) (default: 20) - offset: integer (optional) (default: 0) **Example Request:** ```bash curl -X GET "https://api.eod-infinitee.com/v1/design-files?limit=20" \ -H "Authorization: Bearer " ``` **Success Response:** 200: Page of design files plus `usage`. - data: array of objects (optional) - url: string (uri) (optional) — Public file URL: `DESIGN_FILES_PUBLIC_URL/design-files/{client_id}/{file}` when the dedicated bucket is configured, otherwise `{API_BASE_URL}/v1/design-files/{client_id}/{file}`. When link signing is on it ends with `?exp={unixMs}&sig={hmac}`: the link expires after 90 days and an invalid or expired one answers 404. Call this list again to get a fresh link. Use the URL as-is; do not strip the query. - path: string (optional) — Path relative to the API base (`design-files/{client_id}/{file}`); use it for DELETE. - file_name: string (optional) - size: integer (optional) - content_type: string (optional) - created_at: string (date-time) (optional) - pagination: object (optional) - limit: integer (optional) - offset: integer (optional) - total: integer (optional) - usage: object (optional) - used_bytes: integer (optional) (example: 52428800) - limit_bytes: integer | null (optional) — null = unlimited. (example: 1073741824) - file_count: integer (optional) (example: 12) - plan: object (optional) - code: string (optional) (example: "free") - name: string (optional) (example: "Free") - storage_limit_bytes: integer | null (optional) - max_file_size_bytes: integer (optional) (example: 20971520) **Example Response:** ```json { "data": [ { "url": "https://example.com/resource", "path": "string_value", "file_name": "string_value", "size": 1, "content_type": "string_value", "created_at": "2026-01-01T00:00:00.000Z" } ], "pagination": { "limit": 1, "offset": 1, "total": 1 }, "usage": { "used_bytes": 52428800, "limit_bytes": 1073741824, "file_count": 12, "plan": { "code": "free", "name": "Free", "storage_limit_bytes": 1, "max_file_size_bytes": 20971520 } } } ``` --- ### POST /v1/design-files **Upload a design file** Multipart upload (`file`: pdf, png, jpg, jpeg, emb; max 20MB). Storage is limited by bytes according to the client's storage plan, never by number of files. If used + new file size exceeds the plan's storage the request fails with 403 `STORAGE_LIMIT_REACHED` (`details`: `{used_bytes, limit_bytes, plan}`); a file larger than the plan's per-file limit fails with 413 `FILE_TOO_LARGE`. Nothing is stored in either case. **Authentication:** Bearer token required **Example Request:** ```bash curl -X POST "https://api.eod-infinitee.com/v1/design-files" \ -H "Authorization: Bearer " ``` **Success Response:** 201: Stored. Returns `data` (url, path, file_name, content_type, size) plus `usage`. **Error Responses:** - 400: Missing file or invalid format. - 403: Storage plan full — `STORAGE_LIMIT_REACHED`, details `{used_bytes, limit_bytes, plan}`. - 413: File larger than the plan's per-file limit — `FILE_TOO_LARGE`. --- ### DELETE /v1/design-files/{client_id}/{file} **Delete a design file** Removes the file and frees its bytes. Only the owning client can delete. **Authentication:** Bearer token required **Path Parameters:** - client_id: string (required) - file: string (required) **Example Request:** ```bash curl -X DELETE "https://api.eod-infinitee.com/v1/design-files/abc-123/sample_file" \ -H "Authorization: Bearer " ``` **Success Response:** 204 No Content — Deleted. **Error Responses:** - 404: Not found. --- ### GET /v1/me/features **Get my feature flags** Get the current client's enabled features. Returns a simple map of feature key to boolean — no source/note information is leaked. **Authentication:** Bearer token required **Example Request:** ```bash curl -X GET "https://api.eod-infinitee.com/v1/me/features" \ -H "Authorization: Bearer " ``` **Success Response:** 200: Feature flags for the current client. - features: object (optional) — Map of feature key to enabled state (example: {"orders.create":true,"orders.import":true,"orders.cancel":true,"orders.retry":true,"catalog.products":true,"wallet.view":true,"wallet.topup":true,"tickets":true,"webhooks":true,"api_tokens":true}) **Example Response:** ```json { "features": { "orders.create": true, "orders.import": true, "orders.cancel": true, "orders.retry": true, "catalog.products": true, "wallet.view": true, "wallet.topup": true, "tickets": true, "webhooks": true, "api_tokens": true } } ``` **Error Responses:** - 401: Missing or invalid credentials (`UNAUTHORIZED`). --- ## Webhooks Guide ### Webhooks là gì Thay vì poll API liên tục để biết đơn hàng thay đổi, bạn đăng ký một URL (endpoint) để hệ thống **chủ động POST** sự kiện tới bạn ngay khi có thay đổi: đơn được tạo, đổi trạng thái, bị hủy, hoặc có cập nhật tracking. Mỗi sự kiện là một request `POST` với body JSON và các header chữ ký để bạn xác thực request thật sự đến từ chúng tôi. > **Note:** Toàn bộ endpoint quản lý webhook nằm dưới `/v1/me/webhooks` và dùng **client token** > để xác thực — giống các API client khác. Base URL production: `https://api.eod-infinitee.com`. Client token có prefix `sqok_`. Đây **khác** với webhook signing secret (prefix `whsec_`) — secret dùng để ký payload, không phải để gọi API. --- ### Quickstart Từ zero tới một receiver đã verify được, trong 5 bước. Đặt hai biến môi trường trước: ```bash export SOQ_BASE="https://api.eod-infinitee.com" export SOQ_TOKEN="sqok_..." # client token của bạn (lấy trong Client Portal) ``` **1. Đăng ký endpoint** — `POST /v1/me/webhooks`. URL phải là **https public** (xem [Local testing](#local-testing) nếu đang chạy máy local): ```bash curl -sS -X POST "$SOQ_BASE/v1/me/webhooks" \ -H "Authorization: Bearer $SOQ_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.example.com/webhooks/soq", "topics": ["order.created", "order.status_updated", "tracking.updated"], "description": "Production endpoint" }' ``` **2. Lưu lại `secret`** — response `201` trả về `secret` (prefix `whsec_`) **đúng một lần**. Copy ngay và lưu vào nơi an toàn (biến môi trường / secret manager). Mất thì phải [rotate](#api-quản-lý-webhook): ```json { "id": "b2f8c0a1-3d4e-4f5a-9b6c-7d8e9f0a1b2c", "url": "https://your-app.example.com/webhooks/soq", "topics": ["order.created", "order.status_updated", "tracking.updated"], "is_active": true, "secret": "whsec_ZmFrZXNlY3JldGV4YW1wbGVvbmx5bm90cmVhbHZhbHVl" } ``` ```bash export WEBHOOK_SECRET="whsec_..." # secret vừa nhận được ``` **3. Chạy một receiver** — copy một trong [3 receiver bên dưới](#receiver-hoàn-chỉnh), verify chữ ký trên **raw body** và trả `2xx` nhanh. **4. Gửi sự kiện test** — thay `:id` bằng `id` ở bước 1: ```bash curl -sS -X POST "$SOQ_BASE/v1/me/webhooks/b2f8c0a1-3d4e-4f5a-9b6c-7d8e9f0a1b2c/test" \ -H "Authorization: Bearer $SOQ_TOKEN" ``` Trả về `202 Accepted` kèm bản ghi delivery. Endpoint phải đang **active**, nếu không sẽ nhận `400 WEBHOOK_INACTIVE`. **5. Kiểm tra delivery** — xem sự kiện test đã tới `success` chưa: ```bash curl -sS "$SOQ_BASE/v1/me/webhooks/b2f8c0a1-3d4e-4f5a-9b6c-7d8e9f0a1b2c/deliveries?limit=5" \ -H "Authorization: Bearer $SOQ_TOKEN" ``` Mỗi bản ghi có `status` (`pending`/`success`/`failed`), `attempts`, `last_status_code`, `last_error` — dùng để debug ngay. > **Tip:** Toàn bộ endpoint + payload của từng event có trang reference riêng dưới section > **Webhooks** ở sidebar — xem [danh sách bên dưới](#trang-reference). --- ### Topics Chọn ít nhất 1 topic khi đăng ký. Danh sách topic hợp lệ (lấy động qua [`GET /v1/me/webhooks/topics`](/vi/api-reference/webhooks/list-webhook-topics)): | Topic | Khi nào bắn | Reference | |-------|-------------|-----------| | `order.created` | Đơn hàng được tạo. | [order.created](/vi/api-reference/webhooks/events/on-order-created) | | `order.status_updated` | Trạng thái đơn thay đổi. | [order.status_updated](/vi/api-reference/webhooks/events/on-order-status-updated) | | `order.cancelled` | Đơn hàng bị hủy. | [order.cancelled](/vi/api-reference/webhooks/events/on-order-cancelled) | | `tracking.created` | Đơn được gán mã tracking. | [tracking.created](/vi/api-reference/webhooks/events/on-tracking-created) | | `tracking.updated` | Nhận được cập nhật tracking từ nhà vận chuyển. | [tracking.updated](/vi/api-reference/webhooks/events/on-tracking-updated) | > **Note:** Ngoài ra có topic [`webhook.test`](/vi/api-reference/webhooks/events/on-webhook-test) — chỉ dùng cho sự kiện > test thủ công. Bạn **không đăng ký** được `webhook.test`; nó chỉ xuất hiện khi bạn gọi endpoint test. #### Ràng buộc URL | Rule | Chi tiết | |------|----------| | Scheme | `https://` (bắt buộc trên production). `http://` chỉ được phép ở môi trường non-production. | | Độ dài | Tối đa 2048 ký tự | | Host | Không được là `localhost`, IP private/loopback/link-local/CGNAT (chống SSRF) | | Số lượng | Tối đa **5** webhook mỗi client | URL không hợp lệ trả về `400 INVALID_WEBHOOK_URL`. Vượt quá 5 webhook trả về `400 WEBHOOK_LIMIT_REACHED`. --- ### Cấu trúc payload (envelope) Mọi sự kiện đều được bọc trong một envelope chung: ```json { "id": "e7c1a2b3-4d5e-6f70-8192-a3b4c5d6e7f8", "topic": "order.created", "created_at": "2026-09-23T08:05:12.345Z", "data": { } } ``` | Field | Kiểu | Mô tả | |-------|------|-------| | `id` | UUID | ID sự kiện. **Giống nhau** cho mọi endpoint nhận cùng một sự kiện và giữ nguyên qua các lần retry — dùng để dedupe. | | `topic` | string | Topic của sự kiện. | | `created_at` | ISO-8601 | Thời điểm sự kiện phát sinh. | | `data` | object | Nội dung sự kiện (xem trang reference của từng event). | #### Payload cho `order.*` và `tracking.*` Tất cả topic `order.*` và `tracking.*` mang cùng một shape `data` — snapshot của đơn hàng, nên bạn chỉ cần parse một cấu trúc cho mọi loại sự kiện: ```json { "id": "e7c1a2b3-4d5e-6f70-8192-a3b4c5d6e7f8", "topic": "order.created", "created_at": "2026-09-23T08:05:12.345Z", "data": { "order": { "id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9", "reference_id": "MY-ORDER-001", "store_id": "9f8e7d6c-5b4a-3210-fedc-ba9876543210", "client_status": "processing", "shipment_status": null, "created_at": "2026-09-23T08:05:12.000Z", "updated_at": "2026-09-23T08:05:12.000Z" }, "tracking": null } } ``` | Field | Kiểu | Mô tả | |-------|------|-------| | `data.order.id` | UUID | ID đơn hàng nội bộ. | | `data.order.reference_id` | string | Reference ID bạn gửi khi tạo đơn. | | `data.order.store_id` | UUID | Store của đơn. | | `data.order.client_status` | string \| null | Trạng thái hiển thị cho client, `null` khi chưa có. | | `data.order.shipment_status` | string \| null | Trạng thái giao hàng suy ra từ tracking, `null` khi chưa ship. | | `data.order.created_at` | ISO-8601 | Thời điểm tạo đơn. | | `data.order.updated_at` | ISO-8601 | Thời điểm cập nhật gần nhất. | | `data.tracking` | object \| null | Snapshot tracking, `null` khi đơn chưa có mã tracking. | Khi đơn đã có tracking, `data.tracking` là: ```json { "data": { "order": { "id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9", "reference_id": "MY-ORDER-001", "store_id": "9f8e7d6c-5b4a-3210-fedc-ba9876543210", "client_status": "shipping", "shipment_status": "in_transit", "created_at": "2026-09-23T08:05:12.000Z", "updated_at": "2026-09-24T10:20:00.000Z" }, "tracking": { "tracking_number": "9400111899223456789012", "carrier": "USPS", "tracking_url": "https://tools.usps.com/go/TrackConfirmAction?tLabels=9400111899223456789012", "shipment_status": "in_transit" } } } ``` #### Field `previous` Với các topic dạng `*_updated` ([`order.status_updated`](/vi/api-reference/webhooks/events/on-order-status-updated), [`tracking.updated`](/vi/api-reference/webhooks/events/on-tracking-updated)), payload có thêm `data.previous` chứa các field đã thay đổi: ```json { "topic": "order.status_updated", "data": { "order": { "client_status": "shipping" }, "tracking": null, "previous": { "client_status": "processing" } } } ``` #### Payload `webhook.test` Sự kiện test có `data` riêng (xem [reference](/vi/api-reference/webhooks/events/on-webhook-test)): ```json { "id": "11112222-3333-4444-5555-666677778888", "topic": "webhook.test", "created_at": "2026-09-23T08:10:00.000Z", "data": { "message": "This is a test event from Shopify Order Queue.", "webhook_id": "b2f8c0a1-3d4e-4f5a-9b6c-7d8e9f0a1b2c" } } ``` --- ### Headers Mỗi request delivery mang các header sau: | Header | Ví dụ | Mô tả | |--------|-------|-------| | `Content-Type` | `application/json` | Body luôn là JSON. | | `User-Agent` | `ShopifyOrderQueue-Webhook/1.0` | Nhận diện request của chúng tôi. | | `X-Webhook-Id` | `e7c1a2b3-...` | ID sự kiện (bằng `id` trong envelope). Dùng để **dedupe**. | | `X-Webhook-Topic` | `order.created` | Topic của sự kiện. | | `X-Webhook-Timestamp` | `1758614712` | Unix timestamp (giây) lúc ký. | | `X-Webhook-Signature` | `sha256=` | HMAC-SHA256 của payload (xem bên dưới). | --- ### Xác thực chữ ký Chữ ký được tính bằng **HMAC-SHA256** với key là **signing secret**, trên chuỗi ghép `"."`: ``` signature = "sha256=" + HMAC_SHA256(secret, `${timestamp}.${rawBody}`).hex() ``` Trong đó: - `timestamp` = giá trị header `X-Webhook-Timestamp` (unix giây). - `rawBody` = **đúng byte thô** của request body (chưa qua parse/re-serialize). Đừng parse rồi stringify lại — thứ tự key có thể đổi và chữ ký sẽ không khớp. - Kết quả so sánh với header `X-Webhook-Signature` bằng **so sánh hằng thời gian** (constant-time). > **Warning:** Luôn xác thực chữ ký **trên raw body** trước khi parse JSON. Trong Express, dùng > `express.raw()` cho route webhook, hoặc lưu raw body bằng `verify` callback của > `express.json()`. Trong FastAPI, dùng `await request.body()`. Trong PHP, đọc > `file_get_contents('php://input')`. --- ### Receiver hoàn chỉnh Ba receiver dưới đây chạy được ngay: verify chữ ký trên **raw body**, kiểm tra timestamp trong **5 phút**, dedupe theo `X-Webhook-Id`, rồi trả `2xx` **nhanh** trước khi xử lý nặng. #### Node.js / Express ```js import express from 'express' import crypto from 'crypto' const app = express() const SECRET = process.env.WEBHOOK_SECRET // whsec_... const TOLERANCE_SEC = 300 // 5 phút const seen = new Set() // production: dùng Redis/DB, không dùng Set trong RAM function verify(headers, rawBody) { const timestamp = headers['x-webhook-timestamp'] const signature = headers['x-webhook-signature'] if (!timestamp || !signature) return false // Chống replay: từ chối request quá cũ. const age = Math.floor(Date.now() / 1000) - Number(timestamp) if (!Number.isFinite(age) || age > TOLERANCE_SEC) return false const body = Buffer.isBuffer(rawBody) ? rawBody.toString('utf8') : rawBody const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(`${timestamp}.${body}`).digest('hex') const a = Buffer.from(signature) const b = Buffer.from(expected) // timingSafeEqual ném lỗi nếu độ dài khác nhau — check trước. return a.length === b.length && crypto.timingSafeEqual(a, b) } // QUAN TRỌNG: express.raw() để có đúng byte thô, không phải express.json(). app.post('/webhooks/soq', express.raw({ type: '*/*' }), (req, res) => { if (!verify(req.headers, req.body)) { return res.status(401).send('invalid signature') } const eventId = req.headers['x-webhook-id'] if (seen.has(eventId)) return res.sendStatus(200) // đã xử lý — bỏ qua seen.add(eventId) const event = JSON.parse(req.body.toString('utf8')) // Trả 2xx NGAY, đẩy xử lý nặng sang hàng đợi nền. res.sendStatus(200) queueMicrotask(() => handleEvent(event)) }) function handleEvent(event) { switch (event.topic) { case 'order.created': case 'order.status_updated': console.log('order', event.data.order.reference_id, event.data.order.client_status) break case 'tracking.updated': console.log('tracking', event.data.tracking?.tracking_number) break } } app.listen(3000) ``` #### Python / FastAPI ```python import hashlib import hmac import json import time from fastapi import FastAPI, Request, Response app = FastAPI() SECRET = "whsec_..." # đọc từ env trong thực tế TOLERANCE_SEC = 300 # 5 phút seen: set[str] = set() # production: dùng Redis/DB def verify(headers, raw_body: bytes) -> bool: timestamp = headers.get("x-webhook-timestamp") signature = headers.get("x-webhook-signature") if not timestamp or not signature: return False # Chống replay: từ chối request quá cũ. try: age = int(time.time()) - int(timestamp) except ValueError: return False if age > TOLERANCE_SEC: return False signed = f"{timestamp}.".encode("utf-8") + raw_body digest = hmac.new(SECRET.encode("utf-8"), signed, hashlib.sha256).hexdigest() expected = f"sha256={digest}" # So sánh hằng thời gian. return hmac.compare_digest(signature, expected) @app.post("/webhooks/soq") async def receive(request: Request): raw_body = await request.body() # đúng byte thô, chưa parse if not verify(request.headers, raw_body): return Response(status_code=401, content="invalid signature") event_id = request.headers.get("x-webhook-id") if event_id in seen: return Response(status_code=200) # đã xử lý — bỏ qua seen.add(event_id) event = json.loads(raw_body) # Trả 2xx nhanh; đẩy xử lý nặng sang background task / hàng đợi. handle_event(event) return Response(status_code=200) def handle_event(event: dict) -> None: topic = event["topic"] if topic in ("order.created", "order.status_updated"): order = event["data"]["order"] print("order", order["reference_id"], order["client_status"]) elif topic == "tracking.updated": tracking = event["data"].get("tracking") or {} print("tracking", tracking.get("tracking_number")) ``` #### PHP (plain) ```php $tolerance) { return false; } $expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); // So sánh hằng thời gian. return hash_equals($expected, $signature); } if (!verify($secret, $timestamp, $signature, $rawBody, $toleranceSec)) { http_response_code(401); echo 'invalid signature'; exit; } // Dedupe theo X-Webhook-Id (ví dụ đơn giản: file lock; production dùng Redis/DB). $marker = sys_get_temp_dir() . '/soq_wh_' . preg_replace('/[^a-zA-Z0-9_-]/', '', $eventId); if ($eventId !== '' && file_exists($marker)) { http_response_code(200); // đã xử lý — bỏ qua exit; } if ($eventId !== '') { touch($marker); } // Trả 2xx NGAY; đẩy xử lý nặng ra tiến trình nền (queue/cron). http_response_code(200); if (function_exists('fastcgi_finish_request')) { fastcgi_finish_request(); } $event = json_decode($rawBody, true); switch ($event['topic'] ?? '') { case 'order.created': case 'order.status_updated': error_log('order ' . $event['data']['order']['reference_id'] . ' ' . $event['data']['order']['client_status']); break; case 'tracking.updated': error_log('tracking ' . ($event['data']['tracking']['tracking_number'] ?? '')); break; } ``` > **Note:** Nếu bạn **rotate secret**, hệ thống dùng secret mới cho các delivery kế tiếp. Trong > giai đoạn chuyển đổi, hãy chấp nhận cả secret cũ và mới cho tới khi chắc chắn không còn > delivery ký bằng secret cũ. --- ### Local testing URL đăng ký **phải là https public** — hệ thống từ chối `localhost` và mọi IP private/loopback/link-local/CGNAT (chống SSRF), nên trỏ thẳng vào máy dev sẽ nhận `400 INVALID_WEBHOOK_URL`. Cách chuẩn khi phát triển: expose port local ra một URL https tạm thời rồi đăng ký URL đó. #### Cloudflare Tunnel (`cloudflared`) ```bash # receiver đang chạy ở localhost:3000 cloudflared tunnel --url http://localhost:3000 # → in ra một URL dạng https://.trycloudflare.com ``` Dùng URL `https://.trycloudflare.com/webhooks/soq` khi `POST /v1/me/webhooks`. #### ngrok ```bash ngrok http 3000 # → Forwarding https://.ngrok-free.app -> http://localhost:3000 ``` Dùng URL `https://.ngrok-free.app/webhooks/soq` khi đăng ký. > **Tip:** URL tunnel đổi mỗi lần chạy lại. Sau khi khởi động lại tunnel, cập nhật webhook bằng > `PATCH /v1/me/webhooks/:id` với `url` mới thay vì tạo webhook mới (tránh chạm giới hạn 5). > Rồi gọi lại `POST /v1/me/webhooks/:id/test` để xác nhận. --- ### Yêu cầu response - Trả về HTTP **`2xx`** để báo đã nhận thành công. - Trả về trong vòng **10 giây** — quá thời gian này request bị coi là timeout và sẽ retry. - **Redirect không được follow** — endpoint phải trả về trực tiếp, không `3xx`. - Xử lý nặng nên đẩy sang hàng đợi nền; chỉ cần xác thực + lưu event rồi trả `2xx` ngay. --- ### Retry và auto-disable Nếu một delivery thất bại (timeout, lỗi kết nối, hoặc status không phải `2xx`), nó được thử lại theo lịch backoff cố định: | Lần thử | Thời gian chờ trước lần thử này | |:-------:|--------------------------------| | 1 | (gửi ngay) | | 2 | 1 phút | | 3 | 5 phút | | 4 | 30 phút | | 5 | 2 giờ | | 6 | 6 giờ | | 7 | 12 giờ | Tổng cộng **7 lần thử** (1 lần đầu + 6 lần retry). Sau lần thứ 7 vẫn thất bại, delivery được đánh `failed` và không thử lại nữa. **Auto-disable:** nếu một endpoint có **10 delivery thất bại liên tiếp**, nó tự động bị vô hiệu hóa (`is_active: false`, `disabled_reason: "too_many_failures"`). Một delivery thành công sẽ reset bộ đếm này về 0. Khi endpoint bị vô hiệu hóa, mọi delivery đang chờ retry sẽ **bị hủy** (không gửi nữa, `last_error: "webhook_disabled"`) — chúng không tính vào chuỗi thất bại. Để bật lại, gọi [`PATCH /v1/me/webhooks/:id`](/vi/api-reference/webhooks/update-webhook) với `{ "is_active": true }`. Việc này reset `consecutive_failures` về 0 và xóa `disabled_reason`. > **Note:** Log delivery được lưu **30 ngày** rồi tự xóa. --- ### Idempotency Cùng một sự kiện có thể được gửi **nhiều lần** (do retry, hoặc bạn đăng ký nhiều endpoint). Mọi lần gửi của cùng một sự kiện đều mang **cùng `X-Webhook-Id`** (bằng `id` trong envelope). > **Important:** Dedupe theo `X-Webhook-Id`. Lưu lại các event id đã xử lý và bỏ qua id trùng để > tránh xử lý hai lần (như trong ba receiver ở trên). --- ### API quản lý webhook Tất cả endpoint dưới đây yêu cầu header `Authorization: Bearer `. | Method | Endpoint | Reference | Mô tả | |--------|----------|-----------|-------| | `GET` | `/v1/me/webhooks/topics` | [list-webhook-topics](/vi/api-reference/webhooks/list-webhook-topics) | Danh sách topic hợp lệ + mô tả. | | `GET` | `/v1/me/webhooks` | [list-webhooks](/vi/api-reference/webhooks/list-webhooks) | Liệt kê webhook của client (không kèm secret). | | `POST` | `/v1/me/webhooks` | [create-webhook](/vi/api-reference/webhooks/create-webhook) | Đăng ký webhook mới. Trả `201` kèm `secret` (một lần). | | `PATCH` | `/v1/me/webhooks/:id` | [update-webhook](/vi/api-reference/webhooks/update-webhook) | Cập nhật `url`, `topics`, `description`, `is_active`. | | `DELETE` | `/v1/me/webhooks/:id` | [delete-webhook](/vi/api-reference/webhooks/delete-webhook) | Xóa webhook. Trả `204`. | | `POST` | `/v1/me/webhooks/:id/rotate-secret` | [rotate-webhook-secret](/vi/api-reference/webhooks/rotate-webhook-secret) | Cấp signing secret mới. Trả `secret` (một lần). | | `POST` | `/v1/me/webhooks/:id/test` | [test-webhook](/vi/api-reference/webhooks/test-webhook) | Gửi sự kiện `webhook.test`. Trả `202`. | | `GET` | `/v1/me/webhooks/:id/deliveries` | [list-webhook-deliveries](/vi/api-reference/webhooks/list-webhook-deliveries) | Lịch sử delivery của webhook. Phân trang: `page` (mặc định 1), `limit` (mặc định 20, tối đa 100). | | `POST` | `/v1/me/webhooks/deliveries/:delivery_id/redeliver` | [redeliver-webhook-delivery](/vi/api-reference/webhooks/redeliver-webhook-delivery) | Gửi lại một delivery từ đầu. Trả `202`. | #### Cập nhật webhook ```json PATCH /v1/me/webhooks/b2f8c0a1-3d4e-4f5a-9b6c-7d8e9f0a1b2c { "topics": ["order.created", "order.cancelled"], "is_active": true } ``` Truyền `is_active: true` trên một endpoint đang bị vô hiệu hóa sẽ bật lại nó và reset chuỗi thất bại. #### Xem lịch sử delivery ```bash GET /v1/me/webhooks/:id/deliveries?page=1&limit=20 ``` Mỗi bản ghi delivery gồm `status` (`pending`/`success`/`failed`), `attempts`, `last_status_code`, `last_error`, `next_retry_at`, `delivered_at` và cả `payload` đã gửi — hữu ích để debug. --- ### Troubleshooting | HTTP / Trạng thái | Mã / dấu hiệu | Nguyên nhân & cách xử lý | |-------------------|---------------|--------------------------| | 400 | `INVALID_WEBHOOK_URL` | URL sai scheme, quá 2048 ký tự, hoặc trỏ tới host private/`localhost`/loopback/CGNAT. Dùng https public; khi dev thì [expose bằng cloudflared/ngrok](#local-testing). | | 400 | `WEBHOOK_LIMIT_REACHED` | Đã đạt tối đa **5** webhook mỗi client. Xóa (`DELETE`) hoặc cập nhật (`PATCH`) webhook cũ thay vì tạo mới. | | 400 | `WEBHOOK_INACTIVE` | Gọi `/test` (hoặc endpoint bị auto-disable) trên webhook đang `is_active: false`. Bật lại bằng `PATCH { "is_active": true }` rồi test lại. | | — | `disabled_reason: "too_many_failures"` | Endpoint bị auto-disable sau **10 delivery thất bại liên tiếp**. Sửa receiver, rồi `PATCH { "is_active": true }` để reset `consecutive_failures` và xóa `disabled_reason`. | | — | delivery `last_error: "webhook_disabled"` | Delivery bị hủy vì endpoint đã bị vô hiệu hóa lúc tới lượt gửi. Không tính vào chuỗi thất bại; bật lại endpoint rồi `redeliver` nếu cần. | | 401 (phía bạn) | signature mismatch | (1) Verify trên **body đã parse/re-serialize** thay vì raw body — luôn ký/verify trên byte thô. (2) Dùng **sai secret** sau khi rotate — cập nhật secret mới, chấp nhận cả cũ + mới trong giai đoạn chuyển. (3) **Lệch đồng hồ** khiến timestamp quá 5 phút — đồng bộ NTP hoặc nới `tolerance`. (4) Quên ghép `"."` trước raw body. | | timeout / retry | delivery `status: failed`, `attempts` tăng | Receiver trả chậm hơn **10 giây** hoặc không `2xx`. Trả `2xx` ngay rồi xử lý nền; kiểm tra `GET /:id/deliveries` xem `last_status_code`/`last_error`. | | 404 | (not found) | Webhook/delivery id không tồn tại hoặc không thuộc client của bạn. | --- ### Trang reference - **Endpoints:** [list-webhook-topics](/vi/api-reference/webhooks/list-webhook-topics) · [list-webhooks](/vi/api-reference/webhooks/list-webhooks) · [create-webhook](/vi/api-reference/webhooks/create-webhook) · [update-webhook](/vi/api-reference/webhooks/update-webhook) · [delete-webhook](/vi/api-reference/webhooks/delete-webhook) · [rotate-webhook-secret](/vi/api-reference/webhooks/rotate-webhook-secret) · [test-webhook](/vi/api-reference/webhooks/test-webhook) · [list-webhook-deliveries](/vi/api-reference/webhooks/list-webhook-deliveries) · [redeliver-webhook-delivery](/vi/api-reference/webhooks/redeliver-webhook-delivery) - **Events (payload từng topic):** [order.created](/vi/api-reference/webhooks/events/on-order-created) · [order.status_updated](/vi/api-reference/webhooks/events/on-order-status-updated) · [order.cancelled](/vi/api-reference/webhooks/events/on-order-cancelled) · [tracking.created](/vi/api-reference/webhooks/events/on-tracking-created) · [tracking.updated](/vi/api-reference/webhooks/events/on-tracking-updated) · [webhook.test](/vi/api-reference/webhooks/events/on-webhook-test) --- ### Checklist tích hợp 1. Đăng ký endpoint và lưu `secret` (chỉ hiện một lần). 2. Xác thực `X-Webhook-Signature` trên **raw body** trước khi parse. 3. Từ chối request có `X-Webhook-Timestamp` cũ hơn 5 phút. 4. Dedupe theo `X-Webhook-Id`. 5. Trả `2xx` trong vòng 10 giây; đẩy xử lý nặng sang hàng đợi nền. 6. Gửi sự kiện test để xác nhận endpoint hoạt động. 7. Theo dõi lịch sử delivery và bật lại endpoint nếu bị auto-disable. --- ## Webhook Events The system sends these events to your registered webhook endpoints. ### order.created **order.created** An order was created. We POST a signed `WebhookEventEnvelope` with `topic: "order.created"` to your registered endpoint. **Headers sent with this event:** - Content-Type: string - User-Agent: string - X-Webhook-Id: string — The event id (matches the envelope `id`); stable across retries. - X-Webhook-Topic: string - X-Webhook-Timestamp: string — Unix seconds; part of the signed string. Reject stale requests. - X-Webhook-Signature: string — HMAC-SHA256 over `"."` using your signing secret, hex-encoded and prefixed `sha256=`. **Payload Structure:** - id: string (uuid) (required) — Event id — identical across every webhook that receives this event; use it to dedupe retries. - topic: string (required) (example: "order.created") - created_at: string (date-time) (required) — ISO-8601 timestamp of when the event was emitted. - data: object (required) (oneOf: this is one of 2 possible schemas) - order: object (required) - id: string (uuid) (required) - reference_id: string (required) (example: "REF-10023") - store_id: string (uuid) (required) - client_status: string | null (required) — Client-facing status key, or null when none is set yet. (example: "shipping") - shipment_status: string | null (required) — Shipment status derived from carrier tracking, or null when not shipping yet. (example: "in_transit") - created_at: string (date-time) (required) - updated_at: string (date-time) (required) - tracking: object (required) - tracking_number: string | null (required) (example: "1Z999AA10123456784") - carrier: string | null (required) (example: "UPS") - tracking_url: string (uri) | null (required) (example: "https://www.ups.com/track?tracknum=1Z999AA10123456784") - shipment_status: string | null (required) (example: "in_transit") - previous: object (optional) — Only present on `*_updated` topics — the subset of fields that changed. **Example Payload:** ```json { "id": "11111111-1111-1111-1111-111111111111", "topic": "order.created", "created_at": "2026-01-01T00:00:00.000Z", "data": { "order": { "id": "33333333-3333-3333-3333-333333333333", "reference_id": "REF-10023", "store_id": "44444444-4444-4444-4444-444444444444", "client_status": "shipping", "shipment_status": "in_transit", "created_at": "2026-01-01T00:00:00.000Z", "updated_at": "2026-01-01T00:05:00.000Z" }, "tracking": null } } ``` **HMAC Signature Verification:** ``` signature = "sha256=" + HMAC_SHA256(secret, timestamp + "." + rawBody).hex() ``` Where: - `secret`: Your webhook signing secret (`whsec_*`) - `timestamp`: Value of `X-Webhook-Timestamp` header (unix seconds) - `rawBody`: The raw request body bytes (do NOT parse and re-serialize) Compare with `X-Webhook-Signature` header using constant-time comparison. --- ### order.status_updated **order.status_updated** An order's status changed. We POST a signed `WebhookEventEnvelope` with `topic: "order.status_updated"` to your registered endpoint. **Headers sent with this event:** - Content-Type: string - User-Agent: string - X-Webhook-Id: string — The event id (matches the envelope `id`); stable across retries. - X-Webhook-Topic: string - X-Webhook-Timestamp: string — Unix seconds; part of the signed string. Reject stale requests. - X-Webhook-Signature: string — HMAC-SHA256 over `"."` using your signing secret, hex-encoded and prefixed `sha256=`. **Payload Structure:** - id: string (uuid) (required) — Event id — identical across every webhook that receives this event; use it to dedupe retries. - topic: string (required) (example: "order.created") - created_at: string (date-time) (required) — ISO-8601 timestamp of when the event was emitted. - data: object (required) (oneOf: this is one of 2 possible schemas) - order: object (required) - id: string (uuid) (required) - reference_id: string (required) (example: "REF-10023") - store_id: string (uuid) (required) - client_status: string | null (required) — Client-facing status key, or null when none is set yet. (example: "shipping") - shipment_status: string | null (required) — Shipment status derived from carrier tracking, or null when not shipping yet. (example: "in_transit") - created_at: string (date-time) (required) - updated_at: string (date-time) (required) - tracking: object (required) - tracking_number: string | null (required) (example: "1Z999AA10123456784") - carrier: string | null (required) (example: "UPS") - tracking_url: string (uri) | null (required) (example: "https://www.ups.com/track?tracknum=1Z999AA10123456784") - shipment_status: string | null (required) (example: "in_transit") - previous: object (optional) — Only present on `*_updated` topics — the subset of fields that changed. **Example Payload:** ```json { "id": "11111111-1111-1111-1111-111111111111", "topic": "order.status_updated", "created_at": "2026-01-01T00:00:00.000Z", "data": { "order": { "id": "33333333-3333-3333-3333-333333333333", "reference_id": "REF-10023", "store_id": "44444444-4444-4444-4444-444444444444", "client_status": "shipping", "shipment_status": "in_transit", "created_at": "2026-01-01T00:00:00.000Z", "updated_at": "2026-01-01T00:05:00.000Z" }, "tracking": null } } ``` **HMAC Signature Verification:** ``` signature = "sha256=" + HMAC_SHA256(secret, timestamp + "." + rawBody).hex() ``` Where: - `secret`: Your webhook signing secret (`whsec_*`) - `timestamp`: Value of `X-Webhook-Timestamp` header (unix seconds) - `rawBody`: The raw request body bytes (do NOT parse and re-serialize) Compare with `X-Webhook-Signature` header using constant-time comparison. --- ### order.cancelled **order.cancelled** An order was cancelled. We POST a signed `WebhookEventEnvelope` with `topic: "order.cancelled"` to your registered endpoint. **Headers sent with this event:** - Content-Type: string - User-Agent: string - X-Webhook-Id: string — The event id (matches the envelope `id`); stable across retries. - X-Webhook-Topic: string - X-Webhook-Timestamp: string — Unix seconds; part of the signed string. Reject stale requests. - X-Webhook-Signature: string — HMAC-SHA256 over `"."` using your signing secret, hex-encoded and prefixed `sha256=`. **Payload Structure:** - id: string (uuid) (required) — Event id — identical across every webhook that receives this event; use it to dedupe retries. - topic: string (required) (example: "order.created") - created_at: string (date-time) (required) — ISO-8601 timestamp of when the event was emitted. - data: object (required) (oneOf: this is one of 2 possible schemas) - order: object (required) - id: string (uuid) (required) - reference_id: string (required) (example: "REF-10023") - store_id: string (uuid) (required) - client_status: string | null (required) — Client-facing status key, or null when none is set yet. (example: "shipping") - shipment_status: string | null (required) — Shipment status derived from carrier tracking, or null when not shipping yet. (example: "in_transit") - created_at: string (date-time) (required) - updated_at: string (date-time) (required) - tracking: object (required) - tracking_number: string | null (required) (example: "1Z999AA10123456784") - carrier: string | null (required) (example: "UPS") - tracking_url: string (uri) | null (required) (example: "https://www.ups.com/track?tracknum=1Z999AA10123456784") - shipment_status: string | null (required) (example: "in_transit") - previous: object (optional) — Only present on `*_updated` topics — the subset of fields that changed. **Example Payload:** ```json { "id": "11111111-1111-1111-1111-111111111111", "topic": "order.cancelled", "created_at": "2026-01-01T00:00:00.000Z", "data": { "order": { "id": "33333333-3333-3333-3333-333333333333", "reference_id": "REF-10023", "store_id": "44444444-4444-4444-4444-444444444444", "client_status": "shipping", "shipment_status": "in_transit", "created_at": "2026-01-01T00:00:00.000Z", "updated_at": "2026-01-01T00:05:00.000Z" }, "tracking": null } } ``` **HMAC Signature Verification:** ``` signature = "sha256=" + HMAC_SHA256(secret, timestamp + "." + rawBody).hex() ``` Where: - `secret`: Your webhook signing secret (`whsec_*`) - `timestamp`: Value of `X-Webhook-Timestamp` header (unix seconds) - `rawBody`: The raw request body bytes (do NOT parse and re-serialize) Compare with `X-Webhook-Signature` header using constant-time comparison. --- ### tracking.created **tracking.created** A tracking number was assigned to an order. We POST a signed `WebhookEventEnvelope` with `topic: "tracking.created"` to your registered endpoint. **Headers sent with this event:** - Content-Type: string - User-Agent: string - X-Webhook-Id: string — The event id (matches the envelope `id`); stable across retries. - X-Webhook-Topic: string - X-Webhook-Timestamp: string — Unix seconds; part of the signed string. Reject stale requests. - X-Webhook-Signature: string — HMAC-SHA256 over `"."` using your signing secret, hex-encoded and prefixed `sha256=`. **Payload Structure:** - id: string (uuid) (required) — Event id — identical across every webhook that receives this event; use it to dedupe retries. - topic: string (required) (example: "order.created") - created_at: string (date-time) (required) — ISO-8601 timestamp of when the event was emitted. - data: object (required) (oneOf: this is one of 2 possible schemas) - order: object (required) - id: string (uuid) (required) - reference_id: string (required) (example: "REF-10023") - store_id: string (uuid) (required) - client_status: string | null (required) — Client-facing status key, or null when none is set yet. (example: "shipping") - shipment_status: string | null (required) — Shipment status derived from carrier tracking, or null when not shipping yet. (example: "in_transit") - created_at: string (date-time) (required) - updated_at: string (date-time) (required) - tracking: object (required) - tracking_number: string | null (required) (example: "1Z999AA10123456784") - carrier: string | null (required) (example: "UPS") - tracking_url: string (uri) | null (required) (example: "https://www.ups.com/track?tracknum=1Z999AA10123456784") - shipment_status: string | null (required) (example: "in_transit") - previous: object (optional) — Only present on `*_updated` topics — the subset of fields that changed. **Example Payload:** ```json { "id": "11111111-1111-1111-1111-111111111111", "topic": "tracking.created", "created_at": "2026-01-01T00:00:00.000Z", "data": { "order": { "id": "33333333-3333-3333-3333-333333333333", "reference_id": "REF-10023", "store_id": "44444444-4444-4444-4444-444444444444", "client_status": "shipping", "shipment_status": "in_transit", "created_at": "2026-01-01T00:00:00.000Z", "updated_at": "2026-01-01T00:05:00.000Z" }, "tracking": { "tracking_number": "1Z999AA10123456784", "carrier": "UPS", "tracking_url": "https://www.ups.com/track?tracknum=1Z999AA10123456784", "shipment_status": "in_transit" } } } ``` **HMAC Signature Verification:** ``` signature = "sha256=" + HMAC_SHA256(secret, timestamp + "." + rawBody).hex() ``` Where: - `secret`: Your webhook signing secret (`whsec_*`) - `timestamp`: Value of `X-Webhook-Timestamp` header (unix seconds) - `rawBody`: The raw request body bytes (do NOT parse and re-serialize) Compare with `X-Webhook-Signature` header using constant-time comparison. --- ### tracking.updated **tracking.updated** A shipment tracking update was received. We POST a signed `WebhookEventEnvelope` with `topic: "tracking.updated"` to your registered endpoint. **Headers sent with this event:** - Content-Type: string - User-Agent: string - X-Webhook-Id: string — The event id (matches the envelope `id`); stable across retries. - X-Webhook-Topic: string - X-Webhook-Timestamp: string — Unix seconds; part of the signed string. Reject stale requests. - X-Webhook-Signature: string — HMAC-SHA256 over `"."` using your signing secret, hex-encoded and prefixed `sha256=`. **Payload Structure:** - id: string (uuid) (required) — Event id — identical across every webhook that receives this event; use it to dedupe retries. - topic: string (required) (example: "order.created") - created_at: string (date-time) (required) — ISO-8601 timestamp of when the event was emitted. - data: object (required) (oneOf: this is one of 2 possible schemas) - order: object (required) - id: string (uuid) (required) - reference_id: string (required) (example: "REF-10023") - store_id: string (uuid) (required) - client_status: string | null (required) — Client-facing status key, or null when none is set yet. (example: "shipping") - shipment_status: string | null (required) — Shipment status derived from carrier tracking, or null when not shipping yet. (example: "in_transit") - created_at: string (date-time) (required) - updated_at: string (date-time) (required) - tracking: object (required) - tracking_number: string | null (required) (example: "1Z999AA10123456784") - carrier: string | null (required) (example: "UPS") - tracking_url: string (uri) | null (required) (example: "https://www.ups.com/track?tracknum=1Z999AA10123456784") - shipment_status: string | null (required) (example: "in_transit") - previous: object (optional) — Only present on `*_updated` topics — the subset of fields that changed. **Example Payload:** ```json { "id": "11111111-1111-1111-1111-111111111111", "topic": "tracking.updated", "created_at": "2026-01-01T00:00:00.000Z", "data": { "order": { "id": "33333333-3333-3333-3333-333333333333", "reference_id": "REF-10023", "store_id": "44444444-4444-4444-4444-444444444444", "client_status": "shipping", "shipment_status": "in_transit", "created_at": "2026-01-01T00:00:00.000Z", "updated_at": "2026-01-01T00:05:00.000Z" }, "tracking": { "tracking_number": "1Z999AA10123456784", "carrier": "UPS", "tracking_url": "https://www.ups.com/track?tracknum=1Z999AA10123456784", "shipment_status": "in_transit" } } } ``` **HMAC Signature Verification:** ``` signature = "sha256=" + HMAC_SHA256(secret, timestamp + "." + rawBody).hex() ``` Where: - `secret`: Your webhook signing secret (`whsec_*`) - `timestamp`: Value of `X-Webhook-Timestamp` header (unix seconds) - `rawBody`: The raw request body bytes (do NOT parse and re-serialize) Compare with `X-Webhook-Signature` header using constant-time comparison. --- ### webhook.test **webhook.test** A manual test ping triggered via the test endpoint. We POST a signed `WebhookEventEnvelope` with `topic: "webhook.test"` to your registered endpoint. **Headers sent with this event:** - Content-Type: string - User-Agent: string - X-Webhook-Id: string — The event id (matches the envelope `id`); stable across retries. - X-Webhook-Topic: string - X-Webhook-Timestamp: string — Unix seconds; part of the signed string. Reject stale requests. - X-Webhook-Signature: string — HMAC-SHA256 over `"."` using your signing secret, hex-encoded and prefixed `sha256=`. **Payload Structure:** - id: string (uuid) (required) — Event id — identical across every webhook that receives this event; use it to dedupe retries. - topic: string (required) (example: "order.created") - created_at: string (date-time) (required) — ISO-8601 timestamp of when the event was emitted. - data: object (required) (oneOf: this is one of 2 possible schemas) - order: object (required) - id: string (uuid) (required) - reference_id: string (required) (example: "REF-10023") - store_id: string (uuid) (required) - client_status: string | null (required) — Client-facing status key, or null when none is set yet. (example: "shipping") - shipment_status: string | null (required) — Shipment status derived from carrier tracking, or null when not shipping yet. (example: "in_transit") - created_at: string (date-time) (required) - updated_at: string (date-time) (required) - tracking: object (required) - tracking_number: string | null (required) (example: "1Z999AA10123456784") - carrier: string | null (required) (example: "UPS") - tracking_url: string (uri) | null (required) (example: "https://www.ups.com/track?tracknum=1Z999AA10123456784") - shipment_status: string | null (required) (example: "in_transit") - previous: object (optional) — Only present on `*_updated` topics — the subset of fields that changed. **Example Payload:** ```json { "id": "11111111-1111-1111-1111-111111111111", "topic": "webhook.test", "created_at": "2026-01-01T00:00:00.000Z", "data": { "message": "This is a test event from Shopify Order Queue.", "webhook_id": "22222222-2222-2222-2222-222222222222" } } ``` **HMAC Signature Verification:** ``` signature = "sha256=" + HMAC_SHA256(secret, timestamp + "." + rawBody).hex() ``` Where: - `secret`: Your webhook signing secret (`whsec_*`) - `timestamp`: Value of `X-Webhook-Timestamp` header (unix seconds) - `rawBody`: The raw request body bytes (do NOT parse and re-serialize) Compare with `X-Webhook-Signature` header using constant-time comparison. ---