Webhooks
Realtime updates: signed deliveries, verified in your language, retried for a day.
A webhook is how the platform tells your system what happened the moment it happens - a customer's reply, a delivery receipt, a conversation handed to an agent, a lead moving stage - without you polling. Register an HTTPS endpoint in the developer portal, choose the events, and every one arrives as a signed POST within seconds, retried for a day if your endpoint is down.
Events
| Event | When | data carries |
|---|---|---|
message.received | A customer sent a message on one of your allow-listed channels. | message |
message.queued | A message you (or an agent) sent was accepted and queued for delivery. | message |
message.sent | The channel provider accepted the message. | message |
message.delivered | The provider confirmed delivery to the customer's device. | message |
message.read | The customer read the message (where the channel reports it). | message |
message.failed | The provider could not deliver the message; `error` says why. | message |
conversation.created | A new conversation was opened with a contact on an allow-listed channel. | conversation |
conversation.assigned | A conversation was assigned to an agent or a team, or unassigned. | conversation, previous_user_id, previous_team_id |
conversation.status_changed | A conversation was resolved, closed, reopened or marked pending. | conversation, previous_status |
contact.created | A contact was created - by an inbound message, an agent, an import or the API. | contact |
contact.updated | A contact's details, identities or tags changed. | contact |
lead.created | A lead was created for a contact. | lead |
lead.stage_changed | A lead moved to another pipeline stage. | lead, from_stage, to_stage |
bot.started | A chatbot started talking to a customer on a conversation. | conversation, bot |
bot.handoff | A chatbot handed a conversation to a person; `bot.answers` is what it learned. | conversation, bot |
bot.completed | A chatbot session ended without a handoff - finished, timed out, stopped by an agent or failed; `bot.outcome` says which. | conversation, bot |
webhook.test | A test delivery sent from the developer portal. | test |
Every message event about something you sent carries the external_reference you gave it, so the delivery of invoice INV-10025 reaches you as an event that says INV-10025 - no lookup needed.
The delivery
POST https://your-system.example.com/hooks/messaging
Content-Type: application/json
User-Agent: CommunicationPlatform-Webhooks/1.0
X-Webhook-ID: 6d2c0a84-1f2e-4b3c-9d4e-5f6a7b8c9d0e the event id; the same on every retry
X-Webhook-Timestamp: 1789456931 unix seconds this attempt was signed
X-Webhook-Signature: sha256=4f1c2a…e9b0 see below
X-Event-ID / X-Event-Type / X-Event-Timestamp the same values under their original names
X-Delivery-ID / X-Delivery-Attempt this attempt, 1-based
{
"id": "6d2c0a84-1f2e-4b3c-9d4e-5f6a7b8c9d0e",
"type": "message.delivered",
"created_at": "2026-09-17T09:13:02+00:00",
"external_reference": "ORDER-10025",
"data": {
"message": {
"id": "3a1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f",
"external_reference": "ORDER-10025",
"conversation_id": "9c0e1f2a-3b4c-4d5e-8f60-718293a4b5c6",
"contact_id": "b7f2c3a4-5d6e-4f70-8a91-b2c3d4e5f607",
"connection_id": "e51a2b3c-4d5e-4f60-8172-8394a5b6c7d8",
"direction": "outbound",
"type": "template",
"status": "delivered",
"body": "Hi Rahul, your order 10025 is confirmed.",
"attachments": [],
"error": null,
"sent_at": "2026-09-17T09:13:00+00:00",
"delivered_at": "2026-09-17T09:13:02+00:00",
"read_at": null
}
}
}
data carries the resource the event is about under its own key (message, conversation, contact, lead) in exactly the shape the reference documents, plus the event's extras - previous_status on a status change, from_stage and to_stage on a stage move. The reference lists every event with its full payload under Webhooks.
Answer any 2xx within 10 seconds and the delivery is done. Do the real work after you have answered - queue it - so a slow database on your side never turns into a retry storm on ours.
Verifying the signature
Every delivery is signed with your endpoint's secret (whsec_…, shown once when the endpoint is created and again only when rotated). The signature is an HMAC-SHA256 over the timestamp and the raw body, joined by a dot:
X-Webhook-Signature: sha256=hex( HMAC-SHA256( secret, timestamp + "." + raw_body ) )
Verify it before you trust anything in the body, and reject a timestamp more than five minutes old - the timestamp is inside the signed string, so a captured delivery cannot be replayed later with a fresh-looking header. Compare in constant time.
Sign the bytes you received, not a re-encoded version of them. Parsing the JSON and serialising it again changes key order or whitespace and the signature will not match.
# Shell (for a quick check, not a handler): recompute the signature over a saved raw body.
# $TIMESTAMP is the X-Webhook-Timestamp header; body.json holds the bytes exactly as received.
printf '%s.' "$TIMESTAMP" | cat - body.json \
| openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" \
| sed 's/^.* /sha256=/'
# Compare the output with the X-Webhook-Signature header.
// Node.js: verify X-Webhook-Signature over the RAW body (use express.raw(), not express.json()).
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyWebhookSignature(secret, timestamp, rawBody, header, toleranceSeconds = 300) {
if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
return false; // too old, or from the future: a replay
}
const expected = "sha256=" + createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header ?? "");
return a.length === b.length && timingSafeEqual(a, b); // constant time
}
// Express example
// app.post("/hooks/messaging", express.raw({ type: "application/json" }), (req, res) => {
// const ok = verifyWebhookSignature(
// process.env.WEBHOOK_SECRET,
// req.header("X-Webhook-Timestamp"),
// req.body.toString("utf8"),
// req.header("X-Webhook-Signature"),
// );
// if (!ok) return res.sendStatus(401);
// const event = JSON.parse(req.body.toString("utf8"));
// // Dedupe on event.id (also in X-Webhook-ID), queue the work, answer fast.
// res.sendStatus(200);
// });
# Python: verify X-Webhook-Signature over the RAW body (request.get_data(), not request.json).
import hashlib
import hmac
import time
def verify_webhook_signature(secret: str, timestamp: str, raw_body: bytes, header: str, tolerance_seconds: int = 300) -> bool:
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance_seconds:
return False # too old, or from the future: a replay
signed = timestamp.encode() + b"." + raw_body
expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header or "") # constant time
# Flask example
# @app.post("/hooks/messaging")
# def hook():
# ok = verify_webhook_signature(
# os.environ["WEBHOOK_SECRET"],
# request.headers.get("X-Webhook-Timestamp", ""),
# request.get_data(),
# request.headers.get("X-Webhook-Signature", ""),
# )
# if not ok:
# return "", 401
# event = request.get_json()
# # Dedupe on event["id"] (also in X-Webhook-ID), queue the work, answer fast.
# return "", 200
<?php
// Verify X-Webhook-Signature. Use the RAW request body - never a re-encoded one.
function verifyWebhookSignature(string $secret, string $timestamp, string $rawBody, string $header, int $toleranceSeconds = 300): bool
{
if (! ctype_digit($timestamp) || abs(time() - (int) $timestamp) > $toleranceSeconds) {
return false; // too old, or from the future: a replay
}
$expected = 'sha256='.hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);
return hash_equals($expected, $header); // constant time
}
// In a Laravel/Symfony/plain PHP handler:
$rawBody = file_get_contents('php://input');
$ok = verifyWebhookSignature(
getenv('WEBHOOK_SECRET'),
$_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '',
$rawBody,
$_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '',
);
if (! $ok) {
http_response_code(401);
exit;
}
$event = json_decode($rawBody, true);
// Dedupe on $event['id'] (also in X-Webhook-ID), then queue the work and answer 200 fast.
http_response_code(200);
// Java 17+: verify X-Webhook-Signature over the RAW body bytes.
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
public final class WebhookSignature {
public static boolean verify(String secret, String timestamp, byte[] rawBody, String header, long toleranceSeconds) throws Exception {
if (!timestamp.matches("\\d+") || Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(timestamp)) > toleranceSeconds) {
return false; // too old, or from the future: a replay
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
mac.update(rawBody);
String expected = "sha256=" + HexFormat.of().formatHex(mac.doFinal());
return MessageDigest.isEqual( // constant time
expected.getBytes(StandardCharsets.UTF_8),
(header == null ? "" : header).getBytes(StandardCharsets.UTF_8)
);
}
}
// In a servlet / Spring handler: read request.getInputStream() fully into rawBody
// BEFORE any JSON binding, call verify(...), answer 401 when false, then dedupe
// on the event id (X-Webhook-ID), queue the work and answer 200.
// C# (.NET 6+): verify X-Webhook-Signature over the RAW body bytes.
using System;
using System.Security.Cryptography;
using System.Text;
public static class WebhookSignature
{
public static bool Verify(string secret, string timestamp, byte[] rawBody, string? header, long toleranceSeconds = 300)
{
if (!long.TryParse(timestamp, out var ts) || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > toleranceSeconds)
{
return false; // too old, or from the future: a replay
}
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var prefix = Encoding.UTF8.GetBytes(timestamp + ".");
var signed = new byte[prefix.Length + rawBody.Length];
Buffer.BlockCopy(prefix, 0, signed, 0, prefix.Length);
Buffer.BlockCopy(rawBody, 0, signed, prefix.Length, rawBody.Length);
var expected = "sha256=" + Convert.ToHexString(hmac.ComputeHash(signed)).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals( // constant time
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(header ?? string.Empty));
}
}
// ASP.NET Core: enable buffering and read Request.Body fully into rawBody BEFORE
// model binding, call Verify(...), return 401 when false, then dedupe on the
// event id (X-Webhook-ID), queue the work and return 200.
// Go: verify X-Webhook-Signature over the RAW body bytes.
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"time"
)
func VerifyWebhookSignature(secret, timestamp string, rawBody []byte, header string, tolerance time.Duration) bool {
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if d := time.Since(time.Unix(ts, 0)); d > tolerance || d < -tolerance {
return false // too old, or from the future: a replay
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(rawBody)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(header)) // constant time
}
// In an http.Handler: rawBody, _ := io.ReadAll(r.Body) before any JSON decoding,
// then VerifyWebhookSignature(secret, r.Header.Get("X-Webhook-Timestamp"), rawBody,
// r.Header.Get("X-Webhook-Signature"), 5*time.Minute); 401 when false; dedupe on
// X-Webhook-ID, queue the work, answer 200.
The developer portal can send a webhook.test event on demand so you can prove the check end to end before a real one arrives.
Retries and ordering
A non-2xx answer, a timeout (10 seconds) or a connection failure is retried on a schedule: 1 minute, 5 minutes, 15 minutes, 1 hour, 6 hours, 24 hours, then the delivery is marked failed and stays replayable from the portal. Twenty-five consecutive failures disable the endpoint and the workspace's administrators are told.
Two consequences for your handler:
- Deliveries can arrive twice. A retry after a timeout you actually handled is the common case. Treat
X-Webhook-ID(the body'sid) as the dedupe key: store it, and drop a repeat. - Deliveries can arrive out of order. A
message.deliveredretried an hour later lands after themessage.readthat succeeded the first time. Apply state by the timestamps insidedata(sent_at,delivered_at,read_at), or by the natural order of statuses, never by arrival.
The portal
Settings → Developer → your application → Webhooks shows every endpoint, its secret's age, and every delivery with the payload sent, the response received and a Replay button. Request logs sit beside it: every call your application made, its status and its request id.
https://communication-api.artofluminaire.com
Every response carries X-Request-ID; quote it when you write to support.