---
title: List the replies nobody answered, longest waiting first, from a scheduled job
description: One function that reads recent replies, checks each conversation for a message sent afterwards, and returns the ones still waiting as text, plus what it could not check. It lists replies and sends nothing.
sidebar:
  label: Unanswered replies
related:
  - /docs/insights/people
  - /docs/guides/person-across-email-and-linkedin
  - /docs/guides/hot-leads-call-list
---

A prospect replies "yes, send me pricing" and nobody answers, because the reply sat in a shared inbox over a weekend. `droppedReplies` finds those. It reads the replies since a date, keeps the categories that need an answer, reads each conversation, and reports a reply as dropped when nothing was sent after it and it has waited longer than a number of hours you choose. The list comes back longest waiting first.

It only lists. It does not answer, assign or mark anything, in the SDK or in the vendor tool. It also does not know about answers given outside the tool: a reply you answered from your own mailbox or by phone still shows as unanswered.

> **Preview: not published yet**
>
> `outbound-sdk` is not on npm, and its GitHub repository is private, so there
> is no install command that works today. Instantly and HeyReach are `beta`:
> their reads were run, read-only, against one live workspace each. Smartlead is
> `unverified`: written from its public documentation and tested offline only.
> See [what is verified](/docs/status).

## Before you start

- Node 22 or later.
- The code below runs on the in-memory adapter, so it needs no vendor key. 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 data: an Instantly, Smartlead or HeyReach API key. A read-only key is enough. No method of the SDK writes to a vendor.
- A scheduler you already run. The SDK schedules nothing.

## What you get

```text title="Output"
2 unanswered for more than 24 hours, since 2026-10-31

  99h  priya@example.com
       email, positive, 2 unanswered, "Re: pricing for 10 seats"
  31h  https://www.linkedin.com/in/tom-okafor-example
       linkedin, positive, 1 unanswered

Read 7 replies in 4 conversations to check: 1 answered, 1 too recent, 0 could not be checked.
Left out: 1 by category, 1 automatic.
```

## Write the list

```ts title="unanswered.ts"
import type { Outbound } from "outbound-sdk";
import type { InsightsExtension } from "outbound-sdk/insights";

/** Replies waiting longer than `hours` with nothing sent after them, as text. */
export const unansweredReplies = async (
  outbound: Outbound & InsightsExtension,
  hours = 24
): Promise<string> => {
  const { data, meta } = await outbound.insights.droppedReplies({
    older_than_hours: hours,
  });

  const lines = [
    `${data.totals.dropped} unanswered for more than ${data.olderThanHours} hours, since ${data.since.slice(0, 10)}`,
    "",
  ];

  // Longest waiting first. That order comes from the insight.
  for (const reply of data.dropped) {
    // A reply names its lead by ref. Reading the lead costs one request, or
    // two on a vendor whose lead read is two.
    const who =
      reply.lead === null
        ? null
        : // oxlint-disable-next-line no-await-in-loop -- one lead at a time keeps the rate budget predictable.
          await outbound.lead(reply.lead).get();

    lines.push(
      `  ${Math.floor(reply.hoursWaiting)}h  ${who?.email ?? who?.linkedinUrl ?? "unknown lead"}`,
      `       ${reply.channel}, ${reply.category}, ${reply.unansweredReplies} unanswered${
        reply.subject === null ? "" : `, "${reply.subject}"`
      }`
    );
  }

  const { totals } = data;

  lines.push(
    "",
    `Read ${totals.repliesRead} replies in ${totals.conversations} conversations to check: ` +
      `${totals.answered} answered, ${totals.tooRecent} too recent, ${totals.unchecked} could not be checked.`,
    `Left out: ${totals.skippedByCategory} by category, ${totals.skippedAuto} automatic.`
  );

  // A list cut short by max_replies is not the whole list. Say so.
  if (!meta.complete || meta.notes.length > 0) {
    lines.push("", ...meta.notes.map((note) => `Note: ${note}`));
  }

  return lines.join("\n");
};
```

How a reply ends up on the list:

