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 Webhooks are available on all plans, including the Free plan.
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 4 seconds.
Choose the event that matches your workflow. Use conversation events to track new and resolved chats, message events to maintain transcripts, and contact events to synchronize profiles. Use contact.form.submitted for form-based follow-up, agent.assigned for routing updates, and automation.triggered for rule activity. Tag events describe label changes; custom-data events describe key/value changes. A webhook notifies your application that something happened. Use the REST API to retrieve more information or take an action. To build a receive-and-reply integration, follow the Quickstart.
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. Timeouts, network errors, and 5xx responses are retried. Every other status (1xx, 3xx, and all 4xx, including 408, 410, and 429) fails the delivery without a retry. |
| 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-4f70-8a9b-0c1d2e3f4a5b
X-Chatway-Delivery-Id: del_1b0279b1f28f12c0b9f2a2fdbf3758ee
X-Chatway-Delivery-Attempt: 1
X-Chatway-Timestamp: 1770201073
X-Chatway-Signature: sha256=1ee49edcc1e4bb0e58ed2ec2e054bac3a2cfabdd1546115542b4de0d791ca9b9
These values belong to the example message.received envelope shown under Event payload structure. The signature is real for that body and timestamp, signed with a placeholder secret (see Worked example below).
| 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_ + 32 lowercase hex characters). 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 this attempt was signed. Each retry is signed again with a new timestamp. Sent as a decimal string in the header. Use with signature verification to limit replay attacks. |
X-Chatway-Signature |
sha256= followed by the lowercase hex (64 characters) HMAC SHA256 of {timestamp}.{raw_request_body}, keyed with your webhook secret |
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 sha256= followed by the digest as 64 lowercase hex characters. The timestamp is the value of X-Chatway-Timestamp.
Verification steps
- Read the raw request body bytes before JSON parsing. Always verify against those exact bytes. Do not re-serialize parsed JSON (for example with
JSON.stringify(req.body)orjson_encode(json_decode(...))): whitespace, key order, and escaping can differ from what Chatway signed, and verification will fail. - Extract
X-Chatway-SignatureandX-Chatway-Timestamp. - Reject the request when the timestamp is more than 300 seconds in the past or in the future compared with your server clock (recommended; keep your clock in sync with NTP).
- Compute HMAC SHA256 over
{timestamp}.{raw_body}using your webhook secret. - Compare with the header using a constant-time comparison.
Worked example
Use these inputs to test your verification code. The secret is a placeholder, not a real webhook secret. The raw body is the example message.received envelope exactly as it is sent on the wire: compact JSON on one line, with no extra whitespace.
secret: whsec_exampleSecretForDocsOnly00000000
timestamp: 1770201073
raw body: {"id":"evt_1234567890abcdef1234567890abcdef","event":"message.received","data":{"message":{"id":"679f5ba4-3cbf-42eb-86dc-63566cec6b58","content":"Hello, I need help","attachments":[{"content":"https://cdn.example.com/files/invoice.pdf","type":"file","mime":"application/pdf","original_file_name":"invoice.pdf","size":204800}],"created_at":"2026-02-04T10:31:12Z"},"conversation":{"id":"0f46c0a1-8938-40e3-b2b5-bb56f569fcab","channel":"website","status":"open","widget_name":"Main site widget","identifier":"eouqtiupanm6uvglwshy"},"visitor":{"id":"9b0c5fe6-35ad-459d-b6ba-4288191f84f1","name":"John Doe","email":"[email protected]","phone":"+1XXXXXXXXXX"}},"occurred_at":"2026-02-04T10:31:12Z","version":"2026-09"}
Signing 1770201073. followed by that raw body with the secret produces:
X-Chatway-Signature: sha256=1ee49edcc1e4bb0e58ed2ec2e054bac3a2cfabdd1546115542b4de0d791ca9b9
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 expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`, "utf8")
.update(rawBody)
.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": [
{
"content": "https://cdn.example.com/files/invoice.pdf",
"type": "file",
"mime": "application/pdf",
"original_file_name": "invoice.pdf",
"size": 204800
}
],
"created_at": "2026-02-04T10:31:12Z"
},
"conversation": {
"id": "0f46c0a1-8938-40e3-b2b5-bb56f569fcab",
"channel": "website",
"status": "open",
"widget_name": "Main site widget",
"identifier": "eouqtiupanm6uvglwshy"
},
"visitor": {
"id": "9b0c5fe6-35ad-459d-b6ba-4288191f84f1",
"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_ + 32 lowercase hex characters; not a UUID). 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) | On visitor, omitted when empty. On contact (contact.created and contact.updated), always present; null when unknown. |
email |
string | null | Always present on visitor and contact; null when unknown. |
phone |
string | null | Always present on visitor and contact; null when unknown. |
country |
string (optional) | Visitor country code. On conversation.created only; omitted when unknown. |
ip |
string (optional) | Visitor IP address. On conversation.created only; omitted when unknown. |
first_page_url |
string (optional) | First page the visitor viewed. On conversation.created only; omitted when unknown. |
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 |
|---|---|---|
id |
string (UUID) | Message ID. |
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) | Size in bytes when known. |
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-Ididentifies a single HTTP attempt (del_…) and changes on each retry.X-Chatway-Delivery-Attemptis the attempt counter (1through4).occurred_atin 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 each delivery attempt. The limit covers the whole request: connecting, sending the body, and receiving your response. If your endpoint does not respond within 4 seconds, the attempt fails and is retried according to the retry policy.
Delivery & retry policy
Retry conditions
- Timeout (no response within 4 seconds)
- Network or connection error (for example DNS, TLS, or connection refused)
- HTTP
5xxresponse (500–599)
No retry
- Any other non-
2xxresponse is not retried:1xx,3xx(redirects are not followed), and every4xx, including408 Request Timeout,410 Gone, and429 Too Many Requests. - Deliveries that fail before a request is sent are not retried: an endpoint blocked by the network target rules, or an unavailable webhook secret.
- Returning
410does not automatically disable your webhook subscription; turn it off in Settings → Developer Tools → Webhooks if needed.
Retry schedule
- Attempt 1: sent when the event is dispatched.
- Attempt 2: immediately after attempt 1 fails.
- Attempt 3: 1 minute after attempt 2 fails.
- Attempt 4: 5 minutes after attempt 3 fails.
Each delay is measured from the moment the previous attempt failed. Delays are not cumulative offsets from the first attempt. Without counting request time and queue latency, the final attempt starts about 360 seconds after the first one fails. Each failed attempt can add up to 4 seconds.
Chatway makes up to 4 delivery attempts in total (the first attempt plus up to 3 retries). After the final failure, the delivery is marked as failed.
Required behavior
Verify the signature and timestamp before accepting an event. Save the verified event in your application's durable queue or inbox, then return a 2xx response within 4 seconds. Process business work asynchronously. Use the envelope id to prevent duplicate processing; X-Chatway-Delivery-Id identifies an individual delivery attempt. If an event is already safely stored, acknowledge the duplicate without processing it again. If temporary storage fails, return 5xx so Chatway can retry. The examples above demonstrate signature verification; add durable storage and processing to complete your receiver.