API Documentation & Integrations

Complete REST API documentation for WarmySender. Build custom integrations with our cold email, email warmup, campaign, and LinkedIn automation APIs.

OpenAPI specification

The full machine-readable contract is published as an OpenAPI 3.1 document: Download the OpenAPI 3.1 spec. Import it into Postman, Insomnia, Swagger UI, or Redoc to generate a client, explore every endpoint interactively, or scaffold typed SDKs. The reference below is generated from the same source, so it always matches the spec and production.

Authentication

All API requests require a Bearer token in the Authorization header. Generate API keys from Settings > API Keys in your WarmySender dashboard. Keys are scoped to your workspace and support granular permissions.

Authorization: Bearer your-api-key-here

Base URL

All API endpoints are available at:

https://warmysender.com/api/v1

Rate Limiting

API requests are limited to 60 requests per minute per workspace. Rate limit headers are included in all responses:

Scopes

API keys are scoped to your workspace with granular permissions. Each endpoint lists the scope it needs.

API reference

Every endpoint below is generated from the same source as our OpenAPI 3.1 specification, so the request bodies, response shapes, parameters, and scopes always match production. Base URL for all paths: https://warmysender.com/api/v1.

Identity

Get the authenticated API key + workspace

GET /api/v1/me

Returns the API key and workspace that the Bearer token belongs to, including the key's granted scopes, plus the account's current standing. Useful for verifying a key, discovering its permissions, and checking whether the account can send before you attempt any write. The billing block is present only when the account is suspended over an outstanding invoice. This is the one endpoint whose response is NOT wrapped in the standard data envelope.

Response — 200
{
  "apiKey": {
    "id": "key_8f1c2a",
    "name": "Production integration",
    "scopes": [
      "mailboxes:read",
      "mailboxes:write",
      "warmup:read"
    ]
  },
  "workspace": {
    "id": "ws_4b9d10"
  },
  "account_status": "suspended",
  "account_status_label": "Suspended — an invoice is outstanding",
  "account_suspended": true,
  "can_send": false,
  "billing": {
    "reason": "An invoice on the account has not been settled yet.",
    "what_to_do": "Settle the outstanding invoice and sending resumes automatically.",
    "amount_outstanding": "$32.99",
    "amount_outstanding_cents": 3299,
    "currency": "USD",
    "invoice_number": "B1C2D3-0004",
    "pay_url": "https://invoice.stripe.com/i/...",
    "since": "2026-07-29T12:52:00.000Z"
  }
}
Errors

Workspace

Get the cold email footer settings

GET /api/v1/workspace/settings

Returns the postal address printed in the footer of your cold email, how people opt out of it, and the exact sentence the footer will show. "link": The footer carries an Unsubscribe link — on your own verified tracking domain when you have one, otherwise on a shared WarmySender domain. "reply": The footer reads “Reply "unsubscribe" to opt out” and points back at the sending mailbox — no third-party link at all. Either way, a reply asking to unsubscribe is honored automatically and that person is never contacted again. Writing your own sentence? Put the word in quotes — like Reply “stop” — and a reply of just that word is honored too. opt_out_text is your own wording, or null when you have not set any; opt_out_text_effective is what the footer actually shows.

Response — 200
{
  "data": {
    "company_address": "120 Market Street, Suite 400, San Francisco, CA 94105",
    "opt_out_style": "link",
    "opt_out_text": null,
    "opt_out_text_effective": "Unsubscribe"
  }
}
Errors

Update the cold email footer settings

PATCH /api/v1/workspace/settings

Change the postal address printed in the footer of your cold email, the way people opt out, the wording of the opt-out sentence, or any combination. Send at least one field; anything you leave out is unchanged. This changes what the footer says only — it sends nothing and changes no sending limit.

Request body
Request example
{
  "company_address": "120 Market Street, Suite 400, San Francisco, CA 94105",
  "opt_out_style": "reply",
  "opt_out_text": "Reply \"stop\" and we will not write again"
}
Response — 200
{
  "data": {
    "company_address": "120 Market Street, Suite 400, San Francisco, CA 94105",
    "opt_out_style": "reply",
    "opt_out_text": "Reply \"stop\" and we will not write again",
    "opt_out_text_effective": "Reply \"stop\" and we will not write again"
  }
}
Errors

Mailboxes

List mailboxes

GET /api/v1/mailboxes

Returns connected mailboxes, newest first, with cursor pagination. Soft-deleted mailboxes are excluded unless you filter by status=deleted.

Parameters
Response — 200
{
  "data": [
    {
      "id": "5bd50e20-6b75-4429-9a5a-4a09b46e945a",
      "external_id": "crm-contact-8841",
      "email_address": "[email protected]",
      "display_name": "Jordan Lee",
      "status": "connected",
      "health_status": "ok",
      "health_reasons": null,
      "read_channel_state": "ok",
      "read_channel_verified": true,
      "read_channel_verified_at": "2026-06-10T07:58:00.000Z",
      "read_channel_state_label": "Inbox readable",
      "read_channel_state_detail": "We have opened this mailbox and read what arrived, so replies and warmup confirmations can be seen.",
      "read_channel_measures": "whether we can open this mailbox and read what arrives — a separate check from whether it can send, and the two can disagree",
      "warmup_enabled": true,
      "warmup_type": "normal",
      "warmup_progress": 62,
      "inbox_rate_measures": "warmup this mailbox SENT that landed in recipients' inboxes — not what arrives here",
      "incoming_warmup_state": "receiving",
      "incoming_warmup_state_detail": "Warmup is arriving at this mailbox normally.",
      "incoming_warmup_paused": false,
      "incoming_warmup_pause_needs_review": false,
      "incoming_warmup_resumes_at": null,
      "domain_blocked_for_incoming_warmup": false,
      "last_received_at": "2026-06-10T07:58:00.000Z",
      "days_since_last_received": 0,
      "sending_enabled": true,
      "daily_send_limit": 50,
      "last_tested_at": "2026-06-10T08:31:00.000Z",
      "last_test_error": null,
      "signature": null,
      "created_at": "2026-06-06T10:43:01.357Z",
      "updated_at": "2026-06-10T08:31:05.220Z"
    }
  ],
  "pagination": {
    "has_more": false,
    "next_cursor": null
  },
  "meta": {
    "request_id": "req_mq28denhy91fh6"
  }
}

Get a mailbox

GET /api/v1/mailboxes/{id}

Fetch a single mailbox by id. Poll this after create/restore until status becomes "connected". The response also carries the incoming-warmup fields (`incoming_warmup_state` and friends) describing whether warmup is actually arriving at this mailbox, and the read-channel fields (`read_channel_state` and friends) describing whether we can open the mailbox and read it at all. `status` and `health_status` cover the sending side only — a mailbox can be "connected" and "ok" while its inbox cannot be opened, so check `read_channel_state` before concluding a mailbox is fully working.

Parameters
Response — 200
{
  "data": {
    "id": "5bd50e20-6b75-4429-9a5a-4a09b46e945a",
    "external_id": "crm-contact-8841",
    "email_address": "[email protected]",
    "display_name": "Jordan Lee",
    "status": "connected",
    "health_status": "ok",
    "health_reasons": null,
    "read_channel_state": "ok",
    "read_channel_verified": true,
    "read_channel_verified_at": "2026-06-10T07:58:00.000Z",
    "read_channel_state_label": "Inbox readable",
    "read_channel_state_detail": "We have opened this mailbox and read what arrived, so replies and warmup confirmations can be seen.",
    "read_channel_measures": "whether we can open this mailbox and read what arrives — a separate check from whether it can send, and the two can disagree",
    "warmup_enabled": true,
    "warmup_type": "normal",
    "warmup_progress": 62,
    "inbox_rate_measures": "warmup this mailbox SENT that landed in recipients' inboxes — not what arrives here",
    "incoming_warmup_state": "receiving",
    "incoming_warmup_state_detail": "Warmup is arriving at this mailbox normally.",
    "incoming_warmup_paused": false,
    "incoming_warmup_pause_needs_review": false,
    "incoming_warmup_resumes_at": null,
    "domain_blocked_for_incoming_warmup": false,
    "last_received_at": "2026-06-10T07:58:00.000Z",
    "days_since_last_received": 0,
    "sending_enabled": true,
    "daily_send_limit": 50,
    "last_tested_at": "2026-06-10T08:31:00.000Z",
    "last_test_error": null,
    "signature": null,
    "created_at": "2026-06-06T10:43:01.357Z",
    "updated_at": "2026-06-10T08:31:05.220Z"
  },
  "meta": {
    "request_id": "req_mq28denhy91fh6"
  }
}
Errors

Create a mailbox

POST /api/v1/mailboxes

Adds a mailbox and runs an asynchronous connection test (status moves created → testing → connected or error). If the email matches a previously soft-deleted mailbox, that mailbox is restored instead and the response is 200 with its preserved warmup history (otherwise 201).

