Webhooks
LinkedIn events (messages, connections, account status) delivered as signed HTTP POSTs to your server. Messaging and account events arrive in near-real-time; connection events on a poll delay.
You need an API key and a connected LinkedIn account:
account_ids is required and non-empty for messaging, user, post, and motion
webhooks (an account_status webhook may omit it to cover every account). Read your ids with
GET /v1/accounts; if none, start with the
Quickstart.
curl -X POST https://api.curviate.com/v1/webhooks \
-H "Authorization: Bearer cvt_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "My message webhook",
"source": "messaging",
"request_url": "https://hooks.example.com/curviate",
"account_ids": ["acc_YOUR_ACCOUNT_ID"],
"events": ["message.received", "message.read"]
}'A webhook has one source (messaging, user, account_status, post, or motion), and every
event in events must belong to it. Create one webhook per source.
The response carries secret, your HMAC signing key (64 lowercase hex characters):
{
"object": "webhook",
"id": "wh_01J8Z3K9P0Q1R2S3T4V5W6X7Y8",
"source": "messaging",
"request_url": "https://hooks.example.com/curviate",
"account_ids": ["acc_YOUR_ACCOUNT_ID"],
"events": ["message.received", "message.read"],
"enabled": true,
"secret": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
"secret_prefix": "a1b2c3d4",
"created_at": "2026-05-28T08:00:00.000Z"
}The secret is never returned again. Store it as
CURVIATE_WEBHOOK_SECRET now. Reads and the dashboard show only the
8-character secret_prefix.
How webhooks work
Each matching event on a listed account is POSTed with a Curviate-Signature
header (HMAC-SHA256 of the body); verify it first.
Messaging and connection events carry their content; account-status events carry
state only; post events carry ids, timestamps, and a failure code, never the post text; Motion events carry ids, reason codes and the Contact record, never message text. data always has account_id,
event, and occurred_at (account_id is null on a motion.status_changed).
On any source, an optional motion_ids list limits delivery to events of those Motions; see Motion events.
Delivery is at-least-once, up to 5 attempts. The top-level id (wdl_...)
changes per attempt, so deduplicate on event + data.account_id +
data.occurred_at (Delivery and retries).
Change account_ids any time with PATCH /v1/webhooks/{id}; the secret stays.
Listing webhooks
GET /v1/webhooks is the only endpoint with delivery health; call it when a webhook
stops working. Pages with limit and cursor.
curl "https://api.curviate.com/v1/webhooks" \
-H "Authorization: Bearer cvt_live_YOUR_API_KEY"Each item adds health, last_delivery_at, and delivery_success_rate_7d
(meanings).
Retrieving a webhook
GET /v1/webhooks/{id} returns the same object without health fields or secret.
curl "https://api.curviate.com/v1/webhooks/wh_01J8Z3K9P0Q1R2S3T4V5W6X7Y8" \
-H "Authorization: Bearer cvt_live_YOUR_API_KEY"Testing a webhook
A 201 from create does not prove your endpoint receives deliveries.
POST /v1/webhooks/{id}/test sends one now.
curl -X POST "https://api.curviate.com/v1/webhooks/wh_01J8Z3K9P0Q1R2S3T4V5W6X7Y8/test" \
-H "Authorization: Bearer cvt_live_YOUR_API_KEY"{
"object": "webhook_test",
"webhook_id": "wh_01J8Z3K9P0Q1R2S3T4V5W6X7Y8",
"test_id": "wht_01J8Z3K9P0Q1R2S3T4V5W6X7Y8",
"event": "webhook.test",
"queued_at": "2026-06-17T10:00:00Z"
}202 means queued; the proof is the request reaching your endpoint:
{
"id": "wdl_01J8Z3K9P0Q1R2S3T4V5W6X7Y8",
"webhook_id": "wh_01J8Z3K9P0Q1R2S3T4V5W6X7Y8",
"event": "webhook.test",
"data": {
"account_id": "acc_YOUR_ACCOUNT_ID",
"event": "webhook.test",
"occurred_at": "2026-06-17T10:00:00Z",
"test_id": "wht_01J8Z3K9P0Q1R2S3T4V5W6X7Y8"
},
"delivered_at": "2026-06-17T10:00:00Z"
}Match data.test_id to your test_id.
- It is a real delivery: same signature, headers, history entry, and retries, so it checks your stored secret.
webhook.testis not a subscribable event and carries no LinkedIn content. Ignore it in your handler; it arrives whatever the webhook subscribes to.- Limit: 5 tests per webhook per minute; over that,
429 PLATFORM_RATE_LIMITwithRetry-After. - A disabled webhook, or one targeting no account, returns
400.
Deleting a webhook
curl -X DELETE "https://api.curviate.com/v1/webhooks/wh_01J8Z3K9P0Q1R2S3T4V5W6X7Y8" \
-H "Authorization: Bearer cvt_live_YOUR_API_KEY"{
"object": "webhook_deleted",
"id": "wh_01J8Z3K9P0Q1R2S3T4V5W6X7Y8"
}Repeating a delete on your own webhook returns 200. An id that was never yours
returns 404 RESOURCE_NOT_FOUND.
Reading events without a listener
Every event for a connected account is kept for 24 hours, subscribed or not.
GET /v1/{account_id}/events reads them back:
curl https://api.curviate.com/v1/acc_YOUR_ACCOUNT_ID/events \
-H "Authorization: Bearer cvt_live_YOUR_API_KEY"Each item carries id, event, occurred_at, received_at, and payload:
exactly what a webhook would receive, content included.
- Retention is 24 hours from
received_at, absolute. Poll more often. - Order is arrival order, not causal; LinkedIn can deliver out of sequence.
- Disconnecting an account hides its events immediately, here and everywhere.
- A duplicate delivery is stored once; distinct events in the same second are both kept.
- Queue state:
state(unread,claimed,done),claimed_by,lease_until,done_at. Filter with?state=. An expired lease reads asunread. Reading never claims.
Page with cursor until it is null; limit defaults to 50, max 100.
Working events as a queue
Agents sharing an account can both act on one unread event. Claim instead: a claim leases events to you alone.
curl -X POST https://api.curviate.com/v1/acc_YOUR_ACCOUNT_ID/events/claim \
-H "Authorization: Bearer cvt_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"consumer": "inbox-triage-worker-1", "limit": 5}'{
"object": "event_claim",
"consumer": "inbox-triage-worker-1",
"lease_until": "2026-09-04T09:19:03.400Z",
"items": [
{
"object": "event",
"id": "wel_01JQZ8T7K3M9V2X4B6C8D0E1F2",
"event": "message.received",
"occurred_at": "2026-09-04T09:14:02.000Z",
"received_at": "2026-09-04T09:14:03.118Z",
"payload": { "account_id": "acc_01JQZ7R5H2K4M6P8S0T2V4W6X8", "event": "message.received", "text": "Tuesday at 10?" }
}
]
}Once handled, close it:
curl -X POST https://api.curviate.com/v1/acc_YOUR_ACCOUNT_ID/events/wel_01JQZ8T7K3M9V2X4B6C8D0E1F2/done \
-H "Authorization: Bearer cvt_live_YOUR_API_KEY"On failure, release it for immediate re-claim:
curl -X POST https://api.curviate.com/v1/acc_YOUR_ACCOUNT_ID/events/wel_01JQZ8T7K3M9V2X4B6C8D0E1F2/release \
-H "Authorization: Bearer cvt_live_YOUR_API_KEY"- The lease is cooperative, not access control.
consumeris free text, never verified;releaseanddonedo not check the caller. It only stops your own workers duplicating each other. doneis terminal and tenant-wide. There is no failed state; unhandled events expire at 24 hours.- A lease expires.
lease_secondsdefaults to 300, max 3600; after that the event is claimable again, finished or not. - An empty
itemswith200means the queue is empty. - Retention still wins: a lease does not extend the 24 hours.
- Oldest
occurred_atfirst, in arrival order, not causal order.
Errors you may hit
| Code | HTTP | Cause | Fix |
|---|---|---|---|
INVALID_REQUEST | 400 | Missing field, non-HTTPS request_url, event outside source, or malformed id (wh_ plus 26 base32 characters). | The message names the field. |
ACCOUNT_NOT_FOUND | 404 | An account_ids entry is not yours. | Check GET /v1/accounts. |
RESOURCE_NOT_FOUND | 404 | Well-formed webhook id, not yours. | Check GET /v1/webhooks. |
UNAUTHORIZED | 401 | Missing or invalid API key. | Send Authorization: Bearer cvt_live_<key>. |
PAYMENT_REQUIRED | 402 | No active subscription. | Add a seat in the dashboard. |
RATE_LIMIT_TENANT | 429 | Tenant quota exceeded. | Honour Retry-After. |
PLATFORM_RATE_LIMIT | 429 | Too many test deliveries. | Honour Retry-After. |
Envelopes: error reference.
Next steps
- Event reference
- Messaging, User, Account status, Post: payloads per source. Motion events are on the event reference.