# Guide to Kustomer Webhooks: Features and Best Practices

Kustomer outbound webhooks send customer service activity to your own systems. When a conversation opens, a customer record changes, a message arrives or an agent joins a team, Kustomer can post the event to your endpoint, so you can sync CRM data, update an order system, log conversations to a warehouse, or trigger automation outside Kustomer.

This guide covers how Kustomer outbound webhooks are created and signed, the events and the Event object they carry, the retry and auto-disable rules, and the best practices for running a Kustomer receiver in production.

## What are Kustomer webhooks?

An outbound webhook in Kustomer is a destination URL plus a set of trigger events. When one of those events happens, Kustomer sends a `POST` whose body is a Kustomer Event object, "an updated snapshot of the updated or created object along with any changes". The payload is fixed: Kustomer says you "cannot filter, customize, or modify which fields the payload includes".

Every request is signed. Kustomer generates a secret for each webhook and sends a hex HMAC-SHA256 of the raw body in the `X-Kustomer-Webhook-Signature` header. You don't choose the secret, and there is no unsigned mode.

## Kustomer webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Settings > Platform > Outbound webhooks** (admins), an app definition's `outboundWebhooks`, or `POST /v1/outbound-webhooks` |
| Subscription model | Any of ten create and update events per webhook |
| Payload | A fixed Kustomer Event object, JSON |
| Custom headers | Supported in app definitions |
| Signature header | `X-Kustomer-Webhook-Signature` |
| Scheme | HMAC-SHA256, hex digest |
| Signed content | The raw request body, nothing else |
| Secret | Generated by Kustomer after the first save. The `token` field in the API |
| Replay window | None. No timestamp is sent or signed |
| Success | Any `2xx` response |
| Retries | 10 attempts over about 80 minutes |
| Auto-disable | After 10 consecutive failures, with no notification |

Kustomer asks for a response "within a few seconds" but publishes no exact timeout. It publishes no source IP list, though it mentions firewall, VPN and DNS rules as a common cause of failed deliveries.

## Common events

Kustomer has ten outbound webhook events:

| Object | Events | Fires when |
| --- | --- | --- |
| Customer | `kustomer.customer.create`, `kustomer.customer.update` | A customer is created or updated |
| Conversation | `kustomer.conversation.create`, `kustomer.conversation.update` | A conversation is created or updated |
| Message | `kustomer.message.create`, `kustomer.message.update` | A message is created or updated |
| User | `kustomer.user.create`, `kustomer.user.update` | A user is created or updated |
| Team | `kustomer.team.create`, `kustomer.team.update` | A team is created or updated |

Org-level events such as `org.user.login`, `org.permission.update`, `app.install` and `app.uninstall` are not available as outbound webhooks.

The event type is the body's `name`. There is no event header. Here is Kustomer's `kustomer.conversation.update` sample, heavily trimmed:

```json
{
  "id": "608738af3c16f2a7c22a36c2",
  "name": "kustomer.conversation.update",
  "org": "5f8481c6a22ab5b9d5f2dd8a",
  "partition": "5f8481c6a22ab5b9d5f2dd8a",
  "data": {
    "type": "conversation",
    "id": "60873865d43fde605b8003bf",
    "attributes": {
      "channels": ["sms"],
      "status": "open",
      "messageCount": 1,
      "priority": 3,
      "rev": 2,
      "updatedAt": "2021-04-26T22:03:27.857Z"
    },
    "relationships": {
      "customer": {
        "data": { "type": "customer", "id": "5fcea6ca9725f81c5ffbebdd" },
        "links": { "self": "/v1/customers/5fcea6ca9725f81c5ffbebdd" }
      }
    },
    "links": { "self": "/v1/conversations/60873865d43fde605b8003bf" }
  },
  "createdAt": "2021-04-26T22:03:27.872Z",
  "changes": {
    "attributes": {
      "channels": { "op": "replace", "before": [], "after": ["sms"] },
      "messageCount": { "op": "replace", "before": 0, "after": 1 }
    },
    "relationships": {}
  },
  "persist": true
}

```