1. Replies received since `since` are read, up to `max_replies` (default 50). Without `since`, that is 14 days before now.
2. Replies in a category that needs no answer are left out. By default `positive`, `neutral` and `unknown` are kept; out-of-office, bounce, unsubscribe and negative replies are not. Replies the vendor marks as automatic are left out too, unless `include_auto` is `true`.
3. The rest are grouped by conversation. A conversation is read by its thread when the reply has a thread id and the adapter can read one, otherwise by the lead's timeline.
4. A conversation is dropped when it has replies after the last message sent. The oldest of those is reported, with how many there are in `unansweredReplies`.
5. It is listed only once it has waited `older_than_hours` (default 24). Younger ones are counted in `totals.tooRecent`.

Three things in the answer are worth copying:

- **`totals.unchecked` is not zero by accident.** A reply with no thread and no lead to read, or no time on it, cannot be checked. It is counted there and never reported as dropped. Print the count, so a short list is not read as a clean one.
- **A reply names its lead by ref.** The function reads each lead to print an address. That is one more read per listed reply.
- **`meta.complete` is `false` when `max_replies` stopped the read.** The list then covers the replies that were read, and a note says so.

The categories come from the vendor or from fixed rules. The SDK calls no AI model, so a reply the vendor filed as `neutral` is treated as one, whatever it says.

## Try it without a key

Ten messages on one made-up tool: two people waiting, one answered, one too recent, and two that need no answer.

```ts title="seed.ts"
// One made-up tool with an email campaign and a LinkedIn campaign. The clock
// in the samples is 2026-11-14 12:00 UTC.
import type { MemoryData } from "outbound-sdk/memory";

export const seed: MemoryData = {
  campaigns: [
    { id: "cmp_email", name: "Founders Q4" },
    { channel: "linkedin", id: "cmp_linkedin", name: "LinkedIn founders" },
  ],
  leads: [
    { campaignId: "cmp_email", email: "priya@example.com", id: "lead_priya" },
    { campaignId: "cmp_email", email: "luis@example.com", id: "lead_luis" },
    { campaignId: "cmp_email", email: "mei@example.com", id: "lead_mei" },
    { campaignId: "cmp_email", email: "sam@example.com", id: "lead_sam" },
    {
      campaignId: "cmp_linkedin",
      id: "lead_tom",
      linkedinUrl: "https://www.linkedin.com/in/tom-okafor-example",
    },
  ],
  messages: [
    // Priya replied on Tuesday and again on Thursday. Nothing went back.
    {
      campaignId: "cmp_email",
      direction: "out",
      id: "msg_01",
      leadId: "lead_priya",
      sentAt: "2026-11-09T08:00:00.000Z",
      threadId: "thread_priya",
    },
    {
      campaignId: "cmp_email",
      category: "positive",
      direction: "in",
      id: "msg_02",
      leadId: "lead_priya",
      sentAt: "2026-11-10T09:00:00.000Z",
      subject: "Re: pricing for 10 seats",
      threadId: "thread_priya",
    },
    {
      campaignId: "cmp_email",
      category: "neutral",
      direction: "in",
      id: "msg_03",
      leadId: "lead_priya",
      sentAt: "2026-11-12T10:00:00.000Z",
      subject: "Re: pricing for 10 seats",
      threadId: "thread_priya",
    },
    // Luis was answered ninety minutes after he replied.
    {
      campaignId: "cmp_email",
      category: "neutral",
      direction: "in",
      id: "msg_04",
      leadId: "lead_luis",
      sentAt: "2026-11-11T08:00:00.000Z",
      subject: "Re: quick question",
      threadId: "thread_luis",
    },
    {
      campaignId: "cmp_email",
      direction: "out",
      id: "msg_05",
      leadId: "lead_luis",
      sentAt: "2026-11-11T09:30:00.000Z",
      threadId: "thread_luis",
    },
    // Mei replied five hours ago: unanswered, but not yet a day old.
    {
      campaignId: "cmp_email",
      category: "positive",
      direction: "in",
      id: "msg_06",
      leadId: "lead_mei",
      sentAt: "2026-11-14T07:00:00.000Z",
      subject: "Re: intro",
      threadId: "thread_mei",
    },
    // Sam is out of office, twice: once filed as such, once filed as neutral
    // but marked automatic. Neither needs an answer.
    {
      campaignId: "cmp_email",
      category: "ooo",
      direction: "in",
      id: "msg_07",
      leadId: "lead_sam",
      sentAt: "2026-11-10T06:00:00.000Z",
      subject: "Out of office",
      threadId: "thread_sam_1",
    },
    {
      campaignId: "cmp_email",
      category: "neutral",
      direction: "in",
      id: "msg_08",
      isAuto: true,
      leadId: "lead_sam",
      sentAt: "2026-11-11T06:00:00.000Z",
      subject: "Automatic reply",
      threadId: "thread_sam_2",
    },
    // Tom replied on LinkedIn. The message has no thread id, so the
    // conversation is read from his timeline.
    {
      campaignId: "cmp_linkedin",
      direction: "out",
      id: "msg_09",
      leadId: "lead_tom",
      sentAt: "2026-11-12T09:00:00.000Z",
    },
    {
      campaignId: "cmp_linkedin",
      category: "positive",
      direction: "in",
      id: "msg_10",
      leadId: "lead_tom",
      sentAt: "2026-11-13T05:00:00.000Z",
    },
  ],
};
```

