Delivery and security
How Kustomer delivers outbound webhooks. HMAC-SHA256 signatures, the retry schedule, at-least-once delivery, ordering, timeouts, automatic disabling and how to debug failed deliveries.
Kustomer delivers each event as an HTTPS POST and retries failures for about an hour. Your receiver should do three things: verify the signature, return 2xx quickly, and tolerate duplicates and reordering.
The request
POST /kustomer HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
X-Kustomer-Webhook-Event: kustomer.conversation.update
X-Kustomer-Webhook-Signature: 3f1c9a…e07b
X-Source: kustomer
{"id":"6717c2b1e5b1f00012ab3511","name":"kustomer.conversation.update",…}Verify signatures
The token returned when you create the webhook is the signing secret. You can also read it later with GET /v1/outbound-webhooks/{id}, or in Settings › Platform › Outbound webhooks › Edit › Header information.
signature = hex( HMAC_SHA256( key = token, message = raw request body ) )import crypto from 'node:crypto'
export function verifyKustomer(rawBody: Buffer, signature: string | undefined, token: string): boolean {
if (!signature) return false
const expected = crypto.createHmac('sha256', token).update(rawBody).digest('hex')
return signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
}Always compare in constant time (timingSafeEqual, compare_digest, hmac.Equal). A plain === leaks how many leading characters matched.
Rotate the token
There's no rotate endpoint. To rotate, create a second webhook with the same URL and events, update your receiver to accept either token, then delete the old webhook.
Respond fast
Kustomer waits up to 30 seconds for a response. Return 200 as soon as the signature checks out, then do the work from a queue. Slow responses tie up deliveries and count as failures when they time out.
Diagram source
sequenceDiagram participant K as Kustomer participant R as Your receiver participant Q as Queue participant W as Worker K->>R: POST event (signed) R->>R: verify signature R->>Q: enqueue raw body R-->>K: 200 OK (milliseconds) Q->>W: deliver W->>W: dedupe on event id, apply
Retries
A delivery fails on a timeout, a connection error, or a status of 400 or higher. Kustomer retries a failed event up to 8 attempts in total, over about 64 minutes:
Delays vary by about ±10% so retries from many events don't arrive in lockstep. If all 8 attempts fail, Kustomer drops that event. It isn't redelivered later, so plan to reconcile after an outage. A delivery whose host name doesn't resolve (a DNS failure) isn't retried at all.
- 1Attempt 1T+0 min↓ wait 1 min
- 2Attempt 2T+1 min↓ wait 1 min
- 3Attempt 3T+2 min↓ wait 2 min
- 4Attempt 4T+4 min↓ wait 5 min
- 5Attempt 5T+9 min↓ wait 10 min
- 6Attempt 6T+19 min↓ wait 15 min
- 7Attempt 7T+34 min↓ wait 30 min
- 8Attempt 8T+64 min
Delivery is at least once: even a 200 can be followed by a duplicate if the response is lost. Deduplicate on the envelope's id.
Duplicates and ordering
Delivery is at least once. The same event can arrive twice, for example if your 200 was lost on the way back. Deduplicate on the envelope's id:
async function handle(event: KustomerEvent) {
const firstTime = await redis.set(`kustomer:event:${event.id}`, '1', { NX: true, EX: 60 * 60 * 24 })
if (!firstTime) return
await apply(event)
}Order isn't guaranteed either: a retried event can arrive after a newer one for the same record. Compare data.attributes.updatedAt with what you stored and skip older snapshots:
INSERT INTO conversations (id, status, updated_at, doc)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE
SET status = EXCLUDED.status, updated_at = EXCLUDED.updated_at, doc = EXCLUDED.doc
WHERE conversations.updated_at < EXCLUDED.updated_at;Automatic disabling
If your endpoint keeps failing, Kustomer turns the webhook off so it stops sending to a dead destination. This is based on failures sustained over time, not a short blip: a brief outage or a burst of errors on a busy webhook won't disable it, but an endpoint that stays down for tens of minutes will. Successful deliveries count in your favor.
When a webhook is disabled:
enabledbecomesfalseandisErrorbecomestrue.- Admins see an error notification in Kustomer.
- Events that happen while it's off are not queued.
Fix the endpoint, then re-enable it with PATCH /v1/outbound-webhooks/{id} and { "enabled": true }, or with the toggle in Settings › Platform › Outbound webhooks. Re-enabling resets the failure history.
Debug deliveries
List the 25 most recent attempts for a webhook:
get/v1/outbound-webhooks/{webhookId}/transactions
{
"data": [
{
"type": "outbound_webhook_transaction_log",
"id": "5f2c62067aba570013e64bb1",
"attributes": {
"webhookId": "5f0f572d3919cc00123fd52e",
"eventName": "kustomer.conversation.update",
"sentAt": 1729522931273,
"status": "failed",
"nextRetry": "1729522991000"
}
}
]
}status is succeeded or failed. nextRetry is a millisecond timestamp for the next attempt, or null once the event succeeded or ran out of attempts.
Common causes of failures:
Reconcile after an outage
Webhooks are for low latency, not completeness. After an outage longer than about an hour, or after re-enabling a webhook, fetch what changed with search using an updated_at window that starts before the outage began. Because your handler ignores older snapshots, overlapping with events you already processed is harmless.
Network and security checklist
- HTTPS endpoint with a valid certificate
- Signature verified over the raw body, in constant time
- Token stored in a secret manager, not in code
-
200returned within a few seconds; work done asynchronously - Deduplication on event
id - Older snapshots ignored using
updatedAt - Alerting on non-2xx rates and on the webhook being disabled
- A reconciliation job for outages