# Guide to Chatim Webhooks: Features and Best Practices

Chatim webhooks tell your systems what is happening in your website chat: a visitor opens the widget, sends a message, fills in a form in a chatbot flow, or gets handed to a human agent. If you run live chat or chatbots with Chatim, webhooks are how those conversations and leads reach your CRM, your team's chat tools or your analytics as they happen.

This guide covers how Chatim webhooks are created and signed, the six chat events and their payloads, Chatim's retry and auto-disable rules, and the practices that keep a Chatim receiver reliable.

## What are Chatim webhooks?

A Chatim webhook belongs to a project. You give it a URL and pick the events it should receive, and Chatim sends each event as an HTTP `POST` with a JSON body. Every body carries the event name, a unique `eventId`, the project and chat UUIDs, and an event-specific `data` object.

Each request is signed. Chatim computes an HMAC-SHA256 over the `X-Chatim-Timestamp` header, a dot and the payload, keyed with the webhook's secret, and sends it in `X-Chatim-Signature` as `sha256=<hex>`. Chatim shows the secret once, straight after you create the webhook.

## Chatim webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard: select the project, then **Webhooks > Create Webhook** |
| Subscription model | One or more events per webhook |
| Signature header | `X-Chatim-Signature: sha256=<hex>` |
| Timestamp header | `X-Chatim-Timestamp`, Unix seconds |
| Other headers | `X-Chatim-Event` (the event type), `X-Chatim-Delivery-Id` (a unique delivery ID) |
| Scheme | HMAC-SHA256, hex |
| Signed content | `<X-Chatim-Timestamp>.<raw body>` |
| Secret | Per webhook, shown once. **Regenerate Secret** invalidates the old one immediately |
| Response window | 30 seconds |
| Retries | 5xx and timeouts: after 1 minute, 5 minutes, 30 minutes, 2 hours and 24 hours. 4xx: none |
| Auto-disable | After 10 consecutive failures |
| Testing | **Test** sends a sample `chat.started` |
| Delivery history | Kept for 30 days |
| Source IPs | Not published |

## Common events

| Event | Fires when |
| --- | --- |
| `chat.started` | A visitor opens the chat widget and begins a new session |
| `chat.message.received` | A visitor sends a text message |
| `chat.form.submitted` | A visitor submits a form in the chatbot flow |
| `chat.closed` | A chat session is closed |
| `chat.assigned` | A support agent joins the conversation |
| `chat.handoff` | The chatbot hands the visitor over from bot mode to live chat |

Every delivery shares these common fields:

```json
{
  "event": "chat.started",
  "eventId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "timestamp": "2025-01-15T10:30:00.000Z",
  "projectUuid": "your-project-uuid",
  "chatUuid": "visitor-chat-uuid",
  "data": { }
}

```

The `data` object depends on the event. Chatim's `chat.form.submitted` example, which is the one most integrations want for lead capture, looks like this:

```json
{
  "event": "chat.form.submitted",
  "data": {
    "formNodeId": "node-id",
    "formName": "Contact Form",
    "fields": {
      "name": "John Doe",
      "phone": "+1234567890",
      "message": "I'd like a product demo."
    },
    "visitor": { "uuid": "visitor-uuid", "name": "John Doe" }
  }
}

```

`chat.started` carries the visitor, the page URL and title, a location and a parsed user agent. `chat.closed` carries `closedBy` (agent, visitor, system or timeout), `duration` in seconds and `messageCount`. Chatim's description of `chat.closed` also mentions a transcript, which its example doesn't show. The per-event examples leave out the common fields, so expect both together in a real delivery.

## Setting up Chatim webhooks

1. Log in to the Chatim dashboard, select your project in the sidebar, and click **Webhooks**.
2. Click **Create Webhook**, give it an optional name, and enter your HTTPS endpoint URL.
3. Select the events you want and click **Create**.
4. Copy the **Webhook Secret** that appears and store it server-side, for example as `CHATIM_WEBHOOK_SECRET`. Chatim shows it only once.
5. Click **Test** to send a sample `chat.started` to your endpoint.

If the secret is compromised, click **Regenerate Secret**. The old one stops working immediately, so update your receiver at the same time.

If you receive Chatim webhooks through Hookdeck, create a source with the Chatim type, use its URL as the webhook URL, and paste the Webhook Secret into the source. The source accepts `POST` only, which is the method Chatim uses.

## Securing Chatim webhooks

