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.sent
A category-scoped send (POST /v1/:businessAccountId/messages) was delivered.
message.failed
A category-scoped send could not be delivered.
grant.revoked
A user revoked a ChatX Login OAuth grant previously issued to your client.
webhook.test
Sent only by the console's Send test button. Not a subscribable event - every active endpoint receives it regardless of its configured event list.

Payload 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

HeaderDescription
X-ChatX-EventThe event type, e.g. message.sent. Matches the payload's type field.
X-ChatX-DeliveryA 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-SignatureProves the request came from ChatX and the body wasn't altered in transit. See verification below.
Content-Typeapplication/json.
User-AgentChatX-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:

  1. Parse t and v1 out of the header.
  2. Reject the request if t is 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.
  3. Recompute v1 from t and the exact raw body bytes you received, using the secret for that endpoint.
  4. Compare it to the header's v1 with 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.