Webhook Events & Payload - Event Catalog and Data Structure
This page is for the technical team on the receiving side: it lists every event the system supports, the data structure (payload) sent, and how to verify the integrity of each request.
Overview
Every webhook delivery is an HTTP POST with Content-Type: application/json and a common body structure:
{
"eventId": "665f1a2b3c4d5e6f7a8b9c0d",
"event": "order.created",
"data": { "...": "event-specific details" },
"timestamp": "2026-07-17T08:30:00.000Z"
}
| Field | Meaning |
|---|---|
eventId | Unique identifier of the delivery. Use it for deduplication (see Idempotency below) |
event | Event type, e.g. order.created, customer.phone_updated |
data | Event details — the structure differs per event type |
timestamp | Time the event was recorded (ISO 8601, UTC) |
Event catalog
Orders (order.*)
| Event | When it fires |
|---|---|
order.created | A new order is created (from the Mini App, the Portal, or synced from a marketplace) |
order.status_changed | The order status changes |
order.paid | The order is marked as paid |
order.cancelled | The order is cancelled (sent together with order.status_changed) |
Main fields in data: orderId, code, platform, sOrderId (marketplace order ID, when the order is synced from a marketplace), status, isPaid, price, originPrice, discount, shippingFee, buyer, lineItems, appliedVouchers, createdAt. For update events (order.status_changed, order.paid, order.cancelled), data contains only the key changed fields (orderId, status, isPaid, updatedAt...).
Payload example — order.created
{
"eventId": "665f1a2b3c4d5e6f7a8b9c0d",
"event": "order.created",
"data": {
"orderId": "665f1a2b3c4d5e6f7a8b9c1a",
"code": "DH000123",
"platform": "mini_app",
"sOrderId": "SP-000123",
"status": "pending",
"isPaid": false,
"price": 250000,
"originPrice": 300000,
"discount": 50000,
"shippingFee": 20000,
"buyer": { "fullName": "Nguyễn Văn A", "phone": "0901234567" },
"lineItems": [
{ "itemId": "665f1a2b3c4d5e6f7a8b9c1b", "name": "Áo thun nam", "quantity": 2, "price": 125000 }
],
"appliedVouchers": [],
"createdAt": "2026-07-17T08:30:00.000Z"
},
"timestamp": "2026-07-17T08:30:00.000Z"
}
Payload example — order.status_changed
{
"eventId": "665f1a2b3c4d5e6f7a8b9c0d",
"event": "order.status_changed",
"data": {
"orderId": "665f1a2b3c4d5e6f7a8b9c1a",
"status": "confirmed",
"isPaid": false,
"updatedAt": "2026-07-17T08:30:00.000Z"
},
"timestamp": "2026-07-17T08:30:00.000Z"
}
Payload example — order.paid
{
"eventId": "665f1a2b3c4d5e6f7a8b9c0d",
"event": "order.paid",
"data": {
"orderId": "665f1a2b3c4d5e6f7a8b9c1a",
"isPaid": true,
"paidAmount": 250000,
"updatedAt": "2026-07-17T08:30:00.000Z"
},
"timestamp": "2026-07-17T08:30:00.000Z"
}
Payload example — order.cancelled
{
"eventId": "665f1a2b3c4d5e6f7a8b9c0d",
"event": "order.cancelled",
"data": {
"orderId": "665f1a2b3c4d5e6f7a8b9c1a",
"status": "cancelled",
"isPaid": false,
"updatedAt": "2026-07-17T08:30:00.000Z"
},
"timestamp": "2026-07-17T08:30:00.000Z"
}
Sent right after (or together with) an order.status_changed payload carrying the same data.
Customers (customer.*)
| Event | When it fires |
|---|---|
customer.created | A new customer is created |
customer.phone_updated | The customer updates their phone number |
customer.followed_oa | The customer follows the Official Account |
customer.updated | Other profile updates |
Main fields in data: userId, shopId, fullName, phone, email, gender, BOD, avatar, tags, hashTags, createdAt.
Customer payloads contain only explicitly whitelisted fields — sensitive data such as passwords or internal information is never sent out.
Payload example — customer.created
{
"eventId": "665f1a2b3c4d5e6f7a8b9c0d",
"event": "customer.created",
"data": {
"userId": "665f1a2b3c4d5e6f7a8b9c2a",
"shopId": "665f1a2b3c4d5e6f7a8b9c00",
"fullName": "Nguyễn Văn A",
"phone": "0901234567",
"email": "nguyenvana@example.com",
"gender": "male",
"BOD": "1995-01-01T00:00:00.000Z",
"avatar": "https://example.com/avatar.png",
"tags": "vip,new",
"hashTags": ["665f1a2b3c4d5e6f7a8b9c2b"],
"createdAt": "2026-07-17T08:30:00.000Z"
},
"timestamp": "2026-07-17T08:30:00.000Z"
}
customer.updated uses the same payload builder as customer.created — the data structure is identical to the example above.
Payload example — customer.phone_updated
{
"eventId": "665f1a2b3c4d5e6f7a8b9c0d",
"event": "customer.phone_updated",
"data": {
"userId": "665f1a2b3c4d5e6f7a8b9c2a",
"phone": "0901234567"
},
"timestamp": "2026-07-17T08:30:00.000Z"
}
Payload example — customer.followed_oa
{
"eventId": "665f1a2b3c4d5e6f7a8b9c0d",
"event": "customer.followed_oa",
"data": {
"userId": "665f1a2b3c4d5e6f7a8b9c2a",
"isFollowOa": true,
"followOaAt": "2026-07-17T08:30:00.000Z"
},
"timestamp": "2026-07-17T08:30:00.000Z"
}
Inventory (inventory.*)
| Event | When it fires |
|---|---|
inventory.order_stock_changed | Stock changed by an order (created / delivered / cancelled) |
inventory.stock_transferred | Stock changed by a warehouse transfer |
inventory.stock_adjusted | Manual stock adjustment |
inventory.stock_imported | Bulk stock import |
inventory.stock_deleted | An inventory record is deleted |
Main fields in data: inventoryHistoryId, shopId, itemId, warehouseId, skuId, sku, reason, delta, quantityBefore, quantityAfter, createdAt.
All 5 events share the same data structure — only the reason value differs:
| Event | reason |
|---|---|
inventory.order_stock_changed | order_created / order_delivered / order_cancelled |
inventory.stock_transferred | transfer_out / transfer_in / transfer_in_damaged / transfer_cancelled |
inventory.stock_adjusted | manual_adjustment |
inventory.stock_imported | bulk_import |
inventory.stock_deleted | deleted |
Payload example — inventory.order_stock_changed
{
"eventId": "665f1a2b3c4d5e6f7a8b9c0d",
"event": "inventory.order_stock_changed",
"data": {
"inventoryHistoryId": "665f1a2b3c4d5e6f7a8b9c3a",
"shopId": "665f1a2b3c4d5e6f7a8b9c00",
"itemId": "665f1a2b3c4d5e6f7a8b9c1b",
"warehouseId": "665f1a2b3c4d5e6f7a8b9c3b",
"skuId": "665f1a2b3c4d5e6f7a8b9c3c",
"sku": "SP001-DEN-M",
"reason": "order_created",
"delta": -2,
"quantityBefore": 20,
"quantityAfter": 18,
"createdAt": "2026-07-17T08:30:00.000Z"
},
"timestamp": "2026-07-17T08:30:00.000Z"
}
inventory.stock_transferred/stock_adjusted/stock_imported/stock_deleted share the same data structure — only reason changes per the table above (and delta is positive or negative depending on the direction of the change).
Products (product.*)
| Event | When it fires |
|---|---|
product.created | A new product is created (from the Portal, or synced from a connected sales/ERP system) |
product.updated | The product is updated (including price and status changes) |
product.deleted | The product is deleted |
Main fields in data: itemId, shopId, name, slug, price, originPrice, wholesalePrice, status, platform, categoryId, images, deletedAt, createdAt, updatedAt.
Product events also cover data synced from connected sales/ERP systems
(Nhanh, KiotViet, Sapo, Haravan, Odoo, Pancake POS). Use the platform
field in data to identify which system a product came from and filter as
needed.
Payload example — product.created
{
"eventId": "665f1a2b3c4d5e6f7a8b9c0d",
"event": "product.created",
"data": {
"itemId": "665f1a2b3c4d5e6f7a8b9c1b",
"shopId": "665f1a2b3c4d5e6f7a8b9c00",
"name": "Áo thun nam basic",
"slug": "ao-thun-nam-basic",
"price": 199000,
"originPrice": 249000,
"wholesalePrice": 179000,
"status": "active",
"platform": "nhanh",
"categoryId": "665f1a2b3c4d5e6f7a8b9c4a",
"images": ["https://example.com/product.png"],
"deletedAt": null,
"createdAt": "2026-07-17T08:30:00.000Z",
"updatedAt": "2026-07-17T08:30:00.000Z"
},
"timestamp": "2026-07-17T08:30:00.000Z"
}
product.updated uses the same payload builder as product.created — the data structure is identical to the example above, even when only price or status changed.
Payload example — product.deleted
{
"eventId": "665f1a2b3c4d5e6f7a8b9c0d",
"event": "product.deleted",
"data": {
"itemId": "665f1a2b3c4d5e6f7a8b9c1b",
"shopId": "665f1a2b3c4d5e6f7a8b9c00",
"name": "Áo thun nam basic",
"slug": "ao-thun-nam-basic",
"price": 199000,
"originPrice": 249000,
"wholesalePrice": 179000,
"status": "deleted",
"platform": "nhanh",
"categoryId": "665f1a2b3c4d5e6f7a8b9c4a",
"images": ["https://example.com/product.png"],
"deletedAt": "2026-07-17T08:30:00.000Z",
"createdAt": "2026-07-17T08:20:00.000Z",
"updatedAt": "2026-07-17T08:30:00.000Z"
},
"timestamp": "2026-07-17T08:30:00.000Z"
}
ZNS messages (zns_message.*)
| Event | When it fires |
|---|---|
zns_message.status_changed | The delivery status of a ZNS message changes (delivered / failed...) |
Main fields in data: messageId, shopId, phone, templateId, status, service, campaignId, actionId, isUid, reason, price, createdAt, updatedAt. campaignId/actionId/reason only appear when the message belongs to a campaign / is tied to an action / has a corresponding error — otherwise they are omitted from the JSON.
Payload example — zns_message.status_changed
{
"eventId": "665f1a2b3c4d5e6f7a8b9c0d",
"event": "zns_message.status_changed",
"data": {
"messageId": "665f1a2b3c4d5e6f7a8b9c5a",
"shopId": "665f1a2b3c4d5e6f7a8b9c00",
"phone": "0901234567",
"templateId": "665f1a2b3c4d5e6f7a8b9c5b",
"status": "RECEIVED",
"service": "zns",
"isUid": false,
"price": 300,
"createdAt": "2026-07-17T08:29:00.000Z",
"updatedAt": "2026-07-17T08:30:00.000Z"
},
"timestamp": "2026-07-17T08:30:00.000Z"
}
ZNS campaigns (zns_campaign.*)
| Event | When it fires |
|---|---|
zns_campaign.status_changed | A ZNS campaign changes status (in progress / completed / failed) |
Main fields in data: campaignId, shopId, title, type, status, sentCount, failedCount, reason, createdAt, updatedAt.
Payload example — zns_campaign.status_changed
{
"eventId": "665f1a2b3c4d5e6f7a8b9c0d",
"event": "zns_campaign.status_changed",
"data": {
"campaignId": "665f1a2b3c4d5e6f7a8b9c6a",
"shopId": "665f1a2b3c4d5e6f7a8b9c00",
"title": "Chiến dịch ZNS khuyến mãi tháng 7",
"type": "zns",
"status": "completed",
"sentCount": 120,
"failedCount": 3,
"createdAt": "2026-07-17T07:00:00.000Z",
"updatedAt": "2026-07-17T08:30:00.000Z"
},
"timestamp": "2026-07-17T08:30:00.000Z"
}
Inquiries (inquiry.*)
| Event | When it fires |
|---|---|
inquiry.created | A customer submits a consultation request form on the Mini App |
inquiry.status_changed | An admin updates the processing status of the request |
inquiry.created—dataincludes:inquiryId,shopId,formId,productId,status,data(form content: name, phone, service, message),createdAt.inquiry.status_changed—dataonly includes:inquiryId,status,updatedAt(noformId/productId/form content).
Payload example — inquiry.created
{
"eventId": "665f1a2b3c4d5e6f7a8b9c0d",
"event": "inquiry.created",
"data": {
"inquiryId": "665f1a2b3c4d5e6f7a8b9c7a",
"shopId": "665f1a2b3c4d5e6f7a8b9c00",
"formId": "665f1a2b3c4d5e6f7a8b9c7b",
"productId": "665f1a2b3c4d5e6f7a8b9c1b",
"status": "pending",
"data": {
"customerName": "Trần Thị B",
"phone": "0912345678",
"serviceName": "Tư vấn sản phẩm",
"content": "Tôi muốn biết thêm về sản phẩm này",
"inquiryDate": "2026-07-17T08:30:00.000Z"
},
"createdAt": "2026-07-17T08:30:00.000Z"
},
"timestamp": "2026-07-17T08:30:00.000Z"
}
Payload example — inquiry.status_changed
{
"eventId": "665f1a2b3c4d5e6f7a8b9c0d",
"event": "inquiry.status_changed",
"data": {
"inquiryId": "665f1a2b3c4d5e6f7a8b9c7a",
"status": "processing",
"updatedAt": "2026-07-17T08:30:00.000Z"
},
"timestamp": "2026-07-17T08:30:00.000Z"
}
Reviews (review.*)
| Event | When it fires |
|---|---|
review.created | A customer submits a product review on the Mini App |
review.status_changed | An admin approves or rejects the review |
review.created—dataincludes:reviewId,shopId,userId,itemId,orderItemId,rating,content,images,status,createdAt.review.status_changed—dataonly includes:reviewId,status,updatedAt(norating/content/images).
Payload example — review.created
{
"eventId": "665f1a2b3c4d5e6f7a8b9c0d",
"event": "review.created",
"data": {
"reviewId": "665f1a2b3c4d5e6f7a8b9c8a",
"shopId": "665f1a2b3c4d5e6f7a8b9c00",
"userId": "665f1a2b3c4d5e6f7a8b9c2a",
"itemId": "665f1a2b3c4d5e6f7a8b9c1b",
"orderItemId": "665f1a2b3c4d5e6f7a8b9c8b",
"rating": 5,
"content": "Sản phẩm rất tốt, đóng gói cẩn thận",
"images": [],
"status": "pending",
"createdAt": "2026-07-17T08:30:00.000Z"
},
"timestamp": "2026-07-17T08:30:00.000Z"
}
Payload example — review.status_changed
{
"eventId": "665f1a2b3c4d5e6f7a8b9c0d",
"event": "review.status_changed",
"data": {
"reviewId": "665f1a2b3c4d5e6f7a8b9c8a",
"status": "active",
"updatedAt": "2026-07-17T08:30:00.000Z"
},
"timestamp": "2026-07-17T08:30:00.000Z"
}
status: active (approved) or inactive (rejected).
Loyalty points (loyalty.*)
| Event | When it fires |
|---|---|
loyalty.created | A new loyalty point transaction is created |
loyalty.status_changed | A loyalty transaction changes status (pending → success / cancelled) |
Main fields in data: loyaltyHistoryId, shopId, customerId, customerLoyaltyId, orderId, type, status, extraData, createdAt, updatedAt. type is an internal integer code (e.g. 7 = points earned for a successful order) — not a string.
Payload example — loyalty.created
{
"eventId": "665f1a2b3c4d5e6f7a8b9c0d",
"event": "loyalty.created",
"data": {
"loyaltyHistoryId": "665f1a2b3c4d5e6f7a8b9c9a",
"shopId": "665f1a2b3c4d5e6f7a8b9c00",
"customerId": "665f1a2b3c4d5e6f7a8b9c2a",
"customerLoyaltyId": "665f1a2b3c4d5e6f7a8b9c9b",
"orderId": "665f1a2b3c4d5e6f7a8b9c1a",
"type": 7,
"status": "pending",
"createdAt": "2026-07-17T08:30:00.000Z",
"updatedAt": "2026-07-17T08:30:00.000Z"
},
"timestamp": "2026-07-17T08:30:00.000Z"
}
Payload example — loyalty.status_changed
{
"eventId": "665f1a2b3c4d5e6f7a8b9c0d",
"event": "loyalty.status_changed",
"data": {
"loyaltyHistoryId": "665f1a2b3c4d5e6f7a8b9c9a",
"shopId": "665f1a2b3c4d5e6f7a8b9c00",
"customerId": "665f1a2b3c4d5e6f7a8b9c2a",
"customerLoyaltyId": "665f1a2b3c4d5e6f7a8b9c9b",
"orderId": "665f1a2b3c4d5e6f7a8b9c1a",
"type": 7,
"status": "success",
"createdAt": "2026-07-17T08:00:00.000Z",
"updatedAt": "2026-07-17T08:30:00.000Z"
},
"timestamp": "2026-07-17T08:30:00.000Z"
}
status: pending → success or cancelled.
Verifying the HMAC signature
When the webhook uses the HMAC authentication method, each request is signed with HMAC-SHA256 using the configured Secret. The signature (hex-encoded) is sent in the header:
X-Webhook-Signature: 3f9a1b...
The receiver verifies it by re-signing the entire JSON body and comparing:
const crypto = require("crypto");
function verifyWebhookSignature(rawBody, signatureHeader, secret) {
const expectedSignature = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(signatureHeader),
);
}
Sign the raw body string as received; do not parse and re-serialize it — a different key order breaks the signature. Always reject requests whose signature does not match.
Idempotency — handling duplicates
Because of automatic retries, an event may be delivered more than once. The receiver should store processed eventId values and skip requests with a duplicate eventId to avoid double processing (e.g. deducting stock twice for the same order).
Technical requirements for the receiver
| Item | Requirement |
|---|---|
| Protocol | HTTPS, publicly reachable URL |
| Success response | HTTP 2xx code |
| Response time | Within 10 seconds (recommended: return 200 immediately, process business logic asynchronously) |
| Redirects | Not supported — requests do not follow redirects |
| Retry on failure | Automatic after 5 minutes → 30 minutes → 3 hours (up to 4 attempts in total) |