---
title: Smartlead adapter
description: Construct the Smartlead adapter, know which key it needs, what it can and cannot read, what its reads cost in requests, the rate limits it keeps to and the assumptions it has not confirmed.
sidebar:
  label: Smartlead
related:
  - /docs/adapters
  - /docs/coverage/smartlead
  - /docs/status
---

`outbound-sdk/smartlead` reads Smartlead's API v1. It is an email adapter. It is `unverified`: it has never been run against a live account. See [what is verified](/docs/status).

> **Written from documentation only**
>
> Smartlead has three public documentation sources and they disagree with each
> other, also on response shapes. The adapter accepts both documented shapes
> where they differ. Expect to find differences on a live account, and check
> `outbound.caveats` before relying on a field.

## Construct it

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

// Reads SMARTLEAD_API_KEY when `apiKey` is left out. `workspaceId` is your own
// label for the client; it is part of every ref.
const outbound = createOutbound({
  adapter: smartlead({ workspaceId: "acme" }),
});

// Nothing below makes a request.
console.log(outbound.provider, outbound.maturity, outbound.workspaceId);

console.log("webhook check:", outbound.capabilities["events.verify"]);

console.log("native reads:", Object.keys(outbound.native.catalog).length);

// The rate budget the SDK keeps to: the declared limit times `share` (0.5).
for (const snapshot of await outbound.limits()) {
  for (const window of snapshot.windows) {
    console.log(
      `${window.lane}: ${window.requests} per ${window.perMs / 1000}s`
    );
  }
}

// What a read costs in vendor requests, before making it.
for (const action of [
  "campaigns.list",
  "leads.get",
  "senders.health",
  "leads.list",
]) {
  console.log(action, "-", outbound.estimate({ action, records: 250 }).basis);
}
```

```text title="Output"
smartlead unverified acme
webhook check: signature
native reads: 78
default: 30 per 60s
all-leads-activities: 5 per 60s
domain-wise-health: 5 per 60s
leads-statistics: 5 per 60s
campaigns.list - 1 request(s) per page
leads.get - 2 request(s) per page
senders.health - 2 request(s) per page
leads.list - 3 page(s) of 100 at 1 request(s) each, plus 0 per record
```

| Option | Default | What it is |
| --- | --- | --- |
| `apiKey` | `SMARTLEAD_API_KEY` | An admin key or a client-level key. |
| `workspaceId` | `SMARTLEAD_WORKSPACE_ID`, then `default` | Your own label for the client or workspace. It is part of every ref and may not contain a colon. |
| `baseUrl` | `https://server.smartlead.ai/api/v1` | For a proxy or a test server. |
| `capabilities` | what the adapter declares | Overrides single capabilities, for example on a key some endpoints refuse. |

The module also exports `envVars` (the two variable names) and `PROVIDER` (`"smartlead"`).

## Key and scope

- By Smartlead's documentation, API access needs the Pro plan or higher.
- An **admin key** sees the whole account, every client included. A **client-level key** sees one client. Smartlead documents that some endpoints refuse a client key with `401`; turn the matching capability off with `capabilities` if yours does.
- Smartlead takes the key in the URL (`api_key`) and accepts it nowhere else. The SDK redacts it from errors, hooks and logs, but a proxy or a server log between you and Smartlead can record it.
- `outbound.workspaces` lists Smartlead clients. A client-level key may be refused there.

## What it can and cannot do

