Guide to Needle Webhooks: Features and Best Practices
Needle webhooks tell your systems what is happening in your outbound campaigns: a lead is added, a prospect replies on LinkedIn or Instagram, a sequence finishes, a draft waits for approval, or a connected account needs signing in again. Needle is an agent-native outbound engine that runs LinkedIn, Instagram and email campaigns over MCP and REST, and webhooks are how that activity reaches your CRM, alerting and reporting without polling the Search events API.
This guide covers how Needle webhooks are added and signed, the event types and the body they share, Needle's short retry schedule, and the practices that keep a Needle receiver reliable. It is about Needle at needle.app, not Needle Engine, the 3D web engine, which has no webhooks.
What are Needle webhooks?
A Needle webhook is an HTTPS URL plus the event types it should receive. Any member of a workspace can add one, and a workspace can have up to five. When an event happens that the webhook's creator is allowed to see, Needle sends it as a JSON POST.
Every request is signed with the Standard Webhooks scheme: webhook-id, webhook-timestamp and webhook-signature headers, and a whsec_ secret per webhook that Needle shows only once.
Webhooks belong to the member who added them. A webhook receives only the events that member can see, and Needle deletes it when that member leaves the workspace. Pick the creator with that in mind.
Needle webhook features
| Feature | Details |
|---|---|
| Configuration | Settings > Integrations, the Create a webhook API, or the needleCreateWebhook MCP tool |
| Limit | Up to 5 webhooks per workspace |
| Ownership | Only the creator can change a webhook. The creator or a workspace admin can delete it |
| Visibility | A webhook receives only events its creator can see, and is deleted when they leave |
| Endpoint URL | Must start with https:// |
| Subscription | A list of event types, or "all" (also types Needle adds later) |
| Payload | { "version": "v1", "id", "type", "timestamp", "data" } |
| Signature headers | webhook-id, webhook-timestamp, webhook-signature |
| Scheme | Standard Webhooks: HMAC-SHA256, base64, entries prefixed with v1, |
| Signed content | {webhook-id}.{webhook-timestamp}.{raw body} |
| Secret | whsec_..., shown once when the webhook is added |
| Custom headers | Up to 5, sent with every request and not signed |
| Success response | A 2xx within 10 seconds |
| Retries | Right away, 10 minutes later and 1 hour after the first try, then dropped. Given up after 3 days |
| Ordering | At least once and unordered, with up to 5 requests open at once |
| Source IPs | Not published |
Common events
Needle documents 67 event types and says it adds more over time. Two of them, linkedin_follow_sent and linkedin_unfollowed, are listed as "Not sent yet".
| Area | Event types |
|---|---|
| Leads | lead_created, lead_updated, lead_deleted, lead_enrolled, lead_skipped, lead_suppressed |
| Sequences | message_generated, condition_evaluated, task_skipped, task_cancelled, task_failed, enrollment_waiting, enrollment_completed, enrollment_stopped |
| Drafts and approvals | draft_created, draft_approved, draft_discarded, draft_failed, and the deprecated task_held and task_approved |
| Campaigns | campaign_status_changed, campaign_updated |
| Connected accounts | account_connected, account_reconnected, account_deleted, account_paused, account_resumed, account_disconnected, account_suspended, account_limits_updated, account_access_granted, account_access_revoked, account_sharing_changed |
| Workspace | member_invited, member_, member_joined, member_role_changed, member_removed, member_left, api_key_created, api_key_revoked, api_key_enabled, api_key_deleted, webhook_created, webhook_updated, webhook_deleted, suppression_added, suppression_removed |
linkedin_profile_visited, linkedin_invite_sent, linkedin_, linkedin_invite_accepted, linkedin_message_sent, linkedin_, linkedin_post_commented, linkedin_post_reacted | |
instagram_message_sent, instagram_, instagram_, instagram_follow_sent, instagram_unfollowed, instagram_post_commented, instagram_post_reacted, instagram_, instagram_post_created |
Every body has the same wrapper. Here is Needle's example, trimmed:
{
"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",
"leadEmail": "jane@example.com",
"campaignId": "00000000-0000-0000-0000-000000000003",
"enrollmentId": "00000000-0000-0000-0000-000000000004",
"connectedAccountId": "00000000-0000-0000-0000-000000000005",
"data": { "text": "Hello!" }
}
}
typeis the event type, repeated asdata.type. Route on it.timestampis when the event happened. Use it, not arrival order, to order events.datais the same event object that Needle's Search events API returns.nodeIdis the campaign step andenrollmentIdis the lead's run through a campaign.leadEmailandleadIdentitieshelp you match the person in your own system. They are empty when the event has no lead.- The inner
data.datadepends on the type. For example,lead_updatedcarrieschanges,lead_skippedcarriesskipReason,enrollment_stoppedcarriesexitReasonandaccount_disconnectedcarrieserrorKind. versionisv1. Needle says it can add types and fields within v1 but won't remove or rename fields.
Setting up Needle webhooks
- In Needle, open Settings > Integrations and add a webhook.
- Enter your HTTPS endpoint URL.
- Choose the event types. Start from a preset: All events, Recommended (LinkedIn and Instagram activity plus leads added, suppressed leads, finished sequences, AI messages and approvals) or Engagements (replies only), then tick or untick single types. Turn on "Also send event types Needle adds later" if you want new types automatically.
- Save, and copy the signing secret straight away. It starts with
whsec_and Needle shows it only once. Store it server-side asNEEDLE_WEBHOOK_SECRET.
Through the API or MCP, eventTypes is either a list or "all", and "all" is the default when you leave it out. A list never grows on its own.
If your endpoint also expects its own key, add it as a custom header, such as Authorization: Bearer <your key>. A webhook can have up to five, Needle stores their names in lower case, and you can't override Needle's own headers. Changing the webhook's URL removes the saved headers, so add them again in the same change.
With Hookdeck. Create a source with the Needle source type, paste the whole whsec_ secret into Webhook Signing Secret, and use the Hookdeck source URL as the webhook URL. A delivery that fails the check still gets a 200 from Hookdeck, but Hookdeck marks it as failed verification and doesn't pass it on to your destination.
Securing Needle webhooks
To verify a delivery, join webhook-id, webhook-timestamp and the raw request body with dots. Take the part of the secret after whsec_, base64-decode it, and use the bytes as the HMAC-SHA256 key. Encode the result as base64 and compare it in constant time with each v1, entry in the space-delimited webhook-signature header. A match on any entry is enough.
The timestamp check below is optional and off by default. If you turn it on, remember that a delivery retried or replayed later, for example from Hookdeck, keeps its original timestamp.
const crypto = require('crypto');
const express = require('express');
function verifyNeedleSignature(rawBody, headers, secret, toleranceSeconds = 0) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatureHeader = headers['webhook-signature'];
if (!id || !timestamp || !signatureHeader || !secret || !secret.startsWith('whsec_')) return false;
if (!/^\d+$/.test(timestamp)) return false;
// Optional replay check, off by default.
if (toleranceSeconds > 0 && Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
return false;
}
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const expected = crypto
.createHmac('sha256', key)
.update(`${id}.${timestamp}.`)
.update(rawBody)
.digest();
return signatureHeader.split(' ').some((entry) => {
const [version, signature] = entry.split(',');
if (version !== 'v1' || !signature) return false;
const received = Buffer.from(signature, 'base64');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
const app = express();
app.post('/webhooks/needle', express.raw({ type: '*/*' }), (req, res) => {
if (!verifyNeedleSignature(req.body, req.headers, process.env.NEEDLE_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString('utf8'));
// Deduplicate on the webhook-id header, then route on event.type.
res.sendStatus(200);
});
The same check in Python:
import base64
import binascii
import hashlib
import hmac
import time
def verify_needle_signature(raw_body: bytes, headers, secret: str, tolerance_seconds: int = 0) -> bool:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signature_header = headers.get("webhook-signature")
if not (msg_id and timestamp and signature_header and secret and secret.startswith("whsec_")):
return False
if not timestamp.isdigit():
return False
# Optional replay check, off by default.
if tolerance_seconds > 0 and abs(time.time() - int(timestamp)) > tolerance_seconds:
return False
try:
key = base64.b64decode(secret[len("whsec_"):])
except (binascii.Error, ValueError):
return False
signed = f"{msg_id}.{timestamp}.".encode("utf-8") + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())
for entry in signature_header.split(" "):
version, _, signature = entry.partition(",")
if version == "v1" and hmac.compare_digest(signature.encode("utf-8"), expected):
return True
return False
# In FastAPI: raw_body = await request.body()
# verify_needle_signature(raw_body, request.headers, os.environ["NEEDLE_WEBHOOK_SECRET"])
Needle's own Node and Python samples use the standardwebhooks library, which does the same check. Needle points out that the library also refuses a request whose webhook-timestamp is more than five minutes from your server's clock. That window is fixed, so a delivery that Hookdeck retries or replays more than five minutes after Needle sent it fails the library's check even though its signature is valid. The Needle webhook skill includes a manual verifier where you can drop or widen the window.
Custom headers aren't part of the signature. Treat a custom Authorization header as an extra check, not a replacement for verifying webhook-signature.
Make Needle webhooks production-ready. Hookdeck Event Gateway verifies the Standard Webhooks signature on every Needle delivery, stores each lead, reply and account event, and retries delivery to your services for as long as you configure, well past Needle's one-hour retry window.
Needle webhook limitations and pain points
Three tries and the event is gone
The Problem: An outage of a couple of hours loses events for good.
Why It Happens: An event fails when your URL doesn't answer with a 2xx within 10 seconds. Needle tries a failed event three times: right away, 10 minutes later and an hour after the first try. After the third failure it is not sent again, and anything still undelivered after three days is given up.
Workarounds:
- Return
2xxwithin 10 seconds and do the work afterwards. - After an outage, fetch the missed events with Needle's Search events API. Needle keeps them in the event log.
- Answer
429only when you truly need Needle to back off. It pauses sending for a few seconds and retries those events on the same schedule.
How Hookdeck Can Help: Hookdeck accepts each delivery as soon as it arrives, so Needle sees a success even while your service is down. Hookdeck then delivers to your service with retries on your own schedule, and you can replay any stored event.
Events arrive out of order, sometimes twice
The Problem: A reply is processed before the message it answers, or the same event is handled twice.
Why It Happens: Delivery is at least once. Up to five requests to your URL can be open at once, and a retried event can arrive after newer ones.
Workarounds:
- Deduplicate on
webhook-id, which Needle keeps the same when it resends a request. - Order by the body's
timestamp, which is when the event happened.
How Hookdeck Can Help: Hookdeck's deduplication can drop repeated deliveries before they reach your handler, and its delivery rate limit controls how many requests reach your service at once.
Webhooks belong to a person
The Problem: Events stop when someone leaves the company, or a webhook never sees some LinkedIn activity.
Why It Happens: A webhook receives only what its creator can see, and Needle deletes it when that member leaves the workspace. Account-sensitive events, such as messages sent from someone else's connected LinkedIn account, are visible only to people with access to that account.
Workarounds:
- Add production webhooks from a long-lived account that has access to every connected account you need.
- Subscribe to
webhook_deletedandmember_removedon another webhook so a deleted webhook doesn't go unnoticed.
How Hookdeck Can Help: Hookdeck's request log shows when Needle traffic stops arriving at the source, which points you at the Needle side quickly.
Paused plans drop events
The Problem: Nothing is delivered for the period a subscription lapsed, even after it is active again.
Why It Happens: Needle pauses webhooks while a plan is inactive, for example after the free trial ends, and doesn't send events from that time later.
Workarounds:
- Backfill the gap from the Search events API once the plan is active again.
How Hookdeck Can Help: Hookdeck can't recover events that Needle never sent, but its request history makes the gap easy to see and bound.
The secret is shown once
The Problem: You lose the signing secret and can't read it back.
Why It Happens: Needle shows the whsec_ secret only when the webhook is added. Changing the URL, headers or event types keeps the same secret, but there is no way to view it again.
Workarounds:
- Store the secret in a secrets manager as soon as you add the webhook.
- If it is lost, delete the webhook and add it again to get a new secret. Undelivered events for the old webhook are dropped.
How Hookdeck Can Help: With Hookdeck in front, only the Hookdeck source holds the Needle secret, so replacing it is one change in one place.
Best practices
Verify against the raw body
The signature covers the exact bytes Needle sent. Use express.raw({ type: '*/*' }) in Express, await request.text() in a Next.js route handler and await request.body() in FastAPI, and parse the JSON only after the check passes.
Ignore event types you don't know
Needle adds event types over time, and "all" subscriptions receive them automatically. Log and acknowledge unknown types with a 2xx rather than failing, so a new type never uses up retries.
Deduplicate on webhook-id
Record webhook-id before processing and skip repeats. See our guide to webhook idempotency.
Acknowledge fast and process asynchronously
Needle allows 10 seconds. Verify, store the event, return 200, and update your CRM or send alerts afterwards. See why to process webhooks asynchronously.
Prefer draft_* over task_held and task_approved
Needle marks task_held and task_approved as deprecated and sends draft_created and draft_approved alongside them. Build approval workflows on the draft_* events, and match the message that follows an approval by data.draftId.
Conclusion
Needle signs every delivery with Standard Webhooks headers and a whsec_ secret you see only once. Verify against the raw body with the decoded key, deduplicate on webhook-id, order by timestamp, and answer within 10 seconds, because Needle stops retrying an hour after the first attempt.
Hookdeck Event Gateway verifies each Needle signature and holds every event until your service has processed it, so a short outage on your side doesn't cost you a prospect's reply.
Get started with Hookdeck for free and handle Needle webhooks reliably in minutes.