Webhooks overview
This document provides detailed technical information for developers integrating with Chatway Webhooks, including event schemas, delivery behavior, security, retries, and best practices.
Chatway sends HTTP POST requests to your configured HTTPS endpoint when a subscribed event occurs.
- Webhooks are event-driven, near real-time, and use at-least-once delivery.
- Configure webhooks from the Chatway app under Settings → Developer Tools → Webhooks.
- Your endpoint must respond with any 2xx status within the configured timeout.
Replying to a visitor message
For the full end-to-end tutorial (subscribe to message.received, verify the signature, read data.conversation.id, and call POST /messages), start with Quickstart → Receive a message and send a reply in the sidebar.
Endpoint requirements
| Requirement |
Value |
| Supported protocol |
HTTPS only |
| Request method |
POST |
| Expected response |
Any 2xx HTTP status code acknowledges receipt. 5xx, timeouts, and network errors are retried; most 4xx responses (including 410) are not. |
| Redirects |
Not followed. Your endpoint must respond directly (no 3xx redirect chain). |
| Network targets |
Public HTTPS endpoints only. Private, loopback, and link-local addresses (for example 127.0.0.1, 10.0.0.0/8, 169.254.169.254) are blocked at delivery time. |
Request headers
Every webhook request includes the following headers:
Content-Type: application/json
X-Chatway-Event: message.received
X-Chatway-Webhook-Id: 9f3c2a1b-4d5e-6f70-8a9b-0c1d2e3f4a5b
X-Chatway-Delivery-Id: del_456
X-Chatway-Delivery-Attempt: 1
X-Chatway-Timestamp: 1785933000
X-Chatway-Signature: sha256=3c9f5e...
| Header |
Description |
X-Chatway-Event |
Event name (for example, message.received) |
X-Chatway-Webhook-Id |
Unique webhook subscription ID |
X-Chatway-Delivery-Id |
Unique ID for this HTTP delivery attempt (del_…). A new value is sent on each retry. |
X-Chatway-Delivery-Attempt |
Attempt number for this event (1 on first try, then 2, 3, … up to 4). Sent as a decimal string in the header. |
X-Chatway-Timestamp |
Unix timestamp in seconds when the request was signed. Sent as a decimal string in the header. Use with signature verification to limit replay attacks. |
X-Chatway-Signature |
HMAC SHA256 of {timestamp}.{raw_request_body} |
Signature verification
Chatway signs each request using HMAC SHA256.
Signature generation
signed_payload = timestamp + "." + raw_request_body
HMAC_SHA256(secret, signed_payload)
The header value is prefixed with sha256=. The timestamp is the value of X-Chatway-Timestamp.
Verification steps
- Read the raw request body before JSON parsing.
- Extract
X-Chatway-Signature and X-Chatway-Timestamp.
- Reject requests whose timestamp is older than 300 seconds (recommended).
- Compute HMAC SHA256 over
{timestamp}.{raw_body} using your webhook secret.
- Compare using a constant-time comparison.
Example (Node.js + Express)
const crypto = require("crypto");
const express = require("express");
const app = express();
// Preserve raw body for signature verification (required).
app.post(
"/webhooks/chatway",
express.raw({ type: "application/json" }),
(req, res) => {
const secret = process.env.CHATWAY_WEBHOOK_SECRET;
const signature = req.headers["x-chatway-signature"];
const timestamp = req.headers["x-chatway-timestamp"];
const rawBody = req.body; // Buffer
if (!verifyChatwaySignature(rawBody, signature, timestamp, secret)) {
return res.status(401).send("Invalid signature");
}
res.status(200).send("ok");
},
);
function verifyChatwaySignature(rawBody, signatureHeader, timestampHeader, secret) {
if (!signatureHeader || !timestampHeader || !secret) {
return false;
}
const timestamp = Number(timestampHeader);
if (!Number.isFinite(timestamp)) {
return false;
}
const toleranceSeconds = 300;
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) {
return false;
}
const signedPayload = `${timestamp}.${rawBody.toString("utf8")}`;
const expected = crypto.createHmac("sha256", secret).update(signedPayload).digest("hex");
const expectedHeader = `sha256=${expected}`;
const expectedBuffer = Buffer.from(expectedHeader);
const actualBuffer = Buffer.from(signatureHeader);
if (expectedBuffer.length !== actualBuffer.length) {
return false;
}
return crypto.timingSafeEqual(expectedBuffer, actualBuffer);
}
Example (PHP)
function verifyChatwaySignature(
string $rawBody,
?string $signatureHeader,
?string $timestampHeader,
string $secret,
int $toleranceSeconds = 300
): bool {
if ($signatureHeader === null || $signatureHeader === '' || $timestampHeader === null || $timestampHeader === '') {
return false;
}
$timestamp = filter_var($timestampHeader, FILTER_VALIDATE_INT);
if ($timestamp === false) {
return false;
}
if (abs(time() - $timestamp) > $toleranceSeconds) {
return false;
}
$signedPayload = $timestamp.'.'.$rawBody;
$expected = 'sha256='.hash_hmac('sha256', $signedPayload, $secret);
return hash_equals($expected, $signatureHeader);
}
// Laravel example
$rawBody = $request->getContent();
$signature = $request->header('X-Chatway-Signature');
$timestamp = $request->header('X-Chatway-Timestamp');
$secret = config('services.chatway.webhook_secret');
if (! verifyChatwaySignature($rawBody, $signature, $timestamp, $secret)) {
abort(401, 'Invalid webhook signature.');
}
Event payload structure
All webhook payloads share a common envelope:
{
"id": "evt_1234567890abcdef1234567890abcdef",
"event": "message.received",
"data": {
"message": {
"id": "679f5ba4-3cbf-42eb-86dc-63566cec6b58",
"content": "Hello, I need help",
"attachments": [],
"created_at": "2026-02-04T10:31:12Z"
},
"conversation": {
"id": "750c42a1-1ce1-40ae-beba-4cc66f2b1023",
"channel": "website",
"status": "open",
"widget_name": "Main site widget",
"identifier": "wdg_abc123"
},
"visitor": {
"id": "bfc39f4b-e8aa-4e1c-8e6a-162a5d869702",
"name": "John Doe",
"email": "[email protected]",
"phone": "+1XXXXXXXXXX"
}
},
"occurred_at": "2026-02-04T10:31:12Z",
"version": "2026-09"
}
| Field |
Type |
Description |
id |
string |
Stable event ID (evt_…). Same value on every retry for this notification. |
event |
string |
Event name |
data |
object |
Event-specific payload documented on each webhook below. |
occurred_at |
ISO 8601 |
Event timestamp |
version |
string |
Payload version (2026-09) |
Field reference
Common types, optional fields, and normalized channel values used across events.
Envelope
| Field |
Type |
Notes |
id |
string |
Stable event ID (evt_…). Identical across HTTP retries for the same notification to your endpoint. Use for idempotent processing. |
event |
string |
Event name (for example message.received). |
data |
object |
Event-specific payload. |
occurred_at |
string (ISO 8601) |
When the business event occurred. Stable across retries for a given id. |
version |
string |
Payload schema version. |
Conversation
| Field |
Type |
Notes |
id |
string (UUID) |
Conversation ID. |
channel |
string |
Normalized channel: website, email, messenger, whatsapp, fb, instagram. |
status |
string |
open or resolved. |
widget_name |
string (optional) |
Present when the conversation is tied to a website widget. |
identifier |
string (optional) |
Widget identifier when widget_name is present. |
resolved_at |
string (ISO 8601) (optional) |
Present on conversation.resolved when status is resolved. |
created_at |
string (ISO 8601) (optional) |
Present on conversation.created. |
first_page_url |
string (optional) |
Present on conversation.created when available. |
Agent
| Field |
Type |
Notes |
id |
string (UUID) |
Agent ID. |
name |
string |
Agent display name. |
email |
string (optional) |
Agent email when available. Used on agent.assigned, conversation.resolved, and message.sent. |
Visitor contact
| Field |
Type |
Notes |
id |
string (UUID) |
Visitor/contact ID. |
name |
string |
null (optional) |
email |
string |
null |
phone |
string |
null |
Contact source
| Field |
Type |
Notes |
source |
string |
Page URL when the visitor came from the widget (for example https://example.com/pricing), or a normalized channel label (email, messenger, whatsapp, fb) for integrated channels. |
Assigned by
| Field |
Type |
Notes |
assigned_by |
object |
Who performed the action. Contains exactly one of: agent (id, name, email), automation (id UUID, name), or system (empty object). Used on tag.assigned, custom.data.assigned, and agent.assigned. |
Removed by
| Field |
Type |
Notes |
removed_by |
object |
Same shape as assigned_by. Who removed the tag or custom data: agent, automation (id UUID, name), or system (empty object). Used on tag.removed and custom.data.removed. |
Resolved by
| Field |
Type |
Notes |
resolved_by |
object |
Same shape as assigned_by. Who resolved the conversation: agent, automation (id UUID, name), or system (empty object). Used on conversation.resolved. |
Message
| Field |
Type |
Notes |
content |
string (optional) |
Message text. May be omitted when attachments are present. |
attachments |
array |
Zero or more attachment objects (see below). |
Attachment
| Field |
Type |
Notes |
content |
string (URL) |
Public URL of the file. |
type |
string (optional) |
Logical type (for example image, file, video). |
mime |
string (optional) |
MIME type when known. |
original_file_name |
string (optional) |
Original filename when available. |
size |
integer |
null (optional) |
Contact updated
| Field |
Type |
Notes |
previous_values |
object |
Map of field names to values before the update. Only includes keys listed in updated_fields. |
current_values |
object |
Map of field names to values after the update. |
Custom data
| Field |
Type |
Notes |
custom_data |
object |
Single key/value pair for custom.data.assigned and custom.data.removed. Values are strings or JSON-serializable scalars; complex objects may appear depending on how the field was stored. |
Limits
| Field |
Type |
Notes |
attachment URLs |
string (optional) |
Up to 2048 characters per URL (aligned with upload validation). |
attachments per message |
integer (optional) |
Typically up to 9 files per message in the product UI. |
Idempotency & retries
- Use the envelope field
id (evt_…) to detect duplicate processing of the same business event. It stays the same across HTTP retries to your endpoint.
X-Chatway-Delivery-Id identifies a single HTTP attempt (del_…) and changes on each retry.
X-Chatway-Delivery-Attempt is the attempt counter (1 through 4).
occurred_at in the JSON body is fixed when the event is first dispatched and does not change on retry.
Timeout & processing requirements
Chatway enforces a strict 4-second timeout for webhook deliveries. If your endpoint does not respond within 4 seconds, the delivery is marked as failed and retried according to the retry policy.
Delivery & retry policy
Retry conditions
- Timeout
- Network error
- HTTP
5xx response
No retry
- HTTP
4xx responses (including 410 Gone) are not retried for that delivery attempt.
- Returning
410 does not automatically disable your webhook subscription; turn it off in Settings → Developer Tools → Webhooks if needed.
Retry schedule
- Immediate
- +1 minute
- +5 minutes
- +15 minutes
Chatway makes up to 4 delivery attempts. After the final failure, the delivery is marked as failed.
Required behavior
- Respond immediately with a
2xx HTTP status code.
- Do not perform heavy processing before sending the response.
- Acknowledge receipt first, then process the payload asynchronously.
- Verify
X-Chatway-Signature and X-Chatway-Timestamp on every request.
- Handle duplicate deliveries idempotently using the envelope
id field (evt_…), not X-Chatway-Delivery-Id.