---
title: Webhooks
description: Turn a webhook from Instantly, Smartlead or HeyReach into one normalized event shape with a single function, and know what an event can and cannot tell you.
sidebar:
  label: Overview
related:
  - /docs/webhooks/verification
  - /docs/webhooks/events
  - /docs/plugins/reconcile
  - /guides/webhooks-and-reconcile
---

`parse(request, options)` from `outbound-sdk/webhooks` takes the `Request` your endpoint received and returns `OutboundEvent[]`: the same shape whichever vendor sent it. It is a pure function over the request. It makes no vendor call and stores nothing.

> **Tested on documented payloads only**
>
> The parsers are tested against payloads built from each vendor's
> documentation. No parser has received a live delivery from a vendor, so expect
> differences on real traffic and keep `raw`. See [what is
> verified](/docs/status).

## Parse a request

```ts title="parse.ts"
import { parse } from "outbound-sdk/webhooks";

import { payload, post } from "../webhooks-events/fixtures.ts";

// Instantly's documented "reply received" payload, posted to your endpoint.
const request = post(payload("instantly", "webhook.reply_received.json"));

const events = await parse(request, {
  provider: "instantly",
  workspaceId: "client-a",
});

for (const event of events) {
  // `raw` is the vendor's body, kept whole. Left out here to keep this short.
  console.log(
    JSON.stringify({ ...event, raw: "(the original body)" }, null, 2)
  );
}
```

```text title="Output"
{
  "campaignRef": "instantly:client-a:01a1d77b-a332-7a01-8e03-6f1a3f4d7599",
  "data": {
    "campaignName": "My First Campaign",
    "isFirst": true,
    "leadEmail": "person1@example.com",
    "stepIndex": 0,
    "subject": "Re: Hello Alex",
    "text": "Thanks for reaching out, let's talk next week.",
    "variantKey": "A",
    "vendorEvent": "reply_received"
  },
  "id": "ab72ea7bb4fd8b9ba91a3a2b3e593a10",
  "leadRef": null,
  "messageRef": "instantly:client-a:01a1d77b-a37a-7316-9165-874e4863045c",
  "occurredAt": "2026-11-14T00:36:06.650Z",
  "provider": "instantly",
  "raw": "(the original body)",
  "schemaVersion": 1,
  "senderRef": "instantly:client-a:person2@example.com",
  "type": "reply.received",
  "workspaceId": "client-a"
}
```

The request in these samples is built from a payload in the repository's `fixtures/` folder with this helper. In your code the request comes from your framework.

```ts title="fixtures.ts"
// The vendors' documented webhook payloads, kept in this repository under
// fixtures/<provider>/webhook.<event>.json with personal data removed.
// They are built from each vendor's documentation, not captured deliveries.
import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";

const ROOT = path.resolve(import.meta.dirname, "../../../../fixtures");

/** The names of one provider's documented webhook payloads. */
export const payloadNames = (provider: string): string[] =>
  readdirSync(path.join(ROOT, provider))
    .filter((name) => name.startsWith("webhook.") && name.endsWith(".json"))
    .toSorted();

/** One documented payload, as the text a vendor would post. */
export const payload = (provider: string, name: string): string =>
  readFileSync(path.join(ROOT, provider, name), "utf-8");

/** A POST request carrying `body`, as your endpoint would receive it. */
export const post = (
  body: string,
  url = "https://your.app/hooks/outbound",
  headers: Record<string, string> = {}
): Request =>
  new Request(url, {
    body,
    headers: { "content-type": "application/json", ...headers },
    method: "POST",
  });
```

## The event

| Field | What it holds |
| --- | --- |
| `type` | The normalized type. See the list below. |
| `id` | An id that is the same when the vendor delivers the event again. Use it as the idempotency key. See [events](/docs/webhooks/events). |
| `provider`, `workspaceId` | Who sent it, and the workspace label you passed as `workspaceId` (default `default`). |
| `occurredAt` | The vendor's time for the event, or `null` when the payload has none. |
| `campaignRef`, `leadRef`, `messageRef`, `senderRef` | [Refs](/docs/api/records) to the records the event is about. `null` when the payload does not identify one. |
| `data` | Normalized fields that depend on the type, such as `leadEmail`, `stepIndex`, `category` or `text`. `data.vendorEvent` is the vendor's own event name. |
| `raw` | The vendor's body, kept whole. |
| `schemaVersion` | The version of this shape. |