Request body
Request example
{
  "email_address": "[email protected]",
  "smtp_host": "smtp.gmail.com",
  "smtp_port": 587,
  "smtp_username": "[email protected]",
  "smtp_password": "app-password",
  "imap_host": "imap.gmail.com",
  "imap_port": 993,
  "imap_username": "[email protected]",
  "imap_password": "app-password",
  "external_id": "crm-contact-8841",
  "auto_enable_warmup": true
}
Response — 201
{
  "data": {
    "id": "5bd50e20-6b75-4429-9a5a-4a09b46e945a",
    "external_id": "crm-contact-8841",
    "email_address": "[email protected]",
    "display_name": "Jordan Lee",
    "status": "testing",
    "health_status": "ok",
    "health_reasons": null,
    "read_channel_state": "ok",
    "read_channel_verified": true,
    "read_channel_verified_at": "2026-06-10T07:58:00.000Z",
    "read_channel_state_label": "Inbox readable",
    "read_channel_state_detail": "We have opened this mailbox and read what arrived, so replies and warmup confirmations can be seen.",
    "read_channel_measures": "whether we can open this mailbox and read what arrives — a separate check from whether it can send, and the two can disagree",
    "warmup_enabled": true,
    "warmup_type": "normal",
    "warmup_progress": 0,
    "inbox_rate_measures": "warmup this mailbox SENT that landed in recipients' inboxes — not what arrives here",
    "incoming_warmup_state": "receiving",
    "incoming_warmup_state_detail": "Warmup is arriving at this mailbox normally.",
    "incoming_warmup_paused": false,
    "incoming_warmup_pause_needs_review": false,
    "incoming_warmup_resumes_at": null,
    "domain_blocked_for_incoming_warmup": false,
    "last_received_at": "2026-06-10T07:58:00.000Z",
    "days_since_last_received": 0,
    "sending_enabled": true,
    "daily_send_limit": 50,
    "last_tested_at": null,
    "last_test_error": null,
    "signature": null,
    "created_at": "2026-06-06T10:43:01.357Z",
    "updated_at": "2026-06-10T08:31:05.220Z"
  },
  "meta": {
    "request_id": "req_mq28denhy91fh6",
    "idempotency_key": "550e8400-e29b-41d4-a716-446655440000"
  }
}
Errors

Test credentials (no save)

POST /api/v1/mailboxes/test

Synchronously tests SMTP + IMAP credentials WITHOUT creating a mailbox. Rate limited.

Request body
Request example
{
  "email_address": "[email protected]",
  "smtp_host": "smtp.gmail.com",
  "smtp_port": 587,
  "smtp_username": "[email protected]",
  "smtp_password": "app-password",
  "imap_host": "imap.gmail.com",
  "imap_port": 993,
  "imap_username": "[email protected]",
  "imap_password": "app-password"
}
Response — 200
{
  "data": {
    "smtp_ok": true,
    "imap_ok": true
  },
  "meta": {
    "request_id": "req_mq28denhy91fh6"
  }
}
Errors

Update a mailbox

PATCH /api/v1/mailboxes/{id}

Partial update — only the fields you send change. Changing SMTP/IMAP credentials re-runs the connection test. Set external_id or signature to null to clear them.

Parameters
Request body
Request example
{
  "display_name": "Jordan Lee",
  "daily_send_limit": 80
}
Response — 200
{
  "data": {
    "id": "5bd50e20-6b75-4429-9a5a-4a09b46e945a",
    "external_id": "crm-contact-8841",
    "email_address": "[email protected]",
    "display_name": "Jordan Lee",
    "status": "connected",
    "health_status": "ok",
    "health_reasons": null,
    "read_channel_state": "ok",
    "read_channel_verified": true,
    "read_channel_verified_at": "2026-06-10T07:58:00.000Z",
    "read_channel_state_label": "Inbox readable",
    "read_channel_state_detail": "We have opened this mailbox and read what arrived, so replies and warmup confirmations can be seen.",
    "read_channel_measures": "whether we can open this mailbox and read what arrives — a separate check from whether it can send, and the two can disagree",
    "warmup_enabled": true,
    "warmup_type": "normal",
    "warmup_progress": 62,
    "inbox_rate_measures": "warmup this mailbox SENT that landed in recipients' inboxes — not what arrives here",
    "incoming_warmup_state": "receiving",
    "incoming_warmup_state_detail": "Warmup is arriving at this mailbox normally.",
    "incoming_warmup_paused": false,
    "incoming_warmup_pause_needs_review": false,
    "incoming_warmup_resumes_at": null,
    "domain_blocked_for_incoming_warmup": false,
    "last_received_at": "2026-06-10T07:58:00.000Z",
    "days_since_last_received": 0,
    "sending_enabled": true,
    "daily_send_limit": 80,
    "last_tested_at": "2026-06-10T08:31:00.000Z",
    "last_test_error": null,
    "signature": null,
    "created_at": "2026-06-06T10:43:01.357Z",
    "updated_at": "2026-06-10T08:31:05.220Z"
  },
  "meta": {
    "request_id": "req_mq28denhy91fh6"
  }
}
Errors

Delete (soft) a mailbox

DELETE /api/v1/mailboxes/{id}

Soft-deletes the mailbox: it stops sending and warmup immediately but its history is preserved so it can be restored. Returns 200 with the mailbox object (status "deleted") — not 204.

Parameters
Response — 200
{
  "data": {
    "id": "5bd50e20-6b75-4429-9a5a-4a09b46e945a",
    "external_id": "crm-contact-8841",
    "email_address": "[email protected]",
    "display_name": "Jordan Lee",
    "status": "deleted",
    "health_status": "ok",
    "health_reasons": null,
    "read_channel_state": "ok",
    "read_channel_verified": true,
    "read_channel_verified_at": "2026-06-10T07:58:00.000Z",
    "read_channel_state_label": "Inbox readable",
    "read_channel_state_detail": "We have opened this mailbox and read what arrived, so replies and warmup confirmations can be seen.",
    "read_channel_measures": "whether we can open this mailbox and read what arrives — a separate check from whether it can send, and the two can disagree",
    "warmup_enabled": false,
    "warmup_type": "normal",
    "warmup_progress": 62,
    "inbox_rate_measures": "warmup this mailbox SENT that landed in recipients' inboxes — not what arrives here",
    "incoming_warmup_state": "receiving",
    "incoming_warmup_state_detail": "Warmup is arriving at this mailbox normally.",
    "incoming_warmup_paused": false,
    "incoming_warmup_pause_needs_review": false,
    "incoming_warmup_resumes_at": null,
    "domain_blocked_for_incoming_warmup": false,
    "last_received_at": "2026-06-10T07:58:00.000Z",
    "days_since_last_received": 0,
    "sending_enabled": true,
    "daily_send_limit": 50,
    "last_tested_at": "2026-06-10T08:31:00.000Z",
    "last_test_error": null,
    "signature": null,
    "created_at": "2026-06-06T10:43:01.357Z",
    "updated_at": "2026-06-10T08:31:05.220Z"
  },
  "meta": {
    "request_id": "req_mq28denhy91fh6"
  }
}
Errors

Restore a deleted mailbox

POST /api/v1/mailboxes/{id}/restore

Brings a soft-deleted mailbox back with its warmup history intact and re-runs the connection test (status returns to "created" → "connected"). Warmup starts disabled; enable it once connected. Blocked if an active mailbox already uses the same email.

Parameters
Response — 200
{
  "data": {
    "id": "5bd50e20-6b75-4429-9a5a-4a09b46e945a",
    "external_id": "crm-contact-8841",
    "email_address": "[email protected]",
    "display_name": "Jordan Lee",
    "status": "created",
    "health_status": "ok",
    "health_reasons": null,
    "read_channel_state": "ok",
    "read_channel_verified": true,
    "read_channel_verified_at": "2026-06-10T07:58:00.000Z",
    "read_channel_state_label": "Inbox readable",
    "read_channel_state_detail": "We have opened this mailbox and read what arrived, so replies and warmup confirmations can be seen.",
    "read_channel_measures": "whether we can open this mailbox and read what arrives — a separate check from whether it can send, and the two can disagree",
    "warmup_enabled": false,
    "warmup_type": "normal",
    "warmup_progress": 62,
    "inbox_rate_measures": "warmup this mailbox SENT that landed in recipients' inboxes — not what arrives here",
    "incoming_warmup_state": "receiving",
    "incoming_warmup_state_detail": "Warmup is arriving at this mailbox normally.",
    "incoming_warmup_paused": false,
    "incoming_warmup_pause_needs_review": false,
    "incoming_warmup_resumes_at": null,
    "domain_blocked_for_incoming_warmup": false,
    "last_received_at": "2026-06-10T07:58:00.000Z",
    "days_since_last_received": 0,
    "sending_enabled": true,
    "daily_send_limit": 50,
    "last_tested_at": "2026-06-10T08:31:00.000Z",
    "last_test_error": null,
    "signature": null,
    "created_at": "2026-06-06T10:43:01.357Z",
    "updated_at": "2026-06-10T08:31:05.220Z"
  },
  "meta": {
    "request_id": "req_mq28denhy91fh6"
  }
}
Errors