```ts title="run.ts"
import { createOutbound } from "outbound-sdk";
import { insights } from "outbound-sdk/insights";
import { memory } from "outbound-sdk/memory";

import { seed } from "./seed.ts";
import { unansweredReplies } from "./unanswered.ts";

const outbound = createOutbound({
  adapter: memory({ data: seed, workspaceId: "acme" }),
  plugins: [insights()],
});

console.log(await unansweredReplies(outbound));
```

That is the script that printed the list above.

## Run it on a schedule

Swap the adapter and export a function for your scheduler to call: cron, a GitHub Actions schedule, a Vercel cron route. What you do with the text (post it to a channel, email it to the account owner) is your code.

```ts title="job.ts"
import { createOutbound } from "outbound-sdk";
import { insights } from "outbound-sdk/insights";
import { instantly } from "outbound-sdk/instantly";

import { unansweredReplies } from "./unanswered.ts";

// Reads INSTANTLY_API_KEY. Swap the import for smartlead() or heyreach().
const outbound = createOutbound({
  adapter: instantly({ workspaceId: "acme" }),
  // One run may spend at most 150 vendor requests.
  maxRequests: 150,
  plugins: [insights()],
});

/**
 * What a scheduler calls. It returns the text; posting it to a channel or an
 * inbox is yours to do. The SDK answers no reply.
 */
export const job = (): Promise<string> => unansweredReplies(outbound);
```

The SDK stores nothing, so each run starts from the vendor's data and lists every reply still waiting, including ones the last run listed. If you want each reply reported once, keep the `reply` refs you already reported in your own store.

## What each vendor can serve

The insight, run on the in-memory adapter limited to what each vendor's adapter declares. This shows which calls a vendor's adapter would refuse. It does not show what that vendor's API returns.

```ts title="vendors.ts"
import { createOutbound, OutboundError } from "outbound-sdk";
import { insights } from "outbound-sdk/insights";

import { asVendor, VENDORS } from "../_shared/as-vendor.ts";
import { seed } from "./seed.ts";

for (const vendor of VENDORS) {
  const outbound = createOutbound({
    adapter: asVendor(vendor, seed, "acme"),
    plugins: [insights()],
  });

  try {
    // oxlint-disable-next-line no-await-in-loop -- one vendor at a time.
    const { data, meta } = await outbound.insights.droppedReplies({});

    console.log(
      `${vendor}: ok, ${data.totals.dropped} unanswered, checked by ${[...new Set(data.dropped.map((reply) => reply.checkedBy))].join(" and ")}`
    );

    for (const note of meta.notes) {
      console.log(`  note: ${note}`);
    }
  } catch (error) {
    if (!OutboundError.is(error)) {
      throw error;
    }

    console.log(`${vendor}: ${error.code}: ${error.message}`);
  }
}
```

```text title="Output"
instantly: ok, 2 unanswered, checked by thread and timeline
smartlead: ok, 2 unanswered, checked by thread and timeline
heyreach: ok, 2 unanswered, checked by thread and timeline
```

`droppedReplies` needs only `replies.list`, which all three declare. All three can also read a thread and a lead's timeline, and all three take a date filter on replies, so none adds a note here.

With several tools behind [`combine`](/docs/adapters/combine), a thread id cannot be routed to one tool, so every conversation is checked by the lead's timeline.

## What it costs

