API Docs

Webhooks

AdminUpdated Sep 19, 2026

Webhooks

Subscribe to platform events — the event vocabulary, the HMAC signature scheme, and what the retry budget actually is.

Webhooks are how Chatly tells your systems about things that happen. Eighteen event types can be subscribed to; each delivery is signed with an HMAC and retried on a short exponential schedule.

The signature scheme is Stripe's — t=<seconds>,v1=<hex> over <t>.<rawBody>, with a timestamp tolerance window — so if you have written a Stripe verifier you have written this one. The subscription management API is much smaller than Stripe's: create, list, delete.

Info — Webhooks vs realtime

Webhooks are your-server-to-us push — best for backend integrations (sync to CRM, fan out to internal services). The realtime WebSocket is browser-friendly push — best for the dashboard UI. Don't replicate the dashboard via webhooks; you'll hate yourself.

Register a subscription

POST /v1/webhooks — Bearer token

{
  "url": "https://your-app.com/webhooks/chatly",
  "eventTypes": ["conversation.created", "conversation.closed", "message.created"],
  "description": "Sync conversations to internal CRM"
}

The field is eventTypes, and at least one is required. Each entry must be one of the names in Event types — an unrecognised name is a 400 validation_failed for the whole request.

We respond with the subscription record, including the secret:

{
  "id": "01H7...",
  "workspaceId": "01H7...",
  "url": "https://your-app.com/webhooks/chatly",
  "description": "Sync conversations to internal CRM",
  "eventTypes": ["conversation.created", "conversation.closed", "message.created"],
  "enabled": true,
  "secret": "whsec_..."
}

Warning — Save the signing secret now

The create response is the only place the secret is returned; GET /v1/webhooks does not include it, and there is no rotate endpoint. If you lose it, delete the subscription (DELETE /v1/webhooks/{id}) and create a new one.

The whole subscription API is these four routes — create, list, delete, and the event catalogue. There is no update: to change a URL or the event list, delete and re-create.

GET    /v1/webhooks
GET    /v1/webhooks/events
POST   /v1/webhooks
DELETE /v1/webhooks/{id}

All four need a signed-in user with the integrations:read / integrations:write permission. A workspace API key cannot manage subscriptions.

Event types

This is the whole vocabulary. GET /v1/webhooks/events returns it as { "eventTypes": [...] } — a flat list of names, which is what the dashboard's picker renders and what the create endpoint validates against.

Subscribable events

Name

Type

Description

conversation.created

event

A new conversation opened.

conversation.updated

event

Conversation fields changed.

conversation.assigned

event

Routing assigned the conversation to an agent or team.

conversation.unassigned

event

The assignee was cleared.

conversation.closed

event

Resolved.

conversation.reopened

event

Re-opened after being closed.

message.created

event

Any new message, from any author.

message.read

event

A message was marked read.

contact.created

event

A new contact.

contact.updated

event

Contact fields changed.

contact.merged

event

Two contacts were merged.

contact.unsubscribed

event

The contact opted out.

ticket.created

event

A ticket was opened.

ticket.status_changed

event

Ticket status moved.

ticket.resolved

event

Ticket resolved.

survey.response_received

event

A survey response landed.

survey.low_csat

event

A response below the CSAT threshold — the one worth paging on.

kb.article_published

event

For mirroring published content into your own site or search index.

Delivery envelope

Every delivery body has the same two-key shape:

{
  "type": "conversation.created",
  "payload": { /* event-specific */ }
}

The event name also travels in the x-livechat-event header, and the issuing timestamp in x-livechat-timestamp, so a router can dispatch without parsing the body. The body is always JSON.

Warning — There is no event id in the envelope

The envelope carries no delivery id, no createdAt and no workspaceId — so you cannot deduplicate on an id we send. A retry re-sends the identical bytes, which means the x-livechat-timestamp header and the signature change between attempts too. Deduplicate on something inside payload that identifies the underlying object, and make your handler idempotent.

Signature verification

Every webhook delivery includes:

x-livechat-signature: t=1714060800,v1=ab12...cd34
x-livechat-timestamp: 1714060800
x-livechat-event: conversation.created
  • t is the issuing Unix timestamp, in seconds.

  • v1 is the lowercase hex HMAC-SHA-256 of <t>.<rawBody> using the subscription's signing secret.

The header is x-livechat-signature — the internal package scope, not the brand name. x-livechat-timestamp repeats the same t for convenience; verify against the t inside the signature value, since that is the one bound into the digest.

Warning — You MUST verify the signature

An unverified webhook is an unauthenticated POST from anywhere on the internet. Verify or you've shipped a remote-execution endpoint.

Verifying

Node SDK (recommended)

import { verifyWebhookSignature } from '@livechat/sdk-node';