Re-test a mailbox connection

POST /api/v1/mailboxes/{id}/test

Re-runs the connection test against the stored credentials and updates the mailbox status. Rate limited. The mailbox status reflects the SENDING side only — a problem reading the inbox is reported in test_result.read_channel and never stops the mailbox from sending.

Parameters
Response — 200
{
  "data": {
    "mailbox": {
      "id": "5bd50e20-6b75-4429-9a5a-4a09b46e945a",
      "external_id": "crm-contact-8841",
      "email_address": "[email protected]",
      "display_name": "Jordan Lee",
      "status": "connected",
      "health_status": "ok",
      "health_reasons": null,
      "read_channel_state": "ok",
      "read_channel_verified": true,
      "read_channel_verified_at": "2026-06-10T07:58:00.000Z",
      "read_channel_state_label": "Inbox readable",
      "read_channel_state_detail": "We have opened this mailbox and read what arrived, so replies and warmup confirmations can be seen.",
      "read_channel_measures": "whether we can open this mailbox and read what arrives — a separate check from whether it can send, and the two can disagree",
      "warmup_enabled": true,
      "warmup_type": "normal",
      "warmup_progress": 62,
      "inbox_rate_measures": "warmup this mailbox SENT that landed in recipients' inboxes — not what arrives here",
      "incoming_warmup_state": "receiving",
      "incoming_warmup_state_detail": "Warmup is arriving at this mailbox normally.",
      "incoming_warmup_paused": false,
      "incoming_warmup_pause_needs_review": false,
      "incoming_warmup_resumes_at": null,
      "domain_blocked_for_incoming_warmup": false,
      "last_received_at": "2026-06-10T07:58:00.000Z",
      "days_since_last_received": 0,
      "sending_enabled": true,
      "daily_send_limit": 50,
      "last_tested_at": "2026-06-10T08:31:00.000Z",
      "last_test_error": null,
      "signature": null,
      "created_at": "2026-06-06T10:43:01.357Z",
      "updated_at": "2026-06-10T08:31:05.220Z"
    },
    "test_result": {
      "smtp_ok": true,
      "imap_ok": true,
      "read_channel": {
        "state": "ok",
        "verified": true,
        "code": null
      }
    }
  },
  "meta": {
    "request_id": "req_mq28denhy91fh6"
  }
}
Errors

Warmup

List warmup stats

GET /api/v1/warmup/stats

Per-mailbox warmup health in BOTH directions: sending-side progress, today's volume, placement and reputation, plus the incoming side — whether warmup is actually arriving at the mailbox (`incoming_warmup_state`), when it last did, and whether it is paused and why. `inbox_rate` measures the SENDING side only; `inbox_rate_measures` spells that out so the two are never conflated. Cursor paginated.

Parameters
Response — 200
{
  "data": [
    {
      "mailbox_id": "5bd50e20-6b75-4429-9a5a-4a09b46e945a",
      "email_address": "[email protected]",
      "warmup_enabled": true,
      "warmup_type": "normal",
      "warmup_mode": "new_mailbox",
      "warmup_mode_reason": "Strategy set by you.",
      "warmup_progress": 62,
      "warmup_target_daily_volume": 50,
      "effective_daily_limit": 50,
      "effective_limit_reason": null,
      "sent_today": 18,
      "received_today": 17,
      "reputation_score": 96,
      "inbox_rate": 98,
      "inbox_rate_measures": "warmup this mailbox SENT that landed in recipients' inboxes — not what arrives here",
      "inbox_rate_sample": 240,
      "inbox_rate_reliable": true,
      "inbox_rate_sample_measures": "how many of this mailbox's sent warmup emails we confirmed the placement of — at 0 the rate above is a placeholder default, not a result",
      "spam_rate": 1,
      "last_sent_at": "2026-06-10T09:12:00.000Z",
      "incoming_warmup_state": "receiving",
      "incoming_warmup_state_detail": "Warmup is arriving at this mailbox normally.",
      "incoming_warmup_paused": false,
      "incoming_warmup_pause_needs_review": false,
      "incoming_warmup_resumes_at": null,
      "domain_blocked_for_incoming_warmup": false,
      "last_received_at": "2026-06-10T07:58:00.000Z",
      "days_since_last_received": 0,
      "health_status": "ok",
      "health_reasons": null,
      "read_channel_state": "ok",
      "read_channel_verified": true,
      "read_channel_verified_at": "2026-06-10T07:58:00.000Z",
      "read_channel_state_label": "Inbox readable",
      "read_channel_state_detail": "We have opened this mailbox and read what arrived, so replies and warmup confirmations can be seen.",
      "read_channel_measures": "whether we can open this mailbox and read what arrives — a separate check from whether it can send, and the two can disagree"
    }
  ],
  "pagination": {
    "has_more": false,
    "next_cursor": null
  },
  "meta": {
    "request_id": "req_mq28denhy91fh6"
  }
}

Get warmup stats for one mailbox

GET /api/v1/warmup/stats/{mailboxId}

Warmup health for a single mailbox, in both directions: the sending side (volume, placement, reputation) and the incoming side (`incoming_warmup_state`, `last_received_at`, and a plain-language `incoming_warmup_state_detail` explaining any pause).

Parameters
Response — 200
{
  "data": {
    "mailbox_id": "5bd50e20-6b75-4429-9a5a-4a09b46e945a",
    "email_address": "[email protected]",
    "warmup_enabled": true,
    "warmup_type": "normal",
    "warmup_mode": "new_mailbox",
    "warmup_mode_reason": "Strategy set by you.",
    "warmup_progress": 62,
    "warmup_target_daily_volume": 50,
    "effective_daily_limit": 50,
    "effective_limit_reason": null,
    "sent_today": 18,
    "received_today": 17,
    "reputation_score": 96,
    "inbox_rate": 98,
    "inbox_rate_measures": "warmup this mailbox SENT that landed in recipients' inboxes — not what arrives here",
    "inbox_rate_sample": 240,
    "inbox_rate_reliable": true,
    "inbox_rate_sample_measures": "how many of this mailbox's sent warmup emails we confirmed the placement of — at 0 the rate above is a placeholder default, not a result",
    "spam_rate": 1,
    "last_sent_at": "2026-06-10T09:12:00.000Z",
    "incoming_warmup_state": "receiving",
    "incoming_warmup_state_detail": "Warmup is arriving at this mailbox normally.",
    "incoming_warmup_paused": false,
    "incoming_warmup_pause_needs_review": false,
    "incoming_warmup_resumes_at": null,
    "domain_blocked_for_incoming_warmup": false,
    "last_received_at": "2026-06-10T07:58:00.000Z",
    "days_since_last_received": 0,
    "health_status": "ok",
    "health_reasons": null,
    "read_channel_state": "ok",
    "read_channel_verified": true,
    "read_channel_verified_at": "2026-06-10T07:58:00.000Z",
    "read_channel_state_label": "Inbox readable",
    "read_channel_state_detail": "We have opened this mailbox and read what arrived, so replies and warmup confirmations can be seen.",
    "read_channel_measures": "whether we can open this mailbox and read what arrives — a separate check from whether it can send, and the two can disagree"
  },
  "meta": {
    "request_id": "req_mq28denhy91fh6"
  }
}
Errors

Update warmup settings

PATCH /api/v1/warmup/settings/{mailboxId}

Enable/disable warmup, switch strategy, or set the target daily volume for one mailbox. Enabling requires the mailbox status to be "connected". A strategy change updates both `warmup_type` and `warmup_mode` (the field the ramp reads). If a faster strategy cannot take effect yet because recent deliverability is poor, the response carries a plain-language `meta.warning` — the change is recorded, but the safer pace stays in force until placement recovers.

Parameters
Request body
Request example
{
  "warmup_enabled": true,
  "warmup_type": "normal",
  "warmup_target_daily_volume": 50
}
Response — 200
{
  "data": {
    "mailbox_id": "5bd50e20-6b75-4429-9a5a-4a09b46e945a",
    "email_address": "[email protected]",
    "warmup_enabled": true,
    "warmup_type": "normal",
    "warmup_mode": "new_mailbox",
    "warmup_mode_reason": "Strategy set by you.",
    "warmup_progress": 62,
    "warmup_target_daily_volume": 50,
    "effective_daily_limit": 50,
    "effective_limit_reason": null,
    "sent_today": 18,
    "received_today": 17,
    "reputation_score": 96,
    "inbox_rate": 98,
    "inbox_rate_measures": "warmup this mailbox SENT that landed in recipients' inboxes — not what arrives here",
    "inbox_rate_sample": 240,
    "inbox_rate_reliable": true,
    "inbox_rate_sample_measures": "how many of this mailbox's sent warmup emails we confirmed the placement of — at 0 the rate above is a placeholder default, not a result",
    "spam_rate": 1,
    "last_sent_at": "2026-06-10T09:12:00.000Z",
    "incoming_warmup_state": "receiving",
    "incoming_warmup_state_detail": "Warmup is arriving at this mailbox normally.",
    "incoming_warmup_paused": false,
    "incoming_warmup_pause_needs_review": false,
    "incoming_warmup_resumes_at": null,
    "domain_blocked_for_incoming_warmup": false,
    "last_received_at": "2026-06-10T07:58:00.000Z",
    "days_since_last_received": 0,
    "health_status": "ok",
    "health_reasons": null,
    "read_channel_state": "ok",
    "read_channel_verified": true,
    "read_channel_verified_at": "2026-06-10T07:58:00.000Z",
    "read_channel_state_label": "Inbox readable",
    "read_channel_state_detail": "We have opened this mailbox and read what arrived, so replies and warmup confirmations can be seen.",
    "read_channel_measures": "whether we can open this mailbox and read what arrives — a separate check from whether it can send, and the two can disagree"
  },
  "meta": {
    "request_id": "req_mq28denhy91fh6"
  }
}
Errors