One request per page of replies, plus one per conversation checked: at most about `max_replies` requests, so about 50 by default. The function above adds one lead read per listed reply (two requests each on Smartlead and HeyReach). `maxRequests` on the instance caps the whole run; past it the call fails with `RateLimited` instead of spending your vendor quota.

## When it cannot answer

```ts title="failures.ts"
import { createOutbound, OutboundError } from "outbound-sdk";
import { insights } from "outbound-sdk/insights";
import { memory } from "outbound-sdk/memory";

import { seed } from "./seed.ts";

const outbound = createOutbound({
  adapter: memory({ data: seed, workspaceId: "acme" }),
  plugins: [insights()],
});

// 1. max_replies stopped the read: the list covers part of the replies.
const capped = await outbound.insights.droppedReplies({ max_replies: 3 });

console.log(
  "capped:",
  capped.data.totals.repliesRead,
  capped.meta.complete,
  capped.meta.notes
);

// 2. A tool that can read neither a thread nor a lead's timeline: every
//    reply is counted as unchecked. None is reported as unanswered.
const blind = createOutbound({
  adapter: memory({
    capabilities: { "leads.timeline": false, "messages.byThread": false },
    data: seed,
  }),
  plugins: [insights()],
});

const unchecked = await blind.insights.droppedReplies({});

console.log(
  "cannot check:",
  unchecked.data.totals.dropped,
  unchecked.data.totals.unchecked,
  unchecked.meta.notes
);

// 3. A tool that cannot filter replies by date: read, then filtered here.
const noSince = createOutbound({
  adapter: memory({ capabilities: { "replies.since": false }, data: seed }),
  plugins: [insights()],
});

const filteredHere = await noSince.insights.droppedReplies({});

console.log("no date filter:", filteredHere.meta.notes);

// 4. A date that is not ISO 8601 is refused before any request.
try {
  await outbound.insights.droppedReplies({ since: "last monday" });
} catch (error) {
  if (OutboundError.is(error)) {
    console.log("bad date:", error.code);
  }
}

// 5. An adapter that cannot list replies refuses the whole insight.
const noReplies = createOutbound({
  adapter: memory({ capabilities: { "replies.list": false }, data: seed }),
  plugins: [insights()],
});

try {
  await noReplies.insights.droppedReplies({});
} catch (error) {
  if (OutboundError.is(error)) {
    console.log("no replies:", error.code, "-", error.message);
  }
}
```

```text title="Output"
capped: 3 false [ 'Stopped at 3 replies; more exist.' ]
cannot check: 0 5 [
  "This tool can read neither a conversation nor a lead's timeline, so no reply could be checked."
]
no date filter: [
  'This tool cannot filter replies by date, so the newest replies were read and filtered here.'
]
bad date: Invalid
no replies: Unsupported - dropped_replies is not available: the memory adapter does not support replies.list
```

| What happened | What you get | What to do |
| --- | --- | --- |
| More replies than `max_replies` | `meta.complete` is `false` and a note says where it stopped | Raise `max_replies`, or move `since` later. |
| The adapter can read neither a thread nor a lead's timeline | Nothing is listed, every reply is in `totals.unchecked`, and a note says so | Do not read the empty list as "all answered". Use an adapter that declares `leads.timeline` or `messages.byThread`. |
| The adapter cannot filter replies by date | The newest replies are read and filtered in the SDK, with a note | Nothing. Expect the read to reach `max_replies` sooner. |
| `since` is not an ISO 8601 date or time | `Invalid`, before any request | Pass `2026-11-01` or a full timestamp with an offset. |
| The adapter cannot list replies | `Unsupported`, before any request | Use an adapter that declares `replies.list`. |
| A rejected key | `Unauthorized`. It is not retried. | Check the key and its scope. |

## Limits

- "Answered" means a message was sent in the same conversation after the reply, in the vendor tool. An answer from another mailbox, a call or a meeting booking is not seen.
- An automated follow-up step sent after the reply counts as a message sent. If your tool keeps sending a sequence after a reply, that conversation reads as answered.
- A reply the vendor files as `negative` or out-of-office is left out by default. Pass `categories` to change which ones count.
- One entry per conversation: the oldest unanswered reply, with the count of unanswered replies after the last send.
- On an `unverified` adapter, check a list against the vendor's own inbox before acting on it.
