# Senders REST API v1

Read your lead lists, leads, campaigns, prospects, mailbox status and email replies from
your own code, and push data back in: find new people, add leads to lists, add people to
campaigns, and keep your Do Not Contact list in step. Only the prospecting endpoints spend
credits, always within a limit you set per request and a daily cap you set per account.
Adding people to an active campaign schedules their first email, exactly as it would in Senders.

- **Base URL:** `https://mcp.senders.ai/api/v1`
- **Format:** JSON (UTF-8). Lead lists can also be downloaded as CSV.
- **Machine-readable spec:** [OpenAPI 3.1](https://mcp.senders.ai/api/v1/openapi.json)
- **Who can use it:** every paid plan. Free accounts get `403 feature_not_enabled`.

## Getting started

**1. Create a token.** In the Senders portal open **Settings → API**, name the token after
the place it will live ("HubSpot sync", "BI warehouse"), tick only the permissions it needs
and click **Create token**. Copy it straight away: it is shown once. You need to be the
account owner or an admin.

**2. Make your first call.** Put the token in the `Authorization` header:

```bash
curl -H "Authorization: Bearer snd_live_your_token_here" \
  https://mcp.senders.ai/api/v1/campaigns
```

**3. Pick the pattern you need.**

| You want to | Call | Permission |
|---|---|---|
| Push new replies into your CRM | `GET /replies` every few minutes, resuming from the saved `next_cursor` | `replies:read` |
| Pull a researched lead list | `GET /lists`, then `GET /lists/{id}/leads` (or `?format=csv`) | `leads:read` |
| Show campaign results in a dashboard | `GET /campaigns`, or `GET /campaigns/{id}` for per-step numbers | `campaigns:read` |
| Keep your contact records in step | `GET /prospects?replied_since=…` | `prospects:read` |
| Watch sending health | `GET /mailboxes` | `mailboxes:read` |

**4. Handle the edges.** Every list is paged with a cursor (see *Paging and syncing*). On `429`
wait for `Retry-After` seconds (see *Rate limits*). Errors are JSON with a `code` you can branch
on (see *Errors*). Quote the `X-Request-Id` header when you contact us.

## Authentication

Every request needs `Authorization: Bearer <token>`. Tokens start with `snd_live_`.

- Tokens go in the header only. A token in the URL is ignored, because URLs end up in logs.
- Only the account **owner** or an **admin** can create and revoke tokens. Members can see
  the list of tokens but not the tokens themselves.
- An account can have up to **10 active tokens**. Create one per integration, so you can
  revoke one without breaking the others.
- Senders stores only a hash of each token. If you lose one, revoke it and create a new one.
- Tokens stop working when you revoke them, when the account is paused or cancelled, or
  when the plan no longer includes the API.

### Permissions (scopes)

| Scope | Allows |
|---|---|
| `leads:read` | `GET /lists`, `GET /lists/{id}/leads`, `GET /leads/{id}` |
| `replies:read` | `GET /replies` |
| `campaigns:read` | `GET /campaigns`, `GET /campaigns/{id}` |
| `prospects:read` | `GET /prospects`, `GET /prospects/{id}` |
| `mailboxes:read` | `GET /mailboxes` |
| `leads:write` | `POST /lists`, `POST /lists/{id}/leads`, `POST /campaigns/{id}/leads` |
| `suppression:write` | `POST /suppressions`, `DELETE /suppressions/{email}`, `POST /suppressions/domains`, `DELETE /suppressions/domains/{domain}` |
| `prospecting:run` | `POST /prospecting/search`, `GET /prospecting/jobs/{id}`, `GET /prospecting/quote`, `POST /prospecting/enrich`, `POST /prospecting/acquire` (spends credits) |
| `webhooks:manage` | `GET`/`POST /webhooks`, `DELETE /webhooks/{id}` and the test, secret, enable and delivery endpoints below |

Read permissions are ticked by default when you create a token; write permissions are not.
Give an integration only the permissions it needs.

A token without the scope an endpoint needs gets `403 forbidden`.

## Endpoints

### GET /lists

Your lead lists, newest first.

```json
{
  "data": [
    {
      "id": 42,
      "name": "Q4 Fintech CFOs",
      "source": "discover",
      "notes": null,
      "lead_count": 318,
      "created_at": "2026-10-01T09:12:44Z"
    }
  ]
}
```

### GET /lists/{id}/leads

The leads in one list, oldest first, one page at a time.

| Parameter | Description |
|---|---|
| `limit` | Leads per page, 1 to 500. Default 100. |
| `cursor` | The `next_cursor` from the previous page. Leave it out for the first page. |
| `since` | Only leads added at or after this time (UTC). A date `2026-10-01` or an ISO 8601 timestamp `2026-10-01T09:00:00Z`. |
| `max_priority` | Only leads whose research priority is between 1 and this number (1 = best). |
| `format` | `csv` for a spreadsheet download instead of JSON. |

```json
{
  "data": [
    {
      "id": 90211,
      "list_id": 42,
      "priority": 1,
      "segment": "Series B fintech",
      "name": "Ava Lee",
      "first_name": "Ava",
      "last_name": "Lee",
      "title": "CFO",
      "organization": "Ledgerly",
      "domain": "ledgerly.com",
      "signal_summary": "Hiring a head of FP&A; raised Series B in August",
      "reach": null,
      "email": "ava@ledgerly.com",
      "email_status": "verified",
      "contact_channel": "email",
      "linkedin_url": "https://www.linkedin.com/in/avalee",
      "alt_contact": null,
      "notes": null,
      "outreach": {
        "status": "replied",
        "lead_status": "interested",
        "reply_sentiment": "positive",
        "last_contacted_at": "2026-10-06T14:02:10Z",
        "replied_at": "2026-10-07T08:41:55Z"
      },
      "added_at": "2026-10-01T09:13:02Z"
    }
  ],
  "has_more": true,
  "next_cursor": "OTAyMTE"
}
```

**CSV.** `?format=csv` returns the same page as a CSV file with these columns: Priority,
Segment, Name, Role / Handle, Org / Brand, Why they have signal, Reach / Trajectory, Email,
Email status, Contact channel, LinkedIn, Alt contact, Notes, Outreach status. Paging works
the same way: the response carries `X-Has-More` and `X-Next-Cursor` headers. Cells that
start with `=`, `+`, `-` or `@` are prefixed with `'` so spreadsheets don't run them as formulas.

### GET /leads/{id}

One lead, in the same shape as above. `{id}` is the lead's `id`.

### GET /replies

Inbound email replies to your campaigns, **newest first**. To sync them into another system,
use `order=asc` (see *Paging and syncing*).

| Parameter | Description |
|---|---|
| `limit` | Replies per page, 1 to 500. Default 100. |
| `cursor` | The `next_cursor` from the previous page. |
| `order` | `desc` (default): newest first, and the cursor pages back to older replies. `asc`: oldest first, and the saved cursor returns only newer replies. |
| `since` | Only replies received at or after this time (UTC). |
| `campaign_id` | Only replies to this campaign. |
| `sentiment` | `positive`, `negative`, `neutral` or `auto_reply`. |

```json
{
  "data": [
    {
      "id": 5531,
      "thread_id": 812,
      "channel": "email",
      "received_at": "2026-10-07T08:41:55Z",
      "sentiment": "positive",
      "subject": "Re: FP&A hiring",
      "body": "Thanks, this is timely. Can you send over pricing?",
      "body_html": "<div>Thanks, this is timely. Can you send over pricing?</div>",
      "lead": {
        "prospect_id": 77120,
        "name": "Ava Lee",
        "email": "ava@ledgerly.com",
        "title": "CFO",
        "organization": "Ledgerly",
        "linkedin_url": "https://www.linkedin.com/in/avalee",
        "status": "replied",
        "lead_status": "interested"
      },
      "campaign": { "id": 359, "name": "Q4 Fintech CFOs" }
    }
  ],
  "has_more": false,
  "next_cursor": "NTUzMQ"
}
```

`body` is the plain text of the new message. Quoted history (`On … wrote:`, `From: … Sent: …`),
byte order marks and leading blank lines are removed. If removing it would leave nothing (a reply written below the quote, for example),
`body` is the whole message instead, so it is never empty when the reply has text. `body_html` is the same message as sanitized HTML (no scripts, styles or remote
images, quoted chain dropped), or `null` when the reply had no HTML part. If a reply was
flagged as a prompt-injection attempt, `body` carries a warning instead of the text and
`body_html` is `null`. `campaign` is `null` when the reply can't be matched to a campaign.

### GET /campaigns

Your campaigns with their results, newest first. Archived campaigns are left out unless you
ask for them with `status=archived`.

| Parameter | Description |
|---|---|
| `limit` | Campaigns per page, 1 to 50. Default 25. |
| `cursor` | The `next_cursor` from the previous page. |
| `status` | `active`, `paused`, `completed` or `archived`. |
| `order` | `desc` (default): newest first. `asc`: oldest first, for syncing. |

```json
{
  "data": [
    {
      "id": 359,
      "name": "Q4 Fintech CFOs",
      "status": "active",
      "type": "multi_step",
      "created_at": "2026-10-01T09:00:00Z",
      "updated_at": "2026-10-08T16:20:11Z",
      "archived_at": null,
      "stats": {
        "prospects": 318,
        "contacted": 301,
        "sent": 642,
        "delivered": 636,
        "bounced": 6,
        "replied": 27,
        "positive_replies": 11,
        "meetings_booked": 4,
        "unsubscribed": 3,
        "opened": null,
        "clicked": 14,
        "reply_rate": 9.0,
        "bounce_rate": 0.9
      }
    }
  ],
  "has_more": false,
  "next_cursor": "MzU5"
}
```

What the numbers mean:

| Stat | Meaning |
|---|---|
| `prospects` | People in the campaign |
| `contacted` | Prospects who have been sent at least one email |
| `sent` | Emails that left (not queued, not failed). Our replies to prospects are not counted. |
| `bounced` | Prospects whose email bounced |
| `delivered` | `sent` minus `bounced` |
| `replied` | Prospects who replied. `positive_replies` and `meetings_booked` are subsets. |
| `opened`, `clicked` | `null` when nothing was recorded. Opens and clicks often can't be measured on connected mailboxes, so `null` means "not measured", not zero. |
| `reply_rate` | `replied` ÷ `contacted`, in percent. `null` before anyone is contacted. |
| `bounce_rate` | `bounced` ÷ `sent`, in percent. |

Stats are refreshed at most once a minute.

### GET /campaigns/{id}

One campaign with its stats and a `steps` array: for each step of the sequence, how many
emails are `scheduled`, `sent`, `pending`, `skipped` and `failed`.

```json
"steps": [
  { "step": 1, "delay_days": 0, "subject": "FP&A hiring", "scheduled": 318, "sent": 301, "pending": 12, "skipped": 5, "failed": 0 }
]
```

### GET /prospects

The people in your campaigns, newest first.

| Parameter | Description |
|---|---|
| `limit` | Prospects per page, 1 to 500. Default 100. |
| `cursor` | The `next_cursor` from the previous page. |
| `campaign_id` | Only this campaign. |
| `status` | `pending`, `contacted`, `replied`, `bounced`, `unsubscribed`, `dnc`, `held`, `meeting_booked`. |
| `lead_status` | `interested`, `meeting_booked`, `not_interested`, `out_of_office`, `wrong_person`, `closed`. |
| `since` | Only prospects added at or after this time (UTC). |
| `replied_since` | Only prospects who replied at or after this time (UTC). |
| `order` | `desc` (default): newest first. `asc`: oldest first, for syncing. |

```json
{
  "data": [
    {
      "id": 77120,
      "campaign_id": 359,
      "email": "ava@ledgerly.com",
      "name": "Ava Lee",
      "first_name": "Ava",
      "last_name": "Lee",
      "title": "CFO",
      "organization": "Ledgerly",
      "linkedin_url": "https://www.linkedin.com/in/avalee",
      "timezone": "America/New_York",
      "status": "replied",
      "lead_status": "interested",
      "reply_sentiment": "positive",
      "last_contacted_at": "2026-10-06T14:02:10Z",
      "replied_at": "2026-10-07T08:41:55Z",
      "created_at": "2026-10-01T09:20:00Z"
    }
  ],
  "has_more": true,
  "next_cursor": "NzcxMjA"
}
```

### GET /prospects/{id}

One prospect, same shape.

### GET /mailboxes

Your sending mailboxes and their health. Status only: passwords, tokens, server names and
usernames are never returned.

```json
{
  "data": [
    {
      "id": 41,
      "email": "dame@getledgerly.com",
      "display_name": "Dame",
      "domain": "getledgerly.com",
      "status": "active",
      "role": "primary",
      "provider": "gmail",
      "connection": "ok",
      "daily_limit": 40,
      "target_daily_limit": 50,
      "warmup_day": 21,
      "warming_external": false,
      "send_interval_minutes": 0,
      "activated_at": "2026-09-12T10:00:00Z",
      "last_reply_check_at": "2026-10-13T12:55:00Z",
      "sent_7d": 212,
      "bounced_7d": 2,
      "bounce_rate_7d": 0.94,
      "health": "good"
    }
  ]
}
```

`connection` is `auth_failed` when we can no longer sign in to the mailbox: reconnect it in
the portal. `health` is `warning` above 3% bounces over 50+ sends in 7 days, and `critical`
above 5% over 100+ sends. Retired mailboxes are left out.

## Changing data

Every request that changes data needs three things:

1. A token with the right write permission (`leads:write` or `suppression:write`).
2. A JSON body with `Content-Type: application/json`, at most 1 MB.
3. An `Idempotency-Key` header: any unique string of 8 to 100 characters, for example a UUID.
   If the network drops and you retry with the same key and the same body, you get the first
   answer back (marked `Idempotent-Replayed: true`) and nothing happens twice. Use a new key
   for each new request. Keys are remembered for 24 hours.

```bash
curl -X POST https://mcp.senders.ai/api/v1/lists/42/leads \
  -H "Authorization: Bearer snd_live_your_token_here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7f3c2a90-5b1e-4f6a-9d1c-2e8b7a4c0f11" \
  -d '{"leads":[{"email":"ava@ledgerly.com","first_name":"Ava","last_name":"Lee","title":"CFO","organization":"Ledgerly"}]}'
```

### The lead object you send

| Field | Notes |
|---|---|
| `email` | Required when adding to a campaign. Lowercased. |
| `first_name`, `last_name` | Up to 100 characters each. |
| `title`, `organization` | Up to 200 characters each. |
| `linkedin_url` | Up to 500 characters. |
| `timezone` | Campaigns only. An IANA name such as `America/New_York`, used for send timing. |
| `custom_fields` | An object of up to 50 keys (letters, digits, `_`), text/number/true/false values, 16 KB in total. Usable as `{{key}}` in your email copy. |

### POST /lists

Create a lead list. Body: `{"name": "Q1 fintech CFOs", "notes": "optional"}`. Returns `201`
with the new list. An account can have up to 1,000 lists.

### POST /lists/{id}/leads

Add up to 500 leads to a list. Invalid addresses, personal email domains (gmail.com and the
like) and leads already in the list are skipped and counted:

```json
{ "data": { "list_id": 42, "added": 180, "skipped": { "duplicate": 12, "invalid_email": 3, "personal_domain": 5 } } }
```

### POST /campaigns/{id}/leads

Add up to 500 people to a campaign. If the campaign is active, their first email is
scheduled right away, inside your sending window. If it's paused, they wait until you
resume it. A manual-mode campaign sends nothing until each batch is approved in Senders.
Archived and completed campaigns are refused (`409 campaign_closed`).

Anyone Senders shouldn't contact, or who is already in your account, is skipped and listed
with a reason. The API never moves people between campaigns or re-contacts someone who
replied, and there is no override:

| `reason` | Meaning |
|---|---|
| `already_in_campaign` | Already in this campaign |
| `in_other_campaign` | Already in another campaign. Move them in Senders if you mean to. |
| `previously_contacted` | Already contacted outside a campaign |
| `previously_replied` | Has replied to you before |
| `on_hold` | Parked on a hold list |
| `suppressed` | On your Do Not Contact list or the global suppression list |
| `do_not_prospect` | Their domain is blocked |
| `personal_domain` | A personal address (gmail.com and the like) |
| `duplicate_in_request` | The same address appears twice in this request |

```json
{
  "data": {
    "campaign_id": 359,
    "added": 140,
    "scheduled": 140,
    "skipped_count": 4,
    "skipped_reasons": { "previously_replied": 1, "in_other_campaign": 3 },
    "skipped": [ { "email": "ana@acme.io", "reason": "previously_replied" } ],
    "note": "Step 1 is scheduled now for everyone added, inside your sending window."
  }
}
```

The API adds at most 2,000 people to campaigns per account per day (UTC). Over that you get
`429 daily_enroll_limit` with the number left today.

### POST /suppressions

Add up to 100 addresses to your Do Not Contact list. Body:
`{"emails": ["ana@acme.io"], "reason": "unsubscribe"}`. `reason` is `manual` (default) or
`unsubscribe`. The answer lists `added`, `already_suppressed` and `invalid`. If the request
runs long, the addresses not reached come back in `not_processed`: send them again.

### DELETE /suppressions/{email}

Lift an entry you added by hand (`reason` manual). Bounces, spam complaints, opt-outs and
failed verifications can't be removed (`409 protected_suppression`). If the address is also on
the global suppression list, it still can't be emailed; the answer says so in
`still_globally_suppressed`.

### POST /suppressions/domains

Block up to 100 domains so no one there is prospected (Do Not Prospect). Each item is a
domain string, or `{"domain": "acme.io", "reason": "customer", "company_name": "Acme", "note": "…"}`.
`reason` is `competitor`, `customer`, `partner`, `client_account` or `other`. Personal email
domains are refused.

### DELETE /suppressions/domains/{domain}

Unblock a domain.

## Finding new leads

Search the data network for people, then buy the ones you want. Search is free. Getting a
person's email costs credits: 1 credit per contact today. Contacts your account has already
bought cost nothing, and so does a contact we can't find.

**Two limits protect your balance on every spending request:**

1. `max_spend_credits` (required): the most this one request may spend. We stop before any
   contact that would take the total over it and return the rest in `not_processed`.
2. The account's **API spend cap**: credits the API may spend per day (UTC), across all
   tokens. It is 1,000 by default; the account owner or an admin can change it in
   **Settings → API** (up to 20,000; contact us for more). A request whose
   `max_spend_credits` is more than what's left of today's cap is refused before anything
   is charged (`402 spend_cap_reached`).

Your normal credit balance still applies. When it runs out, the request stops and the
rest come back in `not_processed` with `stopped_because: "insufficient_credits"`.

### POST /prospecting/search

Free, and asynchronous: the answer is always `202` with a `job_id`, straight away. Fetch the
results with `GET /prospecting/jobs/{id}`; they are usually ready within a minute or two.
Give at least one filter:

| Field | Notes |
|---|---|
| `titles`, `seniorities`, `locations` | Arrays of up to 20 strings, about the person |
| `company_size` | Employee ranges such as `"11,50"` or `"51,200"` |
| `hq_locations`, `exclude_hq_locations` | Where the company is based |
| `industries`, `exclude_industries` | Industry tag ids |
| `domain` | One company domain |
| `per_page` | 1 to 50, default 25 |

```json
{ "data": { "job_id": 88213, "status": "queued", "message": "Search queued. Fetch GET /api/v1/prospecting/jobs/88213; ..." } }
```

### GET /prospecting/jobs/{id}

While the search runs, `status` is `queued` or `running`: ask again in 15 to 30 seconds.
When it's `done`:

```json
{
  "data": {
    "job_id": 88213,
    "status": "done",
    "people": [
      { "contact_id": "5f2b9c...", "first_name": "Ava", "last_name_hint": "L.", "title": "CFO",
        "organization": "Ledgerly", "domain": "ledgerly.com", "industry": "financial services",
        "employee_count": 120, "location": "Austin, Texas, United States", "country": "United States",
        "linkedin_url": "https://www.linkedin.com/in/avalee", "has_email": true }
    ],
    "total": 1840,
    "dnp_filtered": 2,
    "credits_charged": 0,
    "completed_at": "2026-10-13T14:02:31Z"
  }
}
```

`status` is `failed` if the search could not be completed; search again or narrow the filters.
Search results don't include emails, and the last name is only a hint. People at domains you
have blocked are left out (`dnp_filtered`). An account can have 5 searches waiting at once;
a sixth gets `429 too_many_searches_queued` until you fetch results.

### GET /prospecting/quote?count=N

What enriching N contacts (1 to 10) would cost at most, plus `api_daily_cap`,
`api_spent_today` and `api_cap_remaining`. Free, and nothing is reserved.

### POST /prospecting/enrich

Get emails for up to 10 `contact_id`s from a search.

```json
{ "contact_ids": ["5f2b9c...", "61aa07..."], "max_spend_credits": 2 }
```

```json
{
  "data": {
    "people": [
      { "contact_id": "5f2b9c...", "email": "ava@ledgerly.com", "email_status": "verified",
        "first_name": "Ava", "last_name": "Lee", "title": "CFO", "organization": "Ledgerly",
        "domain": "ledgerly.com", "linkedin_url": "https://www.linkedin.com/in/avalee", "credits_charged": 1 }
    ],
    "credits_charged": 1,
    "max_spend_credits": 2,
    "failed": [ { "contact_id": "61aa07...", "reason": "not_found_or_unavailable" } ],
    "not_processed": [],
    "stopped_because": null,
    "api_cap_remaining": 999
  }
}
```

An enrich request also stops starting new contacts after about 20 seconds, so it always
answers quickly; anything not reached comes back in `not_processed` with
`stopped_because: "time_budget"`. Send those again.

### POST /prospecting/acquire

The same as enrich, plus `list_id`: everyone found with an email is added to that people list
(the usual list rules apply: duplicates and personal addresses are skipped). The answer adds
a `list` object with `added` and `skipped`.

Spending requests need an `Idempotency-Key` like every other write. If you retry after a
network error, contacts that were already bought are not charged again.

## Reply webhooks

Instead of polling `GET /replies`, give us a URL and we POST every new reply to it as it
arrives, usually within a minute. Set one up in **Settings → API → Reply webhooks**, or with
`POST /webhooks`.

### What we send

One `POST` per reply, JSON body:

```json
{
  "version": "v1",
  "event": "reply.received",
  "fired_at": "2026-10-13T14:05:12Z",
  "delivery_id": "6f1c2a9e-8b3d-4c1f-9a7e-2d5b8c0e4f11",
  "data": { "id": 5531, "thread_id": 812, "channel": "email", "received_at": "2026-10-13T14:04:58Z",
            "sentiment": "positive", "subject": "Re: FP&A hiring", "body": "Thanks, this is timely...",
            "body_html": "<div>Thanks, this is timely...</div>", "lead": { "...": "..." }, "campaign": { "id": 359, "name": "Q4 Fintech CFOs" } }
}
```

`data` is exactly the reply object of `GET /replies`. Very large replies drop `body_html` and
shorten `body` (with `"truncated": true`) to keep a delivery under 256 KB.

| Header | Meaning |
|---|---|
| `X-Senders-Event` | `reply.received` (or `test`) |
| `X-Senders-Delivery` | Unique per delivery. Retries of the same delivery reuse it: use it to ignore duplicates. |
| `X-Senders-Timestamp` | Unix time of this attempt |
| `X-Senders-Signature` | `sha256=` + HMAC-SHA256 of `{timestamp}.{raw body}` with your signing secret |
| `X-Senders-Signature-Previous` | Only for 24 hours after you rotate the secret: the same, signed with the old secret |

### Verifying a delivery

Check the signature against the raw request body (before any JSON parsing), and reject
timestamps more than 5 minutes old.

```python
import hmac, hashlib, time

def verify(raw_body: bytes, headers, secret: str) -> bool:
    ts = headers.get("X-Senders-Timestamp", "")
    if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
        return False
    expected = "sha256=" + hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
    sigs = [headers.get("X-Senders-Signature", ""), headers.get("X-Senders-Signature-Previous", "")]
    return any(s and hmac.compare_digest(s, expected) for s in sigs)
```

```javascript
const crypto = require("crypto");
function verify(rawBody, headers, secret) {
  const ts = headers["x-senders-timestamp"] || "";
  if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const expected = "sha256=" + crypto.createHmac("sha256", secret).update(ts + "." + rawBody).digest("hex");
  return [headers["x-senders-signature"], headers["x-senders-signature-previous"]]
    .some(s => s && s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}
```

### Answering, retries and turning off

- Answer with any `2xx` within 10 seconds. Do slow work after you answer.
- No answer, a network error, `408`, `429` or any `5xx`: we retry after 1, 5, 30, 120 and
  360 minutes and 24 hours, then give up (the delivery is `dead`).
- Any other answer (`3xx`, `4xx`) is taken as a refusal: we don't retry it.
- After 50 deliveries in a row that end `dead`, we turn the endpoint off and leave you a
  notice in Senders. Fix the receiver, then turn it back on (`POST /webhooks/{id}/enable`
  or the portal). Nothing is queued while it's off.
- Redirects are not followed. The URL must be a public `https://` address on port 443.

### Endpoints

| Call | Does |
|---|---|
| `GET /webhooks` | Your endpoints and their state |
| `POST /webhooks` | Body `{"url": "https://..."}`. Returns the endpoint and its `secret`, **once**. Up to 5 per account. |
| `DELETE /webhooks/{id}` | Remove it; pending deliveries to it are dropped |
| `POST /webhooks/{id}/test` | Sends a signed `test` delivery now and tells you the result. 5 a minute per account. |
| `POST /webhooks/{id}/rotate-secret` | Returns a new secret, once. The old one keeps signing (`X-Senders-Signature-Previous`) for 24 hours. |
| `POST /webhooks/{id}/enable` | Turn it back on and reset its failure count |
| `GET /webhooks/{id}/deliveries` | Delivery log, newest first; filter with `status` (`pending`, `delivered`, `failed`, `dead`). Kept 30 days. |
| `POST /webhooks/{id}/deliveries/{delivery_id}/redeliver` | Send a `failed` or `dead` delivery again. 100 a day per account. |

The `POST` calls need an `Idempotency-Key` like every other write. An account receives at most
20,000 reply deliveries a day.

## Field values

| Field | Values |
|---|---|
| `campaign.status` | `active`, `paused`, `completed`, `archived` |
| `mailbox.status` | `warming`, `active`, `paused`, `disconnected` |
| `email_status` | `verified`, `catch_all`, `risky`, `invalid`, `personal` (a free-mail address), `unverified`, `none` (no email) |
| `contact_channel` | `email`, `linkedin`, `form`, `dm`, `mgmt`, `other`, `none` |
| `outreach.status`, `lead.status` | `not_contacted`, `pending`, `contacted`, `replied`, `bounced`, `unsubscribed`, `dnc`, `held`, `meeting_booked` |
| `lead_status` | `interested`, `meeting_booked`, `not_interested`, `out_of_office`, `wrong_person`, `closed`, or `null` |
| `sentiment`, `reply_sentiment` | `positive`, `negative`, `neutral`, `auto_reply`, or `null` |

All timestamps are ISO 8601 in UTC (`2026-10-07T08:41:55Z`). Fields with no value are `null`,
never an empty string.

## Paging and syncing

Replies, campaigns and prospects are returned newest first; pass `order=asc` for oldest
first. Leads in a list are always oldest first. Every list is paged with a cursor.

- Keep calling with `cursor=<next_cursor>` while `has_more` is `true`.
- `next_cursor` is returned even on the last page. Save it. Next time, start from it and you
  get only what arrived since. This is the way to poll for new items: use `order=asc` so
  the cursor moves forward in time.
- Treat the cursor as an opaque string. Don't build or change it yourself.
- An empty page keeps the cursor you sent, so you can poll with the same value until
  something new arrives.

A reply sync every few minutes looks like this:

```python
import requests, time

BASE = "https://mcp.senders.ai/api/v1"
HEADERS = {"Authorization": "Bearer snd_live_your_token_here"}
cursor = load_saved_cursor()          # None the first time

while True:
    params = {"limit": 500, "order": "asc"}   # oldest first, so the cursor moves forward
    if cursor:
        params["cursor"] = cursor
    r = requests.get(f"{BASE}/replies", headers=HEADERS, params=params, timeout=30)
    if r.status_code == 429:
        time.sleep(int(r.headers.get("Retry-After", "60")))
        continue
    r.raise_for_status()
    body = r.json()
    handle(body["data"])
    cursor = body["next_cursor"] or cursor
    save_cursor(cursor)
    if not body["has_more"]:
        time.sleep(300)               # caught up: check again in 5 minutes
```

## Rate limits

| Limit | Value |
|---|---|
| Per token | 120 requests per minute |
| Per account, all tokens together | 10,000 requests per UTC day |
| Failed authentication, per IP address | 30 per minute |
| Requests that change data, per token | 30 per minute |
| Requests that spend credits, per account | 10 per minute, one at a time |
| Searches waiting at once, per account | 5 |
| Webhook test sends / redeliveries, per account | 5 per minute / 100 per day |
| Credits the API may spend, per account | Your API spend cap per UTC day (1,000 by default) |
| People added to campaigns, per account | 2,000 per UTC day |

Successful responses tell you where you stand:

| Header | Meaning |
|---|---|
| `X-RateLimit-Limit` / `X-RateLimit-Remaining` | This token's per-minute limit and what's left of it |
| `X-RateLimit-Reset` | When the per-minute window resets (Unix time, seconds) |
| `X-RateLimit-Daily-Limit` / `X-RateLimit-Daily-Remaining` | The account's daily limit and what's left of it |
| `X-RateLimit-Write-Limit` / `X-RateLimit-Write-Remaining` | On requests that change data: the per-minute write limit and what's left |

Over a limit you get `429` with a `Retry-After` header in seconds. Wait that long, then retry.

## Errors

Errors use the HTTP status code plus a JSON body:

```json
{
  "error": {
    "code": "forbidden",
    "message": "This token lacks the replies:read scope.",
    "request_id": "req_4f9c2a7d1e0b3c55"
  }
}
```

| Status | `code` | What to do |
|---|---|---|
| 400 | `bad_request` | A parameter or field is invalid. The message names it. |
| 400 | `idempotency_key_required` | Add an `Idempotency-Key` header to requests that change data. |
| 401 | `unauthorized` | The header is missing, or the token is wrong or revoked. |
| 403 | `forbidden` | The token lacks the permission this endpoint needs. Create a token with it. |
| 403 | `account_inactive` | The account is paused or cancelled. Reactivate the subscription. |
| 403 | `feature_not_enabled` | The plan doesn't include the API (it's on every paid plan), or the feature behind this endpoint is off for your account. |
| 404 | `not_found` | No such list, lead, campaign, prospect or endpoint, or it belongs to another account. |
| 405 | `method_not_allowed` | This endpoint doesn't take that method. The `Allow` header lists the ones it does. |
| 400 | `max_spend_required` | Add `max_spend_credits` (a whole number) to spending requests. |
| 402 | `spend_cap_reached` | `max_spend_credits` is more than what's left of today's API spend cap. Lower it, wait for 00:00 UTC, or raise the cap. |
| 402 | `estimate_exceeds_max_spend` | One contact costs more than `max_spend_credits`. |
| 400 | `invalid_url` | The webhook URL was refused: it must be a public `https://` address on port 443. |
| 409 | `busy` | Another request for your account is still running. Retry when it finishes. |
| 409 | `not_redeliverable` | Only `failed` or `dead` deliveries can be sent again. |
| 409 | `conflict` | The data changed while we were working. Retry; the same key is fine. |
| 409 | `idempotency_in_progress` | A request with this key is still running. Retry shortly to get its result. |
| 409 | `campaign_closed` | The campaign is archived or completed. |
| 409 | `protected_suppression` | That Do Not Contact entry can't be removed (bounce, spam complaint, opt-out). |
| 409 | `limit_reached` | The account has hit a size limit, such as 1,000 lists. |
| 413 | `payload_too_large` | The request body is over 1 MB. Split it up. |
| 415 | `unsupported_media_type` | Send request bodies as JSON with `Content-Type: application/json`. |
| 422 | `idempotency_key_reused` | This key was used for a different request. Use a new key. |
| 429 | `rate_limited`, `daily_limit` | Too many requests. Wait for `Retry-After` seconds. |
| 429 | `daily_enroll_limit` | The daily campaign-enrollment limit is used up. Try again after 00:00 UTC. |
| 429 | `search_busy` | The search engine is at its per-minute limit. Wait for `Retry-After` seconds. |
| 429 | `too_many_searches_queued` | Five searches are already waiting for this account. Fetch their results first. |
| 503 | `service_unavailable` | Requests that change data are paused for a moment. Retry after `Retry-After` seconds. |
| 500 | `internal_error` | A problem on our side. Retry with backoff (the same `Idempotency-Key` is safe). If it keeps happening, contact support. |
| other 5xx | | Same: retry with backoff. |

Every response carries an `X-Request-Id` header, also shown as `request_id` in error bodies.
Include it when you contact support.

## Browsers and CORS

The API is meant to be called from servers. It sends no CORS headers, so a browser page on
another site can't call it. Keep tokens on your server and never put them in front-end code.
The OpenAPI spec is the exception: it's public and can be loaded from anywhere.

## Versioning

This is version 1. Within v1 we only make additive changes: new endpoints, new optional
parameters, new fields in responses. Write your client to ignore fields it doesn't know.
Anything that would break an existing client (removing or renaming a field, changing a type
or a meaning) ships as `/api/v2`, announced in advance, with v1 kept running alongside it.

## Support

Questions or problems: email support@senders.co and include the `X-Request-Id` of the call.