app.post('/webhooks/chatly', express.raw({ type: 'application/json' }), (req, res) => {
  const ok = verifyWebhookSignature({
    secret: process.env.CHATLY_WEBHOOK_SECRET!,
    signatureHeader: req.header('x-livechat-signature')!,
    body: req.body, // Buffer or string — the raw bytes we POSTed
    // toleranceSeconds defaults to 300 (WEBHOOK_SIGNATURE_TOLERANCE_SECONDS)
  });
  if (!ok) return res.status(400).send('invalid signature');

  const event = JSON.parse(req.body.toString('utf8'));
  // handle event...
  return res.status(200).send('ok');
});

Node (no SDK)

import crypto from 'node:crypto';

function verify(rawBody: Buffer, header: string, secret: string, toleranceSec = 300): boolean {
  const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
  const t = parseInt(parts.t, 10);
  if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(parts.v1 ?? '', 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Python

import hmac, hashlib, time

def verify(raw_body: bytes, header: str, secret: str, tolerance_s: int = 300) -> bool:
    parts = dict(p.split("=") for p in header.split(","))
    t = int(parts["t"])
    if abs(time.time() - t) > tolerance_s:
        return False
    expected = hmac.new(secret.encode(), f"{t}.{raw_body.decode()}".encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

Ruby

require 'openssl'

def verify(raw_body, header, secret, tolerance: 300)
  parts = header.split(',').to_h { |p| p.split('=') }
  t = parts['t'].to_i
  return false if (Time.now.to_i - t).abs > tolerance
  expected = OpenSSL::HMAC.hexdigest('sha256', secret, "#{t}.#{raw_body}")
  Rack::Utils.secure_compare(expected, parts['v1'].to_s)
end

Go

func Verify(rawBody []byte, header, secret string, toleranceSec int64) bool {
    parts := map[string]string{}
    for _, p := range strings.Split(header, ",") {
        kv := strings.SplitN(p, "=", 2)
        parts[kv[0]] = kv[1]
    }
    t, _ := strconv.ParseInt(parts["t"], 10, 64)
    if abs(time.Now().Unix()-t) > toleranceSec {
        return false
    }
    h := hmac.New(sha256.New, []byte(secret))
    fmt.Fprintf(h, "%d.%s", t, rawBody)
    return hmac.Equal([]byte(parts["v1"]), []byte(hex.EncodeToString(h.Sum(nil))))
}

Info — Always use raw body, not parsed JSON

JSON parsing reorders keys, normalizes whitespace, etc. Once you've re-serialized, the signature won't match. Capture the raw bytes before parsing.

Retries

A delivery is attempted up to 8 times with exponential backoff, base 1 second — so roughly at 0s, 1s, 2s, 4s, 8s, 16s, 32s and 64s. The whole sequence is over in about two minutes; it absorbs a deploy or a blip, not an outage. The request timeout is 8 seconds.

Not every failure is retried. We retry when we got no response at all (DNS failure, refused connection, timeout), on any 5xx, and on 408, 409, 423, 425 and 429. Every other 4xx is your endpoint telling us the request itself is wrong — a dead route, a revoked token, a body it will never accept — so it is recorded as dead immediately rather than repeated eight times.

Info — Subscriptions are not auto-paused

A subscription stays enabled no matter how many deliveries fail, and we do not alert the workspace. Monitor your own endpoint; nothing here will disable it for you.

Replay protection

The t in the signature header is the issue time. Reject any delivery where |now - t| > 300 — a 5-minute tolerance, which is what the platform signs for and what the SDK verifier defaults to. This stops an attacker from replaying a captured webhook hours or days later.

Delivery logs

Every attempt is written to a webhook_deliveries row — the event type, the payload, the attempt number, the HTTP status, the latency, the error, the outcome (delivered, failed while retries remain, or dead), and the next retry time.

Warning — Not yet readable from anywhere

Those rows are written but no API endpoint and no dashboard screen reads them back, and there is no manual re-delivery. Until that ships, treat your own endpoint's logs as the record of what arrived, and keep the raw body of anything you reject.

Troubleshooting

Warning — Constant signature failures, even with the right secret

99% of the time: you're verifying against the parsed JSON, not the raw body. Or your framework is decompressing gzip / decoding before your handler runs. Capture raw bytes.

Info — Out-of-order deliveries

We don't promise order — retries reorder events. If your handler needs strict ordering, key by the conversation or contact id inside payload and reconcile against the REST API rather than trusting arrival order.

Warning — An event that never arrives is gone

Eight attempts inside two minutes is the whole retry budget, and there is no replay. If your endpoint is down for longer than that, backfill from the REST API — GET /v1/conversations, GET /v1/contacts — rather than waiting for a re-delivery that is not coming.

Was this page helpful?