A ref is `null` more often than on a record you read. Instantly names the lead by email address only, so its events have no `leadRef`; the address is in `data.leadEmail`.

The types are `reply.received`, `reply.categorized`, `message.sent`, `message.opened`, `message.clicked`, `message.bounced`, `lead.unsubscribed`, `lead.status_changed`, `meeting.booked`, `campaign.status_changed`, `sender.status_changed`, `linkedin.connection_sent`, `linkedin.connection_accepted`, `linkedin.message_sent`, `linkedin.message_received` and `unknown`. [Events](/docs/webhooks/events) lists which vendor event becomes which type.

## Options

| Option | What it does |
| --- | --- |
| `provider` | A provider id, or `auto` (the default) to detect the vendor from the payload. |
| `secret` | Checks that the request is authentic and throws `Unauthorized` when it is not. Without it nothing is checked. See [verification](/docs/webhooks/verification). |
| `workspaceId` | The workspace label the events carry. Defaults to `default`. |
| `dedupe` | A store that drops events already handled. See [events](/docs/webhooks/events). |
| `plugins` | Plugins whose `event` hooks should run on each event, for example [`redact`](/docs/plugins/redact). Pass the same list, in the same order, as on your instance. |

`webhookProviders()` returns the provider ids that have a parser.

## Named provider or auto

```ts title="unknown.ts"
import { OutboundError } from "outbound-sdk";
import { parse } from "outbound-sdk/webhooks";

import { payload, post } from "../webhooks-events/fixtures.ts";

const show = async (label: string, request: Request, provider?: string) => {
  const events = await parse(request, { provider });
  console.log(
    label,
    events.map((event) => ({
      provider: event.provider,
      raw: event.raw === null ? null : "(kept)",
      type: event.type,
      vendorEvent: event.data.vendorEvent,
    }))
  );
};

const reply = payload("instantly", "webhook.reply_received.json");

// `auto` (the default) detects the vendor from the payload.
await show("auto:", post(reply));

// A body no parser recognises is still returned, as one unknown event.
await show("unrecognised:", post(JSON.stringify({ hello: "world" })));

// A vendor event with no normalized type keeps its vendor name and its body.
await show(
  "no normalized type:",
  post(payload("smartlead", "webhook.MANUAL_STEP_REACHED.json")),
  "smartlead"
);

// A provider with no parser is an error, not an unknown event.
try {
  await parse(post(reply), { provider: "mailchimp" });
} catch (error) {
  if (OutboundError.is(error)) {
    console.log(error.code, "-", error.message);
  }
}
```

```text title="Output"
auto: [
  {
    provider: 'instantly',
    raw: '(kept)',
    type: 'reply.received',
    vendorEvent: 'reply_received'
  }
]
unrecognised: [
  {
    provider: 'unknown',
    raw: '(kept)',
    type: 'unknown',
    vendorEvent: undefined
  }
]
no normalized type: [
  {
    provider: 'smartlead',
    raw: '(kept)',
    type: 'unknown',
    vendorEvent: 'MANUAL_STEP_REACHED'
  }
]
Invalid - No webhook parser for "mailchimp". Known providers: heyreach, instantly, smartlead
```

- **Name the provider in production.** One endpoint per vendor, each with its provider named, means a payload is only ever read by the parser you chose and checked the way that vendor is checked. `auto` picks both from the shape of the body.
- **Unknown events are kept.** A body no parser recognises comes back as one event with `type: "unknown"` and `provider: "unknown"`. A vendor event the SDK has no type for comes back as `unknown` with the vendor's name in `data.vendorEvent`. Neither is dropped, and both keep `raw`, so you can log what you are not handling.
- **A provider with no parser throws `Invalid`.**
- **A named provider and a body that is not a JSON object** gives an empty array.

