Webhooks
Receive realtime events from NextX via HTTP push instead of polling. Each event sends a POST with JSON to your URL, signed with HMAC-SHA256 so you can verify the source.
1. Overview
Webhooks work like Meta's: each workspace has exactly one webhook (one URL) per service — CRM, Sales, Accounts & Branches, Office, Omnichannel chat. You pick a service, give it an HTTPS URL, then switch on the events of that service one by one. When an event happens in the workspace, NextX sends a signed request to the URL. Your endpoint must return 2xx within 15 seconds; otherwise the system redelivers — up to 8 delivery attempts (the first + 7 retries over ~24 hours), with increasing backoff (see Delivery & retry).
Authorization: Bearer <USER_ACCESS_TOKEN> of a user holding webhook.endpoints.manage (listing needs webhook.endpoints.view, delivery logs webhook.deliveries.view / webhook.deliveries.manage). An application's OAuth token (client_credentials) and API keys cannot call these APIs: the webhook module is not granted to applications. An app/agent can guide the customer, or the customer sets it up in the app.2. Set up a webhook per service
In the app: Settings › Apps & API › Webhooks → pick a service → paste the URL → Verify and save → switch on each event you need. The steps below are the same flow over the API.
Step 1 — List services and their events
curl https://api.nextx.vn/api/v1/webhook/services \
-H "Authorization: Bearer <USER_ACCESS_TOKEN>"Returns services[]: each service has a service code (CRM, SALES, IAM, OFFICE, COMM), a displayName, its events[] (eventType, displayName, description) and endpoint — the current webhook, or null if not configured yet. legacy[] lists old-style endpoints (created without a service) that have not been deleted.
Step 2 — Verify the URL (recommended)
curl -X POST https://api.nextx.vn/api/v1/webhook/endpoints/verify \
-H "Authorization: Bearer <USER_ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-d '{ "url": "https://your-app.com/webhooks/nextx/crm", "verifyToken": "my-random-token" }'NextX sends a POST with type: "endpoint.verification", a random challenge and your verify_token. Your server must reply 2xx with the challenge string somewhere in the response body. Nothing is saved by this call; the result is success, responseStatus, durationMs, challenge, responseBody, errorMessage and failureReason (0 OK · 1 unreachable — DNS/TLS/timeout/private IP · 2 HTTP error · 3 returned 2xx but did not echo the challenge).
// Node.js (Express) — answer the verification ping BEFORE checking the signature
app.post("/webhooks/nextx/crm", express.raw({ type: "application/json" }), (req, res) => {
const event = JSON.parse(req.body.toString("utf8"));
if (event.type === "endpoint.verification") {
// optional: compare event.verify_token with the verifyToken you sent
return res.status(200).json({ challenge: event.challenge });
}
// ...verify X-NextX-Signature-256 (section 6), then process the event
res.sendStatus(200);
});The verification request is signed with a one-time secret (there is no real secret yet), so it will not pass your signature check — handle endpoint.verification first, as above. Echoing the challenge reveals nothing secret.
Step 3 — Set the service's URL (create or update)
curl -X PUT https://api.nextx.vn/api/v1/webhook/services/CRM \
-H "Authorization: Bearer <USER_ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks/nextx/crm",
"subscribedEvents": ["contact.created", "contact.updated"]
}'PUT /api/v1/webhook/services/{service} with body { url, subscribedEvents?, customHeaders? }. The first call creates the webhook (201, created: true) and returns signingSecret (whk_sec_…) only this once — store it to verify signatures:
HTTP/1.1 201 Created
{
"id": "<endpoint_id>",
"service": "CRM",
"url": "https://your-app.com/webhooks/nextx/crm",
"status": "Active",
"subscribedEvents": ["contact.created", "contact.updated"],
"created": true,
"signingSecret": "whk_sec_3f9a...c21",
"createdAt": "2026-10-08T03:12:56Z",
"updatedAt": "2026-10-08T03:12:56Z"
}Calling it again updates the URL (200, created: false, no secret). On update, omitting subscribedEvents / customHeaders keeps the current value; on create, an empty or missing list means no events yet — the webhook receives nothing until you switch events on.
Step 4 — Switch events on/off, or remove the webhook
# Turn one event on (or off with "subscribed": false)
curl -X PUT https://api.nextx.vn/api/v1/webhook/services/CRM/events/deal.won \
-H "Authorization: Bearer <USER_ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-d '{ "subscribed": true }'
# Remove the service's webhook (it can be created again right away with PUT)
curl -X DELETE https://api.nextx.vn/api/v1/webhook/services/CRM \
-H "Authorization: Bearer <USER_ACCESS_TOKEN>"- Service codes are upper-case and matched exactly (
CRM, notcrm). A code with no active events →404 webhook.service.not_found. - An event that belongs to another service →
422 webhook.event.not_in_service(e.g.order.completedonCRM); an unknown event →422as well. - Switching an event or deleting when the service has no webhook yet →
404 webhook.service.not_configured. - The old
POST /api/v1/webhook/endpointsstill works but now requiresservice; a service that already has a webhook →409 webhook.endpoint.service_exists. A body mixing CRM and Sales events with noserviceis rejected — use onePUT services/{service}per service instead.
By-id operations still apply to a service's webhook (its id is in endpoint.id): GET /endpoints/{id}, POST /endpoints/{id}/disable / /enable, POST /endpoints/{id}/rotate-secret, POST /endpoints/{id}/test, and the delivery log at /endpoints/{id}/deliveries.
3. Event catalog
33 events across 8 services. The Source column is the service code — a service's webhook can only subscribe to that service's events.
CRM · CRMSALES · SalesIAM · Accounts & BranchesOFFICE · OfficeCOMM · Omnichannel chatHRM · HRPAYMENT · PaymentsEINVOICE · E-invoicescontact.identifiedCOMMPROA customer just sent a phone number in chat, with the conversation source (ad / UTM) and the assigned staff member
| Field | Type |
|---|---|
data.object.email | string (email) |
data.object.phone | string |
data.object.agentId | string (uuid) |
data.object.attribution | object |
data.object.channelType | string |
data.object.displayName | string |
data.object.workspaceId | string (uuid) |
data.object.crmContactId | string (uuid) |
data.object.identifiedAt | string (date-time) |
data.object.conversationId | string (uuid) |
data.object.channelAccountId | string (uuid) |
data.object.channelContactId | string (uuid) |
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "contact.identified",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {
"email": "khach@example.com",
"phone": "string",
"agentId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"attribution": {
"adId": "string",
"adName": null,
"adsetId": null,
"utmTerm": null,
"adsetName": null,
"utmMedium": null,
"utmSource": null,
"adPlatform": "string",
"landingUrl": null,
"utmContent": null,
"referrerUrl": null,
"utmCampaign": null,
"adCampaignId": null,
"adCampaignName": null,
"attributionMethod": "string"
},
"channelType": "string",
"displayName": "string",
"workspaceId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"crmContactId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"identifiedAt": "2026-10-27T09:00:00+07:00",
"conversationId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"channelAccountId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"channelContactId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b"
}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}conversation.attribution_resolvedCOMMPROThe conversation's ad tree (ad set, campaign) was just fetched from Meta, usually a few minutes after the event above; same conversationId — an overwrite, not a new record
| Field | Type |
|---|---|
data.object.resolvedAt | string (date-time) |
data.object.attribution | object |
data.object.workspaceId | string (uuid) |
data.object.conversationId | string (uuid) |
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "conversation.attribution_resolved",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {
"resolvedAt": "2026-10-27T09:00:00+07:00",
"attribution": {
"adId": "string",
"adName": "string",
"adsetId": "string",
"utmTerm": null,
"adsetName": "string",
"utmMedium": null,
"utmSource": null,
"adPlatform": "string",
"landingUrl": null,
"utmContent": null,
"referrerUrl": null,
"utmCampaign": null,
"adCampaignId": "string",
"adCampaignName": "string",
"attributionMethod": "string"
},
"workspaceId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"conversationId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b"
}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}contact.createdCRMPROA new contact was created in the CRM — manually, by file import, or automatically from chat / web forms
| Field | Type |
|---|---|
data.object.code | string |
data.object.name | string |
data.object.type | string |
data.object.email | string |
data.object.phone | string |
data.object.source | string |
data.object.branchId | string (uuid) |
data.object.contactId | string (uuid) |
data.object.createdAt | string (date-time) |
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "contact.created",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {
"code": "string",
"name": "string",
"type": "string",
"email": "string",
"phone": "string",
"source": "string",
"branchId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"contactId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"createdAt": "2026-10-27T09:00:00+07:00"
}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}contact.deletedCRMPROA contact was deleted from the CRM
| Field | Type |
|---|---|
data.object.contactId | string (uuid) |
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "contact.deleted",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {
"contactId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b"
}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}contact.updatedCRMPROA contact was edited; the payload is the state AFTER the edit, not a diff
| Field | Type |
|---|---|
data.object.city | string |
data.object.code | string |
data.object.ward | string |
data.object.email | string |
data.object.phone | string |
data.object.address | string |
data.object.taxCode | string |
data.object.branchId | string (uuid) |
data.object.fullName | string |
data.object.sourceId | string (uuid) |
data.object.contactId | string (uuid) |
data.object.assigneeId | string (uuid) |
data.object.sourceName | string |
data.object.changedFields | string[] |
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "contact.updated",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {
"city": "string",
"code": "string",
"ward": "string",
"email": "string",
"phone": "string",
"address": "string",
"taxCode": "string",
"branchId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"fullName": "string",
"sourceId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"contactId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"assigneeId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"sourceName": "string",
"changedFields": [
"string"
]
}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}deal.wonCRMPROAn opportunity moved to won (does not fire for lost or cancelled opportunities)
Fields are being recorded from real events and will appear here after the first delivery. The example below is the standard envelope.
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "deal.won",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}invoice.cancelledEINVOICEPROAn e-invoice was cancelled — on NextX or detected as cancelled at the provider during sync. Payload includes the cancellation reason.
| Field | Type |
|---|---|
data.object.status | string |
data.object.cqtCode | null |
data.object.issuedAt | null |
data.object.invoiceId | string (uuid) |
data.object.referenceId | string (uuid) |
data.object.cancelReason | string |
data.object.errorMessage | null |
data.object.invoiceNumber | null |
data.object.referenceCode | string |
data.object.referenceType | string |
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "invoice.cancelled",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {
"status": "string",
"cqtCode": null,
"issuedAt": null,
"invoiceId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"referenceId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"cancelReason": "string",
"errorMessage": null,
"invoiceNumber": null,
"referenceCode": "string",
"referenceType": "string"
}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}invoice.failedEINVOICEPROIssuing an e-invoice failed. Payload includes the source document and the error message returned by the e-invoice provider.
Fields are being recorded from real events and will appear here after the first delivery. The example below is the standard envelope.
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "invoice.failed",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}invoice.issuedEINVOICEPROAn e-invoice was issued successfully. Payload includes the source document (referenceType / referenceId / referenceCode), invoice number, tax authority code (if any) and issue time.
| Field | Type |
|---|---|
data.object.status | string |
data.object.cqtCode | string |
data.object.issuedAt | string (date-time) |
data.object.invoiceId | string (uuid) |
data.object.referenceId | string (uuid) |
data.object.cancelReason | null |
data.object.errorMessage | null |
data.object.invoiceNumber | string |
data.object.referenceCode | string |
data.object.referenceType | string |
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "invoice.issued",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {
"status": "string",
"cqtCode": "string",
"issuedAt": "2026-10-27T09:00:00+07:00",
"invoiceId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"referenceId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"cancelReason": null,
"errorMessage": null,
"invoiceNumber": "string",
"referenceCode": "string",
"referenceType": "string"
}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}employee.createdHRMPROA new employee was created in HRM. Payload includes code, full name, employee type, start date, department, job title and the first contract type.
| Field | Type |
|---|---|
data.object.code | string |
data.object.fullName | string |
data.object.joinDate | string (date) |
data.object.orgUnitId | string (uuid) |
data.object.employeeId | string (uuid) |
data.object.positionId | string (uuid) |
data.object.employeeType | string |
data.object.initialContractType | string |
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "employee.created",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {
"code": "string",
"fullName": "string",
"joinDate": "2026-10-27",
"orgUnitId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"employeeId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"positionId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"employeeType": "string",
"initialContractType": "string"
}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}employee.terminatedHRMPROAn employee was terminated. Payload includes code, full name, last working date and decision number; the reason is not included.
Fields are being recorded from real events and will appear here after the first delivery. The example below is the standard envelope.
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "employee.terminated",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}employee.updatedHRMPROAn employee profile was edited. The payload is the state AFTER the edit (code, full name, email, department, job title).
Fields are being recorded from real events and will appear here after the first delivery. The example below is the standard envelope.
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "employee.updated",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}labor_contract.evaluatedHRMPROA labor contract was evaluated. Payload includes contract type, decision, total / average score and the next contract if any.
Fields are being recorded from real events and will appear here after the first delivery. The example below is the standard envelope.
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "labor_contract.evaluated",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}leave.approvedHRMPROA leave request was approved at the final step. Payload includes employee, leave type, code and date range; approval notes are not included.
| Field | Type |
|---|---|
data.object.toDate | string (date) |
data.object.leaveId | string (uuid) |
data.object.fromDate | string (date) |
data.object.leaveType | string |
data.object.approvedAt | string (date-time) |
data.object.employeeId | string (uuid) |
data.object.leaveSymbol | string |
data.object.workspaceId | string (uuid) |
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "leave.approved",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {
"toDate": "2026-10-27",
"leaveId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"fromDate": "2026-10-27",
"leaveType": "string",
"approvedAt": "2026-10-27T09:00:00+07:00",
"employeeId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"leaveSymbol": "string",
"workspaceId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b"
}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}leave.rejectedHRMPROA leave request was rejected. Payload includes employee, leave type and date range; the rejection reason is not included.
Fields are being recorded from real events and will appear here after the first delivery. The example below is the standard envelope.
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "leave.rejected",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}payroll.lockedHRMPROA monthly payroll was locked. Payload includes year, month, lock time and who locked it; no amounts.
Fields are being recorded from real events and will appear here after the first delivery. The example below is the standard envelope.
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "payroll.locked",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}payslip.acknowledgedHRMPROAn employee signed to acknowledge a payslip. Payload includes payslip, employee, month / year and signing time.
Fields are being recorded from real events and will appear here after the first delivery. The example below is the standard envelope.
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "payslip.acknowledged",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}recruitment.hiredHRMPROA candidate was hired. Payload includes application, candidate, job posting, hire date and the created employee (if any).
Fields are being recorded from real events and will appear here after the first delivery. The example below is the standard envelope.
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "recruitment.hired",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}branch.createdIAMPROA new branch was created
| Field | Type |
|---|---|
data.object.code | string |
data.object.name | string |
data.object.address | string |
data.object.branchId | string (uuid) |
data.object.parentId | null |
data.object.managerId | null |
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "branch.created",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {
"code": "string",
"name": "string",
"address": "string",
"branchId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"parentId": null,
"managerId": null
}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}branch.updatedIAMPROA branch was edited
| Field | Type |
|---|---|
data.object.branchId | string (uuid) |
data.object.changedFields | string[] |
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "branch.updated",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {
"branchId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"changedFields": [
"string"
]
}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}user.createdIAMPROA new user was added to the workspace
| Field | Type |
|---|---|
data.object.email | string |
data.object.userId | string (uuid) |
data.object.fullName | string |
data.object.createdAt | string (date-time) |
data.object.workspaceId | string (uuid) |
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "user.created",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {
"email": "string",
"userId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"fullName": "string",
"createdAt": "2026-10-27T09:00:00+07:00",
"workspaceId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b"
}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}workspace.createdIAMPROA new workspace was created
| Field | Type |
|---|---|
data.object.code | string |
data.object.name | string |
data.object.locale | string |
data.object.ownerId | string (uuid) |
data.object.timezone | string |
data.object.createdAt | string (date-time) |
data.object.ownerEmail | string (email) |
data.object.workspaceId | string (uuid) |
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "workspace.created",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {
"code": "string",
"name": "string",
"locale": "string",
"ownerId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"timezone": "string",
"createdAt": "2026-10-27T09:00:00+07:00",
"ownerEmail": "khach@example.com",
"workspaceId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b"
}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}approval.instance.status_changedOFFICEPROAn approval request changed status (pending / approved / rejected / withdrawn)
Fields are being recorded from real events and will appear here after the first delivery. The example below is the standard envelope.
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "approval.instance.status_changed",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}approval.task.completedOFFICEPROAn approver finished their approval step
Fields are being recorded from real events and will appear here after the first delivery. The example below is the standard envelope.
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "approval.task.completed",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}approval.task.createdOFFICEPROAn approval step was assigned to an approver
Fields are being recorded from real events and will appear here after the first delivery. The example below is the standard envelope.
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "approval.task.created",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}payment.completedPAYMENTPROA buyer paid successfully through an online gateway (QR, card, wallet, pay-later…) or a manual collection was confirmed. Payload includes transaction code, source document (referenceType / referenceId — sales order, gym contract…), gateway, amount and paid time.
| Field | Type |
|---|---|
data.object.amount | integer |
data.object.paidAt | string (date-time) |
data.object.gateway | string |
data.object.payerId | string (uuid) |
data.object.currency | string |
data.object.metadata | object |
data.object.netAmount | integer |
data.object.payerType | string |
data.object.referenceId | string (uuid) |
data.object.workspaceId | string (uuid) |
data.object.eInvoiceHint | null |
data.object.providerCode | string |
data.object.referenceType | string |
data.object.transactionId | string |
data.object.dbTransactionId | string (uuid) |
data.object.paymentIntentId | string (uuid) |
data.object.paymentInstrument | string |
data.object.providerApprovalCode | string |
data.object.providerTransactionId | string |
data.object.paymentInstrumentDetail | string |
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "payment.completed",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {
"amount": 0,
"paidAt": "2026-10-27T09:00:00+07:00",
"gateway": "string",
"payerId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"currency": "string",
"metadata": {},
"netAmount": 0,
"payerType": "string",
"referenceId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"workspaceId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"eInvoiceHint": null,
"providerCode": "string",
"referenceType": "string",
"transactionId": "string",
"dbTransactionId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"paymentIntentId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"paymentInstrument": "string",
"providerApprovalCode": "string",
"providerTransactionId": "string",
"paymentInstrumentDetail": "string"
}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}payment.failedPAYMENTPROAn online payment attempt failed (gateway declined, buyer cancelled, expired). Payload includes source document, gateway and the reason returned by the gateway.
| Field | Type |
|---|---|
data.object.reason | string |
data.object.failedAt | string (date-time) |
data.object.referenceId | string (uuid) |
data.object.workspaceId | string (uuid) |
data.object.providerCode | string |
data.object.referenceType | string |
data.object.paymentIntentId | string (uuid) |
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "payment.failed",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {
"reason": "string",
"failedAt": "2026-10-27T09:00:00+07:00",
"referenceId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"workspaceId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"providerCode": "string",
"referenceType": "string",
"paymentIntentId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b"
}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}payment.recurring_chargedPAYMENTPROThe gateway successfully charged one period of a recurring payment agreement. Payload has the agreement number and period number (periodNum) — the gateway may report the same period more than once, so deduplicate on this pair.
| Field | Type |
|---|---|
data.object.amount | integer |
data.object.paidAt | string (date-time) |
data.object.payerId | string (uuid) |
data.object.currency | string |
data.object.metadata | object |
data.object.payerType | string |
data.object.periodNum | integer |
data.object.referenceId | string (uuid) |
data.object.workspaceId | string (uuid) |
data.object.providerCode | string |
data.object.referenceType | string |
data.object.dbTransactionId | string (uuid) |
data.object.paymentIntentId | string (uuid) |
data.object.paymentInstrument | string |
data.object.recurringContractNo | string |
data.object.providerApprovalCode | null |
data.object.providerTransactionId | string |
data.object.originalPaymentIntentId | string (uuid) |
data.object.paymentInstrumentDetail | string |
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "payment.recurring_charged",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {
"amount": 0,
"paidAt": "2026-10-27T09:00:00+07:00",
"payerId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"currency": "string",
"metadata": {},
"payerType": "string",
"periodNum": 0,
"referenceId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"workspaceId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"providerCode": "string",
"referenceType": "string",
"dbTransactionId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"paymentIntentId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"paymentInstrument": "string",
"recurringContractNo": "string",
"providerApprovalCode": null,
"providerTransactionId": "string",
"originalPaymentIntentId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"paymentInstrumentDetail": "string"
}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}payment.recurring_failedPAYMENTPROThe gateway tried to charge one recurring period but failed. Payload includes agreement number, period number, the charge order code and the reason.
| Field | Type |
|---|---|
data.object.reason | string |
data.object.failedAt | string (date-time) |
data.object.periodNum | integer |
data.object.referenceId | string (uuid) |
data.object.workspaceId | string (uuid) |
data.object.providerCode | string |
data.object.referenceType | string |
data.object.providerOrderRef | string |
data.object.recurringContractNo | string |
data.object.originalPaymentIntentId | string (uuid) |
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "payment.recurring_failed",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {
"reason": "string",
"failedAt": "2026-10-27T09:00:00+07:00",
"periodNum": 0,
"referenceId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"workspaceId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"providerCode": "string",
"referenceType": "string",
"providerOrderRef": "string",
"recurringContractNo": "string",
"originalPaymentIntentId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b"
}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}payment.refundedPAYMENTPROA refund was completed. Payload includes refund code, original transaction, source document, amount and refund method.
Fields are being recorded from real events and will appear here after the first delivery. The example below is the standard envelope.
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "payment.refunded",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}order.cancelledSALESPROAn order was cancelled
| Field | Type |
|---|---|
data.object.total | integer |
data.object.orderId | string (uuid) |
data.object.orderCode | string |
data.object.cancelledAt | string (date-time) |
data.object.cancelledBy | string (uuid) |
data.object.channelCode | null |
data.object.cancelReason | string |
data.object.crmContactId | string (uuid) |
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "order.cancelled",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {
"total": 0,
"orderId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"orderCode": "string",
"cancelledAt": "2026-10-27T09:00:00+07:00",
"cancelledBy": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"channelCode": null,
"cancelReason": "string",
"crmContactId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b"
}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}order.completedSALESPROAn order was completed — delivered and fully paid
| Field | Type |
|---|---|
data.object.items | object[] |
data.object.total | string |
data.object.orderId | string (uuid) |
data.object.vatMode | string |
data.object.branchId | string (uuid) |
data.object.subtotal | string |
data.object.orderCode | string |
data.object.orderType | string |
data.object.taxAmount | string |
data.object.assignedTo | string (uuid) |
data.object.paidAmount | string |
data.object.vatPercent | null |
data.object.channelCode | string |
data.object.completedAt | string (date-time) |
data.object.shippingFee | string |
data.object.vatIncluded | boolean |
data.object.crmContactId | string (uuid) |
data.object.customerName | string |
data.object.customerType | string |
data.object.customerEmail | string |
data.object.customerPhone | string |
data.object.paymentMethod | string |
data.object.discountAmount | string |
data.object.customerAddress | string |
data.object.customerTaxCode | string |
data.object.customerWardCode | null |
data.object.sourceContractId | string (uuid) |
data.object.sourceQuotationId | null |
data.object.customerDistrictCode | null |
data.object.customerProvinceCode | null |
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "order.completed",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {
"items": [
{
"total": "string",
"barcode": "string",
"serials": null,
"taxRate": "string",
"quantity": "string",
"subtotal": "string",
"unitCode": "string",
"unitName": "string",
"lotNumber": "string",
"productId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"taxAmount": "string",
"unitPrice": "string",
"expiryDate": "2026-10-27",
"productSku": "string",
"productName": "string",
"variantName": null,
"discountAmount": "string",
"warrantyMonths": 0
}
],
"total": "string",
"orderId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"vatMode": "string",
"branchId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"subtotal": "string",
"orderCode": "string",
"orderType": "string",
"taxAmount": "string",
"assignedTo": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"paidAmount": "string",
"vatPercent": null,
"channelCode": "string",
"completedAt": "2026-10-27T09:00:00+07:00",
"shippingFee": "string",
"vatIncluded": false,
"crmContactId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"customerName": "string",
"customerType": "string",
"customerEmail": "string",
"customerPhone": "string",
"paymentMethod": "string",
"discountAmount": "string",
"customerAddress": "string",
"customerTaxCode": "string",
"customerWardCode": null,
"sourceContractId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"sourceQuotationId": null,
"customerDistrictCode": null,
"customerProvinceCode": null
}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}order.status_changedSALESPROAn order changed status; fires on EVERY change, including to completed or cancelled
| Field | Type |
|---|---|
data.object.total | integer |
data.object.orderId | string (uuid) |
data.object.branchId | string (uuid) |
data.object.changedAt | string (date-time) |
data.object.changedBy | string (uuid) |
data.object.newStatus | string |
data.object.orderCode | string |
data.object.orderType | string |
data.object.assignedTo | string (uuid) |
data.object.channelCode | string |
data.object.crmContactId | string (uuid) |
data.object.previousStatus | string |
{
"id": "evt_0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"type": "order.status_changed",
"api_version": "v1",
"created_at": "2026-10-27T02:00:00+00:00",
"workspace_id": "0199a1b2-0000-7000-8000-000000000001",
"livemode": true,
"data": {
"object": {
"total": 0,
"orderId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"branchId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"changedAt": "2026-10-27T09:00:00+07:00",
"changedBy": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"newStatus": "string",
"orderCode": "string",
"orderType": "string",
"assignedTo": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"channelCode": "string",
"crmContactId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"previousStatus": "string"
}
},
"metadata": {
"source_service": "string",
"correlation_id": "string"
}
}Only events that are actually delivered are listed. Retired names (deal.created, deal.updated, deal.stage_changed, member.updated, user.updated, order.created, order.confirmed, product.created, product.updated) can no longer be subscribed to; an endpoint that still has them can switch them off.
4. Payload structure
Each request has a JSON body in the standard envelope:
{
"id": "evt_018f...d2", // ID sự kiện (idempotency)
"type": "contact.created", // = X-NextX-Event-Type
"api_version": "v1",
"created_at": "2026-06-16T03:00:00Z",
"workspace_id": "<workspace_guid>",
"livemode": true, // false nếu là test
"data": {
"object": { "id": "...", "...": "..." } // payload của entity
}
}data.object carries the event's object. Test events have livemode: false. There is currently no slim-payload option: every request carries the full data.object.
5. Headers
| Header | Meaning |
|---|---|
X-NextX-Event-Id | Event ID (bare GUID) — use it for idempotency. The body's id field is evt_ + the same GUID |
X-NextX-Event-Type | Event type, e.g. contact.created |
X-NextX-Timestamp | Unix seconds at signing time — check it to prevent replay |
X-NextX-Signature-256 | sha256=<hex> — HMAC-SHA256 of `timestamp.body` with the current secret |
X-NextX-Signature-256-Previous | Only present during the 7 days after a secret rotation: sha256=<hex>, same timestamp + body but signed with the OLD secret |
X-NextX-Delivery-Attempt | Delivery attempt number (1 = first) |
X-NextX-Webhook-Id | ID of the registered webhook (endpoint) |
webhook-id | Standard Webhooks — message id, stable across redeliveries (= X-NextX-Event-Id). Only with whsec_ secrets |
webhook-timestamp | Standard Webhooks — Unix seconds at signing time (= X-NextX-Timestamp) |
webhook-signature | Standard Webhooks — v1,<base64> = HMAC-SHA256 of `id.timestamp.body`, key = base64-decoded part after whsec_. Two space-separated signatures during rotation |
User-Agent: NextX-Webhook/1.0. Custom headers set on the webhook (customHeaders) are also sent.
6. Verifying the signature
Fastest — Standard Webhooks: whsec_… secrets follow the open Standard Webhooks spec (standardwebhooks.com); each request also carries webhook-id, webhook-timestamp, webhook-signature. Use the standardwebhooks library (Node, Python, Go, Java, C#, PHP, Ruby…) — it checks the signature, clock skew and the dual signature during rotation for you. Webhooks created before 14/10/2026 (whk_sec_… secret) only have the X-NextX-* headers: rotate the secret once to get the standard headers too.
// Node.js — npm i standardwebhooks
import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.NEXTX_WEBHOOK_SECRET); // "whsec_..."
// Ném lỗi nếu chữ ký sai hoặc timestamp lệch quá 5 phút. headers = header thô của request.
const event = wh.verify(req.rawBody, req.headers);# Python — pip install standardwebhooks
import os
from standardwebhooks.webhooks import Webhook
wh = Webhook(os.environ["NEXTX_WEBHOOK_SECRET"]) # "whsec_..."
event = wh.verify(raw_body, headers) # raise nếu signature/timestamp saiOr verify the HMAC yourself — works with every secret, including whk_sec_…:
Signature = HMAC-SHA256(secret, `{ts}.{raw_body}`), output as lowercase hex, placed in X-NextX-Signature-256: sha256=<hex>. Recompute using the raw body (before JSON parsing) and X-NextX-Timestamp, then compare; reject if the timestamp drifts more than ~5 minutes (replay protection).
// Node.js (Express, cần raw body)
import crypto from "crypto";
function verify(req, secret) {
const ts = req.header("X-NextX-Timestamp");
const sig = req.header("X-NextX-Signature-256"); // "sha256=..."
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(ts + "." + req.rawBody) // timestamp.body
.digest("hex");
const ok = sig?.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
return ok && fresh;
}# Python
import hmac, hashlib, time
def verify(headers, raw_body, secret):
ts = headers["X-NextX-Timestamp"]
sig = headers["X-NextX-Signature-256"] # "sha256=..."
expected = "sha256=" + hmac.new(
secret.encode(), f"{ts}.{raw_body}".encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(sig, expected) and abs(time.time() - int(ts)) < 3007. Rotating the secret
Rotating (POST /api/v1/webhook/endpoints/{id}/rotate-secret) returns newSecret and oldSecretValidUntil. The old secret keeps working for a 7-day grace period: until that moment every request carries two signatures over the same timestamp.body — X-NextX-Signature-256 with the new secret and X-NextX-Signature-256-Previous with the old one, same sha256=<hex> format.
curl -X POST https://api.nextx.vn/api/v1/webhook/endpoints/<endpoint_id>/rotate-secret \
-H "Authorization: Bearer <USER_ACCESS_TOKEN>"
{
"newSecret": "whsec_Mf9KQr8GKYqr...",
"oldSecretValidUntil": "2026-10-15T03:12:56Z"
}So you can switch without downtime: keep both secrets on your side, accept a request when either header matches either secret, then drop the old secret once you have deployed the new one (at the latest by oldSecretValidUntil):
// Node.js — accept a request if ANY known secret matches ANY NextX signature header
function verifyDuringRotation(req, secrets /* [newSecret, oldSecret] */) {
const ts = req.header("X-NextX-Timestamp");
const sigs = [
req.header("X-NextX-Signature-256"), // signed with the NEW secret
req.header("X-NextX-Signature-256-Previous"), // signed with the OLD secret (7-day grace only)
].filter(Boolean);
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
const ok = secrets.filter(Boolean).some((secret) => {
const expected = "sha256=" + crypto
.createHmac("sha256", secret).update(ts + "." + req.rawBody).digest("hex");
return sigs.some((sig) => sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)));
});
return ok && fresh;
}After the grace period the -Previous header is no longer sent and only the new secret verifies. Rotating again during a grace period starts a new 7-day window with the secret that was current at that time as the "previous" one — the older secret stops verifying immediately.
8. Delivery & retry
- Success = the endpoint returns
2xxwithin 15s. - Up to 8 delivery attempts (attempt 1 immediately, then retries after 1 min, 5 min, 30 min, 2 h, 4 h, 8 h, 12 h — about 24 hours in total) on error/timeout; see
X-NextX-Delivery-Attempt(1 = first). After 8 failed attempts the delivery stays failed. - Idempotency: the same
id(evt_…) may arrive multiple times — dedupe byid. - Circuit breaker: an endpoint that fails 100 times in a row or more (the default threshold) is switched off automatically. It is not switched back on automatically and no notification is sent yet — re-enable it with
POST /endpoints/{id}/enableonce you have fixed the problem on your side. - SSRF guard: URLs pointing at internal/private IPs are blocked — only public HTTPS is accepted.
- You can retry one delivery manually:
POST /endpoints/{id}/deliveries/{deliveryId}/retry.
9. Test
Send a test event to your endpoint to check the integration:
curl -X POST https://api.nextx.vn/api/v1/webhook/endpoints/<endpoint_id>/test \
-H "Authorization: Bearer <USER_ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-d '{ "eventType": "contact.created" }'The test payload has livemode: false and a placeholder data.object (id: "test_object_id"), not real data. Any event name is accepted.