Search documentation

Jump to any section of the Crispy docs

Guides

Webhooks

Get real-time, signed v1 events when things happen on your LinkedIn account. New webhooks use the v1 signing scheme and a structured Hybrid payload by default. Configure a webhook URL and signing secret from your dashboard settings.

v1 is the default

New webhooks emit v1 events: a signed Webhook-Signature header, a stable Webhook-Event-Id for idempotency, and a structured Hybrid data payload (a stable identifiers block plus curated fields — never raw database rows). When you call create_webhook the new webhook defaults to signing_format: "v1"; pass signing_format: "legacy" only if you need the older scheme. Webhooks created before v1 keep working unchanged — there is no forced migration.

Event types

CategoryEvents
Actions you takeinvitation.sent, message.sent, inmail.sent, post.published, post.reacted, post.commented, follow.sent, invitation.cancelled, invitation.accepted_by_us, invitation.declined_by_us
Things that happenmessage.received, invitation.received, invitation.accepted, account.connected, account.disconnected, post.milestone, post.trending, profile.views_spike, safety_limit.reached, usage.daily_summary
Billingbilling.subscription_changed, billing.payment_failed, billing.payment_succeeded
Organizationorg.member_added, org.member_removed
Workspaceworkspace.created, workspace.member_added, workspace.member_removed

Delivery headers (v1)

HeaderDescription
Webhook-Signaturev1,t=<unix_seconds>,s=<hex digest> (HMAC-SHA256 over v1.<t>.<rawBody>)
Webhook-Event-IdEvent ID (== payload.id), stable across retries — use it for idempotency
X-Crispy-EventEvent type (e.g. contact.connection_status_changed)
Content-Typeapplication/json

Legacy webhooks (signing_format: "legacy") instead send X-Crispy-Signature, X-Crispy-Timestamp, and X-Crispy-Delivery-Id. See the HMAC verification page for the legacy verifiers.

Payload structure (Hybrid)

v1 payloads carry a top-level envelope (payload_version, id == Webhook-Event-Id, event, timestamp, webhook_id, account_id) and a structured data block: a stable identifiers object plus curated fields. No raw internal database rows.

Event-specific fields ride alongside the curated set. For example, message.received carries the message inline — chat_id, message_id, text, sender_name, direction, timestamp— plus the sender's identifiers (including member_id) and CRM fields when the sender is a known contact, so you don't need a follow-up lookup to act on the message.

v1 webhook payload (Hybrid)
{
  "payload_version": "1",
  "id": "3f1c2a90-8b4e-4d21-9f0a-2c7d5e6b1a34",
  "event": "contact.connection_status_changed",
  "timestamp": "2026-01-15T10:30:00.000Z",
  "webhook_id": "8d2a7c14-3e9b-4f56-8a01-9c4d2e1f7b60",
  "account_id": "a1b2c3d4-e5f6-4789-90ab-1c2d3e4f5a6b",
  "data": {
    "identifiers": {
      "crispy_id": "contact_01J8Z9K2QY",
      "linkedin_url": "https://www.linkedin.com/in/jane-doe",
      "linkedin_provider_id": "ACoAAB1cD2eF",
      "member_id": "121354090",
      "email": "[email protected]",
      "external_ids": {}
    },
    "name": "Jane Doe",
    "first_name": "Jane",
    "last_name": "Doe",
    "headline": "VP Engineering at Acme",
    "company_name": "Acme",
    "company_domain": "acme.com",
    "location": "San Francisco, CA",
    "country": "United States",
    "phone": null,
    "connection_status": "connected",
    "tags": ["target-account"],
    "custom_attrs": {},
    "source_event": "invitation.accepted",
    "triggered_at": "2026-01-15T10:30:00.000Z"
  }
}
message.received fires for every message in the chat, not only ones sent to you. LinkedIn relays your own replies too (sent from the LinkedIn app, mobile, or another device), so the same event stream carries both directions. Always check data.direction ("inbound" or "outbound") or data.is_sender before treating a delivery as an incoming reply.
Filtering out your own outbound messages
app.post("/webhooks/crispy", (req, res) => {
  const { event, data } = req.body;

  if (event === "message.received") {
    if (data.direction === "outbound" || data.is_sender) {
      // The account owner sent this themselves (LinkedIn app, mobile, another
      // device) - Crispy still relays it so you see the full thread.
      return res.sendStatus(200);
    }
    // data.direction === "inbound": a real reply from the other person.
    handleInboundMessage(data);
  }

  res.sendStatus(200);
});

