# Webhooks

Receive every event at your own URL as it happens.

## Adding a webhook

Add one in Settings, with [Create a webhook](/docs/api-reference/create-webhook), or with the [needleCreateWebhook](/docs/mcp/needle-create-webhook) MCP tool. Any member of the organization can manage webhooks. An organization can have up to 5, and each one gets every [event](/docs/events) 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.

## What Needle sends

Each event is one `POST` with a JSON body:

```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",
    "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](/docs/api-reference/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.

## Verifying requests

The headers follow [Standard Webhooks](https://www.standardwebhooks.com), 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.

```js title="Node.js" icon="/images/docs/nodejs-logo.svg" install="npm install express standardwebhooks" heading="Verify a request" summary="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.
})
```

```python title="Python" icon="/images/docs/python-logo.svg" install="pip install flask standardwebhooks"
import os
from flask import Flask, request
from standardwebhooks.webhooks import Webhook

webhook = Webhook(os.environ["NEEDLE_WEBHOOK_SECRET"])
app = Flask(__name__)

@app.post("/webhooks/needle")
def needle_webhook():
    try:
        event = webhook.verify(request.get_data(), request.headers)
    except Exception:
        return "", 400
    # Handle the event, or queue it and answer right away.
    return "", 204
```

Standard Webhooks has libraries for other languages.

## Retries

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](/docs/api-reference/search-events).

## Duplicates and order

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.

## Changing or removing a webhook

Change the URL in Settings, or with [Change a webhook URL](/docs/api-reference/update-webhook). 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.

## Event types

| 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](https://needle.app/docs/suppressions). |
| `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. |

## Versioning

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.
