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
| Category | Events |
|---|---|
| Actions you take | invitation.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 happen | message.received, invitation.received, invitation.accepted, account.connected, account.disconnected, post.milestone, post.trending, profile.views_spike, safety_limit.reached, usage.daily_summary |
| Billing | billing.subscription_changed, billing.payment_failed, billing.payment_succeeded |
| Organization | org.member_added, org.member_removed |
Delivery headers (v1)
| Header | Description |
|---|---|
Webhook-Signature | v1,t=<unix_seconds>,s=<hex digest> (HMAC-SHA256 over v1.<t>.<rawBody>) |
Webhook-Event-Id | Event ID (== payload.id), stable across retries — use it for idempotency |
X-Crispy-Event | Event type (e.g. contact.connection_status_changed) |
Content-Type | application/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.
{
"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.
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"];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.
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.