## Events are hints

A webhook can arrive late, arrive twice, or never arrive. So:

- Treat an event as a reason to look, not as the record. Read the record back with the SDK before acting on it.
- Do not build totals by counting events. Count from list calls, or check event-driven numbers against them with the [`reconcile` plugin](/docs/plugins/reconcile).
- Use `event.id` so that handling the same event twice does nothing the second time.

## A route handler

```ts title="handler.ts"
import process from "node:process";

import { OutboundError } from "outbound-sdk";
import { memoryDedupeStore, parse } from "outbound-sdk/webhooks";

import { payload, post } from "../webhooks-events/fixtures.ts";

// One process only. Use a store of your own when more than one instance runs.
const dedupe = memoryDedupeStore();

/** A route handler in the web-standard shape: Next.js, Hono, Workers, Bun. */
export const POST = async (request: Request): Promise<Response> => {
  const secret = process.env.WEBHOOK_SECRET;

  // Without a secret `parse` checks nothing, so refuse to run without one.
  if (secret === undefined || secret === "") {
    return new Response(null, { status: 500 });
  }

  try {
    const events = await parse(request, {
      dedupe,
      provider: "instantly",
      secret,
    });

    for (const event of events) {
      // Hand each event to your own code. An event is a hint: read the
      // record back with the SDK before acting on it.
      console.log(event.type, event.campaignRef, event.data.leadEmail);
    }

    return new Response(null, { status: 204 });
  } catch (error) {
    if (OutboundError.is(error) && error.code === "Unauthorized") {
      return new Response(null, { status: 401 });
    }

    throw error;
  }
};

// Called here with the documented payload, to show it runs.
process.env.WEBHOOK_SECRET = "example-shared-secret";

const body = payload("instantly", "webhook.reply_received.json");
const url = "https://your.app/hooks/example-shared-secret";

const first = await POST(post(body, url));
const redelivery = await POST(post(body, url));
const unsigned = await POST(post(body));

console.log(first.status);

console.log(redelivery.status, "(redelivery: no event)");

console.log(unsigned.status, "(no secret in the request)");
```

```text title="Output"
reply.received instantly:default:01a1d77b-a332-7a01-8e03-6f1a3f4d7599 person1@example.com
204
204 (redelivery: no event)
401 (no secret in the request)
```

The sample calls the handler three times with the documented payload: once with the secret in the URL, once as a redelivery, and once without the secret. No web framework was run.

## Registering the webhook

The SDK is read-only and registers nothing at the vendor. You add the webhook in the vendor's app. `setupInstructions(provider)` returns where to do that and how the request will be authenticated:

```ts title="setup.ts"
import { setupInstructions, webhookProviders } from "outbound-sdk/webhooks";

for (const provider of webhookProviders()) {
  console.log(`${provider}\n${setupInstructions(provider)}\n`);
}
```

```text title="Output"
heyreach
HeyReach: Integrations > Webhooks > Create webhook, one per event type. HeyReach does not sign payloads, so put a secret in the URL (https://your.app/hooks/<secret>) or add it as a custom header named x-webhook-secret, and pass the same value as `secret`.

instantly
Instantly: Settings > Integrations > Webhooks > Add webhook (Hyper Growth plan or above). Instantly does not sign payloads, so put a secret in the URL (https://your.app/hooks/<secret>) or add it as a custom header named x-webhook-secret, and pass the same value as `secret`.

smartlead
Smartlead: Settings > Webhooks > Add webhook, per account, client or campaign (only one fires per event; the account-level one wins). Turn on signing for the webhook and pass its whsec_ secret as `secret`: the SDK then checks the Standard Webhooks signature and rejects anything older than five minutes. An unsigned Smartlead webhook fails verification.

```

The menu paths in those instructions were not checked against a live account for this page. To see the webhooks that already exist at a vendor, list them with `outbound.webhooks` or [`outbound webhooks`](/docs/cli/commands).
