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

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"
  }
}

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.