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:
{
"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
- In Kustomer, go to Settings > Platform > Outbound webhooks and select Add Outbound Webhook. You need to be an admin.
- Enter a name and your endpoint as the Destination URL.
- Under Webhook Trigger Events, choose the events you need.
- Select Save Changes.
- 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_ permission, the response's token field is the secret. Apps declare webhooks in their app definition instead:
{
"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 (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.
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:
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 verifies
X-Kustomer-Webhook-Signature, answers Kustomer with a200as 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
2xxwithin a few seconds and do the work in a background job, which is what Kustomer recommends. - Answer
2xxto 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, and 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-webhookwith 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 can reduce each event to the fields your service needs before delivery, and 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.attributesand 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.revto ignore an update older than the version you already hold.
How Hookdeck Can Help: Hookdeck's 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.
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.
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 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 for free and handle Kustomer webhooks reliably in minutes.