Receiving Events
Webhook delivery & signature verification
How UnifyPort delivers events to your endpoint over HTTP POST, the X-Device-* headers on every delivery, and how to verify the HMAC-SHA256 signature so you can trust the payload. Also covers the response we expect, retries, and idempotency. Quyền nhận sự kiện gọi do is_beta của không gian làm việc quyết định. Với quyền thử nghiệm, subscribed_events là *, rỗng hoặc bị bỏ qua bao gồm sự kiện gọi; danh sách cụ thể chỉ nhận các sự kiện đã liệt kê. Không gian không có quyền vẫn dùng được wildcard nhưng không nhận sự kiện gọi; đăng ký rõ sự kiện gọi trả về 403 beta_feature_not_enabled. Quyền được kiểm tra lại mỗi lần gửi, thử lại và phát lại dead letter; lỗi tra cứu không cho phép gửi. Bản ghi cuộc gọi message.* thông thường độc lập.
Xác nhận và thử lại
- 2xx
- Lưu sự kiện rồi trả về bất kỳ 2xx nào để xác nhận đã nhận. Nội dung phản hồi không được dùng để xử lý kết quả.
- 408 / 429 / 5xx
- Lỗi kết nối và 408 / 429 / 5xx được thử lại ngay. retry_policy.max_attempts quy định số lần sau yêu cầu đầu tiên: mặc định 3 lần, tổng tối đa 4 yêu cầu. Không có backoff và không đọc Retry-After.
- 4xx (≠ 408 / 429)
- Các lỗi 4xx khác dừng gửi tự động và đánh dấu sự kiện là dead-letter. Sửa cấu hình phía nhận; không chờ hệ thống tự phát lại.
Delivery headers
X-Device-Event-IdCorrelation id for the emitted event. It stays the same across retries of that event, but WhatsApp HistorySync batches must not be globally deduplicated by this header alone.
X-Device-Delivery-IdDelivery correlation id. It can fall back to X-Device-Event-Id and is therefore not guaranteed to be unique for each HTTP attempt.
X-Device-TimestampRFC 3339 UTC time the delivery was signed (e.g. 2026-06-08T12:34:56Z). It is part of the signed string and also lets you reject stale deliveries.
X-Device-SignatureHex-encoded HMAC-SHA256 of "<X-Device-Timestamp>" + "." + "<raw request body>". Present only when the endpoint has a signing_secret; omitted when signing is disabled.
Content-TypeAlways application/json.
Verify the signature
Node.js (Express)
import crypto from 'crypto';
import express from 'express';
const app = express();
const SECRET = process.env.WEBHOOK_SIGNING_SECRET;
// express.raw keeps the body as the exact bytes we signed — never re-serialize.
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
let timestamp = req.get('X-Device-Timestamp');
if (!timestamp) {
timestamp = '';
}
let signature = req.get('X-Device-Signature');
if (!signature) {
signature = '';
}
const hmac = crypto.createHmac('sha256', SECRET);
hmac.update(timestamp + '.');
hmac.update(req.body); // raw Buffer
const expected = hmac.digest('hex');
const valid =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!valid) return res.status(401).end();
const event = JSON.parse(req.body.toString('utf8'));
// handle event.type ...
res.status(200).end(); // any 2xx acknowledges the delivery
});Python (Flask)
import hmac, hashlib, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["WEBHOOK_SIGNING_SECRET"].encode()
@app.post("/webhook")
def webhook():
timestamp = request.headers.get("X-Device-Timestamp", "")
signature = request.headers.get("X-Device-Signature", "")
body = request.get_data() # raw bytes, exactly as delivered
expected = hmac.new(
SECRET, timestamp.encode() + b"." + body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401)
event = request.get_json()
# handle event["type"] ...
return "", 200Ghi chú
- Delivery is at-least-once. For ordinary events, retries reuse X-Device-Event-Id and receivers should deduplicate idempotently. For conversation.history, do not globally deduplicate on the top-level event id alone; merge by provider, account_id, conversation.id, and messages[].id so later chunks are retained.
- Verify against the raw request bytes, before any JSON parse or re-serialize. Re-encoding the body changes the bytes and breaks the signature.
- Reject deliveries whose X-Device-Timestamp is too far from your clock to limit replay — the timestamp is covered by the signature.
- Signing is per-endpoint: set signing_secret on Create or Update webhook endpoint to enable it; leave it empty to disable and drop the X-Device-Signature header.
- Delivery order is not guaranteed — events from the same conversation can arrive out of order (a read receipt may land before the message it refers to). Order by occurred_at, with the event id as a tiebreaker, before applying state.
- UnifyPort has no REST API for reading message history and does not guarantee replay of missed payloads. Store events when they arrive. WhatsApp can emit limited best-effort conversation.history data after bootstrap or reconnect, but it is neither complete nor a durable archive.