Complete REST API documentation for WarmySender. Build custom integrations with our cold email, email warmup, campaign, and LinkedIn automation APIs.
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.
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
All API endpoints are available at:
https://warmysender.com/api/v1
API requests are limited to 60 requests per minute per workspace. Rate limit headers are included in all responses:
X-RateLimit-Limit — Maximum requests per windowX-RateLimit-Remaining — Remaining requests in current windowX-RateLimit-Reset — Unix timestamp when the window resetsRetry-After — Seconds to wait (only on 429 responses)API keys are scoped to your workspace with granular permissions. Each endpoint lists the scope it needs.
mailboxes:read — List and read connected mailboxes.mailboxes:write — Create, update, test, delete, and restore mailboxes.warmup:read — Read warmup stats and per-mailbox warmup health.warmup:write — Enable/disable warmup and change warmup strategy or volume.jobs:read — Read the status of asynchronous jobs.prospects:read — List and read prospects and your prospect lists.prospects:write — Create and update prospects, and delete prospect lists.campaigns:read — List and read campaigns and their steps.campaigns:write — Create, update, start, and pause campaigns.enrollments:write — Enroll and unenroll prospects in campaigns.suppressions:read — Read the suppression (blocklist) entries.suppressions:write — Suppress prospects so they are never contacted.webhooks:read — List configured webhook endpoints.webhooks:write — Create, update, delete, and test webhook endpoints.linkedin:read — Read connected LinkedIn accounts and post reactions.linkedin:write — Perform LinkedIn engagement actions (e.g. react to posts).whatsapp:read — Read connected WhatsApp numbers and WhatsApp campaign stats.whatsapp:write — Create, start, pause and enroll into WhatsApp campaigns. Required on top of campaigns:write before any campaign carrying WhatsApp steps will start or resume.instagram:read — Read connected Instagram accounts and Instagram campaign stats.instagram:write — Create, start, pause and enroll into Instagram campaigns. Required on top of campaigns:write before any campaign carrying Instagram steps will start or resume.verification:read — Read email-verification batch results.verification:write — Verify email addresses (single checks and batches).leads:read — Search the leads database (masked results — no contact details).leads:write — Save or export leads (reveals contact details; uses your allowance or lead credits).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.
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.
{
"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"
}
}
unauthorized — Missing or invalid API key.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. timezone is your workspace timezone — every new campaign starts with it.
campaigns:read{
"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",
"timezone": "Europe/Paris"
}
}
insufficient_scope — The key lacks campaigns:read.not_found — The workspace no longer exists.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, your workspace timezone, or any combination. Send at least one field; anything you leave out is unchanged. This sends nothing and changes no sending limit.
campaigns:writecompany_address — type: string — optional — range: –1000. The postal address shown in the footer of your cold email. Send an empty string to clear it.opt_out_style — type: string — optional — one of: link, reply. How people opt out of your cold email. "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 — type: string — optional — range: –120. The sentence recipients click or reply to in order to opt out — written in your own words, 4–120 characters, one line. Send an empty string to go back to the default for your opt-out style ("Unsubscribe" for link, "Reply "unsubscribe" to opt out" for reply). Put {{opt_out}} inside an email or signature to place the sentence yourself.timezone — type: string — optional — range: –100. Your workspace timezone, as a name like "Europe/Paris" or "America/New_York". Every new campaign starts with this timezone for its sending hours unless you give it a different one. Changing it does not move campaigns you already created — each keeps its own timezone.{
"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"
}
{
"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",
"timezone": "Europe/Paris"
}
}
validation_error — No field was supplied, an unknown field was sent, opt_out_style was not "link" or "reply", or opt_out_text was too short, too long, or worded in a way that would not read as an opt-out.insufficient_scope — The key lacks campaigns:write.not_found — The workspace no longer exists.GET /api/v1/mailboxes
Returns connected mailboxes, newest first, with cursor pagination. Soft-deleted mailboxes are excluded unless you filter by status=deleted. Each mailbox carries `campaigns` — the campaigns it sends for (`id`, `name`, `status`, `channel`; running first, up to 50) — and the exact `campaign_count`.
mailboxes:readpagination.next_cursor.limit — in: query — type: integer — optional — default: 50 — range: 1–100. Results per page (1–100).cursor — in: query — type: string — optional. next_cursor from the previous page.external_id — in: query — type: string — optional. Exact-match filter on your external_id.email_address — in: query — type: string — optional. Case-insensitive substring match on the email address.status — in: query — type: string — optional — one of: created, testing, connected, error, deleted. Filter by status.warmup_enabled — in: query — type: string — optional — one of: true, false. Filter by warmup on/off.{
"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",
"inbox_rate_scope": "Measured only on warmup emails exchanged with mailboxes in the WarmySender warmup network (warmup never emails your own contacts); it shows how providers currently treat mail from this mailbox, not whether a specific email to a contact arrived, which can't be seen from 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,
"domain_on_blocklist": false,
"domain_blocklist_lists": null,
"domain_blocklist_detail": null,
"domain_blocklist_help_url": null,
"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",
"campaigns": [
{
"id": "c1a2b3c4-d5e6-47f8-9a0b-1c2d3e4f5a6b",
"name": "Q3 Outbound — Founders",
"status": "running",
"channel": "email"
}
],
"campaign_count": 1
}
],
"pagination": {
"has_more": false,
"next_cursor": null
},
"meta": {
"request_id": "req_mq28denhy91fh6"
}
}
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. `campaigns` / `campaign_count` list the campaigns this mailbox sends for.
mailboxes:readid — in: path — type: string — required. Mailbox id (UUID).{
"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",
"inbox_rate_scope": "Measured only on warmup emails exchanged with mailboxes in the WarmySender warmup network (warmup never emails your own contacts); it shows how providers currently treat mail from this mailbox, not whether a specific email to a contact arrived, which can't be seen from 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,
"domain_on_blocklist": false,
"domain_blocklist_lists": null,
"domain_blocklist_detail": null,
"domain_blocklist_help_url": null,
"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",
"campaigns": [
{
"id": "c1a2b3c4-d5e6-47f8-9a0b-1c2d3e4f5a6b",
"name": "Q3 Outbound — Founders",
"status": "running",
"channel": "email"
}
],
"campaign_count": 1
},
"meta": {
"request_id": "req_mq28denhy91fh6"
}
}
not_found — No mailbox with that id in your workspace.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).
mailboxes:writeemail_address — type: string — required — format: email. The mailbox email address.smtp_host — type: string — required. SMTP server hostname, e.g. smtp.gmail.com.smtp_port — type: integer — optional — default: 587 — range: 1–65535. SMTP port. One of 25, 465, 587. Defaults to 587.smtp_username — type: string — required. SMTP login (usually the email address).smtp_password — type: string — required — format: password. SMTP password or app password.imap_host — type: string — required. IMAP server hostname, e.g. imap.gmail.com.imap_port — type: integer — optional — default: 993 — range: 1–65535. IMAP port. One of 143, 993. Defaults to 993.imap_username — type: string — required. IMAP login (usually the email address).imap_password — type: string — required — format: password. IMAP password or app password.external_id — type: string — optional — range: –255. Your own reference id for this mailbox (unique per workspace).display_name — type: string — optional — range: –255. Display name shown on outgoing mail.sending_enabled — type: boolean — optional — default: true. Whether the mailbox may send. Defaults to true.daily_send_limit — type: integer — optional — default: 50 — range: 1–1000. Max sends per day. Defaults to 50.auto_enable_warmup — type: boolean — optional — default: false. Enable warmup automatically once the connection test passes.{
"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
}
{
"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",
"inbox_rate_scope": "Measured only on warmup emails exchanged with mailboxes in the WarmySender warmup network (warmup never emails your own contacts); it shows how providers currently treat mail from this mailbox, not whether a specific email to a contact arrived, which can't be seen from 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,
"domain_on_blocklist": false,
"domain_blocklist_lists": null,
"domain_blocklist_detail": null,
"domain_blocklist_help_url": null,
"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"
}
}
validation_error — Body failed validation.mailbox_limit_exceeded — At or above your plan's mailbox limit.duplicate_email — An active mailbox already uses this email.duplicate_external_id — The external_id is already in use.ssrf_blocked — A host resolved to a private/internal IP.invalid_port — A port outside the allowed list (25/465/587/143/993).POST /api/v1/mailboxes/test
Synchronously tests SMTP + IMAP credentials WITHOUT creating a mailbox. Rate limited.
mailboxes:writeemail_address — type: string — required — format: email. The mailbox email address.smtp_host — type: string — required. SMTP server hostname, e.g. smtp.gmail.com.smtp_port — type: integer — optional — default: 587 — range: 1–65535. SMTP port. One of 25, 465, 587. Defaults to 587.smtp_username — type: string — required. SMTP login (usually the email address).smtp_password — type: string — required — format: password. SMTP password or app password.imap_host — type: string — required. IMAP server hostname, e.g. imap.gmail.com.imap_port — type: integer — optional — default: 993 — range: 1–65535. IMAP port. One of 143, 993. Defaults to 993.imap_username — type: string — required. IMAP login (usually the email address).imap_password — type: string — required — format: password. IMAP password or app password.{
"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"
}
{
"data": {
"smtp_ok": true,
"imap_ok": true
},
"meta": {
"request_id": "req_mq28denhy91fh6"
}
}
validation_error — Body failed validation.ssrf_blocked — A host resolved to a private/internal IP.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.
mailboxes:writeid — in: path — type: string — required. Mailbox id (UUID).display_name — type: string — optional — range: –255. Display name.smtp_host — type: string — optional. SMTP host.smtp_port — type: integer — optional — range: 1–65535. SMTP port (25/465/587).smtp_username — type: string — optional. SMTP login.smtp_password — type: string — optional — format: password. SMTP password.imap_host — type: string — optional. IMAP host.imap_port — type: integer — optional — range: 1–65535. IMAP port (143/993).imap_username — type: string — optional. IMAP login.imap_password — type: string — optional — format: password. IMAP password.sending_enabled — type: boolean — optional. Allow sending.daily_send_limit — type: integer — optional — range: 1–1000. Daily send cap.external_id — type: string — optional — nullable — range: –255. Your reference id, or null to clear.signature — type: string — optional — nullable — range: –65536. HTML/text signature, or null to clear.{
"display_name": "Jordan Lee",
"daily_send_limit": 80
}
{
"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",
"inbox_rate_scope": "Measured only on warmup emails exchanged with mailboxes in the WarmySender warmup network (warmup never emails your own contacts); it shows how providers currently treat mail from this mailbox, not whether a specific email to a contact arrived, which can't be seen from 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,
"domain_on_blocklist": false,
"domain_blocklist_lists": null,
"domain_blocklist_detail": null,
"domain_blocklist_help_url": null,
"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"
}
}
validation_error — Body failed validation.not_found — Mailbox not found.duplicate_external_id — The new external_id is already in use.ssrf_blocked — A host resolved to a private/internal IP.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.
mailboxes:writeid — in: path — type: string — required. Mailbox id (UUID).{
"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",
"inbox_rate_scope": "Measured only on warmup emails exchanged with mailboxes in the WarmySender warmup network (warmup never emails your own contacts); it shows how providers currently treat mail from this mailbox, not whether a specific email to a contact arrived, which can't be seen from 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,
"domain_on_blocklist": false,
"domain_blocklist_lists": null,
"domain_blocklist_detail": null,
"domain_blocklist_help_url": null,
"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"
}
}
not_found — Mailbox not found (or already deleted).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.
mailboxes:writeid — in: path — type: string — required. Mailbox id (UUID).{
"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",
"inbox_rate_scope": "Measured only on warmup emails exchanged with mailboxes in the WarmySender warmup network (warmup never emails your own contacts); it shows how providers currently treat mail from this mailbox, not whether a specific email to a contact arrived, which can't be seen from 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,
"domain_on_blocklist": false,
"domain_blocklist_lists": null,
"domain_blocklist_detail": null,
"domain_blocklist_help_url": null,
"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"
}
}
not_found — No soft-deleted mailbox with that id.duplicate_email — An active mailbox already uses this email.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.
mailboxes:writeid — in: path — type: string — required. Mailbox id (UUID).{
"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",
"inbox_rate_scope": "Measured only on warmup emails exchanged with mailboxes in the WarmySender warmup network (warmup never emails your own contacts); it shows how providers currently treat mail from this mailbox, not whether a specific email to a contact arrived, which can't be seen from 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,
"domain_on_blocklist": false,
"domain_blocklist_lists": null,
"domain_blocklist_detail": null,
"domain_blocklist_help_url": null,
"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"
}
}
not_found — Mailbox not found.ssrf_blocked — A host resolved to a private/internal IP.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. `inbox_rate_scope` says where it is measured: warmup emails between mailboxes in the warmup network, never your own contacts. Cursor paginated.
warmup:readpagination.next_cursor.limit — in: query — type: integer — optional — default: 50 — range: 1–100. Results per page (1–100).cursor — in: query — type: string — optional. next_cursor from the previous page.mailbox_id — in: query — type: string — optional. One mailbox id, or comma-separated ids (up to 100), to filter.{
"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,
"language": "en",
"effective_daily_limit": 50,
"effective_limit_reason": null,
"spam_hold": 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_scope": "Measured only on warmup emails exchanged with mailboxes in the WarmySender warmup network (warmup never emails your own contacts); it shows how providers currently treat mail from this mailbox, not whether a specific email to a contact arrived, which can't be seen from 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,
"domain_on_blocklist": false,
"domain_blocklist_lists": null,
"domain_blocklist_detail": null,
"domain_blocklist_help_url": null,
"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 /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). Also returns `placement_by_provider` — where this mailbox's warmup emails landed over the last 14 days, split by the receiving provider (Google, Microsoft, other providers) — and `spam_hold`, a plain sentence explaining a reduced daily volume while the spam check holds it (null otherwise).
warmup:readmailboxId — in: path — type: string — required. Mailbox id (UUID).{
"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,
"language": "en",
"effective_daily_limit": 50,
"effective_limit_reason": null,
"spam_hold": 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_scope": "Measured only on warmup emails exchanged with mailboxes in the WarmySender warmup network (warmup never emails your own contacts); it shows how providers currently treat mail from this mailbox, not whether a specific email to a contact arrived, which can't be seen from 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,
"domain_on_blocklist": false,
"domain_blocklist_lists": null,
"domain_blocklist_detail": null,
"domain_blocklist_help_url": null,
"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",
"placement_by_provider": {
"window_days": 14,
"window_start": "2026-09-23T00:00:00.000Z",
"providers": [
{
"provider": "google",
"label": "Google",
"sent": 11,
"inbox": 10,
"spam": 1,
"other_folder": 0,
"bounced": 0,
"unconfirmed": 0
},
{
"provider": "microsoft",
"label": "Microsoft",
"sent": 6,
"inbox": 6,
"spam": 0,
"other_folder": 0,
"bounced": 0,
"unconfirmed": 0
},
{
"provider": "other",
"label": "Other providers",
"sent": 9,
"inbox": 8,
"spam": 0,
"other_folder": 0,
"bounced": 0,
"unconfirmed": 1
}
],
"totals": {
"sent": 26,
"inbox": 24,
"spam": 1,
"other_folder": 0,
"bounced": 0,
"unconfirmed": 1
},
"spam_not_split_by_provider": 0,
"measures": "where the warmup emails this mailbox SENT over the last 14 days landed, grouped by the provider of the mailbox that received each one"
}
},
"meta": {
"request_id": "req_mq28denhy91fh6"
}
}
not_found — Mailbox not found.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.
warmup:writemailboxId — in: path — type: string — required. Mailbox id (UUID).warmup_enabled — type: boolean — optional. Turn warmup on or off. Enabling requires the mailbox status to be "connected".warmup_type — type: string — optional — one of: new_domain, new_mailbox, dormant, maintenance, recovery, aggressive, normal, conservative. Warmup strategy. Controls ramp speed and target.warmup_target_daily_volume — type: integer — optional — range: 1–100. Target warmup emails per day once fully ramped.language — type: string — optional — one of: en, es, fr, de, pt, it, nl. Language the warmup emails are written in (written natively, not translated). Applies to newly written warmup mail; does not reset the ramp or change the volume.{
"warmup_enabled": true,
"warmup_type": "normal",
"warmup_target_daily_volume": 50
}
{
"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,
"language": "en",
"effective_daily_limit": 50,
"effective_limit_reason": null,
"spam_hold": 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_scope": "Measured only on warmup emails exchanged with mailboxes in the WarmySender warmup network (warmup never emails your own contacts); it shows how providers currently treat mail from this mailbox, not whether a specific email to a contact arrived, which can't be seen from 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,
"domain_on_blocklist": false,
"domain_blocklist_lists": null,
"domain_blocklist_detail": null,
"domain_blocklist_help_url": null,
"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"
}
}
validation_error — Body failed validation.not_found — Mailbox not found.mailbox_not_connected — Tried to enable warmup on a mailbox that is not connected.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.
warmup:writemailbox_ids — type: string[] — required — range: 1–500. Mailbox ids to update (1–500).updates — type: object — required. A warmup settings object — same fields as Update warmup settings (warmup_enabled, warmup_type, warmup_target_daily_volume, language). At least one field required.{
"mailbox_ids": [
"5bd50e20-6b75-4429-9a5a-4a09b46e945a",
"a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
],
"updates": {
"warmup_enabled": true,
"warmup_type": "normal"
}
}
{
"data": {
"job_id": "job_7d2f1a",
"status": "processing",
"total_items": 2
},
"meta": {
"request_id": "req_mq28denhy91fh6",
"idempotency_key": "550e8400-e29b-41d4-a716-446655440000"
}
}
validation_error — Body failed validation or no update fields given.mailboxes_not_found — One or more mailbox ids don't exist (details.missing_mailbox_ids).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.
jobs:readid — in: path — type: string — required. Job id (UUID).{
"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"
}
}
not_found — Job not found.GET /api/v1/prospects
Returns prospects in your workspace, newest first, with cursor pagination. Pass listId to return only the prospects in one list — the way to pull everyone in a list from your own server or script. Get list ids from GET /lists. Pass field and fieldValue to keep only the prospects whose job title, company, country, city or one of your own CSV columns contains a value — the same "Filter by field" as the Prospects page.
prospects:readpagination.next_cursor.limit — in: query — type: integer — optional — default: 50 — range: 1–100. Results per page (1–100).cursor — in: query — type: string — optional. next_cursor from the previous page.listId — in: query — type: string — optional. Only return prospects in this list (a list id from GET /lists). Pass __unassigned__ to return only the prospects that are not in any list. Leave it out to return every prospect.field — in: query — type: string — optional. Keep only prospects whose field contains fieldValue (not case-sensitive). One of: role (job title), company, country, city, or custom:<name> for one of your own fields — a CSV column named Language is stored as custom:language. Requires fieldValue.fieldValue — in: query — type: string — optional. Text to look for in field, e.g. German or Head of Sales (up to 200 characters). Requires field.filter — in: query — type: string — optional. Repeatable metadata filter, formatted field:op:value (e.g. company:eq:Acme, custom:region:eq:US, email:exists). Fields: email, firstName, lastName, company, role, country, city, industry, globalStatus, tags, or custom:<key>. Ops: eq, neq, contains, exists. Unknown/invalid filters are ignored and echoed back in filters_ignored.{
"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"
}
}
validation_error — listId was empty, repeated, or longer than 128 characters; or field / fieldValue was given without the other, field is not one of the fields listed above, or fieldValue is longer than 200 characters.list_not_found — listId is not a list in your workspace. No prospects are returned.GET /api/v1/lists
Returns the lists in your workspace, newest first, with cursor pagination. Each list includes its id, name and how many prospects it holds. Use a list id with GET /prospects?listId=<id> to pull the prospects in that list.
prospects:readpagination.next_cursor.limit — in: query — type: integer — optional — default: 50 — range: 1–100. Results per page (1–100).cursor — in: query — type: string — optional. next_cursor from the previous page.{
"data": [
{
"id": "d4e5f6a7-b8c9-4012-8345-6789abcdef01",
"name": "EU ops leads",
"description": "Imported from the June trade show",
"prospectCount": 1240,
"createdAt": "2026-06-01T09:00:00.000Z",
"updatedAt": "2026-06-12T16:30:00.000Z"
}
],
"pagination": {
"has_more": false,
"next_cursor": null
}
}
DELETE /api/v1/lists/{id}
Deletes one of your lists, exactly like deleting it in the app. Prospects that are in this list and in no other list are permanently deleted with it, together with their campaign history. Prospects that are also in another list are kept. If any prospect that would be deleted is still active in a campaign, nothing is deleted and the response names the campaigns to pause first.
prospects:writeid — in: path — type: string — required. List id (from GET /lists).{
"data": {
"deleted": true,
"id": "d4e5f6a7-b8c9-4012-8345-6789abcdef01",
"contacts_deleted": 12
},
"meta": {
"request_id": "req_mq28denhy91fh6"
}
}
not_found — No list with that id in this workspace.list_in_use — Some prospects that would be deleted with the list are still active in a campaign. Nothing was deleted. `details.campaigns` lists the campaigns, `details.blocked_contacts` how many prospects, and `details.action` what to do.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.
prospects:writeemail — type: string — optional — format: email. Prospect email (unique per workspace). Optional — provide email, linkedinUrl, instagramUsername, or whatsappPhone.firstName — type: string — optional. First name.lastName — type: string — optional. Last name.company — type: string — optional. Company.role — type: string — optional. Job title / role.phone — type: string — optional. Phone number.linkedinUrl — type: string — optional — format: uri. LinkedIn profile URL. Use for a LinkedIn-only prospect with no email.instagramUsername — type: string — optional. Instagram handle (with or without the leading @, or a full instagram.com profile URL). Use for an Instagram-only prospect with no email.whatsappPhone — type: string — optional. WhatsApp number in any format (+49 171 234 5678, 0049…, bare digits, or a wa.me link). Stored in a single normalized form so the same person is never created twice. Use for a WhatsApp-only prospect with no email.customFields — type: object — optional. Arbitrary key/value pairs for personalization.listId — type: string — optional. Add the created/updated prospect to this list. When a running campaign is linked to the list, the prospect is auto-enrolled into it (continuous enrollment). Find the list ID on the Lists page.enroll — type: boolean — optional — default: true. When listId is set and a running campaign is linked to that list, auto-enroll the prospect into it. Set false to add to the list only, without enrolling. Defaults to true. Ignored when listId is omitted.enrollInCampaignId — type: string — optional. Enroll the created/updated prospect into this specific campaign in the same call (bypasses lists). Requires the enrollments:write scope. Suppressed prospects are skipped.{
"email": "[email protected]",
"firstName": "Sam",
"listId": "list_9f3a2b",
"enroll": true
}
{
"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
}
}
validation_error — Body failed validation, or none of email, linkedinUrl, instagramUsername, or whatsappPhone was provided.prospect_limit_reached — Workspace prospect limit reached.list_not_found — listId did not match a list in your workspace. The prospect is still saved; see list.error in the response body.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.
prospects:writeid — in: path — type: string — required. Prospect id (UUID).firstName — type: string — optional — nullable. First name, or null to clear.lastName — type: string — optional — nullable. Last name, or null to clear.company — type: string — optional — nullable. Company, or null to clear.role — type: string — optional — nullable. Role, or null to clear.phone — type: string — optional — nullable. Phone, or null to clear.linkedinUrl — type: string — optional — format: uri — nullable. LinkedIn URL, or null to clear.instagramUsername — type: string — optional — nullable. Instagram handle (with or without the leading @, or a full instagram.com profile URL), or null to clear.whatsappPhone — type: string — optional — nullable. WhatsApp number in any format, or null to clear. A number that cannot be read as a real phone number clears the field rather than being stored as typed.website — type: string — optional — nullable. Website.country — type: string — optional — nullable. Country.city — type: string — optional — nullable. City.industry — type: string — optional — nullable. Industry.tags — type: string[] — optional — nullable. Tags (replaces the existing set). A comma-separated string is also accepted; null or an empty list clears all tags.customFields — type: object — optional — nullable. Custom fields, merged per name. Names you omit are left unchanged. Set a name to null to remove that field. Send null for the whole object to remove every custom field. An empty object {} changes nothing.{
"role": "VP Engineering",
"industry": "SaaS",
"tags": [
"vip"
],
"customFields": {
"plan": "enterprise"
}
}
{
"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"
}
}
unknown_fields — The body included a field that cannot be updated. The response names those fields.validation_error — Body failed validation.not_found — Prospect not found.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.
suppressions:writeemails — type: string[] — required — range: 1–1000. Emails to suppress (1–1000).reason — type: string — optional. Optional reason stored with each entry.{
"emails": [
"[email protected]",
"[email protected]"
],
"reason": "manual opt-out"
}
{
"data": {
"suppressedCount": 2,
"alreadySuppressedCount": 0,
"notFoundCount": 0,
"totalProcessed": 2
}
}
validation_error — Body failed validation.GET /api/v1/suppressions
Returns suppressed emails and domains (the blocklist) with cursor pagination.
suppressions:readpagination.next_cursor.limit — in: query — type: integer — optional — default: 50 — range: 1–100. Results per page (1–100).cursor — in: query — type: string — optional. next_cursor from the previous page.type — in: query — type: string — optional — one of: email, domain. Filter by entry type.{
"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"
}
}
GET /api/v1/campaigns
Returns campaigns with summary stats (sent/opened/replied/...). Cursor paginated. Email and multichannel campaigns also carry `sendingMailboxes` (the mailboxes each one sends from — `state` is ready, sending_paused or needs_reconnect; `campaignDailyLimit` is the most the campaign sends from that mailbox per day; up to 50 per campaign), the exact `sendingMailboxCount` and `sendingMailboxStateCounts` (how many of them are in each state). All three are null for other channels.
campaigns:readpagination.next_cursor.limit — in: query — type: integer — optional — default: 50 — range: 1–100. Results per page (1–100).cursor — in: query — type: string — optional. next_cursor from the previous page.status — in: query — type: string — optional — one of: draft, scheduled, running, paused, completed, error. Filter by status.archived — in: query — type: string — optional — one of: true, false. true lists only archived campaigns (hidden from the default list). Restore one with POST /campaigns/{id}/unarchive.{
"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,
"company_address": 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",
"sendingMailboxes": [
{
"id": "m1b2c3d4-e5f6-47a8-9b0c-1d2e3f4a5b6c",
"emailAddress": "[email protected]",
"state": "ready",
"dailyLimit": 50,
"campaignDailyLimit": 50
}
],
"sendingMailboxCount": 1,
"sendingMailboxStateCounts": {
"ready": 1,
"sending_paused": 0,
"needs_reconnect": 0
}
}
],
"pagination": {
"has_more": false,
"next_cursor": null
},
"meta": {
"request_id": "req_mq28denhy91fh6"
}
}
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). Email and multichannel campaigns also carry `sendingMailboxes` — every mailbox the campaign sends from (up to 200), each with `state`, `dailyLimit` (the mailbox's own limit), `campaignDailyLimit` (the most this campaign sends from it per day) and `sentToday` (campaign emails it sent today, from any campaign) — plus the exact `sendingMailboxCount` and `sendingMailboxStateCounts`.
campaigns:readid — in: path — type: string — required. Campaign id (UUID).{
"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,
"company_address": 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",
"company_address": null,
"company_address_source": "none"
},
"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
}
],
"sendingMailboxes": [
{
"id": "m1b2c3d4-e5f6-47a8-9b0c-1d2e3f4a5b6c",
"emailAddress": "[email protected]",
"state": "ready",
"dailyLimit": 50,
"campaignDailyLimit": 50,
"sentToday": 12
}
],
"sendingMailboxCount": 1,
"sendingMailboxStateCounts": {
"ready": 1,
"sending_paused": 0,
"needs_reconnect": 0
}
},
"meta": {
"request_id": "req_mq28denhy91fh6"
}
}
not_found — Campaign not found.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, inmail_refused. `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.
campaigns:readid — in: path — type: string — required. Campaign id (UUID).{
"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"
}
}
not_found — Campaign not found.not_linkedin_campaign — The campaign does not send on LinkedIn, so there is no LinkedIn allowance to report.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.
campaigns:readid — in: path — type: string — required. Campaign id (UUID).hours — in: query — type: integer — optional — default: 24 — range: 1–168. How far back to look, in hours. Clamped to 1–168.limit — in: query — type: integer — optional — default: 50 — range: 1–100. Most individual examples to return in `recent`. Clamped to 1–100.{
"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."
}
]
}
}
not_found — Campaign not found.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.
campaigns:writename — type: string — required — range: 1–200. Campaign name.description — type: string — optional — range: –2000. Optional description.channel — type: string — optional — one of: email, linkedin, whatsapp, instagram, multichannel — default: email. Campaign channel. A single-channel campaign may only carry steps of that channel (plus condition steps); mix channels only under "multichannel".linkedinAccountId — type: string — optional — format: uuid. LinkedIn account id (for linkedin/multichannel).instagramAccountId — type: string — optional. Instagram account that sends this campaign. Required when the campaign carries Instagram steps. Needs an active Instagram seat.whatsappAccountId — type: string — optional. WhatsApp number that sends this campaign. Required when the campaign carries WhatsApp steps. Needs an active WhatsApp seat.timezone — type: string — optional — range: –100. Timezone the sending window and days are read in, as a name like "Europe/Paris" or "America/New_York". Leave it out to use your workspace timezone (Settings → Workspace), which is UTC unless you changed it.dailySendLimit — type: integer — optional — default: 50 — range: 1–10000. Daily send cap for the campaign.sendingWindowStart — type: integer — optional — range: 0–23. Start hour (0–23) of the daily sending window.sendingWindowEnd — type: integer — optional — range: 0–23. End hour (0–23) of the daily sending window.scheduleDays — type: integer[] — optional. Days of the week this campaign may send on. Numbers use 0=Sunday, 1=Monday, 2=Tuesday, 3=Wednesday, 4=Thursday, 5=Friday, 6=Saturday (Sunday is 0, not Monday). Day names such as "mon" or "monday" are accepted too and are the safest choice. Order and repeats do not matter. Leave it out for Monday–Friday [1,2,3,4,5]; an empty list is refused because a campaign with no sending days could never send.stopOnReply — type: boolean — optional — default: true. Stop a prospect's sequence when they reply.stopOnBounce — type: boolean — optional — default: true. Stop on bounce.stopOnUnsubscribe — type: boolean — optional — default: true. Stop on unsubscribe.trackOpens — type: boolean — optional — default: true. Track opens.trackClicks — type: boolean — optional — default: true. Track clicks.opt_out_style — type: string — optional — one of: link, reply — nullable. This applies to THIS campaign only — useful when one campaign writes to people in a different language from the rest of your outreach. Leave it out and the campaign keeps using your workspace setting; send null (or an empty string) to go back to following it. The choice itself works the same way it does for your workspace: How people opt out of your cold email. "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 — type: string — optional — nullable — range: –120. This applies to THIS campaign only — useful when one campaign writes to people in a different language from the rest of your outreach. Leave it out and the campaign keeps using your workspace setting; send null (or an empty string) to go back to following it. The wording rules are the same as your workspace setting: The sentence recipients click or reply to in order to opt out — written in your own words, 4–120 characters, one line. Send an empty string to go back to the default for your opt-out style ("Unsubscribe" for link, "Reply "unsubscribe" to opt out" for reply). Put {{opt_out}} inside an email or signature to place the sentence yourself.company_address — type: string — optional — nullable — range: –1000. The postal address shown in the footer of THIS campaign's emails — useful when you run campaigns for several companies from one workspace. Leave it out and the campaign keeps using your workspace address; send null (or an empty string) to go back to it.startDate — type: string — optional — format: date-time. Optional scheduled start.endDate — type: string — optional — format: date-time. Optional end date.steps — type: object[] — required — range: 1–50. Ordered steps. Each: { stepIndex, type (email|linkedin|instagram|whatsapp|condition), subject, bodyHtml, bodyText, delayDays, delayHours, isActive, … }. See the steps[] fields below for what an email body may contain and how to reuse a saved template. Condition steps additionally take the four condition fields below.steps[].subject — type: string — optional — range: –500. Subject line of an email step. Merge tags and spintax work here. Optional when the step reuses a template that carries one.steps[].preheader — type: string — optional — range: –200. Preview text shown after the subject line in the inbox.steps[].bodyHtml — type: string — optional — range: –100000. HTML email body. Use simple HTML: <p>, <br>, <strong>, <em>, <u>, <a href>, <ul>, <ol>, <li>, <h2>, <h3>, <blockquote>, <hr>, <img src>. Inline styles are supported for font-family, font-size, color, background-color, line-height, margin-left and text-align — the same formatting the WarmySender editor produces. Plain text is fine too: send bodyText (or plain body_html without tags) and line breaks are formatted automatically. Scripts and event handlers are removed. Merge tags like {{first_name | default: 'there'}} and spintax {a|b|c} work in both. Keep formatting light: plain-looking emails get more replies.steps[].bodyText — type: string — optional — range: –100000. Plain-text version of the email. Optional — when you send only HTML, the plain-text alternative is written for you. Merge tags like {{first_name | default: 'there'}} and spintax {a|b|c} work here too.steps[].templateId — type: string — optional. Reuse one of your saved email templates for this step: its subject, preview text and body are copied in. Anything you also set on the step overrides the template's version. An unknown id (or one from another workspace) fails the whole request with not_found.steps[].quotePreviousMessages — type: boolean — optional — default: true. Quote the earlier email underneath a threaded follow-up, the way a hand-written reply does. Default true; only applies to follow-up steps that reply in the same thread.steps[].instagramActionType — type: string — optional — one of: follow, view_profile, like_post, comment_post, send_dm, wait, wait_reply, condition_replied. Action for a step of type "instagram".steps[].instagramDmTemplate — type: string — optional. DM body for a send_dm step (supports {{firstName}} etc).steps[].instagramCommentTemplate — type: string — optional. Comment body for a comment_post step.steps[].whatsappActionType — type: string — optional — one of: send_message, wait, wait_reply, condition_replied. Action for a step of type "whatsapp". WhatsApp outreach is reply-first: send_message is the only outbound action.steps[].whatsappMessageTemplate — type: string — optional. Message body for a send_message step (supports {{firstName}} etc).steps[].conditionType — type: string — optional — one of: email_opened, email_replied, email_clicked, email_bounced, linkedin_accepted, linkedin_replied, has_linkedin_profile, timeout. Required on a step of type "condition": the signal the branch evaluates. Any other value is rejected with 400 validation_error.steps[].conditionTrueNext — type: integer — optional — range: -1–. Required on a condition step: stepIndex to jump to when the condition is met. Use -1 to end the sequence on the YES branch. Must point forward.steps[].conditionFalseNext — type: integer — optional — nullable — range: -1–. stepIndex to jump to when the condition is NOT met. Omit (or use -1) to end the sequence on the NO branch — a first-class outcome, not an error. Must point forward.steps[].conditionTimeoutDays — type: integer — optional — default: 3 — range: 1–30. How long the condition waits before the NO branch is taken. Defaults to 3 days when omitted.mailboxIds — type: string[] — required — range: –100. Mailbox ids that send this campaign.prospectIds — type: string[] — optional — range: –10000. Prospect ids to enroll on creation.{
"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"
]
}
{
"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,
"company_address": 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",
"company_address": null,
"company_address_source": "none"
},
"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
}
}
validation_error — Body / step validation failed — including a conditionType outside the allowed list.unsupported_field — A step supplied "conditions" (multi-condition AND/OR logic). Author those in the campaign builder; the API returns them read-only. Also returned when the opt-out setting was sent as optOutStyle / optOutText — use opt_out_style / opt_out_text, the same names your workspace settings use. Nothing was created.campaign_step_validation_error — A step failed sequence-integrity checks (e.g. a branch pointing backward or at a step that does not exist).subscription_blocked — Your plan doesn't allow this action.PATCH /api/v1/campaigns/{id}
Updates campaign settings. Only allowed while the campaign is in draft or paused status.
campaigns:writeid — in: path — type: string — required. Campaign id (UUID).name — type: string — optional — range: 1–200. Campaign name.description — type: string — optional — range: –2000. Description.dailySendLimit — type: integer — optional — range: 1–10000. Daily send cap.sendingWindowStart — type: integer — optional — range: 0–23. Window start hour.sendingWindowEnd — type: integer — optional — range: 0–23. Window end hour.scheduleDays — type: integer[] — optional. Days of the week this campaign may send on. Numbers use 0=Sunday, 1=Monday, 2=Tuesday, 3=Wednesday, 4=Thursday, 5=Friday, 6=Saturday (Sunday is 0, not Monday). Day names such as "mon" or "monday" are accepted too and are the safest choice. Order and repeats do not matter. Leave it out for Monday–Friday [1,2,3,4,5]; an empty list is refused because a campaign with no sending days could never send.stopOnReply — type: boolean — optional. Stop on reply.stopOnBounce — type: boolean — optional. Stop on bounce.stopOnUnsubscribe — type: boolean — optional. Stop on unsubscribe.trackOpens — type: boolean — optional. Track opens.trackClicks — type: boolean — optional. Track clicks.opt_out_style — type: string — optional — one of: link, reply — nullable. This applies to THIS campaign only — useful when one campaign writes to people in a different language from the rest of your outreach. Leave it out and the campaign keeps using your workspace setting; send null (or an empty string) to go back to following it. The choice itself works the same way it does for your workspace: How people opt out of your cold email. "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 — type: string — optional — nullable — range: –120. This applies to THIS campaign only — useful when one campaign writes to people in a different language from the rest of your outreach. Leave it out and the campaign keeps using your workspace setting; send null (or an empty string) to go back to following it. The wording rules are the same as your workspace setting: The sentence recipients click or reply to in order to opt out — written in your own words, 4–120 characters, one line. Send an empty string to go back to the default for your opt-out style ("Unsubscribe" for link, "Reply "unsubscribe" to opt out" for reply). Put {{opt_out}} inside an email or signature to place the sentence yourself.company_address — type: string — optional — nullable — range: –1000. The postal address shown in the footer of THIS campaign's emails — useful when you run campaigns for several companies from one workspace. Leave it out and the campaign keeps using your workspace address; send null (or an empty string) to go back to it.startDate — type: string — optional — format: date-time — nullable. Scheduled start, or null.endDate — type: string — optional — format: date-time — nullable. End date, or null.{
"dailySendLimit": 80,
"scheduleDays": [
1,
2,
3,
4,
5
]
}
{
"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,
"company_address": 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",
"company_address": null,
"company_address_source": "none"
},
"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"
}
}
invalid_status — Campaign isn't in draft/paused status.validation_error — opt_out_text was too short, too long, or worded in a way that would not read as an opt-out.unsupported_field — The opt-out setting was sent as optOutStyle / optOutText. Use opt_out_style / opt_out_text — the same names your workspace settings use. Nothing was changed.not_found — Campaign not found.POST /api/v1/campaigns/{id}/start
Moves a draft/paused campaign into running (or scheduled, when startDate is in the future). A completed campaign starts again only when new prospects have been enrolled in it (otherwise 400 nothing_new_to_send). 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.
campaigns:writeid — in: path — type: string — required. Campaign id (UUID).force — in: query — type: string — optional — one of: 1, true. Launch even though the configuration check found problems — the headless equivalent of "Launch anyway" in the app. Omit unless you have reviewed the reported violations.{
"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,
"company_address": 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"
}
}
invalid_status — Campaign can't be started from its current status.nothing_new_to_send — The campaign is completed and nobody new has been enrolled in it. Enroll prospects who aren't already in it (that reopens it), or start another campaign.campaign_validation_failed — The sequence has configuration problems that would strand prospects. details.violations lists each one with its step index and a fix hint. Resolve them, or retry with ?force=1. Note that not every violation is force-overridable — see no_active_steps below.no_active_steps — Every step in the campaign is switched off, so it could never send anything. Switch at least one step back on, then start it again. This one is NOT overridable with ?force=1 — forcing it would only start a campaign that cannot send.subscription_blocked — Your plan doesn't allow this action.insufficient_scope — The campaign runs Instagram or WhatsApp steps and this key lacks that channel's write permission. Nothing was started.seat_required — The campaign runs Instagram or WhatsApp steps and the workspace has no active seat for that channel.account_not_connected — The Instagram account or WhatsApp number this campaign sends from is not connected. Reconnect it, then start again.rate_limited — Too many Instagram/WhatsApp campaign changes in one minute. Nothing was changed — wait for the Retry-After delay.not_found — Campaign not found.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.
campaigns:writeid — in: path — type: string — required. Campaign id (UUID).{
"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,
"company_address": 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"
}
}
invalid_status — Campaign isn't running.insufficient_scope — The campaign runs Instagram or WhatsApp steps and this key lacks that channel's write permission.not_found — Campaign not found.POST /api/v1/campaigns/{id}/archive
Hides a paused, completed or draft campaign from the campaign list. Nothing is deleted: steps, settings, contacts and results are kept, and you can bring it back any time with the unarchive call. List archived campaigns with GET /campaigns?archived=true. No request body.
campaigns:writeid — in: path — type: string — required. Campaign id (UUID).{
"data": {
"id": "c1a2b3c4-d5e6-47f8-9a0b-1c2d3e4f5a6b",
"name": "Q3 Outbound — Founders",
"status": "completed",
"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,
"company_address": 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",
"archived": true,
"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"
}
}
invalid_status — The campaign is running or scheduled. Pause it first.already_archived — The campaign is already archived.not_found — Campaign not found.POST /api/v1/campaigns/{id}/unarchive
Brings an archived campaign back to your campaign list as paused. It never starts sending on its own: start it with POST /campaigns/{id}/start when you are ready. No request body.
campaigns:writeid — in: path — type: string — required. Campaign id (UUID).{
"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,
"company_address": 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",
"archived": false,
"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"
}
}
not_archived — The campaign is not archived.not_found — Campaign not found.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. Enrolling new prospects into a completed campaign reopens it for them (campaignReopened: true) — prospects already in it, including those who finished, are skipped and never emailed again, and sending is paced by the scheduler as usual.
enrollments:writeid — in: path — type: string — required. Campaign id (UUID).prospectIds — type: string[] — optional. Prospect ids to enroll.listIds — type: string[] — optional. Audience list ids — every prospect on each list is enrolled.emails — type: string[] — optional. Prospect emails to enroll.whatsappPhones — type: string[] — optional. WhatsApp numbers of existing leads to enroll. Any human format is accepted (+49 171 234 5678, 0049…, bare digits, a wa.me link) and normalized before matching.{
"emails": [
"[email protected]"
],
"listIds": [
"list_9f3a2b"
],
"whatsappPhones": [
"+49 171 2345678"
]
}
{
"data": {
"enrolledCount": 12,
"suppressedCount": 1,
"invalidCount": 0,
"alreadyEnrolledCount": 2,
"totalProcessed": 15,
"notFoundEmails": [],
"notFoundPhones": [],
"campaignStatus": "running",
"campaignReopened": false
}
}
validation_error — No targets given or body invalid.insufficient_scope — The campaign runs Instagram or WhatsApp steps and this key lacks that channel's write permission. Nobody was enrolled.seat_required — The campaign runs Instagram or WhatsApp steps and the workspace has no active seat for that channel.not_found — Campaign not found.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.
enrollments:writeid — in: path — type: string — required. Campaign id (UUID).prospectIds — type: string[] — optional. Prospect ids to unenroll.emails — type: string[] — optional. Prospect emails to unenroll.{
"emails": [
"[email protected]"
]
}
{
"data": {
"unenrolledCount": 1,
"totalProcessed": 1
}
}
validation_error — No targets given or body invalid.not_found — Campaign not found.GET /api/v1/emails/sent
One record per campaign email your mailboxes sent: who it went to, the campaign and step, the From address, the subject, the Message-ID and when it was sent. Each record has exactly the same fields as the email.sent webhook, so you can backfill any event your endpoint missed. "Sent" means the email was accepted by your sending mailbox's server — not yet a delivery confirmation. Problems found later arrive as the email.bounced webhook. Newest first unless you pass order=asc. Cursor paginated.
campaigns:readpagination.next_cursor.campaignId — in: query — type: string — optional. Only emails from this campaign.prospectId — in: query — type: string — optional. Only emails sent to this prospect.since — in: query — type: string — optional. ISO 8601 date-time. Only emails sent at or after this moment.until — in: query — type: string — optional. ISO 8601 date-time. Only emails sent before this moment.order — in: query — type: string — optional — one of: desc, asc — default: desc. desc = newest first, asc = oldest first.limit — in: query — type: integer — optional — default: 50 — range: 1–200. Results per page (1–200).cursor — in: query — type: string — optional. next_cursor from the previous page, passed back unchanged.{
"data": [
{
"prospectId": "f3c4a2d1-...",
"prospectEmail": "[email protected]",
"campaignId": "c1a2b3c4-...",
"campaignName": "Q3 founders outreach",
"stepIndex": 1,
"stepNumber": 2,
"mailboxId": "5bd50e20-...",
"fromEmail": "[email protected]",
"subject": "Quick question, Jane",
"messageId": "<[email protected]>",
"sentAt": "2026-06-10T10:42:00.000Z",
"channel": "email"
}
],
"pagination": {
"has_more": false,
"next_cursor": null
},
"meta": {
"request_id": "req_mq28denhy91fh6"
}
}
validation_error — A filter, the order, the limit or the cursor is not valid. The message says which.GET /api/v1/replies
The replies your email campaigns received, newest first, with the reply text. Each record has the same fields as the reply.received webhook (plus an id, the conversation id and the sender's name), so you can backfill any reply your endpoint missed or read the text of a reply later. bodyText holds the text of the reply as it arrived (up to 4,000 characters; bodyTruncated is true when it was cut). Cursor paginated. Read-only.
campaigns:readpagination.next_cursor.campaign_id — in: query — type: string — optional. Only replies to this campaign. campaignId also works.prospect_id — in: query — type: string — optional. Only replies from this prospect. prospectId also works.since — in: query — type: string — optional. ISO 8601 date-time. Only replies received at or after this moment.until — in: query — type: string — optional. ISO 8601 date-time. Only replies received before this moment.order — in: query — type: string — optional — one of: desc, asc — default: desc. desc = newest first, asc = oldest first.limit — in: query — type: integer — optional — default: 50 — range: 1–100. Results per page (1–100).cursor — in: query — type: string — optional. next_cursor from the previous page, passed back unchanged.{
"data": [
{
"id": "7d1e4c9a-...",
"threadId": "2b8f6a10-...",
"prospectId": "f3c4a2d1-...",
"prospectEmail": "[email protected]",
"campaignId": "c1a2b3c4-...",
"campaignName": "Q3 founders outreach",
"mailboxId": "5bd50e20-...",
"fromEmail": "[email protected]",
"fromName": "Jane Cooper",
"toEmail": "[email protected]",
"subject": "Re: Quick question, Jane",
"messageId": "<[email protected]>",
"inReplyToMessageId": "<[email protected]>",
"receivedAt": "2026-06-10T11:00:00.000Z",
"bodyText": "Hi Alex, yes, send me the details on Thursday.",
"bodyTruncated": false,
"channel": "email"
}
],
"pagination": {
"has_more": false,
"next_cursor": null
},
"meta": {
"request_id": "req_mq28denhy91fh6"
}
}
validation_error — A filter, the order, the limit or the cursor is not valid. The message says which.GET /api/v1/replies/{id}
One reply by its id, with the same fields as the list plus bodyHtml, the formatted version of the message (up to 20,000 characters; bodyHtmlTruncated is true when it was cut).
campaigns:readid — in: path — type: string — required. Reply id, from GET /replies.{
"data": {
"id": "7d1e4c9a-...",
"threadId": "2b8f6a10-...",
"prospectId": "f3c4a2d1-...",
"prospectEmail": "[email protected]",
"campaignId": "c1a2b3c4-...",
"campaignName": "Q3 founders outreach",
"mailboxId": "5bd50e20-...",
"fromEmail": "[email protected]",
"fromName": "Jane Cooper",
"toEmail": "[email protected]",
"subject": "Re: Quick question, Jane",
"messageId": "<[email protected]>",
"inReplyToMessageId": "<[email protected]>",
"receivedAt": "2026-06-10T11:00:00.000Z",
"bodyText": "Hi Alex, yes, send me the details on Thursday.",
"bodyTruncated": false,
"channel": "email",
"bodyHtml": "<div>Hi Alex, yes, send me the details on Thursday.</div>",
"bodyHtmlTruncated": false
},
"meta": {
"request_id": "req_mq28denhy91fh6"
}
}
not_found — No such reply in your workspace.GET /api/v1/inbox/conversations
Your Unified Inbox: replies to your cold emails plus LinkedIn, Instagram and WhatsApp chats, newest first, in one feed. Each row says who the conversation is with, what was last said and by whom, whether it needs a reply, the campaign it came from and the mailbox or account on your side. Filter to one channel, to conversations that need a reply, to unread ones, by status, sentiment, campaign, mailbox, connected account or prospect, or search by name, address, company, subject and last message. Cursor paginated. Reading never spends any sending limit.
campaigns:readpagination.next_cursor.channel — in: query — type: string — optional — one of: all, email, linkedin, instagram, whatsapp — default: all. Which channel to list; all merges every channel by recency.needsReply — in: query — type: boolean — optional. Only conversations where the contact wrote last and nothing is closed, snoozed, out-of-office or spam.unreadOnly — in: query — type: boolean — optional. Only unread conversations.status — in: query — type: string — optional. email: open | closed | snoozed. Chats: active | archived | spam.sentiment — in: query — type: string — optional — one of: positive, negative, neutral, ooo, spam, uncategorized. Email only.search — in: query — type: string — optional. Matches the person's name, email / handle / phone, company, the subject and the last message.campaignId — in: query — type: string — optional. Email only.mailboxId — in: query — type: string — optional. Email only.prospectId — in: query — type: string — optional. Conversations with this prospect on any channel.linkedinAccountId — in: query — type: string — optional. Only chats on this connected LinkedIn account.instagramAccountId — in: query — type: string — optional. Only chats on this connected Instagram account.whatsappAccountId — in: query — type: string — optional. Only chats on this connected WhatsApp number.limit — in: query — type: integer — optional — default: 50 — range: 1–200. Results per page (1–200).cursor — in: query — type: string — optional. next_cursor from the previous page, passed back unchanged.{
"data": [
{
"id": "8f2dc14c-9b1e-4a7a-8f0e-2b6c1d3e4f50",
"channel": "email",
"participant": {
"name": "Jane Doe",
"email": "[email protected]",
"handle": null,
"phone": null,
"company": "Acme",
"profile_url": null
},
"subject": "Re: Quick question, Jane",
"last_message_at": "2026-10-14T08:10:00.000Z",
"last_message_preview": "Yes — can you send a deck?",
"last_message_from": "them",
"needs_reply": true,
"unread": true,
"unread_count": 1,
"message_count": 2,
"status": "open",
"sentiment": "positive",
"starred": false,
"assigned_to": null,
"is_group": false,
"prospect_id": "f3c4a2d1-...",
"campaign_id": "c1a2b3c4-...",
"campaign_name": "Q3 founders outreach",
"account": {
"label": "[email protected]",
"mailbox_id": "5bd50e20-...",
"linkedin_account_id": null,
"instagram_account_id": null,
"whatsapp_account_id": null
},
"created_at": "2026-10-13T09:02:00.000Z"
}
],
"pagination": {
"has_more": true,
"next_cursor": "MTc1OTk5OTk5OTAwMHw4ZjJkYzE0Yy0..."
},
"meta": {
"request_id": "req_mq28denhy91fh6"
}
}
validation_error — A filter, the limit or the cursor is not valid. The message says which.GET /api/v1/inbox/conversations/{channel}/{id}
One conversation in full: every message in the order it happened, who wrote it, when it was sent and (for chats) when it was delivered, read or edited, plus the internal notes your team left on an email thread. Does not mark the conversation read.
campaigns:readchannel — in: path — type: string — required — one of: email, linkedin, instagram, whatsapp. The conversation's channel, as returned in the list.id — in: path — type: string — required. The conversation id from the list.{
"data": {
"conversation": {
"id": "8f2dc14c-9b1e-4a7a-8f0e-2b6c1d3e4f50",
"channel": "email",
"participant": {
"name": "Jane Doe",
"email": "[email protected]",
"handle": null,
"phone": null,
"company": "Acme",
"profile_url": null
},
"subject": "Re: Quick question, Jane",
"last_message_at": "2026-10-14T08:10:00.000Z",
"last_message_preview": "Yes — can you send a deck?",
"last_message_from": "them",
"needs_reply": true,
"unread": true,
"unread_count": 1,
"message_count": 2,
"status": "open",
"sentiment": "positive",
"starred": false,
"assigned_to": null,
"is_group": false,
"prospect_id": "f3c4a2d1-...",
"campaign_id": "c1a2b3c4-...",
"campaign_name": "Q3 founders outreach",
"account": {
"label": "[email protected]",
"mailbox_id": "5bd50e20-...",
"linkedin_account_id": null,
"instagram_account_id": null,
"whatsapp_account_id": null
},
"created_at": "2026-10-13T09:02:00.000Z"
},
"messages": [
{
"id": "c0a1b2c3-...",
"from": "you",
"from_name": "Jordan Lee",
"from_address": "[email protected]",
"subject": "Quick question, Jane",
"text": "Hi Jane, quick question — ...",
"sent_at": "2026-10-13T09:02:00.000Z",
"delivered_at": null,
"read_at": null,
"edited_at": null,
"deleted_at": null,
"reactions": null,
"attachments": null,
"is_voice_note": false
},
{
"id": "d1b2c3d4-...",
"from": "them",
"from_name": "Jane Doe",
"from_address": "[email protected]",
"subject": "Re: Quick question, Jane",
"text": "Yes — can you send a deck?",
"sent_at": "2026-10-14T08:10:00.000Z",
"delivered_at": null,
"read_at": null,
"edited_at": null,
"deleted_at": null,
"reactions": null,
"attachments": null,
"is_voice_note": false
}
],
"notes": [
{
"id": "e2c3d4e5-...",
"body": "Wants a deck — send Thursday.",
"author_name": "Jordan Lee",
"created_at": "2026-10-14T08:20:00.000Z"
}
]
},
"meta": {
"request_id": "req_mq28denhy91fh6"
}
}
validation_error — channel is not one of the four.not_found — No such conversation in your workspace.PATCH /api/v1/inbox/conversations/{id}
Change how an email conversation is filed: status (open, closed, snoozed with snoozeUntil), sentiment, unread, starred, or assignedTo (a workspace member's user id, or null). Closing a conversation, or labelling it out-of-office or spam, removes it from Needs Reply; a new message from the contact reopens it. Labelling ooo resumes the campaign for that contact. Nothing is sent. LinkedIn, Instagram and WhatsApp chats cannot be changed here.
campaigns:writeid — in: path — type: string — required. An email conversation id.status — type: string — optional — one of: open, closed, snoozed. Open, closed, or snoozed (pair snoozed with snoozeUntil).snoozeUntil — type: string — optional. ISO 8601 date-time, or null to clear.sentiment — type: string — optional — one of: positive, negative, neutral, ooo, spam, uncategorized. How the reply reads; ooo marks an out-of-office and resumes the campaign.unread — type: boolean — optional. true to mark unread, false to mark read.starred — type: boolean — optional. Star or unstar the conversation.assignedTo — type: string — optional. A workspace member's user id, or null to unassign.{
"status": "closed",
"sentiment": "positive"
}
{
"data": {
"id": "8f2dc14c-9b1e-4a7a-8f0e-2b6c1d3e4f50",
"channel": "email",
"participant": {
"name": "Jane Doe",
"email": "[email protected]",
"handle": null,
"phone": null,
"company": "Acme",
"profile_url": null
},
"subject": "Re: Quick question, Jane",
"last_message_at": "2026-10-14T08:10:00.000Z",
"last_message_preview": "Yes — can you send a deck?",
"last_message_from": "them",
"needs_reply": false,
"unread": true,
"unread_count": 1,
"message_count": 2,
"status": "closed",
"sentiment": "positive",
"starred": false,
"assigned_to": null,
"is_group": false,
"prospect_id": "f3c4a2d1-...",
"campaign_id": "c1a2b3c4-...",
"campaign_name": "Q3 founders outreach",
"account": {
"label": "[email protected]",
"mailbox_id": "5bd50e20-...",
"linkedin_account_id": null,
"instagram_account_id": null,
"whatsapp_account_id": null
},
"created_at": "2026-10-13T09:02:00.000Z"
},
"changed": [
"status",
"sentiment"
]
}
validation_error — A value is not allowed, assignedTo is not a member, or nothing was given to change.not_found — No such email conversation in your workspace.POST /api/v1/inbox/conversations/{id}/notes
Leave an internal note on an email conversation for your team. Notes show in the inbox side panel and on GET; the contact never sees them. Up to 5,000 characters.
campaigns:writeid — in: path — type: string — required. An email conversation id.body — type: string — required — range: –5000. The note text. Up to 5,000 characters.{
"body": "Qualified — wants a demo next week. Handing to Sam."
}
{
"data": {
"id": "e2c3d4e5-...",
"body": "Qualified — wants a demo next week. Handing to Sam.",
"created_at": "2026-10-14T08:20:00.000Z"
}
}
validation_error — The body is empty or longer than 5,000 characters.not_found — No such email conversation in your workspace.GET /api/v1/webhooks
Returns your configured webhook endpoints (without secrets).
webhooks:read{
"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"
}
]
}
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.
webhooks:writeurl — type: string — required — format: uri — range: –500. HTTPS URL that receives POST deliveries.events — type: string[] — required — range: 1–. Event types to subscribe to (see Webhook Events).{
"url": "https://example.com/hooks/warmysender",
"events": [
"reply.received",
"email.bounced",
"linkedin.reply_received"
]
}
{
"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."
}
validation_error — Invalid URL or unknown event type.PATCH /api/v1/webhooks/{id}
Change a webhook's URL, subscribed events, or active flag.
webhooks:writeid — in: path — type: string — required. Webhook id.url — type: string — optional — format: uri — range: –500. New delivery URL.events — type: string[] — optional — range: 1–. Replacement list of event types.isActive — type: boolean — optional. Enable or disable deliveries.{
"isActive": false
}
{
"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"
}
}
validation_error — Body failed validation.not_found — Webhook not found.DELETE /api/v1/webhooks/{id}
Permanently removes a webhook endpoint. Returns 204 with no body.
webhooks:writeid — in: path — type: string — required. Webhook id.No response body.
not_found — Webhook not found.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.
webhooks:writeid — in: path — type: string — required. Webhook id.{
"data": {
"eventId": "evt_a1b2c3",
"status": "queued",
"message": "Test webhook event has been queued for delivery"
}
}
not_found — Webhook not found.GET /api/v1/linkedin/accounts
Returns connected LinkedIn accounts with their daily engagement limits and how many actions remain today.
linkedin:readlimit — in: query — type: integer — optional — default: 50 — range: 1–100. Results to return (1–100).{
"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
}
}
]
}
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.
linkedin:writepostId — in: path — type: string — required. LinkedIn post id (URN).linkedin_account_id — type: string — required. Which connected LinkedIn account reacts.reaction_type — type: string — optional — one of: LIKE, CELEBRATE, SUPPORT, LOVE, INSIGHTFUL, FUNNY — default: LIKE. Reaction (case-insensitive; defaults to LIKE).{
"linkedin_account_id": "li_5a2b1c",
"reaction_type": "LIKE"
}
{
"data": {
"post_id": "urn:li:activity:7300000000000000000",
"reaction_type": "LIKE",
"linkedin_account_id": "li_5a2b1c"
}
}
account_not_connected — The LinkedIn account isn't connected.account_unauthorized — The LinkedIn session needs re-authentication.daily_limit_reached — The account hit its daily engagement limit.GET /api/v1/linkedin/posts/{postId}/reactions
Lists who reacted to a post, as seen by one of your connected accounts. Cursor paginated.
linkedin:readpagination.next_cursor.postId — in: path — type: string — required. LinkedIn post id (URN).linkedin_account_id — in: query — type: string — required. Which connected account performs the read.limit — in: query — type: integer — optional — default: 20 — range: 1–100. Results per page (1–100).cursor — in: query — type: string — optional. next_cursor from the previous page.{
"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
}
}
account_not_connected — The LinkedIn account isn't connected.account_unauthorized — The LinkedIn session needs re-authentication.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.
verification:writeemail — type: string — required — format: email. The address to check.{
"email": "[email protected]"
}
{
"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"
}
}
verifier_access_required — Paying unblocks it: there is no plan on the account, or an invoice is unsettled, or the one free daily check is already used. Verification credits clear all three immediately and need no subscription — `details.reason` says which one it is.verifier_access_required — The plan on this account carries no monthly verification allowance (or the account is on hold). Credits still work without a plan — `details.can_buy_credits` says whether they can be bought.verification_rate_limited — More than 20 single checks in a minute for this workspace.allowance_exhausted — The plan allowance is used up and there are no credits to cover the check. Nothing was charged.verifier_access_required — We could not read this account's verification entitlement. This is NOT a refusal — retry shortly rather than treating the account as ineligible.verification_capacity_reserved — Checking capacity is reserved for paid work right now. Nothing was used up — a free check is given back — so retry shortly.verification_unavailable — Checking is temporarily unavailable. Nothing was charged.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. When the run finishes, the addresses that are ready for a campaign are saved as a list named "<run name> - Verified" — pass `create_list: false` with `emails` if you keep the results in your own system and do not want that list. A `list_id` run works exactly like Verify list in the app: contacts on that list whose address turns out invalid are marked invalid so campaigns stop emailing them, and the campaign-ready contacts are saved as "<list name> - Verified".
verification:writeemails — type: string[] — optional — range: 1–5000. Addresses to check (1–5,000). Duplicates are collapsed. Provide this OR list_id, not both.list_id — type: string — optional. Check every address in one of your prospect lists instead of listing them out. Contacts sourced from LinkedIn that have no real email address yet are skipped and never charged for — the response reports how many under `skipped_without_email`. When the run finishes, the list’s contacts with an invalid address are marked invalid so campaigns stop emailing them (a contact already marked bounced, unsubscribed or do-not-contact keeps that status). Provide this OR emails, not both.name — type: string — optional. Label for this run. Defaults to an auto-generated one.create_list — type: boolean — optional — default: true. Defaults to true: when the run finishes, the addresses that are ready for a campaign are saved as a new list named "<run name> - Verified" (adding contacts for any address you do not have yet). Set false to keep the results only on the run — nothing is added to your contacts or lists, and you read each address's result from the run itself. Only works with `emails`, not with `list_id`. You can still build the list later from the finished run if you change your mind.{
"emails": [
"[email protected]",
"[email protected]"
],
"name": "Q3 outbound list",
"create_list": false
}
{
"data": {
"id": "b1a2c3d4-...",
"status": "processing",
"continues_tomorrow": false,
"accepted": 2,
"submitted": 2,
"create_list": false
},
"meta": {
"request_id": "req_lz3k9a2b"
}
}
validation_error — The body is invalid: neither or both of `emails` and `list_id` were given, `create_list` is not true or false, or `create_list: false` was combined with `list_id` (it only works with `emails`). Nothing was charged.batch_too_large — More than 5,000 addresses were submitted in one run — including when a `list_id` resolves to more than that.verifier_access_required — Paying unblocks it: there is no plan on the account, or an invoice is unsettled, or the one free daily check is already used. Verification credits clear all three immediately and need no subscription — `details.reason` says which one it is.verifier_access_required — The plan on this account carries no monthly verification allowance (or the account is on hold). Credits still work without a plan — `details.can_buy_credits` says whether they can be bought.not_found — A `list_id` was given but no such prospect list exists in this workspace.no_verifiable_emails — The list exists but holds no address we can check — usually because every contact came from LinkedIn and has no real email address yet.verification_rate_limited — More than 5 runs started in a minute for this workspace.allowance_exhausted — The allowance and credit balance cover none of the list. Nothing was charged.exceeds_today — The list is larger than everything this workspace can check today (allowance left plus usable credits). Nothing was charged and no run was created — `details.admissible_today` says how many would start now.verifier_access_required — We could not read this account's verification entitlement. This is NOT a refusal — retry shortly rather than treating the account as ineligible.GET /api/v1/verification/batches
Lists this workspace's verification runs, newest first, with their progress and result counts.
verification:readpagination.next_cursor.limit — in: query — type: integer — optional — default: 25 — range: 1–100. Results per page (1–100).cursor — in: query — type: string — optional. next_cursor from the previous page.{
"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",
"create_list": true
}
],
"pagination": {
"has_more": false
},
"meta": {
"request_id": "req_lz3k9a2b"
}
}
verifier_access_required — This account is on hold, so its verification data cannot be read.verifier_access_required — We could not read this account's verification entitlement. This is NOT a refusal — retry shortly rather than treating the account as ineligible.GET /api/v1/verification/batches/{id}
Returns the progress and result counts for one verification run.
verification:readid — in: path — type: string — required. The run id.{
"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,
"create_list": true
},
"meta": {
"request_id": "req_lz3k9a2b"
}
}
not_found — No run with that id in this workspace.verifier_access_required — This account is on hold, so its verification data cannot be read.verifier_access_required — We could not read this account's verification entitlement. This is NOT a refusal — retry shortly rather than treating the account as ineligible.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.
verification:readid — in: path — type: string — required. The run id.status — in: query — type: string — optional — one of: pending, processing, valid, invalid, risky, unknown, error, cancelled. Return only addresses with this outcome. Omit for all.limit — in: query — type: integer — optional — default: 100 — range: 1–1000. Results per page (1–1,000).offset — in: query — type: integer — optional — default: 0 — range: 0–. Result offset for paging.{
"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"
}
}
validation_error — The `status` filter is not one of the listed outcomes.not_found — No run with that id in this workspace.verifier_access_required — This account is on hold, so its verification data cannot be read.verifier_access_required — We could not read this account's verification entitlement. This is NOT a refusal — retry shortly rather than treating the account as ineligible.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.
verification:writeid — in: path — type: string — required. The run id.{
"data": {
"id": "b1a2c3d4-...",
"status": "cancelled",
"cancelled": true,
"stopped_addresses": 1360,
"credits_refunded": 1360,
"credit_balance": 4360
},
"meta": {
"request_id": "req_lz3k9a2b"
}
}
not_found — No run with that id in this workspace.verifier_access_required — This account is on hold, so its verification data cannot be read.verifier_access_required — We could not read this account's verification entitlement. This is NOT a refusal — retry shortly rather than treating the account as ineligible.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.
verification:writeid — in: path — type: string — required. The run id.keep — type: string — optional — one of: campaign_ready, valid, risky — default: campaign_ready. Who to put in the list. campaign_ready (default) = confirmed plus catch-all. valid = confirmed only. risky = catch-all only.{
"keep": "valid"
}
{
"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"
}
}
invalid_keep — keep was not campaign_ready, valid, or risky.not_found — No run with that id in this workspace.batch_not_finished — The run is still going, or it did not finish successfully.verifier_access_required — This account is on hold, so its verification data cannot be read.verifier_access_required — We could not read this account's verification entitlement. This is NOT a refusal — retry shortly rather than treating the account as ineligible.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, any monthly add-on allowance (addon_monthly_allowance / addon_remaining — not paced daily), 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.
verification:read{
"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,
"addon_monthly_allowance": 0,
"addon_remaining": 0,
"free_checks_remaining_today": 0,
"can_buy_credits": true,
"verifiable_now": 5293,
"max_addresses_per_batch": 5000
},
"meta": {
"request_id": "req_lz3k9a2b"
}
}
verifier_access_required — This account is on hold, so its verification data cannot be read.verifier_access_required — We could not read this account's verification entitlement. This is NOT a refusal — retry shortly rather than treating the account as ineligible.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.receivedA prospect replied (email or LinkedIn). messageId is the Message-ID of the reply itself. For email, inReplyToMessageId is the messageId of the email it answered (the same value as in email.sent and GET /emails/sent), plus the stepIndex and stepNumber of that email; these three fields are left out when the reply does not name one of your emails. For email, bodyText is the text of the reply (up to 4,000 characters, with bodyTruncated telling you when it was cut) and fromEmail is the address it came from; GET /replies returns the same record for backfilling.
{
"prospectId": "f3c4a2d1-...",
"prospectEmail": "[email protected]",
"campaignId": "c1a2b3c4-...",
"mailboxId": "5bd50e20-...",
"subject": "Re: Quick question, Jane",
"messageId": "<[email protected]>",
"receivedAt": "2026-06-10T11:00:00.000Z",
"channel": "email",
"inReplyToMessageId": "<[email protected]>",
"stepIndex": 1,
"stepNumber": 2,
"fromEmail": "[email protected]",
"bodyText": "Hi Alex, yes, send me the details on Thursday.",
"bodyTruncated": false
}
email.sentA campaign email was accepted by your sending mailbox server. This is not yet a delivery confirmation: problems found later arrive as email.bounced. Warmup emails and test sends are not included. Same fields as GET /emails/sent.
{
"prospectId": "f3c4a2d1-...",
"prospectEmail": "[email protected]",
"campaignId": "c1a2b3c4-...",
"campaignName": "Q3 founders outreach",
"stepIndex": 1,
"stepNumber": 2,
"mailboxId": "5bd50e20-...",
"fromEmail": "[email protected]",
"subject": "Quick question, Jane",
"messageId": "<[email protected]>",
"sentAt": "2026-06-10T10:42:00.000Z",
"channel": "email"
}
email.bouncedAn 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.unsubscribedA prospect unsubscribed.
{
"prospectId": "f3c4a2d1-...",
"prospectEmail": "[email protected]",
"campaignId": "c1a2b3c4-...",
"unsubscribedAt": "2026-06-10T11:02:00.000Z"
}
email.openedA tracked email was opened.
{
"prospectId": "f3c4a2d1-...",
"prospectEmail": "[email protected]",
"campaignId": "c1a2b3c4-...",
"stepIndex": 0,
"openedAt": "2026-06-10T11:03:00.000Z"
}
email.clickedA tracked link was clicked.
{
"prospectId": "f3c4a2d1-...",
"prospectEmail": "[email protected]",
"campaignId": "c1a2b3c4-...",
"url": "https://yourdomain.com/demo",
"clickedAt": "2026-06-10T11:04:00.000Z"
}
prospect.suppressedA prospect was added to the suppression list.
{
"prospectId": "f3c4a2d1-...",
"email": "[email protected]",
"reason": "manual opt-out",
"suppressedAt": "2026-06-10T11:05:00.000Z"
}
limit.hitA 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_acceptedA LinkedIn connection invite was accepted. Sent once per person per campaign. lateAccept is true when the person accepted after their last step in the campaign had already run (for example on an invite-only campaign, or after the wait-for-acceptance window); lateAcceptObservedAt is when we saw it. Nothing extra is sent on LinkedIn because of this event.
{
"prospectName": "Sam Rivera",
"prospectLinkedinUrl": "https://www.linkedin.com/in/samrivera",
"campaignId": "c1a2b3c4-...",
"enrollmentId": "enr_...",
"linkedinAccountId": "li_5a2b1c",
"acceptedAt": "2026-06-10T11:07:00.000Z",
"lateAccept": false,
"lateAcceptObservedAt": null
}
linkedin.reply_receivedA 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_receivedAn 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_receivedA 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.testA 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"
}
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).
missing_idempotency_key — An Idempotency-Key header is required for this request.validation_error — The request body or parameters failed validation.unauthorized — Missing or invalid API key.insufficient_scope — The API key lacks the scope this endpoint requires.tier_not_eligible — The plan on this account does not include the v1 API, so the request was refused before it reached the endpoint. One exception: an account holding purchased verification credits can call /verification/* without any plan.not_found — The resource does not exist (or you have no access to it).idempotency_conflict — The Idempotency-Key was reused with a different request body.ssrf_blocked — A supplied host resolved to a private/internal IP and was blocked.rate_limited — Too many requests — slow down and retry after the Retry-After delay.internal_error — An unexpected server-side error.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.
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.