Bulk-update warmup settings (async)

POST /api/v1/warmup/bulk-update

Applies the same warmup changes to many mailboxes at once. Returns 202 with a job id immediately — poll GET /jobs/{id} for progress. Mailboxes that can't be enabled (not connected) are recorded as per-item errors on the job. A strategy change updates both `warmup_type` and `warmup_mode` (the field the ramp reads) on every mailbox in the batch.

Request body
Request example
{
  "mailbox_ids": [
    "5bd50e20-6b75-4429-9a5a-4a09b46e945a",
    "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
  ],
  "updates": {
    "warmup_enabled": true,
    "warmup_type": "normal"
  }
}
Response — 202
{
  "data": {
    "job_id": "job_7d2f1a",
    "status": "processing",
    "total_items": 2
  },
  "meta": {
    "request_id": "req_mq28denhy91fh6",
    "idempotency_key": "550e8400-e29b-41d4-a716-446655440000"
  }
}
Errors

Jobs

Get job status

GET /api/v1/jobs/{id}

Status and progress of an asynchronous job (e.g. a warmup bulk-update). Per-item failures are listed in errors.

Parameters
Response — 200
{
  "data": {
    "id": "job_7d2f1a",
    "type": "bulk_warmup_update",
    "status": "completed",
    "total_items": 2,
    "processed_items": 2,
    "failed_items": 0,
    "errors": [],
    "created_at": "2026-06-10T12:00:00.000Z",
    "completed_at": "2026-06-10T12:00:04.000Z"
  },
  "meta": {
    "request_id": "req_mq28denhy91fh6"
  }
}
Errors

Prospects

List prospects

GET /api/v1/prospects

Returns prospects in your workspace with cursor pagination.

Parameters
Response — 200
{
  "data": [
    {
      "id": "f3c4a2d1-9b7e-4c2a-8f10-2b6d5e8a1c44",
      "email": "[email protected]",
      "firstName": "Jane",
      "lastName": "Doe",
      "company": "Acme Inc",
      "role": "CTO",
      "phone": null,
      "globalStatus": "active",
      "linkedinUrl": "https://www.linkedin.com/in/janedoe",
      "instagramUsername": "janedoe",
      "whatsappPhone": "491712345678",
      "customFields": {
        "plan": "enterprise"
      },
      "createdAt": "2026-06-09T14:02:00.000Z",
      "updatedAt": "2026-06-09T14:02:00.000Z"
    }
  ],
  "pagination": {
    "has_more": false,
    "next_cursor": null
  },
  "meta": {
    "request_id": "req_mq28denhy91fh6"
  }
}

Create a prospect

POST /api/v1/prospects

Creates a prospect. Provide at least one of email, linkedinUrl, instagramUsername, or whatsappPhone — a prospect with no email address is still created and can be enrolled in LinkedIn, Instagram or WhatsApp campaigns. If one with the same email already exists it is merged and returned with alreadyExisted=true (HTTP 200); a brand-new prospect returns 201. Pass listId to drop the prospect into one of your lists; if a running campaign is linked to that list the prospect is auto-enrolled (continuous enrollment) — ideal for a website signup or popup form. Pass enrollInCampaignId instead to enroll into one specific campaign in the same call.

Request body
Request example
{
  "email": "[email protected]",
  "firstName": "Sam",
  "listId": "list_9f3a2b",
  "enroll": true
}
Response — 201
{
  "data": {
    "id": "f3c4a2d1-9b7e-4c2a-8f10-2b6d5e8a1c44",
    "email": "[email protected]",
    "firstName": "Jane",
    "lastName": "Doe",
    "company": "Acme Inc",
    "role": "CTO",
    "phone": null,
    "globalStatus": "active",
    "linkedinUrl": "https://www.linkedin.com/in/janedoe",
    "instagramUsername": "janedoe",
    "whatsappPhone": "491712345678",
    "customFields": {
      "plan": "enterprise"
    },
    "createdAt": "2026-06-09T14:02:00.000Z",
    "updatedAt": "2026-06-09T14:02:00.000Z"
  },
  "alreadyExisted": false,
  "listId": "list_9f3a2b",
  "list": {
    "attached": true,
    "enrolledCampaigns": 1
  }
}
Errors

Update a prospect

PATCH /api/v1/prospects/{id}

Partial update of a prospect. Accepts every field the in-app contact editor exposes except email (change the address in the app) and status (bounced / unsubscribed / do-not-contact cannot be lifted here). website, country, city, industry, tags and custom fields are included. linkedinUrl, instagramUsername and whatsappPhone may be set or cleared (null). A field name that is not on this list is refused and named in the error.

Parameters
Request body
Request example
{
  "role": "VP Engineering",
  "industry": "SaaS",
  "tags": [
    "vip"
  ],
  "customFields": {
    "plan": "enterprise"
  }
}
Response — 200
{
  "data": {
    "id": "f3c4a2d1-9b7e-4c2a-8f10-2b6d5e8a1c44",
    "email": "[email protected]",
    "firstName": "Jane",
    "lastName": "Doe",
    "company": "Acme Inc",
    "role": "VP Engineering",
    "phone": null,
    "globalStatus": "active",
    "linkedinUrl": "https://www.linkedin.com/in/janedoe",
    "instagramUsername": "janedoe",
    "whatsappPhone": "491712345678",
    "customFields": {
      "plan": "enterprise"
    },
    "createdAt": "2026-06-09T14:02:00.000Z",
    "updatedAt": "2026-06-09T14:02:00.000Z"
  }
}
Errors

Suppressions

Suppress prospects by email

POST /api/v1/prospects/suppress

Adds emails to your suppression list so those people are never contacted by any campaign. Existing prospects are marked suppressed.

Request body
Request example
{
  "emails": [
    "[email protected]",
    "[email protected]"
  ],
  "reason": "manual opt-out"
}
Response — 200
{
  "data": {
    "suppressedCount": 2,
    "alreadySuppressedCount": 0,
    "notFoundCount": 0,
    "totalProcessed": 2
  }
}
Errors

List suppression entries

GET /api/v1/suppressions

Returns suppressed emails and domains (the blocklist) with cursor pagination.

Parameters
Response — 200
{
  "data": [
    {
      "id": "sup_1a2b3c",
      "type": "email",
      "value": "[email protected]",
      "source": "api",
      "reason": "manual opt-out",
      "createdAt": "2026-06-10T12:00:00.000Z"
    }
  ],
  "pagination": {
    "has_more": false,
    "next_cursor": null
  },
  "meta": {
    "request_id": "req_mq28denhy91fh6"
  }
}

Campaigns

List campaigns

GET /api/v1/campaigns

Returns campaigns with summary stats (sent/opened/replied/...). Cursor paginated.

Parameters
Response — 200
{
  "data": [
    {
      "id": "c1a2b3c4-d5e6-47f8-9a0b-1c2d3e4f5a6b",
      "name": "Q3 Outbound — Founders",
      "status": "draft",
      "channel": "email",
      "description": null,
      "timezone": "UTC",
      "dailySendLimit": 50,
      "sendingWindowStart": null,
      "sendingWindowEnd": null,
      "scheduleDays": null,
      "stopOnReply": true,
      "stopOnBounce": true,
      "stopOnUnsubscribe": true,
      "trackOpens": true,
      "trackClicks": true,
      "opt_out_style": null,
      "opt_out_text": null,
      "totalProspects": 0,
      "sent": 0,
      "opened": 0,
      "clicked": 0,
      "replied": 0,
      "bounced": 0,
      "unsubscribed": 0,
      "startDate": null,
      "endDate": null,
      "createdAt": "2026-06-10T12:00:00.000Z",
      "updatedAt": "2026-06-10T12:00:00.000Z"
    }
  ],
  "pagination": {
    "has_more": false,
    "next_cursor": null
  },
  "meta": {
    "request_id": "req_mq28denhy91fh6"
  }
}

Get a campaign (with steps)

GET /api/v1/campaigns/{id}

Returns a single campaign including its ordered steps array. Steps of type "condition" additionally carry conditionType, conditionTrueNext, conditionFalseNext, conditionTimeoutDays and conditions (read-only).

