Skip to content

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.

  1. 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"]}'
  2. Store the secret from the response (whsec_…). It’s shown once.

  3. Answer deliveries with any 2xx within 10 seconds.

  • events empty (the default) means every event.
  • mode: fat (default) sends the full event data; thin keeps only ids, status, kind and type, so you fetch details with the API.
  • URLs must use https and resolve to public addresses.
  • PATCH /v1/webhooks/{id} changes the URL, events or mode, or sets enabled: false. GET /v1/webhooks/{id}/deliveries shows the last 50 deliveries with attempts and errors.
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.

POST /linnia HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: Linnia-Webhooks/1.0
linnia-event: message.received
linnia-delivery: e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b
linnia-signature-timestamp: 1792302062
linnia-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.

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);
});

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.

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.