Against the [table](/docs/adapters#what-each-adapter-can-do), Smartlead lacks two capabilities besides `deletionDetection`, and applies four filters on the client:

| Capability | Value | Why |
| --- | --- | --- |
| `leads.engagement` | `no` | No JSON response documents per-lead open or reply counts. `lead.engagement` is `null`. |
| `lists.leads` | `no` | No endpoint returns the leads of a saved lead list. |
| `campaigns.status` | `client` | `GET /campaigns/` has no status filter and returns every campaign at once. |
| `leads.since`, `messages.since`, `replies.since` | `client` | The SDK filters on the time after fetching and marks the result `derived`. |
| `events.verify` | `signature` | Smartlead signs webhooks once you turn signing on for the webhook. |

`leads.lookup` works by email only. No endpoint finds a lead by LinkedIn profile, so that lookup returns nothing and sends no request.

## Rate limits

The adapter declares 60 requests per minute. Smartlead documents 60 per minute for a client-level key and 200 per minute shared by an account's admin keys, and the key type cannot be detected, so every key gets the lower number. Three endpoints have their own lane at 10 requests per 60 seconds:

- `GET /campaigns/all-leads-activities`, which every message listing reads.
- `GET /campaigns/{id}/leads-statistics` and the domain health metrics, which only native reads use.

At the default `share` of 0.5 that is 30 requests a minute, and 5 a minute on each lane, as the output above shows. Smartlead has not been load-tested. See [rate limits](/docs/rate-limits).

## What reads cost

| Read | Vendor requests |
| --- | --- |
| Every campaign | 1: one request returns all of them, with no pages |
| One lead | 2: the lead, then its tags |
| Find a person by email | 1, or 2 when a campaign gives the lead a category |
| Sender health | 2: the mailbox, then its warmup statistics |
| A lead's timeline | 1 per campaign the lead is in, plus 1 to find those campaigns |
| A mailbox's totals | 2: the mailbox for its address, then the list of all mailboxes |
| Replies | 1 per page of 20 threads; the first page also reads the lead category list |
| Any other listing | 1 per page |

Two reads walk more than they look like they do:

- **`outbound.leads.list()` without a campaign walks every campaign.** Smartlead has no list of all leads. A lead in no campaign is never returned.
- **`outbound.campaign(id).messages` reads the whole activity feed**, which has no campaign filter, on the 10 per minute lane.

## Vendor behavior that affects your code

- **`outbound.raw` can return passwords.** `GET /email-accounts` includes SMTP and IMAP passwords. Only normalized records drop them. The native catalog leaves out the reads that return secrets.
- **A lead's status, campaign and creation time are per campaign.** A lookup by email returns one record per campaign, all with the lead's id.
- **`senderRef` is `null`** on feed and history messages: Smartlead names the mailbox by address there.
- **Replies in one thread share the lead's current category.**
- **No delivered count and no automatic-reply count exist.** Daily rows have a `null` bounce count: the daily endpoint always reports 0, and the SDK does not pass that on as a number.
- **Dated statistics need both dates.** A missing end is today (UTC); a missing start is 2000-01-01 for totals, or 30 days before the end for daily rows.
- **Webhooks are listed per campaign.** Without a campaign the adapter walks the campaigns. A user-level or client-level webhook may appear under none.
- **Four sister services use other hosts** with the same key (SmartDelivery, SmartProspect, SmartSenders, SmartServers). Their reads are native only, and an account without the product is refused.

## Caveats

`outbound.caveats` is the adapter's own list of what it assumes and has not confirmed.

```ts title="caveats.ts"
import { createOutbound } from "outbound-sdk";
import { smartlead } from "outbound-sdk/smartlead";

const outbound = createOutbound({ adapter: smartlead() });

// What the adapter assumes and has not confirmed. No request is made.
for (const caveat of outbound.caveats) {
  console.log(`- ${caveat}\n`);
}
```

```text title="Output"
- outbound.raw returns responses unchanged: GET /email-accounts includes SMTP and IMAP passwords, which only normalized records drop.

- outbound.native returns each payload as Smartlead sent it. Its catalog leaves out the reads that return secrets: client API keys, a mailbox looked up by address, SmartSenders orders and one-time passwords, and a client's whitelabel credentials.

- Native reads of SmartDelivery, SmartProspect, SmartSenders and SmartServers call other hosts with the same key; an account without the product is refused. Their parameters and responses follow the reference alone.

- Nothing is checked against a live account; mappings follow the documentation read on 2026-11-14, whose three sources disagree, also on envelopes (bare or { ok | success, data }; both are accepted).

- The API key travels in the URL (api_key): the SDK redacts it, but a proxy or server log can record it.

- leads.list without a campaign walks every campaign: a lead in no campaign is never returned, and one in several is returned once by listAll.

- leads.list({ email }) reads GET /leads/?email=: one record per campaign, all with the lead's id (listAll keeps one), status unknown unless unsubscribed, createdAt the lead's own. Address matching is undocumented; other addresses are dropped.

- No endpoint finds a lead by LinkedIn profile: leads.list({ linkedinUrl }) returns nothing and sends no request.

- A lead's status, campaign and creation time are per campaign; leads.get returns no campaign and its example looks illustrative.

- STOPPED maps to paused; lead status BLOCKED maps to bounced.

- Messages come from GET /campaigns/all-leads-activities (10 requests per 60 s, no campaign filter, pages of leads): campaign(id).messages reads the whole feed, and thread_replies is not read.

- senderRef is null on feed and history messages (mailbox named by address). leadRef on replies and thread messages needs lead.id or lead_id on the thread; documented examples show an address only.

- threadId is the lead-in-campaign mapping id; history messages have none. GET /master-inbox/{id} is documented without message ids or bodies; older history items are accepted too.

- A lead's timeline follows the older documentation's { history, from, to } shape.

- Replies come from POST /master-inbox/inbox-replies (illustrative item shape), 20 threads per page: each inbound history message is a reply, or only the latest without a history, and all share the lead's current category.

- Reply categories come from GET /leads/fetch-categories: sentiment_type gives positive or negative, null neutral, the name Out Of Office ooo; a list without sentiment_type leaves the rest unknown.

- Dated statistics need both dates: a missing end is today (UTC), a missing start 2000-01-01 for totals, or 30 days before the end for daily rows and a campaign's dated total.

- No delivered or automatic-reply count exists. A campaign's dated total uses non_ooo_reply_count for replied when present; lifetime reply_count includes out-of-office replies.

- Daily rows have sent, total opens and replies; the daily endpoint always reports 0 bounces, so bounced is null.

- Step statistics do not say whether opens and clicks are unique (mapped to openedTotal and clicked). A step with variants has a row per variant and no total.

- A sender is disconnected when is_smtp_success or is_imap_success is false, active when both are true, else unknown. warmup_reputation may be a number or a string such as 95%.

- Workspaces are Smartlead clients; a client-level key may be refused.

- Webhook signing is off until turned on per webhook; unsigned webhooks fail verify.

- Webhook examples omit lead ids, so leadRef needs sl_email_lead_id or lead_id.

- Settings: dailyNewLeads is max_leads_per_day, gapMinutes min_time_btwn_emails, dailyLimit sending_limit (newer example only), startDate the UTC day of schedule_start_time. One sending window, no end date.

- stopOnReply is true for REPLY_TO_AN_EMAIL, false for NEVER, else null. stopOnAutoReply needs out_of_office_detection_settings.ignoreOOOasReply, documented on the settings update only.

- Subsequences are campaigns whose parent_campaign_id is the campaign, one per page; that GET /campaigns/ lists them is implied, not confirmed. Trigger conditions cannot be read, so branch is null.

- Sender health reads the mailbox and its 7-day warmup-stats (both documented shapes). DNS records, block list state and check time are not reported.

- A mailbox's statistics are its row, by address, of GET /analytics/mailbox/name-wise-health-metrics (totals only, no filter, undocumented paging): a mailbox it leaves out gets null counts. opened is taken as total opens.

- interest is the lead category's name, resolved through GET /leads/fetch-categories; leads.get reads category_name, shown only in an illustrative example.

- Lead tags cost a request per lead (GET /crm/leads/tags), so only leads.get fills them. engagement is null: no JSON response documents per-lead counts.

- Tags come from GET /email-accounts/tags; whether campaign tags share that list is unconfirmed.

- Webhooks are listed per campaign; without one the adapter walks the campaigns and returns each once. User-level or client-level webhooks may appear under none.

- Block list entries with an @ are emails, the rest domains.

```

## Webhooks

Verification mode: `signature`. Turn on signing for the webhook in Smartlead and pass its `whsec_` secret as `secret` when parsing: the SDK checks the Standard Webhooks signature and rejects anything older than five minutes. An unsigned Smartlead webhook fails verification. See [webhook verification](/docs/webhooks/verification).

## Coverage

[Smartlead coverage](/docs/coverage/smartlead) lists 230 endpoints of the API reference: 27 behind normalized reads, 78 in the [native catalog](/docs/native), 20 excluded and 105 writes. The research note the adapter was written from is in the repository at `docs/research/smartlead.md`.
