Webhooks

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.

Before you start

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"
}
Secret shown once

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.test is 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_LIMIT with Retry-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 as unread. 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. consumer is free text, never verified; release and done do not check the caller. It only stops your own workers duplicating each other.
  • done is terminal and tenant-wide. There is no failed state; unhandled events expire at 24 hours.
  • A lease expires. lease_seconds defaults to 300, max 3600; after that the event is claimable again, finished or not.
  • An empty items with 200 means the queue is empty.
  • Retention still wins: a lease does not extend the 24 hours.
  • Oldest occurred_at first, in arrival order, not causal order.

Errors you may hit

CodeHTTPCauseFix
INVALID_REQUEST400Missing field, non-HTTPS request_url, event outside source, or malformed id (wh_ plus 26 base32 characters).The message names the field.
ACCOUNT_NOT_FOUND404An account_ids entry is not yours.Check GET /v1/accounts.
RESOURCE_NOT_FOUND404Well-formed webhook id, not yours.Check GET /v1/webhooks.
UNAUTHORIZED401Missing or invalid API key.Send Authorization: Bearer cvt_live_<key>.
PAYMENT_REQUIRED402No active subscription.Add a seat in the dashboard.
RATE_LIMIT_TENANT429Tenant quota exceeded.Honour Retry-After.
PLATFORM_RATE_LIMIT429Too many test deliveries.Honour Retry-After.

Envelopes: error reference.

Next steps