---
title: reconcile
description: 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.
sidebar:
  label: reconcile
related:
  - /guides/webhooks-and-reconcile
  - /docs/webhooks
  - /docs/plugins/sync
---

`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()`](/docs/webhooks), each with the time it was received. They are made up to match the [shared seed](/docs/plugins#every-plugin), which has three messages.

```ts title="received.ts"
// 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.
];
```

```ts title="run.ts"
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);
```

```text title="Output"
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:

- `cache` can 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.
- `failover` can serve a stale listing when the vendor is down, with the same effect. The report does not carry `meta`, 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

```ts title="failures.ts"
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"
);
```

```text title="Output"
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. A `campaignId` on the check with `known` refs from the whole account reports every other campaign's refs as deleted.
