reconcile
Compare the webhook events you received and the refs you hold with what the vendor lists, and get a report of what is missing, unmatched, late, deleted or new.
reconcile() from outbound-sdk/reconcile adds two checks. outbound.reconcile.events() compares the webhook events your endpoint received in a window with the messages the vendor lists for that window, and reports webhooks that never arrived, events that name no listed message, and late delivery. outbound.reconcile.deletions() compares a full listing with the refs you hold and reports what is gone and what is new. Both only report. Nothing is repaired, and nothing is written anywhere.
Sample
The events are ones an endpoint would have stored after parse(), each with the time it was received. They are made up to match the shared seed, which has three messages.
// The webhook events an endpoint stored during the week, as
// `parse()` from outbound-sdk/webhooks returns them, each with the time the
// endpoint received it. Made up, to match the seed.
import { SCHEMA_VERSION } from "outbound-sdk";
import type { EventType, OutboundEvent } from "outbound-sdk";
import type { ReceivedEvent } from "outbound-sdk/reconcile";
const event = (
id: string,
type: EventType,
messageId: string | null,
occurredAt: string
): OutboundEvent => ({
campaignRef: null,
data: {},
id,
leadRef: null,
messageRef: messageId === null ? null : `memory:agency:${messageId}`,
occurredAt,
provider: "memory",
raw: null,
schemaVersion: SCHEMA_VERSION,
senderRef: null,
type,
workspaceId: "agency",
});
export const received: ReceivedEvent[] = [
// Arrived two seconds after the send.
{
event: event("evt_1", "message.sent", "msg_1", "2026-11-10T10:00:00.000Z"),
receivedAt: "2026-11-10T10:00:02.000Z",
},
// Arrived 20 minutes after the reply.
{
event: event(
"evt_2",
"reply.received",
"msg_2",
"2026-11-11T08:30:00.000Z"
),
receivedAt: "2026-11-11T08:50:00.000Z",
},
// Names a message the vendor does not list.
{
event: event("evt_3", "message.sent", "msg_9", "2026-11-12T09:00:00.000Z"),
receivedAt: "2026-11-12T09:00:01.000Z",
},
// Not about a message, so there is nothing to compare it with.
{
event: event(
"evt_4",
"lead.unsubscribed",
null,
"2026-11-12T11:00:00.000Z"
),
receivedAt: "2026-11-12T11:00:01.000Z",
},
// No webhook arrived for msg_3, sent on 2026-11-12.
];
import { createOutbound } from "outbound-sdk";
import { memory } from "outbound-sdk/memory";
import { reconcile } from "outbound-sdk/reconcile";
import { seed } from "../plugins-index/seed.ts";
import { received } from "./received.ts";
const outbound = createOutbound({
adapter: memory({ data: seed, workspaceId: "agency" }),
plugins: [reconcile()],
});
const report = await outbound.reconcile.events({
events: received,
since: "2026-11-09T00:00:00.000Z",
until: "2026-11-14T00:00:00.000Z",
});
console.log("checked:", report.checked, "matched:", report.matched);
console.log("uncheckable:", report.uncheckable);
console.log(
"webhook never arrived for:",
report.missingEvents.map((message) => message.ref)
);
console.log(
"event with no message:",
report.unmatchedEvents.map((event) => `${event.id} (${event.messageRef})`)
);
console.log(
"late:",
report.late.map(
({ delayMs, event }) => `${event.id} by ${delayMs / 60_000} min`
)
);
// The lead refs your database holds from an earlier sync.
const deletions = await outbound.reconcile.deletions({
collection: "leads",
known: [
"memory:agency:lead_jane",
"memory:agency:lead_sam",
"memory:agency:lead_removed",
],
});
console.log("\nlisted by the vendor:", deletions.listed);
console.log("gone from the vendor:", deletions.deleted);
console.log("not in your copy yet:", deletions.added);
checked: 3 matched: 2
uncheckable: 1
webhook never arrived for: [ 'memory:agency:msg_3' ]
event with no message: [ 'evt_3 (memory:agency:msg_9)' ]
late: [ 'evt_2 by 20 min' ]
listed by the vendor: 4
gone from the vendor: [ 'memory:agency:lead_removed' ]
not in your copy yet: [ 'memory:agency:lead_priya', 'memory:agency:lead_omar' ]
Three of the four events name a message, and two of those messages are listed. msg_3 was sent and no event announced it. The unsubscribe event names no message, so it cannot be compared.
Options
reconcile() takes no options.
outbound.reconcile.events(check):
| Option | Type | Default | Meaning |
|---|---|---|---|
events |
(OutboundEvent | ReceivedEvent)[] |
required | The events your endpoint received in the window. A ReceivedEvent is { event, receivedAt? }. |
since |
string |
required | Start of the window, ISO 8601 UTC. |
until |
string |
none | End of the window. |
campaignId |
string |
none | Compare one campaign’s messages only. |
lateAfterMinutes |
number |
5 |
An event received this long after it happened counts as late. |
signal, deadline, maxRequests |
the instance’s | Passed to the listing. |
outbound.reconcile.deletions(check):
| Option | Type | Default | Meaning |
|---|---|---|---|
collection |
"campaigns" | "leads" | "senders" |
required | What to list. |
known |
Iterable<string> |
required | The refs you hold from an earlier sync. |
campaignId |
string |
none | For leads and senders: list one campaign’s only. |
signal, timeout, retries, deadline, maxRequests |
the instance’s | Passed to the listing. |
What it adds
outbound.reconcile.events() returns an EventReport:
| Field | Meaning |
|---|---|
checked |
Events that could be compared: type message.sent, reply.received, linkedin.message_sent or linkedin.message_received, with a messageRef. |
matched |
Of those, the ones whose message the vendor lists. |
unmatchedEvents |
Events naming a message the vendor does not list in the window. |
missingEvents |
Messages the vendor lists that no compared event named: a webhook never arrived. |
late |
{ event, delayMs } for every event, of any type, with a receivedAt and an occurredAt more than lateAfterMinutes apart. |
uncheckable |
Events of another type, or with no messageRef. |
outbound.reconcile.deletions() returns a DeletionReport:
| Field | Meaning |
|---|---|
deleted |
Refs you hold that the vendor no longer lists. |
added |
Refs the vendor lists that you do not hold. |
listed |
How many distinct refs the vendor listed. |
What it costs
events() lists every message in the window, with since and until passed to outbound.messages.listAll. deletions() lists the whole collection, so it costs as much as a full sync. Neither is cached unless the instance has cache.
What it changes
It changes no capability, no record and nothing in meta. It has no layer of its own, so its position in plugins does not matter.
With other plugins
Both checks read through the instance’s public API, so every layer applies. Two layers can make a report wrong:
cachecan serve an old listing, and then a recent message reads as an unmatched event, or a deleted record as still present. Reconcile on an instance without a cache on list operations.failovercan serve a stale listing when the vendor is down, with the same effect. The report does not carrymeta, so it cannot tell you.
Events passed in are compared by messageRef only. Events that went through redact still carry their refs and compare the same.
When it cannot answer
import { createOutbound, OutboundError } from "outbound-sdk";
import { memory } from "outbound-sdk/memory";
import { reconcile } from "outbound-sdk/reconcile";
import { seed } from "../plugins-index/seed.ts";
import { received } from "./received.ts";
// 1. An adapter that cannot list messages cannot check events.
const noMessages = createOutbound({
adapter: memory({ capabilities: { "messages.list": false }, data: seed }),
plugins: [reconcile()],
});
try {
await noMessages.reconcile.events({
events: received,
since: "2026-11-09T00:00:00.000Z",
});
} catch (error) {
if (OutboundError.is(error)) {
console.log("no message list:", error.code, "-", error.message);
}
}
// 2. Events passed without `receivedAt` are compared, but lateness is unknown.
const outbound = createOutbound({
adapter: memory({ data: seed, workspaceId: "agency" }),
plugins: [reconcile()],
});
const bare = await outbound.reconcile.events({
events: received.map(({ event }) => event),
since: "2026-11-09T00:00:00.000Z",
});
console.log(
"without receivedAt:",
bare.matched,
"matched,",
bare.late.length,
"late"
);
// 3. A window that leaves out the days the events are about: every event
// reads as unmatched. The report is only as good as the window.
const narrow = await outbound.reconcile.events({
events: received,
since: "2026-11-13T00:00:00.000Z",
});
console.log(
"wrong window:",
narrow.matched,
"matched,",
narrow.unmatchedEvents.length,
"unmatched"
);
no message list: Unsupported - Listing messages is not available: the memory adapter does not support it (messages.list)
without receivedAt: 2 matched, 0 late
wrong window: 0 matched, 3 unmatched
| What happened | What you get | What to do |
|---|---|---|
| The adapter cannot list messages | events() throws Unsupported, before any request |
Use deletions() only, or an adapter that declares messages.list. |
Events were stored without receivedAt |
They are compared, and late is empty |
Store the receive time with each event. |
| The window does not cover the events | Every event is unmatched | Pass the window the events were collected in. Nothing checks that they belong to it. |
| An event repeats | It is counted each time in checked and matched |
Drop repeats when you store events. parse() takes a dedupe store for that. |
| A listing fails part way | The call rejects with the OutboundError. No partial report. |
Run it again. |
| A request cap stops the listing | RateLimited. No partial report. |
Raise maxRequests, or narrow the window or the campaign. |
Limits
- A vendor that does not put a message ref on its webhook events gives events that are all
uncheckable. - An event for a message sent a little outside the window reads as unmatched, and a message with a late webhook reads as missing. Leave a margin at both ends of the window, and re-check what a report flags before acting on it.
deletions()cannot tell a deleted record from one the listing did not reach. AcampaignIdon the check withknownrefs from the whole account reports every other campaign’s refs as deleted.