Webhooks
Webhooks push events to your systems as they happen: a prospect replied, an invitation was accepted, a queued write finished, an account needs reconnecting. Each delivery is signed so you can prove it came from Linnia.
Add an endpoint
Section titled “Add an endpoint”-
In the dashboard, open Integrations → Webhooks (owners and admins), or call the API:
Terminal window curl -X POST https://api.linnia.ai/v1/webhooks \-H "Authorization: Bearer $LINNIA_KEY" \-H "Content-Type: application/json" \-d '{"url":"https://hooks.example.com/linnia","events":["message.received","invitation.accepted"]}' -
Store the
secretfrom the response (whsec_…). It’s shown once. -
Answer deliveries with any
2xxwithin 10 seconds.
eventsempty (the default) means every event.mode:fat(default) sends the full event data;thinkeeps only ids,status,kindandtype, so you fetch details with the API.- URLs must use
httpsand resolve to public addresses. PATCH /v1/webhooks/{id}changes the URL, events or mode, or setsenabled: false.GET /v1/webhooks/{id}/deliveriesshows the last 50 deliveries with attempts and errors.
Events
Section titled “Events”| Event | When | data |
|---|---|---|
message.received |
Someone replied to the account | accountId, chatId, messageId, senderProviderId, text, sentAt, triage ({ class, suggestedReply } or null) |
invitation.accepted |
An invitation from the account was accepted | accountId, invitationId, providerId, via, and name when known |
job.completed |
A job finished | jobId, accountId, kind, status, result, error |
job.failed |
A job failed, or its approval was rejected or cancelled | jobId, accountId, kind, status, error |
account.connected |
An account finished connecting or reconnecting | accountId, tier, products, reconnected |
account.reconnection_required |
LinkedIn signed the account out | accountId, reason, needsHuman |
account.paused |
The account was paused | accountId, reason |
account.resumed |
The account was resumed | accountId, wasRestricted |
Reply triage.class is one of interested, meeting, referral, not_now, not_interested, objection, out_of_office or other. Background jobs Linnia runs on its own (syncs, automatic withdrawals) don’t send job events.
A delivery
Section titled “A delivery”POST /linnia HTTP/1.1Host: hooks.example.comContent-Type: application/jsonUser-Agent: Linnia-Webhooks/1.0linnia-event: message.receivedlinnia-delivery: e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5blinnia-signature-timestamp: 1792302062linnia-signature: 5f0c2b1e9a…(64 hex characters)
{"id":"4f3e2d1c-0b9a-4876-9543-21fedcba9876","type":"message.received","createdAt":"2026-10-18T07:41:02.000Z","data":{"accountId":"6f1c2a9e-4b7d-4c1e-9a53-2f8e0d7b1c44","chatId":"2-YjM4ZTQ1MjAtNzQ4ZC00","messageId":"7251234567890999999","senderProviderId":"ACoAAA0001","text":"Happy to chat, does Thursday work?","sentAt":"2026-10-18T07:41:00.000Z","triage":{"class":"meeting","suggestedReply":null}}}| Header | Meaning |
|---|---|
linnia-event |
The event type. |
linnia-delivery |
Delivery id. The same across retries of this delivery. |
linnia-signature-timestamp |
Unix time (seconds) when this attempt was signed. |
linnia-signature |
Hex HMAC-SHA256 of <timestamp>.<raw body>, keyed with your endpoint secret. |
The body’s id is the event id: if you have several endpoints, each gets a delivery for the same event id. Use it to de-duplicate.
Verify the signature
Section titled “Verify the signature”Compute HMAC-SHA256 over the timestamp header, a dot, and the raw request body (exactly the bytes received, before any JSON parsing), using the whole secret including the whsec_ prefix as the key. Compare the hex digest with linnia-signature in constant time, and reject old timestamps to stop replays.
import { createHmac, timingSafeEqual } from "node:crypto";import express from "express";
const secret = process.env.LINNIA_WEBHOOK_SECRET; // "whsec_…"const app = express();
// Keep the raw body: the signature is over the exact bytes Linnia sent.app.post("/linnia", express.raw({ type: "application/json" }), (req, res) => { const timestamp = req.header("linnia-signature-timestamp") ?? ""; const signature = req.header("linnia-signature") ?? "";
const expected = createHmac("sha256", secret).update(`${timestamp}.${req.body.toString("utf8")}`).digest("hex"); const valid = signature.length === expected.length && timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300; // 5 minutes
if (!valid || !fresh) return res.status(400).send("invalid signature");
const event = JSON.parse(req.body.toString("utf8")); // Acknowledge fast; do slow work in the background. res.sendStatus(204); handle(event);});import hashlibimport hmacimport jsonimport osimport time
from flask import Flask, abort, request
SECRET = os.environ["LINNIA_WEBHOOK_SECRET"] # "whsec_…"app = Flask(__name__)
@app.post("/linnia")def linnia_webhook(): timestamp = request.headers.get("linnia-signature-timestamp", "") signature = request.headers.get("linnia-signature", "") raw = request.get_data() # exact bytes, before JSON parsing
expected = hmac.new( SECRET.encode(), f"{timestamp}.".encode() + raw, hashlib.sha256 ).hexdigest() fresh = timestamp.isdigit() and abs(time.time() - int(timestamp)) < 300
if not fresh or not hmac.compare_digest(signature, expected): abort(400)
event = json.loads(raw) handle(event) # keep it quick, or queue it return "", 204Retries
Section titled “Retries”A delivery succeeds when your endpoint answers 2xx within 10 seconds. Anything else (a timeout, a 4xx or 5xx, or a redirect, which isn’t followed) is retried with exponential backoff, starting around a minute and growing to at most 6 hours between attempts, up to 8 attempts in total. Each attempt is signed with a fresh timestamp.
Deliveries can arrive more than once and out of order. Make your handler idempotent on the event id and use createdAt to order events.
Testing locally
Section titled “Testing locally”Expose your local server with a tunnel (any HTTPS tunnelling tool) and register the tunnel’s https URL. Linnia won’t deliver to localhost or private addresses in production.