# 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_invitation_revoked`, `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 | `linkedin_profile_visited`, `linkedin_invite_sent`, `linkedin_invite_withdrawn`, `linkedin_invite_accepted`, `linkedin_message_sent`, `linkedin_message_received`, `linkedin_post_commented`, `linkedin_post_reacted` |
| Instagram | `instagram_message_sent`, `instagram_message_received`, `instagram_profile_visited`, `instagram_follow_sent`, `instagram_unfollowed`, `instagram_post_commented`, `instagram_post_reacted`, `instagram_message_reacted`, `instagram_post_created` |

Every body has the same wrapper. Here is Needle's example, trimmed:

```json
{
  "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!" }
  }
}

```

* `type` is the event type, repeated as `data.type`. Route on it.
* `timestamp` is when the event happened. Use it, not arrival order, to order events.
* `data` is the same event object that Needle's Search events API returns. `nodeId` is the campaign step and `enrollmentId` is the lead's run through a campaign.
* `leadEmail` and `leadIdentities` help you match the person in your own system. They are empty when the event has no lead.
* The inner `data.data` depends on the type. For example, `lead_updated` carries `changes`, `lead_skipped` carries `skipReason`, `enrollment_stopped` carries `exitReason` and `account_disconnected` carries `errorKind`.
* `version` is `v1`. Needle says it can add types and fields within v1 but won't remove or rename fields.

## Setting up Needle webhooks

1. In Needle, open **Settings > Integrations** and add a webhook.
2. Enter your HTTPS endpoint URL.
3. 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.
4. Save, and copy the signing secret straight away. It starts with `whsec_` and Needle shows it only once. Store it server-side as `NEEDLE_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.

```javascript
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:

```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](https://github.com/hookdeck/webhook-skills/tree/main/skills/needle-webhooks) 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](/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 `2xx` within 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 `429` only 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](/docs/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](/docs/deduplication) can drop repeated deliveries before they reach your handler, and its [delivery rate limit](/docs/destinations#set-a-max-delivery-rate) 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_deleted` and `member_removed` on 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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Needle webhooks reliably in minutes.