The top-level `id` identifies the event, and `data.id` the object. `data` follows JSON:API conventions, with the object's fields under `attributes` and links to related objects under `relationships`. On update events, `changes.attributes` lists each changed field with `op`, `before` and `after`, which saves you diffing the snapshot yourself. Some samples also carry `orgName` and `client`, for example `"client": "api"`. Real bodies are much larger than this one: Kustomer always sends the complete object, custom attributes included.

## Setting up Kustomer webhooks

1. In Kustomer, go to **Settings > Platform > Outbound webhooks** and select **Add Outbound Webhook**. You need to be an admin.
2. Enter a name and your endpoint as the **Destination URL**.
3. Under **Webhook Trigger Events**, choose the events you need.
4. Select **Save Changes**.
5. Reopen the webhook with **Edit** and scroll to **Header Information** to copy the secret. Kustomer only generates it after the first save. Store it server-side, for example as `KUSTOMER_WEBHOOK_SECRET`.

If you create the webhook with `POST /v1/outbound-webhooks`, using an API key with the `org.permission.outbound_webhook` permission, the response's `token` field is the secret. Apps declare webhooks in their app definition instead:

```json
{
  "outboundWebhooks": [
    {
      "name": "Send conversation events to our service",
      "url": "https://example.com/webhooks/kustomer",
      "headers": [{ "name": "x-special-info-source", "value": "kustomer" }],
      "events": ["kustomer.conversation.create", "kustomer.conversation.update"]
    }
  ]
}

```

Kustomer has no test button. Create or update a customer or conversation to send a delivery. For local development, the [Hookdeck CLI](/docs/cli) (`hookdeck listen 3000 kustomer --path /webhooks/kustomer`) gives you a public HTTPS URL that forwards to your local server, with no account required.

## Securing Kustomer webhooks

Read `X-Kustomer-Webhook-Signature`, compute an HMAC-SHA256 of the raw request body with the webhook secret as the key, encode it as hex and compare in constant time. Kustomer's own example compares with `===`, which leaks timing, so use a constant-time comparison instead.

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

function verifyKustomerSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/kustomer', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifyKustomerSignature(req.body, req.get('x-kustomer-webhook-signature'), process.env.KUSTOMER_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Route on event.name. Store event.id, then process asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import os

