Linkable
Webhooks

Webhooks

Đăng ký endpoint để nhận sự kiện order/tracking theo thời gian thực — quickstart, receiver mẫu, xác thực chữ ký, local testing, retry và troubleshooting.

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:

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 nếu đang chạy máy local):

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:

{
  "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"
}
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, 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:

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:

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.


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):

TopicKhi nào bắnReference
order.createdĐơn hàng được tạo.order.created
order.status_updatedTrạng thái đơn thay đổi.order.status_updated
order.cancelledĐơn hàng bị hủy.order.cancelled
tracking.createdĐơn được gán mã tracking.tracking.created
tracking.updatedNhận được cập nhật tracking từ nhà vận chuyển.tracking.updated

Note: Ngoài ra có topic 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

RuleChi tiết
Schemehttps:// (bắt buộc trên production). http:// chỉ được phép ở môi trường non-production.
Độ dàiTối đa 2048 ký tự
HostKhông được là localhost, IP private/loopback/link-local/CGNAT (chống SSRF)
Số lượngTố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:

{
  "id": "e7c1a2b3-4d5e-6f70-8192-a3b4c5d6e7f8",
  "topic": "order.created",
  "created_at": "2026-09-23T08:05:12.345Z",
  "data": { }
}
FieldKiểuMô tả
idUUIDID 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.
topicstringTopic của sự kiện.
created_atISO-8601Thời điểm sự kiện phát sinh.
dataobjectNộ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:

{
  "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
  }
}
FieldKiểuMô tả
data.order.idUUIDID đơn hàng nội bộ.
data.order.reference_idstringReference ID bạn gửi khi tạo đơn.
data.order.store_idUUIDStore của đơn.
data.order.client_statusstring | nullTrạng thái hiển thị cho client, null khi chưa có.
data.order.shipment_statusstring | nullTrạng thái giao hàng suy ra từ tracking, null khi chưa ship.
data.order.created_atISO-8601Thời điểm tạo đơn.
data.order.updated_atISO-8601Thời điểm cập nhật gần nhất.
data.trackingobject | nullSnapshot tracking, null khi đơn chưa có mã tracking.

Khi đơn đã có tracking, data.tracking là:

{
  "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, tracking.updated), payload có thêm data.previous chứa các field đã thay đổi:

{
  "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):

{
  "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:

HeaderVí dụMô tả
Content-Typeapplication/jsonBody luôn là JSON.
User-AgentShopifyOrderQueue-Webhook/1.0Nhận diện request của chúng tôi.
X-Webhook-Ide7c1a2b3-...ID sự kiện (bằng id trong envelope). Dùng để dedupe.
X-Webhook-Topicorder.createdTopic của sự kiện.
X-Webhook-Timestamp1758614712Unix timestamp (giây) lúc ký.
X-Webhook-Signaturesha256=<hex>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 "<timestamp>.<raw body>":

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

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

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
// webhook.php — trỏ endpoint của bạn tới file này.
$secret        = getenv('WEBHOOK_SECRET'); // whsec_...
$toleranceSec  = 300;                      // 5 phút

$rawBody   = file_get_contents('php://input');           // đúng byte thô
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$eventId   = $_SERVER['HTTP_X_WEBHOOK_ID'] ?? '';

function verify(string $secret, string $timestamp, string $signature, string $rawBody, int $tolerance): bool
{
    if ($timestamp === '' || $signature === '') {
        return false;
    }
    // Chống replay: từ chối request quá cũ.
    if (time() - (int) $timestamp > $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)

# receiver đang chạy ở localhost:3000
cloudflared tunnel --url http://localhost:3000
# → in ra một URL dạng https://<random>.trycloudflare.com

Dùng URL https://<random>.trycloudflare.com/webhooks/soq khi POST /v1/me/webhooks.

ngrok

ngrok http 3000
# → Forwarding  https://<random>.ngrok-free.app -> http://localhost:3000

Dùng URL https://<random>.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)
21 phút
35 phút
430 phút
52 giờ
66 giờ
712 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 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 <client_token>.

MethodEndpointReferenceMô tả
GET/v1/me/webhooks/topicslist-webhook-topicsDanh sách topic hợp lệ + mô tả.
GET/v1/me/webhookslist-webhooksLiệt kê webhook của client (không kèm secret).
POST/v1/me/webhookscreate-webhookĐăng ký webhook mới. Trả 201 kèm secret (một lần).
PATCH/v1/me/webhooks/:idupdate-webhookCập nhật url, topics, description, is_active.
DELETE/v1/me/webhooks/:iddelete-webhookXóa webhook. Trả 204.
POST/v1/me/webhooks/:id/rotate-secretrotate-webhook-secretCấp signing secret mới. Trả secret (một lần).
POST/v1/me/webhooks/:id/testtest-webhookGửi sự kiện webhook.test. Trả 202.
GET/v1/me/webhooks/:id/deliverieslist-webhook-deliveriesLị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/redeliverredeliver-webhook-deliveryGửi lại một delivery từ đầu. Trả 202.

Cập nhật webhook

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

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áiMã / dấu hiệuNguyên nhân & cách xử lý
400INVALID_WEBHOOK_URLURL 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.
400WEBHOOK_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.
400WEBHOOK_INACTIVEGọ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 "<timestamp>." trước raw body.
timeout / retrydelivery status: failed, attempts tăngReceiver 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


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.