Webhooks
Signed, idempotent event delivery.
HMAC-SHA256 signed event delivery for Link AI Voice and Mailer. Event catalog, signature verification, retry semantics, and replay protection.
Webhooks are how Link AI tells your backend that something happened — a call ended, a prospect replied, a campaign hit its send budget. Every delivery is a POST to a URL you control, with a JSON body and two signature headers.
Setup
Add an endpoint in Settings → Developers → Webhooks. Each endpoint receives its own signing secret — keep them narrowly scoped per service so a leak in one environment does not blast the others.
- Endpoint URL must be HTTPS.
localhostis rejected. - You may subscribe to a subset of event types per endpoint, or use the wildcard
*. - Per-workspace limit: 25 active endpoints. Email office@linkaiil.com for more.
Payload envelope
Every event ships in the same wrapper. The data object varies by type; everything else is stable.
{
"id": "evt_01HFE9XQR4...",
"type": "voice.call.completed",
"created_at": "2026-05-21T09:18:44Z",
"workspace_id": "ws_2b9c1f3a",
"api_version": "2026-05-01",
"data": {
"call_id": "call_01HFE9X1G6...",
"agent_id": "agt_5fd1a02b",
"to": "+972541234567",
"duration_seconds": 142,
"outcome": "answered",
"transcript_url": "https://api.linkaiil.com/v1/voice/calls/.../transcript",
"metadata": { "lead_id": "lead_9182" }
}
}Event types
| Event | Fired when |
|---|---|
voice.call.queued | A call has been accepted and is waiting for the carrier dial-out. |
voice.call.started | The remote party picked up. Timestamp marks the start of billable minutes. |
voice.call.completed | Call ended cleanly. Carries transcript URL + outcome. |
voice.call.failed | Call did not connect (no-answer, busy, blocked, carrier error). |
mailer.email.sent | A generated email cleared the throttle and shipped via SmartLead. |
mailer.reply.received | A prospect replied. Payload includes the parsed reply body and sentiment. |
mailer.campaign.completed | Every lead in a campaign has reached a terminal state. |
workspace.plan.upgraded | Workspace switched to a higher tier. Useful for unlocking feature gates downstream. |
Verifying signatures
Link AI signs every delivery with HMAC-SHA256 over the raw request body. The hex digest lands in X-Linkai-Signature; the delivery wall-clock timestamp lands in X-Linkai-Timestamp (Unix seconds). Reject anything older than five minutes to neutralise replay.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.LINKAI_WEBHOOK_SECRET!;
const TOLERANCE_SECONDS = 300;
app.post(
"/linkai/webhook",
// Critical: raw body, NOT express.json().
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.header("X-Linkai-Signature") ?? "";
const timestamp = Number(req.header("X-Linkai-Timestamp") ?? "0");
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) {
return res.status(401).end("stale");
}
const expected = crypto
.createHmac("sha256", SECRET)
.update(req.body)
.digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).end("bad signature");
}
const event = JSON.parse(req.body.toString("utf8"));
handleEvent(event); // your business logic
res.status(204).end();
},
);curl + openssl (for tests)
# Replay a captured payload against your local handler.
BODY=$(cat payload.json)
SECRET=whsec_test
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')
curl -X POST http://localhost:3000/linkai/webhook \
-H "Content-Type: application/json" \
-H "X-Linkai-Timestamp: $(date +%s)" \
-H "X-Linkai-Signature: $SIG" \
--data-raw "$BODY"Retry semantics
- Any non-2xx response (or a connect/read timeout over 30 seconds) marks the delivery as failed.
- Failed deliveries retry with exponential backoff:
1m, 5m, 30m, 2h, 12h, 24h. After six attempts the delivery is dropped and the endpoint is flagged in the dashboard. - Twenty-four hours of consecutive failures auto-disable the endpoint. Re-enable from the dashboard once the receiver is back.
- Replay any delivery from the dashboard. The replayed event carries a fresh signature + timestamp but the same
id— your handler should be idempotent on event id.