Receive vendor webhooks once each, and list the ones that never arrived
A webhook endpoint that rejects forged requests and drops redeliveries, with a dedupe store in your own database, and a scheduled check that reports messages the vendor lists but no webhook announced.
A webhook can arrive twice, late or not at all. Code that counts replies from webhooks alone drifts from what the vendor shows. This guide builds the two halves that keep it honest:
- An endpoint that checks the request is yours, turns the vendor’s payload into
OutboundEventrecords, and drops an event it has already handled. - A check you run on a schedule that compares the events you kept with the messages the vendor lists, and reports the difference.
The SDK receives and compares. It does not register a webhook with a vendor, and reconcile reports a mismatch without repairing anything.
Before you start
- Node 22 or later. The dedupe store in this guide uses
node:sqlite, which is built into Node; the samples are run on Node 24, where it needs no flag. - The code below needs no vendor key and no public URL. Each sample on this page is a file in the repository that the test suite runs; the output shown is what it printed.
- For real deliveries: an HTTPS endpoint the vendor can reach, and a secret you choose.
What you get
Five requests arrive at the endpoint: a sent email, a reply, the same reply again, an open, and a sent email with the wrong secret.
email_sent: 204, kept message.sent
reply_received: 204, kept reply.received
reply_received again: 204, kept nothing
email_opened: 204, kept message.opened
email_sent, wrong secret: 401, kept nothing
3 events kept:
message.sent instantly:acme:01a1d77b-a37a-7316-9165-874e4863045b at 2026-11-14T00:36:06.650Z, received 2026-11-14T00:36:10.000Z
reply.received instantly:acme:01a1d77b-a37a-7316-9165-874e4863045c at 2026-11-14T00:36:06.650Z, received 2026-11-14T01:10:06.650Z
message.opened (names no message) at 2026-11-14T00:36:06.650Z, received 2026-11-14T01:15:00.000Z
Later, the check against what the vendor lists for the same morning:
compared 2 events, matched 2
not compared: 1
listed by the vendor, no webhook: [ 'instantly:acme:msg_no_webhook' ]
webhook for a message the vendor does not list: []
late: reply.received by 34 minutes
late: message.opened by 39 minutes
The endpoint
import type { ReceivedEvent } from "outbound-sdk/reconcile";
import { parse, verify } from "outbound-sdk/webhooks";
import type { DedupeStore } from "outbound-sdk/webhooks";
export interface HandlerOptions {
readonly secret: string;
readonly dedupe: DedupeStore;
/** Keeps one event. Make it durable: reconcile reads these back later. */
readonly save: (received: ReceivedEvent) => Promise<void> | void;
/** The clock, ISO 8601 UTC. */
readonly now: () => string;
}
/** A webhook endpoint for one Instantly workspace, as a fetch-style handler. */
export const createHandler =
(options: HandlerOptions) =>
async (request: Request): Promise<Response> => {
// `verify` answers true or false and leaves the body readable.
const trusted = await verify(request, {
provider: "instantly",
secret: options.secret,
});
if (!trusted) {
return new Response(null, { status: 401 });
}
// An event already claimed in the dedupe store is not returned again.
const events = await parse(request, {
dedupe: options.dedupe,
provider: "instantly",
workspaceId: "acme",
});
for (const event of events) {
// oxlint-disable-next-line no-await-in-loop -- events are saved in the order they arrived.
await options.save({ event, receivedAt: options.now() });
}
return new Response(null, { status: 204 });
};
verifyanswerstrueorfalseand never throws for a bad secret. It reads a copy of the request, so the body is still there forparse. Passingsecrettoparsedoes the same check and throwsUnauthorizedinstead.- Name the provider. Without
provider, the parser is picked from the shape of the payload, so the request decides which vendor’s rules it is checked by. Use one URL per vendor and name the provider on each. workspaceIdis your label for the account the webhook belongs to. It goes into every ref the event carries, so it must be the label the adapter for that account uses.event.idis the idempotency key. It is the same for every redelivery of an event. Where the vendor sends an id it is built from that; otherwise from the fields that identify the event.- Save first, work later. The handler writes the event with the time it arrived and returns. Whatever you do with a reply (a CRM update, a notification) reads from that table.
How each vendor authenticates is different. This is setupInstructions(provider) for each one, printed by a script:
import { setupInstructions, webhookProviders } from "outbound-sdk/webhooks";
for (const provider of webhookProviders()) {
console.log(`${setupInstructions(provider)}\n`);
}
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: 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: 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.
For a vendor that does not sign, the shared secret is looked for in a path segment of the URL, in a secret, token or key query parameter, in an x-webhook-secret or x-outbound-secret header, and in Authorization: Bearer.
A dedupe store of your own
parse asks the store to claim each event’s id. A claim that succeeds means the event is new. memoryDedupeStore() ships with the package: it lives in one process and forgets its oldest ids after 10,000. Behind a load balancer, or on a platform that starts a process per request, every process must share one store. It is one function:
import type { DatabaseSync } from "node:sqlite";
import type { DedupeStore } from "outbound-sdk/webhooks";
/**
* A dedupe store in your own database, so every process behind the endpoint
* agrees on what was handled. The insert is the claim: it succeeds once.
*/
export const sqliteDedupe = (db: DatabaseSync): DedupeStore => {
db.exec(`
CREATE TABLE IF NOT EXISTS webhook_events (
id TEXT PRIMARY KEY, claimed_at TEXT NOT NULL
)
`);
const insert = db.prepare(`
INSERT INTO webhook_events (id, claimed_at) VALUES (?, ?)
ON CONFLICT (id) DO NOTHING
`);
return {
claim: (id) => {
const { changes } = insert.run(id, new Date().toISOString());
return Promise.resolve(Number(changes) === 1);
},
};
};
The claim has to be atomic: two requests carrying the same event at the same moment must not both win. An insert on a primary key gives you that. The same claim in Postgres, which the test suite does not run:
INSERT INTO webhook_events (id, claimed_at)
VALUES ($1, now())
ON CONFLICT (id) DO NOTHING
RETURNING id;
A row back means the claim succeeded.
Try it without a key
The request bodies come from the repository’s fixtures:
// The request bodies on this page are the vendors' documented webhook
// payloads, kept in the repository under fixtures/<provider>/ with personal
// data replaced. None of them was captured from a real delivery.
import { readFileSync } from "node:fs";
const FIXTURES = new URL("../../../../fixtures/", import.meta.url);
export const payload = (provider: string, name: string): string =>
readFileSync(new URL(`${provider}/webhook.${name}.json`, FIXTURES), "utf-8");
export const SECRET = "whk_example_secret_0001";
/** The request a vendor would send, with the shared secret in a header. */
export const post = (body: string, secret: string = SECRET): Request =>
new Request("https://app.example.com/hooks/outbound", {
body,
headers: {
"content-type": "application/json",
"x-webhook-secret": secret,
},
method: "POST",
});
import { DatabaseSync } from "node:sqlite";
import type { ReceivedEvent } from "outbound-sdk/reconcile";
import { sqliteDedupe } from "./dedupe-store.ts";
import { createHandler } from "./handler.ts";
import { payload, post, SECRET } from "./payloads.ts";
const sent = payload("instantly", "email_sent");
const reply = payload("instantly", "reply_received");
const opened = payload("instantly", "email_opened");
// What arrives at the endpoint, in order, and when. Both messages happened
// at 00:36:06, so the reply's webhook is 34 minutes late.
const deliveries = () => [
{ at: "2026-11-14T00:36:10.000Z", name: "email_sent", request: post(sent) },
{
at: "2026-11-14T01:10:06.650Z",
name: "reply_received",
request: post(reply),
},
{
at: "2026-11-14T01:10:09.000Z",
name: "reply_received again",
request: post(reply),
},
{
at: "2026-11-14T01:15:00.000Z",
name: "email_opened",
request: post(opened),
},
{
at: "2026-11-14T01:20:00.000Z",
name: "email_sent, wrong secret",
request: post(sent, "not-the-secret"),
},
];
/** Sends every delivery through the handler. Returns what it kept. */
export const receive = async (): Promise<{
readonly log: string[];
readonly received: ReceivedEvent[];
}> => {
const received: ReceivedEvent[] = [];
const log: string[] = [];
let clock = "";
const handle = createHandler({
dedupe: sqliteDedupe(new DatabaseSync(":memory:")),
now: () => clock,
save: (entry) => {
received.push(entry);
},
secret: SECRET,
});
for (const delivery of deliveries()) {
const before = received.length;
clock = delivery.at;
// oxlint-disable-next-line no-await-in-loop -- deliveries arrive one after another.
const response = await handle(delivery.request);
const kept = received
.slice(before)
.map(({ event }) => event.type)
.join(", ");
log.push(
`${delivery.name}: ${response.status}, kept ${kept === "" ? "nothing" : kept}`
);
}
return { log, received };
};
import { receive } from "./inbox.ts";
const { log, received } = await receive();
console.log(log.join("\n"));
console.log(`\n${received.length} events kept:`);
for (const { event, receivedAt } of received) {
console.log(
` ${event.type} ${event.messageRef ?? "(names no message)"} at ${event.occurredAt}, received ${receivedAt}`
);
}
That is the script that printed the five deliveries above. The redelivered reply was answered 204 and kept nothing. The documented payload for an open carries no message id, so its event names no message.
Report what never arrived
outbound.reconcile.events() lists the vendor’s messages in a window and compares them with the events you kept.
// What the vendor lists for the same morning: the message the `email_sent`
// payload announces, the reply the `reply_received` payload announces, and a
// second sent message that no webhook announced.
import type { MemoryData } from "outbound-sdk/memory";
export const seed: MemoryData = {
campaigns: [{ id: "01a1d77b-a332-7a01-8e03-6f1a3f4d7599" }],
messages: [
{
campaignId: "01a1d77b-a332-7a01-8e03-6f1a3f4d7599",
direction: "out",
id: "01a1d77b-a37a-7316-9165-874e4863045b",
sentAt: "2026-11-14T00:36:06.650Z",
},
{
campaignId: "01a1d77b-a332-7a01-8e03-6f1a3f4d7599",
direction: "in",
id: "01a1d77b-a37a-7316-9165-874e4863045c",
sentAt: "2026-11-14T00:36:06.650Z",
},
{
campaignId: "01a1d77b-a332-7a01-8e03-6f1a3f4d7599",
direction: "out",
id: "msg_no_webhook",
sentAt: "2026-11-14T02:00:00.000Z",
},
],
};
import { createOutbound } from "outbound-sdk";
import { memory } from "outbound-sdk/memory";
import { reconcile } from "outbound-sdk/reconcile";
import { receive } from "./inbox.ts";
import { seed } from "./seed.ts";
const { received } = await receive();
// The in-memory adapter, carrying the provider name and workspace label the
// events carry, so a message's ref is the same on both sides.
const outbound = createOutbound({
adapter: memory({ data: seed, name: "instantly", workspaceId: "acme" }),
plugins: [reconcile()],
});
const report = await outbound.reconcile.events({
events: received,
since: "2026-11-14T00:00:00.000Z",
until: "2026-11-14T12:00:00.000Z",
});
console.log(`compared ${report.checked} events, matched ${report.matched}`);
console.log(`not compared: ${report.uncheckable}`);
console.log(
"listed by the vendor, no webhook:",
report.missingEvents.map((message) => message.ref)
);
console.log(
"webhook for a message the vendor does not list:",
report.unmatchedEvents.map((event) => event.messageRef)
);
for (const { delayMs, event } of report.late) {
console.log(`late: ${event.type} by ${Math.round(delayMs / 60_000)} minutes`);
}
That is the script that printed the report above. What each field means:
| Field | Meaning |
|---|---|
checked |
Events that could be compared. |
matched |
Of those, the ones whose message the vendor lists. |
missingEvents |
Messages the vendor lists that no event announced: a webhook never arrived, or you are not subscribed to it. |
unmatchedEvents |
Events naming a message the vendor does not list in the window. |
late |
Events received more than lateAfterMinutes (default 5) after they happened. Needs receivedAt. |
uncheckable |
Events that were not compared. |
Reconcile compares exactly four event types, and only when the event names a message:
message.sentreply.receivedlinkedin.message_sentlinkedin.message_received
Everything else is counted in uncheckable: opens, clicks, bounces, unsubscribes, category changes, status changes, LinkedIn connection events, and any of the four that arrives without a message ref. A missing bounce or unsubscribe webhook is not found by this check.
Three more things to know before trusting the report:
- It lists every message in the window, sent and received. If you subscribe to replies only, every sent message appears in
missingEvents. Filter the list bydirectionfor the kind you subscribe to. - It does not filter your events by the window. Pass the events from the same window you ask the vendor for. An event from before
sinceis reported as unmatched. - It reports and stops. Fetching a missed reply is a call you make:
outbound.replies.list({ since }), or the sync in the database guide.
Point it at your vendor
Export the handler from whatever serves your routes, and open your real database where the sample opens one in memory.
import { DatabaseSync } from "node:sqlite";
import { sqliteDedupe } from "./dedupe-store.ts";
import { createHandler } from "./handler.ts";
// Open your real database here: the dedupe table must outlive the process.
const db = new DatabaseSync(":memory:");
/** A route handler: Next.js, Hono, Bun and Deno all take this shape. */
export const POST = createHandler({
dedupe: sqliteDedupe(db),
now: () => new Date().toISOString(),
save: ({ event, receivedAt }) => {
// Write the event to your own table before doing anything else with it.
console.log(receivedAt, event.type, event.id);
},
// With no secret set, every request is answered 401.
secret: process.env.WEBHOOK_SECRET ?? "",
});
For the check, use the adapter for the same account with the same workspaceId, add reconcile() to its plugins, read the saved events for the window back from your table, and call outbound.reconcile.events() from your scheduler.
What each vendor can serve
One documented payload per vendor, parsed with no provider named, then handed to reconcile on the in-memory adapter limited to what that vendor’s adapter declares. This shows what the parser makes of the documented payload. It does not show what the vendor sends.
import { createOutbound } from "outbound-sdk";
import { reconcile } from "outbound-sdk/reconcile";
import { parse } from "outbound-sdk/webhooks";
import { asVendor } from "../_shared/as-vendor.ts";
import type { Vendor } from "../_shared/as-vendor.ts";
import { payload, post } from "./payloads.ts";
// One documented payload per vendor, each announcing a message.
const DOCUMENTED: readonly { name: string; vendor: Vendor }[] = [
{ name: "email_sent", vendor: "instantly" },
{ name: "EMAIL_SENT", vendor: "smartlead" },
{ name: "voice_note_received", vendor: "heyreach" },
];
const receiveAs = async (vendor: Vendor, name: string): Promise<string> => {
// No provider is named, so the parser is picked from the payload's shape.
const events = await parse(post(payload(vendor, name)), {
workspaceId: "acme",
});
const outbound = createOutbound({
adapter: asVendor(vendor, {}, "acme"),
plugins: [reconcile()],
});
const report = await outbound.reconcile.events({
events,
since: "2026-11-01T00:00:00.000Z",
});
return events
.map((event) => {
const names = event.messageRef === null ? "no message" : "a message";
return `${event.provider}: ${name} is ${event.type}, names ${names}; reconcile compared ${report.checked}, could not compare ${report.uncheckable}`;
})
.join("\n");
};
const lines = await Promise.all(
DOCUMENTED.map(({ name, vendor }) => receiveAs(vendor, name))
);
console.log(lines.join("\n"));
instantly: email_sent is message.sent, names a message; reconcile compared 1, could not compare 0
smartlead: EMAIL_SENT is message.sent, names a message; reconcile compared 1, could not compare 0
heyreach: voice_note_received is linkedin.message_received, names no message; reconcile compared 0, could not compare 1
HeyReach’s payloads carry no message id, so its events never name a message and reconcile cannot compare them: every HeyReach event is uncheckable. Deduplication still works, because event.id is built from HeyReach’s correlation_id.
What it costs in vendor requests
Receiving a webhook costs no vendor request. reconcile.events() costs one listing of every message in the window, and campaignId narrows it to one campaign. Keep the window short and run it often. On Smartlead the date filter for messages is applied by the SDK after fetching, so the window does not reduce the requests there. Pass maxRequests or deadline to cap one check.
When it cannot answer
import { createOutbound, OutboundError } from "outbound-sdk";
import { memory } from "outbound-sdk/memory";
import { reconcile } from "outbound-sdk/reconcile";
import { memoryDedupeStore, parse, verify } from "outbound-sdk/webhooks";
import { payload, post, SECRET } from "./payloads.ts";
import { seed } from "./seed.ts";
const sent = payload("instantly", "email_sent");
// 1. A request without the secret. `verify` says no; `parse` throws.
console.log(
"wrong secret, verify:",
await verify(post(sent, "guess"), { provider: "instantly", secret: SECRET })
);
try {
await parse(post(sent, "guess"), { provider: "instantly", secret: SECRET });
} catch (error) {
if (OutboundError.is(error)) {
console.log("wrong secret, parse:", error.code, "-", error.message);
}
}
// 2. A provider with no parser.
try {
await parse(post(sent), { provider: "mailchimp" });
} catch (error) {
if (OutboundError.is(error)) {
console.log("unknown provider:", error.code, "-", error.message);
}
}
// 3. A body no parser recognises comes back as one `unknown` event with the
// body in `raw`. It is not dropped and nothing throws.
const [unknown] = await parse(post(JSON.stringify({ hello: "world" })));
console.log("unrecognised body:", unknown?.type, JSON.stringify(unknown?.raw));
// 4. The id is claimed before your code runs. If your code then fails, the
// vendor's retry is dropped as a repeat: the event is lost.
const dedupe = memoryDedupeStore();
const first = await parse(post(sent), { dedupe, provider: "instantly" });
const retry = await parse(post(sent), { dedupe, provider: "instantly" });
console.log(
`claimed, then the handler crashed: first ${first.length}, retry ${retry.length}`
);
// 5. Reconcile compares every event it is given with the messages in the
// window. An event from before the window is reported as unmatched.
const outbound = createOutbound({
adapter: memory({ data: seed, name: "instantly", workspaceId: "acme" }),
plugins: [reconcile()],
});
const narrow = await outbound.reconcile.events({
events: first,
since: "2026-11-14T01:00:00.000Z",
});
console.log(
`window starts after the event: matched ${narrow.matched}, unmatched ${narrow.unmatchedEvents.length}`
);
// 6. An adapter that cannot list messages cannot be reconciled.
const noMessages = createOutbound({
adapter: memory({ capabilities: { "messages.list": false }, data: seed }),
plugins: [reconcile()],
});
try {
await noMessages.reconcile.events({ events: first, since: "2026-11-14" });
} catch (error) {
if (OutboundError.is(error)) {
console.log("no message list:", error.code, "-", error.message);
}
}
wrong secret, verify: false
wrong secret, parse: Unauthorized - The webhook does not carry the shared secret
unknown provider: Invalid - No webhook parser for "mailchimp". Known providers: heyreach, instantly, smartlead
unrecognised body: unknown {"hello":"world"}
claimed, then the handler crashed: first 1, retry 0
window starts after the event: matched 0, unmatched 1
no message list: Unsupported - Listing messages is not available: the memory adapter does not support it (messages.list)
| What happened | What you get | What to do |
|---|---|---|
| The request does not carry the secret | verify is false; parse with secret throws Unauthorized |
Answer 401. Do not log the body as an event. |
| A provider with no parser | Invalid, naming the known providers |
Use instantly, smartlead or heyreach. |
| A body no parser recognises | One event of type unknown with the body in raw |
Keep it and look at it. A vendor may have added an event type. |
| The same event again | An empty array | Answer 2xx so the vendor stops retrying. |
| Your handler failed after the claim | The retry is dropped as a repeat | See the callout above. Reconcile reports the message if it is one of the four types. |
| An event from outside the window | It is in unmatchedEvents |
Pass only the window’s events. |
| The adapter cannot list messages | Unsupported, before any request |
Reconcile is not available on that adapter. |
Limits
- No parser has been checked against a real delivery. That includes whether the message id in a webhook equals the id the vendor’s list returns, which the comparison depends on.
- Instantly sends no event id and no signature. The id is built from the event name, campaign, lead address, message id and timestamp.
- Smartlead verification needs signing turned on for the webhook. An unsigned Smartlead request fails verification when you pass a secret.
- HeyReach events name no message and cannot be reconciled.
- On Instantly, the email list does not hold every email a campaign sent (seen on the live workspace). A genuine
message.sentwebhook can therefore be reported underunmatchedEventsalthough nothing is wrong. Treat that list as leads to check, not as proof. - Only the four message event types are compared. Nothing checks that an open, a bounce or an unsubscribe webhook arrived.
latecompares your clock with the vendor’s timestamp. A skewed clock on either side shows up as lateness.