Documentation
Receive every event at your own URL as it happens.
Add one in Settings, with Create a webhook, or with the needleCreateWebhook MCP tool. Any member of the organization can manage webhooks. An organization can have up to 5, and each one gets every event type.
The URL must start with https://. When you add a webhook you get its signing secret, which starts with whsec_. It is shown only this once, so store it where your receiver can read it.
Each event is one POST with a JSON body:
{
"version": "v1",
"id": "00000000-0000-0000-0000-000000000001",
"type": "linkedin_message_received",
"timestamp": "2026-01-01T12:00:00.000Z",
"data": {
"id": "00000000-0000-0000-0000-000000000001",
"type": "linkedin_message_received",
"label": "Reply received",
"occurredAt": "2026-01-01T12:00:00.000Z",
"channel": "linkedin",
"nodeId": null,
"leadId": "00000000-0000-0000-0000-000000000002",
"leadName": "Jane Doe",
"leadImageUrl": null,
"campaignId": "00000000-0000-0000-0000-000000000003",
"campaignName": "Example campaign",
"enrollmentId": "00000000-0000-0000-0000-000000000004",
"connectedAccountId": "00000000-0000-0000-0000-000000000005",
"accountName": "John Doe",
"data": { "text": "Hello!" }
}
}
data is the same event object that Search events returns. timestamp is when the event happened.
Each request also has these headers:
webhook-id: the event id. It stays the same when a request is sent again.webhook-timestamp: when this request was sent, in Unix seconds.webhook-signature: v1, followed by a base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body}, keyed with your secret.The headers follow Standard Webhooks, so its libraries can check them. A request that fails the check did not come from Needle. The libraries also refuse a request whose webhook-timestamp is more than 5 minutes from your server's clock, so a captured request can't be sent again later.
Pass the raw body, before any JSON parsing, and the request headers to verify().
import express from 'express'
import { Webhook } from 'standardwebhooks'
const webhook = new Webhook(process.env.NEEDLE_WEBHOOK_SECRET)
const app = express()
app.post('/webhooks/needle', express.raw({ type: 'application/json' }), (req, res) => {
let event
try {
event = webhook.verify(req.body, req.headers)
} catch {
return res.status(400).end()
}
res.status(204).end()
// Handle the event here, after answering.
})Standard Webhooks has libraries for other languages.
An event fails when your URL doesn't answer with a 2xx within 10 seconds. A failed event is tried 3 times: right away, 10 minutes later, and 1 hour after the first try. After the 3rd failure it is not sent again. An event that has not been delivered after 3 days is given up without further tries.
Missed events are still in the event log. Fetch them with Search events.
Delivery is at least once: after a crash on our side, an event you already got can arrive again. Use webhook-id to skip events you have already handled.
Events are not sent in order, and a retried event can arrive after newer ones. Use timestamp for the order in which things happened.
Change the URL in Settings, or with Change a webhook URL. Events not yet delivered go to the new URL, and the secret stays the same.
Removing a webhook stops its deliveries, and events not yet delivered to it are dropped. If you lose the secret, remove the webhook and add it again to get a new one.
| Type | When it is sent |
|---|---|
lead_enrolled | A lead was added to a campaign. |
lead_skipped | A lead was not added to a campaign; data.skipReason says why. |
message_generated | AI wrote a message for a step. |
condition_evaluated | A check in the sequence was evaluated. |
task_held | A step is waiting for approval. |
task_approved | A held step was approved. |
task_skipped | A step was skipped; data.skipReason says why. |
task_cancelled | A step was cancelled before it ran. |
task_failed | A step failed; data.errorKind says why. |
enrollment_waiting | The sequence is waiting, for example for a reply. |
enrollment_completed | The lead reached the end of the sequence. |
enrollment_stopped | The sequence stopped early; data.exitReason says why. |
lead_suppressed | A lead matched a suppression. |
account_paused | A connected account was paused after hitting a limit. |
campaign_status_changed | A campaign's status changed, for example it was started or paused. |
linkedin_profile_visited | A LinkedIn profile was visited. |
linkedin_invite_sent | A LinkedIn connection request was sent. |
linkedin_invite_withdrawn | A LinkedIn connection request was withdrawn. |
linkedin_message_sent | A LinkedIn message was sent. |
linkedin_follow_sent | A LinkedIn profile was followed. |
linkedin_unfollowed | A LinkedIn profile was unfollowed. |
linkedin_invite_accepted | A LinkedIn connection request was accepted. |
linkedin_message_received | A LinkedIn reply was received. |
linkedin_account_status_changed | A LinkedIn connected account changed status, for example it was disconnected. |
linkedin_post_commented | A comment was posted on LinkedIn. |
linkedin_post_reacted | A reaction was added to a LinkedIn post. |
instagram_message_sent | An Instagram message was sent. |
instagram_profile_visited | An Instagram profile was visited. |
instagram_follow_sent | An Instagram profile was followed. |
instagram_unfollowed | An Instagram profile was unfollowed. |
instagram_message_received | An Instagram reply was received. |
instagram_account_status_changed | An Instagram connected account changed status, for example it was disconnected. |
instagram_post_commented | A comment was posted on Instagram. |
instagram_post_reacted | An Instagram post was liked. |
instagram_message_reacted | A reaction was added to an Instagram message. |
instagram_post_created | An Instagram post was published. |
Every body has "version": "v1". Within v1, new event types and new fields can be added, but fields are not removed or renamed. Ignore types and fields you do not know.
Search documentation, API reference, and MCP tools
We use cookies to enhance your experience on Needle and keep your data secure. Privacy Policy