> For the complete documentation index, see [llms.txt](https://docs.klink.cloud/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.klink.cloud/getting-started/setup-channels/live-chat/agent-reply-notifications-webhook.md).

# Agent Reply Notifications (Webhook)

When an agent replies to a visitor who isn't looking at the chat, K-Link POSTs a signed event to your server. You then send the push notification yourself (FCM, APNs, SFMC, ...). K-Link never needs your users' device tokens: you already know which device belongs to which user id.

## When an event is sent

All of these must be true:

1. **The channel has a webhook URL configured** (see Setup).
2. **The visitor has a verified identity.** They opened the chat with an `identity` (or `email`) and a valid `userHash` (see [Identity Verification (Secure User Login)](/getting-started/setup-channels/live-chat/identity-verification-secure-user-login.md)). Guests and unverified identity claims are never notified. Otherwise anyone could claim someone else's `identity` and have their own conversation's text pushed to that person's phone.
3. **The visitor isn't viewing the chat.** The chat is closed, the page or app is in the background, or they're disconnected. A visitor who is reading the chat doesn't get a push for the message they're already looking at.

## Setup

Use the K-Link public API with your `kpt_` token. `<channelId>` is the same value you pass to the widget as `scId`.

```bash
# Set the URL. The first time, the response includes the signing secret — ONCE.
curl -X PUT "https://apigw.klinkcx.com/api/v1/webchat/channels/<channelId>/notification-webhook" \
  -H "Authorization: Bearer kpt_xxx" -H "Content-Type: application/json" \
  -d '{ "url": "https://hooks.example.com/klink" }'
# => { "requestId": "…", "url": "https://hooks.example.com/klink", "secret": "3f9a…64 hex…" }

# Send a signed test event ("ping") now and see your server's status code
curl -X POST "https://apigw.klinkcx.com/api/v1/webchat/channels/<channelId>/notification-webhook/test" \
  -H "Authorization: Bearer kpt_xxx"
# => { "requestId": "…", "outcome": "delivered", "status": 200 }

# Status (never returns the secret)
curl "https://apigw.klinkcx.com/api/v1/webchat/channels/<channelId>/notification-webhook" \
  -H "Authorization: Bearer kpt_xxx"

# Rotate the secret (the old one stops working immediately)
curl -X POST "https://apigw.klinkcx.com/api/v1/webchat/channels/<channelId>/notification-webhook/secret" \
  -H "Authorization: Bearer kpt_xxx"

# Disable notifications
curl -X PUT … -d '{ "url": null }'
```

The URL must be `https` and point to a public host.

## The request

```http
POST /klink HTTP/1.1
Content-Type: application/json
X-Klink-Event: message.created
X-Klink-Delivery: 7c1e…            (same on every retry of this event)
X-Klink-Timestamp: 1790661369      (unix seconds, set per attempt)
X-Klink-Signature: sha256=5d2c…

{
  "event": "message.created",
  "channelId": "<channelId>",
  "visitor": { "identity": "ssoid_123", "verified": true },
  "conversationId": "…",
  "message": {
    "id": "…",
    "type": "text",
    "preview": "Your order #A1234 has been shipped and will arrive tomor…",
    "senderName": "Agent Mia",
    "sentAt": "2026-09-29T06:09:16.000Z"
  },
  "unreadCount": 3
}
```

* `visitor.identity` is the value you passed as `identity` (or `email`). Use it to find the user's devices.
* `message.preview` is at most 120 characters. Non-text messages read "Sent an attachment" or "Sent a message".
* `unreadCount` is the number of agent messages the visitor hasn't seen yet, including this one. Use it for the app icon badge. A message counts as seen once it has been on screen while the chat was open and visible.
* A `ping` event (`{ "event": "ping", "channelId": "…" }`) is sent only by the test endpoint.

## Verifying the signature

Compute `HMAC-SHA256(secret, timestamp + "." + rawBody)` over the **raw request body bytes** (before JSON parsing), and compare it with the signature in constant time. Reject timestamps older than 5 minutes; that stops replays.

```js
// Node.js / Express — use express.raw so the body stays byte-exact
app.post("/klink", express.raw({ type: "application/json" }), (req, res) => {
  const ts = req.header("X-Klink-Timestamp");
  const expected = "sha256=" + crypto
    .createHmac("sha256", process.env.KLINK_WEBHOOK_SECRET)
    .update(`${ts}.${req.body}`)
    .digest("hex");
  const given = req.header("X-Klink-Signature") || "";
  const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
  if (!fresh || given.length !== expected.length ||
      !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(req.body);
  // ... look up devices for event.visitor.identity, send the push ...
  res.sendStatus(200);
});
```

```kotlin
// Kotlin / Spring
val mac = Mac.getInstance("HmacSHA256").apply { init(SecretKeySpec(secret.toByteArray(), "HmacSHA256")) }
val expected = "sha256=" + mac.doFinal("$timestamp.$rawBody".toByteArray()).joinToString("") { "%02x".format(it) }
val valid = MessageDigest.isEqual(expected.toByteArray(), signature.toByteArray()) &&
            abs(Instant.now().epochSecond - timestamp.toLong()) < 300
```

## Delivery and retries

* Respond with any `2xx` quickly, within 5 seconds, and do the push asynchronously.
* Network errors, timeouts, `408`, `429` and `5xx` are retried twice (after about 2 s and 10 s). Other `4xx` responses aren't retried.
* Retries reuse the same `X-Klink-Delivery` value, so use it to skip duplicates.
* Delivery is best-effort: in rare cases an event can be lost. Use it for notifications, not as the only record of a message. The full conversation is always in klink.cloud.
* Redirects aren't followed.

## Unread counts in the widget

The chat bubble's unread badge uses the same count, so it stays correct after the page reloads.