Parameters
Response — 200
{
  "data": {
    "id": "c1a2b3c4-d5e6-47f8-9a0b-1c2d3e4f5a6b",
    "name": "Q3 Outbound — Founders",
    "status": "draft",
    "channel": "email",
    "description": null,
    "timezone": "UTC",
    "dailySendLimit": 50,
    "sendingWindowStart": null,
    "sendingWindowEnd": null,
    "scheduleDays": null,
    "stopOnReply": true,
    "stopOnBounce": true,
    "stopOnUnsubscribe": true,
    "trackOpens": true,
    "trackClicks": true,
    "opt_out_style": null,
    "opt_out_text": null,
    "totalProspects": 0,
    "sent": 0,
    "opened": 0,
    "clicked": 0,
    "replied": 0,
    "bounced": 0,
    "unsubscribed": 0,
    "startDate": null,
    "endDate": null,
    "createdAt": "2026-06-10T12:00:00.000Z",
    "updatedAt": "2026-06-10T12:00:00.000Z",
    "opt_out_effective": {
      "style": "link",
      "style_source": "workspace",
      "text": "Unsubscribe",
      "text_source": "default"
    },
    "steps": [
      {
        "id": "s1a2b3c4-d5e6-47f8-9a0b-1c2d3e4f5a6b",
        "stepIndex": 0,
        "type": "email",
        "name": "Intro",
        "subject": "Quick question, {{firstName}}",
        "bodyHtml": "<p>Hi {{firstName}}, ...</p>",
        "bodyText": "Hi {{firstName}}, ...",
        "delayDays": 0,
        "delayHours": null,
        "isActive": true,
        "sendAsReply": null,
        "quotePreviousMessages": true,
        "sentCount": 0,
        "openCount": 0,
        "clickCount": 0,
        "replyCount": 0,
        "bounceCount": 0,
        "linkedinActionType": null,
        "linkedinMessageTemplate": null,
        "linkedinInviteNote": null,
        "linkedinSubject": null,
        "linkedinMaxWaitDays": null,
        "linkedinEngagementConfig": null,
        "linkedinInmailConfig": null
      },
      {
        "id": "s2b3c4d5-e6f7-48a9-b0c1-2d3e4f5a6b7c",
        "stepIndex": 1,
        "type": "condition",
        "name": "Opened the intro?",
        "subject": null,
        "bodyHtml": null,
        "bodyText": null,
        "delayDays": 2,
        "delayHours": null,
        "isActive": true,
        "sendAsReply": null,
        "quotePreviousMessages": true,
        "sentCount": 0,
        "openCount": 0,
        "clickCount": 0,
        "replyCount": 0,
        "bounceCount": 0,
        "conditionType": "email_opened",
        "conditionTrueNext": 2,
        "conditionFalseNext": 3,
        "conditionTimeoutDays": 3,
        "conditions": null
      }
    ]
  },
  "meta": {
    "request_id": "req_mq28denhy91fh6"
  }
}
Errors

