Webhooks
Receive LinkedIn event notifications (messages received, connections accepted, accounts connected) delivered as signed HTTP POST callbacks to your server. Messaging and account-lifecycle events arrive in near-real-time; connection events are delivered on a poll delay.
Creating a webhook needs an API key and at least one connected
LinkedIn account. account_ids is required and must be
non-empty, so there is no account-free path here. Read your ids with
GET /v1/accounts; if that returns an empty list, connect an
account first (see
Authentication and accounts).
Creating with an id you do not own returns 404 ACCOUNT_NOT_FOUND.
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"]
}'Each webhook subscribes to a single event source (messaging, user, or
account_status), and every event in events must belong to that source. To
receive events from more than one source, create one webhook per source.
The response includes a one-time secret, your HMAC signing key for verifying every
incoming delivery. Copy it immediately. It is 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 returned exactly once and is never retrievable
again. Store it as CURVIATE_WEBHOOK_SECRET in your environment
before discarding this response. The dashboard shows only the 8-character
prefix (a1b2c3d4...) for identification.
How webhooks work
When a LinkedIn event occurs on a connected account in your workspace, Curviate
dispatches an HTTP POST to every registered URL subscribed to that event type.
Each delivery carries:
- A JSON body with the event name, a delivery ID, and event data. Content events:
messaging events include the full message content (
text,sender, attachment metadata) and connection events carry the new contact's profile fields. Structural events: the account-status lifecycle events and initial-sync progress carry the account state only, no LinkedIn content. This distinguishes Curviate from metadata-only webhook systems: your handler receives everything it needs in one delivery, with no follow-up fetch required. - A
Curviate-Signatureheader, an HMAC-SHA256 digest of the delivery body using your secret. Always verify this before processing.
Every delivery also carries account_id, event, and occurred_at inside data,
whatever the source.
Delivery is at-least-once. Curviate makes up to 5 delivery attempts in total (the first plus 4 retries) with exponential backoff.
The top-level id (wdl_...) identifies a delivery
attempt, and a fresh one is minted on every retry. Keying on it
reprocesses every retried event, which is exactly the duplicate handling it looks
like it prevents. Deduplicate on the combination of event,
data.account_id, and data.occurred_at, which is stable
across attempts. See
Delivery and retries.
Account IDs
The account_ids field is required and must be a non-empty array of acc_...
IDs, each a connected LinkedIn account owned by your tenant. Deliveries are scoped
to the accounts you list. To receive events from all your connected accounts, provide
the full set:
{
"account_ids": ["acc_YOUR_ACCOUNT_ID", "acc_YOUR_SECOND_ACCOUNT_ID"]
}You can update the list at any time via PATCH /v1/webhooks/{id} without rotating
the signing secret.
Listing webhooks
GET /v1/webhooks is the only endpoint that returns delivery health, so it is the
call to reach for when a webhook has stopped working.
curl "https://api.curviate.com/v1/webhooks" \
-H "Authorization: Bearer cvt_live_YOUR_API_KEY"Each item carries everything GET /v1/webhooks/{id} returns plus three diagnostic
fields the single-webhook read deliberately omits:
| Field | Description |
|---|---|
health | ok or degraded. degraded means the last delivery exhausted all 5 attempts. |
last_delivery_at | ISO-8601 timestamp of the most recent delivery attempt, successful or not, or null when this webhook has never attempted one. |
delivery_success_rate_7d | Percentage (0-100) of the last 7 days' attempts that succeeded, or null when there were none in that window. A webhook that attempted and failed every time returns 0 with a non-null last_delivery_at, which is how you tell "failing" apart from "never used". |
Supports limit and cursor for pagination.
Retrieving a webhook
Fetch a single webhook by id with GET /v1/webhooks/{id}. The response is the same
webhook object minus the health fields above; the plaintext secret is never
returned on a read, only its 8-character secret_prefix.
curl "https://api.curviate.com/v1/webhooks/wh_01J8Z3K9P0Q1R2S3T4V5W6X7Y8" \
-H "Authorization: Bearer cvt_live_YOUR_API_KEY"Testing a webhook
A 201 from create tells you the subscription was stored. It does not tell you your
endpoint can receive a delivery, so a tunnel that died after registration, a route
that does not accept POSTs, or a signature check with the wrong secret all look
healthy until the first real event. POST /v1/webhooks/{id}/test closes that gap by
sending a delivery on demand.
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"
}The 202 means queued, not delivered. What proves the subscription works is the
request that then arrives at 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 against the test_id you were given, so you are confirming your
own test rather than any delivery that happened to arrive.
Four things worth knowing:
- It is a real delivery. Same signature construction, same custom headers, same entry in your delivery history, same retry schedule. Verifying the signature on a test delivery is a genuine check of the secret you stored.
webhook.testis not a real event. It is not in the event catalogue and cannot be subscribed to, so no other call will ever produce it. Ignore it in the branch that handles events, and never write it to a cache of LinkedIn data. It carries no LinkedIn content.- It reaches this webhook whatever it subscribes to. You do not need to add an event to test a subscription.
- It counts against a small limit. At most 5 test deliveries per webhook per
minute; over that,
429 PLATFORM_RATE_LIMITwith aRetry-Afterheader.
A webhook that is disabled, or that targets no account, returns 400 rather than a
202 it could not honour.
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"
}Deleting a webhook you own that is already deleted returns 200, so teardown is
safe to repeat. An id that was never yours returns 404 RESOURCE_NOT_FOUND.
Errors you may hit
| Code | HTTP | Cause | Fix |
|---|---|---|---|
INVALID_REQUEST | 400 | A missing field, a non-HTTPS request_url, an event that does not belong to source, or a malformed id. Webhook ids must be wh_ plus 26 base32 characters. | Read the message, it names the field. |
ACCOUNT_NOT_FOUND | 404 | An acc_... in account_ids is not owned by this workspace. | Read your ids from GET /v1/accounts. |
RESOURCE_NOT_FOUND | 404 | The webhook id is well-formed but not yours. | Confirm it with 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 | Workspace quota exceeded. | Honour the Retry-After header. |
PLATFORM_RATE_LIMIT | 429 | Too many test deliveries, for one webhook or across your workspace's webhooks. | Honour the Retry-After header. |
Full envelope shapes are in the error reference.
Next steps
- Webhook authentication: the two gates, the exact signed bytes, secret rotation, and a runnable receiver you can prove rejects a forgery.
- Event reference: all event types with payload examples and LinkedIn-specific timing notes.
- Verifying signatures: parse the
Curviate-Signatureheader and validate HMAC in TypeScript or Python. - Delivery & retries: retry schedule, health status, and duplicate-delivery handling.
- Messaging events: the
messagingsource payloads. - User events: the
usersource payloads. - Account status events: the
account_statussource payloads.