Skip to content
Preview: outbound-sdk is not on npm yet and its repository is private. Instantly and HeyReach are beta, Smartlead is unverified.What is verified
outbound-sdk
Esc
↑↓navigate↵open⌘Jpreview
On this page

Webhooks

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.

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.

Parse a request

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)
  );
}
{
  "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.

// 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.
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 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 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.
workspaceId The workspace label the events carry. Defaults to default.
dedupe A store that drops events already handled. See events.
plugins Plugins whose event hooks should run on each event, for example 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

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);
  }
}
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.
  • Use event.id so that handling the same event twice does nothing the second time.

A route handler

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)");
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:

import { setupInstructions, webhookProviders } from "outbound-sdk/webhooks";

for (const provider of webhookProviders()) {
  console.log(`${provider}\n${setupInstructions(provider)}\n`);
}
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.

Was this page helpful?