Signature verification (v1)

Always verify the signature to ensure the request came from Crispy. Crispy v1 signs the literal string v1.<t>.<rawBody> with your signing secret and sends the digest as Webhook-Signature: v1,t=<t>,s=<hex>. The timestamp is the t= field of that header, not a separate header. Use the raw request body, not the re-serialized JSON.

Node.js
import { createHmac, timingSafeEqual } from "crypto";

// Crispy v1 signs the string "v1.<t>.<rawBody>" with your signing secret
// (HMAC-SHA256) and sends "Webhook-Signature: v1,t=<t>,s=<hex_digest>".
function verifyWebhookV1(rawBody, signatureHeader, secret) {
  const parts = signatureHeader.split(",").map((p) => p.trim());
  if (parts[0] !== "v1") return false;
  let t = "", s = "";
  for (const part of parts.slice(1)) {
    const i = part.indexOf("=");
    if (i === -1) continue;
    if (part.slice(0, i) === "t") t = part.slice(i + 1);
    else if (part.slice(0, i) === "s") s = part.slice(i + 1);
  }
  if (!/^[0-9]+$/.test(t) || !/^[a-f0-9]+$/i.test(s)) return false;
  const digest = createHmac("sha256", secret)
    .update("v1." + t + "." + rawBody)
    .digest("hex");
  const a = Buffer.from(digest, "hex");
  const b = Buffer.from(s, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

// In your webhook handler (use the RAW request body, not the parsed JSON):
const isValid = verifyWebhookV1(
  rawBody,
  req.headers["webhook-signature"],
  process.env.WEBHOOK_SECRET
);
// Dedupe on the stable Webhook-Event-Id header (identical across retries):
const eventId = req.headers["webhook-event-id"];
Prefer Python or Go? The HMAC verification page has copy-pasteable v1 verifiers in Node, Python, and Go, with timing-safe comparison, dual-secret rotation, and idempotency via Webhook-Event-Id — plus the legacy verifiers for older webhooks.

Responding to deliveries

Your endpoint must respond with a 2xx status within 10 seconds. Crispy aborts the request at that point and treats it as a failed delivery.

Acknowledge first, then do the work. Return 200 as soon as you have verified the signature, and run any slower processing (calling get_messages, enriching, writing to a CRM, triggering a multi-step flow) asynchronously after you respond. Do not call back into Crispy or your own downstream systems before replying. A handler that does the work inline will routinely exceed 10 seconds and be marked failed even though it “worked” on your side.

A slow handler is not just retried, it can cause duplicate work. When a delivery times out, Crispy retries it, so an endpoint that eventually succeeds in 11 seconds may process the same event more than once. Dedupe on the Webhook-Event-Id header (identical across retries of the same event); store delivered IDs for ~7 days and skip duplicates.

Retry policy

Failed deliveries (non-2xx, connection errors, or the 10s timeout above) are retried with exponential backoff (plus jitter): 5 seconds, 30 seconds, 2 minutes, 15 minutes, then 1 hour. After 5 failed attempts, the delivery is marked as permanently failed.

A single 2xx resets the failure counter, so an endpoint that occasionally times out but mostly succeeds keeps running. An endpoint that fails persistently is auto-disabled after 100 consecutive permanently-failed deliveries, and the webhook owner is emailed. If your deliveries are failing on the 10s timeout, fix the response time (see above) rather than waiting for the retries.