def verify_kustomer_signature(raw_body: bytes, signature_header, secret: str) -> bool:
    if not signature_header or not secret:
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    received = signature_header.strip().lower().encode("utf-8")
    return hmac.compare_digest(received, expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_kustomer_signature(raw_body, request.headers.get("x-kustomer-webhook-signature"),
#                           os.environ["KUSTOMER_WEBHOOK_SECRET"])

```

To check your verifier, use the secret `kustomer-test-secret` and this exact body:

```
{"id":"6087326fa8b1c7b07da5e5c9","name":"kustomer.customer.update","org":"5f8481c6a22ab5b9d5f2dd8a","data":{"type":"customer","id":"608731c238e15a90895f7029"}}

```

The signature should be:

```
2a5dc61590f61396bcf983e292fb36abed15d0ea31ac7de4557ca97aa70b7620

```

Each webhook has its own secret. If you point several webhooks at one endpoint, give each its own path so you know which secret to use.

> **Make Kustomer webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies `X-Kustomer-Webhook-Signature`, answers Kustomer with a `200` as soon as each event arrives, and durably queues it so a slow endpoint never switches the webhook off.

## Kustomer webhook limitations and pain points

### Ten failures and the webhook switches off silently

**The Problem:** Events stop arriving and nobody notices until data is missing downstream.

**Why It Happens:** Kustomer attempts each event 10 times over about 80 minutes. If 10 requests in a row fail, through a timeout such as `ESOCKETTIMEDOUT`, a non-2xx response or a connection error, the webhook is turned off and stays off until someone toggles it back on. Kustomer says plainly: "There is currently no automatic notification when a webhook is disabled."

**Workarounds:**

* Return a `2xx` within a few seconds and do the work in a background job, which is what Kustomer recommends.
* Answer `2xx` to events you do not handle.
* Check the webhook's status regularly, or alert when events stop arriving during business hours.
* Add addresses under **Settings > Platform > Org Logging > Notifications** to receive error emails.

**How Hookdeck Can Help:** Hookdeck accepts each request as soon as it arrives, so your own outages never count towards Kustomer's limit. It then retries delivery to your service on [your own schedule](/docs/retries), and [issues](/docs/issues) alert you when deliveries to your service fail.

### Error emails for problems that already resolved

**The Problem:** You get an error email, but the delivery log shows the event as sent.

**Why It Happens:** Kustomer sends an error email whenever a request gets a non-2xx response, "even if a subsequent retry succeeds". The email reflects the first failure, not the outcome.

**Workarounds:**

* Check **Settings > Platform > Org Logging** or call `GET /v1/logs/search/outbound-webhook` with the time range before acting on an email.
* Reduce transient failures by acknowledging quickly.

**How Hookdeck Can Help:** Hookdeck records every attempt to your service with its response, so you can see which events failed and which recovered without piecing it together from emails.

### Large, unfiltered payloads

**The Problem:** Each request carries the whole object, and high-volume events such as `kustomer.message.create` add up quickly.

**Why It Happens:** Payloads are predefined and always complete. Kustomer suggests you "transform or filter the payload on the receiving end before passing it downstream".

**Workarounds:**

* Subscribe each webhook only to the events you use.
* Extract the fields you need as soon as the event is verified, and store those rather than the whole body.

**How Hookdeck Can Help:** Hookdeck [transformations](/docs/transformations) can reduce each event to the fields your service needs before delivery, and [filters](/docs/filters) can drop events you don't want, such as updates that only changed `updatedAt`.

### Updates that change nothing you care about

**The Problem:** Your handler runs for every `kustomer.customer.update`, even when only a timestamp or revision number changed.

**Why It Happens:** Any change to the object fires an update, and Kustomer's own samples show `updatedAt`, `modifiedAt`, `lastActivityAt` and `rev` in `changes.attributes`.

**Workarounds:**

* Look at the keys of `changes.attributes` and skip events that changed only bookkeeping fields.

**How Hookdeck Can Help:** A Hookdeck filter can pass only events whose `changes.attributes` include the fields you care about.

### No timestamp means no replay protection

**The Problem:** A captured request still verifies if it is sent again later.

**Why It Happens:** The signature covers only the body, and no timestamp header is sent.

**Workarounds:**

* Deduplicate on the event `id`, which also handles retries.
* Use `data.attributes.rev` to ignore an update older than the version you already hold.

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/deduplication) can drop repeated requests based on the event `id`.

## Best practices

### Verify against the raw body

Mount `express.raw({ type: '*/*' })` in Express, use `await request.text()` in a Next.js route handler, or read `await request.body()` in FastAPI. Parse only after verifying.

### Deduplicate on the event ID

Retries can deliver an event more than once. Store the top-level `id` before processing and skip any you have seen. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Use `rev` to keep the latest state

Retries and parallel deliveries can arrive out of order. When you store an object's state, keep the highest `data.attributes.rev` you have seen and ignore older updates.

### Acknowledge fast and process asynchronously

Slow responses count as failures towards the auto-disable limit. Verify, store and return `200`, then process from a queue. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Monitor the webhook itself

Since Kustomer does not tell you when it disables a webhook, monitor for silence. An hour without conversation events during a working day is usually a disabled webhook, not a quiet support queue.

## Conclusion

Kustomer signs every outbound webhook with a hex HMAC-SHA256 of the raw body, keyed with a secret it generates after the first save. Verify `X-Kustomer-Webhook-Signature` in constant time, route on `name`, deduplicate on `id`, use `changes` and `rev` to skip noise and stale updates, and respond within a few seconds, because ten failures in a row switch the webhook off without telling anyone.

[Hookdeck Event Gateway](https://hookdeck.com) checks every signature, answers Kustomer at once and holds each event until your service has processed it, so the webhook stays on through your deploys and outages.

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