Read `X-Chatim-Timestamp` and `X-Chatim-Signature`. Build the signed bytes from the timestamp exactly as received, a literal `.`, and the raw request body. Compute an HMAC-SHA256 of them with the secret as a UTF-8 key, hex-encode it, and compare it with the digest after `sha256=` in constant time.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyChatimSignature(rawBody, signatureHeader, timestampHeader, secret, toleranceSeconds = 0) {
  if (!signatureHeader || !timestampHeader || !secret) return false;

  // The header is "sha256=<hex>". Accept a bare digest too.
  const digest = signatureHeader.trim().replace(/^sha256=/, '');
  if (!/^[0-9a-fA-F]{64}$/.test(digest)) return false; // check before Buffer.from(x, 'hex')

  // Optional replay window, off by default. Retries and replays resend the original timestamp.
  if (toleranceSeconds > 0) {
    if (!/^\d+$/.test(timestampHeader)) return false;
    if (Math.abs(Date.now() / 1000 - Number(timestampHeader)) > toleranceSeconds) return false;
  }

  const expected = crypto
    .createHmac('sha256', secret)
    .update(Buffer.concat([Buffer.from(`${timestampHeader}.`, 'utf8'), rawBody]))
    .digest();
  const received = Buffer.from(digest, 'hex');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

app.post('/webhooks/chatim', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.CHATIM_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  const valid = verifyChatimSignature(
    req.body,
    req.get('x-chatim-signature'),
    req.get('x-chatim-timestamp'),
    secret
  );
  if (!valid) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.eventId, route on event.event, then process asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import re
import time

def verify_chatim_signature(raw_body: bytes, signature_header, timestamp_header, secret: str,
                            tolerance_seconds: int = 0) -> bool:
    if not signature_header or not timestamp_header or not secret:
        return False
    digest = signature_header.strip().removeprefix("sha256=")
    if not re.fullmatch(r"[0-9a-fA-F]{64}", digest):
        return False
    # Optional replay window, off by default.
    if tolerance_seconds > 0:
        if not (timestamp_header.isascii() and timestamp_header.isdigit()):
            return False
        if abs(time.time() - int(timestamp_header)) > tolerance_seconds:
            return False
    signed = timestamp_header.encode("utf-8") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(digest.lower().encode("utf-8"), expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_chatim_signature(raw_body, request.headers.get("x-chatim-signature"),
#                         request.headers.get("x-chatim-timestamp"),
#                         os.environ["CHATIM_WEBHOOK_SECRET"])

```

Chatim's own Node example signs `JSON.stringify(req.body)`, which is the body after a JSON parser has run, and it calls `crypto.timingSafeEqual` without a length check. Its troubleshooting section says the opposite about the body: sign `timestamp + "." + raw_body`, "not parsed JSON". Use the raw body. Chatim's Python example puts `body` into an f-string, which only works if `body` is already a decoded string. Pass bytes and you sign the `b'...'` representation instead.

Both of Chatim's examples reject requests older than 5 minutes, and its best practices say to validate the timestamp. The samples above leave that off unless you pass a tolerance. Hookdeck applies no timestamp tolerance, and its retries and replays resend Chatim's original `X-Chatim-Timestamp`, so a 5-minute window in your own code would reject a legitimate retry that arrives later. `X-Chatim-Event` isn't part of the signed content, so route on the verified body's `event` field rather than on the header.

> **Make Chatim webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-Chatim-Signature` header over the timestamp and raw body, stores every chat and lead event, and delivers it to your service with retries you control.

## Chatim webhook limitations and pain points

### A 4xx is never retried

**The Problem:** Events disappear after a deploy that briefly returned `401` or `404`.

**Why It Happens:** Chatim treats a 4xx response as a permanent failure and doesn't retry it. Only 5xx responses and timeouts are retried. A misconfigured secret, a moved route or a validation error all lose the event for good.

**Workarounds:**

* Return a 5xx, not a 4xx, for failures that are on your side and temporary, so Chatim retries them.
* Watch the delivery history in the dashboard after each deploy.

**How Hookdeck Can Help:** Hookdeck answers Chatim with a `200` as soon as it has stored the request, so your own 4xx responses never reach Chatim. Hookdeck then delivers to your service with [retries](/docs/retries) you configure, and you can replay anything that failed. A request that fails signature verification is recorded as failed verification and doesn't reach your destination.

### Ten failures switch the webhook off

**The Problem:** After a short outage, Chatim stops sending events entirely.

**Why It Happens:** A webhook that accumulates 10 consecutive failures is disabled automatically, and stays off until someone clicks **Enable** in the dashboard.

**Workarounds:**

* Respond within the 30-second window and keep the handler light.
* Check the webhook's **Active** state after incidents, and look at the disabled reason the dashboard shows.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as it arrives, so your downtime doesn't count against Chatim's failure limit.

### The sample signs re-serialised JSON

**The Problem:** Signatures match in some tests and fail on real traffic, especially messages with special characters.

**Why It Happens:** Chatim's Node example signs `JSON.stringify(req.body)`. That only reproduces Chatim's bytes if your parser and serialiser happen to produce the same JSON.

**Workarounds:**

* Mount `express.raw()` on the webhook route and sign the bytes you received.
* Parse JSON only after verification.

**How Hookdeck Can Help:** Hookdeck's Chatim source verifies against the raw body it received, so the signature check is independent of whatever your framework does to the body.

### Rotating the secret has no overlap

**The Problem:** Requests fail verification between regenerating the secret and deploying the new one.

**Why It Happens:** **Regenerate Secret** invalidates the old secret at once.

**Workarounds:**

* Regenerate only when you can deploy the new secret immediately.
* Remember that during the gap, your `401`s are 4xx failures that Chatim won't retry.

**How Hookdeck Can Help:** With Hookdeck in front, the secret lives in the Chatim source. Update it there as you regenerate, and requests that failed in between are still stored.

## Best practices

### Verify against the raw body

The signature covers the exact bytes Chatim sent. In Express, use `express.raw({ type: '*/*' })`. In Next.js, use `Buffer.from(await request.arrayBuffer())`. In FastAPI, use `await request.body()`.

### Deduplicate on `eventId`

Retries resend the same event, and Chatim recommends idempotency based on `eventId`. Store each `eventId` before processing and skip repeats. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Choose your status codes deliberately

Return `200` once the event is stored. For temporary failures on your side, return a 5xx so Chatim retries. Keep 4xx responses for requests you never want again, such as ones that fail verification.

### Acknowledge fast and process asynchronously

Chatim waits 30 seconds. Verify, persist the event, return `200`, and push CRM or Slack calls to a background job. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Chatim signs every webhook with a hex HMAC-SHA256 of the timestamp, a dot and the raw body, sent as `sha256=<hex>` in `X-Chatim-Signature`. Save the secret when it is shown, verify the raw bytes rather than re-serialised JSON, deduplicate on `eventId`, and be careful with 4xx responses, which Chatim never retries.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, verifies every Chatim signature, and holds each chat event until your service has processed it, so your own errors don't cost you events or a disabled webhook.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Chatim webhooks reliably in minutes.