# Guide to Instantly Webhooks: Features and Best Practices

Instantly webhooks tell your systems what is happening in your cold email campaigns: an email goes out, a lead opens it, replies, books a meeting or unsubscribes, a message bounces, or a sending account runs into an error. If you push Instantly activity into a CRM, a Slack channel or your own reporting, webhooks are how that activity reaches you as it happens, without polling the API.

This guide covers how Instantly webhooks are created, how to authenticate deliveries that Instantly does not sign, the events and payload fields you can expect, the gaps in Instantly's documentation, and the best practices for running an Instantly receiver in production.

## What are Instantly webhooks?

An Instantly webhook is a URL that receives campaign events. Webhooks are available on the Hyper Growth plan or above. When a matching event happens, Instantly sends a `POST` with a JSON body to the URL.

Each webhook subscribes to one event type, to all events, or to custom label events, and can be limited to one campaign or cover every campaign in the workspace. The event name is in the body's `event_type` field.

Instantly does not sign webhook requests. There is no signature header, no signing secret and no timestamp header. The only security control Instantly offers is the option to add custom HTTP headers to a webhook, which Instantly then sends on every delivery. That header is how your receiver tells a real Instantly request from a forged one.

## Instantly webhook features

| Feature | Details |
| --- | --- |
| Plan | Hyper Growth or above |
| Configuration | The app (**Integrations > Webhooks > Add Webhook**) or API v2 (`POST https://api.instantly.ai/api/v2/webhooks`) |
| Subscription | One event type, `all_events`, or custom label events, for one campaign or all campaigns |
| Method and format | `POST` with a JSON body |
| Authentication | No signature. Optional custom headers you set on the webhook, sent verbatim |
| Event name | Top-level `event_type` field |
| Event ID | None, in the body or the headers |
| Retries | Failed deliveries are retried. Counts, timing and timeout are not documented |
| Auto-disable | After repeated delivery failures. Resume with `POST /api/v2/webhooks/{id}/resume` |
| Testing | `POST /api/v2/webhooks/{id}/test` sends a test payload |
| Delivery log | `GET /api/v2/webhook-events` lists delivery records, including retry state |

Instantly publishes no source IP list for webhook traffic.

## Common events

Instantly's webhook events guide groups events like this:

| Group | Events |
| --- | --- |
| Email | `email_sent`, `email_opened`, `reply_received`, `auto_reply_received`, `link_clicked`, `email_bounced`, `lead_unsubscribed`, `account_error`, `campaign_completed` |
| Lead status | `lead_neutral`, `lead_interested`, `lead_not_interested` |
| Meetings | `lead_meeting_booked`, `lead_meeting_completed` |
| Other lead status | `lead_closed`, `lead_out_of_office`, `lead_wrong_person` |
| Custom labels | Any custom label in your workspace, "sent as the `event_type` value as-is" |

The API's list of subscribable event types does not match that table exactly. It has `email_link_clicked` where the guide has `link_clicked`, it has no `auto_reply_received`, and it adds `lead_no_show` and `supersearch_enrichment_completed`. Instantly does not say which spelling of the click event arrives on the wire, so handle both.

Every payload has five base fields, which Instantly marks as always present: `timestamp` (ISO, when the event occurred), `event_type`, `workspace`, `campaign_id` and `campaign_name`. Other fields depend on the event:

* `lead_email` and `email_account` when the event concerns a lead
* `step`, `variant` and `is_first` for campaign steps, where `is_first` says whether this is the first event of its type for the lead
* `email_id`, `email_subject`, `email_text` and `email_html` for sent emails. `email_id` can be used with the reply endpoint
* `reply_text_snippet`, `reply_subject`, `reply_text`, `reply_html` and `unibox_url` for replies
* extra lead fields "merged from database if available", such as the lead's name and company

Instantly documents the payload as an annotated schema rather than a real example. Based on that schema, a reply looks roughly like this (values are illustrative):

