WEBHOOKS
Webhooks
Receive asynchronous notifications when a message send resolves or a ChatX Login grant is revoked, and verify that each request genuinely came from ChatX.
Register an endpoint
Add an HTTPS endpoint from the Business console's Webhooks page, choosing which events it should receive. The console shows the signing secret exactly once, at creation - store it alongside your app token. Use Send test on an active endpoint to trigger a webhook.test delivery without waiting for a real event, and check Recent deliveries for attempt count, HTTP status, and the last error on any failed delivery.
Events
message.sentPOST /v1/:businessAccountId/messages) was delivered.message.failedgrant.revokedwebhook.testPayload envelope
Every delivery POSTs the same envelope, regardless of event:
{
"id": "9f1c9e2a-...", // unique per delivery attempt
"type": "message.sent", // matches X-ChatX-Event
"created_at": "2026-10-05T09:12:00Z",
"data": { /* event-specific, see below */ }
}message.sent / message.failed
{
"id": "9f1c9e2a-...",
"type": "message.sent",
"created_at": "2026-10-05T09:12:00Z",
"data": {
"message_send_id": "b1a2c3d4-...",
"category": "utility",
"recipient_user_id": "7e5d6c4b-...",
"status": "sent"
}
}grant.revoked
{
"id": "9f1c9e2a-...",
"type": "grant.revoked",
"created_at": "2026-10-05T09:12:00Z",
"data": {
"client_id": "4a3b2c1d-...",
"chatx_user_id": "7e5d6c4b-..."
}
}webhook.test
{
"id": "9f1c9e2a-...",
"type": "webhook.test",
"created_at": "2026-10-05T09:12:00Z",
"data": { "message": "ChatX Business webhook test" }
}Request headers
| Header | Description |
|---|---|
X-ChatX-Event | The event type, e.g. message.sent. Matches the payload's type field. |
X-ChatX-Delivery | A stable ID for this delivery attempt. Retries of the same event reuse it - use it to deduplicate if your handler isn't naturally idempotent. |
X-ChatX-Signature | Proves the request came from ChatX and the body wasn't altered in transit. See verification below. |
Content-Type | application/json. |
User-Agent | ChatX-Webhooks/1.0. |
Verify the signature
X-ChatX-Signature looks like this:
t=1770282720,v1=5257a869e7bfb...t is the Unix timestamp (seconds) the delivery was sent, and v1 is a hex-encoded HMAC-SHA256 computed over the timestamp and the raw, unparsed request body, joined by a literal .:
signed_payload = "{t}." + raw_request_body
expected_v1 = hex( HMAC_SHA256(key = webhook_secret, message = signed_payload) )To verify a delivery:
- Parse
tandv1out of the header. - Reject the request if
tis further than a few minutes from your own clock - ChatX does not resend an old delivery under a new timestamp, so a large skew means the request is stale or forged. Five minutes is a reasonable window. - Recompute
v1fromtand the exact raw body bytes you received, using the secret for that endpoint. - Compare it to the header's
v1with a constant-time comparison, not==.
Read the body as raw bytes for this check before any JSON parsing or re-serialization - re-encoding the body (different key order, whitespace, Unicode escaping) changes its bytes and breaks the signature even though the data is identical.
Node.js
import crypto from 'crypto'
function verifyChatXSignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')))
const timestamp = Number(parts.t)
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(rawBody) // Buffer or raw string - not JSON.parse(rawBody) re-stringified
.digest('hex')
const expectedBuf = Buffer.from(expected, 'hex')
const actualBuf = Buffer.from(parts.v1 ?? '', 'hex')
return expectedBuf.length === actualBuf.length && crypto.timingSafeEqual(expectedBuf, actualBuf)
}Python
import hmac, hashlib, time
def verify_chatx_signature(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
timestamp = int(parts.get("t", 0))
if not timestamp or abs(time.time() - timestamp) > tolerance_seconds:
return False
signed_payload = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))Whichever language you use, pull the secret from wherever you stored it at webhook creation - ChatX never shows it again, and there is no endpoint to retrieve it later. Delete and recreate the endpoint if it's lost or exposed.
Responding and retries
Respond 2xx as soon as you've accepted the delivery - do the real work after responding if it takes a while. ChatX waits up to 10 seconds for your response.
A timeout, 408, 429, or any 5xx response is retried automatically with exponential backoff (1, 2, 4, 8, then 16 minutes later), up to 6 attempts total. Any other 4xx is treated as permanent and not retried - fix the endpoint and use Send test to confirm before expecting real events to arrive. A paused endpoint (toggled off in the console) receives nothing until reactivated.
See the API reference for the message-send endpoints that trigger message.sent/message.failed, or the Business console's Webhooks page to register and manage endpoints.