Browse the docs
Developers
Webhooks
Receive signed events when visitors leave details, leads change, conversations end, questions go unanswered or sources sync, and verify every delivery.
Last updated
On this page
Webhooks send what happens in your workspace to your own systems as it happens: a CRM, a help desk, Zapier, Make or your own code. ASDK POSTs a signed JSON event to every endpoint that subscribed to it, usually within a few seconds.
Events
| Event | When it is sent |
|---|---|
lead.created | A visitor left their contact details (chat, forms or an AI agent through WebMCP). |
lead.updated | A lead's email, name, company, phone, note, status or qualification changed. data.changes names the fields, data.previous their earlier values. |
conversation.closed | A conversation had no new message for 30 minutes. The payload carries the transcript (the latest 100 messages). If the visitor comes back and goes quiet again, it is sent again. |
knowledge_gap.created | Visitors asked something your knowledge base could not answer, for the first time. |
source.synced | A knowledge source finished syncing: status is ready, or error with the reason. |
Leads and conversations from the dashboard's preview are sent too, marked "preview": true, so you can test your integration end to end. The full payload of every event is in the API reference.
{
"id": "evt_2Hq7Lm9Xc4Tz8Pw1Nb6V",
"type": "lead.created",
"createdAt": "2026-10-24T09:12:44Z",
"workspaceId": "wsp_8fK2nQ7xLm3Pz9Tb4Wv1",
"data": {
"site": { "id": "site_…", "name": "Acme", "origin": "https://acme.com" },
"lead": {
"id": "lead_…",
"siteId": "site_…",
"conversationId": "conv_…",
"email": "jane@example.com",
"name": "Jane Doe",
"company": null,
"phone": null,
"note": null,
"consent": true,
"pageUrl": "https://acme.com/pricing",
"status": "new",
"score": null,
"qualifiedAt": null,
"createdAt": "2026-10-24T09:12:43Z",
"preview": false,
"dashboardUrl": "https://app.asdk.com/w/wsp_…/leads/lead_…"
}
}
}Add an endpoint
Create it
In the dashboard, open Settings → Webhooks, choose Add endpoint, enter a public https:// address and pick the events. Owners and admins manage endpoints; API keys with the write scope can too (POST /v1/workspaces/{id}/webhooks), which is how automation tools subscribe. A workspace can have 10 endpoints.
Store the signing secret
The secret (whsec_…) is shown once. Store it with your handler's configuration; you need it to verify deliveries.
Send a test event
Send test event posts a signed test event right away and shows what your endpoint answered. Use it until your handler answers with a 2xx.
What a delivery looks like
Each delivery is a POST with a JSON body and these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | ASDK-Webhooks/1.0 |
X-ASDK-Event | The event type, such as lead.created (or test). |
X-ASDK-Delivery | The delivery id (whd_…), the same on every retry. Use it to ignore duplicates. |
X-ASDK-Signature | t=<unix seconds>,v1=<signature> |
Answer with any 2xx within 15 seconds to confirm the delivery; do slow work after answering. ASDK does not follow redirects.
Verify the signature
The signature is the hex HMAC-SHA256 of the timestamp, a dot and the raw request body, keyed with the endpoint's secret: HMAC-SHA256(secret, "<t>.<raw body>"). To verify a delivery:
- Read the raw body before parsing it as JSON (parsing and serializing again changes the bytes).
- Split
X-ASDK-Signatureat commas intotand one or morev1values. - Reject the delivery when
tis more than five minutes from your clock (a replayed request). - Compute the HMAC and compare it with each
v1in constant time. Any match is valid.
JavaScript (Node.js)
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyAsdkSignature(secret, header, rawBody, now = Date.now()) {
let t = 0;
const signatures = [];
for (const part of (header ?? "").split(",")) {
const [key, value] = part.trim().split("=");
if (key === "t") t = Number(value);
if (key === "v1") signatures.push(value);
}
if (!t || Math.abs(now / 1000 - t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return signatures.some(
(s) => s.length === expected.length && timingSafeEqual(Buffer.from(s), Buffer.from(expected)),
);
}With @asdk/js, verifyWebhook({ secret, body, signature }) does the same with the Web Crypto API (Node.js, edge functions, Workers) and returns the parsed event.
Python
import hashlib
import hmac
import time
def verify_asdk_signature(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
t, signatures = 0, []
for part in (header or "").split(","):
key, _, value = part.strip().partition("=")
if key == "t" and value.isdigit():
t = int(value)
elif key == "v1":
signatures.append(value)
if not t or abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(s, expected) for s in signatures)Go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
)
// VerifyASDKSignature checks an X-ASDK-Signature header against the raw body.
func VerifyASDKSignature(secret, header string, body []byte, now time.Time) bool {
var t int64
var signatures []string
for _, part := range strings.Split(header, ",") {
key, value, _ := strings.Cut(strings.TrimSpace(part), "=")
switch key {
case "t":
t, _ = strconv.ParseInt(value, 10, 64)
case "v1":
signatures = append(signatures, value)
}
}
if t == 0 || now.Sub(time.Unix(t, 0)).Abs() > 5*time.Minute {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "%d.", t)
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
for _, s := range signatures {
if hmac.Equal([]byte(s), []byte(expected)) {
return true
}
}
return false
}Check your code
With the secret whsec_0123456789abcdef0123456789abcdef0123456789abcdef, the body {"id":"evt_test","type":"test"} and t=1767225600, the signature is:
9d0492ab65fa50095a75db12fab32250affedfafc76969e8feda47c4fa59d146Retries
Any 2xx answer is a delivery. ASDK retries 408, 429, 5xx answers and requests that got no answer (a timeout or a refused connection) after 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours; a Retry-After header makes the wait longer (up to 6 hours). Other answers, such as 400 or 404, fail the delivery at once. After 15 deliveries in a row fail for good, the endpoint is switched off; switch it on again on the Webhooks page once it is fixed.
The delivery log on the Webhooks page shows every delivery of the last 30 days with its payload, the status your endpoint answered and the start of its answer. Resend sends a delivery's event again.
Rotate the secret
Rotate secret creates a new secret and shows it once. For the next 24 hours every delivery is signed with both secrets (two v1 values), so you can deploy the new secret without missing an event. After that only the new secret signs.