```json
{
  "timestamp": "2026-10-21T09:14:03.000Z",
  "event_type": "reply_received",
  "workspace": "6e3c6c0e-6d1e-4c1a-9b8a-2f4b7d1e0a11",
  "campaign_id": "01a15b45-cbcc-7e79-9923-767240005bcf",
  "campaign_name": "Q4 outbound",
  "lead_email": "jane@example.com",
  "email_account": "alex@outreach.example.com",
  "step": 2,
  "variant": 1,
  "reply_subject": "Re: Quick question",
  "reply_text_snippet": "Thanks, happy to talk next week"
}

```

There is no event ID anywhere in the payload.

## Setting up Instantly webhooks

Decide on your authentication header before you create the webhook. Generate a long random value, for example with `openssl rand -hex 32`, and choose a header name, such as `Authorization` or `X-Instantly-Secret`.

In the app:

1. Go to the **Integrations** tab and open the **Webhooks** section.
2. Click **Add Webhook** and enter your endpoint URL.
3. Choose **All events** or a specific event type, and **All campaigns** or a specific campaign.
4. Under **Add headers**, add your header name and secret value.
5. Click **Add Webhook**. You can edit the webhook later.

With API v2, authenticate with an API key that has a webhook scope (for example `webhooks:create`) and send the same settings:

```bash
curl -X POST "https://api.instantly.ai/api/v2/webhooks" \
  -H "Authorization: Bearer $INSTANTLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "CRM sync",
    "target_hook_url": "https://example.com/webhooks/instantly",
    "event_type": "all_events",
    "headers": { "X-Instantly-Secret": "YOUR_GENERATED_SECRET" }
  }'

```

`event_type` takes a single event, `all_events` (which "includes custom label events"), or `null` for custom label events selected with `custom_interest_value`. `campaign` takes a campaign UUID, or `null` for every campaign in the workspace. The API key authenticates you to Instantly. It never appears on a delivery and is not a webhook secret.

To try the integration, call `POST /api/v2/webhooks/{id}/test`, which sends a test payload to the webhook URL. Instantly does not document the shape of that test payload.

## Securing Instantly webhooks

Because nothing is signed, verification means checking your custom header. Compare the whole header value with the value you stored, in constant time, and reject the request if the header is missing or wrong. Fail closed if your secret is not configured.

```javascript
const crypto = require('crypto');
const express = require('express');

// The header name you set on the Instantly webhook. Node lowercases incoming header names.
const SECRET_HEADER = (process.env.INSTANTLY_WEBHOOK_HEADER || 'x-instantly-secret').toLowerCase();

function verifyInstantlyHeader(headers, expected) {
  const received = headers[SECRET_HEADER];
  if (typeof received !== 'string' || !expected) return false;

  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post('/webhooks/instantly', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.INSTANTLY_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyInstantlyHeader(req.headers, secret)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Route on event.event_type. Accept both link_clicked and email_link_clicked.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

def verify_instantly_header(headers, expected: str, header_name: str = "x-instantly-secret") -> bool:
    received = headers.get(header_name)
    if not received or not expected:
        return False
    return hmac.compare_digest(received.encode("utf-8"), expected.encode("utf-8"))

# In FastAPI: verify_instantly_header(request.headers, os.environ["INSTANTLY_WEBHOOK_SECRET"])

```

If you use the `Authorization` header, as in Instantly's own API example (`Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=`), store and compare the full value including the `Basic ` or `Bearer ` prefix.

This check proves the caller knows your secret. It does not prove the body is unchanged, and it gives no protection against a captured request being sent again, because the header is identical on every delivery.

> **Make Instantly webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks your Instantly header before anything reaches your code, queues every campaign event durably, and lets you replay what failed.

## Instantly webhook limitations and pain points

### No signature, only a static header

**The Problem:** A webhook created without headers accepts anything posted to its URL. With a header, anyone who captures one request has the credential for every future one.

**Why It Happens:** Instantly signs nothing. Its only documented authentication is the optional custom headers on each webhook, which are sent unchanged with every delivery.

**Workarounds:**

* Add a long random secret header to every webhook, in the app or through the `headers` object.
* Serve the endpoint over HTTPS and never log the header value.
* Rotate the secret by editing the webhook, accepting both old and new values in your receiver during the switch.