Why a LinkedIn campaign is (or isn't) sending

GET /api/v1/campaigns/{id}/linkedin-throttle

Explains the current sending state of a LinkedIn or multichannel campaign: which daily or weekly allowance is holding it back, when that allowance refreshes, how many campaigns share the same LinkedIn account, and this campaign's protected share of that account's allowance versus what it has used today, per action type. `state` is a stable token — one of sending, sharing_allowance, waiting_shared_allowance, waiting_daily_allowance, waiting_weekly_allowance, resting, inmail_credits_used. `waiting: true` means a normal, expected wait rather than a failure. `status_headline` and `status_detail` are plain-language strings safe to show a person verbatim. Read-only: this endpoint cannot change a campaign or raise any limit.

Parameters
Response — 200
{
  "data": {
    "campaign_id": "c1a2b3c4-d5e6-4789-a012-3456789abcde",
    "campaign_name": "Q3 founders — connect",
    "campaign_status": "running",
    "linkedin_account_id": "a1b2c3d4-e5f6-4789-a012-3456789abcde",
    "linkedin_account_name": "Jane Doe",
    "applicable": true,
    "state": "waiting_shared_allowance",
    "status_headline": "Waiting for its share",
    "status_detail": "This campaign has used its share of today's LinkedIn allowance — the allowance is split with 2 other campaigns on this account. Yours picks up again in about 7 hours.",
    "waiting": true,
    "resumes_at": "2026-07-31T00:00:00.000Z",
    "today_allowance": {
      "used": 9,
      "allowance": 25
    },
    "account_usage_today": {
      "invites": {
        "used": 29,
        "cap": 29
      },
      "messages": {
        "used": 12,
        "cap": 100
      },
      "inmails": {
        "used": 0,
        "cap": 50,
        "creditsExhausted": false
      },
      "profile_views": {
        "used": 41,
        "cap": 100
      }
    },
    "account_warmup": {
      "account_plan": "Sales Navigator",
      "pace": "Established",
      "week": 3,
      "weeks_total": 4,
      "invites_per_week": 200
    },
    "sharing": {
      "is_shared": true,
      "campaigns_on_this_account": 3,
      "other_campaigns": [
        {
          "id": "d4e5f6a7-b8c9-4012-8345-6789abcdef01",
          "name": "EU ops leads"
        },
        {
          "id": "e5f6a7b8-c9d0-4123-8456-789abcdef012",
          "name": "Warm re-engage"
        }
      ],
      "per_action": [
        {
          "action": "Invites",
          "guaranteed_share": 9,
          "used_by_this_campaign": 9,
          "used_by_account": 29,
          "account_allowance": 29,
          "held_for_other_campaigns": 0,
          "can_send_now": false
        }
      ]
    }
  },
  "meta": {
    "request_id": "req_mq28denhy91fh6"
  }
}
Errors

Why a campaign sent little or nothing

GET /api/v1/campaigns/{id}/skipped-actions

Returns the actions that did NOT run for this campaign in a recent window, grouped by cause. Each group carries `reason` (a stable token to branch on), a short `label`, a full plain-language `plain_english` sentence safe to show a person verbatim, `count`, `first_seen_at` / `last_seen_at`, `resumes_at` when the moment is knowable, and `is_normal_wait`. `is_normal_wait: true` marks the many causes that are the system working correctly — normal pacing that resumes on its own, not a fault. `summary_headline` answers the question in one sentence. `recent` is a capped sample of individual actions, newest first. Read-only: this endpoint cannot change a campaign or raise any limit, and it never returns raw internal error text.

Parameters
Response — 200
{
  "data": {
    "window_hours": 24,
    "campaign_id": "c1a2b3c4-d5e6-4789-a012-3456789abcde",
    "summary_headline": "Nothing here is broken: everything held back in the last 24 hours is waiting for one reason — today’s allowance used — and it starts again on its own.",
    "total_skipped": 41,
    "reasons": [
      {
        "reason": "daily_allowance_reached",
        "label": "Today's allowance used",
        "count": 38,
        "plain_english": "This campaign has used everything it is allowed to send today, so the rest is queued for the next day. This is normal pacing that protects your sending reputation — it starts again on its own and there is nothing to fix.",
        "first_seen_at": "2026-08-10T09:12:04.000Z",
        "last_seen_at": "2026-08-10T17:44:51.000Z",
        "is_normal_wait": true,
        "resumes_at": "2026-08-11T00:00:00.000Z"
      },
      {
        "reason": "contact_missing_details",
        "label": "Contact details missing",
        "count": 3,
        "plain_english": "Some contacts are missing the detail this step needs, such as an email address or a profile link, so those actions were skipped and the sequence moved on. Fill in the missing details and re-add those contacts if you want to reach them.",
        "first_seen_at": "2026-08-10T10:02:11.000Z",
        "last_seen_at": "2026-08-10T10:06:33.000Z",
        "is_normal_wait": false,
        "resumes_at": null
      }
    ],
    "recent": [
      {
        "occurred_at": "2026-08-10T17:44:51.000Z",
        "channel": "linkedin",
        "action": "Invites",
        "reason": "daily_allowance_reached",
        "plain_english": "This campaign has used everything it is allowed to send today, so the rest is queued for the next day. This is normal pacing that protects your sending reputation — it starts again on its own and there is nothing to fix."
      }
    ]
  }
}
Errors

Create a campaign

POST /api/v1/campaigns

Creates a campaign in draft status with one or more steps, assigned mailboxes, and (optionally) enrolled prospects. Start it later with the start endpoint. Email, LinkedIn, WhatsApp, Instagram and mixed (multichannel) campaigns are all created here; mailboxes are only required when the campaign sends email. Creating a campaign never sends anything — every message is paced later by the scheduler within that account's safe limits.

Request body
Request example
{
  "name": "Q3 Outbound — Founders",
  "channel": "email",
  "dailySendLimit": 50,
  "mailboxIds": [
    "5bd50e20-6b75-4429-9a5a-4a09b46e945a"
  ],
  "steps": [
    {
      "stepIndex": 0,
      "type": "email",
      "subject": "Quick question, {{firstName}}",
      "bodyHtml": "<p>Hi {{firstName}}, ...</p>",
      "delayDays": 0
    },
    {
      "stepIndex": 1,
      "type": "condition",
      "name": "Opened the intro?",
      "conditionType": "email_opened",
      "conditionTrueNext": 2,
      "conditionFalseNext": 3,
      "conditionTimeoutDays": 3,
      "delayDays": 2
    },
    {
      "stepIndex": 2,
      "type": "email",
      "subject": "Re: Quick question",
      "bodyHtml": "<p>Following up ...</p>",
      "delayDays": 3,
      "sendAsReply": true
    },
    {
      "stepIndex": 3,
      "type": "email",
      "subject": "One more idea, {{firstName}}",
      "bodyHtml": "<p>Different angle ...</p>",
      "delayDays": 3
    }
  ],
  "prospectIds": [
    "f3c4a2d1-9b7e-4c2a-8f10-2b6d5e8a1c44"
  ]
}
Response — 201
{
  "data": {
    "id": "c1a2b3c4-d5e6-47f8-9a0b-1c2d3e4f5a6b",
    "name": "Q3 Outbound — Founders",
    "status": "draft",
    "channel": "email",
    "description": null,
    "timezone": "UTC",
    "dailySendLimit": 50,
    "sendingWindowStart": null,
    "sendingWindowEnd": null,
    "scheduleDays": null,
    "stopOnReply": true,
    "stopOnBounce": true,
    "stopOnUnsubscribe": true,
    "trackOpens": true,
    "trackClicks": true,
    "opt_out_style": null,
    "opt_out_text": null,
    "totalProspects": 0,
    "sent": 0,
    "opened": 0,
    "clicked": 0,
    "replied": 0,
    "bounced": 0,
    "unsubscribed": 0,
    "startDate": null,
    "endDate": null,
    "createdAt": "2026-06-10T12:00:00.000Z",
    "updatedAt": "2026-06-10T12:00:00.000Z",
    "opt_out_effective": {
      "style": "link",
      "style_source": "workspace",
      "text": "Unsubscribe",
      "text_source": "default"
    },
    "steps": [
      {
        "id": "s1a2b3c4-d5e6-47f8-9a0b-1c2d3e4f5a6b",
        "stepIndex": 0,
        "type": "email",
        "name": "Intro",
        "subject": "Quick question, {{firstName}}",
        "bodyHtml": "<p>Hi {{firstName}}, ...</p>",
        "bodyText": "Hi {{firstName}}, ...",
        "delayDays": 0,
        "delayHours": null,
        "isActive": true,
        "sendAsReply": null,
        "quotePreviousMessages": true,
        "sentCount": 0,
        "openCount": 0,
        "clickCount": 0,
        "replyCount": 0,
        "bounceCount": 0,
        "linkedinActionType": null,
        "linkedinMessageTemplate": null,
        "linkedinInviteNote": null,
        "linkedinSubject": null,
        "linkedinMaxWaitDays": null,
        "linkedinEngagementConfig": null,
        "linkedinInmailConfig": null
      }
    ]
  },
  "meta": {
    "prospects_enrolled": 1,
    "prospects_suppressed": 0,
    "mailboxes_assigned": 1
  }
}
Errors

Update a campaign

PATCH /api/v1/campaigns/{id}

Updates campaign settings. Only allowed while the campaign is in draft or paused status.

Parameters
Request body
Request example
{
  "dailySendLimit": 80,
  "scheduleDays": [
    1,
    2,
    3,
    4,
    5
  ]
}
Response — 200
{
  "data": {
    "id": "c1a2b3c4-d5e6-47f8-9a0b-1c2d3e4f5a6b",
    "name": "Q3 Outbound — Founders",
    "status": "draft",
    "channel": "email",
    "description": null,
    "timezone": "UTC",
    "dailySendLimit": 80,
    "sendingWindowStart": null,
    "sendingWindowEnd": null,
    "scheduleDays": [
      1,
      2,
      3,
      4,
      5
    ],
    "stopOnReply": true,
    "stopOnBounce": true,
    "stopOnUnsubscribe": true,
    "trackOpens": true,
    "trackClicks": true,
    "opt_out_style": null,
    "opt_out_text": null,
    "totalProspects": 0,
    "sent": 0,
    "opened": 0,
    "clicked": 0,
    "replied": 0,
    "bounced": 0,
    "unsubscribed": 0,
    "startDate": null,
    "endDate": null,
    "createdAt": "2026-06-10T12:00:00.000Z",
    "updatedAt": "2026-06-10T12:00:00.000Z",
    "opt_out_effective": {
      "style": "link",
      "style_source": "workspace",
      "text": "Unsubscribe",
      "text_source": "default"
    },
    "steps": [
      {
        "id": "s1a2b3c4-d5e6-47f8-9a0b-1c2d3e4f5a6b",
        "stepIndex": 0,
        "type": "email",
        "name": "Intro",
        "subject": "Quick question, {{firstName}}",
        "bodyHtml": "<p>Hi {{firstName}}, ...</p>",
        "bodyText": "Hi {{firstName}}, ...",
        "delayDays": 0,
        "delayHours": null,
        "isActive": true,
        "sendAsReply": null,
        "quotePreviousMessages": true,
        "sentCount": 0,
        "openCount": 0,
        "clickCount": 0,
        "replyCount": 0,
        "bounceCount": 0,
        "linkedinActionType": null,
        "linkedinMessageTemplate": null,
        "linkedinInviteNote": null,
        "linkedinSubject": null,
        "linkedinMaxWaitDays": null,
        "linkedinEngagementConfig": null,
        "linkedinInmailConfig": null
      }
    ]
  },
  "meta": {
    "request_id": "req_mq28denhy91fh6"
  }
}
Errors

Start a campaign

POST /api/v1/campaigns/{id}/start

Moves a draft/paused campaign into running (or scheduled, when startDate is in the future). No request body. Before starting, the campaign runs the same configuration check as the Launch button in the app — sequences that would strand prospects mid-flow are rejected with 400 campaign_validation_failed and the offending steps listed.

Parameters
Response — 200
{
  "data": {
    "id": "c1a2b3c4-d5e6-47f8-9a0b-1c2d3e4f5a6b",
    "name": "Q3 Outbound — Founders",
    "status": "running",
    "channel": "email",
    "description": null,
    "timezone": "UTC",
    "dailySendLimit": 50,
    "sendingWindowStart": null,
    "sendingWindowEnd": null,
    "scheduleDays": null,
    "stopOnReply": true,
    "stopOnBounce": true,
    "stopOnUnsubscribe": true,
    "trackOpens": true,
    "trackClicks": true,
    "opt_out_style": null,
    "opt_out_text": null,
    "totalProspects": 0,
    "sent": 0,
    "opened": 0,
    "clicked": 0,
    "replied": 0,
    "bounced": 0,
    "unsubscribed": 0,
    "startDate": null,
    "endDate": null,
    "createdAt": "2026-06-10T12:00:00.000Z",
    "updatedAt": "2026-06-10T12:00:00.000Z",
    "steps": [
      {
        "id": "s1a2b3c4-d5e6-47f8-9a0b-1c2d3e4f5a6b",
        "stepIndex": 0,
        "type": "email",
        "name": "Intro",
        "subject": "Quick question, {{firstName}}",
        "bodyHtml": "<p>Hi {{firstName}}, ...</p>",
        "bodyText": "Hi {{firstName}}, ...",
        "delayDays": 0,
        "delayHours": null,
        "isActive": true,
        "sendAsReply": null,
        "quotePreviousMessages": true,
        "sentCount": 0,
        "openCount": 0,
        "clickCount": 0,
        "replyCount": 0,
        "bounceCount": 0,
        "linkedinActionType": null,
        "linkedinMessageTemplate": null,
        "linkedinInviteNote": null,
        "linkedinSubject": null,
        "linkedinMaxWaitDays": null,
        "linkedinEngagementConfig": null,
        "linkedinInmailConfig": null
      }
    ]
  },
  "meta": {
    "request_id": "req_mq28denhy91fh6"
  }
}
Errors

Pause a campaign

POST /api/v1/campaigns/{id}/pause

Pauses a running campaign. No request body. Pausing is never blocked by seats or by a disconnected sender — stopping your own outreach always works.

Parameters
Response — 200
{
  "data": {
    "id": "c1a2b3c4-d5e6-47f8-9a0b-1c2d3e4f5a6b",
    "name": "Q3 Outbound — Founders",
    "status": "paused",
    "channel": "email",
    "description": null,
    "timezone": "UTC",
    "dailySendLimit": 50,
    "sendingWindowStart": null,
    "sendingWindowEnd": null,
    "scheduleDays": null,
    "stopOnReply": true,
    "stopOnBounce": true,
    "stopOnUnsubscribe": true,
    "trackOpens": true,
    "trackClicks": true,
    "opt_out_style": null,
    "opt_out_text": null,
    "totalProspects": 0,
    "sent": 0,
    "opened": 0,
    "clicked": 0,
    "replied": 0,
    "bounced": 0,
    "unsubscribed": 0,
    "startDate": null,
    "endDate": null,
    "createdAt": "2026-06-10T12:00:00.000Z",
    "updatedAt": "2026-06-10T12:00:00.000Z",
    "steps": [
      {
        "id": "s1a2b3c4-d5e6-47f8-9a0b-1c2d3e4f5a6b",
        "stepIndex": 0,
        "type": "email",
        "name": "Intro",
        "subject": "Quick question, {{firstName}}",
        "bodyHtml": "<p>Hi {{firstName}}, ...</p>",
        "bodyText": "Hi {{firstName}}, ...",
        "delayDays": 0,
        "delayHours": null,
        "isActive": true,
        "sendAsReply": null,
        "quotePreviousMessages": true,
        "sentCount": 0,
        "openCount": 0,
        "clickCount": 0,
        "replyCount": 0,
        "bounceCount": 0,
        "linkedinActionType": null,
        "linkedinMessageTemplate": null,
        "linkedinInviteNote": null,
        "linkedinSubject": null,
        "linkedinMaxWaitDays": null,
        "linkedinEngagementConfig": null,
        "linkedinInmailConfig": null
      }
    ]
  },
  "meta": {
    "request_id": "req_mq28denhy91fh6"
  }
}
Errors

Enrollments

Enroll prospects

POST /api/v1/campaigns/{id}/enrollments

Adds prospects to a campaign by prospect id, audience list id, email, and/or WhatsApp number. Suppressed prospects are skipped and counted separately. Provide at least one of prospectIds, listIds, emails, whatsappPhones. Emails and numbers that match no existing lead are reported back rather than silently dropped.

Parameters
Request body
Request example
{
  "emails": [
    "[email protected]"
  ],
  "listIds": [
    "list_9f3a2b"
  ],
  "whatsappPhones": [
    "+49 171 2345678"
  ]
}
Response — 200
{
  "data": {
    "enrolledCount": 12,
    "suppressedCount": 1,
    "invalidCount": 0,
    "alreadyEnrolledCount": 2,
    "totalProcessed": 15,
    "notFoundEmails": [],
    "notFoundPhones": []
  }
}
Errors

Unenroll prospects

DELETE /api/v1/campaigns/{id}/enrollments

Removes prospects from a campaign (soft-stop — their audit history is preserved and any pending sends are cancelled). Provide at least one of prospectIds, emails.

Parameters
Request body
Request example
{
  "emails": [
    "[email protected]"
  ]
}
Response — 200
{
  "data": {
    "unenrolledCount": 1,
    "totalProcessed": 1
  }
}
Errors

Webhooks

List webhooks

GET /api/v1/webhooks

Returns your configured webhook endpoints (without secrets).

Response — 200
{
  "data": [
    {
      "id": "wh_2c1a9f",
      "url": "https://example.com/hooks/warmysender",
      "events": [
        "reply.received",
        "email.bounced"
      ],
      "isActive": true,
      "lastTriggeredAt": null,
      "failureCount": 0,
      "createdAt": "2026-06-10T12:00:00.000Z",
      "updatedAt": "2026-06-10T12:00:00.000Z"
    }
  ]
}

Create a webhook

POST /api/v1/webhooks

Registers a webhook endpoint for one or more event types. The signing secret (whsec_…) is returned ONCE in this response — store it; you'll use it to verify payload signatures.

Request body
Request example
{
  "url": "https://example.com/hooks/warmysender",
  "events": [
    "reply.received",
    "email.bounced",
    "linkedin.reply_received"
  ]
}
Response — 201
{
  "data": {
    "id": "wh_2c1a9f",
    "url": "https://example.com/hooks/warmysender",
    "events": [
      "reply.received",
      "email.bounced",
      "linkedin.reply_received"
    ],
    "isActive": true,
    "secret": "whsec_3f9a...redacted",
    "createdAt": "2026-06-10T12:00:00.000Z"
  },
  "message": "Webhook created. Save the secret - it will not be shown again."
}
Errors

Update a webhook

PATCH /api/v1/webhooks/{id}

Change a webhook's URL, subscribed events, or active flag.

Parameters
Request body
Request example
{
  "isActive": false
}
Response — 200
{
  "data": {
    "id": "wh_2c1a9f",
    "url": "https://example.com/hooks/warmysender",
    "events": [
      "reply.received"
    ],
    "isActive": false,
    "lastTriggeredAt": null,
    "failureCount": 0,
    "createdAt": "2026-06-10T12:00:00.000Z",
    "updatedAt": "2026-06-10T12:05:00.000Z"
  }
}
Errors

Delete a webhook

DELETE /api/v1/webhooks/{id}

Permanently removes a webhook endpoint. Returns 204 with no body.

Parameters
Response — 204

No response body.

Errors

Send a test event

POST /api/v1/webhooks/{id}/test

Queues a webhook.test delivery to the endpoint so you can verify signature handling end-to-end. No request body.

Parameters
Response — 200
{
  "data": {
    "eventId": "evt_a1b2c3",
    "status": "queued",
    "message": "Test webhook event has been queued for delivery"
  }
}
Errors

LinkedIn

List LinkedIn accounts

GET /api/v1/linkedin/accounts

Returns connected LinkedIn accounts with their daily engagement limits and how many actions remain today.

Parameters
Response — 200
{
  "data": [
    {
      "id": "li_5a2b1c",
      "name": "Jordan Lee",
      "linkedin_url": "https://www.linkedin.com/in/jordanlee",
      "status": "connected",
      "linkedin_account_type": "classic",
      "strategy": "steady",
      "ramp_week": 3,
      "engagements_today": 4,
      "engagements_day": "2026-06-10",
      "created_at": "2026-05-20T10:00:00.000Z",
      "daily_limits": {
        "engagements_per_day": 30,
        "engagements_remaining": 26
      }
    }
  ]
}

React to a post

POST /api/v1/linkedin/posts/{postId}/reactions

Adds a reaction (like, celebrate, etc.) to a LinkedIn post from one of your connected accounts. Subject to per-account daily engagement limits — account safety always wins.

Parameters
Request body
Request example
{
  "linkedin_account_id": "li_5a2b1c",
  "reaction_type": "LIKE"
}
Response — 201
{
  "data": {
    "post_id": "urn:li:activity:7300000000000000000",
    "reaction_type": "LIKE",
    "linkedin_account_id": "li_5a2b1c"
  }
}
Errors

List reactions on a post

GET /api/v1/linkedin/posts/{postId}/reactions

Lists who reacted to a post, as seen by one of your connected accounts. Cursor paginated.

Parameters
Response — 200
{
  "data": [
    {
      "user_id": "urn:li:person:abc",
      "name": "Sam Rivera",
      "reaction_type": "LIKE",
      "timestamp": "2026-06-10T11:00:00.000Z"
    }
  ],
  "pagination": {
    "has_more": false,
    "next_cursor": null
  }
}
Errors

Verification

Verify one email address

POST /api/v1/verification/verify

Checks a single address and returns one of four outcomes with a plain-English reason. Note that a confirmed mailbox is only one of them — on a mail server that accepts every address the outcome is `risky`, which is deliverable-but-unconfirmable rather than bad. Answers we cannot confirm are always free.

Request body
Request example
{
  "email": "[email protected]"
}
Response — 200
{
  "data": {
    "email": "[email protected]",
    "status": "valid",
    "reason": "The mail provider accepted this address.",
    "is_catch_all": false,
    "mx_host": "aspmx.l.google.com"
  },
  "meta": {
    "request_id": "req_lz3k9a2b"
  }
}
Errors

Start a verification run

POST /api/v1/verification/batches

Submits a list of addresses to be checked in the background and returns a run id to poll. Pass either an `emails` array or a `list_id` to check one of your prospect lists in place. A run starts in full or not at all: if the list is larger than what your allowance and credits cover today, the whole request is refused with `exceeds_today` and nothing is charged — the error says how many can start today, so split the list to that size or add credits.

Request body
Request example
{
  "emails": [
    "[email protected]",
    "[email protected]"
  ],
  "name": "Q3 outbound list"
}
Response — 202
{
  "data": {
    "id": "b1a2c3d4-...",
    "status": "processing",
    "accepted": 2,
    "submitted": 2
  },
  "meta": {
    "request_id": "req_lz3k9a2b"
  }
}
Errors

List verification runs

GET /api/v1/verification/batches

Lists this workspace's verification runs, newest first, with their progress and result counts.

Parameters
Response — 200
{
  "data": [
    {
      "id": "b1a2c3d4-...",
      "name": "Q3 outbound list",
      "status": "completed",
      "source": "upload",
      "total": 2,
      "checked": 2,
      "valid": 1,
      "invalid": 1,
      "risky": 0,
      "unknown": 0,
      "created_at": "2026-07-29T09:00:00.000Z",
      "started_at": "2026-07-29T09:00:01.000Z",
      "completed_at": "2026-07-29T09:04:12.000Z"
    }
  ],
  "pagination": {
    "has_more": false
  },
  "meta": {
    "request_id": "req_lz3k9a2b"
  }
}
Errors

Get a verification run

GET /api/v1/verification/batches/{id}

Returns the progress and result counts for one verification run.

Parameters
Response — 200
{
  "data": {
    "id": "b1a2c3d4-...",
    "name": "Q3 outbound list",
    "status": "processing",
    "source": "upload",
    "total": 2000,
    "checked": 640,
    "valid": 512,
    "invalid": 96,
    "risky": 24,
    "unknown": 8,
    "created_at": "2026-07-29T09:00:00.000Z",
    "started_at": "2026-07-29T09:00:01.000Z",
    "completed_at": null
  },
  "meta": {
    "request_id": "req_lz3k9a2b"
  }
}
Errors

Get per-address results

GET /api/v1/verification/batches/{id}/results

Returns the outcome for each address in a run, optionally filtered to one outcome. Results are available while the run is still going, so you can act on the part that has finished.

Parameters
Response — 200
{
  "data": [
    {
      "email": "[email protected]",
      "status": "valid",
      "reason": "The mail provider accepted this address.",
      "is_catch_all": false,
      "mx_host": "aspmx.l.google.com",
      "checked_at": "2026-07-29T09:01:44.000Z"
    }
  ],
  "pagination": {
    "has_more": false,
    "total": 1,
    "limit": 100,
    "offset": 0
  },
  "meta": {
    "request_id": "req_lz3k9a2b"
  }
}
Errors

Stop a run and refund unused credits

POST /api/v1/verification/batches/{id}/cancel

Stops a verification run that is still going and returns the credits that were paid for but never used. Addresses already checked keep their results.

Parameters
Response — 200
{
  "data": {
    "id": "b1a2c3d4-...",
    "status": "cancelled",
    "cancelled": true,
    "stopped_addresses": 1360,
    "credits_refunded": 1360,
    "credit_balance": 4360
  },
  "meta": {
    "request_id": "req_lz3k9a2b"
  }
}
Errors

Build a prospect list from results

POST /api/v1/verification/batches/{id}/create-list

Builds a reusable prospect list from a finished run. By default it keeps two kinds of address: ones whose mailbox was confirmed, and ones on a mail server that accepts every address. Pass keep=valid for confirmed-only, or keep=risky for catch-all only. The default mix is on purpose — many catch-all addresses still deliver — and the response reports the split so you can decide how to send.

Parameters
Request body
Request example
{
  "keep": "valid"
}
Response — 201
{
  "data": {
    "created": true,
    "list_id": "l9f8e7d6-...",
    "list_name": "Q3 outbound list (verified) — confirmed only",
    "address_count": 604,
    "confirmed_count": 604,
    "unconfirmable_count": 0,
    "keep": "valid"
  },
  "meta": {
    "request_id": "req_lz3k9a2b"
  }
}
Errors

Check remaining verification allowance

GET /api/v1/verification/allowance

Returns whether this account can check an address right now and what would pay for it — the plan allowance left this month and today, the purchased credit balance, free checks left today, and the largest run accepted. Read this before submitting so you never discover a limit by being refused.

Response — 200
{
  "data": {
    "can_verify_now": true,
    "access_source": "plan",
    "monthly_allowance": 10000,
    "monthly_used": 1240,
    "monthly_remaining": 8760,
    "daily_allowance": 333,
    "daily_used": 40,
    "daily_remaining": 293,
    "credit_balance": 5000,
    "free_checks_remaining_today": 0,
    "can_buy_credits": true,
    "verifiable_now": 5293,
    "max_addresses_per_batch": 5000
  },
  "meta": {
    "request_id": "req_lz3k9a2b"
  }
}
Errors

Webhook events

WarmySender posts real-time webhook notifications for key events. Configure them from Settings → Webhooks or via the API. Each delivery is signed with HMAC-SHA256 (X-Warmy-Signature) and retried with exponential backoff until your endpoint returns a 2xx. Subscribe to any of the event types below.

reply.received

A prospect replied (email or LinkedIn).

{
  "prospectId": "f3c4a2d1-...",
  "prospectEmail": "[email protected]",
  "campaignId": "c1a2b3c4-...",
  "mailboxId": "5bd50e20-...",
  "subject": "Re: Quick question",
  "messageId": "<...>",
  "receivedAt": "2026-06-10T11:00:00.000Z",
  "channel": "email"
}

email.bounced

An outbound email bounced (hard or soft).

{
  "prospectEmail": "[email protected]",
  "campaignId": "c1a2b3c4-...",
  "mailboxId": "5bd50e20-...",
  "bounceType": "hard",
  "bounceReason": "550 user unknown",
  "bouncedAt": "2026-06-10T11:01:00.000Z"
}

email.unsubscribed

A prospect unsubscribed.

{
  "prospectId": "f3c4a2d1-...",
  "prospectEmail": "[email protected]",
  "campaignId": "c1a2b3c4-...",
  "unsubscribedAt": "2026-06-10T11:02:00.000Z"
}

email.opened

A tracked email was opened.

{
  "prospectId": "f3c4a2d1-...",
  "prospectEmail": "[email protected]",
  "campaignId": "c1a2b3c4-...",
  "stepIndex": 0,
  "openedAt": "2026-06-10T11:03:00.000Z"
}

email.clicked

A tracked link was clicked.

{
  "prospectId": "f3c4a2d1-...",
  "prospectEmail": "[email protected]",
  "campaignId": "c1a2b3c4-...",
  "url": "https://yourdomain.com/demo",
  "clickedAt": "2026-06-10T11:04:00.000Z"
}

prospect.suppressed

A prospect was added to the suppression list.

{
  "prospectId": "f3c4a2d1-...",
  "email": "[email protected]",
  "reason": "manual opt-out",
  "suppressedAt": "2026-06-10T11:05:00.000Z"
}

limit.hit

A sending limit (mailbox/domain/prospect/cooldown) was reached.

{
  "limitType": "daily_mailbox",
  "mailboxId": "5bd50e20-...",
  "currentValue": 50,
  "limitValue": 50,
  "hitAt": "2026-06-10T11:06:00.000Z"
}

linkedin.invite_accepted

A LinkedIn connection invite was accepted.

{
  "prospectName": "Sam Rivera",
  "prospectLinkedinUrl": "https://www.linkedin.com/in/samrivera",
  "campaignId": "c1a2b3c4-...",
  "enrollmentId": "enr_...",
  "linkedinAccountId": "li_5a2b1c",
  "acceptedAt": "2026-06-10T11:07:00.000Z"
}

linkedin.reply_received

A LinkedIn message reply was received.

{
  "prospectName": "Sam Rivera",
  "prospectLinkedinUrl": "https://www.linkedin.com/in/samrivera",
  "campaignId": "c1a2b3c4-...",
  "enrollmentId": "enr_...",
  "linkedinAccountId": "li_5a2b1c",
  "messageText": "Sure, let's talk",
  "threadId": "th_...",
  "receivedAt": "2026-06-10T11:08:00.000Z"
}

instagram.reply_received

An Instagram DM reply was received.

{
  "prospectName": "Sam Rivera",
  "instagramProfileUrl": "https://instagram.com/samrivera",
  "instagramUsername": "samrivera",
  "campaignId": "c1a2b3c4-...",
  "enrollmentId": "enr_...",
  "instagramAccountId": "ig_5a2b1c",
  "messageText": "Sure, send it over",
  "threadId": "th_...",
  "receivedAt": "2026-06-10T11:08:30.000Z"
}

whatsapp.reply_received

A WhatsApp reply was received.

{
  "prospectName": "Sam Rivera",
  "whatsappPhone": "491712345678",
  "campaignId": "c1a2b3c4-...",
  "enrollmentId": "enr_...",
  "whatsappAccountId": "wa_5a2b1c",
  "messageText": "Yes, go ahead",
  "chatId": "ch_...",
  "receivedAt": "2026-06-10T11:08:45.000Z"
}

webhook.test

A manual test event sent from the test endpoint.

{
  "webhookId": "wh_2c1a9f",
  "timestamp": "2026-06-10T11:09:00.000Z",
  "message": "This is a test event from WarmySender"
}

Common errors

Errors return a JSON body with a stable code and a human-readable message. These apply across all endpoints (individual endpoints list their own additional errors above).

Native Slack integration (no middleware)

Paste a Slack Incoming Webhook URL (https://hooks.slack.com/services/…) into Settings → Webhooks and we auto-format each event as a Block Kit message. LinkedIn reply notifications include the reply body, prospect name, profile URL, and campaign — rendered as a readable Slack card. No Zapier, Make, or n8n required in the middle. Slack rate limits are honored automatically via the Retry-After header Slack returns when throttling.

n8n, Zapier, Make integration

Create an HTTP Trigger (Webhook) node in n8n (or a Webhook trigger in Zapier / Make), copy its Production URL, and paste it into Settings → Webhooks. You receive the full JSON payload and can fan out to any downstream system — Slack, Discord, Teams, HubSpot, Pipedrive, Airtable, Google Sheets, Notion, or a custom database — with conditional logic and transformations between steps. Signature verification via X-Warmy-Signature is recommended when your endpoint is publicly reachable.

Popular Integrations

Full Documentation | Pricing