> ## Documentation Index
> Fetch the complete documentation index at: https://docs.b2bsignals.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Send B2B Signals events to Zapier, Make, n8n or your own code, with the event list, payload fields, secret check and retry rules.

A webhook sends events to your own tools the moment they happen. Set them up in **Settings** › **Webhooks**.

Only owners and admins can add or change webhooks. Members can see the list, with the address cut down to its host.

## Add a webhook

**1. Press Add webhook**

Give it a **Name**, then put your address in **Where to send**. It must start with `http://` or `https://` and be publicly reachable.

**2. Choose your events**

Under **When should we send?**, tick the events you want. **First reply** starts ticked. **Choose all** ticks all 20.

**3. Limit it, if you want**

Under **Only for**, pick **Campaigns** and **Sources**. See [Only for](#only-for).

**4. Press Send a test event**

We send one test event, and show your tool's answer. Nothing is saved yet.

**5. Press Save webhook**

Copy the signing secret now. It's shown once.

![Add webhook drawer with name, address, events, Only for and Technical details](https://mintcdn.com/b2-b-signals-llc/u5yYe4FZtFc5vtD_/images/developers/add-webhook-drawer.png?fit=max\&auto=format\&n=u5yYe4FZtFc5vtD_\&q=85\&s=4a51005e90937de289a83198c49bc486)

Add webhook, filled in, with three events ticked and Technical details open: the signing secret note, the retry rule and every event code.

You can also add a webhook from a campaign's settings. It then starts limited to that campaign.

The number of webhooks depends on your plan: none on Free, 3 on Software, and set by your agreement on Custom. Past the limit, saving returns "Webhook limit reached". See [Plans](https://docs.b2bsignals.ai/getting-started/plans.md).

## What we send

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

```json theme={null}
{
  "event": "message_replied",
  "tenant_id": "your-workspace-id",
  "data": { }
}
```

`tenant_id` is your workspace's id. `data` depends on the event.

### Headers

| Header | Value |
| - | - |
| `X-Webhook-Secret` | Your signing secret, starting with `whsec_` |
| `X-Webhook-Delivery` | One id for this delivery. Retries repeat it. Not on test events. |
| `X-Webhook-Attempt` | `1` for the first try, then counts up. Not on test events. |

### Check it came from us

Compare `X-Webhook-Secret` with your signing secret, and reject the request if they differ. The header holds the secret itself, not a signature of the body, so use HTTPS to keep it private.

A test event sent before you save has no secret header, because the webhook has no secret yet.

### Handle repeats

Your tool can occasionally receive the same event twice. Use `X-Webhook-Delivery` to drop repeats.

## Answer and retries

Answer with any `2xx` within 5 seconds. Anything else counts as a failure, including a redirect, because we don't follow them. Do slow work after you answer.

After a failed first try, we retry 3 times over an hour:

| Retry | Wait after the previous try | Time since the first try |
| - | - | - |
| 1 | 5 minutes | 5 minutes |
| 2 | 15 minutes | 20 minutes |
| 3 | 40 minutes | 60 minutes |

If the last retry fails, the webhook is marked **Not working**. From then on:

* it gets no new events,
* its other waiting retries are cancelled,
* events that happen while it's stopped aren't saved or sent later.

### Get it working again

Fix your tool, then press **Try again** in the list. We send one test event. If your tool answers with a `2xx`, new events flow again. If not, it stays **Not working**.

Editing the webhook doesn't restart it. Only **Try again** does.

### Status in the list

| Status | Meaning |
| - | - |
| **Nothing sent yet** | No event has been sent |
| **Working** | The last send time is shown, and "retrying" if a retry is waiting |
| **Not working** | Retries were used up and sending stopped |
| **Off** | You turned it off |

## Only for

Use **Only for** to limit a webhook. Leaving a list empty means all of them.

| Setting | Applies to |
| - | - |
| **Sources** | **New person found** and **Their details were filled in**. A person from a source you didn't pick isn't sent. **Their tags changed** also names a source when the person has one, so it follows this list too. |
| **Campaigns** | Events that carry a `workflow_id` |

Events with no `workflow_id` are always delivered, whatever you picked under **Campaigns**. That includes sender alerts, tag changes, and reply events on chats that aren't tied to a campaign.

New campaigns and sources aren't added on their own.

## Events

**Event codes** in **Technical details** on each webhook shows the same list.

Ids in payloads can be `null` when we don't have them. `workflow_id` is a campaign's id, `run_id` is one person's journey through a campaign, and `lead_id` is the person. Older names like `agent_id` refer to the source that found them.

### People

| Label | Event code |
| - | - |
| New person found | `lead.created` |
| Their details were filled in | `lead.enriched` |
| Their tags changed | `lead.tag_updated` |

`lead.created` and `lead.enriched` have this `data`:

| Field | Contents |
| - | - |
| `lead_id` | The person's id |
| `agent_id`, `agent_name` | The source that found them, if any |
| `radar_id`, `watchlist_id` | Present when Radar or a watchlist found them |
| `score` | The match score |
| `signal_type`, `signal_label` | The kind of signal, and its label |
| `source` | Where the signal came from |
| `contact` | `first_name`, `last_name`, `linkedin_url`, `title`, `headline`, `email`, `location` |
| `company` | `name`, `domain`, `linkedin_url`, `website`, `industry`, `employees`, `location`, `description`, `tech_stack`, `pains` |
| `decision_makers` | Other people at the company: `first_name`, `last_name`, `title`, `linkedin_url` |
| `context` | `icp_fit`, plus `engaged_posts`, `role_hiring`, `dm_titles`, `keyword` and `recent_signals` when we have them |
| `outreach` | Present only when messages are already written: `email_subject`, `email_body`, `linkedin_connection_request`, `linkedin_first_message` |

`lead.tag_updated` covers the tags you put on a person, not the reply tags in Inbox:

| Field | Contents |
| - | - |
| `lead_id`, `agent_id` | The person and their source |
| `action` | `added` or `replaced` |
| `tags` | All their tags now |

### Sent for you

| Label | Event code |
| - | - |
| Invite sent | `connection_request_sent` |
| Message sent | `message_sent` |
| InMail sent | `inmail_sent` |
| Followed them | `follow_sent` |
| Liked their post | `post_liked` |
| Viewed their profile | `profile_viewed` |

Each has this `data`:

| Field | Contents |
| - | - |
| `run_id`, `workflow_id`, `lead_id` | The journey, campaign and person |
| `connected_account_id` | The sender |
| `node_id` | The campaign step |
| `send_type` | `connection_request`, `message`, `inmail`, `follow`, `like_post` or `view_profile` |
| `provider_id` | The person's id on LinkedIn, as an opaque value |
| `chat_id` | The conversation's id, if there is one |
| `occurred_at` | When it happened |

### Replies

| Label | Event code | Fires |
| - | - | - |
| Invite accepted | `invitation_accepted` | When someone accepts an invite |
| First reply | `message_replied` | The first time a reply stops the campaign for someone |
| Every reply | `every_message_reply_received` | On every message they write that has text |
| InMail reply | `inmail_reply_received` | With **First reply**, when an InMail went out to them in that campaign |
| Reply tagged | `lead_auto_tagged` | Each time we change a reply's tag |
| Reply tagged Interested | `lead_auto_tagged_interested` | With **Reply tagged**, when the tag is `interested` |
| Reply tagged Not interested | `lead_auto_tagged_not_interested` | With **Reply tagged**, when the tag is `not_interested` |

Reply tag events fire only for tags we set, not when you change a tag by hand in Inbox.

`invitation_accepted`, `message_replied` and `inmail_reply_received` have this `data`:

| Field | Contents |
| - | - |
| `run_id`, `workflow_id`, `lead_id` | The journey, campaign and person |
| `connected_account_id` | The sender |
| `provider_id`, `chat_id` | The person's LinkedIn id and the conversation |
| `occurred_at` | When we recorded it |
| `reply_text`, `reply_intent` | The reply and its tag. `null` on `invitation_accepted`. |
| `person_name`, `person_linkedin_url` | Who it is |
| `source` | `webhook` if we caught it live, `reconciliation` if a later check found one we missed, possibly days later |

`every_message_reply_received` has `run_id`, `workflow_id`, `lead_id`, `chat_id`, `reply_text`, `reply_intent` and `occurred_at`.

The three reply tag events have `run_id`, `workflow_id`, `lead_id`, `chat_id`, `tag`, `confidence` and `occurred_at`. `tag` is one of `interested`, `meeting_request`, `question`, `objection`, `not_interested`, `out_of_office` or `neutral`.

### Campaigns

| Label | Event code | `data` |
| - | - | - |
| Person finished the campaign | `workflow_completed` | `run_id`, `workflow_id`, `lead_id`, `final_status` (`completed` or `failed`), `reason` (only if it failed), `completed_at` |
| Person finished without a reply | `lead_finished_without_reply` | `run_id`, `workflow_id`, `lead_id`, `completed_at` |
| Campaign finished | `campaign_completed` | `workflow_id`, `last_run_id`, `completed_at` |

**Campaign finished** fires when nobody is left in progress in the campaign right now. It can fire again if you add more people later.

### Senders

| Label | Event code |
| - | - |
| A sender needs attention | `account_health_alert` |

| Field | Contents |
| - | - |
| `account_id`, `display_name` | The sender |
| `action` | `paused`, or `resume_recommended` |
| `reason` | Why, in words |
| `acceptance_rate`, `sent`, `accepted` | Present when invite numbers triggered it |
| `linkedin_status` | Present when the account's LinkedIn status triggered it |
| `reconciliation_corrected` | Present on `resume_recommended` |

### Test event

**Send a test event** and **Try again** send `webhook.test`. It has made-up data in the `lead.created` shape. You can't subscribe to it. Make sure your tool accepts unknown event names without failing.

## Turn a webhook off

Open it and switch **On** off, then press **Save webhook**. It stops getting events and you can turn it back on later.

**Delete…** removes it for good, after you confirm. Nothing more is sent to it, and you can't undo this.

## Troubleshooting

> [accordion] That address can't be used
>
> We only send to public addresses. Addresses inside a private network, and names that lead to one, are refused when you save and again at every delivery. A name we can't find yet can be saved, but deliveries fail until it resolves. A webhook that has never worked and points at a private address goes straight to **Not working**, with no retries.

> [accordion] Your tool answered with an error
>
> We show the status it sent back. Check the address and what your tool accepts. A `405` or `404` often means the address is wrong for a `POST`.

> [accordion] Your tool didn't answer
>
> It didn't answer within 5 seconds, or we couldn't connect. Check the address and that your tool is running.

> [accordion] I lost my signing secret
>
> It's shown once, and after that only the last 4 characters. To get a new one, delete the webhook and add it again, then update your tool.

> [accordion] I missed events while it was Not working
>
> They aren't sent later. To catch up, use the [REST API](https://docs.b2bsignals.ai/developers/overview.md#rest-api).

## Related

> [card] [Developers overview](https://docs.b2bsignals.ai/developers/overview.md)
>
> API, permissions and limits.

> [card] [Integrations](https://docs.b2bsignals.ai/sources/integrations.md)
>
> Send people in from another tool.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.