**How Hookdeck Can Help:** Hookdeck's Instantly source type checks a header for you. Enter the header name and the exact value you set in Instantly, and Hookdeck rejects requests without it before they reach your service.

### The event names don't agree

**The Problem:** A handler that waits for `link_clicked` may never see a click, and one that waits for `auto_reply_received` may wait forever.

**Why It Happens:** Instantly's events guide and its API enum list different names. The guide has `link_clicked` and `auto_reply_received`. The API has `email_link_clicked`, `lead_no_show` and `supersearch_enrichment_completed`, and no auto-reply event. Custom labels add workspace-specific `event_type` values that appear in neither list.

**Workarounds:**

* Treat `link_clicked` and `email_link_clicked` as the same event.
* Log and acknowledge any `event_type` you don't recognise rather than rejecting it.
* Keep a list of your workspace's custom label names so your router knows what they mean.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can route on `event_type` with a list of accepted values, so both click spellings and your custom labels reach the right destination.

### No event ID, and retries happen

**The Problem:** A retried delivery looks exactly like a new event, so a reply can create two CRM tasks.

**Why It Happens:** Instantly retries failed deliveries (its delivery records carry `retry_count`, `will_retry` and `timestamp_next_retry`), but the payload has no event ID and Instantly documents no delivery ID header.

**Workarounds:**

* Build an idempotency key from `event_type`, `timestamp`, `campaign_id` and `lead_email`.
* Make writes idempotent, for example by upserting on that key.

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/deduplication) can drop repeated deliveries based on the fields you choose, such as `event_type`, `timestamp` and `lead_email`.

### A failing endpoint gets switched off

**The Problem:** After an outage, Instantly stops sending to the webhook and nothing arrives until someone notices.

**Why It Happens:** Instantly disables a webhook after repeated delivery failures. Its status becomes `-1` and it stays off until you call `POST /api/v2/webhooks/{id}/resume`. Instantly does not say how many failures it takes, how long it waits for a response or how often it retries.

**Workarounds:**

* Return a `2xx` as soon as the header checks out and process the event afterwards.
* Poll `GET /api/v2/webhooks` and alert on any webhook whose `status` is `-1`.
* Use `GET /api/v2/webhook-events` to see failed deliveries and their retry state.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as soon as it arrives and stores it, so your own downtime doesn't count as a failure against the webhook. You then [retry](/docs/retries) and replay events on your own schedule, and [issues](/docs/issues) alert you when your service starts failing.

## Best practices

### Set a header on every webhook

Instantly does not require headers, so it is easy to create a webhook that sends events with no credential at all. Make the secret header part of your setup checklist, and check existing webhooks with `GET /api/v2/webhooks`.

### Compare the full value and fail closed

Compare the whole header value, including any `Bearer ` or `Basic ` prefix, in constant time. Return `401` on a missing or wrong header, and reject everything if the secret is not configured.

### Route on `event_type` and expect new values

Switch on `event_type`, accept both click spellings, and acknowledge anything unknown. New event types and custom labels appear without notice.

### Read optional fields defensively

Only the five base fields are guaranteed. Use optional access for `lead_email`, `step`, `reply_text` and the rest, and don't assume merged lead data has a fixed set of keys.

### Acknowledge fast and process asynchronously

Instantly does not publish its timeout, so treat every slow response as a possible failure. Return `200` after the header check and hand the event to a queue. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Deduplicate deliveries

With no event ID, build your own key and store it before processing. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

Instantly sends campaign and lead events as plain JSON `POST`s with no signature, so the custom header you add to each webhook is your only protection against forged requests. Set one on every webhook, compare it in full, route on `event_type` with both click spellings in mind, and build your own deduplication key because the payload has no event ID.

[Hookdeck Event Gateway](https://hookdeck.com) verifies that header and stops requests that fail it, stores every event before your service sees it, and retries on your terms, so a slow or offline receiver doesn't end with a disabled webhook.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Instantly webhooks reliably in minutes.