# Raw for AI (/raw-api/ai) Give a model the Raw API in one read: its guides, then every endpoint and webhook event with their fields, as Markdown. ``` https://developer.swarmhit.com/raw-api/llms.txt ``` ## Use it [#use-it] * **Chat assistants** (Claude, ChatGPT): paste the link, or download the file and attach it, then ask for what you want to build: *"Using the Swarmhit Raw API in this file, connect a LinkedIn account and handle its checkpoints."* * **Coding assistants** (Cursor, Claude Code, Copilot): add the link as documentation (for example `@docs` in Cursor), so their suggestions use real endpoints and fields. * **One page at a time**: add `.md` to any page's address for its Markdown, for example [`/raw-api/webhooks.md`](/raw-api/webhooks.md). Every page also has a *Copy Markdown* button. ## Other formats [#other-formats] * The reference alone, compact, as the API publishes it: [`https://console.swarmhit.com/llms.txt`](https://console.swarmhit.com/llms.txt). * The OpenAPI document: [`https://console.swarmhit.com/openapi.yaml`](https://console.swarmhit.com/openapi.yaml). * Every page of this site, Smart and Raw: [`/llms-full.txt`](/llms-full.txt). # Authentication (/raw-api/authentication) Every call carries an API key, in either header: ``` X-API-KEY: gw_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx Authorization: Bearer gw_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` ## Creating a key [#creating-a-key] Keys are made in the [console](https://console.swarmhit.com), on the **API keys** page, where you sign in with your Swarmhit account. A key's full value is shown once, when it is made; it can expire on a date you choose and be revoked at any time. Give each service of yours its own key, so one can be revoked alone. | Key | Starts with | Reaches | | ------- | ----------- | -------------------------------------------------------- | | Live | `gw_live_` | Your real accounts. Billed. | | Sandbox | `gw_test_` | Sandbox accounts only, played by Swarmhit. Never billed. | A sandbox key never sees a live account and a live key never sees a sandbox one. See [Sandbox](/raw-api/sandbox). ## What a key reaches [#what-a-key-reaches] * **Its services.** A key is made for some channels (LinkedIn, WhatsApp, email); a route of another one answers `403 errors/forbidden`. * **Your accounts only.** An account, chat, message or attendee of anyone else answers `404`, exactly as one that does not exist, wherever the call names it (path, `account_id` in the query or the body, a multipart field). Lists across accounts list yours only. ## When a key is refused [#when-a-key-is-refused] | Answer | When | | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `401 errors/unauthorized` | The key is missing, wrong, revoked or expired. Nothing is done. | | `402 errors/payment_required` `payment-method-required` | Your plan costs money and no valid card is on file: live keys are refused until you add one on the console's **Billing** page. Sandbox keys keep working. | | `402 errors/payment_required` `payment-failed` | The charge for a new account was declined: the account was not created. Check the card, then retry. | | `403 errors/client_suspended` | Your company's access is suspended: every call is refused and your accounts are stopped until it is active again. Your accounts resume within minutes after that; one you paused stays paused. | Each billing cycle is charged in advance at its start, so the first account you create is paid for before it is created. Only `/health`, `/whatsapp/health`, `/email/health` and the live view (`/li-live/`, guarded by its single-use token) need no key. # Email (/raw-api/email) Mailboxes (IMAP/SMTP, Gmail, Microsoft 365) live under `/email/api/v1`. ## Connecting a mailbox [#connecting-a-mailbox] [`POST /email/api/v1/accounts`](/raw-api/reference/email-accounts/connectEmailAccount) takes: * `provider: MAIL` with the IMAP and SMTP servers and passwords. They are checked before anything is created: a refused login is `401 errors/invalid_credentials`. [`GET …/accounts/autoconfig`](/raw-api/reference/email-accounts/emailAutoconfig) finds the servers for an address, and [`POST …/accounts/verify`](/raw-api/reference/email-accounts/verifyEmailAccount) checks them without creating anything. * `GOOGLE_OAUTH` or `OUTLOOK` with your OAuth app's tokens: register the app once with [`POST /email/api/v1/oauth_apps`](/raw-api/reference/email-oauth-apps/createEmailOauthApp), then pass its `oauth_app_id`. Every host you name must be public. The answer is `201`; `CREATION_SUCCESS` follows when the mailbox is connected. Events start from then: no history is replayed. Connects, reconnects and login checks together are limited to 120 per 10 minutes for your company (`429`, with `retry_after_ms`). ## Sending [#sending] [`POST /email/api/v1/emails`](/raw-api/reference/email-messages/sendEmail) sends, as JSON or multipart (attachments as file parts): at most 500 recipients and 25 MB of attachments. The answer is `201`: the mail is queued, and `mail_sent` follows when it has left. * **`Idempotency-Key`** header: a retry with the same key answers the first send instead of sending again (keys are per mailbox). * **`from`** may set a display name; its address must be the mailbox's own (`422 errors/send_as_denied` otherwise). * **`reply_to`** is the email (`emm_…` or provider id) this one answers: it threads the reply. It is not a Reply-To header (that one goes in `custom_headers`). * **`tracking_id`** (the Message-ID without `<>`) ties the send to its `mail_sent` event, its opens and clicks, and its bounces (`gateway.mail_bounced`), complaints (`gateway.mail_complaint`) and failures (`gateway.mail_failed`). ## Reading [#reading] [`GET /email/api/v1/emails`](/raw-api/reference/email-messages/listEmails) lists one folder at a time, newest first, metadata only (`include_body=true` adds the bodies, 25 a page at most), and [`GET /email/api/v1/emails/{id}`](/raw-api/reference/email-messages/getEmail) reads one in full. Replies arrive as `mail_received` events. An email id (`emm_…`) and a folder id (`emf_…`) carry their mailbox; a provider's own ids (`provider_id`) are accepted with `account_id`. An IMAP mail's `provider_id` changes when it moves folder. # Errors (/raw-api/errors) Every error has the same body: ```json { "status": 429, "type": "errors/too_many_requests", "code": "safety-floor", "title": "Daily safety limit reached for invite.", "detail": "Daily safety limit reached for invite.", "retry_after_ms": 43200000 } ``` * `status` and `type` are the contract: **branch on them**. * `code` is Swarmhit's finer reason, stable, for logs and support. * `title` and `detail` are one sentence a person can read, safe to show. * Some errors add a field: `retry_after_ms` (on a 429: wait at least this long), `replied_at` (`recipient-replied`), `ends_at` (`whatsapp-new-chats-blocked`). **Retry rule of thumb:** retry `429` (after `retry_after_ms`), `502` and `503`; never retry `400`, `401`, `403`, `404`, `409` or `422` unchanged. ## The errors [#the-errors] | HTTP | `type` | `code` | When | | ---------- | ------------------------------------ | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | 400 | `errors/invalid_parameters` | `bad-request` | A body or query fails its schema, a required field is missing, the JSON does not parse. | | 401 | `errors/unauthorized` | `unauthorized` | Missing, wrong, revoked or expired API key. | | 401 | `errors/invalid_credentials` | `bad-credentials` | LinkedIn refused the email and password (or the TOTP secret), or a mailbox refused its login on a connect. | | 401 | `errors/invalid_checkpoint_solution` | `code-rejected` | LinkedIn did not accept the code. The sign-in still waits: send the right code, or ask for a new one. | | 401 | `errors/disconnected_account` | `no-session`, `session-expired` | The account has no working session (never signed in, LinkedIn signed it out, a WhatsApp number no longer linked). Reconnect it. | | 402 | `errors/payment_required` | `payment-method-required`, `payment-failed` | No valid card on file for live keys, or the charge for a new account was declined (see [Authentication](/raw-api/authentication#when-a-key-is-refused)). | | 403 | `errors/forbidden` | `forbidden` | The key was not made for this channel. | | 403 | `errors/account_restricted` | `restricted` | LinkedIn restricted the account. A person must look before any retry. | | 403 | `errors/account_restricted` | `whatsapp-new-chats-blocked` | WhatsApp blocks this number from starting chats until `ends_at`; existing chats still work. | | 403 | `errors/client_suspended` | `client-suspended` | Your company's access is suspended. | | 403 | `errors/insufficient_permissions` | `whatsapp-refused` | WhatsApp refused the action (not a group admin, not your message). | | 404 | `errors/resource_not_found` | `not-found`, `account-not-found`, `profile-not-found` | No such route, account, chat, message, invitation or profile (or not yours). | | 408 | `errors/request_timeout` | `sign-in-timeout`, `connect-timeout` | The sign-in, or LinkedIn's answer to a cookie connect, took too long. Try again. | | 408 | `errors/checkpoint_expired` | `no-attempt` | No sign-in is waiting for a code or a live view any more. Start the connect again. | | 409 | `errors/sign_in_in_progress` | `sign-in-in-progress` | Another sign-in for this account is running. | | 409 | `errors/account_paused` | `paused` | The account is paused. Resume it first. | | 409 | `errors/identity_mismatch` | `identity-mismatch` | The session belongs to another LinkedIn member than this account's. | | 409 | `errors/recipient_replied` | `recipient-replied` | `unless_replied_since` was set and the recipient wrote after it: nothing was sent. `replied_at` says when. | | 413 | `errors/content_too_large` | `bad-request` | The body is over the size limit. | | 422 | `errors/not_supported` | `not-supported` | Swarmhit does not offer this (a passthrough write, a hosted connection link, a feature switched off, some Sales Navigator InMail actions). | | 422 | `errors/unsupported` | `not-supported` | A Sales Navigator search URL or filter Swarmhit cannot run. | | 422 | `errors/no_browser` | `not-supported` | The action needs a browser and this account (connected by cookie) has none. | | 422 | `errors/feature_not_subscribed` | `sales-seat-required` | The member has no Premium or Sales Navigator seat (InMail). | | 422 | `errors/insufficient_credits` | `linkedin-error` | No InMail credits left. | | 422 | `errors/cannot_resend_yet` | `invite-limit`, `already-invited` | LinkedIn's invitation limit is reached, or it will not let you invite this person again yet. Cool the account down; do not retry. | | 422 | `errors/already_invited_recently` | `already-invited` | An invitation to this person is already pending. Treat it as sent. | | 422 | `errors/too_many_characters` | `bad-request` | An invitation note over 300 characters (or over LinkedIn's limit for this member). | | 422 | `errors/invalid_recipient` | `invalid-recipient`, `not-on-whatsapp`, `not-supported` | This person cannot be messaged from this account, or the number is not on WhatsApp. | | 422 | `errors/limit_exceeded` | `whatsapp-quota-reached` | WhatsApp's new-chat quota for this number is spent for its cycle. | | 422 | `errors/proxy_error` | `no-ip-in-country` | No IP is available in the account's country. Pick a nearby country. | | 429 | `errors/too_many_requests` | `rate-limited` | LinkedIn is limiting the account (its 429 or 999): nothing reaches LinkedIn until `retry_after_ms` has passed. | | 429 | `errors/too_many_requests` | `safety-floor` | A daily safety cap is reached (see [Write safety](/raw-api/write-safety)). | | 429 | `errors/too_many_requests` | `account-busy` | Another write, a sign-in, the minimum gap between writes, or a send with the same `client_id` in progress. | | 429 | `errors/too_many_requests` | `sign-in-throttled` | Too many sign-ins for this account lately. | | 500 | `errors/unexpected_error` | `internal` | A bug on our side, reported automatically. | | 502 | `errors/unexpected_error` | `linkedin-error`, `linkedin-endpoint-changed`, `sign-in-failed` | LinkedIn could not be reached or answered oddly, or the sign-in broke. Retry later. | | 502 | `errors/attachment_upload_failed` | `linkedin-error` | An attachment could not be uploaded to LinkedIn; nothing was sent. | | 502 | `errors/provider_unavailable` | `browser-error` | The account's browser could not start. | | 502 | `errors/provider_error` | `whatsapp-error` | WhatsApp failed the call. Retry later. | | 503 | `errors/proxy_error` | `proxy-drift`, `proxy-down` | The account's exit IP left its country or does not answer. The account is held until it is back; do not reconnect it for this. | | 503 | `errors/no_client_session` | `whatsapp-connecting` | The number is between two connections. Retry in a moment. | | 503 | `errors/provider_unavailable` | `proxy-provider-unavailable` | A service Swarmhit depends on is down or asked us to slow down. | | 503 | `errors/service_unavailable` | `browser-busy`, `linkedin-error` | Every sign-in browser is busy, the Sales Navigator session is being renewed, or an upload took too long. Retry shortly. | | LinkedIn's | `errors/unexpected_error` | `linkedin-error` | LinkedIn answered an error Swarmhit has no better row for: passed through with LinkedIn's own status. | ### Email [#email] | HTTP | `type` | When | | ---- | ---------------------------------- | ----------------------------------------------------------------------------------------------- | | 400 | `errors/invalid_headers` | A `custom_headers` entry that is not allowed, or a line break in one. | | 401 | `errors/disconnected_account` | The mailbox refused Swarmhit's login: reconnect it. | | 422 | `errors/cant_send_message` | The mail server refused the message. | | 422 | `errors/send_as_denied` | `from` is not the mailbox's own address. | | 422 | `errors/parent_mail_not_found` | `reply_to` names no mail of this mailbox. | | 422 | `errors/not_supported_by_provider` | The mailbox does not support this (for example the address book). | | 429 | `errors/too_many_requests` | Connects and login checks are over 120 per 10 minutes, or the mail provider asked to slow down. | | 502 | `errors/provider_error` | The mail service failed the call. Retry later. | | 503 | `errors/provider_unavailable` | The mail service is unavailable for a moment. Retry shortly. | The [API reference](/raw-api/reference) lists, on each endpoint, the errors it can answer. Every `429` is answered **before** anything reaches LinkedIn or WhatsApp, so retrying it can never send twice. # Getting started (/raw-api) The Raw API runs your LinkedIn accounts, WhatsApp numbers and mailboxes and gives you direct access to them: connect an account, read its conversations, send messages and invitations, react, search, and receive every event as a signed webhook. The same paths, request bodies, response fields, error envelope and events work on every channel. **You decide, Swarmhit executes.** Pacing, daily limits, warm-up and schedules belong to your application. Swarmhit keeps each account signed in, on its own device and IP, and only refuses what a runaway client would do (see [Write safety](/raw-api/write-safety)). ## Smart API or Raw API? [#smart-api-or-raw-api] | | [Smart API](/smart-api) | Raw API | | ---------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------- | | What it is | Swarmhit's outreach engine: campaigns, sequences, inbox, leads, database | Direct access to the accounts: one call, one action | | Who paces | Swarmhit (warm-up, limits, schedules) | Your application | | Channels | LinkedIn and email | LinkedIn, WhatsApp and email | | Base URL | `https://app.swarmhit.com/api/v1` | `https://console.swarmhit.com` | | Keys | `swh_live_…`, in Swarmhit | `gw_live_…` and `gw_test_…`, in the [Raw API console](https://console.swarmhit.com) | | SDK | [JavaScript SDK](/smart-api/sdk) | Plain HTTP | One Swarmhit login opens both; each has its own plan. ## Base URL and channels [#base-url-and-channels] ``` https://console.swarmhit.com ``` | Channel | Base path | Ids | | -------- | ------------------ | -------------------------------------------------------------------- | | LinkedIn | `/api/v1` | accounts `lg_…`, chats `lgc_…`, messages `lgm_…` | | WhatsApp | `/whatsapp/api/v1` | accounts `wa_…`, chats `wac_…`, messages `wam_…`, attendees `waa_…` | | Email | `/email/api/v1` | accounts `em_…`, emails `emm_…`, folders `emf_…`, OAuth apps `emo_…` | ## Your first requests [#your-first-requests] Create an API key in the [console](https://console.swarmhit.com) (*API keys*). Start with a **sandbox** key (`gw_test_…`): the same API, with LinkedIn, WhatsApp and the mail servers played by Swarmhit, so you can build without a real account (see [Sandbox](/raw-api/sandbox)). Connect a LinkedIn account with its email and password: ```bash curl -X POST https://console.swarmhit.com/api/v1/accounts \ -H "X-API-KEY: gw_test_your_key_here" -H "Content-Type: application/json" \ -d '{"provider":"LINKEDIN","username":"jane.doe@example.com","password":"…","country":"FR"}' ``` The answer is `201` (signed in, with its `account_id`), `202` with a checkpoint (LinkedIn wants a code or a CAPTCHA, see [LinkedIn](/raw-api/linkedin)), or an error. Then send a message: ```bash curl -X POST https://console.swarmhit.com/api/v1/chats \ -H "X-API-KEY: gw_test_your_key_here" -H "Content-Type: application/json" \ -d '{"account_id":"lg_3f9c2a7b1d","attendees_ids":["ACoAAB1a2b3c4d5e6f"],"text":"Hello Jane"}' ``` Link a WhatsApp number (the answer carries a QR code to scan with the phone): ```bash curl -X POST https://console.swarmhit.com/whatsapp/api/v1/accounts \ -H "X-API-KEY: gw_test_your_key_here" -H "Content-Type: application/json" \ -d '{"provider":"WHATSAPP","country":"FR"}' ``` ## Requests and responses [#requests-and-responses] * JSON in and out (`Content-Type: application/json`). Sends with files are `multipart/form-data`: bodies up to 30 MB, a file up to 20 MB, 10 files a request. * Validation is loose on purpose: unknown fields are ignored, optional fields may be `null`, and scalars are coerced (a code sent as a number arrives as a string). * Dates are ISO 8601 strings in UTC, except where a field says milliseconds. * `X-Request-Id`: send your own (8 to 64 characters of `A-Z a-z 0-9 _ -`) or Swarmhit makes one; it comes back in the `x-request-id` header. Quote it when you report a failure. ## Next steps [#next-steps] # LinkedIn (/raw-api/linkedin) Each LinkedIn account runs on its own browser identity and its own sticky IP, in the country you choose. Swarmhit keeps it signed in and tells you when it needs you. ## Connecting an account [#connecting-an-account] [`POST /api/v1/accounts`](/raw-api/reference/linkedin-accounts/linkedinConnectAccount) takes one of two kinds of connection: * **Credentials**: `username` and `password`, and optionally `totp_secret` so Swarmhit answers an authenticator 2FA by itself. * **Cookie**: `access_token`, the member's `li_at` cookie (optionally `jsessionid`, `user_agent`, `browser`, `cookies`). A cookie connect answers `201` or an error, never a checkpoint. ```bash curl -X POST https://console.swarmhit.com/api/v1/accounts \ -H "X-API-KEY: gw_live_…" -H "Content-Type: application/json" \ -d '{"provider":"LINKEDIN","username":"jane.doe@example.com","password":"…","country":"FR"}' ``` A credentials connect signs in the way the member would, which can take up to about 150 seconds (give the call a long timeout), and answers one of: 1. **`201 AccountCreated`**: signed in. Store `account_id`. 2. **`202 Checkpoint`**: LinkedIn asks for something; `checkpoint.type` says what. 3. **`202 AccountQueued`**: the result arrives as events (`AccountStatus` `OK`, `gateway.checkpoint` or `gateway.sign_in_failed`). This happens only when Swarmhit queues the sign-in, or for a connect that sent `"background": true` (where Swarmhit enables it) and needs no typed code. A refused connect (wrong password, throttled, no IP) keeps nothing: retrying it reuses the same device and IP. ### Checkpoints [#checkpoints] | `checkpoint.type` | What to do | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `OTP`, `2FA`, `PHONE_REGISTER` | Send the code (or phone number) with [`POST /api/v1/accounts/checkpoint`](/raw-api/reference/linkedin-accounts/solveLinkedinCheckpoint) `{ "account_id", "code" }`. The answer is again `201`, `202` (LinkedIn chains steps) or an error. [`…/checkpoint/resend`](/raw-api/reference/linkedin-accounts/resendLinkedinCheckpoint) asks for a new code. | | `IN_APP_VALIDATION` | The member approves the sign-in in their LinkedIn app. Poll [`GET /api/v1/accounts/{id}`](/raw-api/reference/linkedin-accounts/getLinkedinAccount), or wait for the `OK` account status event. | | `CAPTCHA`, `UNKNOWN` | A person has to look: use the live view below. | ### The live view [#the-live-view] When a person has to look, the answer carries a **live view**: `{ token, expires_at }`. Open the websocket `wss://console.swarmhit.com/li-live/`. It streams the sign-in's browser as JPEG frames (`{ type: 'frame', data, meta }`, then `{ type: 'state', state: 'live' | 'finishing' | 'ended' }`) and takes the person's input (`{ type: 'mouse', action, x, y }`, `wheel`, `key`, `text`). The token is single use and lasts 10 minutes; [`POST /api/v1/accounts/{id}/live-view`](/raw-api/reference/linkedin-accounts/mintLinkedinLiveView) mints a new one. When the person is through, the account becomes `OK`, or the next checkpoint arrives as a `gateway.checkpoint` event. ## Keeping it signed in [#keeping-it-signed-in] [`GET /api/v1/accounts/{id}`](/raw-api/reference/linkedin-accounts/getLinkedinAccount) shows where an account is at any time: `status` (`OK`, `CREDENTIALS`, `CONNECTING`, `STOPPED`) and, under `gateway`, Swarmhit's own finer status and checkpoint. Branch on `status`. * **LinkedIn ended the session**: the account turns `CREDENTIALS`. Reconnect it with [`POST /api/v1/accounts/{id}`](/raw-api/reference/linkedin-accounts/linkedinReconnectAccount): same id, same device and IP. * **Pause and resume** an account with [`…/pause`](/raw-api/reference/linkedin-accounts/pauseLinkedinAccount) and [`…/resume`](/raw-api/reference/linkedin-accounts/resumeLinkedinAccount): a paused account does nothing, and calls that act answer `409 paused`. * **Placement**: the account's IP is in the `country` you sent (ISO code), else the country of the `ip` you sent, else of your server's address, else `US`. It keeps that IP for as long as it can; [`…/recover`](/raw-api/reference/linkedin-accounts/recoverLinkedinAccount) picks a new one in the same country when its exit stopped it. ## What you can do with it [#what-you-can-do-with-it] | Area | Start with | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Conversations | [List chats](/raw-api/reference/linkedin-messaging/listLinkedinChats), [start a chat](/raw-api/reference/linkedin-messaging/startLinkedinChat), [send a message](/raw-api/reference/linkedin-messaging/sendLinkedinMessage), InMail and Sales Navigator threads | | People | [Read a profile](/raw-api/reference/linkedin-users-and-invitations/getLinkedinProfile), [invite](/raw-api/reference/linkedin-users-and-invitations/sendLinkedinInvitation), [connections](/raw-api/reference/linkedin-users-and-invitations/listLinkedinRelations), sent and received invitations | | Posts | [A member's posts](/raw-api/reference/linkedin-posts/listLinkedinPosts), [react](/raw-api/reference/linkedin-posts/reactToLinkedinPost), [comment](/raw-api/reference/linkedin-posts/commentOnLinkedinPost), [publish](/raw-api/reference/linkedin-posts/createLinkedinPost) | | Search | [People and Sales Navigator search](/raw-api/reference/linkedin-search/searchLinkedin), [companies](/raw-api/reference/linkedin-search/searchLinkedinCompanies), [InMail balance](/raw-api/reference/linkedin-search/getLinkedinInmailBalance) | Use `unless_replied_since` on every automated follow-up: if the recipient wrote after that time, nothing is sent and the call answers `409 recipient-replied`. # Pagination (/raw-api/pagination) Lists answer `{ object, items, cursor }`. Pass `cursor` back as given to get the next page; `null` means there is nothing more. Treat it as opaque: its form differs by route. | Lists | Order and size | | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | WhatsApp lists | Newest first; `limit` 1 to 250 (default 100); `before` and `after` (ISO dates) to window them. | | LinkedIn chats and messages | Newest first; `limit` (default 20), read from LinkedIn in pages of 20 the way the member's inbox scrolls, at most 10 pages a call. | | LinkedIn invitations, reactions and comments | `limit` up to 100 (invitations) or 50. | | LinkedIn search | One page of results a call; `cursor` for the next. | | LinkedIn connections | Newest first; `limit` up to 40 (default 40); `cursor` for the next page (some accounts can page through their newest 200 only). | | LinkedIn posts | One page; `cursor` is always `null`. | | Email lists | Newest first, one folder at a time; `limit` 1 to 250 (default 100); `cursor` keeps the folder. | # Sandbox (/raw-api/sandbox) Make a **sandbox key** (`gw_test_…`) in the [console](https://console.swarmhit.com). Every call goes through the same routes, checks, limits and events as live; only LinkedIn, WhatsApp and the mail servers are played by Swarmhit. * Sandbox accounts are apart from live ones: a sandbox key sees only sandbox accounts and a live key never sees them. They are never billed. * Their events reach your **sandbox webhook**, set apart from the live one, each with `livemode: false`. ## LinkedIn [#linkedin] Connect with any email and password and the account is connected at once, with a small world of fake people, conversations, invitations and posts that your calls change (a message you send is in the conversation; an invitation is in the sent list). Test credentials, like test cards: | Use | And get | | ----------------------------------- | -------------------------------------------------------------------- | | Any email and password | Connected at once | | Password `checkpoint` | An OTP checkpoint: answer it with `123456` (any other code is wrong) | | Password `wrong` | Refused as bad credentials | | An email starting with `restricted` | Refused as restricted (`403 restricted`) | ## WhatsApp [#whatsapp] Link a number and it shows a QR, then links itself a few seconds later. Every number is on WhatsApp, and what you send is delivered, then read. ## Email [#email] Connect a mailbox with any servers; a password (or OAuth token) of `wrong` is refused as bad credentials. ## What they would do on their own [#what-they-would-do-on-their-own] The *Sandbox* operations play what the other side would do (a reply, an accepted invitation, a new mail): [LinkedIn](/raw-api/reference/sandbox/simulateLinkedin), [WhatsApp](/raw-api/reference/sandbox/simulateWhatsappMessage), [email](/raw-api/reference/sandbox/simulateEmail). The events reach your sandbox webhook. # Versioning (/raw-api/versioning) * **Additive changes only.** New fields, routes and events may appear at any time; an existing field, route, error `status` or `type`, or event payload is never renamed or removed. Ignore what you do not know. * **Loose validation.** Unknown request fields are ignored, optional ones may be `null`, and scalars are coerced. * **Branch on `status` and `type`.** An error's `status` and `type` and an account's `status` are the contract. `code`, the other error fields and the `gateway` object on accounts and sends are finer detail, for logs, support and display. * WhatsApp objects carry their flags as the numbers `0` and `1`. * **No webhook management API.** The webhook URL and its secret are set in the console, and events are signed (see [Webhooks](/raw-api/webhooks)). * **No hosted connection page.** The hosted-link routes answer `422`; connect with the accounts routes, and the [live view](/raw-api/linkedin#the-live-view) covers CAPTCHAs. * The raw LinkedIn passthrough (`POST /api/v1/linkedin`) reads anything but writes only what is allowed. * Where LinkedIn's data is passed through loosely (search filters, some rows), the reference says so instead of promising a schema. # Webhooks (/raw-api/webhooks) Swarmhit POSTs what happens on your accounts (new messages, accepted invitations, status changes, sends, opens, bounces) to **your webhook URL**. Set it in the [console](https://console.swarmhit.com) on the **Webhook** page: an `https://` address on the public internet, with its own signing secret (`whsec_…`, shown once; rotate it there). The sandbox has its own webhook, set apart from the live one. With no webhook on, your events are not kept for later. *Send a test event* in the console sends a signed `webhook.test`. ## The events [#the-events] Every body carries `event_id`, `emitted_at` and `livemode` (`false` for a sandbox account's events). An account status change comes as `{ "AccountStatus": { account_id, message, … } }`; every other event names itself in `event`, with its `account_id` (the id's prefix, `lg_`, `wa_` or `em_`, says the channel). | Event | When | | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `AccountStatus` | An account's status changed: `OK`, `CREDENTIALS` (reconnect it), `STOPPED` with a `reason`, `DELETED`; WhatsApp numbers and mailboxes also send `CREATION_SUCCESS`, `RECONNECTED`, `CONNECTING`, `ERROR` (and `SYNC_SUCCESS` for WhatsApp). | | `message_received` | A new message in a LinkedIn conversation or a WhatsApp chat, the account's own included (`is_sender`). | | `new_relation` | A LinkedIn member accepted the account's invitation. | | `invitation_received` | Someone invited the LinkedIn account to connect. | | `message_delivered`, `message_read`, `message_edited`, `message_deleted`, `message_reaction` | A WhatsApp message changed, or a reaction was added or taken back. | | `mail_received`, `mail_sent` | A mail arrived in a mailbox's inbox, or a mail sent through the API has left. | | `mail_opened`, `mail_link_clicked` | A tracked mail was opened, or one of its links clicked. | The `gateway.*` events are Swarmhit's own additions, more detail you can ignore unless you need it, with a few you will want: `gateway.checkpoint` and `gateway.sign_in_failed` (the outcome of a LinkedIn sign-in after its call returned), and `gateway.mail_bounced`, `gateway.mail_complaint` and `gateway.mail_failed` (the fate of a sent mail). The [API reference](/raw-api/reference) lists every event with its fields. ## Verifying a delivery [#verifying-a-delivery] Each request carries two headers: * `X-LG-Signature`: `base64url(HMAC-SHA256(raw body, your signing secret))`. * `X-LG-Event-Id`: the event's id (also `event_id` in the body). Verify the signature over the **raw bytes**, before parsing: ```js import crypto from 'node:crypto'; import express from 'express'; const app = express(); app.post('/swarmhit/events', express.raw({ type: 'application/json' }), (req, res) => { const expected = crypto .createHmac('sha256', process.env.SWARMHIT_WEBHOOK_SECRET) // your whsec_… .update(req.body) .digest('base64url'); const given = String(req.get('x-lg-signature') || ''); const valid = given.length === expected.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected)); if (!valid) return res.sendStatus(401); const event = JSON.parse(req.body.toString('utf8')); res.sendStatus(200); // answer first, then handle it, once per event.event_id handleOnce(event.event_id, event); }); ``` ## Delivery [#delivery] Delivery is **at least once**. Events are written first, then sent. An answer other than 2xx within 15 seconds is retried after 1 min, 5 min, 15 min, 1 h, 6 h, 12 h, 24 h and 24 h (about 3 days), then given up. When your endpoint answers again after an outage, everything waiting for it is sent at once. Redirects are not followed. * **Deduplicate** on the event id: a retry keeps its id, and a message seen twice by Swarmhit is still one event with one id. * **Answer 2xx fast** and do the work after. * **Do not rely on order.** For an account's status, keep the one with the newest `emitted_at`. # WhatsApp (/raw-api/whatsapp) A number is linked as a **linked device** of the phone, like WhatsApp Web. Everything lives under `/whatsapp/api/v1`. ## Linking a number [#linking-a-number] 1. [`POST /whatsapp/api/v1/accounts`](/raw-api/reference/whatsapp-accounts/linkWhatsappAccount) with `country`, the ISO code of the number's country (its IP is placed there; required unless an `ip` locates it). The answer is `201` with a checkpoint of type `QRCODE`, the QR's text in `checkpoint.qrcode`: render it as a QR image for the person to scan. It changes about every 20 seconds: read the current one from [`GET /whatsapp/api/v1/accounts/{id}`](/raw-api/reference/whatsapp-accounts/getWhatsappAccount) (`gateway.checkpoint`). 2. Or send `pairing_phone_number` (international format) and get a `PAIRING_CODE` checkpoint whose `code` the person types on the phone (*Linked devices → Link with phone number*). 3. When the phone accepts, the account becomes `OK` and the events `CREATION_SUCCESS` then `OK` arrive; `SYNC_SUCCESS` follows when the first history sync is done. ```bash curl -X POST https://console.swarmhit.com/whatsapp/api/v1/accounts \ -H "X-API-KEY: gw_live_…" -H "Content-Type: application/json" \ -d '{"provider":"WHATSAPP","country":"FR"}' ``` There is no code to send back: the checkpoint routes answer `422` for WhatsApp. ## Reconnecting and removing [#reconnecting-and-removing] * [`POST /whatsapp/api/v1/accounts/{id}`](/raw-api/reference/whatsapp-accounts/reconnectWhatsappAccount) reconnects a number that is still linked on its existing login; only a number whose link is gone gets a new QR. * [`DELETE`](/raw-api/reference/whatsapp-accounts/deleteWhatsappAccount) also unlinks the device on the phone, unless `provider_session_revoke=false`. ## Reading and sending [#reading-and-sending] Reads (chats, messages, attendees) answer from Swarmhit's copy, even while a number reconnects. Anything that reaches WhatsApp needs it connected and otherwise answers `503 whatsapp-connecting`: retry in a moment. Chat and attendee routes accept Swarmhit's ids, or a WhatsApp JID or phone number with `account_id`. | Area | Start with | | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Chats | [List chats](/raw-api/reference/whatsapp-chats/listWhatsappChats), [start a chat](/raw-api/reference/whatsapp-chats/startWhatsappChat), [send a message](/raw-api/reference/whatsapp-chats/sendWhatsappMessage) | | Messages | [All messages](/raw-api/reference/whatsapp-messages/listWhatsappMessages), [forward](/raw-api/reference/whatsapp-messages/forwardWhatsappMessage), [react](/raw-api/reference/whatsapp-messages/reactToWhatsappMessage) | | People | [Attendees](/raw-api/reference/whatsapp-attendees/listWhatsappAttendees), [is this number on WhatsApp?](/raw-api/reference/whatsapp-users/getWhatsappUser) | Send `client_id`, your own id for a send: a retry with the same id within 24 hours returns the first answer instead of sending again. ## WhatsApp's own limits [#whatsapps-own-limits] WhatsApp limits new chats per number. Its answers come back as `403 whatsapp-new-chats-blocked` (until `ends_at`; existing chats still work) and `422 whatsapp-quota-reached`. Swarmhit's own caps are in [Write safety](/raw-api/write-safety). # Write safety (/raw-api/write-safety) Your application paces: warm-up, daily limits and schedules are yours. Swarmhit only refuses what a runaway client would do. Every refusal happens **before** anything reaches LinkedIn or WhatsApp, so retrying it can never send twice. ## LinkedIn, per account [#linkedin-per-account] * **One write at a time.** A write waits at most 15 seconds for the account, then answers `429 account-busy`. * **A minimum gap between two writes**, 20 seconds. A write too soon answers `429 account-busy` at once, with `retry_after_ms`; the gap counts from the last attempt, accepted or not. * **Daily caps per kind** (UTC day): invitations 100, messages 300, InMail 100, reactions 100, comments 100, follows 100, withdrawals 300, accepts 100, profile visits 300. Over a cap: `429 safety-floor`, with `retry_after_ms` until the next UTC midnight. The caps sit well above any sane pacing: meeting one means your pacing is broken. * **LinkedIn's own limit.** After LinkedIn answers 429 or 999, the account rests 20 minutes (doubling on repeats within a day, up to 6 hours); every call meanwhile answers `429 rate-limited` with `retry_after_ms`. Five writes in a row that LinkedIn rejects for no known reason cool it down the same way. ## WhatsApp, per number [#whatsapp-per-number] * Daily caps of 40 new chats, 500 messages and 300 "is this number on WhatsApp" checks (`429 safety-floor`). * At least 60 seconds between two new chats (`429 account-busy`, with `retry_after_ms`). * WhatsApp's own limits come back as `403 whatsapp-new-chats-blocked` and `422 whatsapp-quota-reached`. ## Two guards worth using [#two-guards-worth-using] * **`unless_replied_since`** (ISO date or epoch ms) on a send: if the recipient wrote after that time, nothing is sent and the call answers `409 recipient-replied`. Use it on every automated follow-up. * **`client_id`** on a WhatsApp send: your id for that send. A retry with the same id within 24 hours returns the first answer instead of sending again. # Smart for AI (/smart-api/ai) Give a model the Smart API in one read: its guides, the SDK and every endpoint, as Markdown. ``` https://developer.swarmhit.com/smart-api/llms.txt ``` ## Use it [#use-it] * **Chat assistants** (Claude, ChatGPT): paste the link, or download the file and attach it, then ask for what you want to build: *"Using the Swarmhit Smart API in this file, write a script that adds these leads to my campaign."* * **Coding assistants** (Cursor, Claude Code, Copilot): add the link as documentation (for example `@docs` in Cursor), so their suggestions use real endpoints and fields. * **One page at a time**: add `.md` to any page's address for its Markdown, for example [`/smart-api/campaigns.md`](/smart-api/campaigns.md). Every page also has a *Copy Markdown* button. ## Let an agent act [#let-an-agent-act] The [MCP server](/smart-api/mcp-server) exposes most of the Smart API as tools, so an AI agent can run campaigns, read the inbox and manage leads directly, with your API key. ## Other formats [#other-formats] * The OpenAPI document: [`https://app.swarmhit.com/openapi.yaml`](https://app.swarmhit.com/openapi.yaml). * Every page of this site, Smart and Raw: [`/llms-full.txt`](/llms-full.txt). # Authentication (/smart-api/authentication) Every request must carry an API key as a bearer token: ``` Authorization: Bearer swh_live_xxxxxxxxxxxxxxxxxxxxxxxx ``` Create and revoke keys on the **Access tokens** page in Swarmhit (the **API keys** card). Keys start with `swh_live_`. A key's full value is shown only once, at creation, so store it securely. Each key is scoped to the workspace it was created in and can use every endpoint on it; there are no narrower scopes. A key does not expire: it works until it is revoked, and revoking it takes effect immediately. A workspace can hold up to 25 keys. Writes are attributed to the user who created the key. The few actions that touch billing (buying or cancelling mailboxes, reading what they cost) also need that user to be an owner or admin of the paying workspace (the organization, for a workspace inside one); otherwise they return `403` (see [Mailboxes](/smart-api/mailboxes)). A missing or invalid key gets a `401`: ```json { "error": { "status": 401, "code": "unauthorized", "message": "The API key is missing or invalid. Pass it as a Bearer token in the Authorization header." } } ``` The [MCP server](/smart-api/mcp-server) has its own key (`swh_mcp_…`): it does not work on the REST API, and REST keys do not work on the MCP server. One endpoint needs no key: `GET /health`. It reports whether the API is serving and returns no workspace data, so uptime monitors can read a real status code rather than the `401` every other path returns without a key: `200` when the API and its database are up, `503` when they are not. See [Service status](https://status.swarmhit.com) for the public status page. ## Acting as a workspace in your organization [#acting-as-a-workspace-in-your-organization] An **organization** key (one created in the organization workspace) can operate on any workspace inside it without a separate key, by naming the target in a request header: ``` X-Swarmhit-Workspace: ``` With the header set, every data endpoint (campaigns, leads, accounts, inbox, and so on) acts on that workspace. Omit it to act on the organization itself; an empty value or the organization's own id does the same. The header is only honored for an organization key targeting one of its own workspaces; any other value is rejected with `401`, the same response as an invalid key, and never falls back to the key's own workspace. The `/workspaces` endpoints always manage the key's organization, whatever workspace the header names. For the [MCP server](/smart-api/mcp-server), append `&workspaceId=` to the connection URL instead. See [Organization](/smart-api/organization) for managing those workspaces and their sender-seat allocations. # Blocklist (/smart-api/blocklist) The blocklist is the workspace's "do not contact" list. A lead that matches an entry is never added to a campaign, a lead already in one stops as soon as the entry is added, on every channel, and a direct action or email to that person is refused. ## What an entry matches [#what-an-entry-matches] | Type | You add | It matches | | ---------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `email` | `jane@acme.com`, in any case (`Jane ` works too) | Leads with that address, and with that address plus a tag (`jane+news@acme.com`). | | `domain` | `acme.com`, `@acme.com` or `https://www.acme.com/about` | Leads whose email address is at that domain or one of its subdomains (`eu.acme.com`). A personal email provider's domain (`gmail.com`) or a public suffix (`co.uk`) is refused: it would block everyone there. | | `linkedin` | A profile link such as `https://www.linkedin.com/in/jane-doe`, in any case, or a member id (`ACoAA…`) | The person with that LinkedIn profile, whether the lead is known by its profile name or its member id. A name the person changed is caught when the profile is next read, before anything is sent. | A domain matches email addresses only. A lead with no email address, such as one added from LinkedIn, is not matched by its company: add its LinkedIn profile to block it. A Sales Navigator link or id (`ACwAA…`) can't be added: no profile read gives it back, so it would only ever match leads from a Sales Navigator search. Use the person's `linkedin.com/in/` link. Internationalized domain names aren't read yet. Each workspace has its own blocklist: a workspace inside an organization doesn't use the organization's. ## Add and remove entries [#add-and-remove-entries] In Swarmhit, **Settings → Blocklist** takes one value per line or a CSV file, and lists, searches and exports the entries. Before anything is added, it shows how the values read: how many addresses, domains and profiles, a few of each, and what will be left out and why. From a CSV, it starts with the column with the most addresses or profile links, and you can pick another. The lead drawer of a campaign and a lead's inbox thread can add the lead's address, its domain (not a personal provider's such as `gmail.com`) or its profile. Any member can add entries. Removing them and changing the settings take an owner or admin, in Swarmhit and through the API (`403` with `code: not-allowed` for a key whose creator is neither). Removing an entry doesn't restart the leads it stopped. Through the API: ```bash curl -X POST https://app.swarmhit.com/api/v1/blocklist \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "values": ["jane@acme.com", "competitor.com", "https://www.linkedin.com/in/john-roe"] }' ``` * [Add to the blocklist](/smart-api/reference/blocklist/addBlocklistEntries) takes up to 1000 `values`, each read as what it looks like, or `entries` with their `type`. It returns `{ added, alreadyBlocked, invalid, stopped, stopping }`: a value that reads as none of the three types, or is refused, comes back in `invalid` with the reason; `stopped` counts the leads in campaigns the new entries stopped, and `stopping` the ones past the first 500, which stop in the background right after. * [List the blocklist](/smart-api/reference/blocklist/listBlocklistEntries) filters by `type`, or by the start of a value with `search`. * [Remove from the blocklist](/smart-api/reference/blocklist/removeBlocklistEntries) takes `values` as they were added, or `entries` as the list gives them (`{ type, value }`), and returns `{ removed, invalid }`; [Remove one blocklist entry](/smart-api/reference/blocklist/removeBlocklistEntry) takes an entry's `id`. * The [MCP server](/smart-api/mcp-server) has `list_blocklist`, `add_to_blocklist` and `remove_from_blocklist`. ## What it stops [#what-it-stops] * **Joining a campaign.** [Add leads to a campaign](/smart-api/reference/leads-in-campaigns/addLeadsToCampaign), a list import and every other way leads join a campaign leave a blocklisted lead out and count it as `blocklisted`. The blocklist is checked before the campaign's exclusion rules. * **Leads in campaigns.** When you add an entry, the leads it matches in your campaigns stop at once, mid-sequence too: their status becomes `stopped`, with a `blocklisted` entry in their `history`, `lead.status_changed` fires, and their open call tasks close. Every step checks again before it runs, on LinkedIn, email and WhatsApp, and a LinkedIn step checks once more after it reads the profile, so a person known by another name or id is stopped before anything is sent. * **Direct actions.** A LinkedIn [direct action](/smart-api/direct-actions) or a [direct email](/smart-api/reference/direct-actions/sendEmail) to a blocklisted person is refused with `409` and `code: blocklisted`, with the matching entry in `details`. A direct email is refused for a person blocked by their LinkedIn profile too. * **Profile visits.** A visit reads the profile silently first (in a campaign, when the person's member id isn't known yet), so a blocked person never sees it. A LinkedIn account connected with a browser cookie records every profile read as a visit: there, a person blocked only by a member id Swarmhit doesn't know yet can see that one read. * A reply in an existing conversation still goes out, whether you write it in the inbox or send it through the API or MCP. The thread shows **Blocklisted**. ## Unsubscribes [#unsubscribes] An unsubscribe (the link in an email, or a one-click unsubscribe) and a spam report stop email to that address only. To stop the person's LinkedIn and WhatsApp steps too, turn on **Add unsubscribes to the blocklist** in Settings → Blocklist, or send `{ "addUnsubscribes": true }` to [Update the blocklist settings](/smart-api/reference/blocklist/updateBlocklistSettings). The address is then added to the blocklist, with `source: unsubscribe`. This setting is off by default, and turning it on doesn't add the addresses that unsubscribed before. ## Unsubscribes, exclusion rules and the blocklist [#unsubscribes-exclusion-rules-and-the-blocklist] | | Unsubscribe | Exclusion rules | Blocklist | | ---------- | ----------------------------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | | Applies to | One address, in the whole workspace | One campaign | The whole workspace | | Set by | The recipient | The campaign's settings | Your team: in Swarmhit, from a CSV, through the API or MCP, or from unsubscribes | | Matches | Leads with that address | Leads in an excluded list, or already contacted by another campaign or account | An address, a domain and its subdomains, or a LinkedIn profile, for leads you have now and leads you add later | | Stops | Email steps | A lead, until it is first contacted | Every step on every channel, from the moment it's added, and direct actions | # Campaigns and sequences (/smart-api/campaigns) A campaign is an automated outreach program: a pool of sender accounts (`linkedinAccountIds`) and, for email steps, of mailboxes (`emailAccountIds`), a `sequence` of steps to run on each lead, a send `schedule`, exclusion rules, and the leads enrolled in it. You create the campaign, add leads, set it `active`, and the executor works each lead through the sequence inside the schedule window. Campaign endpoints live under the [Campaigns](/smart-api/reference/campaigns/listCampaigns) and [Leads in Campaigns](/smart-api/reference/leads-in-campaigns/listLeadsInCampaign) reference sections. This guide covers the concepts; the reference covers every field. ## Create a campaign [#create-a-campaign] [`createCampaign`](/smart-api/reference/campaigns/createCampaign) accepts everything up front: name, sender pool (`linkedinAccountIds`, plus `emailAccountIds` when the sequence has email steps), sequence, schedule, `aiInterestEnabled` ([labels](#labels); off unless you pass `true`), the email `unsubscribe` line, `tracking` and `matchEmailProvider` ([provider matching](#provider-matching)), exclusion rules (`excludeListIds`, `excludeOtherCampaigns`, `excludeOtherSenders`, `excludeSameSenderOtherCampaigns`), the [company rules](#company-rules) (`companyLimit`, `pauseCompanyOnReply`), the [out-of-office wait](#out-of-office) (`outOfOffice`), and an initial `status`. Every id in `linkedinAccountIds`, `emailAccountIds` and `excludeListIds` must belong to the workspace, or the request is rejected with `400`. Anything omitted falls back to a default: empty sequence, 09:00-19:00 Mon-Fri schedule in `Etc/GMT`, and `paused` status. Creating a campaign as `active` runs the same checks as activating it (see [Campaign lifecycle](#campaign-lifecycle)). ```bash curl -X POST https://app.swarmhit.com/api/v1/campaigns \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "name": "Q3 Founders", "linkedinAccountIds": ["aCc123", "aCc456"], "schedule": { "timezone": "America/New_York", "activeHours": { "from": "09:00", "to": "17:00" }, "activeDays": [1, 2, 3, 4, 5] }, "sequence": { "steps": [ { "type": "visit_profile", "nextDelayHours": 24 }, { "type": "invite", "config": { "note": "Hi {{firstName}}, came across your profile...", "fallbackNote": "Hi there, came across your profile...", "withdrawEnabled": true, "withdrawDays": 25 }, "branchDelays": { "no": 336 } }, { "type": "message", "config": { "text": "Thanks for connecting, {{firstName}}!", "fallbackText": "Thanks for connecting!" } } ] } }' ``` Then add leads with [`addLeadsToCampaign`](/smart-api/reference/leads-in-campaigns/addLeadsToCampaign) and set `status` to `active` with [`updateCampaign`](/smart-api/reference/campaigns/updateCampaign). ## The sequence [#the-sequence] A sequence is a tree of steps. Each lead walks the tree from the root, one step at a time. ### Step types [#step-types] | Type | What it does | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `visit_profile` | Views the lead's LinkedIn profile. | | `follow_profile` | Follows the lead. | | `like_post` | Likes the lead's most recent post. Takes no config. | | `comment_post` | Writes an AI comment on the lead's latest post from your `config.prompt` (supports `{{variables}}`, up to 10,000 characters). `maxPostAgeDays` (1-365, default 30) skips the step when that latest post is older than that many days, so the comment never lands on a stale post. With `config.requireApproval: true` each draft waits in the [approvals queue](/smart-api/reference/approvals/listPendingComments) for a human to edit or approve before it posts; a draft that is rejected or not approved within 7 days is skipped. Writing the comment uses the workspace's credits: while it is out of credits, leads wait on the step. | | `invite` | Sends a connection request with an optional `note` (up to 300 characters; invites from free LinkedIn accounts go out without it, see [Notes on free accounts](#notes-on-free-linkedin-accounts)). Branches: "yes" = Accepted, "no" = still not accepted. `withdrawEnabled` (on by default) plus `withdrawDays` (1-365, default 25) auto-withdraws an unanswered request after that many days; withdrawing never moves the lead along the sequence. | | `message` | Sends a direct message (`text`, up to 8,000 characters). A reply ends the sequence: the "yes" (Replied) branch is terminal, so the follow-up continues on the "no" (still no reply) branch. | | `voice_note` | Sends a recorded voice note: `config.voice` (`{ url, mimeType, durationMs }`, an m4a/AAC file served from a public URL), or one recorded in the campaign builder. The recording is the whole message, so every variant needs one before activation. It supports one recording for the whole sender pool or a separate recording per sender (see below). | | `ai_voice_note` | Speaks a personalized `script` (up to 800 characters, about a minute of audio) in an AI or cloned voice. Generating the audio uses the workspace's credits: while it is out of credits, leads wait on the step. It supports one voice for the whole sender pool or explicit voice assignments by sender (see below). With `config.requireApproval: true` each generated note waits in the [approvals queue](/smart-api/reference/approvals/listPendingVoiceNotes) for a human to listen to it and approve before it is sent; a note that is rejected or not approved within 7 days is not sent, and the lead continues on the "no" branch. | | `inmail` | Sends an InMail (`subject` up to 200 and `text` up to 1,900 characters, with `fallbackSubject` / `fallbackText`). Reply semantics match `message`. `config.whenUnable` decides what happens when the InMail cannot be sent (see below). | | `if_connected` | Condition: "yes" branch when the lead is already connected, "no" otherwise. | | `if_open_profile` | Condition: branch on whether the lead is an open profile. | | `email` | Sends an email (`subject` up to 200 and `text` up to 5,000 characters, with `fallbackSubject` / `fallbackText` for leads missing a variable) from one of the campaign's mailboxes (`emailAccountIds`). Reply semantics match `message`. A lead with no usable email address stops here (route it with `if_has_email`). `config.sameThread` (default `true`) sends it as a reply under the campaign's first email to that lead, with that email's subject prefixed "Re:"; the step's own `subject` is used when there is no earlier email. The first email of a path needs a `subject`. A follow-up (an `email` step with another `email` step above it on its path) may leave `subject` empty: it then goes out as that reply whatever `sameThread` says, and a lead who never got an earlier email gets it under the subject of the email above it. | | `if_has_email` | Condition: "yes" when the lead has a usable email address (present, not bounced, not reported as spam, not opted out), "no" otherwise. Needs no sender, so a mixed sequence can start on it. | | `if_has_linkedin` | Condition: "yes" when the lead has a LinkedIn profile, "no" for a lead added by email alone. Needs no sender. Use it to route email-only leads before any LinkedIn step. | | `if_email_opened` | Waits on the last email sent to the lead: "yes" at its first open (a click counts as one), "no" once the "no" branch delay (default 72 hours, minimum 3) has passed since the lead reached the step. Must sit below an `email` step. Needs no sender. Opens are approximate: some mail apps load images on their own, others block them. | | `if_email_clicked` | The same for a click on a link in the last email (a click on the unsubscribe link does not count). An email with no link can only take "no". | | `call` | Creates a call task in the app's Tasks for your team, with the lead's details (the phone number once revealed), the step's `config.instructions` (talking points; supports `{{variables}}`, up to 2,000 characters) and the last message exchanged. The lead waits until someone logs the call (reached, voicemail or no answer, with an optional note) or skips it, then moves on; after `config.waitDays` (1-30, default 3; values outside are clamped) without either, the task is skipped and the lead moves on. Stopping the sequence from the task stops the lead. A call sends nothing, needs no sender of its own and uses no sender quota or credits; it can appear several times, in LinkedIn, email or mixed sequences, and a lead added by email alone gets its task too. Call tasks are worked in the app's Tasks or with the [call tasks endpoints](/smart-api/direct-actions#call-tasks), and [`call_task.pending`](/smart-api/webhooks#call_taskpending) fires when one is made. | | `if_has_phone` | Condition: "yes" when the lead has a phone number (`phone` on the lead, revealed from the database or found by a lookup), "no" otherwise. Needs no sender and no email channel; use it to send only leads with a number to a `call` step. | | `find_email` | Looks up the lead's work email (from its LinkedIn profile, or its name and company) and saves it on the lead, then moves on whether or not one was found; the lead waits a few minutes on the lookup. A lead that already has an email moves on at once. Needs no sender. Charged at the Email finder price when an email is found, free otherwise (see [credit prices](/smart-api/database#credits-and-errors)). Route on the result with `if_has_email`. | | `find_phone` | The same for a mobile phone number, looked up from the lead's LinkedIn profile, at the Phone finder price when one is found. Route on the result with `if_has_phone`. | Text longer than its field's limit is cut to it when the sequence is saved; a fallback shares the limit of the field it stands in for. `email`, `if_has_email`, `if_has_linkedin`, `if_email_opened` and `if_email_clicked` need the email channel enabled on the workspace: without it, saving a sequence that adds one returns `400`. **Email steps** need a mailbox pool: set `emailAccountIds` to one or more mailboxes from [`GET /email-accounts`](/smart-api/reference/email-accounts/listEmailAccounts). A lead is pinned to one mailbox by its first email and keeps it for the whole thread, the way it is pinned to a LinkedIn sender. A mailbox bought through Swarmhit is held out of campaigns while it warms up: until its warmup hold ends it sends no campaign email and takes no new leads, and leads already assigned to it wait (see [Mailboxes](/smart-api/mailboxes)). Emails go out in plain text with light formatting, paced a few minutes apart per mailbox and capped by the mailbox's daily limit and by the workspace's email allowance for the billing period (`workspace.emails` on [`GET /`](/smart-api/reference/general/getApiInfo)): once its `remaining` reaches zero, email steps wait. A lead whose address bounced, was reported as spam or opted out (`emailStatus` and `emailOptOut` on the lead) stops at the email step (see below), and a bounce, spam report or failed delivery on the email a lead is still waiting on marks that membership `failed`. A reply on either channel ends the sequence; an automatic reply such as an out-of-office does not. See [Email deliverability](/smart-api/email-deliverability) before the first send. **Open and click steps** make the campaign track its own emails, whatever the campaign's `tracking` or the mailbox settings say: opens for `if_email_opened`, links for `if_email_clicked`. They look at the last email sent to the lead and take "yes" as soon as it was opened or clicked, even if that happened before the lead reached the step (the "yes" branch delay then counts from the open or click). A lead whose last email went out untracked, for example because the step was added to a campaign that was already sending, stops there (`stopped`, with `result.stopped: "email_not_tracked"` on its last `history` entry). Opens and clicks in the first two minutes after a send are ignored, because mail security scanners make them. Each first open and click shows in the lead's activity, and in the [`email.opened` and `email.clicked` webhooks](/smart-api/webhooks). A step never answers a question it could not ask. A lead added by email alone (no LinkedIn profile, see [Leads](/smart-api/leads-and-lists)) passes the LinkedIn actions that decide nothing (`visit_profile`, `follow_profile`, `like_post`, `comment_post`), and stops at any other LinkedIn step it reaches (`invite`, `message`, `inmail`, the voice notes, `if_connected`, `if_open_profile`) instead of taking its "no" branch. Likewise, a lead with no usable email address (none, bounced, reported spam, or opted out) stops at an `email` step instead of taking "no reply". A stopped lead ends with status `stopped`, nothing is charged, and its last `history` entry carries `result.stopped`: `no_linkedin_profile` or `no_email`. Route such leads explicitly: `if_has_linkedin` ("yes" when the lead has a LinkedIn profile, needs no sender) and `if_has_email` send each lead down the path it can take, for example `if_has_linkedin` → yes: invite and messages, no: emails. **Finding emails and phones.** `find_email` and `find_phone` run the same lookups as [lead enrichment](/smart-api/leads-and-lists#finding-emails-and-phone-numbers): a value found is saved on the lead, so every campaign it is in sees it, and each finished lookup fires `lead.enriched`. A typical path puts `find_email` before its first `email` step, then `if_has_email` to route the leads with none found. A bounced or opted-out address is never replaced. **InMail `whenUnable`** (`auto` | `pause` | `skip`, default `auto`, invalid values coerced to `auto`) applies when the sender cannot send the InMail: it has no premium LinkedIn plan (free senders cannot InMail anyone, open profiles included), or it is out of InMail credits and cannot InMail this lead for free (only Sales Navigator and Recruiter senders InMail open profiles free, within a monthly allowance). `auto` pauses the sender when it is out of credits and otherwise continues to the still-no-reply branch, `pause` always pauses the sender, `skip` always continues. ### Notes on free LinkedIn accounts [#notes-on-free-linkedin-accounts] A free LinkedIn account can't add a note to its connection requests, so its invites go out without the step's note. An account with a monthly note allowance does the same once the allowance is used up. A Premium account's invites keep their note. Each membership's `invitationSentWithoutNote` says when the invite went without it, and [`getLeadInCampaign`](/smart-api/reference/leads-in-campaigns/getLeadInCampaign)'s `history` says why: its `invite_sent` entry carries `result.noteDroppedReason`, which is `free_account`, `allowance_used`, or `too_long` (LinkedIn found the note too long). With the invite step's `noteAsMessage: true` (off by default), the note goes as a message once the person accepts, to the leads of a free account or a used-up allowance only: * On the run that sees the acceptance, within the campaign's sending hours and never while the campaign is paused (a paused campaign's accepted leads get it on the first run after it resumes), counted against the account's daily message limit like a message step. * It's the note the invite was sent with, filled in for the lead (or its fallback), once, and it shows as an `invite_note_sent` entry in the lead's `history` and in the inbox thread. * The accepted branch's delay then counts from that message, and a LinkedIn message step right after it waits at least 24 hours, so the person never gets two messages back to back. * A lead who replies before it goes ends the sequence as usual. A note too long for an invite isn't sent. When the account sends no messages (its limit is 0), or LinkedIn refuses the message for good or keeps failing, the note is left out and the sequence goes on. ### Variables and fallbacks [#variables-and-fallbacks] Message bodies support `{{variables}}`: the lead's `{{firstName}}`, `{{lastName}}`, `{{company}}`, `{{headline}}`, `{{profileUrl}}` and `{{email}}`, plus any of its `customVariables` (see [Leads and lists](/smart-api/leads-and-lists)). They can also name the sender: `{{senderFirstName}}`, `{{senderLastName}}` and `{{senderFullName}}` read the LinkedIn account sending the step or, for an email, the mailbox's From name (its first word is the first name, the rest the last name). Whenever a body uses a required variable, its fallback must be set: `fallbackNote` on invites, `fallbackText` on messages, InMails and emails, `fallbackSubject` for InMail and email subjects, and `fallbackScript` on AI voice notes. The fallback is sent verbatim to any lead missing the value. This is enforced when the campaign is activated, and on every sequence save once it has been activated; a campaign that has never been active may leave fallbacks blank and fill them in before launching. A `?` after the name makes a variable optional. `{{ps?}}` comes out empty for a lead without the value instead of sending the fallback, and never makes the fallback required. A line holding only optional variables that all come out empty is left out, together with one of the blank lines around it, so the paragraphs close up. With a `ps` custom variable that only some leads have: ```text Hi {{firstName}}, Thanks for your time today. {{ps?}} Best, {{senderFirstName}} ``` A lead without `ps` gets the message without the P.S. paragraph, while one without a first name still gets the fallback. Spaces inside the braces are allowed (`{{ ps ? }}`). A `{{name}}` is matched exactly first: one of the fields above, then the custom variable with that key. After those come the fields and custom variables whose names only differ in case, accents or separators, so `{{FirstName}}` reads `firstName` and `{{Plan_Tier}}` reads `plantier` or `planTier` (fields first, then custom keys in code-point order). The first of these that has a value fills the variable in, so an empty field never hides a filled custom variable of the same name; only text, numbers and booleans count as values. The sender's name comes last, so a lead's own `senderFirstName` custom variable wins over it; a sender with no name counts as a missing value. In an `email` step's `text` and `fallbackText`, `{{unsubscribeLink}}` places the campaign's unsubscribe line (`unsubscribe` on the campaign, see [Opting out](/smart-api/email-deliverability#opting-out)), and `{{signature}}` places the mailbox's signature where it is written instead of under the message, for example above a P.S. Both are filled in by the send, so they never make a fallback required, and they are the variables a fallback fills in. A mailbox without a signature leaves nothing in its place, and a line that held only `{{signature}}` closes up. Only those exact spellings count: `{{unsubscribe_link}}` is an ordinary variable, and so is `{{signature}}` in a subject or a LinkedIn step. ### A/B variants [#ab-variants] Message-bearing steps (`invite`, `message`, `voice_note`, `ai_voice_note`, `inmail`, `email`) accept `config.variants`: up to 5 message variations (extra ones are dropped) rotated evenly across leads. Each variant carries the step's message fields. A `voice_note` variant carries its recording; an `ai_voice_note` variant carries `script` and `fallbackScript`; `message` and `inmail` variants may also carry `attachments` (up to 5 files served from a public URL). Neither takes a voice note: a `voice` sent on a `message` or `inmail` step is dropped, so send voice notes with a `voice_note` or `ai_voice_note` step. The flat keys always mirror variant A, so single-variant steps look unchanged. A `voice_note` that records per sender does not rotate variants: its recordings vary by who is speaking, not by what is being tested. ### Updating one step or variant [#updating-one-step-or-variant] For a copy or media change to an existing step, use [`updateCampaignStep`](/smart-api/reference/campaigns/updateCampaignStep). First read the campaign with [`getCampaign`](/smart-api/reference/campaigns/getCampaign), then use the stable step `id` from its `sequence`. Omit `variantId` to update variant A, or pass the id of another existing variant to update only that variant. The endpoint preserves the step's wiring, delays, and every other variant. ```bash curl -X PATCH https://app.swarmhit.com/api/v1/campaigns/cMp456/sequence/message-step-2 \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "variantId": "b", "text": "{{firstName}}, does this production gap sound familiar?" }' ``` The accepted fields depend on the step type: `text`/`fallbackText` for messages; `note`/`fallbackNote` for invites; `subject`/`fallbackSubject` plus message fields for InMail and email; `script`/`fallbackScript` for AI voice notes; `prompt` for an AI post comment; and `instructions` for a call. Message and InMail variants also accept `attachments`, but not `voice` (it returns `400`), and recorded voice-note variants accept `voice`. A recorded voice note that records per sender is rejected here: replace its recordings through [`updateCampaign`](/smart-api/reference/campaigns/updateCampaign) instead. An unknown step or variant id returns `400`, and once the campaign has been activated the sequence must still pass the activation checks after the edit (a fallback for every variable, for example). The response contains the full campaign with the persisted sequence, so clients can verify the saved value immediately. You can make the same change through [`updateCampaign`](/smart-api/reference/campaigns/updateCampaign), but that endpoint replaces the complete `sequence`: send every step and its wiring back. A campaign read exposes variant A in both the legacy flat fields (`config.text`, `config.note`, and so on) and `config.variants[0]`. When changing variant A through a full-sequence update, update both copies to the same value; conflicting copy or media values return `400`. To change variant B or later, edit only that variant and leave variant A's flat mirrors unchanged. Omitting `variants` and supplying flat content intentionally collapses the step to a single variant. ### Voices and recordings by sender [#voices-and-recordings-by-sender] An `ai_voice_note` step takes `script` and `fallbackScript` (up to 800 characters each; longer text is cut), `voiceId` (default `Enceladus`), `voiceProvider` (derived from `voiceId`), an optional speaking `style` (up to 300 characters; voices whose `provider` is `elevenlabs`, the studio and cloned ones, ignore it), and `requireApproval`. The rendered script goes to the voice as written: Swarmhit has no markup for pauses or tone, so steer the delivery with `style`. List available library and workspace-cloned voices with [`GET /voices`](/smart-api/reference/reference/listVoices). A cloned voice also reports `sampleLanguage`, the language its recording was made in: that is the accent it carries, not a limit on what it can say, since the spoken language follows the step's script. With no `voiceAssignments` field, the step's `voiceId` applies to every sender in the campaign. This keeps single-sender campaigns and existing integrations simple: ```json { "type": "ai_voice_note", "config": { "script": "Hi {{firstName}}, I had a quick idea for {{company}}.", "fallbackScript": "Hi there, I had a quick idea for your team.", "voiceId": "cjVigY5qzO86Huf0OWal", "voiceProvider": "elevenlabs" } } ``` For a multi-sender campaign, add `voiceAssignments`. Each assignment attaches one voice to one sender, several senders, or the whole pool: ```json { "type": "ai_voice_note", "config": { "script": "Hi {{firstName}}, I had a quick idea for {{company}}.", "fallbackScript": "Hi there, I had a quick idea for your team.", "voiceAssignments": [ { "voiceId": "voice_alex", "voiceProvider": "elevenlabs", "linkedinAccountIds": ["aCc123", "aCc456"] }, { "voiceId": "cjVigY5qzO86Huf0OWal", "voiceProvider": "elevenlabs", "linkedinAccountIds": ["aCc789"] } ] } } ``` Once `voiceAssignments` is present, every id in the campaign's `linkedinAccountIds` pool must appear in an assignment. Activating the campaign, or adding an uncovered sender while it is active, is rejected with `400`. The campaign builder marks the AI voice-note step as needing attention and lists the senders that still need a voice. A sender can belong to only one assignment; duplicate ids are normalized to their first assignment. A recorded `voice_note` step works the same way, with a recording where the AI step has a voice id. With no `voiceAssignments`, the step's single recording is sent by every sender. To have each sender send its own voice, add `voiceAssignments` with a `voice` per group: ```json { "type": "voice_note", "config": { "voiceAssignments": [ { "voice": { "url": "https://cdn.example.com/alex.m4a", "mimeType": "audio/mp4", "durationMs": 24000 }, "linkedinAccountIds": ["aCc123", "aCc456"] }, { "voice": { "url": "https://cdn.example.com/sam.m4a", "mimeType": "audio/mp4", "durationMs": 19000 }, "linkedinAccountIds": ["aCc789"] } ] } } ``` The same coverage rule applies, with one addition: a group with no recording covers nobody, so every sender must be in a group that actually has audio before the campaign can be activated. When a sender is listed in two groups, the one holding a recording keeps it. Each group may also carry an opaque `id`, which the campaign builder uses to track a group while the list around it changes. The audio at `voice.url` must already be an m4a/AAC voice note. Per-sender mode also turns off A/B rotation for that step, and its `config.voice` and `config.variants` are left alone but no longer sent. ## Wiring and branching [#wiring-and-branching] Every step has an optional `id`, `parent`, and `branch` (`"yes"` | `"no"`); once any step sets one of them, every step needs an `id`. The root step has `parent: null`. **Linear chains:** omit `id`/`parent`/`branch` on every step and they are auto-wired in array order, with the ids `s_1`, `s_2`, and so on. Each step attaches to the previous step's natural continuation: the "yes" branch of a connection request or condition, but the "no" (still no reply) branch after a `message`, `voice_note`, `ai_voice_note`, `inmail`, or `email`, since their "yes" (Replied) branch ends the sequence. **Branching trees:** give each step a stable `id` and set `parent`/`branch` explicitly. This sequence invites the lead, messages them if they accept, and falls back to an InMail if they do not: ```json { "sequence": { "steps": [ { "id": "invite1", "type": "invite", "config": { "note": "Hi {{firstName}}!", "fallbackNote": "Hi there!" }, "branchDelays": { "no": 336 } }, { "id": "welcome", "type": "message", "parent": "invite1", "branch": "yes", "config": { "text": "Thanks for connecting, {{firstName}}!", "fallbackText": "Thanks for connecting!" } }, { "id": "inmail-fallback", "type": "inmail", "parent": "invite1", "branch": "no", "config": { "subject": "Quick question", "text": "We never connected, so reaching out here instead.", "whenUnable": "skip" } } ] } } ``` Wiring rules the API enforces: * A sequence has at most 200 steps and exactly one root. Ids are unique, every step must be reachable from the root, and a step has at most one child per branch (an action step has a single child, which sets no `branch`). * The `invite` step may appear at most once per sequence. * No step may sit on the "yes" (Replied) branch of a `message`, `voice_note`, `ai_voice_note`, `inmail`, or `email`: a reply ends the sequence. * Once the lead is connected (the "yes" branch of an `invite` or an `if_connected` condition), the `invite`, `inmail`, `if_connected`, and `if_open_profile` steps cannot sit below it. The lead is already connected, so a connection request or InMail cannot fire, and a repeat connected or open-profile check is redundant. * To send an InMail only when a connection request goes unanswered, wire it onto the invite's "no" branch, as in the example above. It cannot follow the invite in a linear chain. ### Delays [#delays] * `nextDelayHours` on an action step: hours to wait before its child runs. * `branchDelays` (`{ "yes": h, "no": h }`) on condition and wait steps: per-branch wait in hours. On a wait step (`invite`, `message`, `inmail`, `email`, the voice notes, and the open and click checks) the "no" delay is the wait timeout, with a 3 hour minimum; if omitted it defaults to 14 days (72 hours for `if_email_opened` and `if_email_clicked`). A "yes" delay after an acceptance, open or click counts from that event, not from when the executor noticed it. ## Schedule [#schedule] The schedule is the window during which the executor may act on the campaign's leads: * `timezone`: an IANA timezone that `activeHours` and `activeDays` are read in. List valid values with [`listTimezones`](/smart-api/reference/reference/listTimezones); treat `id` as the stable value since `offset` shifts with daylight saving. * `activeHours`: daily send window in 24-hour `HH:MM`; `from` must be earlier than `to`. * `activeDays`: ISO weekdays that may send (1 = Mon ... 7 = Sun); must be non-empty. * `startDate` / `endDate`: optional ISO 8601 bounds; `null` means no bound. Once no send window is left before `endDate`, leads still in the sequence are set to `paused` as they come due. Sending `schedule` replaces the whole object; omitted fields fall back to the defaults (Etc/GMT, 09:00-19:00, Mon-Fri). Unknown timezones, empty `activeDays`, inverted hours, or `startDate` after `endDate` are rejected with `400`. There is no per-campaign daily cap: daily limits belong to each sender account and mailbox, and the leads of a large enrollment start gradually, at the pace each sender can sustain, rather than all at once. ## Campaign lifecycle [#campaign-lifecycle] Campaign `status` is one of `draft`, `active`, `paused`, or `finished`. New campaigns start `paused` unless you pass a different initial status. Only an `active` campaign sends: set `active` to start sending and `paused` to stop, via [`updateCampaign`](/smart-api/reference/campaigns/updateCampaign). An `active` campaign switches to `finished` on its own once none of its leads is `pending` or `running` (each is done, replied, failed, stopped, or paused), which also fires the `campaign.finished` [webhook](/smart-api/webhooks). Leads added to a finished campaign wait until you set it `active` again. Activating a campaign, or creating it as `active`, is rejected with `400` until it can run: * It has a sender in `linkedinAccountIds`, unless it is email-only (its steps are `email` steps and conditions that need no sender), and a mailbox in `emailAccountIds` if it has an `email` step. * Every body that uses a required variable has its fallback; every `voice_note` has its recordings and every `ai_voice_note` variant a script; every `email` variant has a body, and a subject unless the step is a follow-up (a subject made only of optional variables doesn't count); and every `inmail` variant a body. * Every `if_email_opened` and `if_email_clicked` sits below an `email` step. * Any `voiceAssignments` cover every sender in the pool, and every AI voice is one the workspace can use (a library voice or one of its own cloned voices). Once a campaign has been activated, the step checks above also run on every later sequence save, even while it is paused. * An `active` campaign must keep its pools: an empty `linkedinAccountIds` (unless the campaign is email-only) or an empty `emailAccountIds` (when it has an `email` step) is rejected with `400` (pause first). * `sequence` and `schedule` are replaced wholesale on update: send the complete object, not a partial patch of it. `unsubscribe` and `tracking` are merged per key. * [`deleteCampaign`](/smart-api/reference/campaigns/deleteCampaign) permanently deletes the campaign and every lead's membership in it; the leads themselves and the campaign's own list are kept. This cannot be undone. ### Editing a live sequence [#editing-a-live-sequence] Once any lead in a campaign has started, the sequence may only be edited safely: you can change step messages and delays and append new steps, but removing, reordering, or retyping existing steps is rejected with `400`, because a lead's progress is pinned to a step id. In a linear chain the ids follow array order, so add new steps at the end. To rework a sequence from scratch, [`duplicateCampaign`](/smart-api/reference/campaigns/duplicateCampaign) clones the blueprint (sequence, schedule, sender and mailbox pools, exclusion rules, email settings) into a fresh paused campaign named "\ (copy)", with no leads. ## Leads in a campaign [#leads-in-a-campaign] [`addLeadsToCampaign`](/smart-api/reference/leads-in-campaigns/addLeadsToCampaign) takes either a `leads` array of `{ profileUrl, ... }` objects (or `{ email, ... }` with the email channel; max 1000 per request) or a `listId` to enroll a whole lead list. To fill a campaign from a LinkedIn or Sales Navigator search, start a [lead import](/smart-api/leads-and-lists#lead-imports-from-a-linkedin-search) with its `campaignId`. Leads are upserted and enrolled in one call, so you do not need to create them separately first. Leads already in the campaign are skipped, and so are items with neither a valid `profileUrl` nor (with the email channel) a valid `email`. * A `leads` array returns `{ inserted, excluded, blocklisted, listId }`: `excluded` counts leads an exclusion rule kept out (see below), `blocklisted` the leads on the [blocklist](/smart-api/blocklist), and `listId` is the campaign's own list. Skipped items are not counted. * A `listId` import returns `{ inserted, skipped, excluded, blocklisted, listId, listName }`: `skipped` counts leads already in the campaign, and `listId` / `listName` name the source list. Adding a lead makes no LinkedIn call. LinkedIn steps read the lead's profile when they reach it and refresh its `firstName`, `lastName`, `headline` and picture from LinkedIn before rendering the message, replacing the values you sent; other fields, such as `company`, `email` and `customVariables`, are kept. Every campaign has its own lead list, created on the first enrollment, named "\'s leads" (or "\' leads" when the name ends in s), and exposed as `listId` on the campaign object. The list and the campaign stay in step in both directions: every enrolled lead is in the list (whether it came from a `leads` array, a `listId` import, or the app's manual add, CSV import and database), adding leads to the list with [`addListLeads`](/smart-api/reference/lists/addListLeads) enrolls them (exclusion rules apply, leads already in the campaign are skipped), removing a lead from the list with [`removeListLead`](/smart-api/reference/lists/removeListLead) removes it from the campaign, and removing a lead from the campaign takes it off the list. Leads imported from another list keep that list too; only the campaign's own list is bound this way. Lead fields, tags and custom variables live on the lead itself, so an edit made anywhere shows everywhere. The campaign's exclusion rules filter who actually gets contacted: `excludeListIds` (members of those lists are never contacted), `excludeOtherCampaigns` (skip leads any other campaign already invited or messaged), `excludeOtherSenders` (skip leads already messaged by a different sender account), and `excludeSameSenderOtherCampaigns` (skip leads the assigned sender already contacted from another campaign). The list and other-campaign rules are checked when leads are added: a matching lead is not enrolled and counts as `excluded`. All four are checked again at each step until the lead is first invited or messaged, so a rule turned on later still applies: a matching lead becomes `stopped`, with an `excluded` entry in its `history`. The two sender rules apply to LinkedIn senders only. ### Company rules [#company-rules] Two campaign settings decide how a campaign treats several people at the same company. Both are off by default. `companyLimit` caps the new people the campaign contacts per company: `{ "max": 2, "period": "week", "scope": "workspace" }` means at most two new people at one company in any 7 days, across all the workspace's campaigns. * A new person is a lead this campaign contacts for the first time: a connection request, an email, or a LinkedIn or WhatsApp message. * `period` is `day` (from midnight in the campaign's `schedule.timezone`) or `week` (the last 7 days, rolling). It defaults to `day`. * `scope` says which campaigns' contacts are counted: `campaign` (the default) counts this campaign's only, `workspace` counts every campaign's in the workspace, a person contacted by two campaigns once. Either way the limit only holds back this campaign's leads. * A lead over the limit is not skipped: it stays `pending` with `waitReason: "company_limit"` and a `waitUntil` set to the first sending time once a place frees up, and goes then. Leads at other companies go on meanwhile. * Changing or removing the limit lets the leads it holds go again at the next sending time, counted against the new limit. `pauseCompanyOnReply` pauses the campaign's other leads at the same company when one of them replies: * A reply no person wrote pauses no one, on any channel: an email marked as an automatic reply, or a reply the [out-of-office](#out-of-office) check reads as automatic (an automatic out-of-office or away message, a business greeting, a receipt). Each reply in a campaign with the rule on is read once, as it arrives, for one reply analysis, or for none extra when its interest is analyzed anyway (`aiInterestEnabled`, or a webhook with `classifyReplies`): the same call answers both. Without credits, only an auto-reply's subject ("Automatic reply:") or a mailbox saying nobody reads it counts as automatic, so a reply a person wrote, someone saying they'll be away included, always pauses. * The other leads of that company that are still `pending` or `running` become `paused` with `pausedReason: "company_replied"`, and so does any lead of that company added to the campaign later. * Resume them one at a time with [`resumeLeadInCampaign`](/smart-api/reference/leads-in-campaigns/resumeLeadInCampaign). A lead resumed this way goes on, and the rule leaves it alone from then on. * Turned on in a running campaign, it pauses the colleagues of everyone who already replied in it, within moments. Only the earlier replies that have colleagues to pause are read, for one reply analysis each. Two leads are the same company when their work-email domains match or, when either has no work email (a lead added from LinkedIn, or one with a Gmail address), when their company names match once normalized: case, accents, punctuation and legal forms such as Inc. or SAS are ignored. Personal email domains (Gmail, Outlook, Hotmail, Yahoo and the like in every country, and internet providers' mailboxes such as Orange or Bigpond) never group leads. Neither does a company name with a placeholder in it ("Freelance Consultant", "Independent", "Looking for new opportunities", "Stealth") or one made only of roles ("Founder", "CEO & Founder", "Startup", "Consultant"). ### Out of office [#out-of-office] With `outOfOffice` on, an automatic email reply that says the lead is away holds the lead's next step until they are back, instead of following up into their vacation. It is on for new campaigns (`{ "enabled": true, "fallbackDays": 7 }`) and off on campaigns created before it existed. * Only an email the lead's mail server sends as an automatic reply counts. An auto-reply that isn't about being away (a ticket receipt, an unmonitored mailbox) changes nothing. * The reply is read for the day the lead is back, and the next step waits until the campaign's first sending time that day. Without a date, it waits `fallbackDays` (1 to 30). * The lead stays `pending` with `waitReason: "out_of_office"` and that `waitUntil`; a wait that already ended later is kept. The auto-reply is not a reply, so the sequence goes on from there, and a real reply later ends it as usual. * Change the wait with [`rescheduleLeadInCampaign`](/smart-api/reference/leads-in-campaigns/rescheduleLeadInCampaign) (`waitUntil`, within the next 12 months), or end it with `waitUntil: null` or [`resumeLeadInCampaign`](/smart-api/reference/leads-in-campaigns/resumeLeadInCampaign): the lead then goes at the next sending time. A `paused` lead keeps its out-of-office wait: resume it first, then change the wait. * Nothing else ends the wait early: an open, an accepted connection request or a topped-up email budget leaves the lead waiting until its return date. That includes a lead whose step already waited longer, such as days for an open: an open before the return date sends nothing. * Turning `outOfOffice` off lets every lead it holds go at the next sending time. * Each auto-reply read uses the credits of one reply analysis, only in campaigns with the setting on. Without credits, the reply's words decide, with no date. An out-of-office message sent without the automatic-reply headers still ends that lead's own sequence, as any reply does; it only pauses no colleague. ### Sender assignment [#sender-assignment] Leads are spread evenly across the campaign's connected, unpaused senders when they are enrolled, when the campaign is activated, and when the sender pool changes; each membership shows its sender as `linkedinAccountId` (and its mailbox as `emailAccountId`). Until a lead has started, its sender may still change to keep the pool balanced, for example onto a sender you just added; once its first step has run, it stays pinned to that sender for the rest of the sequence. Add multiple sender ids to scale beyond one account's daily cap (see [Sender accounts](/smart-api/sender-accounts) and [LinkedIn safety](/smart-api/linkedin-safety)). When accounts belong to [senders](/smart-api/senders), a lead hears from one sender on every channel: its mailbox and WhatsApp number come from the sender of its LinkedIn account. `senderIds` on the campaign lists those senders and can be written to add or remove a sender with all its accounts; see [Senders in campaigns](/smart-api/senders#in-campaigns). For an `ai_voice_note`, that sticky sender assignment also selects the voice: the executor finds the assignment containing the lead's sender id and synthesizes the script with that voice. A `voice_note` that records per sender resolves its recording the same way. Neither ever substitutes another sender's voice, with one exception: a note already generated and waiting for approval is sent as approved, even if the lead has moved to another sender since. If the step uses the all-senders shape, its single `voiceId` (or its single recording) is used instead. Removing a sender from the pool moves its leads that have not started to the remaining senders right away; leads it already contacted stay pinned to it and wait, and carry on if it is added back. A paused sender keeps its leads, which wait, but receives no new ones; the not-yet-started leads of a disconnected sender move to the others on their own. To move a paused or failed sender's not-yet-contacted leads onto the other senders explicitly, call [`rebalanceCampaignSender`](/smart-api/reference/campaigns/rebalanceCampaignSender) (LinkedIn senders only); leads it already contacted stay pinned. ### Provider matching [#provider-matching] With `matchEmailProvider` on, a lead whose mail Google or Microsoft receives gets its emails from one of the campaign's mailboxes on the same provider: Gmail to Gmail, Outlook to Outlook, a common deliverability practice. It is off by default. * Each lead's provider is read from its email domain's MX records before its first email: `google` (Gmail and Google Workspace), `microsoft` (Microsoft 365, and personal Outlook, Hotmail and Live) or `other`. The lead in the campaign shows it as `emailProvider`, `null` until it is read. * The lookups run in the background, a few domains at a time, and each domain's answer is kept 30 days. A lead whose domain hasn't been read by its first email goes with any mailbox. A lead with no email yet is read within the hour once it has one, for example from an Enrich step. * Google leads are spread over the campaign's Google mailboxes and Microsoft leads over its Microsoft mailboxes, and a matched lead waits for its provider's mailbox when that one is at its daily cap. Every other lead (another provider, or one the campaign has no mailbox for) fills the room left on any mailbox. * A mailbox's provider is the one it signs in with, Google or Microsoft; for an IMAP mailbox, the one its own domain's MX records name. * It can lower sending volume when most leads use a provider the campaign has few mailboxes for. Turning it on or off spreads the leads not emailed yet again. ### One sender, one person at a time [#one-sender-one-person-at-a-time] One sender does not work the same person in two campaigns at once. If a lead is enrolled in two campaigns that share the sender it lands on, whichever campaign contacts them first proceeds, and the other waits: its membership stays `pending`, carries a `heldBy` object naming the sender and the campaign it is waiting on, and starts on its own once that campaign finishes with them. It is a wait, not a skip, so the lead is never dropped. Only a membership that has actually contacted the lead (sent an invite or a message) holds the others, and only while its campaign is `active`. A membership in a paused campaign does not hold, since it could stay paused indefinitely; once that campaign is active again, it holds any membership that has not contacted the lead yet. Mailboxes are not subject to this: only LinkedIn senders hold. This matters because an inbox conversation is keyed on the sender account and the person, so two campaigns sharing a sender would share one thread, and a reply could only ever stop one of them. Different senders are unaffected: two campaigns may work the same person concurrently as long as they do it from different sender accounts, since each gets its own conversation. Moving a lead to another sender with [`rebalanceCampaignSender`](/smart-api/reference/campaigns/rebalanceCampaignSender) clears any hold recorded against the old sender; it is re-evaluated against the new one on the next run. Stopping the membership that is holding the lead releases it immediately. This waiting is automatic and needs no configuration. It is not the same as the campaign's `excludeOtherCampaigns` and `excludeSameSenderOtherCampaigns` options, which permanently **skip** a lead another campaign has already contacted rather than queueing it. Those are checked first, so a campaign with either option enabled skips the lead outright and never reaches the waiting behaviour. ### Lead statuses and controls [#lead-statuses-and-controls] Each membership has a `status` you can filter on in [`listLeadsInCampaign`](/smart-api/reference/leads-in-campaigns/listLeadsInCampaign): | Status | Meaning | | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pending` | Queued, waiting for its next step or timer. Also covers a membership waiting on another campaign, which carries `heldBy` (see [One sender, one person at a time](#one-sender-one-person-at-a-time)), one waiting on the [company limit](#company-rules), which carries `waitReason: "company_limit"`, and one waiting for an [out-of-office](#out-of-office) lead to be back, which carries `waitReason: "out_of_office"`. | | `running` | The executor is working a step. | | `replied` | The lead replied; the sequence ended. | | `done` | The sequence completed. | | `failed` | A step failed; see the membership's `error`. | | `paused` | Frozen, resumable. Progress and timers are kept. `pausedReason` says why: `user` (paused by hand), `manual_message` (paused when someone messaged the lead from the inbox) or `company_replied` (see [Company rules](#company-rules)); it is `null` for a membership paused because the schedule has ended (see `endDate`), or paused before the field existed. | | `stopped` | Terminally stopped; not resumable. Set by a manual stop (from the API, the app, or a call task's "Stop sequence"), an exclusion rule, or a step that can't apply to the lead (no LinkedIn profile, no usable email address, an open or click check on an untracked email). | Each membership also has a `stage`: where the lead is, as the app's Status column shows it. While `status` is `pending` or `running` the lead is in the sequence, and the stage says where; otherwise the stage is the status. | Stage | Meaning | | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `pending` | No step started yet. | | `invited` | Its connection request is sent and not accepted yet, and it waits on that invite step. | | `accepted` | It accepted, and no message has been sent yet. | | `messaged` | A message step sent its message and waits for the reply, or the invite's note went as a message once they accepted and no message step has sent since. | | `call_pending` | It waits in Tasks for someone to call. | | `in_progress` | Anything else in the sequence: between steps, on a delay or a condition, or a step being run. | | `done`, `replied`, `failed`, `paused`, `stopped` | The same as `status`. | ### Finding leads in a campaign [#finding-leads-in-a-campaign] [`listLeadsInCampaign`](/smart-api/reference/leads-in-campaigns/listLeadsInCampaign) lists a campaign's leads newest first, with filters you can combine, the same ones as the campaign's leads table: * `stage`, or the raw `status`. * `label`: a [label](#labels), or `none`. * `currentStepId`: the leads on that step now, whatever their stage ("here now" on the step). * `reachedStepId`: the leads that reached that step, the number on the step in the app. A lead reached a step when its `history` has an entry for the step that went through (an email's opens and clicks don't count), or when the step's invite or message is sent and still waits for an answer. * `accountId`: the leads assigned to that LinkedIn account, mailbox or WhatsApp number. * `search`: a case-insensitive match on the lead's name, headline, job title, email, company or profile slug, as on [`listLeads`](/smart-api/reference/leads/listLeads). It reads at most 25,000 of the campaign's leads, after the other filters: past that it answers `400` with `code: search-too-broad`, so add another filter first. Step ids are the `sequence.steps[].id` of [`getCampaign`](/smart-api/reference/campaigns/getCampaign); one that isn't in the campaign's sequence is a `400`. For example, `?reachedStepId=…&label=interested` lists the interested leads among those a step reached, and `?stage=invited&accountId=…` the connection requests from one account still waiting for an answer. Per-lead controls: [`pauseLeadInCampaign`](/smart-api/reference/leads-in-campaigns/pauseLeadInCampaign) freezes the sequence (to do the same when you reply by hand, send the message from the [inbox](/smart-api/inbox) with `sequenceAction: "pause"`; a manual message alone does not pause it), [`resumeLeadInCampaign`](/smart-api/reference/leads-in-campaigns/resumeLeadInCampaign) continues from where it left off (and ends an out-of-office wait), [`rescheduleLeadInCampaign`](/smart-api/reference/leads-in-campaigns/rescheduleLeadInCampaign) changes an out-of-office wait, [`stopLeadInCampaign`](/smart-api/reference/leads-in-campaigns/stopLeadInCampaign) stops it for good while keeping the history, [`retryLeadInCampaign`](/smart-api/reference/leads-in-campaigns/retryLeadInCampaign) re-queues a failed or stuck lead, and [`removeLeadFromCampaign`](/smart-api/reference/leads-in-campaigns/removeLeadFromCampaign) removes the membership while keeping the underlying lead. Pause applies only to a `pending` or `running` lead, resume only to a `paused` one or one waiting out of office, and stop to any of those three; on any other lead the call changes nothing and still returns `200`. Retry does not check the status and puts any lead back in the queue, so use it only on a `failed` or stuck one. [`getLeadInCampaign`](/smart-api/reference/leads-in-campaigns/getLeadInCampaign) returns full progress: the `stage`, the assigned `linkedinAccountId` and `emailAccountId`, the lead's `senderId` ([Senders](/smart-api/senders)), `currentStepId`, `waitUntil` and `waitReason`, `heldBy`, `pausedReason`, invite timestamps (`invitationSentAt`, `invitationAcceptedAt`, `invitationWithdrawnAt`) and `invitationSentWithoutNote`, `messageSentAt`, `lastReplyAt`, the first `emailOpenedAt` and `emailClickedAt`, `finishedAt`, `error`, and the append-only step `history`. [`listLeadsInCampaign`](/smart-api/reference/leads-in-campaigns/listLeadsInCampaign) returns the same fields without `history`. ### Labels [#labels] Each membership carries a `label`: how the person answered, as the inbox and the leads table show it. | Label | Means | | ---------------- | ----------------------------------------------------------------------------------------- | | `interested` | Open to a call, a demo, pricing or learning more. | | `not_interested` | A clear no to your offer, or an unsubscribe request. | | `neutral` | A real reply with no intent either way: "not right now", a question, an off-topic answer. | | `out_of_office` | Away for a while: an automatic away reply, or a message saying so. | With the campaign's `aiInterestEnabled` on, AI labels each reply (`labelBy: ai`), at the Reply analysis price per reply read (listed in Swarmhit under **Settings → Credits usage**, for workspace owners and admins). Campaigns created in Swarmhit start with it on; through the API it is off unless you pass `true`, and a campaign you copy keeps its original's setting. An automatic away reply by email is labelled `out_of_office` from the [out-of-office](#out-of-office) check's own reading when that runs (no second credit), else from its words, for free. AI's label follows the latest reply, even when replies are read out of order, with two exceptions: `neutral` never replaces `interested` or `not_interested` ("thanks, will read" after "let's talk" stays interested), and neither does `out_of_office`. A real reply after an away notice replaces it. Set a label yourself with [`setLeadInterest`](/smart-api/reference/leads-in-campaigns/setLeadInterest) (`{ "label": "out_of_office" }`, or `null` to clear it), in the app, or with the MCP server's `set_lead_label`. AI never changes a label a person set; with none, AI labels the lead's next reply. Each change fires [`lead.label_changed`](/smart-api/webhooks#leadlabel_changed) and goes on the lead's `history`: as `interest_changed` when it changes `interest`, as before labels, else as `label_changed`. Filter [`listLeadsInCampaign`](/smart-api/reference/leads-in-campaigns/listLeadsInCampaign) with `label` (a label, or `none`). `interest` (`interested`, `not_interested` or `null`) and `interestBy` stay as they were before labels: the label when it is interested or not interested, `null` otherwise. `{ "interest": ... }` still sets the label that way, and `null` clears it. Only the label is editable on a membership; lead fields and `customVariables` are updated via `PATCH /leads/{id}` and are shared across every campaign the lead is in. ## Stats [#stats] Each campaign carries a `stats` object: `stats.total` is the number of leads enrolled, while its `done` and `failed` counters are not maintained and read `0`. For live progress, filter [`listLeadsInCampaign`](/smart-api/reference/leads-in-campaigns/listLeadsInCampaign) by `stage` or `status`. For a workspace-wide rollup, [`getStats`](/smart-api/reference/general/getStats) breaks campaigns, lead progress, and sender accounts down by status and reports outreach totals (`invitesSent`, `invitesAccepted`, `replies`). Per-mailbox email results (sent, replies, bounces) are on [`GET /email-accounts/{id}/stats`](/smart-api/reference/email-accounts/getEmailAccountStats). ## Reference [#reference] # Changelog (/smart-api/changelog) ## 1.24.0: senders [#1240-senders] November 9, 2026 * New: [Senders](/smart-api/senders), the people your campaigns speak as. [`GET`](/smart-api/reference/senders/listSenders) and [`POST /senders`](/smart-api/reference/senders/createSender), [`GET`](/smart-api/reference/senders/getSender), [`PATCH`](/smart-api/reference/senders/updateSender) and [`DELETE /senders/{id}`](/smart-api/reference/senders/deleteSender). A sender has one LinkedIn account at most, any number of mailboxes and WhatsApp numbers, and the members who manage it. * `senderId` on LinkedIn accounts, mailboxes, WhatsApp numbers, leads in campaigns, inbox chats, and on every webhook payload with an `accountId`. `PATCH /accounts/{id}` and `PATCH /whatsapp-accounts/{id}` take `senderId`, and [`PATCH /email-accounts/{id}`](/smart-api/reference/email-accounts/updateEmailAccount) is new for it. * `senderIds` on campaigns, read and written. In a campaign whose pools hold accounts with a sender, a lead hears from one sender on every channel; campaigns whose accounts have no sender work as before. * `GET /inbox/chats?senderId=`. * MCP: `list_senders`, `get_sender`, `create_sender`, `update_sender`, `delete_sender`, and `senderIds` on `create_campaign` and `update_campaign`. ## Your own domains for bought mailboxes [#your-own-domains-for-bought-mailboxes] November 4, 2026 * New: [`POST /managed-domains`](/smart-api/reference/mailbox-orders/addOwnDomain) puts bought mailboxes on a domain the workspace already owns. Nothing is charged for the domain, only for its mailboxes. Set the two nameservers it returns at the registrar; the domain connects on its own, then add mailboxes with an `existing: true` entry in the quote. * New: [`GET /managed-domains/{id}`](/smart-api/reference/mailbox-orders/getManagedDomain), [`POST /managed-domains/{id}/check`](/smart-api/reference/mailbox-orders/checkOwnDomain) and [`DELETE /managed-domains/{id}`](/smart-api/reference/mailbox-orders/removeOwnDomain). * Every domain carries `source` (`purchased` or `own`), and a domain you own its `connection`. `reason` gains `connecting` and `moved`. Quotes and orders refuse a domain you own that is not ready (`409`, `code: domain-not-ready`). * New webhook events, sent only to endpoints that subscribe to them: `managed_domain.ready` and `managed_domain.action_required`. * MCP: `add_own_domain`, `get_managed_domain`, `check_own_domain` and `remove_own_domain`. See [Using a domain you own](/smart-api/mailboxes#using-a-domain-you-own). ## The JavaScript SDK [#the-javascript-sdk] October 28, 2026 * New: [`@swarmhit/swarmhit-sdk`](/smart-api/sdk), the official client for JavaScript and TypeScript, in beta (`1.0.0-beta.0`). It calls `/api/v1` with the same API key, covers every endpoint, types every parameter, result and webhook event, retries what can be retried safely, pages through lists and verifies webhooks. `npm install @swarmhit/swarmhit-sdk`. * Each endpoint in the [API Reference](/smart-api/reference) names its SDK method at the top, and [All methods](/smart-api/sdk/methods) lists them all. ## 1.9.0: archived LinkedIn accounts [#190-archived-linkedin-accounts] October 23, 2026 * `DELETE /accounts/{id}` archives the account instead of deleting it. The response is the same, and so are the effects: the session is revoked, the seat freed, the account leaves its campaigns and `GET /accounts`, and `GET /accounts/{id}` answers `404`. No new webhook is sent unless you subscribe to it. What changes: its conversations, its settings (limits, warm-up) and its history are kept, archived. * Archived accounts are kept for 90 days, then deleted permanently; pass `?permanent=true` to delete right away. * `DELETE /accounts/{id}?permanent=true` erases an account for good, live or archived, conversations and settings included. Use it where you delete accounts to erase their data. It takes a key created by an owner or admin. * New: [`GET /accounts?archived=true`](/smart-api/reference/accounts/listAccounts) lists the archived accounts, and [`POST /accounts/{id}/restore`](/smart-api/reference/accounts/restoreAccount) brings one back under the same id, with its limits and warm-up, disconnected until it is reconnected with its credentials, a `liAt` or a hosted link. `{"campaigns": true}` puts it back in its campaigns. A restore is refused (`400`, `code: already-connected`) once the same LinkedIn account is connected again. * New webhook events, sent only to endpoints that subscribe to them: `account.archived`, `account.restored` and `account.deleted` (`via: retention` when the 90 days ran out). * MCP: `disconnect_sender` archives (`permanent: true` erases), `list_accounts` takes `archived`, and `restore_account` is new. See [Archived accounts](/smart-api/sender-accounts#archived-accounts). # Leads Database (/smart-api/database) The Leads Database is a prospecting search over a global B2B database of people and companies. It runs entirely on the Swarmhit side: you do not need a connected LinkedIn account to use it, and no LinkedIn action limits apply. Use it to build lead lists from scratch, then hand the results to a [campaign](/smart-api/campaigns) for outreach. Two things shape how you should use it: Every search result returned and every enrichment that finds a match consumes workspace credits (see [Credits and errors](#credits-and-errors)). Keep pages small and filters tight. Results are transient: nothing you find is stored in your workspace until you add the person to a list or campaign. ## Endpoints [#endpoints] ## Searching people [#searching-people] [`searchPeopleDatabase`](/smart-api/reference/database/searchPeopleDatabase) takes a `filters` object, an optional `limit` (1 to 100, default 25), and an optional `cursor`. At least one filter with a value is required (`excludeExisting` alone does not count), and unknown filter keys are rejected with a 400. Array filters match any of their values and use at most 25 values each (extra values are ignored); text filters are fuzzy. Numeric and boolean filters must be JSON numbers and booleans: a string such as `"50"` or `"true"` is ignored. ```bash curl -X POST https://app.swarmhit.com/api/v1/database/people/search \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "filters": { "titles": ["CTO", "VP Engineering"], "countries": ["United States"], "headcountRanges": ["51-200", "201-500"] }, "limit": 25 }' ``` ### People filters [#people-filters] | Filter | Type | Matches | | ------------------------------------------------------------ | ------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `titles` / `excludeTitles` / `pastTitles` | string\[] | Current job titles (fuzzy), title words to exclude, past titles (fuzzy) | | `seniorities`, `departments`, `functions` | string\[] | Seniority levels, departments, job functions (use suggestions for valid values) | | `keywords` | string or string\[] | Headline keywords (fuzzy, ANY of the array) | | `keywordInProfile` | string or string\[] | Profile summary/bio keywords (fuzzy, ANY of the array) | | `minTenure` / `maxTenure` | number | Years in the current role | | `minYearsExperience` / `maxYearsExperience` | number | Total years of experience | | `recentlyChangedJobs` | boolean | Only people who recently started a new role | | `companyNames` / `pastCompanyNames` | string\[] | Current or past employer names (fuzzy) | | `companyDomains` | string\[] | Current company website domains | | `industries`, `companyTypes`, `companyHqCountries` | string\[] | Current company industry, type, HQ country | | `headcountRanges` | string\[] | Current company size, exact values: `1-10`, `11-50`, `51-200`, `201-500`, `501-1000`, `1001-5000`, `5001-10000`, `10001+` | | `locations`, `states`, `countries`, `continents` | string\[] | Person location: free-text (fuzzy), states/regions (fuzzy), full country names like `"United States"`, continents | | `fullName` | string | Person full name (fuzzy) | | `languages`, `skills`, `schools`, `degrees`, `fieldsOfStudy` | string\[] | Profile attributes (schools, degrees and fields of study are fuzzy) | | `hasVerifiedEmail` | boolean | Only people with a verified business email at their current company | | `excludeExisting` | boolean | Hide people already saved as workspace leads, and never charge for them | Role and company filters all apply to the same, primary current position, so the `company` on each result always satisfies them. Someone whose main job is elsewhere is not matched through a secondary role. Each result is a person record with a stable database `id`, name, headline, title, location, picture, current `company` (with domain, headcount, industries), `pastCompanies`, `education`, `yearsOfExperience`, and three fields worth special attention: * `linkedinUrl`: use this to add the person to a list or campaign. * `contact`: availability flags only (`hasBusinessEmail`, `hasPersonalEmail`, `hasPhone`). The actual email comes from [enrichment](#enrichment). * `saved`: true when the person is already saved as a workspace lead. When that lead already has an email, the result also carries it in `businessEmails`, at no extra cost. ## Searching companies [#searching-companies] [`searchCompaniesDatabase`](/smart-api/reference/database/searchCompaniesDatabase) works the same way (at least one filter, `limit` 1 to 100, cursor paging) and additionally accepts a `sort` with `key` (`headcount`, `funding`, `founded`, `followers`, `lastRound`) and `dir` (`asc`, `desc`; default `desc`). An unknown `key` is ignored and the results come back unsorted. ```bash curl -X POST https://app.swarmhit.com/api/v1/database/companies/search \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "filters": { "industries": ["Software Development"], "countries": ["USA"], "minHeadcount": 50 }, "sort": { "key": "headcount", "dir": "desc" }, "limit": 25 }' ``` ### Company filters [#company-filters] | Filter | Type | Matches | | ------------------------------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | | `names` | string\[] | Company names (fuzzy) | | `domains` | string\[] | Website domains | | `industries` | string\[] | Industries (use suggestions for valid values) | | `countries` | string\[] | ISO 3-letter HQ country codes, e.g. `"USA"` | | `headcountRanges`, `minHeadcount`, `maxHeadcount` | string\[] / number | Company size. `headcountRanges` takes exact values: `1-10`, `11-50`, `51-200`, `201-500`, `501-1000`, `1001-5000`, `5001-10000`, `10001+` | | `minGrowth12m` | number | Minimum 12-month headcount growth, percent | | `foundedAfter` / `foundedBefore` | number | Founding year | | `minFundingUsd`, `lastRoundTypes`, `investors` | number / string\[] | Funding raised, last round type, investors | | `technologies` | string\[] | Technologies in use | Note the country formats differ: people search takes full country names (`"United States"`), company search takes ISO 3-letter codes (`"USA"`). ## What your workspace supports [#what-your-workspace-supports] The filters above are the full vocabulary. The database behind a workspace can support only part of it, with fewer company sorts and suggestion fields. [`databaseCapabilities`](/smart-api/reference/database/databaseCapabilities) is free and returns, for `people` and `companies`, the `filters`, `sorts` (companies) and `suggestionFields` your workspace can use. A search that sets any other filter is rejected with a 400 that names it. The `people` block also carries two flags. When `emailAvailabilityKnown` is `false`, search results omit `contact` until the person is enriched. When `excludeSavedServerSide` is `false`, saved people always come back flagged `saved: true` and are never charged, and `excludeExisting` has no effect on the results. ## Finding valid filter values [#finding-valid-filter-values] Several filters only accept known values (industries, seniorities, headcount ranges, and so on). [`databaseSuggestions`](/smart-api/reference/database/databaseSuggestions) autocompletes them, and it is free: it consumes no credits. ```bash curl -X POST https://app.swarmhit.com/api/v1/database/suggestions \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "entity": "companies", "field": "industry", "query": "software" }' ``` Pass `entity` (`people` or `companies`) and a `field`. Omit `query` to get the most common values. `data` is an array of up to 15 strings. Supported fields: * People: `title`, `keyword`, `seniority`, `department`, `function`, `company`, `industry`, `companyType`, `hqCountry`, `country`, `city`, `state`, `continent`, `language`, `skill`, `school`, `degree`, `fieldOfStudy`, `headcountRange` * Companies: `industry`, `country`, `headcountRange`, `lastRoundType`, `investor`, `technology`, `name` `headcountRange` returns a closed eight-value set on both entities. A field outside your workspace's `suggestionFields` is rejected with a 400. ## Paging [#paging] Search responses carry a `paging` object with `nextCursor` (null when the result set is exhausted) and `totalCount` (when the database reports it). To get the next page, send the same request again with `cursor` set to the returned `nextCursor`. Filters must stay identical between pages. A cursor the database can no longer resume is rejected with a 400 asking you to start the search again. ```bash curl -X POST https://app.swarmhit.com/api/v1/database/people/search \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "filters": { "titles": ["CTO"], "countries": ["United States"] }, "limit": 25, "cursor": "NEXT_CURSOR_FROM_PREVIOUS_RESPONSE" }' ``` Since credits are charged per result returned, do not page further than you need. Fetching 4 pages of 25 costs the same as one page of 100, so there is no discount for large pages either: the lever is total results, and tighter filters are how you reduce it. ## Enrichment [#enrichment] Search results are summaries. Enrichment fetches the full record for one person or company. ### People [#people] [`enrichPersonDatabase`](/smart-api/reference/database/enrichPersonDatabase) takes a `linkedinUrl` (required, a `linkedin.com/in/...` profile URL) and an `includeEmail` flag (default false). It returns the search result fields (without `saved`) plus `summary`, `connections`, `followers`, `skills`, `websites` and `businessEmails`. `businessEmails` lists verified business emails and is only filled with `includeEmail: true` (an empty array otherwise). The email lookup costs extra credits, charged only when it finds an email. Without `includeEmail` the response omits the `contact` availability flags. ```bash curl -X POST https://app.swarmhit.com/api/v1/database/people/enrich \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "linkedinUrl": "https://www.linkedin.com/in/jane-doe", "includeEmail": true }' ``` The email lookup is the same as [find-email](#emails-and-phone-numbers): when you only need the email, call that instead and skip the profile. If the email lookup cannot finish, the profile is still returned, with `emailLookupPending: true`: ask again for the email. When the person is already saved as a workspace lead, an email found with `includeEmail` is also stored on that lead. If the lead already has an email, `includeEmail` returns that email without looking it up again, so only the base enrichment is charged. ### Emails and phone numbers [#emails-and-phone-numbers] [`findEmailDatabase`](/smart-api/reference/database/findEmailDatabase) finds a person's verified work email from their `linkedinUrl`, or from `firstName`, `lastName` and `company` (name) or `domain`; send everything you know. It answers `{ "email": "..." }`, or `{ "email": null }` when none is found. [`findPhoneDatabase`](/smart-api/reference/database/findPhoneDatabase) finds mobile phone numbers from a `linkedinUrl` and answers `{ "phones": ["+14155550142"] }`, primary first, or an empty array. Each is charged only when something is found, at the Email finder or Phone finder price; a miss is free, but can take up to a minute to answer. ```bash curl -X POST https://app.swarmhit.com/api/v1/database/people/find-email \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "firstName": "Jane", "lastName": "Doe", "domain": "stripe.com" }' ``` Nothing is stored, except when the LinkedIn URL is a saved workspace lead: then a value the lead already has is returned free, and a new one is kept on the lead. To look up leads in bulk, see [Finding emails and phone numbers](/smart-api/leads-and-lists#finding-emails-and-phone-numbers). ### Companies [#companies] [`enrichCompanyDatabase`](/smart-api/reference/database/enrichCompanyDatabase) resolves a company from its website `domain` (preferred), `linkedinUrl`, or `name` (at least one is required; when you pass several, the first in that order is used), and returns the full record in the same shape as a company search result: headcount and `headcountGrowth12m`, `funding` (total, last round amount/type/date, investors), `estimatedRevenue` range, `followers`, and `openings` (the number of open job listings). ```bash curl -X POST https://app.swarmhit.com/api/v1/database/companies/enrich \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "domain": "stripe.com" }' ``` Both enrich endpoints return 404 when nothing matches, and a miss is not charged. ## Keeping results: lists and campaigns [#keeping-results-lists-and-campaigns] Database results are transient. Swarmhit does not store them, and re-running a search bills again. As soon as a person is worth keeping, save them using the `linkedinUrl` from the result as the lead's `profileUrl`: * Add them to a list with [`addListLeads`](/smart-api/reference/lists/addListLeads), then enroll the list later. * Or enroll them directly with [`addLeadsToCampaign`](/smart-api/reference/leads-in-campaigns/addLeadsToCampaign); they are saved to the campaign's own list as well. Saving into a campaign's own list with `addListLeads` enrolls them too. Both take up to 1,000 leads per request, and upsert the workspace lead and attach it in one call. Copy any other fields you want on the lead: `firstName`, `lastName`, `headline` and `location` keep their names, the result's `title` goes in `jobTitle`, `company.name` in `company`, and an enriched email in `email`. See [Leads and lists](/smart-api/leads-and-lists) for how leads work. ```bash curl -X POST https://app.swarmhit.com/api/v1/lists/LIST_ID/leads \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "leads": [ { "profileUrl": "https://www.linkedin.com/in/jane-doe", "firstName": "Jane", "lastName": "Doe", "company": "Acme" } ] }' ``` Two fields help you avoid duplicating work: set `excludeExisting: true` in your people search to hide anyone already saved as a workspace lead, and check the `saved` flag on each result before adding. The database can miss some saved people when applying `excludeExisting` (for example in a workspace with more than 25,000 leads); any that come back are flagged `saved: true` and not charged. ## Credits and errors [#credits-and-errors] Credit consumption in one place. Each price can change: the current prices are listed in Swarmhit under **Settings → Credits usage** (workspace owners and admins can open it), by the names below. | Call | Charged | | ---------------------------- | -------------------------------------------------------------------------------------------------------------- | | People or company search | The Database search price per result returned (saved people hidden by `excludeExisting` are not charged) | | Person enrichment | The Person enrichment price per matched person, plus the Email finder price when `includeEmail` finds an email | | Company enrichment | The Company enrichment price per matched company | | Find email | The Email finder price per email found, free when none is | | Find phone | The Phone finder price per phone found, free when none is | | Suggestions and capabilities | Free | Credits come from the workspace's credit pool, or from its organization's pool when the workspace belongs to one. Once the pool is empty, searches and enrichments are refused with `400` and `code: warning-no-credits`. Database endpoints return these errors: * `400` with `code: warning-database-query`: the search or lookup cannot run as sent, for example an unknown filter, a filter your workspace does not support, no filter with a value, an unknown `headcountRanges` value, an expired cursor, an unknown suggestion field, or an invalid LinkedIn profile URL. The message says what to fix. * `400` with `code: warning-no-credits`: the credit pool is empty. * `400` without a code: a malformed request, such as a `limit` outside 1 to 100, a missing `linkedinUrl`, or no `domain`, `linkedinUrl` or `name` on a company enrichment. * `404`: an enrichment matched nothing. Nothing is charged. * `409` with `code: lookup_running`: an email or phone lookup is already running on that saved lead. Retry in a minute. * `429` with `code: rate_limited`: the database is busy right now. Retry after a short delay. * `503` with `code: unavailable`: the database is temporarily unavailable. Retry later. See [Errors](/smart-api#errors) for the error envelope format. # Direct actions (/smart-api/direct-actions) Direct actions drive a connected sender account one action at a time: connection requests, messages, InMails, profile visits, follows, post likes, post comments and connection lookups, all outside a campaign. A connected mailbox sends [emails](#emails) the same way, and a sender with Sales Navigator can [search companies and read its saved lists](#sales-navigator-company-search-and-saved-lists). They are the building blocks for custom outreach tooling: your own scheduler, a CRM-triggered follow-up, a one-off send from an internal dashboard. If you want Swarmhit to run a multi-step sequence for you, use [Campaigns](/smart-api/campaigns) instead. Every LinkedIn action runs from one sender account, so the endpoints live under `/accounts/{id}/...`; emails run from a mailbox, under `/email-accounts/{id}/emails`. Get the account id from [List accounts](/smart-api/reference/accounts/listAccounts). A paused sender rejects every POST action with `409` `code: sender_paused`; resume it first (see [Sender accounts](/smart-api/sender-accounts)). Withdrawals and the reads still work while a sender is paused. Each POST action counts against the sender's daily cap for that action type (the same counters its campaigns use, reset at UTC midnight), and connection requests also count against a rolling weekly limit and a cap on pending invitations. Read [LinkedIn safety](/smart-api/linkedin-safety) before building on these endpoints. ## Endpoints [#endpoints] | Action | Method and path | Reference | | ---------------------------------------- | --------------------------------------------------- | ---------------------------------------------------------------------------------------------- | | Send a connection request | `POST /accounts/{id}/connections` | [sendConnectionRequest](/smart-api/reference/direct-actions/sendConnectionRequest) | | Withdraw a pending request | `DELETE /accounts/{id}/connections/{identifier}` | [withdrawConnectionRequest](/smart-api/reference/direct-actions/withdrawConnectionRequest) | | Message a connection | `POST /accounts/{id}/messages` | [sendDirectMessage](/smart-api/reference/direct-actions/sendDirectMessage) | | Send an InMail | `POST /accounts/{id}/inmails` | [sendInmail](/smart-api/reference/direct-actions/sendInmail) | | Visit a profile | `POST /accounts/{id}/profile-views` | [visitProfile](/smart-api/reference/direct-actions/visitProfile) | | Follow a lead | `POST /accounts/{id}/follows` | [followProfile](/smart-api/reference/direct-actions/followProfile) | | Like a post, or react to it | `POST /accounts/{id}/post-likes` | [likeLastPost](/smart-api/reference/direct-actions/likeLastPost) | | Comment on a post, or reply to a comment | `POST /accounts/{id}/post-comments` | [commentLastPost](/smart-api/reference/direct-actions/commentLastPost) | | List a post's comments | `GET /accounts/{id}/post-comments` | [listPostComments](/smart-api/reference/direct-actions/listPostComments) | | Check connection status | `GET /accounts/{id}/connections/{identifier}` | [checkConnection](/smart-api/reference/direct-actions/checkConnection) | | Look up a profile | `GET /accounts/{id}/profiles/{identifier}` | [lookupProfile](/smart-api/reference/direct-actions/lookupProfile) | | Search Sales Navigator companies | `GET /accounts/{id}/sales-navigator/account-search` | [salesNavigatorAccountSearch](/smart-api/reference/direct-actions/salesNavigatorAccountSearch) | | List saved Sales Navigator lists | `GET /accounts/{id}/sales-navigator/lists` | [listSalesNavigatorLists](/smart-api/reference/direct-actions/listSalesNavigatorLists) | | List direct actions | `GET /accounts/{id}/actions` | [listOutreachActions](/smart-api/reference/direct-actions/listOutreachActions) | | Send an email | `POST /email-accounts/{id}/emails` | [sendEmail](/smart-api/reference/direct-actions/sendEmail) | | List emails sent through the API | `GET /email-accounts/{id}/emails` | [listDirectEmails](/smart-api/reference/direct-actions/listDirectEmails) | The connection-status and profile-lookup reads are silent: no profile view is registered on LinkedIn and no cap is used. Listing a post's comments is a silent read too, and so are the two Sales Navigator reads. Withdrawing a request uses no cap either. ## How targets are resolved [#how-targets-are-resolved] POST endpoints take the target as a `profileUrl` in the JSON body. Swarmhit resolves the URL to a live LinkedIn profile before acting; if it matches no profile, the request fails with `404` and `code: profile_not_found` (distinct from a plain `404`, which means the Swarmhit resource in the path does not exist). The GET reads and the withdrawal take the profile's public identifier in the path instead: the username in `linkedin.com/in/{identifier}`, for example `john-doe`. Use the username form in both places, in lowercase. An identifier with uppercase letters or underscores is read as LinkedIn's internal member id (as in `linkedin.com/in/ACoAA...`), which these endpoints do not accept: POST actions and the GET reads fail with `400`, and a withdrawal finds nothing to cancel (`alreadyGone: true`). On [sendDirectMessage](/smart-api/reference/direct-actions/sendDirectMessage) and [sendInmail](/smart-api/reference/direct-actions/sendInmail) you can also pass `providerId`, the recipient's stable LinkedIn id: `lead.providerId` on a `connection.accepted` [webhook](/smart-api/webhooks), or `from.providerId` on `message.received`. When supplied, the send skips the profile lookup, which is the slow round-trip on this path. `profileUrl` is still required, in the username form, so the contact can be tracked. Store the id when the webhook arrives and reuse it for follow-up sends. ## Sending an invite [#sending-an-invite] ```bash curl -X POST "https://app.swarmhit.com/api/v1/accounts/ACCOUNT_ID/connections" \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 5f0c2a4e-9d3b-4c1a-8e7f-2b6d9a1c3e5f" \ -d '{ "profileUrl": "https://www.linkedin.com/in/john-doe", "note": "Hi John, enjoyed your post on onboarding flows. Would love to connect." }' ``` The response `data` is the logged action. A connection request starts with `status: pending` and later becomes `accepted` or `withdrawn`; every other action is logged as `sent`. The profile is tracked as a lead in your workspace, and a `connection.accepted` webhook (`source: direct`) fires when the invitation is accepted. Notes are a premium feature: up to 300 characters (a longer note is rejected with `400` and nothing is sent). Free sender accounts cannot include one, so their invites go out without the note rather than failing, and the returned action has `note: null`. If this sender already has a pending invitation to the profile from an earlier direct request, the send returns `409`. To cancel a pending request, call [withdrawConnectionRequest](/smart-api/reference/direct-actions/withdrawConnectionRequest); it is safe to call when nothing is pending and returns `{ withdrawn, alreadyGone }`, with `alreadyGone: true` when LinkedIn has no pending invitation left to cancel. It also marks the logged invitation `withdrawn`, which clears the `pending` status Swarmhit reports for that profile. ## Messages and InMails [#messages-and-inmails] [sendDirectMessage](/smart-api/reference/direct-actions/sendDirectMessage) messages a profile the sender is already connected to. It takes `profileUrl` and `text`, plus the optional `providerId`. To reply inside an existing conversation, use the [Inbox](/smart-api/inbox) endpoint ([sendChatMessage](/smart-api/reference/inbox/sendChatMessage)) instead. ```bash curl -X POST "https://app.swarmhit.com/api/v1/accounts/ACCOUNT_ID/messages" \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 8a1d3f5b-2c4e-4a6d-9b8c-7e5f1a3d2c4b" \ -d '{ "profileUrl": "https://www.linkedin.com/in/john-doe", "providerId": "PROVIDER_ID_FROM_WEBHOOK", "text": "Thanks for connecting, John!" }' ``` [sendInmail](/smart-api/reference/direct-actions/sendInmail) reaches non-connections. It takes `profileUrl`, `text` and an optional `subject`, plus the optional `providerId`. It requires a premium LinkedIn plan on the sender (free senders cannot send any; you get `409` `code: inmail_not_available`). Sales Navigator and Recruiter senders InMail open profiles free up to a monthly allowance (about 800 per month, plan-dependent); every other send, including classic Premium, draws the sender's InMail credit balance, visible as `inmailCredits` on the account. When credits run out you get `429` `code: inmail_credits_exhausted`. Not sure which channel applies? [checkConnection](/smart-api/reference/direct-actions/checkConnection) returns the sender's relationship with a profile: `status` (`not_connected`, `pending`, `connected`), connection `degree`, `openProfile`, and whether this sender `canMessage` or `canInvite` them, without using any cap. `pending` only reflects invitations this sender sent through direct actions: an invitation sent by a campaign or from LinkedIn itself shows as `not_connected`. `canMessage` is true for a connection, or for an open profile when the sender has Sales Navigator or Recruiter. [lookupProfile](/smart-api/reference/direct-actions/lookupProfile) returns the profile's name, headline and picture with the same relationship fields under `relationship`. ## Visits, follows, likes and comments [#visits-follows-likes-and-comments] [visitProfile](/smart-api/reference/direct-actions/visitProfile) registers a real profile view (the person sees it), and [followProfile](/smart-api/reference/direct-actions/followProfile) follows the lead without connecting. Both take `profileUrl`. [likeLastPost](/smart-api/reference/direct-actions/likeLastPost) and [commentLastPost](/smart-api/reference/direct-actions/commentLastPost) act on a post. Pass one of: * `profileUrl`: the lead's most recent post, whatever its age. They fail with `422` when the lead has no post. * `postUrl`: that exact post, by the link LinkedIn gives it: `linkedin.com/posts/...` ("Copy link to post") or `linkedin.com/feed/update/urn:li:activity:...` (also `urn:li:ugcPost:` and `urn:li:share:`). A link that isn't a post's, or a post the account can't see, fails with `422` `code: post_not_found`. `likeLastPost` leaves a like unless you pass a `reactionType`: `celebrate`, `support`, `love`, `insightful` or `funny`. Any reaction counts as one post like. `commentLastPost` takes the exact comment `text`. With a `postUrl`, it can reply to one of the post's comments instead: pass that comment's `commentId`, or use the comment's own link (LinkedIn's "Copy link to comment") as the `postUrl`. A reply that can't be made, because the comment in the link can't be read or the comment is on another post, fails with `400` and nothing is posted: it never becomes a comment on the post. ```bash curl -X POST "https://app.swarmhit.com/api/v1/accounts/ACCOUNT_ID/post-comments" \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 5d2b8e4a-7c1f-4b3e-9a6d-2f8c4e1b7a5d" \ -d '{ "postUrl": "https://www.linkedin.com/posts/jane-doe_hiring-activity-7332661864792854528-hcGT", "commentId": "7335000001439513601", "text": "Thanks for sharing this, Sam." }' ``` [listPostComments](/smart-api/reference/direct-actions/listPostComments) returns a post's comments, most recent first, so you can pick one to reply to. Each has its `id` (the `commentId` a reply takes), `text`, `author` (`name`, `headline`, `profileUrl`, `picture`), `createdAt`, `replyCount` and `reactionCount`. Replies to a comment aren't listed. Paginate by passing `paging.nextCursor` back as `cursor`. On likes and comments, the action's `postUrl` is the post acted on, a comment's text is in `note`, `replyTo` is the comment a reply answered, and `reactionType` is the reaction left. A post given by its link isn't tied to a lead: no lead is created and `leadId` is `null`, and `profileUrl` is the post's author, or `null` when a company wrote it. By default a sender can make 40 profile views, 30 follows, 30 post likes and 15 post comments per day. ## Sales Navigator company search and saved lists [#sales-navigator-company-search-and-saved-lists] A sender with a Sales Navigator seat can search Sales Navigator for companies and read its saved lists. Both are reads on the sender's own session: nothing is sent, no cap is used, and they aren't logged as direct actions. [salesNavigatorAccountSearch](/smart-api/reference/direct-actions/salesNavigatorAccountSearch) runs an account (company) search and returns the companies directly, 25 a page. Pass one of: * `keywords`: free text, as typed in Sales Navigator's search box. * `url`: the link of a Sales Navigator company (account) search, copied from the browser's address bar (`https://www.linkedin.com/sales/search/company?query=...`), to run it with its filters as they are. With both, the `url` is used. The first search on a sender can take a few minutes, while Swarmhit signs it in to Sales Navigator, so allow up to 4 minutes before timing out. The `url` must be a company search with its search in `query`, 4,000 characters at most. Any other link fails with `400` and `code: invalid_search_url`, even with `keywords`, and nothing is sent to LinkedIn: a Sales Navigator people search, a classic LinkedIn search, a Sales Navigator page with no search in it, or a link that isn't LinkedIn's. The message says what the link is. A link pasted without `https://`, or with `http://`, is read as the `https://` page. To turn a people search into leads, use a [lead import](/smart-api/leads-and-lists#lead-imports-from-a-linkedin-search). ```bash curl "https://app.swarmhit.com/api/v1/accounts/ACCOUNT_ID/sales-navigator/account-search?keywords=logistics%20software" \ -H "Authorization: Bearer swh_live_..." ``` Each company has its Sales Navigator `id`, `name`, `industry`, `location`, `employeeCount`, `saved` (whether it is already in one of the sender's saved lists), `profileUrl` (its Sales Navigator page) and `picture` (a logo link that expires). A field LinkedIn doesn't give is `null`. `paging.total` is the number of matching companies when LinkedIn reports it, otherwise `null`. For the next page, pass `paging.nextCursor` back as `cursor`, with the same `keywords` or `url`. It is `null` when there are no more pages; when `paging.total` is `null`, the last page can be empty. [listSalesNavigatorLists](/smart-api/reference/direct-actions/listSalesNavigatorLists) returns the sender's saved lists: lead lists by default, or account lists with `type=ACCOUNT`. Each has its `id`, `name`, `type` (`LEAD` or `ACCOUNT`), `totalCount` (how many leads or accounts it holds), `description`, `shared`, `createdAt` and `updatedAt`. It returns up to 100 lists, with no paging. These two reads fail differently from the actions: | Status | Meaning | | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | Neither `keywords` nor `url`, a `url` that isn't a Sales Navigator company search (`code: invalid_search_url`), or a `type` other than `LEAD` or `ACCOUNT`. | | `409` | The account isn't connected or can't be used right now, for example because LinkedIn refused its session and it may need reconnecting. The message says why. | | `422` | Not available for this account: it has no Sales Navigator seat, Sales Navigator search isn't available on it yet, or the search can't run for it. Retrying won't help. | | `429` | LinkedIn is rate-limiting the account. Back off and retry later. | | `502` | LinkedIn is temporarily unavailable. Retry later. | | `503` | Sales Navigator isn't reachable for this account right now, for example while its sign-in is still running. Retry in a minute. | ## Emails [#emails] [sendEmail](/smart-api/reference/direct-actions/sendEmail) sends one email from a connected [mailbox](/smart-api/mailboxes) to an address, outside any campaign. It takes `to`, `subject` and a plain-text `text`; the mailbox's signature is added, and its open and click tracking settings apply. ```bash curl -X POST "https://app.swarmhit.com/api/v1/email-accounts/MAILBOX_ID/emails" \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 3c9e1f7a-5b2d-4e8f-a1c6-9d0b7e4f2a8c" \ -d '{ "to": "maya@lindqvist.io", "subject": "Quick question about your Q4 hiring", "text": "Hi Maya,\n\nSaw your post on scaling the support team. Worth a quick chat?" }' ``` The response `data` is the sent email: its `status`, `to`, `subject`, RFC 5322 `messageId`, the `leadId` the address belongs to (a lead that already has the address is reused, otherwise one is created), and `chatId`, the [inbox](/smart-api/inbox) chat holding the thread. To follow up in the same thread, reply in that chat with [sendChatMessage](/smart-api/reference/inbox/sendChatMessage); another `sendEmail` starts a new thread. Like a campaign email, it counts against the mailbox's daily email cap (`limits.emails`, reset at UTC midnight) and the workspace's email allowance, and it fires `email.sent` with `source: direct`. A reply lands in the same thread and fires `email.received` with `source: direct`. A bounce or spam report fires `email.bounced` and turns the email's `status` to `bounced`, with the reason in `error`; an email that could not be delivered at all turns `failed`. [listDirectEmails](/smart-api/reference/direct-actions/listDirectEmails) lists a mailbox's emails, newest first, filterable by `status`. Nothing is sent, and the request fails with `409`, when: | Code | Meaning | | ----------------------- | ------------------------------------------------------------------------------- | | `mailbox_paused` | The mailbox is paused. Resume it first. | | `mailbox_warming` | Warmup still holds the mailbox (see [The hold](/smart-api/mailboxes#the-hold)). | | `mailbox_not_connected` | The mailbox is not connected. Reconnect it. | | `email_opted_out` | The address unsubscribed or reported an email as spam. | | `email_bounced` | Email to the address bounced for good before. | The email channel has to be enabled for the workspace (`403` otherwise), and while email is paused for the workspace the request fails with `400` and `code: email-limit-reached`. ## Idempotent sends [#idempotent-sends] Every POST direct action accepts an `Idempotency-Key` header: a unique key per action (a UUID works), up to 255 characters. Reusing the key replays the first response instead of sending again, so a retry cannot double-send. It is recommended on all writes. The failure mode it exists for: your request times out at the network level, but the send actually went through. Without a key, retrying would message the person twice. With a key, retry the exact same request with the same key and you get the original response back. A key is scoped to the endpoint and the sender account or mailbox, and is kept for 24 hours; after that, the same key runs the action again. Only a successful response is stored and replayed. A `4xx` means the action did not happen, so it frees the key: once the cause is fixed (for example after a `429` window resets), retrying with the same key runs the action. A `5xx` keeps the key, because the action may have gone out. The request body is not compared: a reused key replays the first response even if the new body differs. Two idempotency-specific `409` codes can come back on a retry: | Code | Meaning | What to do | | ------------------------- | ---------------------------------------------- | -------------------------------------------------------- | | `idempotency_in_progress` | A request with the same key is still in flight | Retry shortly | | `idempotency_failed` | The original's outcome couldn't be confirmed | Check whether it went through, then retry with a new key | ## Error handling [#error-handling] Branch on `error.code`, not on `error.message` (the wording may change, codes will not). | Status | Meaning | | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | Invalid input, for example a missing `profileUrl` or `text`, both a `postUrl` and a `profileUrl`, a `reactionType` LinkedIn doesn't offer, a `note` over 300 characters, a profile URL or identifier that is not a username, or an `Idempotency-Key` over 255 characters | | `404` `code: profile_not_found` | The profile URL or identifier matched no LinkedIn profile | | `409` | A conflict: this sender already has a pending direct invitation to the profile, or the sender's LinkedIn session was rejected or has expired and it may need reconnecting. Codes: `sender_paused`, `inmail_not_available`, `idempotency_in_progress`, `idempotency_failed`, and for emails the codes [above](#emails) | | `422` | LinkedIn could not complete the action on this profile: cannot invite, already connected, the recipient cannot be messaged by this sender, or (for post likes and comments) the lead has no recent post. For a post given by its link: `code: post_not_found` when the link isn't a post's or the account can't see the post; otherwise the post takes no comments or reactions from this account, or LinkedIn refused the comment, reply or reaction. For an email, the mailbox could not send it | | `429` | A sender limit was hit, or LinkedIn is rate-limiting the sender. Codes: `inmail_credits_exhausted` for InMail credits, otherwise none | | `502` | LinkedIn, or for an email the mail service, is temporarily unavailable. Retry later; with an `Idempotency-Key`, use a new key | A `422` is about the target, not the request, so retrying it unchanged will usually fail again; skip the target or switch action type instead. A `429` from a Swarmhit limit carries a `details` object `{ limit, used, scope, resetAt }`: * `scope: day`: the daily cap for the action type. `resetAt` is the next UTC midnight. * `scope: week`: connection requests the sender sent, from campaigns and direct actions, in a rolling 7-day window (tier-based: about 130 for free sender accounts, 150 for paid). `resetAt` is when the oldest request in the window ages out. * `scope: pending`: the sender's pending-invitation backlog, campaigns included (500 by default). There is no `resetAt`; it clears as invitations are accepted or withdrawn. An InMail credit refusal (`code: inmail_credits_exhausted`) has `details.scope: inmail_credits` instead, without `limit`, `used` or `resetAt`. A `429` with no `details` means LinkedIn itself is rate-limiting the sender: back off well beyond your usual spacing. Schedule your retry for `resetAt` rather than hammering the endpoint. ## Tracking what was sent [#tracking-what-was-sent] [listOutreachActions](/smart-api/reference/direct-actions/listOutreachActions) returns the sender's direct actions newest first, paged with `limit` (default 50, max 100) and `offset`, and filterable by `type` (`invitation`, `message`, `inmail`, `profile_view`, `follow`, `post_like`, `post_comment`) and `status` (`pending`, `sent`, `accepted`, `withdrawn`). Each action records the target (`profileUrl`, `publicIdentifier`, `leadId`), the note or comment text, the `postUrl` for likes and comments, the `replyTo` comment of a reply, the `reactionType` of a like, and `sentAt` / `acceptedAt` / `withdrawnAt` timestamps. A request that fails returns its error and is not logged, so the list only holds actions that went out. For push-style tracking, subscribe to [webhooks](/smart-api/webhooks) instead of polling: besides `connection.accepted`, a reply from a lead you acted on directly fires `message.received` with `source: direct` (`campaign` when the conversation belongs to a campaign), and `email.received` when it comes by email. ## Approvals: AI drafts awaiting review [#approvals-ai-drafts-awaiting-review] Approvals are the human-review queue for AI-generated content. It is produced by campaigns, not by the direct-action endpoints: a sequence step with `config.requireApproval: true` generates its content and holds it instead of sending it. Two step types feed the queue, and each has its own endpoints. The API lets you build that review flow into your own tools. An item left undecided for 7 days is marked `skipped`, and the lead moves on without it. ### AI comment drafts [#ai-comment-drafts] A `comment_post` step drafts a comment on the lead's latest post and holds it as a pending comment. Each draft carries the post context (`postUrl`, `postText`), the AI `prompt`, the drafted `comment`, the lead's identity, and a `status`: `pending`, `approved`, `rejected`, `posted`, `skipped` or `failed`. * [listPendingComments](/smart-api/reference/approvals/listPendingComments) lists drafts, defaulting to `status=pending` (pass `status=all` for every status), filterable by `campaignId`. Fetch one with [getPendingComment](/smart-api/reference/approvals/getPendingComment). * [editPendingComment](/smart-api/reference/approvals/editPendingComment) replaces the draft text without approving it. * [approvePendingComment](/smart-api/reference/approvals/approvePendingComment) approves the draft, optionally with replacement text, and the campaign posts it on its next run. * [rejectPendingComment](/smart-api/reference/approvals/rejectPendingComment) discards the draft; the lead skips the comment step and continues through the sequence. * [regeneratePendingComment](/smart-api/reference/approvals/regeneratePendingComment) asks the AI to re-draft, optionally with a new instruction, keeping the same post context. Each re-draft uses workspace credits. Text you pass to edit or approve is trimmed to 1,200 characters. Edit, approve, reject and regenerate only work on a draft that is still `pending`; on one already decided they return `400` with `code: warning-pending-comment-decided`. ```bash curl -X POST "https://app.swarmhit.com/api/v1/pending-comments/PENDING_COMMENT_ID/approve" \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "text": "Great breakdown, especially the point on activation metrics." }' ``` To comment with exact text you control, outside any campaign, use the direct [commentLastPost](/smart-api/reference/direct-actions/commentLastPost) endpoint; no approval step is involved. ### AI voice notes [#ai-voice-notes] An `ai_voice_note` step renders the lead's personalized script as audio and holds it as a pending voice note. Each one carries the exact spoken `script`, a playable `audioUrl` with its `durationMs`, the voice it used, the lead's identity, and a `status`: `pending`, `approved`, `rejected`, `sent`, `skipped` or `failed`. * [listPendingVoiceNotes](/smart-api/reference/approvals/listPendingVoiceNotes) lists notes, defaulting to `status=pending` (pass `status=all` for every status), filterable by `campaignId`. Fetch one with [getPendingVoiceNote](/smart-api/reference/approvals/getPendingVoiceNote). * [approvePendingVoiceNote](/smart-api/reference/approvals/approvePendingVoiceNote) approves the note as it stands, and the campaign sends it on its next run. * [rejectPendingVoiceNote](/smart-api/reference/approvals/rejectPendingVoiceNote) discards it; the lead continues down the still-no-reply branch without it. * [regeneratePendingVoiceNote](/smart-api/reference/approvals/regeneratePendingVoiceNote) re-records the note, optionally rewriting what it says. There is no endpoint to edit the script on its own. The script is what the stored audio actually says, so the two are always replaced together: rewrite and re-record in one call, then approve once you have listened to the result. A rewritten script can use `{{variables}}` and is trimmed to 800 characters. As with comments, only a `pending` note can be approved, rejected or re-recorded (`400` with `code: warning-pending-voice-note-decided` otherwise). ```bash curl -X POST "https://app.swarmhit.com/api/v1/pending-voice-notes/PENDING_VOICE_NOTE_ID/regenerate" \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "script": "Hey {{firstName}}, quick thought on your launch last week." }' ``` Re-recording new wording uses the workspace's credits. Re-recording wording that was already generated replays the cached audio for free. ## Call tasks [#call-tasks] A campaign's `call` step leaves a call task for your team instead of sending anything (see [Campaigns](/smart-api/campaigns)). Your team works them in the app's Tasks; these endpoints let a dialer or CRM do the same. Each task carries: * the `lead` (`id`, `name`, `profileUrl`, `phone`, `email`, as the lead is now, so a phone found since shows up); * the `campaign` (`id`, `name`); * the step's `instructions`, with the lead's variables filled in; * `dueAt`, when it runs out (after the step's `waitDays`); * a `status`: `pending`, `done`, `skipped`, `stopped`, `expired` or `closed`. Once decided, a task also has the `outcome`, the `note` and `decidedAt`. A `closed` task's lead left the step on its own (a reply, a pause, the campaign paused or deleted, the lead removed); `closedReason` says which. * [listCallTasks](/smart-api/reference/call-tasks/listCallTasks) lists tasks newest first. It defaults to `status=pending` (pass `status=all` for every status) and filters by `campaignId`. Fetch one with [getCallTask](/smart-api/reference/call-tasks/getCallTask). * [completeCallTask](/smart-api/reference/call-tasks/completeCallTask) logs the call. `outcome` is required: `reached` for a conversation with the lead, `voicemail` or `no_answer`. An optional `note` takes up to 2,000 characters. * [skipCallTask](/smart-api/reference/call-tasks/skipCallTask) skips it. * [stopCallTask](/smart-api/reference/call-tasks/stopCallTask) stops the lead's sequence (the lead ends `stopped`), with an optional `outcome` and `note`. Logging or skipping a call moves the lead on to its next step at once, exactly as when someone logs it in the app. A task nobody decides expires at `dueAt`, and the lead moves on without a call. Only a `pending` task can be decided. These calls return `400`: * `code: warning-call-task-decided` for a task already decided; * `code: warning-call-task-not-found` for an unknown id; * `code: warning-call-task-stale` for a task whose lead already left the step (the task closes); * `code: warning-call-task-raced` for a task decided elsewhere at the same moment (the first decision stands); * `code: warning-call-task-stop-failed` when stopping, if the lead's sequence can no longer be stopped (the task stays `pending`). Subscribe to [`call_task.pending`](/smart-api/webhooks#call_taskpending) to hear about each new task. It is the only call task event: none fires when a task is decided (here or in the app), expires or closes. A call on such a task returns `warning-call-task-decided` or `warning-call-task-stale`. ```bash curl -X POST "https://app.swarmhit.com/api/v1/call-tasks/CALL_TASK_ID/complete" \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "outcome": "reached", "note": "Interested; wants a demo next Tuesday." }' ``` ## Reference [#reference] # Email deliverability (/smart-api/email-deliverability) Email outreach lives or dies by the reputation of the sending address. Swarmhit paces sends, threads follow-ups and stops on bounces and complaints, but the domain and mailbox setup is yours. This page is the short checklist. ## Before the first campaign [#before-the-first-campaign] * **Authenticate the sending domain.** Publish SPF, DKIM and DMARC records for the domain of every connected mailbox. Google Workspace and Microsoft 365 document the exact records; without DKIM most providers file cold email as spam regardless of content. Swarmhit reads these records when a mailbox connects and once a day after that: the mailbox page shows each one with the fix when something is missing, and the mailbox list flags the domain. Over the API they are `dnsHealth` on the [mailbox](/smart-api/reference/email-accounts/getEmailAccount), where `spf`, `dkim` and `dmarc` each have a `status` of `ok`, `missing`, `invalid`, `weak` (an SPF record that lets any server send) or `unknown`. After changing DNS, use **Check again** on the mailbox page (or [`POST /email-accounts/{id}/check-dns`](/smart-api/reference/email-accounts/checkEmailAccountDns), which returns the mailbox with fresh `dnsHealth`). DKIM keys live under a name the sender picks, so outside Google Workspace and Microsoft 365 a key we cannot find is reported as `unknown`, not `missing`, and so is a lookup that failed. Addresses on consumer domains such as gmail.com need nothing: the provider publishes the records (`dnsHealth.managed` is `true`). * **Use a mailbox with history.** A brand-new address has no reputation. Send real conversations from it for a couple of weeks before it joins a campaign pool, and keep its daily limit low at first. A mailbox bought through Swarmhit with warmup is kept out of campaigns until it has warmed for 14 days (and after that while warmup health is `warning` or `critical`) unless you choose to send anyway; see [Mailboxes](/smart-api/mailboxes). * **Keep the daily limit modest.** Each mailbox has its own daily cap: 30 emails unless you change it in the mailbox settings in Swarmhit (up to 150), counted per UTC day. The API reads it but does not change it: `limits.emails` on each mailbox in [`GET /email-accounts`](/smart-api/reference/email-accounts/listEmailAccounts) (`limits` is `null` while the default applies), and `usage.emails` counts the sends of the UTC day in `usage.day`. Emails are paced 3 to 7 minutes apart per mailbox, at random, inside the campaign schedule, so a campaign never sends in bursts. * **Write like a person.** Campaign emails go out as plain text with light formatting. Short, specific, one question. ## Threads and follow-ups [#threads-and-follow-ups] A follow-up `email` step with `config.sameThread: true` (the default) is sent as a reply under the campaign's first email to that lead (`In-Reply-To` and `References` headers, and the first email's subject with `Re:`), so the recipient sees one conversation. A follow-up whose subject is left empty is always sent this way, whatever `sameThread` says; a lead who never got an earlier email gets it under the subject of the email above it instead. Replies land in the same thread in the inbox, and a reply on any channel ends the sequence. ## Testing a mailbox [#testing-a-mailbox] **Run test** on the mailbox page sends one ordinary email from the mailbox to our test inbox, the way a campaign email from it goes out (signature and the mailbox's own tracking settings included). Within a couple of minutes the page shows whether it landed in the inbox or in spam (or did not arrive within 10 minutes), whether SPF, DKIM and DMARC passed as the receiving server saw them, and whether the sending server or the domain is on a Spamhaus blocklist, with the fix for anything that failed. One test runs at a time per mailbox, and a mailbox can run 10 a day (per UTC day; a test whose email could not be sent does not count). Each test email counts toward your plan's emails like any other. Over the API, start it with [`POST /email-accounts/{id}/deliverability-test`](/smart-api/reference/email-accounts/testEmailAccountDeliverability) and read `deliverabilityTest` on the [mailbox](/smart-api/reference/email-accounts/getEmailAccount), polling about once a minute: its `status` goes from `running` to `done` or `failed`. A `done` test has `landing` (`inbox`, `spam`, or `missing` when it did not arrive), the receiving server's `spf`, `dkim.result` and `dmarc` results, and `blocklists.ip` and `blocklists.domain` (`clean`, `listed` or `unknown`). A DKIM result of `unaligned` means the email was signed, but for another domain than yours (typically the provider's default key). A `failed` test says why in `failure`: `send` (the email could not be sent) or `check` (the result could not be read). The start call answers `409` while a test is already running or when the mailbox is paused or not connected, `429` once it has run 10 tests that day, `400` with `code: email-limit-reached` while email is paused for the workspace (its email allowance is used up or a payment failed), and `502` when the test email could not be sent. It is one inbox at one provider, so treat a single spam result as a signal to check, not a verdict: fix what failed, then run it again. ## Tracking opens and clicks [#tracking-opens-and-clicks] Each mailbox has two settings, changed in its settings in Swarmhit; the API reads them as `trackOpens` and `trackClicks` on the mailbox. **Track opens** adds one tiny image to its emails; it is on by default for a newly connected mailbox. **Track link clicks** sends every link through a tracking address first, which some spam filters hold against the sender, so it starts off. A campaign can decide for itself instead (Config tab, or `tracking: { opens, clicks }` on the campaign in the API, each `mailbox`, the default, to follow each mailbox, or `on` / `off` for all its emails). A campaign with an `if_email_opened` step tracks opens on its emails either way, and one with an `if_email_clicked` step tracks clicks. * Opens are approximate. Apple Mail and some corporate filters load images on their own, so an open can be counted that nobody made; Outlook and other apps block images, so a real open can be missed. Treat them as a hint, and use them for a follow-up, not for anything stronger. * Clicks are closer to the truth, but mail security scanners follow every link as the email lands. Opens and clicks in the first two minutes after a send are ignored for that reason, and so are those from obvious bots and scripts. A click on an open-tracked email also counts as its open. * Rates are per email, against the emails sent with that tracking on, so a mailbox that tracked nothing shows no rate rather than 0%. Over the API, [mailbox stats](/smart-api/reference/email-accounts/getEmailAccountStats) report that case as a `rates.open` or `rates.click` of `0`: check `lifetime.emailsOpenTracked` and `lifetime.emailsClickTracked` first. ### Your own tracking domain [#your-own-tracking-domain] By default, the open image and tracked links use a shared tracking address. Any workspace with email can use its own domain instead, under **Settings, Domains**: enter a subdomain such as `track.yourdomain.com`, add the one CNAME record shown, and it goes live by itself once the record resolves (minutes to an hour). From then on it carries the open image, tracked links and the unsubscribe link. A domain of your own keeps your links' reputation yours, and recipients only ever see your domain. An organization's tracking domain also covers its workspaces that have none. Emails keep the address they went out with, so removing or changing the domain breaks tracked links and unsubscribe links in emails already sent. The workspace in [`GET /`](/smart-api/reference/general/getApiInfo) reports it as `trackingDomain`: `hostname`, `status`, and `inherited` (`true` when the domain is the organization's). ## Bounces and complaints [#bounces-and-complaints] * Any **bounce** fails the lead if it is still on the step that sent the email (a delivery the server merely delays is not a bounce) and fires the [`email.bounced`](/smart-api/webhooks#emailbounced) webhook, whose `leadFailed` says whether it did. * A **hard bounce** (the address does not exist) also marks the address on the lead (`emailStatus: bounced`, `deadAddress: true` in the webhook) unless the lead has replied since that email, so from then on the lead stops at any email step it reaches (route it with `if_has_email` to avoid that). A soft bounce (mailbox full, greylisting) leaves the address usable. * A **spam complaint** marks the address as `complained`, opts the lead out of email (`emailOptOut: true`), fails the lead the way a bounce does, and fires `email.bounced` with `kind: complaint`. Watch the mailbox counters and stats ([`GET /email-accounts/{id}/stats`](/smart-api/reference/email-accounts/getEmailAccountStats)): a bounce rate above a few percent means the list is stale, a complaint means the copy or the targeting is off. Pause the mailbox ([`POST /email-accounts/{id}/pause`](/smart-api/reference/email-accounts/pauseEmailAccount)) while you fix either. ## Opting out [#opting-out] Each campaign has an **unsubscribe line** (Config tab, or `unsubscribe: { enabled, text }` on the campaign in the API): one sentence of up to 200 characters where the words in `[brackets]` become the link (the whole line when there are none), `Not interested? [Let me know]` by default. Write it in the language of your emails. The link opens a short page that asks once and then unsubscribes the person. * **Add to every email** (`enabled`, off by default) puts the line after the signature of every email the campaign sends. * `{{unsubscribeLink}}` in an email step's message puts the line exactly there instead (it works in the fallback message too, and never makes a fallback required). If a lead gets a fallback that leaves it out, the line still goes after the signature. * An email that carries the line also gets a one-click `List-Unsubscribe` header, so Gmail and other mail apps can show their own Unsubscribe button next to the sender. With the switch off and no `{{unsubscribeLink}}`, emails go out exactly as before. * Unsubscribing opts the address out for the whole workspace (`emailOptOut: true` on every lead with it), so no email step sends to it again, and stops the lead in the campaign that emailed it. LinkedIn steps in other campaigns are not affected. * Opening the link does nothing on its own: mail security scanners open every link as an email lands, so only the page's button or the mail app's one-click unsubscribe counts. A click on the link is not an open or a click in the stats. * Each unsubscribe shows in the lead's activity and fires the [`email.unsubscribed` webhook](/smart-api/webhooks#emailunsubscribed), whose `source` is `page` or `one-click`. A reply asking to stop ends the sequence like any reply, and a spam complaint opts the lead out automatically, link or not. # Embedded components (/smart-api/embedded-components) Embedded components put live Swarmhit screens inside your product: the inbox, the tasks and the campaigns. Each of your users reaches only the LinkedIn accounts you assign them, and only what you allow them to do. It takes one endpoint on your backend and a few lines on your page. | Element | What it shows | | ---------------------- | -------------------------------------------------------------------------------------------------------------------- | | `` | The conversations of the user's LinkedIn accounts, with the lead panel. | | `` | Calls to make, and AI comment and voice-note drafts waiting for approval. | | `` | The campaigns that send only from the user's accounts, with the Analytics, Leads, Sequence, Senders and Config tabs. | New messages, replies and campaign progress appear live, and every improvement Swarmhit ships reaches your users with nothing to update on your side. ## How it works [#how-it-works] 1. Your backend creates a session for one of your users with [Create an embedded session](/smart-api/reference/embedded-components/createEmbedSession), using your API key. It gets back a one-time client secret. 2. Your page hands that secret to the element, which signs the user in. 3. The element shows the Swarmhit screen for that user. Your API key never reaches the browser: the page only ever sees a one-time secret. What each user reaches is enforced by Swarmhit's servers, not by hiding buttons, so a user can only ever reach the accounts and actions your backend named for them. Embedded users have no access to your workspace settings, members, billing or LinkedIn account management. ## Before you start [#before-you-start] * **Embedded components turned on for your workspace.** Ask us. Until then, creating a session answers `403` with `code: feature_disabled`. * **An API key**, created on the **Access tokens** page in Swarmhit (the **API keys** card) and stored as a secret on your backend. See [Authentication](/smart-api/authentication). * **Your LinkedIn account ids**, from [`GET /accounts`](/smart-api/reference/accounts/listAccounts). Map each of your users to the accounts they work: a recruiter to their own account, an assistant to their own and the CEO's. ## Quickstart [#quickstart] A complete example: an Express server with the session endpoint, and a page that shows the inbox. You need Node 18 or later. ### 1. Add the session endpoint [#1-add-the-session-endpoint] The endpoint must know who is signed in from **your own session**, never from the request body or a query parameter, and decide that user's accounts and permissions on the server. It asks Swarmhit for a fresh secret on every call and never caches it. ```js title="server.mjs" import express from 'express'; const app = express(); app.use(express.static('public')); // Replace with your own login. The user comes from your session, never from the request. const requireLogin = (req, res, next) => { req.user = { id: 'recruiter_481', name: 'Jane Doe', swarmhitAccountIds: [process.env.SWARMHIT_ACCOUNT_ID] }; next(); }; app.post('/api/swarmhit-session', requireLogin, async (req, res) => { const r = await fetch('https://app.swarmhit.com/api/v1/embed/sessions', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SWARMHIT_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ user: { id: String(req.user.id), name: req.user.name }, accountIds: req.user.swarmhitAccountIds, permissions: ['inbox', 'tasks', 'campaigns.analytics', 'campaigns.leads.manage', 'campaigns.status'], }), }); if (!r.ok) { console.error('Swarmhit session failed', r.status, await r.text()); return res.status(502).send('Could not start the Swarmhit session'); } const { data } = await r.json(); res.set('Cache-Control', 'no-store').type('text/plain').send(data.clientSecret); }); app.listen(3000, () => console.log('Open http://localhost:3000')); ``` ```js title="server.mjs" import express from 'express'; import Swarmhit from '@swarmhit/swarmhit-sdk'; const swarmhit = new Swarmhit(); // reads SWARMHIT_API_KEY const app = express(); app.use(express.static('public')); // Replace with your own login. The user comes from your session, never from the request. const requireLogin = (req, res, next) => { req.user = { id: 'recruiter_481', name: 'Jane Doe', swarmhitAccountIds: [process.env.SWARMHIT_ACCOUNT_ID] }; next(); }; app.post('/api/swarmhit-session', requireLogin, async (req, res) => { try { const session = await swarmhit.embed.sessions.create({ user: { id: String(req.user.id), name: req.user.name }, accountIds: req.user.swarmhitAccountIds, permissions: ['inbox', 'tasks', 'campaigns.analytics', 'campaigns.leads.manage', 'campaigns.status'], }); res.set('Cache-Control', 'no-store').type('text/plain').send(session.clientSecret); } catch (err) { console.error('Swarmhit session failed', err); res.status(502).send('Could not start the Swarmhit session'); } }); app.listen(3000, () => console.log('Open http://localhost:3000')); ``` The request body: | Field | Required | Meaning | | ------------- | -------- | ---------------------------------------------------------------------------------------------- | | `user.id` | yes | Your id for the user, up to 200 characters. The same id always maps to the same Swarmhit user. | | `user.name` | no | Up to 120 characters, shown as the author of what they do in Swarmhit. | | `accountIds` | yes | 1 to 50 LinkedIn account ids of the workspace that owns the key. | | `permissions` | yes | What the user may do: see [Permissions](#permissions). | The response is `{ "data": { "clientSecret", "expiresAt", "user", "accountIds", "permissions" } }`, where `permissions` lists every permission the session holds, with screens and implied reads spelled out. ### 2. Add the element to your page [#2-add-the-element-to-your-page] Load `embed.js` once, place the elements where you want them, and call `Swarmhit.init` once with a function that asks your backend for a secret. ```html title="public/index.html" ``` Load `https://app.swarmhit.com/embed.js` once (in `index.html`) and call `Swarmhit.init` once at start-up, as in the HTML tab. Then use the elements like any other tag: ```jsx title="SwarmhitCampaigns.jsx" import { useEffect, useRef } from 'react'; export function SwarmhitCampaigns({ theme, campaignId, onCampaignCreated }) { const ref = useRef(null); useEffect(() => { const el = ref.current; const handler = (e) => onCampaignCreated?.(e.detail.campaignId); el.addEventListener('swarmhit:campaign-created', handler); return () => el.removeEventListener('swarmhit:campaign-created', handler); }, [onCampaignCreated]); return ; } ``` * Each element calls `fetchClientSecret` when it loads, and again by itself when its session ends, so the user is never asked to sign in. * The elements can be in the page before or after `Swarmhit.init` runs. * `fetchClientSecret` may return the secret as text, or an object holding it: the API's parsed JSON (`data.clientSecret`) or `{ clientSecret }`. ### 3. Open the page [#3-open-the-page] ```bash npm install express # and @swarmhit/swarmhit-sdk for the SDK version SWARMHIT_API_KEY=swh_live_... SWARMHIT_ACCOUNT_ID= node server.mjs ``` Open `http://localhost:3000`. The inbox appears with the conversations of that LinkedIn account, and the element fires `swarmhit:ready`. Swap the element for `` or `` to see the other screens. If it shows "Could not connect" instead, see [Troubleshooting](#troubleshooting). ## Permissions [#permissions] `permissions` is the boundary of what a user can do, not a display setting. Swarmhit's servers check every action and every piece of data an embedded user requests against it, and refuse anything outside it. Screens and tabs the user lacks are not shown. A screen's name (`inbox`, `tasks`, `campaigns`) grants everything in that screen. An edit or manage permission includes its read. | Permission | Allows | | ------------------------- | --------------------------------------------------------------------------------------------- | | `inbox` | Everything below in the inbox | | `inbox.read` | Read conversations and the lead panel, mark them read, star them | | `inbox.reply` | Reply (with attachments and voice notes), mark leads interested, pause or stop their sequence | | `tasks` | Everything below in tasks | | `tasks.read` | See calls to make and drafts waiting for approval | | `tasks.manage` | Log, skip and stop calls; approve, reject and re-draft comments and voice notes | | `campaigns` | Everything below in campaigns | | `campaigns.analytics` | The Analytics tab | | `campaigns.leads` | The Leads tab | | `campaigns.leads.manage` | Pause, resume, stop and retry leads, mark them interested | | `campaigns.sequence` | The Sequence tab, read only | | `campaigns.sequence.edit` | Edit the sequence (messages, voice notes, delays, steps, templates) | | `campaigns.senders` | The Senders tab, read only | | `campaigns.senders.edit` | Choose the campaign's senders, among the session's `accountIds` | | `campaigns.settings` | The Config tab, read only | | `campaigns.settings.edit` | Edit the schedule and options, rename the campaign | | `campaigns.status` | Launch, pause and resume campaigns | | `campaigns.create` | Create campaigns (blank or from a template) and duplicate them | | `campaigns.delete` | Delete campaigns and their leads | Ready-made roles: | Role | `permissions` | | ---------------------------- | ----------------------------------------------------------------------------------------- | | Recruiter who runs campaigns | `["inbox", "tasks", "campaigns.analytics", "campaigns.leads.manage", "campaigns.status"]` | | Manager who builds campaigns | `["inbox", "tasks", "campaigns"]` | | Read-only viewer | `["inbox.read", "tasks.read", "campaigns.analytics", "campaigns.sequence"]` | What a user sees: * **Conversations:** those of their `accountIds`. * **Campaigns:** a campaign appears only when every sender it uses is one of their accounts. A campaign that also sends from someone else's account stays hidden, so that account's conversations never show. * **New campaigns:** a campaign the user creates sends from their accounts from the start. * **Without `campaigns.sequence`:** the user still sees each lead's stage, but never the messages. ## Look and size [#look-and-size] | Attribute | Values | Default | | ---------- | --------------------------------------------------------------------------- | --------------- | | `theme` | `light`, `dark`, or `auto` (follows the visitor's system setting) | `light` | | `accent` | A six-digit hex colour such as `#0ea5e9`, for buttons, links and highlights | Swarmhit indigo | | `campaign` | On ``: the id of a campaign to open | the list | * **Live changes:** all three attributes can change at any time without a reload. Switch the theme when your app does: `el.setAttribute('theme', 'dark')`. * **Sizing:** an element is a block that fills the width it is given; you set the height. Give it a fixed height (`height: 720px`) or let it fill a flex or grid area (`height: 100%`, `flex: 1`). Without one it is 600px tall, and never less than 320px. * **Narrow spaces:** the screens adapt to the element's own width, not the window's, so they work in a side panel or on a phone. * **No flash:** the element stays transparent until its screen has painted, so a dark page never flashes white. ## Events and opening a campaign [#events-and-opening-a-campaign] Events are dispatched on the element and bubble, so you can listen on the element or on `document`: | Event | `detail` | When | | --------------------------- | ---------------- | ----------------------------------------------------------- | | `swarmhit:ready` | `{}` | The user is signed in and the screen is up. | | `swarmhit:error` | `{ message }` | The session could not start, or `fetchClientSecret` failed. | | `swarmhit:campaign-created` | `{ campaignId }` | The user created a campaign in ``. | The elements and the API work on the same data, so you can combine them: * **Open a campaign from your own UI:** set `campaign=""` on ``, for example from a link on your job page. * **Add leads from your platform:** call [Add leads to a campaign](/smart-api/reference/leads-in-campaigns/addLeadsToCampaign) from your backend. For example, when `swarmhit:campaign-created` fires, attach the job's candidates to the new campaign. The Leads tab and the analytics update live. * **React on your backend:** subscribe to [webhooks](/smart-api/webhooks) such as `message.received` or `campaign.finished`. ```js document.addEventListener('swarmhit:campaign-created', (e) => { // Your backend then calls POST /campaigns/{id}/leads with the job's candidates. fetch(`/api/jobs/${jobId}/swarmhit-campaign`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ campaignId: e.detail.campaignId }), }); }); ``` ## Sessions [#sessions] * **The secret** works once, within 5 minutes. **The session** it starts lasts 12 hours, then the element asks for a new secret by itself. * **Changing access:** your latest session for a user sets what all of their open screens reach. Creating a session with fewer accounts or permissions signs their open screens out, and they sign back in by themselves with the narrower access. * **Signing a user out everywhere:** call [End a user's embedded sessions](/smart-api/reference/embedded-components/revokeEmbedSessions) when they sign out of your platform, leave your company or lose access. Secrets not used yet stop working and their open screens are signed out within moments. `revoked` is `false` when that user never had a session. ```bash curl -X DELETE https://app.swarmhit.com/api/v1/embed/users/recruiter_481/sessions \ -H "Authorization: Bearer swh_live_..." ``` With the SDK: `await swarmhit.embed.users.revokeSessions('recruiter_481')`. ## Security checklist [#security-checklist] * Keep the API key on your backend. Never put it in a page, an app bundle or a log. * Authenticate your own user before creating a session: the session endpoint sits behind your login. * Never trust a user id, account ids or permissions sent by the browser. Derive them on your backend from your own data. * If your site sets a Content-Security-Policy, allow `script-src https://app.swarmhit.com` and `frame-src https://app.swarmhit.com`. ## Troubleshooting [#troubleshooting] | Symptom | Cause and fix | | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `403` with `code: feature_disabled` | Embedded components are not on for your workspace yet: contact us. | | `400` with `code: invalid-request` naming `accountIds` | An id is not a LinkedIn account of the workspace that owns the key. List them with [`GET /accounts`](/smart-api/reference/accounts/listAccounts). | | `400` naming `permissions` | `permissions` is required and must use the names in [Permissions](#permissions). | | The element shows "Could not connect" after about 20 seconds | No secret arrived: check that `Swarmhit.init` runs on the page and that `fetchClientSecret` resolves. `swarmhit:error` says why. | | "This session is not valid any more" | The secret was used already or is older than 5 minutes. Return a fresh one on every call and never cache it. | | "This screen is not part of your access" | The session has no permission for that element's screen. | | A campaign is missing from the list | It sends from an account the user is not assigned. Assign that account too, or change the campaign's senders. | | Nothing renders and the console shows a CSP error | Allow `https://app.swarmhit.com` in `script-src` and `frame-src`. | ## Go-live checklist [#go-live-checklist] * Embedded components are on for your production workspace. * The API key is a backend secret, and the session endpoint requires your login. * `accountIds` and `permissions` come from your own user data, on the server. * `fetchClientSecret` returns a fresh secret every time (`Cache-Control: no-store`). * Your CSP allows `https://app.swarmhit.com` for scripts and frames. * Each element has a height, fixed or filling its container. * Sessions are revoked when a user loses access. * You tested with your smallest role: what that user should not see is not there. # Inbox (/smart-api/inbox) The inbox holds the conversations your connected sender accounts and mailboxes have with your leads, in one place: LinkedIn chats with people who are leads in the workspace, and email threads between your mailboxes and your leads. Conversations with anyone else are not stored. Campaign steps, direct actions and manual messages all land in the same threads, so the inbox API is where you build reply handling: routing hot leads to a CRM, powering a custom unibox, or auto-responding to replies. ## The chat and message model [#the-chat-and-message-model] A **chat** is one conversation thread: between a sender account and a lead on LinkedIn, or between one of your mailboxes and a lead by email. `channel` (`linkedin` or `email`) says which. A **message** is one entry inside a chat. Key chat fields: | Field | Type | Meaning | | ---------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | string | The chat id used in every `/inbox/chats/{id}` call. | | `accountId` | string, null | The sender account (LinkedIn) or mailbox (email) on your side of the thread. | | `senderId` | string, null | The [sender](/smart-api/senders) that account belongs to. | | `channel` | string | `linkedin` or `email`. | | `subject` | string, null | The email thread's subject; null on LinkedIn chats. | | `attendee` | object, null | Snapshot of the person on the other side: their LinkedIn profile fields, or `name` and `email` on an email thread. | | `leadId` | string, null | The workspace lead this person maps to, when tracked. | | `lastMessage` | object, null | A preview of the most recent message in the thread: `text`, `at`, `isFromMe`. | | `lastMessageAt` | date-time, null | When the thread last had activity. | | `lastReplyAt` | date-time, null | When the lead last messaged this thread. Null until they answer, and never cleared once set: your own replies do not reset it. | | `label`, `labelBy` | string, null | The lead's [label](/smart-api/campaigns#labels) in its campaign (`interested`, `not_interested`, `neutral`, `out_of_office`), and who set it (`ai` or `manual`). Null when it has none or the lead is in no campaign. | | `unreadCount` | integer | Unread messages in the thread. | | `favorite`, `archived` | boolean | Inbox flags. | Key message fields: | Field | Type | Meaning | | ------------------------------ | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id`, `chatId` | string | Identity and parent thread. | | `channel` | string | `linkedin` or `email`. | | `subject`, `messageId`, `html` | string, null | Email only: the subject, the RFC 5322 Message-ID, and the HTML body as received (`text` is the plain-text version). | | `openedAt`, `clickedAt` | date-time, null | Sent email only, when tracked: the first open and the first link click. | | `text` | string | The message body. | | `isFromMe` | boolean | `true` for messages sent from your side (the sender account or the mailbox). | | `isEvent` | boolean | `true` for system events rather than regular messages. | | `attachments` | array | Media on a LinkedIn message, sent or received: `type` (`image`, `video`, `audio`, `file`), `url`, `name`, `mimeType`, `size`. Voice notes carry `voiceNote: true` and `durationMs`. A LinkedIn post shared in the message is `type: post`, with its link in `url` when there is one; anything else with no file, such as a video meeting invite, is `type: other`, named in `name`. A received file not copied yet has `url: null` and is tried again for a few hours. Email messages carry none for now. | | `at` | date-time | When the message was sent. | ## List and filter chats [#list-and-filter-chats] [`listInboxChats`](/smart-api/reference/inbox/listInboxChats) returns chats ordered by most recent activity, with standard `limit` (1 to 100, default 50) and `offset` paging. The `replied` query parameter filters on whether the lead has answered: * `replied=true`: only threads where the lead has sent at least one message (an automatic reply counts). These are ordered by the lead's most recent reply instead of general activity, so the freshest replies come first. * `replied=false`: only threads still awaiting a first reply. * Omit it for all chats. Any other value is rejected with `400`. Three more filters combine with it: `channel` (`linkedin`, `email` or `whatsapp`), `label` (a [label](/smart-api/campaigns#labels), or `none` for threads whose lead has none) and `senderId` (the chats of every account of one [sender](/smart-api/senders)). There is none by account, lead or unread state, so filter on those fields of the returned chats yourself. [`getInboxCounts`](/smart-api/reference/inbox/getInboxCounts) returns the badges: unread messages, and the people who replied, are favourites, or carry each label. In Swarmhit, the inbox opens on **Replied**: the threads where the person wrote back. Threads holding only your own messages are under **All**. ```bash curl "https://app.swarmhit.com/api/v1/inbox/chats?replied=true&limit=25" \ -H "Authorization: Bearer swh_live_..." ``` This is the polling-friendly view of "who answered me": page through `replied=true` and compare each chat's `lastReplyAt` against your own high-water mark. For push instead of polling, see [reacting to replies](#react-to-replies-programmatically) below. ## Read a conversation [#read-a-conversation] Fetch a single thread with [`getInboxChat`](/smart-api/reference/inbox/getInboxChat), and its history with [`listChatMessages`](/smart-api/reference/inbox/listChatMessages): ```bash curl "https://app.swarmhit.com/api/v1/inbox/chats/CHAT_ID/messages?limit=50" \ -H "Authorization: Bearer swh_live_..." ``` Messages come newest first. Use `isFromMe` to render the two sides of the conversation and `isEvent` to filter out system entries. Reading over the API never fetches from LinkedIn or the mailbox: it returns what Swarmhit has recorded, which includes the lead's replies and every message sent through Swarmhit. For a lead-centric view of campaign activity rather than a thread, [`getLeadActivity`](/smart-api/reference/leads/getLeadActivity) returns a lead's campaign timeline across campaigns, newest first (capped at 200 events). It holds step results and events such as `reply_received`, not the inbox messages themselves. ### Mark a chat read [#mark-a-chat-read] [`markChatRead`](/smart-api/reference/inbox/markChatRead) clears the unread count for the chat and for every sibling thread with the same person on the same channel (one person can have more than one thread). A LinkedIn chat is also marked read on LinkedIn; on an email thread the count is cleared in Swarmhit only, and the message's read state in the mailbox is left as it is: ```bash curl -X POST "https://app.swarmhit.com/api/v1/inbox/chats/CHAT_ID/read" \ -H "Authorization: Bearer swh_live_..." ``` ## Reply in a chat [#reply-in-a-chat] [`sendChatMessage`](/smart-api/reference/inbox/sendChatMessage) sends a message into an existing thread, from the chat's sender account (or, on an email thread, its mailbox): ```bash curl -X POST "https://app.swarmhit.com/api/v1/inbox/chats/CHAT_ID/messages" \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 4f2c9a1e-7d31-4b8a-9f0e-2d6c8b5a1e3f" \ -d '{"text": "Thanks for connecting! Happy to share more."}' ``` Always send an `Idempotency-Key` header (any unique string up to 255 characters, for example a UUID). Reusing the key on the same chat replays the first response instead of sending again, so a network retry cannot double-message a lead. A `409` with `code: idempotency_in_progress` means the original request is still in flight (retry shortly); `code: idempotency_failed` means the original's outcome couldn't be confirmed (check whether it went out before retrying with a new key). A request rejected with a `4xx` sent nothing and frees its key, with one exception: a `400` passing on a LinkedIn, WhatsApp or mail provider failure (code `provider-unreachable`, `provider-408` for the provider's own timeout, or `provider-` and a 5xx status such as `provider-502`). The reply may have gone out then, so the key is kept as after a `5xx`, and asking again with it answers `409` `idempotency_failed`, unless the request provably never reached the provider (its connection never opened). Before sending such a reply again with a new key, check whether it went out. On a LinkedIn chat, pull the chat's latest messages ([`syncChatMessages`](/smart-api/reference/inbox/syncChatMessages)): a reply that went out late is only in the chat after that. Email and WhatsApp chats can't be pulled (the sync answers `skipped`), and Swarmhit keeps its copy of a sent email only when the send was confirmed: look in the mailbox's sent mail, or on the WhatsApp number, instead. The response is `{ ok, messageId, campaignLead }`. `campaignLead` is the chat's linked campaign membership (`id` and `status`) so your app can offer to pause or stop the sequence, or `null` when the chat has none. On an email thread, `messageId` is the sent email's RFC 5322 Message-ID. A reply counts toward the sending account's daily usage like an automated send, but is never blocked by its daily limit. If a LinkedIn chat's sender account is gone or disconnected, the request is rejected with `400` and `code: account-missing`. This endpoint is for existing threads. To start a brand new conversation with a connection, use the direct action [`sendDirectMessage`](/smart-api/reference/direct-actions/sendDirectMessage) instead, or [`sendEmail`](/smart-api/reference/direct-actions/sendEmail) for a new email thread. See [Direct actions](/smart-api/direct-actions). ### Replying on an email thread [#replying-on-an-email-thread] On an email chat, the reply goes out from the chat's mailbox as a reply in the same thread: subject `Re: `, sent to whoever wrote last (out-of-office and other auto-replies are skipped; before any reply, to the lead's address), with the mailbox's signature appended and opens and clicks tracked per the mailbox's settings. It takes `text` only for now: `attachments` or a `voiceMessage` is rejected with `400`. The send counts toward the workspace's email allowance and fires the [`email.sent` webhook](/smart-api/webhooks) with `source: manual`. Two email-specific errors, both `400`: `code: mailbox-not-ready` when the chat's mailbox is not connected and active, and `code: email-limit-reached` while email is paused for the workspace (this billing period's allowance is used up, or a charge for email usage failed). `workspace.emails` on [`GET /`](/smart-api/reference/general/getApiInfo) shows what is left. ### Attachments and voice notes [#attachments-and-voice-notes] LinkedIn chats only. `text` becomes optional when you provide an attachment or a voice message. Attachments are fetched server-side from a public http(s) `url` that serves the file directly, since redirects are not followed (photo, video, audio or document, max 15 MB each): ```bash curl -X POST "https://app.swarmhit.com/api/v1/inbox/chats/CHAT_ID/messages" \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 9b7d3e5f-1a24-4c6d-8e90-3f5a7c9b1d2e" \ -d '{ "text": "Here is the one-pager I mentioned.", "attachments": [ { "url": "https://example.com/files/one-pager.pdf", "name": "one-pager.pdf" } ] }' ``` `voiceMessage` sends a LinkedIn voice note: the audio at its `url` (up to 12 MB) is fetched and transcoded to LinkedIn's format server-side, and anything past 60 seconds is cut off. `durationMs` is optional; the measured length is used whenever it can be read. ### Halting the sequence on send [#halting-the-sequence-on-send] If the chat's lead is running in a campaign, replying manually via the API does not interrupt the sequence by itself. Use `sequenceAction` to halt it on send. It acts on the membership the chat is linked to (the `campaignLead` in the response), on LinkedIn and email alike, and does nothing when there is none: * `"pause"`: freezes the sequence, keeping step progress and timers, so it can be resumed later. A paused membership still holds this person for that sender in other campaigns, since it can resume. * `"stop"`: terminal. The executor never contacts this lead again in that campaign, and any membership waiting behind it (same person, same sender) is released to start. * Omit it (or send `null`) to leave the sequence running. ```bash curl -X POST "https://app.swarmhit.com/api/v1/inbox/chats/CHAT_ID/messages" \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: c1d2e3f4-5a6b-4c7d-8e9f-0a1b2c3d4e5f" \ -d '{"text": "Great, let me send over some times.", "sequenceAction": "stop"}' ``` You can also pause, resume or stop the lead independently of sending, via [`pauseLeadInCampaign`](/smart-api/reference/leads-in-campaigns/pauseLeadInCampaign), [`resumeLeadInCampaign`](/smart-api/reference/leads-in-campaigns/resumeLeadInCampaign) and [`stopLeadInCampaign`](/smart-api/reference/leads-in-campaigns/stopLeadInCampaign). In Swarmhit, the inbox does not pause the lead on its own either: after a manual message it offers to pause or stop the sequence. Over the API, `sequenceAction` is that choice. ## React to replies programmatically [#react-to-replies-programmatically] Polling `replied=true` works, but the push path is [webhooks](/smart-api/webhooks). Subscribe an endpoint to `message.received` (LinkedIn) and `email.received` (email) and Swarmhit calls you when a lead replies. `message.received` fires for replies from campaign leads and from leads you actioned through the API; `email.received` fires for replies from campaign leads, but not for out-of-office and other auto-replies: ```bash curl -X POST "https://app.swarmhit.com/api/v1/webhooks" \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/hooks/swarmhit", "events": ["message.received", "email.received"], "classifyReplies": true }' ``` The `message.received` payload carries: * `data.source`: `campaign` when the reply is on a campaign lead, `direct` when it is on a lead you actioned via the API. * `data.accountId`: the sender account the reply arrived on. * `data.campaignId` and `data.campaignLeadId`: set for campaign replies, null for direct ones. * `data.leadId`: the lead who replied. * `data.chatId`: the conversation's id on LinkedIn (on email, the thread key). It is **not** the inbox chat `id` that the `/inbox/chats/{id}` endpoints take. * `data.message`: the reply `text` and its timestamp. * `data.from`: the replier's `firstName`, `lastName`, `publicIdentifier` and `providerId`. `email.received` has the same shape, with `data.accountId` being the mailbox, `data.from.email` (the lead's address, or the sender's address when the lead has none) in place of `providerId`, and an extra `data.email` block (`subject`, `messageId`). A typical handler: find the inbox chat whose `leadId` and `accountId` match the event ([`listInboxChats`](/smart-api/reference/inbox/listInboxChats) with `replied=true` lists the freshest replies first), fetch its history via [`listChatMessages`](/smart-api/reference/inbox/listChatMessages) for context, send a reply with [`sendChatMessage`](/smart-api/reference/inbox/sendChatMessage), then [`markChatRead`](/smart-api/reference/inbox/markChatRead). The outbound mirror events, `message.sent` (LinkedIn, on chats with a tracked lead) and `email.sent` (campaign emails and inbox replies), let you keep both sides of the conversation in sync in your own system. Deliveries are at-least-once: deduplicate on the `X-Swarmhit-Delivery` header and verify the `X-Swarmhit-Signature` HMAC. The [Webhooks guide](/smart-api/webhooks) covers both. ### Reply classification [#reply-classification] With `classifyReplies` enabled on a webhook endpoint, every `message.received` and `email.received` delivery to it carries a `classification.interest` verdict: `interested`, `not_interested` or `neutral`, or `null` when no classification was produced (treat `null` as unknown, never as neutral). Classification consumes workspace credits. It only runs when an enabled endpoint subscribed to `message.received` has `classifyReplies` on (or the campaign has `aiInterestEnabled`), so an endpoint that wants verdicts on email replies should subscribe to `message.received` as well, as in the example above. The full contract, vocabulary and example payload live in the [Webhooks guide](/smart-api/webhooks). To persist the verdict on the campaign membership, call [`setLeadInterest`](/smart-api/reference/leads-in-campaigns/setLeadInterest) with `interested` or `not_interested`, or `null` to clear it (the stored `interest` field has no `neutral` value; its companion `interestBy` records whether it was set by `manual` action or `ai`). Campaigns can also do this themselves when `aiInterestEnabled` is on, without overriding an interest set manually. ## Reference [#reference] # Getting started (/smart-api) The Smart API lets you run LinkedIn and email outreach programmatically: campaigns whose sequences mix LinkedIn and email steps, leads, lists and LinkedIn search imports, LinkedIn sender accounts, mailboxes (connect existing ones, or buy domains and mailboxes through Swarmhit), the B2B people and company database, one-off LinkedIn actions, your inbox, and the workspaces of an organization. Webhooks deliver events in real time, and an [MCP server](/smart-api/mcp-server) exposes most of the API to AI agents. The Smart API is Swarmhit's outreach engine: it paces, sequences and follows up for you. Need direct, one-call-one-action access to LinkedIn, WhatsApp and email accounts, with your own pacing? That is the [Raw API](/raw-api). One Swarmhit login opens both. ## Base URL [#base-url] ``` https://app.swarmhit.com/api/v1 ``` ## Create an API key [#create-an-api-key] Create and revoke keys on the **Access tokens** page in Swarmhit (the **API keys** card). Keys start with `swh_live_`. A key's full value is shown only once, at creation, so store it securely. Each key is scoped to the workspace it was created in. See [Authentication](/smart-api/authentication) for the details. ## Your first request [#your-first-request] ```bash curl https://app.swarmhit.com/api/v1/campaigns \ -H "Authorization: Bearer swh_live_xxxxxxxxxxxxxxxxxxxxxxxx" ``` Responses are JSON with the result under `data`; list endpoints add `paging`. A workspace with no campaigns yet answers: ```json { "data": [], "paging": { "limit": 50, "offset": 0 } } ``` To check which workspace a key acts on, call [`GET /`](/smart-api/reference/general/getApiInfo): it returns that workspace with its plan and seat usage. ## Request bodies [#request-bodies] Send request bodies as JSON with `Content-Type: application/json`; without that header the body is ignored. A body can be up to 100 KB: a larger one is refused with `413`, and malformed JSON with `400`, before the request reaches the API, so those two responses do not carry the JSON error body described below. Adding leads to a list or a campaign accepts up to 1000 leads per request; split a batch that would go over 100 KB. ## Pagination [#pagination] List endpoints accept `limit` (1-100, default 50) and `offset` query parameters, and return a `paging` object alongside `data`. `paging` echoes the `limit` and `offset` that were applied; most lists carry no total, so request the next page (`offset` + `limit`) until one comes back with fewer than `limit` items. A `limit` above 100 is treated as 100. Database searches page with a cursor instead (see [Database](/smart-api/database#paging)), and a few short lists, such as the reference data and an organization's workspaces, return everything in one response, without `paging`. ## Errors [#errors] Errors use standard HTTP status codes with a JSON body: ```json { "error": { "status": 404, "message": "Campaign not found." } } ``` When present, `code` is a stable, machine-readable identifier (for example `unauthorized`, `unknown_endpoint` or `profile_not_found`): branch on it rather than on `message`, whose wording may change. Some errors also carry a `details` object, such as the limit and its reset time on a sender `429`. | Status | Meaning | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | Invalid request: a missing or malformed field, or a rule the request breaks (for example connecting a sender when the workspace has no seat left, code `seat-limit-reached`) | | `401` | Missing or invalid key, or an `X-Swarmhit-Workspace` value the key cannot use (code `unauthorized`) | | `402` | A payment for bought mailboxes or domains failed, or there is no card to charge | | `403` | Not allowed for this workspace: the `/workspaces` endpoints (other than the list) on a workspace that is not an organization, a feature not enabled for it (email, buying mailboxes), or an action that needs billing rights the key's creator lacks | | `404` | Missing resource, or no endpoint for that method and path (code `unknown_endpoint`). A LinkedIn profile that cannot be resolved returns code `profile_not_found` | | `409` | Conflict with the current state, for example the workspace's cap of 25 webhooks, a LinkedIn invitation already pending to that profile, a paused sender, or a sender LinkedIn rejected (it may need reconnecting) | | `422` | An action LinkedIn could not complete on the target profile | | `429` | A rate limit: a sender's daily, weekly or pending-invitation limit (`details` gives `limit`, `used`, `scope` and, when known, `resetAt`), a sender out of InMail credits, or a service that is busy | | `500` | Server error (code `internal_error`) | | `502`, `503` | LinkedIn, the database search or the mailbox provider failed or is unavailable; retry later | ## Next steps [#next-steps] # Leads, lists and imports (/smart-api/leads-and-lists) Every person you reach out to is a **lead**: a single entry in your workspace address book. **Lists** are named buckets of leads used for campaign targeting and exclusion. **Lead imports** are background jobs that fill lists from a LinkedIn or Sales Navigator search. This guide covers the lifecycle of all three; the full endpoint details live in the [Leads](/smart-api/reference/leads/listLeads), [Lists](/smart-api/reference/lists/listLists) and [Lead Imports](/smart-api/reference/lead-imports/createLeadImport) reference sections. ## Leads: the workspace address book [#leads-the-workspace-address-book] A lead is identified by its LinkedIn profile. `profileUrl` and `publicIdentifier` (the profile URL slug) are the only fields guaranteed to be set at creation. The same person is one lead across every list and campaign: the lead document carries `listIds` and `campaignIds` so you can see everywhere it is used. In a workspace with the email channel, a lead can also be added by its email address alone. Such a lead has no LinkedIn profile, so its `profileUrl` and `publicIdentifier` are `null`, and it is matched on its address instead (case-insensitive). In a campaign it stops at the first LinkedIn step that would need an answer (an invite, a message, a connection check) unless the sequence routes it with `if_has_linkedin` (see [Step types](/smart-api/campaigns#step-types)). If you later add the same person with a LinkedIn `profileUrl` and the same `email`, or set `profileUrl` on the lead with [`PATCH /leads/{id}`](/smart-api/reference/leads/updateLead), that lead gains the LinkedIn profile instead of a second lead being created. On `PATCH` the value must be a LinkedIn profile URL (`https://www.linkedin.com/in/...`), and it is rejected with `400` if another lead already has that profile. A lead without a LinkedIn profile must keep a valid email: clearing it is rejected with `400`. Leads are enriched lazily. Identity fields (`firstName`, `lastName`, `headline`, `picture`) are resolved from LinkedIn the first time the lead is actually contacted, not when it is created, so no profile lookup is spent on a lead that is never reached. Until then those fields are `null` (unless you supplied them) and `enrichedAt` is `null`. If you need names immediately (for example to render your own UI), supply them in the request body when you add the lead. At first contact, the values LinkedIn returns for those four fields replace the ones you supplied; your other fields (`jobTitle`, `company`, `email`, `location`, `customVariables`) are kept. Other fields worth knowing: | Field | Meaning | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `tags` / `autoTags` | `tags` are the manual tags you control. `autoTags` holds automatically applied tags and is currently always empty. | | `customVariables` | Free-form templating values keyed by name, surfaced in messages as `{{key}}` or a spelling that only differs in case, accents or separators (see [Variables and fallbacks](/smart-api/campaigns#variables-and-fallbacks)). Shared across every campaign the lead is in. Values are text: a number or boolean you send is stored as text, and an object or a list is not stored. | | `openProfile` | Whether the profile accepts credit-free InMails (from Sales Navigator / Recruiter senders, within a monthly allowance). `null` until the lead is first contacted. | | `enrichedAt` | When the lead's identity was last filled in from LinkedIn. | | `phone` | Mobile phone number, found by a [lookup](#finding-emails-and-phone-numbers), revealed from the [database](/smart-api/database), or set by you. | | `enrichment` | The last email and phone lookup, per kind: `status` (`pending`, `running`, `found`, `not_found` or `failed`), `at`, and on a failed one a `reason` (`no_credits`, `no_identity` or `unavailable`). `null` for a kind never looked up. | | `source` | Where the lead came from. | A CSV import in the app stores each column under a readable key (`Plan Tier` becomes `Plan_Tier`, `Société` becomes `Societe`). It also renames the other spellings of that key, the ones that only differ in case, accents or separators, on every lead in the workspace, leads added through the API included: a `plantier` you set reads back as `Plan_Tier`. A lead that already had both keeps the readable key's value if it has one, and the old spelling's otherwise. Templates that use the old spelling keep working, but write the key as the lead now spells it: writing the old spelling adds it back next to the new one, and each `{{name}}` reads its exact spelling first. ### Creating leads (upsert, not POST) [#creating-leads-upsert-not-post] There is no standalone create-lead endpoint. Leads are created where you use them, always with upsert semantics: pass an array of `{ profileUrl, ... }` objects and Swarmhit matches existing leads on their LinkedIn identifier (or, for an item added by email alone, on its address), updates them with any new fields, and creates the rest. The two entry points are: * [Add leads to a list](/smart-api/reference/lists/addListLeads): upserts the leads and attaches them to the list in one call. * [Add leads to a campaign](/smart-api/reference/leads-in-campaigns/addLeadsToCampaign): upserts the leads and enrolls them; leads already in the campaign are skipped. They are also saved to the campaign's own list (created on first use and named "\'s leads", `listId` on the campaign). That list mirrors the campaign: adding leads to it enrolls them, removing leads from it removes them from the campaign. See the [campaigns guide](/smart-api/campaigns). Both accept up to 1000 leads per request (more is rejected with `400`). The item shape (`LeadInput`) takes `profileUrl` (required, unless the workspace has the email channel and the item has a valid `email`) plus optional `firstName`, `lastName`, `headline`, `jobTitle`, `company`, `email`, `location` and `customVariables`. Pass the public profile URL with its slug in lower case, such as `https://www.linkedin.com/in/jane-doe`. A slug with capital letters or underscores, which includes member-id URLs (`/in/ACoAA...`), is not recognised as a LinkedIn profile: the item is skipped, or, with the email channel and a valid `email`, added by its address alone. The two responses differ: * Add to a list returns `{ inserted, skipped, enrolled }`. `inserted` counts the items saved to the list, whether the lead is new or already existed; `skipped` counts the items that identify no one; `enrolled` is how many were enrolled when the list is a campaign's own list (otherwise `0`). * Add to a campaign returns `{ inserted, excluded, listId }`. `inserted` counts new enrollments, `excluded` the leads the campaign's exclusion rules kept out, and `listId` is the campaign's own list. Leads already in the campaign and items that identify no one are not counted. Enrolling a whole list with `listId` returns `{ inserted, skipped, excluded, listId, listName }` instead, where `skipped` counts the list's leads already in the campaign. Leads also carry a read-only `emailStatus` (`bounced` or `complained`) once a campaign email to their address came back that way, and a read-only `emailOptOut`, true once they unsubscribed or reported an email as spam; email steps skip such leads. Leads also arrive via [lead imports](#lead-imports-from-a-linkedin-search) and the [prospecting database](/smart-api/database). ### Updating and deleting leads [#updating-and-deleting-leads] [`PATCH /leads/{id}`](/smart-api/reference/leads/updateLead) accepts any subset of `firstName`, `lastName`, `headline`, `jobTitle`, `company`, `email`, `phone`, `location`, `notes`, `profileUrl`, `tags` and `customVariables`. `null` or an empty string clears a field, except `profileUrl`, which cannot be cleared. Two semantics to note: * `tags` **replaces** the lead's manual tags (trimmed, deduplicated, at most 50; `autoTags` are never touched). * `customVariables` is **merged per key**; pass an empty string to clear a key. ```bash curl -X PATCH https://app.swarmhit.com/api/v1/leads/LEAD_ID \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "jobTitle": "VP Engineering", "tags": ["warm", "q3"] }' ``` A lead's campaign progress is a separate object: only `interest` is editable there, via [Set lead interest](/smart-api/reference/leads-in-campaigns/setLeadInterest). Everything else about the person lives on the lead itself. [`DELETE /leads/{id}`](/smart-api/reference/leads/deleteLead) removes the lead from the address book and from every list. Its campaign memberships are kept for the record but lose their `leadId`, and a membership still in progress is marked `failed` (error `Lead missing`) the next time it runs instead of contacting the person. To take a lead out of a single campaign, [remove it from that campaign](/smart-api/reference/leads-in-campaigns/removeLeadFromCampaign) instead. ### Finding and inspecting leads [#finding-and-inspecting-leads] [`GET /leads`](/smart-api/reference/leads/listLeads) returns the newest leads first, with `limit` / `offset` paging and a `search` parameter that matches case-insensitively on first name, last name, headline, job title, email, company or the profile slug. The whole search string must appear within one of those fields, so search one name at a time (`jane`, not `jane doe`). A full LinkedIn profile URL is reduced to its slug first, so a lead that has never been contacted (and therefore has no name yet) is still findable by its URL: ```bash curl "https://app.swarmhit.com/api/v1/leads?search=linkedin.com/in/jane-doe" \ -H "Authorization: Bearer swh_live_..." ``` [`GET /leads/{id}/activity`](/smart-api/reference/leads/getLeadActivity) returns the lead's campaign timeline across every campaign it is in, newest first and capped at 200 events: step results and events such as `invite_sent`, `invite_accepted`, `reply_received`, `call_task_created`, `paused` or `interest_changed`. Each event has `kind` (always `step`), `at`, `campaignId`, `campaignLeadId`, `stepType`, `ok`, `branch`, `error` and `result`. A `call` step's `result` carries the logged `outcome` (`reached`, `voicemail` or `no_answer`) and `note`, or `skipped` (`call_skipped`, or `call_timeout` when nobody logged the call in time); a `stopped` event with `reason: "call"` was stopped from a call task and carries the same `outcome` and `note`. Inbox messages are not part of it; read a conversation through the [inbox](/smart-api/inbox). ### Finding emails and phone numbers [#finding-emails-and-phone-numbers] Swarmhit can look up a lead's verified work email and mobile phone number. An email is found from the lead's LinkedIn profile, or from its first name, last name and company; a phone needs the LinkedIn profile. A result is charged only when it is found, at the Email finder or Phone finder price (see [credit prices](/smart-api/database#credits-and-errors)). A miss is free. A value the lead already has is kept: it is never overwritten, looked up again or charged. * [`POST /leads/{id}/enrich`](/smart-api/reference/leads/enrichLead) looks one lead up while you wait and returns the lead with what was found. Send `{ "email": true, "phone": true }` (`email` defaults to `true`, `phone` to `false`). A hit usually answers in a couple of seconds; a miss only once every source was tried, which can take up to a minute. * [`POST /leads/enrich`](/smart-api/reference/leads/enrichLeads) queues lookups for up to 5,000 `leadIds`, or every lead of a `listId`, and returns at once with `{ total, queued }`. It skips leads that already have the value, were looked up before with nothing found, or have nothing to look them up by. Lookups run in the background, usually within minutes. ```bash curl -X POST https://app.swarmhit.com/api/v1/leads/enrich \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "listId": "LIST_ID", "email": true, "phone": false }' ``` Each finished lookup sets `enrichment..status` on the lead and fires the [`lead.enriched` webhook](/smart-api/webhooks#leadenriched), with the found value already on the lead. A lookup reached after the credit pool ran out ends `failed` with `reason: no_credits`; ask again once credits are added. For a person who is not a lead, use the database's [find-email and find-phone](/smart-api/database#emails-and-phone-numbers) endpoints. Campaigns can look up emails and phones too, with the `find_email` and `find_phone` steps (see the [campaigns guide](/smart-api/campaigns#step-types)). ## Lists [#lists] A list is a named bucket of leads: `id`, `name`, `color`, `leadCount`, timestamps. Lists serve three roles: 1. **Campaign targeting.** Pass `{ "listId": "..." }` to [Add leads to a campaign](/smart-api/reference/leads-in-campaigns/addLeadsToCampaign) to enroll every lead in the list. 2. **Exclusion.** Set `excludeListIds` on a [campaign](/smart-api/campaigns) (at create or update): members of those lists are never contacted by that campaign. Typical use: a "customers" or "do not contact" list shared across campaigns. On update, `excludeListIds` replaces the campaign's exclusion lists; pass an empty array to clear them. 3. **Import destination.** Every [lead import](#lead-imports-from-a-linkedin-search) lands its leads in a list. Create a list: ```bash curl -X POST https://app.swarmhit.com/api/v1/lists \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "name": "Conference leads" }' ``` Add (and thereby create) leads in it: ```bash curl -X POST https://app.swarmhit.com/api/v1/lists/LIST_ID/leads \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "leads": [ { "profileUrl": "https://www.linkedin.com/in/jane-doe", "firstName": "Jane", "lastName": "Doe", "company": "Acme" }, { "profileUrl": "https://www.linkedin.com/in/john-roe", "email": "john@roe.dev" } ] }' ``` Membership is loose by design: [removing a lead from a list](/smart-api/reference/lists/removeListLead) or [deleting the list](/smart-api/reference/lists/deleteList) never deletes the leads themselves, only the membership. Deleting a list also takes it out of every campaign's `excludeListIds`, so its members stop being excluded. Deleting a campaign's own list leaves the campaign's leads enrolled; the campaign starts a new own list the next time leads are added to it. ## Lead imports from a LinkedIn search [#lead-imports-from-a-linkedin-search] A lead import is a background job that runs a LinkedIn or Sales Navigator people search through one of your connected [sender accounts](/smart-api/sender-accounts) and stores the results as leads. [`POST /lead-imports`](/smart-api/reference/lead-imports/createLeadImport) returns immediately; a background worker picks the job up within a minute and fetches the search page by page until `maxLeads` is reached or the search runs out of results. Provide **either**: * `url`: a search URL copied from LinkedIn (Classic or Sales Navigator), or * `params`: a structured filter blob with `api` (`classic` | `sales_navigator`), `category` (`people`, the default) and at least one filter. Sending both, or neither, is rejected with `400`. `accountId` must be a connected LinkedIn sender in the workspace, and a Sales Navigator search needs a sender on Sales Navigator; otherwise, or when the `listId` or `campaignId` you pass does not exist, the request is rejected with `400`. In `params`, filter ids (location, industry, company, ...) must be strings (`"2579"`, not `2579`). Classic search uses flat arrays (`company: ["2579"]`) while Sales Navigator uses include/exclude objects (`company: { include: ["2579"] }`). Destination and limits: pass `listId` to import into an existing list, or omit it and a new list is auto-created (name it with `listName`) and returned in the response under `destination`. To import straight into a campaign, pass `campaignId` instead (it takes precedence over `listId`): the leads land in the campaign's own list (created if it doesn't exist yet) and are enrolled in the campaign page by page, subject to its exclusion rules. `maxLeads` caps how many leads are stored (default 100, at most 2500; leads already in the workspace count too). `tags` (up to 20, 50 characters each) are added to the leads the import creates; leads that were already in the workspace keep their tags unchanged. Each stored lead also counts toward the sender's daily search-import limit. When it is reached, the import waits until midnight UTC: it stays `running`, and `nextPageAt` shows when it continues. ```bash curl -X POST https://app.swarmhit.com/api/v1/lead-imports \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "accountId": "ACCOUNT_ID", "url": "https://www.linkedin.com/search/results/people/?keywords=cto%20fintech", "listName": "Fintech CTOs", "maxLeads": 500, "tags": ["fintech"] }' ``` ### Job lifecycle and polling [#job-lifecycle-and-polling] An import moves through these statuses: | Status | Meaning | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `pending` | Listed in the enum but not used today: a new import starts as `running`. | | `running` | Fetching search pages and storing leads; the worker fetches the first page within a minute of creation. | | `paused` | On hold, for example after LinkedIn rate-limited the search (see the `error` field); restart it with [resume](/smart-api/reference/lead-imports/resumeLeadImport). | | `completed` | Finished: `maxLeads` reached or the search exhausted. | | `failed` | Stopped on an error (see the `error` field); can be [resumed](/smart-api/reference/lead-imports/resumeLeadImport). | | `cancelled` | Stopped by [cancel](/smart-api/reference/lead-imports/cancelLeadImport). | Poll [`GET /lead-imports/{id}`](/smart-api/reference/lead-imports/getLeadImport) to track progress. The `progress` object reports `fetchedPages`, `fetchedItems`, `importedLeads`, `skippedItems` and (when the search reports one) `totalCount`; `nextPageAt` tells you when the worker will next tick the job. [`GET /lead-imports`](/smart-api/reference/lead-imports/listLeadImports) lists jobs and filters by `status`. Control endpoints: [cancel](/smart-api/reference/lead-imports/cancelLeadImport) a running or paused import, [resume](/smart-api/reference/lead-imports/resumeLeadImport) a paused or failed one, and [delete](/smart-api/reference/lead-imports/deleteLeadImport) an import only once it is terminal (`completed`, `failed` or `cancelled`; otherwise `400`). Cancel and resume return the import unchanged when it is not in a state they apply to. Instead of polling per lead, you can subscribe a [webhook](/smart-api/webhooks) to the `lead.imported` event to be notified as leads land. It fires for each lead the import adds to the workspace; leads that were already there do not trigger it. ## Reference [#reference] # LinkedIn safety and rate limits (/smart-api/linkedin-safety) Everything in this guide exists to keep sender accounts safe. A restricted LinkedIn account is worth far more than any single day's sends, so Swarmhit enforces limits server-side and expects your integration to add human-like timing on top. ## The one rule [#the-one-rule] Swarmhit applies LinkedIn's recommended **daily and weekly limits** automatically and caps the number of **outstanding invitations**. What your flow controls is **timing**: since your code decides when each call fires, space the calls like a human would. A tight loop, even while under the limits, is what gets accounts restricted. Add the delays and working hours below. ## Daily limits (per sender, reset at UTC midnight) [#daily-limits-per-sender-reset-at-utc-midnight] Limits are counted per sender account, not per API key or workspace. The same counters apply whether an action runs through the REST API, the [MCP server](/smart-api/mcp-server), or a campaign (in a campaign, `if_connected` and `if_open_profile` checks also draw the profile-view cap, and voice notes the message cap). | Action | Endpoint | MCP tool | Cap (default / max) | Use per day | Hard rule | | ----------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------- | ------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | Connection request | [`POST /accounts/{id}/connections`](/smart-api/reference/direct-actions/sendConnectionRequest) | `send_connection_request` | 25 / 40 | ≤ 20 | also capped over a rolling 7 days (**130** for free senders, **150** for paid) and at **500 pending** by default; stay near 100/week to be safe | | Message (1st-degree) | [`POST /accounts/{id}/messages`](/smart-api/reference/direct-actions/sendDirectMessage) | `send_direct_message` | 40 / 40 | ≤ 40 | connections only | | InMail (non-connection) | [`POST /accounts/{id}/inmails`](/smart-api/reference/direct-actions/sendInmail) | `send_inmail` | 40 / 40 | ≤ 30 | premium senders only; open profiles are free on Sales Navigator and Recruiter (about 800 / month), everything else uses InMail credits | | Profile view | [`POST /accounts/{id}/profile-views`](/smart-api/reference/direct-actions/visitProfile) | `visit_profile` | 40 / 40 | ≤ 40 | real and visible to the target | | Follow | [`POST /accounts/{id}/follows`](/smart-api/reference/direct-actions/followProfile) | `follow_profile` | 30 / 40 | ≤ 30 | | | Post like | [`POST /accounts/{id}/post-likes`](/smart-api/reference/direct-actions/likeLastPost) | `like_last_post`, `react_to_post` | 30 / 40 | ≤ 30 | the lead's latest post or a post by its link; every reaction counts | | Post comment | [`POST /accounts/{id}/post-comments`](/smart-api/reference/direct-actions/commentLastPost) | `comment_last_post`, `comment_on_post` | 15 / 40 | ≤ 15 | posts right away, as written; replies count too | | Search import | [`POST /lead-imports`](/smart-api/reference/lead-imports/createLeadImport) | `create_lead_import` | 1000 / 2500 | ≤ 1000 | default 2500 on Sales Navigator; LinkedIn also limits searches on free accounts each month | A sender is paid when its account has `isPremium: true` or a `linkedinPlan` of `sales_navigator` or `recruiter`, and free otherwise. The defaults apply until someone changes a sender's caps, up to the max, either in Swarmhit (its sender limits) or with [`PATCH /accounts/{id}/limits`](/smart-api/reference/accounts/updateAccountLimits). The weekly limit already sits at its max, so it can only be lowered. [`GET /accounts/{id}/limits`](/smart-api/reference/accounts/getAccountLimits) returns every cap with the `limit` in force, how much is `used`, and the `default`, `min` and `max` for the sender's plan (see [Sender accounts](/smart-api/sender-accounts#reading-and-changing-a-senders-limits)). `limits` on the [account](/smart-api/reference/accounts/getAccount) holds only the caps saved for that sender, and is `null` while it runs on the defaults. The search-import figures are the daily per-sender import volume. They are separate from the `maxLeads` parameter on a single import job (default 100, max 2500), covered in [Leads, lists and imports](/smart-api/leads-and-lists). An import that reaches its sender's daily cap is not refused: it stays `running` and carries on after the next UTC midnight. A direct action over its cap returns `429` with `details: { limit, used, scope, resetAt }`, where `scope` is `day`, `week`, or `pending`. Stop that action for that sender until `resetAt`. A new LinkedIn account should not start at these caps. With a [LinkedIn warm-up](/smart-api/sender-accounts#linkedin-warm-up), a sender starts with a few connection requests a day and climbs to its own caps (over 10 days with the default ramp), every other daily cap following the same share. While it warms up, the caps in force each day are the lower ones in `warmup.today` on [`GET /accounts/{id}/limits`](/smart-api/reference/accounts/getAccountLimits), for campaigns and direct actions alike. Warm-up is off unless you turn it on, for every new sender of an organization or a workspace with [`PATCH /accounts/warmup`](/smart-api/reference/accounts/updateAccountWarmupPolicy), or for one sender with `warmup` on [`PATCH /accounts/{id}/limits`](/smart-api/reference/accounts/updateAccountLimits). Reads and withdrawals draw no cap: [`GET` connection status](/smart-api/reference/direct-actions/checkConnection), [`GET` profile lookup](/smart-api/reference/direct-actions/lookupProfile), [`GET` post comments](/smart-api/reference/direct-actions/listPostComments), and [`DELETE` (withdraw)](/smart-api/reference/direct-actions/withdrawConnectionRequest) are uncapped. Each one still runs on the sender's LinkedIn session (a read loads the profile or the post, a withdrawal is an action), so space them out too and never loop them. LinkedIn tolerates more on paid accounts (roughly 100 invites/day, 100 to 150 messages/day, 100 profile views/day), but Swarmhit's daily caps on these actions stop at 40 on every plan, and the **weekly invite cap is what you will actually hit**: 130 a week for free senders, 150 for paid, enforced server-side. Stay conservative (around 100 invites per week); do not chase the daily maximum. ## Pace your calls (required) [#pace-your-calls-required] * **8 to 15 minutes between write actions** per sender, **randomized** (never a fixed interval). * **Working hours only**, in the account's local time zone (about 9:00 to 18:00), **weekdays**. * Spread the day's volume across 2+ sessions; vary the daily total by ±20%. * Keep writes under about 5 to 10 per hour. Campaigns handle this scheduling for you. These rules matter when you drive senders yourself through the [direct actions](/smart-api/direct-actions) endpoints: Swarmhit runs a direct action the moment you call it. Each one also pushes the sender's next campaign action back by a fresh gap, so a sender you share with campaigns stays paced. ## Hygiene [#hygiene] * Pre-check with the uncapped `GET`s ([connection status](/smart-api/reference/direct-actions/checkConnection), [profile lookup](/smart-api/reference/direct-actions/lookupProfile)) and skip anyone already pending or connected. Fewer wasted sends and `409`s. Their `pending` status only reflects invitations this sender sent through the API or MCP: one sent by a campaign or from LinkedIn itself shows as `not_connected`. * Withdraw stale pending invites (3 to 4 weeks old) via [`DELETE`](/smart-api/reference/direct-actions/withdrawConnectionRequest) (uncapped); keep total pending under \~500. Too many open invites is itself a restriction trigger. Swarmhit counts an invitation as pending until it is accepted or withdrawn, so a declined one keeps counting until you withdraw it. A campaign's invite step withdraws its own unaccepted requests (after 25 days by default: `withdrawEnabled`, `withdrawDays`); invitations you send through the API are never withdrawn for you. * Re-inviting the same person too soon after a withdraw is rejected; they must wait about 3 weeks. * Personalize first touches. Invitation notes are a premium sender feature of up to 300 characters (a longer `note` is rejected with `400`); invites from free senders are sent without the note, and the returned action's `note` is `null` (LinkedIn gives free accounts only a handful of note-invites per month). Never send identical templated messages at volume. * Connect each sender with the `country` its owner signs in from, or with your own `proxy`, so LinkedIn sees the account in a consistent place. See [Sender accounts](/smart-api/sender-accounts). ## Error handling [#error-handling] | Signal | What to do | | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `429` with `details.scope` `day` / `week` / `pending` | Stop that action for that sender until `resetAt`; for `pending`, withdraw some invites first. No retry loop. | | `429` with `code: inmail_credits_exhausted` | Stop InMails from that sender until its credits refill. | | `429` with no `details` ("rate-limiting, slow down") | Back off hard and widen your gaps. | | `409` (already pending) | Skip and move on. | | `401` / `403` | Stop; do not retry. A `401` is your API key; a rejected sender surfaces as `409` or an account webhook. | | `422`, or `404` with `code: profile_not_found` | Skip the profile. | | `502` | LinkedIn did not answer; retry later, with a new `Idempotency-Key`. | ### 429: a sender limit was hit [#429-a-sender-limit-was-hit] A `429` with `details` means the sender hit one of Swarmhit's limits: its daily cap for that action (the day's lower cap while it [warms up](/smart-api/sender-accounts#linkedin-warm-up), which the message says), its weekly connection-request limit (tier-based: 130 invitations over the last 7 days for free sender accounts, 150 for paid), the pending-invitation backlog, or its InMail credits. ```json { "error": { "status": 429, "message": "Daily limit reached for this action on this account (25/25).", "details": { "limit": 25, "used": 25, "scope": "day", "resetAt": "2026-06-19T00:00:00.000Z" } } } ``` Branch on `details.scope`: * `day`: pause that action for that sender until `resetAt`, the next 00:00 UTC. Other actions and other senders are unaffected. * `week`: pause connection requests from that sender until `resetAt`, when the oldest invitation of the last 7 days leaves the window and frees a slot. * `pending`: there is no `resetAt`; the backlog clears as invitations are accepted or withdrawn. Withdraw stale invites, then resume. * `inmail_credits` (`code: inmail_credits_exhausted`): the sender is out of InMail credits. Stop sending InMails from it until the balance refills (visible as `inmailCredits` on the [account](/smart-api/reference/accounts/getAccount)). A `429` without `details` comes from LinkedIn itself: see "rate-limiting, slow down" below. Never sit in a retry loop on a `429`. Retrying does not free the window and the burst pattern itself endangers the account. ### 409: a conflict [#409-a-conflict] Branch on `error.code` where present: * **Invitation already pending** to this profile: skip the profile and move on. This is normal attrition, not a failure. * `sender_paused`: the sender is paused. Either [resume it](/smart-api/reference/accounts/resumeSender) or route the send to another sender. * `inmail_not_available`: the sender has no premium InMail access. Do not retry; InMail needs a premium LinkedIn plan on the sender. * **Sender rejected by LinkedIn**, or its LinkedIn session is gone: stop sending from it. A session LinkedIn is refreshing is refused for a few minutes too, so [reconnect it](/smart-api/reference/accounts/reconnectAccount) once `account.status_changed` reports `error`, or if the `409`s go on for more than about 20 minutes: see [when a sender needs reconnecting](/smart-api/sender-accounts#when-a-sender-needs-reconnecting). * `idempotency_in_progress`: a request with the same `Idempotency-Key` is still in flight; retry shortly with the same key. `idempotency_failed`: the original's outcome couldn't be confirmed; check whether it went through, then retry with a new key. ### 401 and 403: stop and alert [#401-and-403-stop-and-alert] A `401` means the bearer key is missing, invalid or revoked, or the `X-Swarmhit-Workspace` header names a workspace the key cannot act for. A `403` means the workspace is not allowed to perform the action. Neither resolves on retry: stop the integration and alert a human. A sender that loses its LinkedIn session is a separate case. Subscribe to the `account.status_changed` and `account.checkpoint_required` [webhook events](/smart-api/webhooks#sender-and-mailbox-events) so you learn about it: at once when LinkedIn signs it out, or about 20 minutes after a campaign's first refused call (a session LinkedIn is refreshing is refused for a few minutes, and that isn't reported). Then stop all sends from that sender, and [reconnect it](/smart-api/reference/accounts/reconnectAccount) before resuming. If no event comes but its sends keep answering `409` for more than about 20 minutes, reconnect it anyway: see [when a sender needs reconnecting](/smart-api/sender-accounts#when-a-sender-needs-reconnecting). ### 422 and 404: skip the profile [#422-and-404-skip-the-profile] A `422` means LinkedIn could not complete the action on this target (cannot invite, already connected, the recipient cannot be messaged by this sender, or, for likes and comments, the lead has no recent post). A `404` with `code: profile_not_found` means the profile URL matched nothing. Both are per-profile outcomes: log them, skip the profile, and never retry them in a loop. A `404` without a code means the sender id is not in this workspace. ### 502: LinkedIn did not answer [#502-linkedin-did-not-answer] The action may or may not have gone through. Retry later rather than at once. If you sent an `Idempotency-Key`, the retry needs a new one: the old key now answers `409` `idempotency_failed`. ### "rate-limiting, slow down" [#rate-limiting-slow-down] When LinkedIn itself throttles the sender, the call fails with a `429` that has no `details` and a message telling you to slow down. Treat it as a serious signal even when you are under every documented cap. Back off hard and widen your gaps. ## Example: a safe send loop [#example-a-safe-send-loop] Use an `Idempotency-Key` on every send so a timeout retry can never double-send, and check connection status first. ```bash # Free pre-check: skip anyone already pending or connected curl "https://app.swarmhit.com/api/v1/accounts/ACCOUNT_ID/connections/john-doe" \ -H "Authorization: Bearer swh_live_xxxxxxxxxxxxxxxxxxxxxxxx" # Send, spaced 8 to 15 minutes after the sender's previous write curl -X POST "https://app.swarmhit.com/api/v1/accounts/ACCOUNT_ID/connections" \ -H "Authorization: Bearer swh_live_xxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 8b1f2c3d-a4e5-4f60-9b71-2c3d4e5f6a7b" \ -d '{ "profileUrl": "https://www.linkedin.com/in/john-doe", "note": "Hi John, enjoyed your post on outbound benchmarks. Would be glad to connect." }' ``` On `429`, persist `resetAt` per sender and action, and gate future sends on it locally instead of rediscovering the limit with failed calls. ## Related [#related] # Mailboxes (/smart-api/mailboxes) A mailbox is the email counterpart of a [sender](/smart-api/sender-accounts): the address a campaign's `email` steps send from, and where replies land. There are two ways to get one: * **Connect a mailbox someone already has**, through a [hosted connect link](#hosted-connect-links) or in Swarmhit. * **Buy domains and mailboxes through Swarmhit**, [over the API](#buying-domains-and-mailboxes) or on a hosted link, or buy only the mailboxes, on [a domain you already own](#using-a-domain-you-own). Bought mailboxes arrive connected, with no sign-in, and [warm up](#warmup) before campaigns use them. Mailboxes take no seat: a seat covers one LinkedIn sender, and a workspace on a paid plan connects as many mailboxes as it needs. A workspace inside an organization draws its plan from the organization. The email channel has to be enabled for the workspace: until it is, reads still answer, but creating a hosted link and pausing, resuming, removing, re-checking, testing or sending from a mailbox return `403`. Credentials are never returned by any endpoint. With an organization key, set `X-Swarmhit-Workspace` to choose the workspace a mailbox lands in or an order is placed for (see [Organization](/smart-api/organization)). ## Hosted connect links [#hosted-connect-links] [`POST /email-accounts/hosted-link`](/smart-api/reference/email-accounts/createMailboxHostedConnectLink) is how the API connects a mailbox without ever handling its password. It works like the [LinkedIn hosted link](/smart-api/sender-accounts#hosted-connect-links): you mint a one-time URL, send the person to it, and they connect on a page Swarmhit serves in your organization's name and logo. `providers` says what the page offers; the person picks one when there are several. | `providers` value | What the page offers | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `google` | Sign in with Google (Gmail and Google Workspace). | | `google_app_password` | A Google address and an app password. | | `microsoft` | Sign in with Microsoft (Outlook.com, Hotmail and Microsoft 365). | | `imap` | Any other mailbox: its address, password, and IMAP and SMTP servers. The page fills in the servers it can find for the address. | | `buy_mailboxes` | Buying new domains and mailboxes on the page. Needs `purchase`, see [below](#buying-on-the-page). | ```bash curl -X POST https://app.swarmhit.com/api/v1/email-accounts/hosted-link \ -H "Authorization: Bearer swh_live_..." \ -H "X-Swarmhit-Workspace: wSp042" \ -H "Content-Type: application/json" \ -d '{ "providers": ["google", "microsoft", "imap"], "redirectUrl": "https://yourapp.com/settings/email/done", "externalId": "your-user-42" }' ``` ```json { "data": { "id": "hLk789", "status": "pending", "channel": "email", "provider": null, "providers": ["google", "microsoft", "imap"], "workspaceId": "wSp042", "accountId": null, "orderId": null, "purchase": null, "externalId": "your-user-42", "redirectUrl": "https://yourapp.com/settings/email/done", "url": "https://app.swarmhit.com/connect/Qm9Kx...", "expiresAt": "2026-10-08T10:30:00.000Z" } } ``` `provider` is the one-value form of `providers`; send one or the other, not both. The rest works as on LinkedIn links: `url` is returned once, here; the link expires after 30 minutes by default (`expiresIn`, in seconds, between 300 and 604800); and `redirectUrl` may be left out when your organization has a default return URL (`400` with `code: invalid-redirect-url` when there is neither). The link is checked when you create it, so problems surface here rather than at the end of the person's visit: * `400` with `code: no-plan` when the workspace has no paid plan, `code: invalid-provider` for an unknown or missing provider. * `403` when the email channel is not enabled for the workspace. * `409` with `code: provider-unavailable` when a provider the link names is not available yet. ### Buying on the page [#buying-on-the-page] A link offering `buy_mailboxes` lets the person buy new domains and mailboxes themselves. They search for domains, choose Google Workspace or Microsoft 365 for each, and name the mailboxes. The page shows no prices: you pay. * `purchase.maxMailboxes` (1 to 100) caps how many mailboxes the person can buy through the link. `purchase` is required with `buy_mailboxes` and refused without it (`400`, `code: invalid-purchase`). * `purchase.contact` sets the registrant the domains are registered to, with the same fields as an order's [`contact`](#2-get-a-quote). Set it and the page does not ask the person for it. * Domains bought on the page are registered for one year, and their mailboxes warm up with the `standard` strategy. * The order is charged to the card on file of the workspace that pays: the organization, for a workspace inside one. It is placed on behalf of the API key's creator, who needs billing rights there. These are checked when the link is created: `403` with `code: buying-off` when buying mailboxes is not enabled for the workspace, `403` with `code: not-allowed` when the key's creator has no billing rights, and `409` with `code: no-payment-method` when there is no card on file. Once the person buys, the link is done: its status becomes `ordered` and `orderId` names the order. Follow the order like any other ([below](#following-an-order)); `mailbox_order.completed` and each new mailbox's `account.connected` carry the link's `hostedLinkId` and `externalId`. ### Reconnecting through a link [#reconnecting-through-a-link] Pass `accountId` and the link repairs that mailbox instead of adding one: it keeps its id, its campaigns and its settings. A reconnect link offers only the mailbox's own provider, so `providers` can be left out (any other value is a `400` with `code: invalid-provider`). A mailbox that signs in with Google or Microsoft must sign in again as the same address; a password mailbox keeps its address and servers and takes the new password. Bought mailboxes never need reconnecting, and a link for one is refused with `409` and `code: managed-mailbox`. An `accountId` that is not a mailbox of the workspace returns `404`. ### Knowing how it went [#knowing-how-it-went] * **The redirect.** The person comes back to `redirectUrl` with `?hosted_link_id=…&status=connected|ordered|failed|expired|cancelled&account_id=…&order_id=…&external_id=…`. `account_id` is the mailbox id; `order_id` is set after a purchase. * **Webhooks.** `account.connected` (with `data.channel: "email"`) and `account.connection_failed` carry `hostedLinkId` and `externalId`. * **Polling.** [`GET /email-accounts/hosted-link/{id}`](/smart-api/reference/email-accounts/getMailboxHostedConnectLink) returns the link's `status` (`pending`, `connected`, `ordered`, `failed` or `expired`), the `accountId` or `orderId` it produced, and an `error` when it failed. A failed attempt, such as a wrong password, fires `account.connection_failed` and leaves the link open so the person can try again, a limited number of times. Only cancelling on the page (the link becomes `failed` with `error.code: cancelled`) or expiry ends it without a mailbox. ## Connecting in Swarmhit [#connecting-in-swarmhit] Mailboxes can also be connected in Swarmhit, on the **Accounts** page (**Email** tab): Google (sign-in or an app password), Outlook and Microsoft 365 (sign-in), or any other provider over IMAP/SMTP. They then show up in the API like any other. ## Managing mailboxes [#managing-mailboxes] [`GET /email-accounts`](/smart-api/reference/email-accounts/listEmailAccounts) lists the workspace's mailboxes, newest first, and [`GET /email-accounts/{id}`](/smart-api/reference/email-accounts/getEmailAccount) returns one. Each carries: * `status`: `pending` while it connects, `active`, `error` when its sign-in broke (reconnect it through a [link](#reconnecting-through-a-link)), `suspended`, or `disconnected` once it is removed. * `provider` (`google`, `microsoft` or `imap`), `authType` (`oauth` for a mailbox signed in with its provider, `password` otherwise) and `connectedVia` (`app`, `hosted_link`, or `managed` for a bought mailbox). * The pause state, daily `limits` and today's `usage`, the tracking settings `trackOpens` and `trackClicks`, and lifetime `counters` (`sent`, `bounced`, `complained`, `opened`, `clicked`). Tracking is set in Swarmhit, in the mailbox settings. * `dnsHealth`: the sending domain's SPF, DKIM and DMARC records, each `ok`, `missing`, `invalid`, `weak` or `unknown`, re-read daily. * `managed` and `warmup` on a bought mailbox (both `null` on a mailbox you connected). * `senderId`: the [sender](/smart-api/senders) the mailbox belongs to, or `null`. [`PATCH /email-accounts/{id}`](/smart-api/reference/email-accounts/updateEmailAccount) with `{"senderId": "..."}` sets it, `null` clears it. * `picture`: the profile photo, when known. A bought mailbox's is [set by you](#a-bought-mailboxs-picture); a mailbox signed in with Google or Microsoft shows its account's photo. ```bash curl https://app.swarmhit.com/api/v1/email-accounts \ -H "Authorization: Bearer swh_live_..." ``` * [`POST /email-accounts/{id}/pause`](/smart-api/reference/email-accounts/pauseEmailAccount) and [`.../resume`](/smart-api/reference/email-accounts/resumeEmailAccount) work like the sender pair: campaigns stop emailing from a paused mailbox, its leads stay assigned, and both emit `account.paused` / `account.resumed` with `channel: "email"`. * [`DELETE /email-accounts/{id}`](/smart-api/reference/email-accounts/removeEmailAccount) removes the mailbox and its threads, takes it out of every campaign pool and moves the leads it was emailing to the remaining mailboxes. Connecting the same address again within the restore window (15 days by default) brings back its campaigns, leads and settings, under a new id with `restoredFrom` set. A bought mailbox is never removed this way (`409`, `code: managed-mailbox`): [cancel it](#cancelling-a-bought-mailbox) instead. * [`POST /email-accounts/{id}/check-dns`](/smart-api/reference/email-accounts/checkEmailAccountDns) re-reads the domain's records now, after you change DNS. See [Email deliverability](/smart-api/email-deliverability) for what each record does. * [`POST /email-accounts/{id}/deliverability-test`](/smart-api/reference/email-accounts/testEmailAccountDeliverability) sends one email to our test inbox; the mailbox's `deliverabilityTest` then reports where it landed and what the receiving server saw. * [`GET /email-accounts/{id}/stats`](/smart-api/reference/email-accounts/getEmailAccountStats) reports emails sent, replies and bounces over 7 to 90 days (`days`, default 30), plus opens and clicks for tracked emails, with the per-campaign split. * [`POST /email-accounts/{id}/emails`](/smart-api/reference/direct-actions/sendEmail) sends one email from the mailbox outside any campaign; see [Direct actions](/smart-api/direct-actions#emails). A lead whose address bounced hard or reported spam carries `emailStatus` (`bounced` / `complained`) and stops at any email step it reaches from then on; see [Email deliverability](/smart-api/email-deliverability). ## Buying domains and mailboxes [#buying-domains-and-mailboxes] Swarmhit registers the domains, creates Google Workspace or Microsoft 365 mailboxes on them with their DNS records, and connects them to the workspace: nobody signs in, and they never need reconnecting. Buying needs the managed mailboxes feature on the workspace, and the API key's creator needs billing rights in the workspace that pays. Reading orders, domains and billing never needs the feature. To put mailboxes on a domain you already own instead, [connect it first](#using-a-domain-you-own). ### 1. Find domains [#1-find-domains] [`GET /domains/search`](/smart-api/reference/mailbox-orders/searchDomains) takes a `keyword` (a word, or one exact domain) and optional `tlds` (comma separated, e.g. `.com,.org`) and `years`: ```bash curl "https://app.swarmhit.com/api/v1/domains/search?keyword=getacme&tlds=.com,.io" \ -H "Authorization: Bearer swh_live_..." ``` Each result says whether it is `available`, whether Google Workspace and Microsoft 365 can host it (`platforms`), and the registrar's first-year price (`priceCents`). `offered: false` means it cannot be bought here: an ending we do not sell, or a first year above our price cap. The [quote](#2-get-a-quote) has the prices you actually pay. A key may search 15 times a minute (`429`, `code: rate-limited`). ### 2. Get a quote [#2-get-a-quote] [`POST /mailbox-orders/quote`](/smart-api/reference/mailbox-orders/quoteMailboxOrder) prices an order and charges nothing: ```bash curl -X POST https://app.swarmhit.com/api/v1/mailbox-orders/quote \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "domains": [{ "name": "getacme.com", "platform": "google", "years": 1, "redirectUrl": "https://acme.com", "mailboxes": [ { "username": "jane", "firstName": "Jane", "lastName": "Doe" }, { "username": "jane.doe", "firstName": "Jane", "lastName": "Doe" } ] }], "contact": { "firstName": "Jane", "lastName": "Doe", "email": "jane@acme.com", "phone": "+14155550142", "addressLine1": "1 Market St", "city": "San Francisco", "state": "CA", "country": "US", "postalCode": "94105" }, "warmup": { "enabled": true, "strategy": "standard" } }' ``` * An order holds 1 to 20 domains, and each domain 1 to 5 mailboxes. A new domain needs its `platform` (`google` or `microsoft`) and a `redirectUrl`: your own `https://` website, where visitors of the domain are sent. `years` is 1 to 10 (default 1). Each mailbox needs a `username` (the part before @), `firstName` and `lastName`, and may take a `pictureUrl` (`https://`). * `contact` is the registrant of the new domains. It is required when the order registers a domain, and never returned by the API. After the purchase, the registry may email `contact.email` a link to confirm the address (ICANN requires it for `.com`, `.net` and `.org`, and `.fr` asks too). Open it within 14 days, or the domain is suspended (its website and mailboxes stop working) until the link is opened. An address confirmed once is not asked again. * `warmup` is on by default with the `standard` strategy; see [Warmup](#warmup). * To add mailboxes to a domain you already bought, or to [one you own](#using-a-domain-you-own) once it is ready, send `{ "name": "getacme.com", "existing": true, "mailboxes": [...] }`: no domain is charged, and they are usually ready within minutes. [`GET /managed-domains`](#managed-domains) says how many each domain can still take. The quote returns a `quoteId` valid for 30 minutes, the `lines` charged, `dueNowCents` (the domains and the first month, charged when you buy) and `monthlyCents` (charged every month after, while the mailboxes run). Domains that can no longer be registered are listed in `unavailable`, and addresses already taken on one of your domains in `unavailableMailboxes`: remove or rename them and quote again. A workspace may quote 6 times a minute (`429`, `code: rate-limited`). Validation errors are a `400` with `code: invalid-order` and `details.errors` (`[{ path, message }]`). For `existing` domains: `404` with `code: domain-not-owned`, and `409` with `code: domain-full` (`details.slotsLeft`), `domain-admin-cancelled`, or `domain-not-ready` (a domain you own that is still connecting, or whose nameservers moved away). `403` with `code: feature-disabled` when buying is not enabled for the workspace, or `not-allowed` without billing rights. `503` with `code: quote-unavailable` when prices cannot be read right now: retry. ### 3. Buy [#3-buy] [`POST /mailbox-orders`](/smart-api/reference/mailbox-orders/createMailboxOrder) with the `quoteId` charges `dueNowCents` to the card on file of the paying workspace (the organization, for a workspace inside one) and starts the order: ```bash curl -X POST https://app.swarmhit.com/api/v1/mailbox-orders \ -H "Authorization: Bearer swh_live_..." \ -H "Idempotency-Key: 6c1e0f1a-buy-getacme" \ -H "Content-Type: application/json" \ -d '{ "quoteId": "qTe901" }' ``` Buying the same quote twice returns the same order without a second charge. Order fields are optional here; when sent, they must be exactly what was quoted (`409`, `code: quote-mismatch`). | Status | `code` | What to do | | ------ | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `402` | `no-payment-method`, `payment-failed` | Add or fix the card in Billing, then buy again. | | `409` | `quote-expired`, `price-changed`, `domain-unavailable`, `mailbox-unavailable` | Quote again. | | `409` | `domain-full`, `domain-admin-cancelled`, `domain-not-ready` | A domain you already have can't take these mailboxes now. Check its `canAdd` and `reason` with [`GET /managed-domains/{id}`](/smart-api/reference/mailbox-orders/getManagedDomain), then quote again. | | `409` | `payment-pending` | The charge is still being confirmed: do not buy again. The order shows up in `GET /mailbox-orders` within minutes if it went through. | | `409` | `purchase-in-progress` | Another call is buying this quote right now. | | `503` | `temporarily-unavailable` | Retry later. | ### Following an order [#following-an-order] [`GET /mailbox-orders/{id}`](/smart-api/reference/mailbox-orders/getMailboxOrder) returns the order with every domain and mailbox; [`GET /mailbox-orders`](/smart-api/reference/mailbox-orders/listMailboxOrders) lists the workspace's orders, newest first (optional `status`). To list the orders of every workspace in an organization, see [Across an organization](#across-an-organization). An order moves `paid` → `placing` → `placed` → `provisioning` → `authorizing` → `connecting` → `completed`, usually within a few hours. `needs_attention` means it is delayed and our team has been alerted: they resume it, or cancel it. `cancelled` means it was stopped and refunded. `delayed: true` flags an order that is held up, including one still running while a mailbox is stuck in setup. Treat a status you do not recognise as in progress. Each mailbox has a `step` (`ordered`, `provisioning`, `authorizing`, `connecting`, `connected`, `failed` or `refused`) and, once connected, its `accountId`. A domain that could not be registered is `refused` (with `refusedReason`) and is refunded, along with its mailboxes. You do not have to poll: `mailbox_order.completed` fires when the order is done and `mailbox_order.failed` when it needs attention or is cancelled. Each mailbox emits `account.connected` as it connects (`data.channel: "email"`, and `connectedVia: "managed"` on the mailbox). See [Webhooks](/smart-api/webhooks). ## Using a domain you own [#using-a-domain-you-own] Instead of buying a domain, the workspace can use one it already owns: [`POST /managed-domains`](/smart-api/reference/mailbox-orders/addOwnDomain) with its `name` and the `platform` its mailboxes will live on (`google` or `microsoft`, one per domain). Nothing is charged for the domain, only for its mailboxes, and Swarmhit never renews or bills it: it stays registered where it is, and you renew it there. Adding one needs what buying needs: the managed mailboxes feature (`403`, `code: feature-disabled`), billing rights for the key's creator (`403`, `code: not-allowed`) and a card on file (`402`, `code: no-payment-method`). ```bash curl -X POST https://app.swarmhit.com/api/v1/managed-domains \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "name": "getacme.com", "platform": "google" }' ``` The answer is the domain, with `source: "own"` and a `connection` that says where it stands (trimmed below): ```json { "data": { "id": "dMn4Rk8Qw2Zp5Xs3L", "name": "getacme.com", "platform": "google", "source": "own", "slotsLeft": 5, "canAdd": false, "reason": "connecting", "connection": { "state": "nameservers", "stateLabel": "Waiting for nameservers", "nameservers": ["ns1.example.net", "ns2.example.net"], "currentNameservers": ["ns1.registrar.example", "ns2.registrar.example"], "websiteRecords": [{ "type": "A", "host": "@", "value": "203.0.113.10" }], "websiteRecordsCopied": true, "dnssec": false, "expiresAt": "2026-11-12T10:00:00.000Z" } } } ``` 1. **Set the nameservers.** At the domain's registrar, replace `connection.currentNameservers` with the two `connection.nameservers`. They can be empty for a minute right after adding: read the domain again. When `connection.dnssec` is `true`, turn DNSSEC off at the registrar too, or the new nameservers are never seen. 2. **Wait for it to connect.** `connection.state` moves to `setting_up` once the new nameservers are seen (its email records are being written, usually for a few minutes), then to `ready`. Swarmhit checks on its own; right after changing the nameservers, [`POST /managed-domains/{id}/check`](/smart-api/reference/mailbox-orders/checkOwnDomain) looks again within a minute. [`GET /managed-domains/{id}`](/smart-api/reference/mailbox-orders/getManagedDomain) returns where it stands, and `managed_domain.ready` fires when it is ready. 3. **Add mailboxes.** Once `canAdd` is `true`, [quote](#2-get-a-quote) them as on a bought domain, with `{ "name": "getacme.com", "existing": true, "mailboxes": [...] }`, then [buy](#3-buy). Only the mailboxes are charged, and they are usually ready within minutes. The domain's DNS moves to Swarmhit. Its website records (`A` or `AAAA` at the root, and `www`) are copied to its new DNS (`connection.websiteRecords`; `websiteRecordsCopied` is `true` once they are in), so its website keeps working. Nothing else is copied: subdomains, other records and any email the domain has today stop working once its nameservers change. * **Set the nameservers within about 12 hours.** A pair not seen by then is replaced by a new one: `connection.nameserversChangedAt` is set and `managed_domain.action_required` fires with `reason: nameservers_changed`. Set the new pair instead. * **Nameservers never changed within 7 days** (`connection.expiresAt`): the domain becomes `expired`, and `managed_domain.action_required` fires with `reason: expired`. The same call adds it again. A name whose nameservers were never seen can be added again twice; after that, contact support. * **`failed`** means it is delayed and our team is on it. Once it is ready: * **Its nameservers must keep pointing to Swarmhit.** If they are changed, `connection.movedAt` is set, `canAdd` turns `false` with `reason: moved`, and `managed_domain.action_required` fires with `reason: nameservers_moved`. Set them back, or its mailboxes may stop working. * **Its registration stays with you.** `connection.registrarExpiresAt` says when it lapses, when the registry tells us, and `managed_domain.action_required` with `reason: registration_expiring` warns as that date nears: renew it at the registrar. Adding is refused with: * `400`, `code: invalid-domain`; * `409`, `code: domain-exists`, when it is already one of the workspace's domains; * `409`, `code: domain-taken`, when it is, or was, live in another account (contact support if it is yours); * `409`, `code: too-many-domains`, when 5 domains are already connecting; * `422`, `code: domain-refused`, when it cannot be connected, for example because it is not a registered domain; * `429`, `code: rate-limited`, after 5 adds in a minute. [`DELETE /managed-domains/{id}`](/smart-api/reference/mailbox-orders/removeOwnDomain) lets a domain you own go, once no mailbox runs there or is on its way (`409`, `code: domain-in-use`: [cancel](#cancelling-a-bought-mailbox) them first). Its DNS at Swarmhit is deleted, so the domain stops resolving until you set its nameservers back at the registrar (`connection.currentNameservers` holds the ones it had). Removing needs billing rights, and a domain bought here returns `409` with `code: not-own-domain`. In Swarmhit, **Use a domain I own** under **Settings, Domains** and in the **Buy mailboxes** wizard walks through the same steps. On the [MCP server](/smart-api/mcp-server), `add_own_domain`, `get_managed_domain`, `check_own_domain` and `remove_own_domain` do the same. ## Warmup [#warmup] A new mailbox on a new domain has no sending reputation. Warmup builds one: the mailbox exchanges a growing number of emails a day with a warmup network that opens them, replies to them and rescues them from spam. Warmup is for bought mailboxes; it is on by default for every order, and its endpoints return `409` with `code: not-managed` for a mailbox you connected yourself. ### The hold [#the-hold] Campaigns do not send from a warming mailbox until it is ready, and neither does [`sendEmail`](/smart-api/reference/direct-actions/sendEmail) (`409`, `code: mailbox_warming`). The mailbox's `warmup` object says why it is held: * `holdReason: "warming"` while warmup starts and for the first 14 days after it does (until `holdUntil`). * `holdReason: "health"` after that, for as long as its warmup health is `warning` or `critical`. When the hold lifts, `warmup.ready` fires and campaigns that include the mailbox start assigning leads to it right away. To send before warmup is done, [`POST /email-accounts/{id}/warmup/hold-override`](/smart-api/reference/warmup/overrideEmailAccountWarmupHold) with `{ "on": true }`; `{ "on": false }` holds it again. A paused or stopped warmup never holds a mailbox. ### Health [#health] [`GET /email-accounts/{id}/warmup`](/smart-api/reference/warmup/getEmailAccountWarmup) returns the warmup `status` (`pending` while it starts, `active`, `paused`, `cancelled`, `past_due` or `error`), the warmup `day`, the current and target daily volume, and its `health` (`excellent`, `good`, `warning`, `critical`, or `unknown` until there is enough data), with `healthScore` and `inboxRate`. [`GET .../warmup/stats`](/smart-api/reference/warmup/getEmailAccountWarmupStats) returns the daily series (`days`, 1 to 365, default 30). If health turns `critical` after the hold, Swarmhit pauses the mailbox for campaigns (`pausedReason: "warmup_health"`) while warmup carries on, and resumes it on its own once health is back to `good` or `excellent`. `warmup.health_changed` and `warmup.status_changed` report each change. ### Strategy [#strategy] A strategy sets the daily volume (start, increase per day, target) and the share of warmup emails the network replies to, rescues from promotions and rescues from spam. Pick a preset, or send your own values: | Preset | Start | Increase per day | Target | | -------------------- | ----- | ---------------- | ------ | | `conservative` | 5 | 2 | 30 | | `standard` (default) | 10 | 3 | 40 | | `aggressive` | 15 | 5 | 60 | ```bash curl -X PATCH https://app.swarmhit.com/api/v1/email-accounts/eMa123/warmup \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "strategy": { "preset": "standard", "targetVolume": 35 } }' ``` [`PATCH /email-accounts/{id}/warmup`](/smart-api/reference/warmup/updateEmailAccountWarmup) takes a preset name or an object: a `preset` fills the fields you leave out, and without one every field is required. `startingVolume` and `targetVolume` are 1 to 500 and `dailyIncrease` 1 to 50; the target can be at most `limits.maxTarget` (in every warmup endpoint's response), what the mailbox's daily email limit leaves for warmup. The starting volume only applies on the first day and cannot change after it (`409`, `code: starting-volume-locked`). Invalid values are a `400` with `code: invalid-strategy` and `details.errors`. A strategy changed before warmup starts is applied as soon as it does. ### Pausing, stopping and restarting [#pausing-stopping-and-restarting] * [`.../warmup/pause`](/smart-api/reference/warmup/pauseEmailAccountWarmup) and [`.../warmup/resume`](/smart-api/reference/warmup/resumeEmailAccountWarmup) pause and resume a running warmup. A warmup paused because a payment failed resumes only once it is paid (`409`, `code: warmup-billing-paused`). * [`.../warmup/stop`](/smart-api/reference/warmup/stopEmailAccountWarmup) ends warmup for good; campaigns may use the mailbox right away. * [`.../warmup/start`](/smart-api/reference/warmup/startEmailAccountWarmup) starts warmup again on a bought mailbox without one, once a month at most (`429`, `code: warmup-restart-limit`). A mailbox that already finished its hold is not held again. Starting and stopping warmup need billing rights. ## Managed domains [#managed-domains] [`GET /managed-domains`](/smart-api/reference/mailbox-orders/listManagedDomains) lists the domains the workspace bought or [owns](#using-a-domain-you-own) (`source`: `purchased` or `own`), and [`GET /managed-domains/{id}`](/smart-api/reference/mailbox-orders/getManagedDomain) returns one. Each holds up to 5 mailboxes, and each entry says how many run on it (`live`), how many are on their way (`pending`), how many more it can take (`slotsLeft`), and whether a mailbox can be added (`canAdd`). When one can't, `reason` says why: * `full`, `admin-cancelled`, `not-active` or `removed`; * `connecting`, for a domain you own that is not `ready` yet, or a bought one still being set up; * `moved`, for a domain you own whose nameservers no longer point to Swarmhit. `adminEmail` is the domain's admin mailbox, and `takenEmails` the addresses a new mailbox cannot reuse. A domain you own also carries its `connection`. Bought domains renew every year; domains you own are never renewed or billed here. The renewal is charged to the card about 30 days before the domain expires; a domain with no running mailbox is not renewed and lapses. A renewal that cannot be charged fires `mailbox_billing.payment_failed` (with a `payUrl`); a domain that lapses unpaid takes its mailboxes with it. ## Across an organization [#across-an-organization] By default, nothing changes: an organization key acting as the organization lists the organization's own orders and domains, and a key acting as one workspace (`X-Swarmhit-Workspace`), or a workspace's own key, lists that workspace's. Two optional query parameters widen or pick the list, only when you send them: * `orgWide=true` also lists every workspace inside the organization, for an organization key acting as the organization. Any other key keeps its one workspace. * `workspaceId` lists one workspace of that scope: the organization itself or one of its workspaces. Any other id is a `404` (`code: not-found`), so a workspace never learns whether another one exists. With either one, each order and domain also carries `workspaceId` and `workspaceName`; without them, the answer has exactly the fields described above. [`GET /mailbox-orders`](/smart-api/reference/mailbox-orders/listMailboxOrders) and [`GET /managed-domains`](/smart-api/reference/mailbox-orders/listManagedDomains) take both, and [`GET /mailbox-orders/{id}`](/smart-api/reference/mailbox-orders/getMailboxOrder) and [`GET /managed-domains/{id}`](/smart-api/reference/mailbox-orders/getManagedDomain) take `orgWide=true` to find an order or a domain of any workspace in the organization. Acting on a workspace's domain (checking or removing it) takes `X-Swarmhit-Workspace` naming that workspace. Webhook payloads do not change. ```bash curl "https://app.swarmhit.com/api/v1/mailbox-orders?orgWide=true" \ -H "Authorization: Bearer $SWARMHIT_KEY" ``` On the [MCP server](/smart-api/mcp-server), `list_mailbox_orders`, `get_mailbox_order` and `list_managed_domains` take the same `orgWide` and `workspaceId` arguments, for an organization key with no `workspaceId` on the connection, and `get_managed_domain` takes `orgWide`. ## A bought mailbox's picture [#a-bought-mailboxs-picture] [`POST /email-accounts/{id}/picture`](/smart-api/reference/mailbox-orders/setManagedEmailAccountPicture) sets a bought mailbox's profile photo from `{ "url": "https://..." }`, a public JPG or PNG (square works best). Gmail shows it next to the mailbox's emails, which can take up to a day. A mailbox you connected yourself keeps its own account photo (`409`, `code: not-managed`). In Swarmhit, the mailbox settings take an upload, and copy it to your other bought mailboxes. ## Cancelling a bought mailbox [#cancelling-a-bought-mailbox] [`POST /email-accounts/{id}/cancel-managed`](/smart-api/reference/mailbox-orders/cancelManagedEmailAccount) cancels a bought mailbox: it keeps working until its renewal date (`cancelAt`), then it is removed and no longer billed. Its warmup stops at once. Cancelling needs billing rights (`403`, `code: not-allowed`), and a mailbox you connected yourself returns `409` with `code: not-managed`. Each domain has one admin mailbox (`managed.isAdmin` on the mailbox, `adminEmail` on the domain). Once it is cancelled, no mailbox can ever be added to that domain again, so while other mailboxes still run there, cancelling it needs `{ "confirmAdmin": true }` (`409`, `code: admin-mailbox` otherwise). ## Billing [#billing] Bought mailboxes and domains are billed apart from the plan, to the card of the workspace that pays (the organization, for a workspace inside one), including purchases made on hosted links. Each purchase is charged when it is bought (the domains and the first month), then monthly from that date; each domain renews yearly. [`GET /mailbox-billing`](/smart-api/reference/mailbox-orders/getMailboxBilling) returns the monthly total and next charge date, the card charged, each purchase with its status, a `payUrl` when a payment failed and the last receipt, and each domain's renewal. It needs billing rights. When a monthly payment fails, `mailbox_billing.payment_failed` fires with a `payUrl`. A day later the purchase's warmup pauses, and after 14 days unpaid its mailboxes are cancelled. `mailbox_billing.payment_recovered` fires once it is paid. ## Reference [#reference] # MCP server (/smart-api/mcp-server) Most of the REST API is exposed to AI agents over the Model Context Protocol. Generate an MCP key on the **Access tokens** page in Swarmhit (the **MCP Server** card), then connect any MCP client (Claude, ChatGPT, Clay, n8n, Make) to the Streamable HTTP endpoint: ``` https://app.swarmhit.com/mcp?key=swh_mcp_xxxxxxxxxxxxxxxxxxxxxxxx ``` ## The key [#the-key] An MCP key starts with `swh_mcp_` and is separate from REST API keys: neither works on the other's endpoint. A workspace has one MCP key. It stays visible in the settings, so you can copy the connection URL again, and generating a new one stops the old one at once. Most clients need only the URL; a client that can send headers may pass the key as `Authorization: Bearer swh_mcp_…` instead of `?key=`. A missing or unknown key gets an HTTP `401`. ## Transport [#transport] The endpoint speaks JSON-RPC 2.0 and is stateless: each request is a `POST` answered with a plain JSON response, with no session and no server-sent event stream (a `GET` returns `405`). It accepts batches, and protocol versions `2025-06-18`, `2025-03-26` and `2024-11-05`. ## Tools [#tools] Each tool runs the same handler as its REST endpoint, so inputs are validated the same way and the same rules apply. `tools/list` returns every tool with its input schema. A tool result is a text item holding JSON, for most tools what the endpoint returns under `data`. A request the REST API would refuse with a `4xx` comes back as a result with `isError: true` and the error message as its text, so the agent can correct it. Webhooks and hosted connect links have no tools, and neither do a few reads and deletes (for example deleting a campaign, a lead or a list, or renaming a list): use the REST API for those. In hosts that render tool widgets (Claude, ChatGPT), the tools that list or show leads, lists, campaigns, sender accounts and stats also return an interactive widget. The JSON stays the first `content` item, so other clients are not affected. ## Acting as a workspace in your organization [#acting-as-a-workspace-in-your-organization] An organization's MCP key (one generated in the organization workspace) can act on any workspace inside it by appending `&workspaceId=` to the connection URL, one connection per workspace. A `workspaceId` that is not one of the organization's workspaces gets a `401` on every request. See [Authentication](/smart-api/authentication) for how workspace scoping works, and [Organization](/smart-api/organization) for the `list_workspaces`, `get_workspace`, `create_workspace`, `update_workspace`, `delete_workspace`, `invite_workspace_member`, `update_workspace_member` and `remove_workspace_member` tools. They always manage the key's organization, whatever workspace the connection acts on. `get_workspace_info` is the odd one out: it returns the workspace the connection resolves to, not one looked up by id. # Organization (/smart-api/organization) An **organization** workspace on Swarmhit holds the billing and the sender-seat pool, and contains any number of ordinary **workspaces** underneath it. The API mirrors that: one organization key manages every workspace inside it, without a separate key per workspace. A workspace on a paid plan becomes an organization on the **Organization** page in Swarmhit (an owner or admin turns it on). An organization key is an API key created in that workspace. ## The model [#the-model] | | Organization | Workspace inside it | | ---------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | Billing and plan | Owns them (`isOrganization: true`) | Inherits from the parent organization (`isSubWorkspace: true`) | | Sender seats | Holds the pool; `seatLimit` is the pool minus seats handed out | `seatLimit` is its `seatAllocation` from the pool | | Members | Its own members; its owners and admins also get access to every workspace inside it | Its own members, plus read-only organization managers granted via the organization | A seat is one LinkedIn sender account. Mailboxes take no seat: a workspace inside an organization connects as many as it needs on the organization's paid plan, and they are counted apart from seats (`mailboxesUsed`). See [Mailboxes](/smart-api/mailboxes). The [`getApiInfo`](/smart-api/reference/general/getApiInfo) endpoint (`GET /`) returns the connected workspace with `isOrganization`, `isSubWorkspace`, the effective `plan`, `seatLimit` and `seatsUsed`, which makes it a quick way to check what a key (or a header, see below) resolves to. ## Managing workspaces [#managing-workspaces] The Organization endpoints live under `/workspaces` and require the key's workspace to be an organization (anything else gets `403`, and [`listWorkspaces`](/smart-api/reference/organization/listWorkspaces) returns `isOrganization: false` with an empty `workspaces` list). Create a workspace and grant it two sender seats from the pool: ```bash curl -X POST https://app.swarmhit.com/api/v1/workspaces \ -H "Authorization: Bearer swh_live_xxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"name": "Acme Inc.", "seatAllocation": 2}' ``` The response is the new workspace, as [`getWorkspace`](/smart-api/reference/organization/getWorkspace) returns it, including the `id` to use in the `X-Swarmhit-Workspace` header. Seat math is enforced on write. [`createWorkspace`](/smart-api/reference/organization/createWorkspace) and [`updateWorkspace`](/smart-api/reference/organization/updateWorkspace) reject with `400` an allocation larger than the seats left to assign: the pool, minus the seats the organization uses itself, minus what the other workspaces are allocated (`listWorkspaces` reports the unallocated remainder as `available`). `updateWorkspace` also rejects with `400` an allocation lower than the LinkedIn senders the workspace has connected. `seatAllocation` is a whole number, 0 or more. Organizations whose seats are added as needed (the **Auto-add seats** option in Billing, or a plan that adds seats as senders connect) are not capped by the pool: raising an allocation beyond it first adds the missing seats to the subscription, the same way connecting a sender beyond the pool does. That fails with `400` only when the seats cannot be added, for example when the card is declined. Lowering an allocation never changes the subscription. [`deleteWorkspace`](/smart-api/reference/organization/deleteWorkspace) rejects with `400` while the workspace still has sender accounts or mailboxes: disconnect them first (see [Sender accounts](/smart-api/sender-accounts) and [Mailboxes](/smart-api/mailboxes); a bought mailbox is cancelled rather than removed, and counts until its cancellation takes effect). ### Workspace members [#workspace-members] Each workspace has three membership groups, returned by [`listWorkspaceMembers`](/smart-api/reference/organization/listWorkspaceMembers) and on [`getWorkspace`](/smart-api/reference/organization/getWorkspace): * `members`: the workspace's own staff, with roles `owner`, `admin` or `member`. * `orgMembers`: organization owners/admins granted access via the organization. Read-only here; they cannot be changed or removed through the members endpoints. * `invites`: pending email invites (`identifier` is the invited email, `role` is `admin` or `member`; the invite token is never returned). Invite someone into a workspace with [`inviteWorkspaceMember`](/smart-api/reference/organization/inviteWorkspaceMember) (role defaults to `member`). Swarmhit emails them the invitation. An invalid email, an address that is already a member, or one with a pending invite is rejected with `400`. ```bash curl -X POST https://app.swarmhit.com/api/v1/workspaces/aBc123/members \ -H "Authorization: Bearer swh_live_xxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"email": "jane@acme.com", "role": "member"}' ``` [`updateWorkspaceMember`](/smart-api/reference/organization/updateWorkspaceMember) and [`removeWorkspaceMember`](/smart-api/reference/organization/removeWorkspaceMember) work on both members and pending invites: pass `type: "user"` (default, `memberId` is the user id) or `type: "invite"` (`memberId` is the invite email; on delete, use the `?type=invite` query parameter). `role` is `admin` or `member`; any other value sets `member`. The workspace's owner (`ownerId`: the user who created it, which through the API is the user who created the key) and the `orgMembers` cannot have their role changed or be removed (`400`). ## Acting on a workspace: the X-Swarmhit-Workspace header [#acting-on-a-workspace-the-x-swarmhit-workspace-header] An organization key can operate on any workspace inside it by naming the target in a request header: ```text X-Swarmhit-Workspace: ``` With the header set, every data endpoint (campaigns, leads, accounts, inbox, and so on) acts on that workspace. Omit it to act on the organization itself. Same endpoint, same key, two workspaces: ```bash # Campaigns of the organization workspace curl https://app.swarmhit.com/api/v1/campaigns \ -H "Authorization: Bearer swh_live_xxxxxxxxxxxxxxxxxxxxxxxx" # Campaigns of workspace aBc123, same key curl https://app.swarmhit.com/api/v1/campaigns \ -H "Authorization: Bearer swh_live_xxxxxxxxxxxxxxxxxxxxxxxx" \ -H "X-Swarmhit-Workspace: aBc123" ``` The rules are strict, and worth internalizing: * The header is only honored for an **organization key** targeting **one of its own workspaces**. Any other value (a foreign workspace id, a typo, a non-organization key) is rejected with `401`, the same response as an invalid key. It never silently falls back to the key's own workspace, so a bad id cannot accidentally read or write the organization's data. An empty value, or the organization's own id, acts on the organization as if the header were absent. * The `/workspaces` endpoints always manage the key's organization, regardless of the header. You cannot nest: a workspace inside an organization cannot contain workspaces of its own. To confirm what a header resolves to before doing real work, call `GET /` with the header set. The returned `workspace` will show the target's id with `isSubWorkspace: true`. Everything workspace-scoped follows the header. For example, [`connectSender`](/smart-api/reference/accounts/connectSender) with the header set connects the LinkedIn account into that workspace and consumes a seat from its allocation. Hosted connect links follow it too: the sender or mailbox lands in the named workspace, while the page wears the organization's branding and, when a link has no `redirectUrl`, returns the person to the organization's default return URL. A workspace inside an organization has no billing of its own, so seats added on demand and mailboxes bought for it are charged to the organization. See [Authentication](/smart-api/authentication) for how keys are created and scoped in the first place. A few lists can also span the whole organization, but only when asked: [`GET /mailbox-orders`](/smart-api/reference/mailbox-orders/listMailboxOrders) and [`GET /managed-domains`](/smart-api/reference/mailbox-orders/listManagedDomains) take `orgWide=true` (every workspace inside the organization) or `workspaceId` (one of them), for a key acting as the organization. Without them they list the acting workspace's own, as before. See [Mailboxes](/smart-api/mailboxes#across-an-organization). ## LinkedIn warm-up across an organization [#linkedin-warm-up-across-an-organization] The [LinkedIn warm-up](/smart-api/sender-accounts#linkedin-warm-up) settings are set once for a whole organization: without the header, [`PATCH /accounts/warmup`](/smart-api/reference/accounts/updateAccountWarmupPolicy) sets the organization's, which cover its own senders and every workspace inside it that has no settings of its own. They are read when they apply, never copied onto the workspaces, so a change reaches all of them at once. A workspace can use its own instead: the same call with the `X-Swarmhit-Workspace` header saves that workspace's settings, which replace the organization's as a whole (the fields you leave out start from the organization's values). `{"inherit": true}` with the header drops them, and the workspace follows the organization again. [`GET /accounts/warmup`](/smart-api/reference/accounts/getAccountWarmupPolicy) says where a workspace's values come from in `source`: `workspace`, `organization`, or `default` when nothing is set anywhere, which is off. ```bash # Every new sender in the organization warms up, unless its workspace says otherwise curl -X PATCH https://app.swarmhit.com/api/v1/accounts/warmup \ -H "Authorization: Bearer swh_live_xxxxxxxxxxxxxxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{"enabled": true, "autoStart": "all"}' # One workspace with a gentler ramp of its own curl -X PATCH https://app.swarmhit.com/api/v1/accounts/warmup \ -H "Authorization: Bearer swh_live_xxxxxxxxxxxxxxxxxxxxxxxx" \ -H "X-Swarmhit-Workspace: aBc123" \ -H "Content-Type: application/json" \ -d '{"ramp": {"start": 5, "step": 5, "everyDays": 3}}' ``` ## Webhooks per workspace [#webhooks-per-workspace] Webhook endpoints belong to a workspace, and events deliver to the endpoints of the workspace where they occurred. A reply received in one workspace's inbox fires only that workspace's webhooks, never the organization's. So to receive a workspace's events, register an endpoint **on that workspace** by calling [`createWebhook`](/smart-api/reference/webhooks/createWebhook) with the `X-Swarmhit-Workspace` header: ```bash curl -X POST https://app.swarmhit.com/api/v1/webhooks \ -H "Authorization: Bearer swh_live_xxxxxxxxxxxxxxxxxxxxxxxx" \ -H "X-Swarmhit-Workspace: aBc123" \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/hooks/acme", "events": ["message.received", "connection.accepted"]}' ``` You can point every workspace at the same receiver URL. Every payload carries the `workspaceId` it belongs to, and each endpoint gets its own signing secret at creation, so pick the secret by `workspaceId` (or by a path or query parameter you bake into each URL) before verifying the signature. Payload verification and retry behavior are covered in the [Webhooks guide](/smart-api/webhooks). ## MCP server per workspace [#mcp-server-per-workspace] The [MCP server](/smart-api/mcp-server) does not use the header. The acting workspace is fixed per connection instead: append `workspaceId` to the connection URL. ```text https://app.swarmhit.com/mcp?key=swh_mcp_xxxxxxxxxxxxxxxxxxxxxxxx&workspaceId=aBc123 ``` The same rule applies as on REST: the key must be the organization's MCP key and `workspaceId` must name one of its own workspaces, otherwise every request is rejected with `401`. Configure one MCP connection per workspace you want an AI agent to operate. The organization endpoints have MCP equivalents too: `list_workspaces`, `get_workspace`, `create_workspace`, `update_workspace`, `delete_workspace`, `invite_workspace_member`, `update_workspace_member` and `remove_workspace_member`. The ones that act on a single workspace take a `workspaceId` argument, and all of them manage the key's organization whatever workspace the connection acts on. `get_workspace_info` is different: it returns the workspace the connection itself resolves to. ## A typical onboarding flow [#a-typical-onboarding-flow] 1. [`createWorkspace`](/smart-api/reference/organization/createWorkspace) with a name and a `seatAllocation` from the pool. 2. [`inviteWorkspaceMember`](/smart-api/reference/organization/inviteWorkspaceMember) to give that team access to their workspace. 3. With `X-Swarmhit-Workspace` set to the new workspace's id: connect senders, create webhooks, then build [campaigns](/smart-api/campaigns) and import [leads](/smart-api/leads-and-lists). 4. Periodically call [`listWorkspaces`](/smart-api/reference/organization/listWorkspaces) to watch `pool` / `allocated` / `available` and each workspace's `sendersUsed` against its `seatAllocation`. # Sender accounts (/smart-api/sender-accounts) A sender account is a connected LinkedIn account that Swarmhit acts from. Campaign steps, [direct actions](/smart-api/direct-actions) and [inbox](/smart-api/inbox) conversations all execute through a sender. The Accounts endpoints let you connect senders, watch their health, daily limits and usage, pause them, and recover them when LinkedIn interrupts a session. A LinkedIn account can belong to a [sender](/smart-api/senders), the person your campaigns speak as across channels: its `senderId`, set with [`PATCH /accounts/{id}`](/smart-api/reference/accounts/renameAccount). Credentials are stored encrypted and are never returned by any endpoint. Each connected sender occupies a seat in its workspace: with an organization key and the `X-Swarmhit-Workspace` header set, the sender lands in that workspace and consumes a seat from its allocation (see [Organization](/smart-api/organization)). ## Hosted connect links [#hosted-connect-links] If your users connect their own LinkedIn accounts, you do not have to ask them for a LinkedIn password. [`POST /accounts/hosted-link`](/smart-api/reference/accounts/createHostedConnectLink) mints a one-time URL where they sign in on a page Swarmhit serves, carrying your organization's name and logo. Send them to it, and they come back to your return URL when they are done. Your workspace name and picture are used on the page by default. Override them, and set a default return URL, in your organization settings — so most calls only need to say which workspace the sender belongs to. Pass `redirectUrl` when a particular link should land somewhere else. ```bash curl -X POST https://app.swarmhit.com/api/v1/accounts/hosted-link \ -H "Authorization: Bearer swh_live_..." \ -H "X-Swarmhit-Workspace: aBc123" \ -H "Content-Type: application/json" \ -d '{ "redirectUrl": "https://yourapp.com/settings/linkedin/done", "externalId": "your-user-42" }' ``` ```json { "data": { "id": "kT4wQ8nZp2LxR7cVb", "status": "pending", "channel": "linkedin", "provider": null, "providers": null, "workspaceId": "aBc123", "accountId": null, "orderId": null, "purchase": null, "reconnectAccountId": null, "externalId": "your-user-42", "redirectUrl": "https://yourapp.com/settings/linkedin/done", "hasProxy": false, "country": null, "error": null, "expiresAt": "2026-08-20T09:17:23.933Z", "openedAt": null, "completedAt": null, "createdAt": "2026-08-20T08:47:23.933Z", "url": "https://app.swarmhit.com/connect/nmkRnAG..." } } ``` `url` is returned once, here: the token inside it is stored only as a hash and never comes back on a read. Links are single use and expire after 30 minutes by default; set `expiresIn` (seconds, 300 to 604800) to change that. A positive value outside that range is clamped to it rather than rejected. `redirectUrl` must be an absolute `http` or `https` URL. It is optional when your organization has a default set; with neither a valid `redirectUrl` nor a default, the call fails with `400` and `code: invalid-redirect-url`. The seat is checked when the link is created, so a workspace with no seat left fails at this call (`400`, `code: seat-limit-reached`, or `no-plan` without an active plan) rather than at the end of your user's sign-in. It is checked, not reserved: if the last seat goes to another sender in the meantime, the sign-in is refused at the end. Workspaces whose seats are added on demand (pay as you go) are not refused. ### Proxy and country [#proxy-and-country] Pass `proxy` (`{ host, port, username?, password? }`) and `country` when creating the link. They describe the session the sender is connected with, and they belong on the link rather than on the page: the person signing in is your customer, a proxy form would be meaningless to them, and routing a sender near where its owner actually is is a judgement only you can make. The proxy password is stored encrypted and never returned — reads only tell you `hasProxy`. Setting `country` also stops the page asking the person for one. Both apply to either way of signing in on the page: a password sign-in and a browser-session (`li_at`) one. A `proxy` without a `host` is ignored, and a `port` outside 1-65535 fails with `400` and `code: invalid-proxy`. If your own users run their own proxies, you can instead let them enter one: turn on **Let people use their own proxy** in your organization settings and the connect page gains an optional proxy panel. It is off by default, because most people signing in would not know what to put in it. A link created with its own `proxy` always wins and hides the panel. ### Knowing how it went [#knowing-how-it-went] Three ways, and you can use whichever fits: * **The redirect.** They come back to `redirectUrl` with `?hosted_link_id=…&status=connected|failed|expired|cancelled&account_id=…&external_id=…`. * **Webhooks.** `account.connected` and `account.connection_failed` fire as usual, with `hostedLinkId` and `externalId` added so you can match them to the link you created. * **Polling.** [`GET /accounts/hosted-link/{id}`](/smart-api/reference/accounts/getHostedConnectLink) returns the link's current `status` (`pending`, `connected`, `failed` or `expired`), the `accountId` it produced, and an `error` when it failed. A link the person cancelled reads `failed` with `error.code: cancelled`. A failed sign-in attempt does not end the link: the person can try again on the page until it expires or they cancel, and each attempt LinkedIn refuses fires `account.connection_failed`. For a reconnect link that repairs a sender in place, rely on the redirect or the GET: a repair that needs no verification step fires no webhook carrying the link's ids. Verification steps (authenticator code, email or SMS code, phone registration, in-app approval, captcha) are all handled on the page itself. You never drive the checkpoint endpoints for a hosted session; they remain for senders you connect directly with `POST /accounts`. While the person is on a verification step, the sender already exists in the workspace with `status: pending` (its `hostedLinkId` names the link), and `account.checkpoint_required` fires for it; there is nothing for you to answer. If they cancel or start over, that unfinished sender is removed again. ### Reconnecting through a link [#reconnecting-through-a-link] Pass `accountId` and the link repairs that sender instead of adding one. It keeps its id, its campaigns and its seat, and no seat is consumed. The exception is a change of sign-in method (a browser-session sender signing back in with a password, or the reverse): the sender then comes back under a new id, its campaign pools move to the new sender and the old one is removed, so take the id from the redirect's `account_id` or the link's `accountId`. An `accountId` that is not a sender in the workspace fails with `400` and `code: account-not-found`: ```bash curl -X POST https://app.swarmhit.com/api/v1/accounts/hosted-link \ -H "Authorization: Bearer swh_live_..." \ -H "X-Swarmhit-Workspace: aBc123" \ -H "Content-Type: application/json" \ -d '{ "redirectUrl": "https://yourapp.com/settings/linkedin/done", "accountId": "aCc123" }' ``` On the page, users can also supply their authenticator setup key under "Keep it connected automatically". Doing so makes the sender an infinite-login one, which signs itself back in whenever LinkedIn ends the session and clears authenticator challenges on its own, so the person rarely has to be asked again. It is the outcome worth steering your users to. ## Connecting a sender [#connecting-a-sender] [`POST /accounts`](/smart-api/reference/accounts/connectSender) takes the sender's LinkedIn `username` (email) and `password`, plus an optional `proxy` and 2-letter `country`. Without `country` (and without a proxy), the sender connects from the country most of the workspace's accounts use, else from the country of the server calling the API, and that country is kept as the account's `country`. Use it when you already hold the credentials; otherwise prefer a [hosted connect link](#hosted-connect-links). There are two modes: * **Automatic (infinite) login**: include a `totpSecret` (the account's 2FA authenticator setup key). The sign-in completes hands-free, solving a 2FA challenge from the secret (an in-app approval request is first switched to the authenticator code), and the sender reconnects on its own later. Any other verification step fails with `400` (`code: unsupported-verification`) and an `account.connection_failed` webhook rather than leaving a half-connected sender holding a seat. A key that is not a valid authenticator secret fails with `400` and `code: invalid-totp-secret`. * **Interactive**: omit `totpSecret`. If LinkedIn asks for a verification step, the response is `200` with `status: "checkpoint_required"` and a `checkpoint` object describing it, and `account.checkpoint_required` fires. The sender already exists with `status: pending` and holds its seat while it waits; if you abandon the step, reconnect or disconnect it. ```bash curl -X POST https://app.swarmhit.com/api/v1/accounts \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "username": "jane@example.com", "password": "her-linkedin-password", "totpSecret": "JBSWY3DPEHPK3PXP", "country": "US" }' ``` ### Connecting from a browser session [#connecting-from-a-browser-session] A sender can also be connected from a LinkedIn session that already exists in a browser: send `liAt` (the `li_at` cookie value) and the `userAgent` of the browser it came from, instead of `username` and `password`. `userAgent` is optional, but LinkedIn ties the session to the browser that created it, so send the real one. `proxy` and `country` apply as they do to a password sign-in. The session is stored encrypted, `connectionMethod` comes back as `cookie`, and checkpoints and webhooks behave exactly the same. ```bash curl -X POST https://app.swarmhit.com/api/v1/accounts \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "liAt": "AQEDAT...", "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ..." }' ``` A browser session cannot re-establish itself: when LinkedIn ends it, only a fresh `li_at` brings the sender back. These senders never reconnect on their own, so password sign-in with a `totpSecret` — or a [hosted connect link](#hosted-connect-links) — is the lower-maintenance path. Connection failures return `400` with a stable `error.code` to branch on (`invalid-credentials`, `invalid-session` for a rejected `liAt`, `rate-limited`, `proxy-failed`, `provider-timeout`, `provider-unreachable`, `checkpoint-code-rejected`, `checkpoint-restricted`, `unsupported-verification`, `invalid-totp-secret`, `already-connected`, `no-plan`, `seat-limit-reached`, and `connection-failed` for anything else). The full list is on the [connect reference](/smart-api/reference/accounts/connectSender). When the attempt reached LinkedIn, the `account.connection_failed` webhook carries the same `code`; refusals made before signing in (`invalid-totp-secret`, `no-plan`, `seat-limit-reached`, and `already-connected` for a password sign-in) come back on the response only. ### Sign-in checkpoints [#sign-in-checkpoints] A checkpoint is a verification step LinkedIn requires before it finishes signing the sender in. The `checkpoint` object tells you what kind and what answers it: | Type | What LinkedIn wants | `input` | | ------------------- | ------------------------------------------- | ------- | | `2FA` | A code from the account's authenticator app | `code` | | `OTP` | A one-time code sent by email or SMS | `code` | | `IN_APP_VALIDATION` | Approval in the LinkedIn mobile app | `none` | | `PHONE_REGISTER` | A phone number to register | `phone` | | `CAPTCHA` | A puzzle only LinkedIn's own page can show | `none` | A step Swarmhit does not recognise comes back as `UNKNOWN`, with `input: code`. Answer with [`POST /accounts/{id}/checkpoint`](/smart-api/reference/accounts/solveAccountCheckpoint) and a `code`: the verification code, the phone number with its dialling code in brackets (for example `(+33)0612345678`), or the literal `TRY_ANOTHER_WAY` to make LinkedIn offer a different method when `canTryAnotherWay` is true (the way out of an in-app approval nobody can reach). The response is `status: "connected"` once the sender is live, or `checkpoint_required` with the next checkpoint when LinkedIn chains another step. A refused answer is a `400` with an underscored code, such as `checkpoint_code_rejected`, `checkpoint_no_code` (the step has nothing to type), `checkpoint_phone_format`, `checkpoint_no_alternative`, `checkpoint_rate_limited` or `checkpoint_restricted`; a `502` means LinkedIn could not be reached, so retry. Checkpoints expire five minutes after LinkedIn asks (`expiresAt` on the checkpoint object). Past that window the attempt is dropped and answering returns `400` with `code: checkpoint_expired`; reconnect the sender to start a fresh attempt. `IN_APP_VALIDATION` and `CAPTCHA` are completed outside the API, so poll [`GET /accounts/{id}/checkpoint`](/smart-api/reference/accounts/getAccountCheckpoint) (or listen for `account.connected`, which fires when the sender comes up): it re-reads the live session and returns `resolved: true` as soon as the sender is up. [`POST /accounts/{id}/checkpoint/resend`](/smart-api/reference/accounts/resendAccountCheckpoint) asks LinkedIn for a fresh code or a new approval push when the pending type supports it (`canResend` on the checkpoint), and restarts the five-minute window. ## Status and health [#status-and-health] [`GET /accounts`](/smart-api/reference/accounts/listAccounts) (newest first, paged with `limit` and `offset`) and [`GET /accounts/{id}`](/smart-api/reference/accounts/getAccount) return each sender with its `status`, a more granular `connectionStatus` string, an `error` message when it broke, the pending sign-in `checkpoint`, `limits`, `usage`, pause state, InMail credit balance, sign-in `country` and cached [Social Selling Index](#social-selling-index). | `status` | Meaning | What to do | | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `active` | The sender is signed in and available for outreach. | Nothing. | | `pending` | The sign-in has not completed yet (for example, a checkpoint is waiting). | Answer or poll the checkpoint. | | `error` | The LinkedIn session broke, or a reconnect failed. | [Reconnect](/smart-api/reference/accounts/reconnectAccount) the sender. An infinite-login sender first retries on its own, with backoff. | | `suspended` | The workspace's plan does not cover this sender: no active plan, or more senders than seats (the newest are parked first). Its session is closed; its credentials are kept. | Nothing to do on LinkedIn. Swarmhit reconnects it on its own, oldest first, once the plan has a seat for it. | The `account.status_changed` webhook reports transitions between these states (with `previousStatus`), and `disconnected` when the session was deleted outside Swarmhit: that sender has been removed from the workspace and has to be connected again. A LinkedIn restriction on the account itself surfaces as a failed sign-in with `code: checkpoint-restricted`. A sender whose LinkedIn session answers "disconnected" in the middle of a campaign isn't reported at once: LinkedIn refreshes sessions now and then, and refuses calls for a few minutes meanwhile. The campaign lead that got the answer is tried again about 10 minutes later (none of them fails), and nothing is sent if the session comes back. If the sender still answers "disconnected" 20 minutes or more after the first time, `account.status_changed` goes out with `error`, as it does at once when LinkedIn signs the account out. A direct API action from that sender answers `409` meanwhile: see [when a sender needs reconnecting](#when-a-sender-needs-reconnecting). ### When a sender needs reconnecting [#when-a-sender-needs-reconnecting] Send endpoints return `409` when LinkedIn refused the sending account, which may need reconnecting. Stop sending from that sender. A session LinkedIn is refreshing is refused for a few minutes too, so reconnect it once `account.status_changed` says `error`, or if the `409`s go on for more than about 20 minutes; if sends work again before that, the session came back. To reconnect, retry the saved sign-in with [`POST /accounts/{id}/reconnect`](/smart-api/reference/accounts/reconnectAccount). For a credentials or infinite-login sender it runs one attempt now. If the person changed their LinkedIn password or 2FA, pass the new `password` (and `totpSecret`) to replace the stored credentials first. Any credential field (`totpSecret`, `username`, `country`, `proxy`) needs `password` alongside it, or the call fails with `400`. An omitted `totpSecret` or `proxy` keeps the stored one. An omitted `country` clears the stored one, and the reconnect picks one again: where the account already is, else the one most of the workspace's accounts use. Send it to set the country yourself: ```bash curl -X POST https://app.swarmhit.com/api/v1/accounts/aCc123/reconnect \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "password": "the-new-password" }' ``` The response is `200` with `status` `reconnected`, `checkpoint_required` (answer the returned `checkpoint` as above; a stored authenticator secret clears a 2FA challenge on its own) or `failed` (with an `error` message). Either way the sender keeps its id, its campaigns and its seat. A `cookie` sender has no password to retry. With no body, the call signs it in again from its saved browser session, which also picks up a LinkedIn plan bought since it connected. Once LinkedIn has ended that session the result is `failed`: pass a fresh `liAt` (and the `userAgent` it came from) to replace it. The new session must belong to the same LinkedIn account, or the result is `failed` and the sender is left as it was. The stored proxy is kept unless you send a new `proxy`: ```bash curl -X POST https://app.swarmhit.com/api/v1/accounts/aCc123/reconnect \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "liAt": "AQEDAT...", "userAgent": "Mozilla/5.0 ..." }' ``` When you do not hold the person's credentials at all, create a [hosted connect link](#reconnecting-through-a-link) scoped to the sender and let them sign in themselves. Reconnect also recovers a credentials or infinite-login sender whose session no longer exists at all, for example one parked (`suspended`) while the workspace had no active plan. As long as the stored credentials are intact it simply signs in fresh. Seats are not checked here: if the plan still does not cover every sender, Swarmhit parks the excess again within minutes. A parked `cookie` sender is left to Swarmhit, which reconnects it once the plan covers it; reconnecting it yourself returns `failed`. If the account is up but its stored identity (name, headline, picture, paid-plan flag) looks stale, [`POST /accounts/{id}/sync`](/smart-api/reference/accounts/syncAccount) refreshes it from LinkedIn, along with its InMail balance and Social Selling Index. It fails with `400` when the sender is not connected yet. ## Limits and usage [#limits-and-usage] Each account carries two objects on the API: * `limits`: the custom caps saved for this sender (the daily caps, plus `connectionRequestsPerWeek` and `pendingInvitationsMax` when set), or `null` when it runs on the defaults for its LinkedIn plan. The caps actually in force, with how much of each is used and the range each can be set within, are on [`GET /accounts/{id}/limits`](#reading-and-changing-a-senders-limits). * `usage`: the action tally for the UTC day in `usage.day`, for example `{ "day": "2026-10-07", "connectionRequests": 12, "messages": 30 }`. When `day` is not today, nothing has been counted today yet. ### Reading and changing a sender's limits [#reading-and-changing-a-senders-limits] [`GET /accounts/{id}/limits`](/smart-api/reference/accounts/getAccountLimits) returns every cap on the sender. The eight daily caps are under `daily`: `connectionRequests`, `messages`, `inmails`, `profileViews`, `follows`, `postLikes`, `postComments`, and `searchedProfiles` (the profiles a [lead import](/smart-api/leads-and-lists) may pull per day). The two rolling caps are under `rolling`: `connectionRequestsPerWeek` and `pendingInvitationsMax`. Each cap comes with: * `limit`: the sender's own cap, which is its own value or else its plan's default. While the sender [warms up](#linkedin-warm-up), the cap that applies today is the lower one in `warmup.today`, not `limit`: compare `used` with that one. * `used`: how much of it is used. For a daily cap that's today's count, which resets at 00:00 UTC. For a rolling cap it's the invitations sent in the last `weeklyWindowDays` days, or the ones still pending. * `default`, `min` and `max`: the range it can be set within on the sender's LinkedIn plan, which `tier` names (`free`, `premium`, `sales_navigator` or `recruiter`). ```bash curl https://app.swarmhit.com/api/v1/accounts/aCc123/limits \ -H "Authorization: Bearer swh_live_..." ``` ```json { "data": { "accountId": "aCc123", "tier": "free", "daily": [ { "key": "connectionRequests", "label": "Connection requests", "used": 6, "limit": 15, "default": 25, "min": 0, "max": 40 }, { "key": "messages", "label": "Messages", "used": 11, "limit": 40, "default": 40, "min": 0, "max": 40 }, { "key": "searchedProfiles", "label": "Search imports", "used": 0, "limit": 1000, "default": 1000, "min": 0, "max": 2500 } ], "rolling": [ { "key": "connectionRequestsPerWeek", "label": "Connection requests / week", "used": 42, "limit": 130, "default": 130, "min": 0, "max": 130 }, { "key": "pendingInvitationsMax", "label": "Max pending invitations", "used": 118, "limit": 500, "default": 500, "min": 0, "max": 5000 } ], "weeklyWindowDays": 7, "warmup": { "mode": "inherit", "status": "off", "ramp": { "start": 10, "step": 5, "everyDays": 3 }, "rampSource": "default", "target": 25, "startedAt": null, "day": null, "fullSpeedAt": null, "today": null } } } ``` The example is shortened: `daily` always lists all eight caps. The keys are the same as in `limits` on [`GET /accounts/{id}/stats`](#sender-performance). `warmup` is the sender's [LinkedIn warm-up](#linkedin-warm-up). To change caps, send only the ones to change to [`PATCH /accounts/{id}/limits`](/smart-api/reference/accounts/updateAccountLimits). The others keep their value: ```bash curl -X PATCH https://app.swarmhit.com/api/v1/accounts/aCc123/limits \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "connectionRequests": 20, "follows": null }' ``` Each value is saved as a whole number within the sender's range: a value above `max` is saved as `max`, and a value below 0 as 0. Sending `null` removes the sender's own value, so the cap follows its plan's `default` again, even if the plan or its default changes later. The response has the same shape as the `GET` and holds the values as saved, so read `limit` back instead of assuming what you sent was applied. If the body is empty, has an unknown key, or has a value that is neither a number nor `null`, you get a `400` with `code: invalid-limit` and nothing is saved. The [MCP server](/smart-api/mcp-server) has the same two calls as `get_account_limits` and `update_account_limits`. When a [direct action](/smart-api/direct-actions) would exceed a cap, the endpoint returns `429` with `details: { limit, used, scope, resetAt }`, where `scope` is `day` (the daily cap, the day's lower one while the sender warms up), `week` (the weekly connection-request limit: about 130 invites per week for free sender accounts, 150 for paid) or `pending` (the pending-invitation backlog, which clears as invitations are accepted or withdrawn). `resetAt` is the UTC time the window frees; it is absent for `pending`. Campaign steps never fail on a cap: they wait until it frees. When invite limits park campaign sends, the account's `cooldownUntil` is stamped with the time the cooldown ends, and `cooldownReason` says why: `invite_daily_limit`, `invite_weekly_limit` or `provider_invite_limit` (LinkedIn's own invitation limit). The stamp is not cleared when the cooldown ends, so compare `cooldownUntil` with the current time; it is `null` only for a sender that never had one. Sender tier also shapes what an action can include: connection-request notes are a premium feature capped at 300 characters, and free senders' invites go out without the note. See [LinkedIn safety](/smart-api/linkedin-safety) for how to stay within healthy volumes. ### LinkedIn warm-up [#linkedin-warm-up] A new LinkedIn account that sends at full volume from its first day is a common reason for a restriction. A warm-up starts a sender with a few connection requests a day and raises its daily caps step by step, up to its own. With the default ramp, a sender whose cap is 25 sends 10 connection requests a day on days 1 to 3, 15 on days 4 to 6, 20 on days 7 to 9, and 25 from day 10. Its other daily caps (messages, InMails, profile views, follows, post likes and post comments) get the same share of their own value. The ramp climbs to the sender's own connection-request cap, or to its plan's default when that is higher: a sender whose cap is lower warms up over the same days, and no cap ever goes above the sender's own. Search imports are not lowered, and the weekly connection-request limit and the plan's ranges always apply. Days are UTC days, like the caps: day 1 is the UTC day the warm-up started. Warm-up is off unless you turn it on, so senders run as they always have. Its settings decide whether a sender starts warming up on its own, once, when it is connected for the first time. Turning them on never slows a sender that is already connected; you can start or stop any sender by hand. [`GET /accounts/warmup`](/smart-api/reference/accounts/getAccountWarmupPolicy) returns the settings the workspace's senders follow: ```json { "data": { "workspaceId": "aBc123", "isOrganization": false, "source": "organization", "enabled": true, "autoStart": "new", "newAccount": { "maxConnections": 150, "noPreviousActivity": true }, "ramp": { "start": 10, "step": 5, "everyDays": 3 } } } ``` * `enabled`: whether senders following these settings warm up. * `autoStart`: which newly connected senders start on their own: `never`, `new` (those that look new) or `all`. * `newAccount`: what looks new: fewer LinkedIn connections than `maxConnections`, read once when the sender connects when LinkedIn gives the count (`null` leaves the count out), and, with `noPreviousActivity`, a LinkedIn account that was never a sender here before, in any workspace. A sender removed and connected again counts as connected before. When the count can't be read, the second rule decides alone. * `ramp`: `start` connection requests a day, `step` more every `everyDays` days, until each sender reaches its `target` (below). * `source`: where the values come from: this workspace (`workspace`), its [organization](/smart-api/organization#linkedin-warm-up-across-an-organization) (`organization`), or nothing set anywhere (`default`, which is off). Change them with [`PATCH /accounts/warmup`](/smart-api/reference/accounts/updateAccountWarmupPolicy), sending only the fields to change: ```bash curl -X PATCH https://app.swarmhit.com/api/v1/accounts/warmup \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "enabled": true, "autoStart": "all" }' ``` `start` and `step` are 1 to 100, `everyDays` 1 to 30 and `maxConnections` 1 to 30000. A value out of range is refused, never clamped: `400` with `code: invalid-warmup` and every reason in `details.errors`. Each sender's warm-up is the `warmup` object on [`GET /accounts/{id}/limits`](/smart-api/reference/accounts/getAccountLimits). For a sender on its 4th day, with the default ramp and caps: ```json "warmup": { "mode": "inherit", "status": "warming", "ramp": { "start": 10, "step": 5, "everyDays": 3 }, "rampSource": "organization", "target": 25, "startedAt": "2026-10-22T09:14:03.000Z", "day": 4, "fullSpeedAt": "2026-10-31T00:00:00.000Z", "today": { "connectionRequests": 15, "messages": 24, "inmails": 24, "profileViews": 24, "follows": 18, "postLikes": 18, "postComments": 9 } } ``` * `status`: `warming`, `done` once it reached full speed, or `off`, which includes a sender whose warm-up is held back because the settings it follows were turned off: it runs at full speed. * `today`: while it warms up, the caps that apply today. They bind in place of each cap's `limit` in `daily`: campaigns and [direct actions](/smart-api/direct-actions) both stop at them, and a direct action over one gets the usual `429` with `scope: day`, whose message says the cap is lower because of the warm-up. `null` when the sender isn't warming up. * `target`: the connection requests a day the ramp climbs to, the sender's own cap or its plan's default, whichever is higher. Each day's connection-request cap is the ramp's value or the sender's own cap, whichever is lower. * `fullSpeedAt`: the UTC midnight it runs at full speed, the day the ramp reaches `target`. `account.warmup_completed` fires within the hour after it ([Webhooks](/smart-api/webhooks)). A warm-up held back by settings turned off ends on that day too, without the event. * `mode`: the sender's own setting. Set it with `warmup` on [`PATCH /accounts/{id}/limits`](/smart-api/reference/accounts/updateAccountLimits), alone or with caps: ```bash curl -X PATCH https://app.swarmhit.com/api/v1/accounts/aCc123/limits \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "warmup": { "mode": "custom", "ramp": { "start": 8 } } }' ``` | `mode` | Effect | | --------- | --------------------------------------------------------------------------- | | `inherit` | The default: the workspace's settings decide. | | `off` | Full speed now; a warm-up in progress stops. | | `on` | Start now on the workspace's ramp, even when its settings are off. | | `custom` | Start now on the sender's own `ramp`; fields left out keep the ramp it has. | A sender already warming keeps its day when you switch it to `on` or `custom`. A bad `warmup` gets a `400` with `code: invalid-warmup`, and nothing in the request is saved, caps included. `update_account_limits` on the [MCP server](/smart-api/mcp-server) takes the same `warmup`. ## Pausing and resuming [#pausing-and-resuming] [`POST /accounts/{id}/pause`](/smart-api/reference/accounts/pauseSender) stops all activity from a sender: campaigns stop acting from it, and direct actions are rejected with `409` `code: sender_paused` until it is resumed with [`POST /accounts/{id}/resume`](/smart-api/reference/accounts/resumeSender). Both are no-ops when the sender is already in the target state, and both still return the account. ```bash curl -X POST https://app.swarmhit.com/api/v1/accounts/aCc123/pause \ -H "Authorization: Bearer swh_live_..." ``` On the account, `paused` is `true`, `pausedAt` is when it happened, and `pausedReason` says why: * `user`: paused manually (or via this endpoint). * `inmail_credits`: auto-paused because its InMails could not be sent. Swarmhit resumes it automatically once InMail credits are available again (the `account.resumed` webhook fires with `by: system`). A pause does not change the sender's connection status, and its campaign leads stay assigned to it. To keep a campaign moving while a sender is down, move its not-yet-contacted leads to the rest of the pool (see rebalancing below). ## InMail credits [#inmail-credits] `inmailCredits` on the account is the InMail credit balance of the sender's LinkedIn plan: `balance` (or `null` when unknown) and `syncedAt`. It is `null` when it has never been synced. Sending an InMail from a sender without premium InMail access fails with `409` `code: inmail_not_available`; a sender that is out of credits fails with `429` `code: inmail_credits_exhausted` (`details.scope` is `inmail_credits`). ## Social Selling Index [#social-selling-index] LinkedIn scores every member 0 to 100 on how well their profile sells socially. The score is made of four pillars worth up to 25 each, and LinkedIn also reports where the profile sits against its industry and its own network. [`GET /accounts/{id}/ssi`](/smart-api/reference/accounts/getAccountSsi) returns it, and the same object rides along as `ssi` on the account itself: ```bash curl https://app.swarmhit.com/api/v1/accounts/aCc123/ssi \ -H "Authorization: Bearer swh_live_..." ``` ```json { "data": { "score": 62.4, "brand": 17.1, "people": 14.8, "insights": 12.2, "relationships": 18.3, "industryTop": 8, "networkTop": 3, "industryAverage": 31.5, "networkAverage": 28.9, "industryName": "Computer Software", "unavailable": false, "syncedAt": "2026-08-04T09:12:44.120Z" } } ``` | Field | Pillar | | --------------- | --------------------------------- | | `brand` | Establish your professional brand | | `people` | Find the right people | | `insights` | Engage with insights | | `relationships` | Build relationships | `industryTop` and `networkTop` are percentiles: `8` means the profile is in the top 8% of its industry. `industryAverage` and `networkAverage` are the average score across that industry and across the profile's network. The score is cached on the account and refetched once it is more than 12 hours old, since LinkedIn recomputes it daily at most. Pass `refresh=true` to force a live read; that call fails with `400` when LinkedIn cannot be reached at that moment. It is also refreshed as part of [`POST /accounts/{id}/sync`](/smart-api/reference/accounts/syncAccount). `unavailable: true` means LinkedIn is not exposing a score for that sender's session; the numbers are the last ones it returned, or `null` if it never returned any. That is a state, not an error: the endpoint still returns `200`. Treat it the same way as an unsynced InMail balance rather than retrying. ## Sender performance [#sender-performance] [`GET /accounts/{id}/stats`](/smart-api/reference/accounts/getAccountStats) reports how a sender has actually performed, over a window of 7 to 90 days (`days`, default 30; a value outside that range is clamped). Campaign sends and [direct actions](/smart-api/direct-actions) are both counted, so an API-driven workspace sees its full volume. ```bash curl "https://app.swarmhit.com/api/v1/accounts/aCc123/stats?days=30" \ -H "Authorization: Bearer swh_live_..." ``` The response carries: * `cards`: connection requests, acceptances, messages, replies and interested leads for the window, each with `deltaPct` against the preceding window of the same length (a ratio: `0.25` is +25%). * `series`: one entry per calendar day, for charting. * `lifetime` and `rates`: all-time totals from the sender's campaign leads, with acceptance and reply rates as ratios. * `interest`: how its campaign leads' replies are tagged (`interested`, `not_interested`, `untagged`). * `byCampaign` and `poolCampaigns`: the per-campaign split, plus campaigns the sender is pooled into but has not sent from yet. * `actions`: direct (non-campaign) actions in the window, by type. * `limits`: every cap in force with how much is already used: the daily caps (`daily`), and the rolling weekly invitation limit and pending-invitation backlog (`rolling`). `limits` tells you the remaining headroom on each action without having to hit a `429` first. To check that headroom before a large batch, [`GET /accounts/{id}/limits`](#reading-and-changing-a-senders-limits) is the lighter call: it returns the same `limit` and `used` for each cap, without the performance report. Daily series buckets are calendar days on the server's clock, while the daily caps in `limits.daily` reset at 00:00 UTC. The two can disagree by a few hours near midnight. ## Senders and campaigns [#senders-and-campaigns] A campaign's `linkedinAccountIds` is its sender pool: the LinkedIn sender accounts eligible to run its leads. Leads not contacted yet are spread evenly across the pool's healthy, unpaused senders whenever the campaign is activated, its pool changes or leads are added. Once a step has run for a lead, its sender is pinned: every later step runs from it. `LeadInCampaign.linkedinAccountId` exposes the assignment (`null` until a sender is assigned). Add multiple ids to scale outreach beyond a single account's daily cap. Editing the pool with [`PATCH /campaigns/{id}`](/smart-api/reference/campaigns/updateCampaign): * An empty `linkedinAccountIds` is rejected with `400` while the campaign is (or is being set) `active` and its sequence has LinkedIn steps; pause it first. On a paused campaign it clears the pool and leads wait until a sender is added. An id that is not a sender in the workspace is rejected with `400`. * Removing a sender moves its not-yet-contacted leads onto the remaining senders right away. Leads it already contacted cannot change sender: they wait, and only move on if it is added back. ### Rebalancing a paused or failed sender's leads [#rebalancing-a-paused-or-failed-senders-leads] [`POST /campaigns/{id}/senders/{accountId}/rebalance`](/smart-api/reference/campaigns/rebalanceCampaignSender) moves a paused or failed sender's not-yet-contacted leads in that campaign onto the campaign's other available senders, evenly. Leads the sender already contacted stay pinned to it. A moved lead's `heldBy` is cleared and re-evaluated against its new sender on the next run; pausing a sender account does not release holds, because they are recorded against the membership rather than the account. ```bash curl -X POST https://app.swarmhit.com/api/v1/campaigns/cMp456/senders/aCc123/rebalance \ -H "Authorization: Bearer swh_live_..." ``` The response reports how many leads `moved` and how many other senders (`receivers`) could take them. `receivers: 0` means no other sender was available and nothing moved (still `200`); `422` means the account is not in that campaign's sender pool. ## Disconnecting [#disconnecting] [`DELETE /accounts/{id}`](/smart-api/reference/accounts/disconnectSender) revokes the sender's session, removes it from the workspace and frees its seat. Its conversations leave the inbox, and it leaves every campaign's sender pool: leads it had not contacted yet move to the campaign's other senders, while leads it already contacted stop progressing. From then on `GET /accounts/{id}` answers `404` and `GET /accounts` no longer lists it. No webhook fires for a disconnect you make yourself, unless an endpoint subscribes to `account.archived`. Note that deleting a workspace from an organization is rejected with `400` while it still has connected senders or mailboxes; disconnect them first. ### Archived accounts [#archived-accounts] A disconnected sender is archived, not deleted: its conversations, its settings (limits, [warm-up](#linkedin-warm-up)) and its history are kept. Its login secrets (session cookie, password, 2FA key) are not. [`GET /accounts?archived=true`](/smart-api/reference/accounts/listAccounts) lists the archived accounts, newest first, each with `status: "archived"`, `archivedAt` and the `campaignIds` it was in. Archived accounts are kept for 90 days, then deleted permanently; pass `?permanent=true` to delete right away (see [Deleting for good](#deleting-for-good)). ```bash curl -X POST https://app.swarmhit.com/api/v1/accounts/aCc123/restore \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "campaigns": true }' ``` [`POST /accounts/{id}/restore`](/smart-api/reference/accounts/restoreAccount) brings one back under the same id, with its conversations, limits and warm-up, within 90 days of its `archivedAt`. It takes a seat, as connecting does (`400` with `code: seat-limit-reached` or `no-plan` when there is none), and comes back disconnected (`status: "error"`). Its login secrets were not kept, so reconnect it before it sends again: [`POST /accounts/{id}/reconnect`](/smart-api/reference/accounts/reconnectAccount) with its `password` (and `totpSecret`), or with a fresh `liAt` for a browser-session sender, or a [hosted connect link](#reconnecting-through-a-link) with its `accountId`. It rejoins the campaigns it was in only with `{"campaigns": true}`, and its own leads there go on once it is reconnected. Connecting the same LinkedIn account again creates a new account, as before, and leaves the archived one where it is. Once it is connected again in the workspace, the archived one can't be restored next to it: the restore answers `400` with `code: already-connected`, as connecting it twice does. ### Deleting for good [#deleting-for-good] `DELETE /accounts/{id}?permanent=true` deletes an account for good, live or archived: its conversations and settings are erased. What it did in campaigns stays with the campaigns. Only an API key created by an owner or admin of the workspace, or of its organization, can do it (`403` with `code: not-allowed` otherwise). Where you delete accounts to erase their data, pass `permanent=true`. Archived accounts are deleted the same way on their own, 90 days after their `archivedAt` (`account.deleted` with `via: "retention"`). ## Mailboxes [#mailboxes] Mailboxes, the senders of a campaign's `email` steps, have their own guide: [Mailboxes](/smart-api/mailboxes) covers connecting them through hosted links without handling a password, buying domains and mailboxes, warmup, and managing them. Mailboxes take no seat. ## Reacting to account events [#reacting-to-account-events] Account lifecycle changes are pushed as [webhooks](/smart-api/webhooks): `account.connected`, `account.connection_failed`, `account.status_changed`, `account.checkpoint_required`, `account.paused` and `account.resumed`, and for LinkedIn accounts `account.archived`, `account.restored` and `account.deleted`. Subscribing to `account.status_changed` and `account.checkpoint_required` is the reliable way to catch a sender going down without polling; it comes back as `account.status_changed` with `status: active`, or as `account.connected` once a verification step clears. Mailboxes emit the same events except `account.checkpoint_required`, with `channel: "email"` and the mailbox object in `data.account`. ## Reference [#reference] # Senders (/smart-api/senders) A sender is a person your campaigns speak as. It groups that person's accounts: one LinkedIn account at most, and any number of [mailboxes](/smart-api/mailboxes) and WhatsApp numbers. In a campaign, a lead then hears from one sender on every channel: its LinkedIn messages, its emails and its WhatsApp messages all come from the same person. Senders are optional. Until an account belongs to a sender, campaigns assign each channel on its own, exactly as before. In Swarmhit, senders are on the **Accounts** page, **By sender**. The [Sender accounts](/smart-api/sender-accounts) endpoints, and operations such as `connectSender`, `pauseSender` or `rebalanceCampaignSender`, act on LinkedIn accounts, not on these senders. ## Creating a sender [#creating-a-sender] [`POST /senders`](/smart-api/reference/senders/createSender) creates one, with its accounts if you have them: ```bash curl -X POST https://app.swarmhit.com/api/v1/senders \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "firstName": "Maya", "lastName": "Chen", "managerIds": ["uSr9Km2Lp4Qx"], "linkedinAccountId": "sN4Xq7Lm2Rw9Zk5Yp", "emailAccountIds": ["mBx4Qw8RmZk2Yp5Ns", "mBx7Tr2PqLm9Wx3Kd"] }' ``` ```json { "data": { "id": "sDr8Lq2Mw5Xk9Pz3", "firstName": "Maya", "lastName": "Chen", "name": "Maya Chen", "picture": "https://cdn.swarmhit.com/p/maya.jpg", "managerIds": ["uSr9Km2Lp4Qx"], "linkedinAccountId": "sN4Xq7Lm2Rw9Zk5Yp", "emailAccountIds": ["mBx4Qw8RmZk2Yp5Ns", "mBx7Tr2PqLm9Wx3Kd"], "whatsappAccountIds": [], "createdAt": "2026-11-06T10:12:00.000Z", "updatedAt": "2026-11-06T10:12:00.000Z" } } ``` * `firstName` and `lastName` fill `{{senderFirstName}}`, `{{senderLastName}}` and `{{senderFullName}}` in every message its accounts send, on every channel. An account with no sender keeps reading its own name: the LinkedIn profile's, the mailbox's From name, the WhatsApp profile's. * `managerIds` are workspace members who manage the sender: they get its account alerts in Swarmhit and can narrow the inbox, campaigns, tasks and dashboard to the senders they manage. Every member still sees every sender. * `picture` comes from its accounts: the LinkedIn photo, else a mailbox's or a number's. [`GET /senders`](/smart-api/reference/senders/listSenders) lists them, [`GET /senders/{id}`](/smart-api/reference/senders/getSender) reads one, and [`DELETE /senders/{id}`](/smart-api/reference/senders/deleteSender) deletes one: its accounts stay connected and stay in their campaigns, without a sender. ## Linking accounts [#linking-accounts] An account belongs to one sender at most. Link it from either side: * [`PATCH /senders/{id}`](/smart-api/reference/senders/updateSender) with `linkedinAccountId`, `emailAccountIds` or `whatsappAccountIds`. A list replaces that kind of account: accounts left out lose this sender, and an account listed here leaves the sender it had. `linkedinAccountId: null` unlinks its LinkedIn account. * `senderId` (a sender id, or `null`) on [`PATCH /accounts/{id}`](/smart-api/reference/accounts/renameAccount), [`PATCH /email-accounts/{id}`](/smart-api/reference/email-accounts/updateEmailAccount) or [`PATCH /whatsapp-accounts/{id}`](/smart-api/reference/whatsapp-accounts/updateWhatsappAccount). Linking a LinkedIn account to a sender that already has one is refused (`400`, `code: sender-has-linkedin`). Every account object carries its `senderId`. Linking a mailbox or WhatsApp number adds it to the campaigns its sender sends in (below) whose sequence uses its channel; a LinkedIn account is never added this way. Unlinking never takes an account out of a campaign. ## In campaigns [#in-campaigns] A campaign's `senderIds` lists the senders that send in it: those with an account on its primary channel (below), or in any pool while the sequence has no sending step. Set it with [`createCampaign`](/smart-api/reference/campaigns/createCampaign) or [`updateCampaign`](/smart-api/reference/campaigns/updateCampaign): a sender added brings its accounts on the channels the sequence uses, and one removed takes its accounts out of the pools (an account still carrying another sender's conversation stays). A pool you send in the same request (`linkedinAccountIds`, `emailAccountIds`, `whatsappAccountIds`) keeps exactly what you send. A sequence that starts using a channel takes its senders' accounts on that channel. How leads are spread once accounts belong to senders: * The campaign's primary channel is LinkedIn when its sequence has a LinkedIn step, else email, else WhatsApp. Leads are spread over its accounts as before, and that account names the lead's sender. * On each other channel whose pool holds an account with a sender, a lead gets one of its sender's own accounts there, spread evenly (by provider within them with `matchEmailProvider`). * When none of them can send (paused, warming up, offline, disconnected), the sender's leads wait at those steps. They never move to another sender's account. The campaign keeps sending for the other senders, and its notice says it is partly on hold. * A lead whose sender has no account on that channel, or whose primary account has no sender, shares the pool's accounts that have no sender. With none, it waits at those steps. * An account whose sender doesn't send in the campaign takes no new lead there: it only carries on the conversations it has. * A lead that already has a conversation keeps its mailbox or number, and so does a lead whose primary account has left the campaign. Pools in which no account has a sender work exactly as before. On each lead in a campaign, `senderId` is its sender, or `null`. Activating a campaign, or changing the pools or the sequence of an active one, is refused with `400` and `code: sender-no-mailbox` (or `sender-no-whatsapp`) when a sender in it, or an account without a sender on its primary channel, has no account on a channel its steps use, and no account without a sender is there to share. On an active campaign, only a change that opens such a gap is refused: one opened by linking accounts leaves its other changes working, while the leads concerned wait. Adding a sender with `senderIds` is refused with `400` when it can't send in the campaign: * `sender-no-account`: it has no account on any channel the sequence uses. * `sender-without-linkedin` (`sender-without-email`, `sender-without-whatsapp` for a campaign whose primary channel is email or WhatsApp): it has no account on the primary channel. ## Inbox and webhooks [#inbox-and-webhooks] Inbox chats carry the `senderId` of their account, and [`GET /inbox/chats?senderId=`](/smart-api/reference/inbox/listInboxChats) lists the chats of every account of one sender. Every webhook payload with an `accountId` also has `senderId`, the account's sender at the time of the event, or `null`. # Webhooks (/smart-api/webhooks) Webhooks push events to your server as they happen: LinkedIn replies and messages, emails sent, received, bounced, opened and clicked, accepted connections, lead status changes, finished campaigns, sender and mailbox lifecycle, and the orders, payments and warmup of mailboxes bought through Swarmhit. Register an endpoint, pick the events it should receive, and verify the signature on every delivery. ## Register an endpoint [#register-an-endpoint] Register webhook endpoints on the **Webhooks** page in Swarmhit, or with [Create a webhook endpoint](/smart-api/reference/webhooks/createWebhook): ```bash curl -X POST https://app.swarmhit.com/api/v1/webhooks \ -H "Authorization: Bearer swh_live_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/hooks/swarmhit", "events": ["message.received", "connection.accepted"], "classifyReplies": true }' ``` `url` must be an absolute `http://` or `https://` URL (`400` with `code: invalid-url` otherwise). `events` must be an array of names from the [event catalog](#event-catalog). Names Swarmhit does not know are dropped without an error, and the call fails with `400` (`code: invalid-events`) only when none is left, so check the `events` the response echoes. A workspace can have at most 25 endpoints (`409`, `code: limit-reached`). An endpoint receives only the events of the workspace it was created in; to cover the workspaces of an organization, see [Webhooks per workspace](/smart-api/organization#webhooks-per-workspace). The response includes the endpoint's `secret` (`whsec_...`): the HMAC signing key you use to verify deliveries. It is also returned by [List webhook endpoints](/smart-api/reference/webhooks/listWebhooks) and [Get a webhook endpoint](/smart-api/reference/webhooks/getWebhook), so you can re-read it later. There is no rotation endpoint: to change a secret, create a new endpoint and delete the old one. Deleting an endpoint also deletes its queued deliveries and its delivery log. Manage endpoints with [Update a webhook endpoint](/smart-api/reference/webhooks/updateWebhook) (any subset of `url`, `events`, `enabled`, `classifyReplies`, with the same rules as on create), [Delete a webhook endpoint](/smart-api/reference/webhooks/deleteWebhook), and [Send a test delivery](/smart-api/reference/webhooks/testWebhook). ## Delivery [#delivery] Every delivery is an HTTP POST with a JSON body. All payloads share one envelope: ```json { "event": "connection.accepted", "workspaceId": "wJ4kQz8XrN2mYpL5d", "createdAt": "2026-08-03T14:22:07.311Z", "data": { } } ``` `createdAt` is when Swarmhit emitted the event; it stays the same on every retry and replay. Each request carries these headers: | Header | Meaning | | ---------------------- | -------------------------------------------------------------------------------- | | `X-Swarmhit-Event` | The event type (same as `event` in the body). | | `X-Swarmhit-Delivery` | Unique delivery id. Delivery is at-least-once, so deduplicate on it. | | `X-Swarmhit-Signature` | HMAC-SHA256 hex of the raw request body, keyed by the endpoint's signing secret. | | `User-Agent` | `Swarmhit-Webhooks/1.0` | Respond with any 2xx status within 8 seconds to acknowledge. A non-2xx response, a timeout or a network error is retried with exponential backoff, waiting at least 4s, 16s, 64s, about 4 minutes and about 17 minutes, for up to 6 attempts in all, after which the delivery is marked `failed`. Several deliveries go out at once and failed ones are retried later, so events can arrive out of order: use `createdAt` to order them. After 25 consecutive failed attempts (counted across all of the endpoint's deliveries) the endpoint is auto-disabled: `enabled` turns `false` and `disabledReason` says why. Any successful delivery resets the streak (`failureCount`). A disabled endpoint receives nothing: events that happen while it is disabled are not queued for it, and deliveries still queued for it are marked `failed`. Once you fix your endpoint, re-enable it with `PATCH /webhooks/{id}` and `{"enabled": true}`, which also resets `failureCount` and clears `disabledReason`, then [replay](#delivery-log-and-debugging) the `failed` deliveries you need. ## Verify signatures [#verify-signatures] Recompute the HMAC over the **raw** body (before any JSON parsing), keyed by the whole secret string (`whsec_` prefix included), and compare in constant time with `crypto.timingSafeEqual`. The signature covers the body only; no timestamp is signed. ```js const crypto = require('crypto'); app.post('/hooks/swarmhit', express.raw({ type: 'application/json' }), (req, res) => { const received = Buffer.from(req.get('X-Swarmhit-Signature') || ''); const expected = Buffer.from( crypto .createHmac('sha256', process.env.SWARMHIT_WEBHOOK_SECRET) // the full whsec_... secret .update(req.body) .digest('hex') ); const valid = received.length === expected.length && crypto.timingSafeEqual(received, expected); if (!valid) return res.status(401).end(); const payload = JSON.parse(req.body); const deliveryId = req.get('X-Swarmhit-Delivery'); // dedupe on this res.status(200).end(); // ack fast, process async }); ``` ## Event catalog [#event-catalog] These are the event names you can pass in `events` when creating or updating an endpoint. Every payload with an `accountId` also carries `senderId`: the [sender](/smart-api/senders) that account belongs to when the event happens, or `null`. | Event | Fires when | | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `message.received` | A lead replies to a LinkedIn message: on a campaign lead, or on a lead you actioned directly through the API. Email replies fire `email.received`. | | `message.sent` | A sender account sends an outgoing LinkedIn message: a manual reply, a campaign step, or an API send. Emails fire `email.sent`. Only fires when the chat's counterpart is a tracked lead. | | `connection.accepted` | A lead accepts a connection request: a campaign invite, or one sent through the API. | | `lead.status_changed` | A lead in a campaign reaches a resting status: replied, done, failed, paused or stopped. | | `lead.label_changed` | A lead's [label](/smart-api/campaigns#labels) in a campaign changes: set by AI from a reply, by someone in the app or through the API, or cleared. | | `lead.imported` | A new lead lands in the workspace via a LinkedIn search import. Fires once per new lead; refreshes of existing leads do not fire. | | `lead.enriched` | An email or phone lookup on a lead ends: found (now on the lead), not found, or failed. One event per kind. | | `comment.pending` | An AI-drafted comment is waiting for manual approval before it posts. | | `voice_note.pending` | A generated AI voice note is waiting for manual approval before it is sent. | | `call_task.pending` | A campaign's call step made a call task: the lead waits until someone logs, skips or stops it, or until the step's wait runs out. | | `campaign.finished` | A campaign has finished outreach to every one of its leads. | | `account.connected` | A LinkedIn sender or a mailbox finishes connecting. | | `account.connection_failed` | A LinkedIn sender or mailbox connection attempt fails. | | `account.checkpoint_required` | LinkedIn asks a sender for a verification step before it will finish signing in. | | `account.status_changed` | A sender or mailbox changes status (active, error, suspended or disconnected). | | `account.paused` | A sender or mailbox is paused: manually, automatically when a sender's InMails could not be sent, or when a bought mailbox's warmup health turns critical. | | `account.resumed` | A paused sender or mailbox is resumed: manually, automatically when InMail credits return, or when warmup health recovers. | | `account.warmup_completed` | A LinkedIn sender's [warm-up](/smart-api/sender-accounts#linkedin-warm-up) reaches full speed: its daily caps are its own again. | | `account.archived` | A LinkedIn account is [archived](/smart-api/sender-accounts#archived-accounts): disconnected in the app or by `DELETE /accounts/{id}`, or because LinkedIn's side deleted its session. | | `account.restored` | An archived LinkedIn account is restored, disconnected until it is reconnected. | | `account.deleted` | A LinkedIn account is [deleted for good](/smart-api/sender-accounts#deleting-for-good), with its conversations and settings: by you, or 90 days after it was archived. | | `email.sent` | A mailbox sends an email: a campaign step, a reply from the inbox, or an API send. | | `email.received` | A lead replies by email to a campaign that emailed it, or a lead you emailed or acted on through the API writes in. | | `email.bounced` | An email from a campaign or the API bounces or is reported as spam. | | `email.opened` | A lead opens a tracked campaign email, once per email. | | `email.clicked` | A lead clicks a tracked link in a campaign email, once per email. | | `email.unsubscribed` | A lead unsubscribes through the link in a campaign email. | | `mailbox_order.completed` | An order of bought mailboxes finishes: its mailboxes are connected, and warming when the order included warmup. | | `mailbox_order.failed` | A mailbox order stops: delayed until our team steps in (`needs_attention`), or cancelled. | | `mailbox_billing.payment_failed` | A payment for bought mailboxes fails: a purchase's monthly charge or a domain's yearly renewal. | | `mailbox_billing.payment_recovered` | A mailbox payment that had failed goes through. | | `managed_domain.ready` | A domain the workspace [owns](/smart-api/mailboxes#using-a-domain-you-own) finishes connecting: mailboxes can now be added to it. | | `managed_domain.action_required` | A domain the workspace owns needs someone to act: new nameservers to set, nameservers never changed, nameservers moved away, or a registration about to lapse. | | `warmup.status_changed` | A bought mailbox's warmup changes status. | | `warmup.health_changed` | A bought mailbox's warmup health changes. | | `warmup.ready` | A warming mailbox stops being held out of campaigns: campaigns can now email from it. | There is also a `webhook.test` event. It is not subscribable: it only fires when you call [Send a test delivery](/smart-api/reference/webhooks/testWebhook), and its `data` is `{ message, webhookId }`. The call sends it right away, even to a disabled endpoint, and returns the result as `{ ok, status, error }` (`status` is the HTTP status your endpoint answered, `null` on a timeout or network error). A test is not retried, carries no `X-Swarmhit-Delivery` header, is not recorded in the delivery log, and does not affect the endpoint's health counters. ### message.received [#messagereceived] A lead replied. `source` is `campaign` (the reply is on a campaign lead) or `direct` (a lead you actioned via the API); `campaignLeadId` and `campaignId` are null for direct-source events. `chatId` identifies the conversation on LinkedIn and is the same on `message.sent` for that conversation; it is not the inbox chat `id` (the conversation's [inbox](/smart-api/inbox) chats carry the same `leadId` and `accountId`). Email replies come as `email.received` instead. Full payload: ```json { "event": "message.received", "workspaceId": "wJ4kQz8XrN2mYpL5d", "createdAt": "2026-08-03T14:22:07.311Z", "data": { "source": "campaign", "accountId": "sN4Xq7Lm2Rw9Zk5Yp", "campaignLeadId": "cLj2Xw8RmQz5Yp3Ns", "campaignId": "gT5Rw2XqLm8Zk3vNd", "leadId": "eLd7Qw2XmRk9Zp4Yt", "chatId": "Qm7TzX2kLp9RwN4vYc8HdA", "message": { "text": "Thanks for reaching out, happy to hear more. Do you have time Thursday?", "at": "2026-08-03T14:22:05.000Z" }, "from": { "firstName": "Maya", "lastName": "Lindqvist", "publicIdentifier": "maya-lindqvist", "providerId": "ACoAAB1x2y3z" }, "classification": { "interest": "interested" } } } ``` The examples below show only the `data` object; the envelope is always the same. `classification` is the AI read of the reply and is opt-in per endpoint: set `classifyReplies: true` when creating or updating the webhook. It consumes workspace credits. An endpoint without the flag always sees `classification: null`, even when another endpoint in the workspace opted in. When enabled, `classification.interest` is one of: `interested` (open to a call, demo, pricing, or learning more), `not_interested` (a clear no, a rejection, or an unsubscribe request), or `neutral` (anything else: "not right now", a question with no intent either way, an off-topic reply, an away message). The lead's label tells an away message apart as `out_of_office`: subscribe to `lead.label_changed` for it. The field is always present but `null` whenever no classification was produced (flag off, out of credits, or the classifier failed). Treat `null` as unknown, never as neutral. On `email.received`, a reply is classified only when the workspace also has an enabled endpoint subscribed to `message.received` with `classifyReplies: true`, or when the campaign scores replies itself (`aiInterestEnabled`, unless the lead's interest was set by hand). Otherwise `classification` is `null`, even on an endpoint that opted in. ### message.sent [#messagesent] The outbound mirror of `message.received`, so you can show the sender's side of the conversation. `source` is best-effort provenance inferred from the chat: `campaign`, `direct`, or `manual` (neither). Emails come as `email.sent` instead. ```json { "source": "manual", "accountId": "sN4Xq7Lm2Rw9Zk5Yp", "campaignLeadId": null, "campaignId": null, "leadId": "eLd7Qw2XmRk9Zp4Yt", "chatId": "Qm7TzX2kLp9RwN4vYc8HdA", "message": { "text": "Thursday 3pm works. Sending an invite now.", "at": "2026-08-03T14:31:40.000Z" }, "to": { "firstName": "Maya", "lastName": "Lindqvist", "publicIdentifier": "maya-lindqvist", "providerId": "ACoAAB1x2y3z" } } ``` ### connection.accepted [#connectionaccepted] `source` is `campaign` (a campaign invite) or `direct` (a connection request sent via [Send a connection request](/smart-api/reference/direct-actions/sendConnectionRequest)); campaign fields are null for direct-source events. It fires once per accepted invitation. ```json { "source": "campaign", "accountId": "sN4Xq7Lm2Rw9Zk5Yp", "campaignLeadId": "cLj2Xw8RmQz5Yp3Ns", "campaignId": "gT5Rw2XqLm8Zk3vNd", "leadId": "eLd7Qw2XmRk9Zp4Yt", "acceptedAt": "2026-08-03T11:05:22.410Z", "lead": { "firstName": "Maya", "lastName": "Lindqvist", "publicIdentifier": "maya-lindqvist", "providerId": "ACoAAB1x2y3z" } } ``` ### lead.status\_changed [#leadstatus_changed] Fires when a lead in a campaign comes to rest, whether the executor moved it there or you did (for example [Stop a lead](/smart-api/reference/leads-in-campaigns/stopLeadInCampaign), [Pause a lead](/smart-api/reference/leads-in-campaigns/pauseLeadInCampaign), or "Stop sequence" on a call task in the app). Moving a lead back into progress (resume, retry) fires nothing. `lead.publicIdentifier` is `null` for a lead added by email alone; read it by `leadId`. ```json { "campaignLeadId": "cLj2Xw8RmQz5Yp3Ns", "campaignId": "gT5Rw2XqLm8Zk3vNd", "leadId": "eLd7Qw2XmRk9Zp4Yt", "oldStatus": "running", "newStatus": "replied", "lead": { "firstName": "Maya", "lastName": "Lindqvist", "publicIdentifier": "maya-lindqvist" } } ``` ### lead.label\_changed [#leadlabel_changed] Fires when a lead's [label](/smart-api/campaigns#labels) in a campaign changes: AI labelling a reply, someone setting one in the app or through [`setLeadInterest`](/smart-api/reference/leads-in-campaigns/setLeadInterest), or clearing it (`label: null`). `by` is `ai` or `manual`. A person taking AI's label as theirs, unchanged, fires nothing. Like every event, it reaches an endpoint only once that endpoint subscribes to it. ```json { "campaignLeadId": "cLj2Xw8RmQz5Yp3Ns", "campaignId": "gT5Rw2XqLm8Zk3vNd", "leadId": "eLd7Qw2XmRk9Zp4Yt", "label": "interested", "previousLabel": "out_of_office", "by": "ai", "lead": { "firstName": "Maya", "lastName": "Lindqvist", "publicIdentifier": "maya-lindqvist", "email": "maya@nortada.com" } } ``` ### lead.imported [#leadimported] One event per new lead created by a LinkedIn search import (see [Create a lead import](/smart-api/reference/lead-imports/createLeadImport)). `lead` is the full lead object, the same shape [Get a lead](/smart-api/reference/leads/getLead) returns, except that `campaignIds` is always empty here. ```json { "importId": "iMp5Qw2XrLk8Zn4Yd", "source": "linkedin-search", "lead": { "id": "eLd7Qw2XmRk9Zp4Yt", "firstName": "Tomas", "lastName": "Ferreira", "headline": "VP Engineering at Nortada", "jobTitle": null, "company": null, "email": null, "phone": null, "emailStatus": null, "emailOptOut": false, "location": "Porto, Portugal", "picture": "https://cdn.swarmhit.com/pictures/eLd7Qw2XmRk9Zp4Yt.jpg", "notes": null, "profileUrl": "https://www.linkedin.com/in/tomas-ferreira", "publicIdentifier": "tomas-ferreira", "openProfile": null, "tags": [], "autoTags": [], "listIds": ["lQz5Yp3NsRw2XqLm8"], "campaignIds": [], "customVariables": {}, "source": "linkedin-search", "lastActivityAt": null, "enrichedAt": null, "createdAt": "2026-08-03T09:15:44.902Z", "updatedAt": "2026-08-03T09:15:44.902Z" } } ``` ### lead.enriched [#leadenriched] One event per finished email or phone lookup on a lead (see [Finding emails and phone numbers](/smart-api/leads-and-lists#finding-emails-and-phone-numbers)). `kind` is `email` or `phone`; `status` is `found` (the value is on `lead`), `not_found` or `failed`, and a failed one carries `reason`: `no_credits`, `no_identity` or `unavailable`. `lead` is the lead after the lookup, as [Get a lead](/smart-api/reference/leads/getLead) returns it, except that `campaignIds` is always empty here. ```json { "leadId": "eLd7Qw2XmRk9Zp4Yt", "kind": "email", "status": "found", "lead": { "id": "eLd7Qw2XmRk9Zp4Yt", "firstName": "Tomas", "lastName": "Ferreira", "company": "Nortada", "email": "tomas@nortada.com", "phone": null, "profileUrl": "https://www.linkedin.com/in/tomas-ferreira", "publicIdentifier": "tomas-ferreira", "enrichment": { "email": { "status": "found", "at": "2026-10-21T10:27:16.750Z", "reason": null }, "phone": null } } } ``` The example shows only some of the lead's fields; the event carries all of them. ### comment.pending [#commentpending] An AI-drafted comment needs approval before it posts. Review it with the [Approvals endpoints](/smart-api/reference/approvals/listPendingComments): [approve](/smart-api/reference/approvals/approvePendingComment), [edit](/smart-api/reference/approvals/editPendingComment), [reject](/smart-api/reference/approvals/rejectPendingComment) or [regenerate](/smart-api/reference/approvals/regeneratePendingComment). ```json { "campaignLeadId": "cLj2Xw8RmQz5Yp3Ns", "campaignId": "gT5Rw2XqLm8Zk3vNd", "leadId": "eLd7Qw2XmRk9Zp4Yt", "pendingCommentId": "pC8Zk3vNd5Rw2XqLm", "postUrl": "https://www.linkedin.com/feed/update/urn:li:activity:7228...", "comment": "Great point on onboarding friction. We saw the same pattern with self-serve trials." } ``` ### voice\_note.pending [#voice_notepending] A generated AI voice note needs approval before it is sent. Review it with the [Approvals endpoints](/smart-api/reference/approvals/listPendingVoiceNotes): [approve](/smart-api/reference/approvals/approvePendingVoiceNote), [reject](/smart-api/reference/approvals/rejectPendingVoiceNote) or [re-record](/smart-api/reference/approvals/regeneratePendingVoiceNote). Fetch the note to get its playable `audioUrl`. ```json { "campaignLeadId": "cLj2Xw8RmQz5Yp3Ns", "campaignId": "gT5Rw2XqLm8Zk3vNd", "leadId": "eLd7Qw2XmRk9Zp4Yt", "pendingVoiceNoteId": "pV8Zk3vNd5Rw2XqLm", "script": "Hey Sacha, saw you're leading growth at Acme. Quick thought for you.", "durationMs": 7400 } ``` ### call\_task.pending [#call_taskpending] A campaign's `call` step made a call task for your team. Work it with the [call tasks endpoints](/smart-api/direct-actions#call-tasks): [log](/smart-api/reference/call-tasks/completeCallTask), [skip](/smart-api/reference/call-tasks/skipCallTask) or [stop the sequence](/smart-api/reference/call-tasks/stopCallTask), or in the app's Tasks. `callTask` is the task as [getCallTask](/smart-api/reference/call-tasks/getCallTask) returns it. No event fires when a task is decided, expires or closes. ```json { "campaignLeadId": "cLj2Xw8RmQz5Yp3Ns", "campaignId": "gT5Rw2XqLm8Zk3vNd", "leadId": "eLd7Qw2XmRk9Zp4Yt", "callTaskId": "cT4Rw2XqLm8Zk3vNd", "callTask": { "id": "cT4Rw2XqLm8Zk3vNd", "status": "pending", "lead": { "id": "eLd7Qw2XmRk9Zp4Yt", "name": "Léa Martin", "profileUrl": "https://www.linkedin.com/in/lea-martin", "phone": "+33 6 12 34 56 78", "email": "lea@northbeam.io" }, "campaign": { "id": "gT5Rw2XqLm8Zk3vNd", "name": "Q4 outbound" }, "stepId": "s3", "instructions": "Ask Léa how Northbeam books demos today.", "dueAt": "2026-10-29T09:00:00.000Z", "createdAt": "2026-10-26T09:00:00.000Z", "outcome": null, "note": null, "decidedAt": null, "closedReason": null } } ``` ### campaign.finished [#campaignfinished] Fires once, when Swarmhit moves the last in-progress lead of an active campaign to a resting status; the campaign's `status` becomes `finished`. Pausing or stopping that last lead yourself (from the API, the app or an inbox reply) fires `lead.status_changed` but does not finish the campaign. `stats` is the campaign's `stats` object: only `total`, the number of leads in the campaign, is kept up to date, so count outcomes with [List the leads in a campaign](/smart-api/reference/leads-in-campaigns/listLeadsInCampaign) filtered by `status`. ```json { "campaignId": "gT5Rw2XqLm8Zk3vNd", "name": "SaaS founders EU, July", "stats": { "total": 250, "done": 0, "failed": 0 } } ``` ### Email events [#email-events] Email has its own event family so that `message.*` keeps meaning LinkedIn for every existing subscriber. The shapes mirror their LinkedIn counterparts with an `email` block on top, `to` and `from` carry the lead's `email` in place of `providerId`, and `accountId` is the mailbox (see [Mailboxes](/smart-api/mailboxes)) rather than a LinkedIn sender. `chatId` is the thread key, `email::`, shared by every `email.sent` and `email.received` of the thread; like on LinkedIn events, it is not the inbox chat `id`. ### email.sent [#emailsent] A mailbox sent an email: a campaign step (`source: campaign`), a reply from the inbox (`source: manual`) or an email sent through the API (`source: direct`, see [Direct actions](/smart-api/direct-actions#emails)). `email.messageId` is the RFC 5322 Message-ID, quoted again by `email.bounced`, `email.opened` and `email.clicked`; `threaded` is `true` when the email went out as a reply in an existing thread. ```json { "source": "campaign", "accountId": "mBx4Qw8RmZk2Yp5Ns", "campaignLeadId": "cLj2Xw8RmQz5Yp3Ns", "campaignId": "gT5Rw2XqLm8Zk3vNd", "leadId": "eLd7Qw2XmRk9Zp4Yt", "chatId": "email:mBx4Qw8RmZk2Yp5Ns:", "message": { "text": "Hi Maya, quick question about your Q4 hiring plans...", "at": "2026-09-22T09:14:03.000Z" }, "email": { "subject": "Quick question about your Q4 hiring", "messageId": "", "to": "maya@lindqvist.io", "threaded": false }, "to": { "firstName": "Maya", "lastName": "Lindqvist", "email": "maya@lindqvist.io" } } ``` ### email.received [#emailreceived] A lead answered by email. It fires for a lead a campaign has emailed (`source: campaign`), and for a lead you emailed or acted on through the API that no campaign has emailed (`source: direct`, with `campaignLeadId` and `campaignId` null); mail from any other lead lands in the inbox without an event. `message.text` is the reply with the quoted history stripped, and `classification` works as on `message.received` (opt in per endpoint with `classifyReplies`; see the note above for the extra condition on email). Out-of-office and other auto-replies land in the inbox but do not fire this event. ```json { "source": "campaign", "accountId": "mBx4Qw8RmZk2Yp5Ns", "campaignLeadId": "cLj2Xw8RmQz5Yp3Ns", "campaignId": "gT5Rw2XqLm8Zk3vNd", "leadId": "eLd7Qw2XmRk9Zp4Yt", "chatId": "email:mBx4Qw8RmZk2Yp5Ns:", "email": { "subject": "Re: Quick question about your Q4 hiring", "messageId": "" }, "message": { "text": "Happy to chat. Thursday works.", "at": "2026-09-22T11:02:19.000Z" }, "from": { "firstName": "Maya", "lastName": "Lindqvist", "publicIdentifier": "maya-lindqvist", "email": "maya@lindqvist.io" }, "classification": { "interest": "interested" } } ``` ### email.bounced [#emailbounced] An email bounced for good (`kind: bounce`) or the recipient reported it as spam (`kind: complaint`). `source` is `campaign`, or `direct` for an email sent through the API: the campaign fields are then null, `leadFailed` is `false`, and the email's `status` turns `bounced`. It fires once per email; a delayed delivery the mail server is still retrying does not fire it. `bounce.message` is the reason in plain words and `bounce.status` the SMTP status code when the server gave one. `deadAddress` is `true` when the lead's address was marked unusable (`emailStatus` on the lead, so email steps skip it from now on); `leadFailed` when the lead was failed on the step that sent this email. An email the mailbox could not send at all fails the lead (`lead.status_changed` to `failed`) without an `email.bounced`. ```json { "source": "campaign", "accountId": "mBx4Qw8RmZk2Yp5Ns", "campaignLeadId": "cLj2Xw8RmQz5Yp3Ns", "campaignId": "gT5Rw2XqLm8Zk3vNd", "leadId": "eLd7Qw2XmRk9Zp4Yt", "kind": "bounce", "email": { "messageId": "", "recipient": "maya@lindqvist.io" }, "bounce": { "action": "failed", "status": "5.1.1", "message": "The email address does not exist." }, "deadAddress": true, "leadFailed": true, "at": "2026-09-22T09:14:41.000Z" } ``` ### email.opened [#emailopened] A lead opened a tracked campaign email. It fires once per email, on the first open, never for a repeat view. Opens are approximate: some mail apps load images on their own, others block them, and an open in the first two minutes after the send, or from an automated client, is ignored because mail security scanners make them. `stepId` is the `email` step that sent it; `email.messageId` is the same Message-ID `email.sent` carried. ```json { "accountId": "mBx4Qw8RmZk2Yp5Ns", "campaignLeadId": "cLj2Xw8RmQz5Yp3Ns", "campaignId": "gT5Rw2XqLm8Zk3vNd", "leadId": "eLd7Qw2XmRk9Zp4Yt", "stepId": "s1", "email": { "messageId": "", "subject": "Quick question, Maya" }, "at": "2026-09-22T11:02:17.000Z" } ``` ### email.clicked [#emailclicked] A lead clicked a tracked link in a campaign email. Same shape as `email.opened`, plus `url`, the link that was clicked. It fires once per email, on the first click; clicks in the first two minutes after the send are ignored, and a click on the unsubscribe link is never an `email.clicked`. ```json { "accountId": "mBx4Qw8RmZk2Yp5Ns", "campaignLeadId": "cLj2Xw8RmQz5Yp3Ns", "campaignId": "gT5Rw2XqLm8Zk3vNd", "leadId": "eLd7Qw2XmRk9Zp4Yt", "stepId": "s1", "email": { "messageId": "", "subject": "Quick question, Maya" }, "url": "https://calendly.com/maya-lindqvist/intro", "at": "2026-09-22T11:04:52.000Z" } ``` ### email.unsubscribed [#emailunsubscribed] A lead unsubscribed through the unsubscribe link in a campaign email: from the page it opens (`source: page`) or with their mail app's one-click unsubscribe (`source: one-click`). Every lead of the workspace with that address now has `emailOptOut: true`, and `leadStopped` says whether the lead was stopped in the campaign that emailed it (it is not when it had already finished); a stop also fires `lead.status_changed`. `campaignLeadId`, `campaignId` and `accountId` are null when the lead has since been removed from the campaign. It fires once: opening the link alone, or unsubscribing again, fires nothing. ```json { "accountId": "mBx4Qw8RmZk2Yp5Ns", "campaignLeadId": "cLj2Xw8RmQz5Yp3Ns", "campaignId": "gT5Rw2XqLm8Zk3vNd", "leadId": "eLd7Qw2XmRk9Zp4Yt", "email": { "address": "maya@lindqvist.io" }, "source": "one-click", "leadStopped": true, "at": "2026-09-22T12:31:08.000Z" } ``` ### Sender and mailbox events [#sender-and-mailbox-events] Six of the seven `account.*` events share one shape: `data.account` is the full account object, `data.channel` says which kind it is, and `data.status` is the account's connection status at emit time. With `channel: "linkedin"`, `account` is the sender, the same shape [Get a sender account](/smart-api/reference/accounts/getAccount) returns (trimmed in the example below); with `channel: "email"`, it is the mailbox ([Get a mailbox](/smart-api/reference/email-accounts/getEmailAccount)). On either, `account.hostedLinkId` names the [hosted connect link](/smart-api/sender-accounts#hosted-connect-links) the account came through, if any. `account.connection_failed` has its own, leaner shape (see the table). Each event adds its own fields: ```json { "account": { "id": "sN4Xq7Lm2Rw9Zk5Yp", "fullName": "Sofia Marin", "publicIdentifier": "sofia-marin", "status": "error" }, "channel": "linkedin", "status": "error", "previousStatus": "active", "reason": "DISCONNECTED" } ``` | Event | Extra `data` fields | | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `account.connected` | Plus `hostedLinkId` and `externalId` when the sender or mailbox came through a hosted connect link (`externalId` is null when the link was created without one). A mailbox fires it whenever it becomes `active`: when it first connects and again when it recovers from an error, alongside `account.status_changed`. | | `account.connection_failed` | `account` is always `null` and there is no `status`. `username` (for a mailbox, its address when known), `reason` (human-readable), and `code` (stable machine-readable reason, same vocabulary as API error codes; `signin-*` codes for a mailbox's Google or Microsoft sign-in). `channel` is `"email"` for a mailbox and absent for a LinkedIn sender. Plus `hostedLinkId` and `externalId` when the attempt came through a hosted connect link. For a mailbox it fires on a Google or Microsoft sign-in and on any connect through a hosted link. `code: account-mismatch` means a different LinkedIn account signed in on a reconnect link and was removed, possibly after its `account.connected` went out. | | `account.checkpoint_required` | LinkedIn senders only. `checkpointType` (for example `2FA`, `OTP`, `IN_APP_VALIDATION`, `PHONE_REGISTER`, `CAPTCHA`). The full challenge is on `account.checkpoint`. Answer within five minutes via [Solve a checkpoint](/smart-api/reference/accounts/solveAccountCheckpoint); see the [Sender accounts guide](/smart-api/sender-accounts). | | `account.status_changed` | `status` is the new status: `active`, `error`, `suspended`, or `disconnected` when the account no longer exists (for example a mailbox you removed). `previousStatus` (which can also be `pending`) and `reason` (a short code or message) can be null or absent. | | `account.paused` | `reason`: `user` (manual), `inmail_credits` (a sender's InMails could not be sent) or `warmup_health` (a bought mailbox's warmup health turned critical; see [Warmup events](#warmup-events)). A pause does not change `status`. | | `account.resumed` | `by`: `user` (manual), `system` (InMail credits returned) or `warmup_health` (warmup health recovered). | | `account.warmup_completed` | LinkedIn senders only. `warmup`: `{ startedAt, fullSpeedAt }`, when its [warm-up](/smart-api/sender-accounts#linkedin-warm-up) started and the UTC midnight it reached full speed. Sent once, within the hour after `fullSpeedAt`, for a sender that was warming up: one whose warm-up was held back by settings turned off reaches that day without it. | | `account.archived` | LinkedIn accounts only. `account` is the archived account (`status: archived`, `archivedAt`, `campaignIds`). `via`: `app`, `api` or `provider` (LinkedIn's side deleted the session, which also sends `account.status_changed` with `disconnected`, as before). | | `account.restored` | `account` is the restored account (`status: error` until it is reconnected); `campaignIds`: the campaigns it rejoined. | | `account.deleted` | `account` is its last state, with `status: deleted`. `via`: `app`, `api` or `retention` (90 days after it was archived). | ### Mailbox order events [#mailbox-order-events] `mailbox_order.completed` fires when an order of bought mailboxes finishes: every mailbox it could set up is connected (and warming when the order included warmup). Mailboxes that could not be set up are listed with `step: failed` (`refused` for a domain that could not be registered) and are refunded. `mailbox_order.failed` fires each time an order stops: `status: needs_attention` (delayed, our team is on it; the order can still resume and send `mailbox_order.completed`) or `status: cancelled`. `order` is the order object, the same shape [Get a mailbox order](/smart-api/reference/mailbox-orders/getMailboxOrder) returns (trimmed below). `hostedLinkId` and `externalId` are added when the order was bought through a hosted link. ```json { "order": { "id": "oRd8Zk3vNd5Rw2XqL", "status": "completed", "statusLabel": "Ready", "createdVia": "api", "domains": [ { "name": "getnortada.com", "platform": "google", "status": "ready", "mailboxes": [ { "email": "tomas@getnortada.com", "step": "connected", "accountId": "mBx4Qw8RmZk2Yp5Ns", "connectedAt": "2026-09-30T10:12:44.000Z" } ] } ], "completedAt": "2026-09-30T10:13:02.000Z" }, "status": "completed", "previousStatus": "authorizing" } ``` ### Mailbox billing events [#mailbox-billing-events] `mailbox_billing.payment_failed` fires once when a payment for bought mailboxes fails: * `kind: monthly`: a purchase's monthly charge. Its warmup pauses after 1 day, and its mailboxes are cancelled on `cancelOn` (14 days after the first failure) unless it is paid. * `kind: renewal`: a domain's yearly renewal. The domain and its mailboxes stop at `expiresAt` unless it is paid. Both carry `orderId` (the purchase, see [Get a mailbox order](/smart-api/reference/mailbox-orders/getMailboxOrder)), `domains`, `amountCents`, `currency`, and `payUrl`, a page where it can be paid online, or null. `cancelOn` and `expiresAt` are `YYYY-MM-DD` dates. Payment state is also on [What you pay for bought mailboxes](/smart-api/reference/mailbox-orders/getMailboxBilling). ```json { "kind": "monthly", "orderId": "oRd8Zk3vNd5Rw2XqL", "domains": ["getnortada.com"], "amountCents": 4500, "currency": "usd", "payUrl": "https://invoice.stripe.com/i/...", "cancelOn": "2026-10-21" } ``` `mailbox_billing.payment_recovered` fires when a payment that had failed goes through: nothing is cancelled and paused warmup resumes. It carries `kind`, `orderId` and `domains`; a recovered renewal also carries `amountCents` and `currency`. ### Own domain events [#own-domain-events] The connection of a domain the workspace [owns](/smart-api/mailboxes#using-a-domain-you-own). Both events carry `domain`, the domain with its `connection` (the same shape [Get one domain](/smart-api/reference/mailbox-orders/getManagedDomain) returns, trimmed below). * `managed_domain.ready` fires when the domain finishes connecting: mailboxes can now be added to it. * `managed_domain.action_required` fires when someone needs to act, and `reason` says why: * `nameservers_changed`: the first pair was not set in time, so the domain has new nameservers. Set the new `connection.nameservers` at the registrar. * `expired`: the nameservers never changed within 7 days. Add the domain again. * `nameservers_moved`: its nameservers no longer point to Swarmhit, so its mailboxes may stop working. Set them back. * `registration_expiring`: its registration lapses soon (`connection.registrarExpiresAt`). Renew it at the registrar. ```json { "domain": { "id": "dMn4Rk8Qw2Zp5Xs3L", "name": "getacme.com", "platform": "google", "source": "own", "canAdd": false, "reason": "connecting", "connection": { "state": "nameservers", "nameservers": ["ns3.example.net", "ns4.example.net"], "nameserversChangedAt": "2026-11-05T22:14:03.000Z" } }, "reason": "nameservers_changed" } ``` ### Warmup events [#warmup-events] The warmup of a mailbox bought through Swarmhit. All three events carry `account`, the mailbox with its `warmup` object (the same shape [Get a mailbox](/smart-api/reference/email-accounts/getEmailAccount) returns, trimmed below), and `channel: "email"`. Each adds its own fields: | Event | Extra `data` fields | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `warmup.status_changed` | `status` and `previousStatus` (`null` for a warmup that just started): `pending`, `active`, `paused`, `cancelled`, `past_due` (payment due) or `error`. | | `warmup.health_changed` | `health` and `previousHealth`: `excellent`, `good`, `warning`, `critical` or `unknown`. When health turns `critical` after the hold, the mailbox is paused (`account.paused` with `reason: warmup_health`) so campaigns stop emailing from it; it is resumed (`account.resumed` with `by: warmup_health`) once health is back to `good` or `excellent`, unless it was paused by hand. | | `warmup.ready` | None. The mailbox is no longer held out of campaigns: the hold period ended with health not at `warning` or `critical`, you lifted it with [Send before warmup is done](/smart-api/reference/warmup/overrideEmailAccountWarmupHold), or warmup stopped running (paused, cancelled, `past_due` or `error`). Warmup carries on alongside campaigns when it is still running. | ```json { "account": { "id": "mBx4Qw8RmZk2Yp5Ns", "email": "tomas@getnortada.com", "status": "active", "paused": true, "pausedReason": "warmup_health", "warmup": { "status": "active", "health": "critical", "holdUntil": "2026-09-30T00:00:00.000Z", "holdReason": "health" } }, "channel": "email", "health": "critical", "previousHealth": "warning" } ``` ## Delivery log and debugging [#delivery-log-and-debugging] Each delivery (one event for one endpoint) is recorded with the exact payload that was POSTed, and updated on every attempt: `status`, `attempts`, `responseStatus`, `lastError` (for example `endpoint returned HTTP 500` or `timed out after 8000ms`) and `nextAttemptAt`. * [List the delivery log](/smart-api/reference/webhooks/listWorkspaceWebhookDeliveries): all deliveries across the workspace, newest first, filterable by `event`, `status` (`pending`, `delivered`, `failed`) and `webhookId`; `paging.total` counts the matches. An unknown `event` or `status` value is ignored rather than rejected. * [List recent deliveries](/smart-api/reference/webhooks/listWebhookDeliveries): one endpoint's deliveries, newest first, without filters. * [Replay a delivery](/smart-api/reference/webhooks/resendWebhookDelivery): re-queues a past delivery to the endpoint's current URL with the stored payload unchanged. It gets a new delivery id, so a consumer that dedupes will re-process it. The endpoint must still exist (`404` otherwise) and be enabled (`400`, `code: disabled`). Delivery rows are dropped 7 days after creation. If you need a permanent record, poll the log or persist events on receipt. # Overview (/raw-api/reference) All endpoints live under one base URL, by channel: ``` https://console.swarmhit.com/api/v1 LinkedIn https://console.swarmhit.com/whatsapp/api/v1 WhatsApp https://console.swarmhit.com/email/api/v1 Email ``` Every request carries an API key (`X-API-KEY` or `Authorization: Bearer`), except the health checks; see [Authentication](/raw-api/authentication). Endpoints are grouped by channel and resource in the sidebar; each page documents its parameters, request and response schemas and errors, with code examples. The webhook events are listed with them. The OpenAPI document is at [`https://console.swarmhit.com/openapi.yaml`](https://console.swarmhit.com/openapi.yaml), for Postman, Insomnia or code generators. For a model, see [Raw for AI](/raw-api/ai). # Overview (/smart-api/reference) All endpoints live under the production base URL: ``` https://app.swarmhit.com/api/v1 ``` Every request must carry an API key as a bearer token, except `GET /health`; see [Authentication](/smart-api/authentication). Endpoints are grouped by resource in the sidebar, and each page documents parameters, request and response schemas, and code examples, with an interactive playground to try requests against your own workspace. The raw OpenAPI specification is available at [`https://app.swarmhit.com/openapi.yaml`](https://app.swarmhit.com/openapi.yaml) for Postman, Insomnia, or code generators. # Browsers and apps (/smart-api/sdk/browser) An API key opens everything in its workspace, and anyone who runs your web page or app can read what you ship in it. So the SDK refuses to start in a browser, and the API doesn't take calls from a web page either: its CORS doesn't allow the `Authorization` header. Call the API from your server, and have your frontend call your server: ```ts title="server.ts" import Swarmhit from '@swarmhit/swarmhit-sdk'; const swarmhit = new Swarmhit(); // your API key, on the server only // e.g. GET /api/campaigns, behind your own login export async function listCampaigns() { const page = await swarmhit.campaigns.list({ limit: 20 }); return page.data.map((c) => ({ id: c.id, name: c.name, status: c.status })); } ``` Return only what the page needs, and check in your endpoint that the user may see it. ## Connecting senders and mailboxes from your app [#connecting-senders-and-mailboxes-from-your-app] Signing in to LinkedIn or to a mailbox happens on a page Swarmhit hosts. Your server creates a hosted connect link and sends the user to its `url`. The user comes back to your `redirectUrl` when they finish: ```ts const link = await swarmhit.accounts.hostedLinks.create({ redirectUrl: 'https://example.com/settings/linkedin', }); // redirect the user to link.url ``` `swarmhit.emailAccounts.hostedLinks.create(…)` does the same for mailboxes. The `account.connected` webhook tells your server when a sender is ready. ## Tests and proxies [#tests-and-proxies] `dangerouslyAllowBrowser: true` lets the client start in a browser-like runtime anyway: a test environment, or a page whose `baseURL` is your own proxy that adds the key on the server. Never use it to put an API key on a page anyone else can open. # Calling the API (/smart-api/sdk/calling-the-api) ## Methods follow the URLs [#methods-follow-the-urls] Methods mirror the endpoint's path. Each part of the path, except the ids, is a property on the client, and the action is the method: | Endpoint | SDK method | | ---------------------------------------- | ---------------------------------------------------- | | `GET /campaigns` | `swarmhit.campaigns.list()` | | `POST /campaigns` | `swarmhit.campaigns.create(params)` | | `GET /campaigns/{id}` | `swarmhit.campaigns.get(campaignId)` | | `GET /campaigns/{id}/leads` | `swarmhit.campaigns.leads.list(campaignId)` | | `POST /accounts/{id}/messages` | `swarmhit.accounts.messages.send(accountId, params)` | | `POST /email-accounts/{id}/warmup/start` | `swarmhit.emailAccounts.warmup.start(accountId)` | The common method names are `list`, `get`, `create`, `update` and `delete`. Other actions are named for what they do: `pause`, `resume`, `send`, `duplicate`. [All methods](/smart-api/sdk/methods) lists every method with its endpoint. Each endpoint page in the [API Reference](/smart-api/reference) names its SDK method at the top. ## Arguments [#arguments] A method takes, in order: 1. the **ids** in the path, in the order they appear in the URL; 2. one **params** object holding the query parameters and the body fields together; 3. the **request options** (`timeout`, `signal`, `idempotencyKey`, …), always optional. ```ts // PATCH /campaigns/{id} with a body await swarmhit.campaigns.update(campaignId, { name: 'Q4 founders (EU)' }); // GET /accounts/{id}/stats?days=30 const stats = await swarmhit.accounts.getStats(accountId, { days: 30 }); // GET /campaigns/{id}/leads/{leadId}: two ids, no params const lead = await swarmhit.campaigns.leads.get(campaignId, leadId); // With request options await swarmhit.campaigns.list({ limit: 10 }, { timeout: 10_000 }); ``` The SDK URL-encodes the ids, so pass them as they are. ## Results [#results] A method resolves to the object itself. The SDK removes the API's `{ "data": … }` envelope, so there is nothing to unwrap. ```ts const campaign = await swarmhit.campaigns.get(campaignId); campaign.name; // not campaign.data.name ``` A list method resolves to a page you can iterate: see [Lists and pagination](/smart-api/sdk/pagination). A few endpoints answer with more than one object, and resolve to the whole body. Connecting a LinkedIn sender, for example, gives `{ data, status, checkpoint }`. Dates are ISO 8601 strings. ## Fields that can be empty [#fields-that-can-be-empty] The API leaves a field out of the JSON when it has no value, and sends `null` for some others. A field the API always sends is required in the types (`campaign.id: string`); one that can be empty is optional and, where the API documents it, nullable (`lead.email?: string | null`). Test it with `??` or a plain truthiness check rather than `=== null`: ```ts const email = lead.email ?? 'no email yet'; ``` ## TypeScript [#typescript] Every parameter and result is typed, and the types are exported by name: ```ts import Swarmhit from '@swarmhit/swarmhit-sdk'; import type { Campaign, CreateCampaignParams, LeadInCampaign } from '@swarmhit/swarmhit-sdk'; const params: CreateCampaignParams = { name: 'Q4 founders' }; const campaign = await swarmhit.campaigns.create(params); ``` Enumerations in results are open. A campaign's `status`, for example, is typed `'draft' | 'active' | 'paused' | 'finished' | (string & {})`. You get autocompletion for today's values, and your code still compiles when the API adds a new one. The API may add values at any time within v1, so handle unknown values with a default branch. Plain JavaScript works the same way. Your editor still shows the types and each method's documentation. ## The HTTP response [#the-http-response] To read the response's status or headers, add `.withResponse()` to the call: ```ts const { data, response } = await swarmhit.campaigns.get(campaignId).withResponse(); data.name; response.status; // 200 response.headers.get('content-type'); ``` `.asResponse()` gives only the raw `Response`. The SDK has already read its body, so use `.withResponse()` when you also need the data. # Setting up the client (/smart-api/sdk/configuration) Create one client when your application starts and reuse it everywhere. A client holds no connection, so it is cheap. It is safe to share between concurrent requests. ```ts import Swarmhit from '@swarmhit/swarmhit-sdk'; export const swarmhit = new Swarmhit(); ``` With no options, the client reads its key from `SWARMHIT_API_KEY` and sends requests to `https://app.swarmhit.com/api/v1`. If it finds no key, the constructor throws a `SwarmhitError`. In CommonJS, use `const { Swarmhit } = require('@swarmhit/swarmhit-sdk')`. ## Options [#options] ```ts const swarmhit = new Swarmhit({ apiKey: process.env.SWARMHIT_API_KEY, timeout: 30_000, maxRetries: 3, }); ``` | Option | Default | What it does | | ------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `apiKey` | `SWARMHIT_API_KEY` | Your API key (`swh_live_…`). | | `baseURL` | `SWARMHIT_BASE_URL`, then `https://app.swarmhit.com/api/v1` | Where requests go. | | `workspace` | none | With an organization's key, the workspace to act on. See [Organizations](/smart-api/sdk/organizations). | | `timeout` | `60000` | Milliseconds one attempt may take. A slower attempt is abandoned, then retried. | | `maxRetries` | `2` | How many times a failed request is retried. See [Retries](/smart-api/sdk/retries). | | `defaultHeaders` | none | Headers sent with every request. | | `logger` | none | Where the SDK writes what it does, for example `console`. See [Logging](#logging). | | `fetch` | the runtime's | A `fetch` implementation, when you need a specific one: for a proxy, or a test double. | | `dangerouslyAllowBrowser` | `false` | Lets the client start in a browser-like runtime. See [Browsers and apps](/smart-api/sdk/browser). | ## Changing options for some calls [#changing-options-for-some-calls] `withOptions` returns a new client with some options changed. The original client does not change: ```ts const patient = swarmhit.withOptions({ timeout: 120_000, maxRetries: 5 }); await patient.stats.get(); ``` To change an option for a single request, pass it as the last argument of the method instead: ```ts await swarmhit.campaigns.list({ limit: 10 }, { timeout: 5_000, maxRetries: 0 }); ``` The request options are `timeout`, `maxRetries`, `signal` (an `AbortSignal`), `idempotencyKey`, `headers` and `workspace`. [Retries, timeouts and idempotency](/smart-api/sdk/retries) describes them. ## Keeping the key safe [#keeping-the-key-safe] An API key opens everything in its workspace. Keep it in an environment variable or a secret manager, never in your source code. Never send it to a browser: the SDK refuses to start in a web page (see [Browsers and apps](/smart-api/sdk/browser)). You can create several keys, one per integration. Revoking one on the **Access tokens** page leaves the others working. ## Logging [#logging] The SDK writes nothing unless you give it a logger. Anything with `debug`, `info`, `warn` and `error` methods that take a message and an optional data object works, such as `console`: ```ts const swarmhit = new Swarmhit({ logger: console }); ``` It logs: * each request, at `debug`; * each retry, with its reason, at `info`; * each endpoint the API marks as deprecated, once, with its sunset date, at `warn`. # Handling errors (/smart-api/sdk/errors) When a request fails, the method throws. Every error the SDK throws extends `SwarmhitError`, so one `catch` handles them all. The error's class tells you what kind of failure it was. ```ts import { APIError, NotFoundError, RateLimitError } from '@swarmhit/swarmhit-sdk'; try { const campaign = await swarmhit.campaigns.get(campaignId); } catch (err) { if (err instanceof NotFoundError) { // the campaign does not exist (or was deleted) } else if (err instanceof RateLimitError) { console.log('limit reached', err.details); } else if (err instanceof APIError) { console.error(err.status, err.code, err.message, err.details); } else { throw err; // no answer (network, timeout), or a bug in your code } } ``` ## Error classes [#error-classes] **The API answered with an error.** These extend `APIError`: | Class | Status | Typical cause | | -------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------- | | `BadRequestError` | 400 | A missing or invalid field, or a rule the request breaks (no seat left). Some missing records are a 400 with code `not-found` too | | `AuthenticationError` | 401 | A missing, revoked or mistyped key, or an `X-Swarmhit-Workspace` the key cannot act on | | `PaymentRequiredError` | 402 | A purchase (mailboxes, domains, seats) whose payment failed, or no card on file | | `PermissionDeniedError` | 403 | Not allowed for this workspace or the key's creator: a feature not enabled, billing rights missing | | `NotFoundError` | 404 | The resource does not exist in this workspace | | `ConflictError` | 409 | The current state forbids it: a paused sender, an invitation already pending, the 25-webhook cap, an idempotency key still in use | | `UnprocessableEntityError` | 422 | LinkedIn could not act on that profile or post | | `RateLimitError` | 429 | A sender's daily or weekly LinkedIn limit, or LinkedIn throttling the sender | | `InternalServerError` | 5xx | A failure on our side (500), or LinkedIn or the mail provider failing behind the request (502, 503) | **Other failures.** These extend `SwarmhitError` but not `APIError`: | Class | When | | -------------------------- | --------------------------------------------------------------------------------- | | `APIConnectionError` | The request never got an answer: network, DNS or TLS failure. | | `APITimeoutError` | The timeout ran out (it extends `APIConnectionError`). | | `APIUserAbortError` | Your `signal` aborted the request. | | `WebhookVerificationError` | A webhook delivery failed verification (see [Webhooks](/smart-api/sdk/webhooks)). | Before it throws a connection error, a timeout, a `5xx` or a rate-limit `429`, the SDK has already [retried](/smart-api/sdk/retries) the request when that was safe. ## What an APIError carries [#what-an-apierror-carries] | Property | | | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `status` | The HTTP status. | | `code` | A stable, machine-readable reason, such as `seat-limit-reached`, `inmail_credits_exhausted` or `not-found`. When the API sends none, a generic one for the status (`invalid_request` for a 400, `not_found` for a 404). | | `message` | A sentence for humans. Its wording may change: don't parse it. | | `details` | Extra context when there is some, for example `{ limit, used, scope, resetAt }` on a sender's limit. | | `headers` | The response headers. | `RateLimitError` adds `retryAfter`: the number of seconds to wait, when the response carries a `Retry-After` header. A sender's limit says when it resets in `details` instead (see [Sender limits](#sender-limits)). ## Branch on the code [#branch-on-the-code] Use `err.code` to handle a specific situation, rather than `err.message`. It never changes within an API version: ```ts async function sendInMail(senderId, profileUrl, subject, text) { try { return await swarmhit.accounts.inmails.send(senderId, { profileUrl, subject, text }); } catch (err) { if (!(err instanceof APIError)) throw err; switch (err.code) { case 'inmail_credits_exhausted': return queueForNextMonth(profileUrl); case 'profile_not_found': return markInvalid(profileUrl); case 'sender_paused': return notifyOwner(senderId); default: throw err; } } } ``` Some codes you are likely to meet: | Code | Meaning | | ----------------------------------------------- | ------------------------------------------------------------------------------------ | | `unauthorized` | The key is missing, wrong or revoked. | | `not-found`, `not_found` | No such resource in this workspace. | | `invalid_request` | The generic code of a 400 without a more specific one; `message` says what is wrong. | | `seat-limit-reached` | Connecting a sender needs a free seat. | | `profile_not_found` | The LinkedIn profile URL or identifier could not be resolved. | | `sender_paused` | The sender is paused; resume it first. | | `inmail_credits_exhausted` | The sender has no InMail credits left. | | `idempotency_in_progress`, `idempotency_failed` | See [Retries](/smart-api/sdk/retries#your-own-idempotency-keys). | The [Getting started](/smart-api#errors) guide describes the HTTP errors in more detail. ## Sender limits [#sender-limits] LinkedIn actions count against the sender's daily and weekly limits. Over a limit, the API answers `429`. The error's `details` say which limit was reached (`{ limit, used, scope, resetAt }`, `scope` being `day`, `week` or `pending`) and when it resets: ```ts try { await swarmhit.accounts.connections.send(senderId, { profileUrl }); } catch (err) { if (err instanceof RateLimitError && err.details?.resetAt) { await scheduleAt(err.details.resetAt, () => sendInvite(profileUrl)); } else { throw err; } } ``` A `429` without `details` comes from LinkedIn throttling the sender: back off. The SDK never retries these on its own: a retry within seconds can't help, and hurts a throttled account. # JavaScript SDK (/smart-api/sdk) The `@swarmhit/swarmhit-sdk` package is the official client for the Swarmhit API in JavaScript and TypeScript. It covers every endpoint and handles the parts every integration would otherwise write itself: * **Typed end to end**: every parameter, result and webhook event has a TypeScript type, and your editor shows each method's documentation. * **Safe retries**: the SDK retries network errors, timeouts and server errors. One-off sends (messages, InMails, invitations, emails) carry an idempotency key, so a retry never sends twice, and nothing else that creates is retried blindly. * **Pagination**: iterate a list with `for await` and the SDK fetches the next pages as you go. * **Webhook verification**: one call checks a delivery's signature and gives you a typed event. * **No dependencies**: it runs on Node 20+, Bun, Deno, Cloudflare Workers and Vercel Edge. ## Install [#install] npm pnpm yarn bun ```bash npm install @swarmhit/swarmhit-sdk ``` ```bash pnpm add @swarmhit/swarmhit-sdk ``` ```bash yarn add @swarmhit/swarmhit-sdk ``` ```bash bun add @swarmhit/swarmhit-sdk ``` ## Your first call [#your-first-call] Create an API key on the **Access tokens** page in Swarmhit. Keys start with `swh_live_`. Put the key in the `SWARMHIT_API_KEY` environment variable, where the SDK reads it: ```ts title="hello.mjs" import Swarmhit from '@swarmhit/swarmhit-sdk'; const swarmhit = new Swarmhit(); // reads SWARMHIT_API_KEY const info = await swarmhit.info(); console.log(`Connected to ${info.workspace.name} (${info.workspace.plan})`); ``` ```bash SWARMHIT_API_KEY=swh_live_xxxxxxxx node hello.mjs ``` An API key gives full access to its workspace, so keep it on your server: see [Browsers and apps](/smart-api/sdk/browser). ## A complete example [#a-complete-example] Create a campaign, add two leads, start it and follow its progress. The example needs at least one LinkedIn sender connected to your workspace (see [Sender accounts](/smart-api/sender-accounts)): ```ts title="launch.mjs" import Swarmhit from '@swarmhit/swarmhit-sdk'; const swarmhit = new Swarmhit(); // The LinkedIn sender that will run the campaign. const { data: senders } = await swarmhit.accounts.list(); const campaign = await swarmhit.campaigns.create({ name: 'Q4 founders', linkedinAccountIds: [senders[0].id], sequence: { steps: [ { type: 'invite', config: { note: 'Hi {{firstName}}, glad to connect!', fallbackNote: 'Hi, glad to connect!' } }, { type: 'message', config: { text: 'Thanks for connecting, {{firstName}}!', fallbackText: 'Thanks for connecting!' } }, ], }, }); await swarmhit.campaigns.leads.add(campaign.id, { leads: [ { profileUrl: 'https://www.linkedin.com/in/ada-lovelace', firstName: 'Ada' }, { profileUrl: 'https://www.linkedin.com/in/grace-hopper', firstName: 'Grace' }, ], }); await swarmhit.campaigns.update(campaign.id, { status: 'active' }); for await (const lead of swarmhit.campaigns.leads.list(campaign.id)) { console.log(lead.leadId, lead.status); } ``` A message that uses a `{{variable}}` needs a fallback (`fallbackNote`, `fallbackText`), which is sent to leads with no value for that variable. A campaign without its fallbacks cannot be activated. Every method follows the same pattern: ids first, then one object with the parameters. It resolves to the object the API returns. A list method resolves to a page, with the items in `data`. The [Recipes](/smart-api/sdk/recipes) page has more end-to-end examples. ## Next steps [#next-steps] # All methods (/smart-api/sdk/methods) Every method of the `@swarmhit/swarmhit-sdk` package, grouped as they appear on the client. Each method links to its endpoint in the [API Reference](/smart-api/reference), which documents every parameter and field. Arguments follow the usual order: the path ids, then one `params` object, then the [request options](/smart-api/sdk/retries#request-options). The list shows `params?` when everything in `params` is optional. In the Returns column, you can iterate a *page of* result with `for await`: see [Lists and pagination](/smart-api/sdk/pagination). *Retry-safe* methods send an idempotency key, so a retry never repeats the action: see [Never twice](/smart-api/sdk/retries#never-twice). The package exports every type named here: `import type { Campaign } from '@swarmhit/swarmhit-sdk'`. ## swarmhit | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.info()` | `GET /` | API info | `GetApiInfoResponse` | | `swarmhit.health()` | `GET /health` | Service health | `Health` | ## swarmhit.stats | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.stats.get()` | `GET /stats` | Workspace stats | `Stats` | ## swarmhit.campaigns | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.campaigns.list(params?)` | `GET /campaigns` | List campaigns | `page of Campaign` | | `swarmhit.campaigns.create(params?)` | `POST /campaigns` | Create a campaign | `CampaignDetail` | | `swarmhit.campaigns.get(campaignId)` | `GET /campaigns/{id}` | Get a campaign | `CampaignDetail` | | `swarmhit.campaigns.update(campaignId, params)` | `PATCH /campaigns/{id}` | Update a campaign | `CampaignDetail` | | `swarmhit.campaigns.delete(campaignId)` | `DELETE /campaigns/{id}` | Delete a campaign | `DeleteCampaignResponse` | | `swarmhit.campaigns.duplicate(campaignId)` | `POST /campaigns/{id}/duplicate` | Duplicate a campaign | `CampaignDetail` | | `swarmhit.campaigns.updateStep(campaignId, stepId, params)` | `PATCH /campaigns/{id}/sequence/{stepId}` | Update one campaign sequence step | `CampaignDetail` | | `swarmhit.campaigns.rebalanceSender(campaignId, accountId)` | `POST /campaigns/{id}/senders/{accountId}/rebalance` | Rebalance a sender's leads | `RebalanceCampaignSenderResponse` | ## swarmhit.campaigns.leads | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.campaigns.leads.list(campaignId, params?)` | `GET /campaigns/{id}/leads` | List the leads in a campaign | `page of LeadInCampaign` | | `swarmhit.campaigns.leads.add(campaignId, params)` | `POST /campaigns/{id}/leads` | Add leads to a campaign | `AddLeadsToCampaignResponse` | | `swarmhit.campaigns.leads.get(campaignId, leadId)` | `GET /campaigns/{id}/leads/{leadId}` | A lead's progress in a campaign | `LeadInCampaignDetail` | | `swarmhit.campaigns.leads.setInterest(campaignId, leadId, params)` | `PATCH /campaigns/{id}/leads/{leadId}` | Set a lead's label in a campaign | `LeadInCampaignDetail` | | `swarmhit.campaigns.leads.remove(campaignId, leadId)` | `DELETE /campaigns/{id}/leads/{leadId}` | Remove a lead from a campaign | `RemoveLeadFromCampaignResponse` | | `swarmhit.campaigns.leads.pause(campaignId, leadId)` | `POST /campaigns/{id}/leads/{leadId}/pause` | Pause a lead in a campaign | `LeadInCampaign` | | `swarmhit.campaigns.leads.resume(campaignId, leadId)` | `POST /campaigns/{id}/leads/{leadId}/resume` | Resume a paused lead in a campaign | `LeadInCampaign` | | `swarmhit.campaigns.leads.reschedule(campaignId, leadId, params)` | `POST /campaigns/{id}/leads/{leadId}/reschedule` | Change when a lead out of office goes again | `LeadInCampaign` | | `swarmhit.campaigns.leads.retry(campaignId, leadId)` | `POST /campaigns/{id}/leads/{leadId}/retry` | Retry a lead in a campaign | `LeadInCampaign` | | `swarmhit.campaigns.leads.stop(campaignId, leadId)` | `POST /campaigns/{id}/leads/{leadId}/stop` | Stop a lead in a campaign for good | `LeadInCampaign` | ## swarmhit.leads | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.leads.list(params?)` | `GET /leads` | List leads | `page of Lead` | | `swarmhit.leads.get(leadId)` | `GET /leads/{id}` | Get a lead | `Lead` | | `swarmhit.leads.update(leadId, params)` | `PATCH /leads/{id}` | Update a lead | `Lead` | | `swarmhit.leads.delete(leadId)` | `DELETE /leads/{id}` | Delete a lead | `DeleteLeadResponse` | | `swarmhit.leads.getActivity(leadId)` | `GET /leads/{id}/activity` | Get a lead's activity | `GetLeadActivityResponse` | | `swarmhit.leads.enrich(leadId, params?)` | `POST /leads/{id}/enrich` | Find a lead's email or phone | `Lead` | | `swarmhit.leads.enrichMany(params)` | `POST /leads/enrich` | Find emails or phones for many leads | `EnrichLeadsResponse` | ## swarmhit.lists | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.lists.list(params?)` | `GET /lists` | List lead lists | `page of List` | | `swarmhit.lists.create(params?)` | `POST /lists` | Create a lead list | `List` | | `swarmhit.lists.get(listId)` | `GET /lists/{id}` | Get a lead list | `List` | | `swarmhit.lists.update(listId, params)` | `PATCH /lists/{id}` | Rename a lead list | `List` | | `swarmhit.lists.delete(listId)` | `DELETE /lists/{id}` | Delete a lead list | `DeleteListResponse` | ## swarmhit.lists.leads | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.lists.leads.list(listId, params?)` | `GET /lists/{id}/leads` | List a list's leads | `page of Lead` | | `swarmhit.lists.leads.add(listId, params)` | `POST /lists/{id}/leads` | Add leads to a list | `AddListLeadsResponse` | | `swarmhit.lists.leads.remove(listId, leadId)` | `DELETE /lists/{id}/leads/{leadId}` | Remove a lead from a list | `RemoveListLeadResponse` | ## swarmhit.leadImports | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.leadImports.create(params)` | `POST /lead-imports` | Start a LinkedIn search import | `LeadImport` | | `swarmhit.leadImports.list(params?)` | `GET /lead-imports` | List lead imports | `page of LeadImport` | | `swarmhit.leadImports.get(leadImportId)` | `GET /lead-imports/{id}` | Get a lead import | `LeadImport` | | `swarmhit.leadImports.delete(leadImportId)` | `DELETE /lead-imports/{id}` | Delete a terminal lead import | `DeleteLeadImportResponse` | | `swarmhit.leadImports.cancel(leadImportId)` | `POST /lead-imports/{id}/cancel` | Cancel a running lead import | `LeadImport` | | `swarmhit.leadImports.resume(leadImportId)` | `POST /lead-imports/{id}/resume` | Resume a paused or failed lead import | `LeadImport` | ## swarmhit.database | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.database.suggestions(params)` | `POST /database/suggestions` | List valid filter values | `DatabaseSuggestionsResponse` | | `swarmhit.database.capabilities()` | `GET /database/capabilities` | List supported filters | `DatabaseCapabilitiesResponse` | ## swarmhit.database.people | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.database.people.search(params)` | `POST /database/people/search` | Search people in the database | `page of DbPerson` | | `swarmhit.database.people.enrich(params)` | `POST /database/people/enrich` | Enrich a person | `DbPersonDetail` | | `swarmhit.database.people.findEmail(params)` | `POST /database/people/find-email` | Find a person's email | `FindEmailDatabaseResponse` | | `swarmhit.database.people.findPhone(params)` | `POST /database/people/find-phone` | Find a person's phone | `FindPhoneDatabaseResponse` | ## swarmhit.database.companies | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.database.companies.search(params)` | `POST /database/companies/search` | Search companies in the database | `page of DbCompany` | | `swarmhit.database.companies.enrich(params)` | `POST /database/companies/enrich` | Enrich a company | `DbCompany` | ## swarmhit.inbox | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.inbox.getCounts(params?)` | `GET /inbox/counts` | Unread messages, replies, favourites and labels | `GetInboxCountsResponse` | | `swarmhit.inbox.sync(params)` | `POST /inbox/sync` | Pull a sender's recent chats from LinkedIn | `SyncInboxChatsResponse` | ## swarmhit.inbox.chats | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.inbox.chats.list(params?)` | `GET /inbox/chats` | List inbox chats | `page of Chat` | | `swarmhit.inbox.chats.get(chatId)` | `GET /inbox/chats/{id}` | Get an inbox chat | `Chat` | | `swarmhit.inbox.chats.markRead(chatId)` | `POST /inbox/chats/{id}/read` | Mark a chat read | `MarkChatReadResponse` | | `swarmhit.inbox.chats.setFavorite(chatId, params)` | `POST /inbox/chats/{id}/favorite` | Star or unstar a chat | `Chat` | | `swarmhit.inbox.chats.sync(chatId, params?)` | `POST /inbox/chats/{id}/sync` | Pull a chat's latest messages from LinkedIn | `SyncChatMessagesResponse` | ## swarmhit.inbox.chats.messages | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.inbox.chats.messages.list(chatId, params?)` | `GET /inbox/chats/{id}/messages` | List messages in a chat | `page of Message` | | `swarmhit.inbox.chats.messages.send(chatId, params)` | `POST /inbox/chats/{id}/messages` | Send a message in a chat Retry-safe. | `SendChatMessageResponse` | ## swarmhit.senders | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.senders.list(params?)` | `GET /senders` | List senders | `page of Sender` | | `swarmhit.senders.create(params)` | `POST /senders` | Create a sender | `Sender` | | `swarmhit.senders.get(senderId)` | `GET /senders/{id}` | Get a sender | `Sender` | | `swarmhit.senders.update(senderId, params)` | `PATCH /senders/{id}` | Update a sender | `Sender` | | `swarmhit.senders.delete(senderId)` | `DELETE /senders/{id}` | Delete a sender | `DeleteSenderResponse` | ## swarmhit.accounts | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.accounts.list(params?)` | `GET /accounts` | List sender accounts | `page of ListAccountsItem` | | `swarmhit.accounts.connect(params)` | `POST /accounts` | Connect a sender | `ConnectSenderResponse` | | `swarmhit.accounts.get(accountId)` | `GET /accounts/{id}` | Get a sender account | `Account` | | `swarmhit.accounts.update(accountId, params)` | `PATCH /accounts/{id}` | Rename an account or set its sender | `Account` | | `swarmhit.accounts.disconnect(accountId, params?)` | `DELETE /accounts/{id}` | Disconnect a sender | `DisconnectSenderResponse` | | `swarmhit.accounts.restore(accountId, params?)` | `POST /accounts/{id}/restore` | Restore an archived account | `Account` | | `swarmhit.accounts.sync(accountId)` | `POST /accounts/{id}/sync` | Sync account information | `Account` | | `swarmhit.accounts.reconnect(accountId, params?)` | `POST /accounts/{id}/reconnect` | Reconnect a sender account | `ReconnectAccountResponse` | | `swarmhit.accounts.pause(accountId)` | `POST /accounts/{id}/pause` | Pause a sender | `Account` | | `swarmhit.accounts.resume(accountId)` | `POST /accounts/{id}/resume` | Resume a paused sender | `Account` | | `swarmhit.accounts.getSsi(accountId, params?)` | `GET /accounts/{id}/ssi` | Social Selling Index | `AccountSsi` | | `swarmhit.accounts.getStats(accountId, params?)` | `GET /accounts/{id}/stats` | Sender performance | `AccountStats` | | `swarmhit.accounts.createLiveView(accountId)` | `POST /accounts/{id}/live-view` | Open a live view of the sign-in browser | `CreateAccountLiveViewResponse` | | `swarmhit.accounts.pickNewIp(accountId)` | `POST /accounts/{id}/pick-new-ip` | Move a stopped sender onto a new IP | `Account` | ## swarmhit.accounts.checkpoint | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.accounts.checkpoint.get(accountId)` | `GET /accounts/{id}/checkpoint` | Get the pending sign-in challenge | `GetAccountCheckpointResponse` | | `swarmhit.accounts.checkpoint.solve(accountId, params)` | `POST /accounts/{id}/checkpoint` | Answer the pending sign-in challenge | `SolveAccountCheckpointResponse` | | `swarmhit.accounts.checkpoint.resend(accountId)` | `POST /accounts/{id}/checkpoint/resend` | Re-send the pending sign-in challenge | `ResendAccountCheckpointResponse` | ## swarmhit.accounts.limits | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.accounts.limits.get(accountId)` | `GET /accounts/{id}/limits` | Get a sender's limits | `AccountLimits` | | `swarmhit.accounts.limits.update(accountId, params)` | `PATCH /accounts/{id}/limits` | Change a sender's limits | `AccountLimits` | ## swarmhit.accounts.warmup | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.accounts.warmup.get()` | `GET /accounts/warmup` | Get the LinkedIn warm-up settings | `AccountWarmupPolicy` | | `swarmhit.accounts.warmup.update(params)` | `PATCH /accounts/warmup` | Change the LinkedIn warm-up settings | `AccountWarmupPolicy` | ## swarmhit.accounts.hostedLinks | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.accounts.hostedLinks.create(params)` | `POST /accounts/hosted-link` | Create a hosted connect link | `HostedLink` | | `swarmhit.accounts.hostedLinks.get(hostedLinkId)` | `GET /accounts/hosted-link/{id}` | Get a hosted connect link | `HostedLink` | ## swarmhit.accounts.connections | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.accounts.connections.send(accountId, params)` | `POST /accounts/{id}/connections` | Send a connection request Retry-safe. | `OutreachAction` | | `swarmhit.accounts.connections.get(accountId, identifier)` | `GET /accounts/{id}/connections/{identifier}` | Check connection status | `Connection` | | `swarmhit.accounts.connections.withdraw(accountId, identifier)` | `DELETE /accounts/{id}/connections/{identifier}` | Withdraw a connection request | `WithdrawConnectionRequestResponse` | ## swarmhit.accounts.messages | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.accounts.messages.send(accountId, params)` | `POST /accounts/{id}/messages` | Message a connection Retry-safe. | `OutreachAction` | ## swarmhit.accounts.inmails | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.accounts.inmails.send(accountId, params)` | `POST /accounts/{id}/inmails` | Send an InMail Retry-safe. | `OutreachAction` | ## swarmhit.accounts.profiles | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.accounts.profiles.get(accountId, identifier)` | `GET /accounts/{id}/profiles/{identifier}` | Look up a profile | `Profile` | | `swarmhit.accounts.profiles.visit(accountId, params)` | `POST /accounts/{id}/profile-views` | Visit a profile Retry-safe. | `OutreachAction` | | `swarmhit.accounts.profiles.follow(accountId, params)` | `POST /accounts/{id}/follows` | Follow a lead Retry-safe. | `OutreachAction` | ## swarmhit.accounts.posts | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.accounts.posts.like(accountId, params)` | `POST /accounts/{id}/post-likes` | Like a post Retry-safe. | `OutreachAction` | | `swarmhit.accounts.posts.comment(accountId, params)` | `POST /accounts/{id}/post-comments` | Comment on a post Retry-safe. | `OutreachAction` | | `swarmhit.accounts.posts.listComments(accountId, params)` | `GET /accounts/{id}/post-comments` | List a post's comments | `page of PostComment` | ## swarmhit.accounts.actions | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.accounts.actions.list(accountId, params?)` | `GET /accounts/{id}/actions` | List direct actions | `page of OutreachAction` | ## swarmhit.accounts.salesNavigator | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.accounts.salesNavigator.accountSearch(accountId, params?)` | `GET /accounts/{id}/sales-navigator/account-search` | Sales Navigator company search | `SalesNavigatorAccountSearchResponse` | | `swarmhit.accounts.salesNavigator.lists(accountId, params?)` | `GET /accounts/{id}/sales-navigator/lists` | List saved Sales Navigator lists | `ListSalesNavigatorListsResponse` | ## swarmhit.emailAccounts | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.emailAccounts.list(params?)` | `GET /email-accounts` | List mailboxes | `page of MailboxAccount` | | `swarmhit.emailAccounts.get(emailAccountId)` | `GET /email-accounts/{id}` | Get a mailbox | `MailboxAccount` | | `swarmhit.emailAccounts.update(emailAccountId, params)` | `PATCH /email-accounts/{id}` | Set a mailbox's sender | `MailboxAccount` | | `swarmhit.emailAccounts.delete(emailAccountId)` | `DELETE /email-accounts/{id}` | Remove a mailbox | `RemoveEmailAccountResponse` | | `swarmhit.emailAccounts.pause(emailAccountId)` | `POST /email-accounts/{id}/pause` | Pause a mailbox | `MailboxAccount` | | `swarmhit.emailAccounts.resume(emailAccountId)` | `POST /email-accounts/{id}/resume` | Resume a paused mailbox | `MailboxAccount` | | `swarmhit.emailAccounts.checkDns(emailAccountId)` | `POST /email-accounts/{id}/check-dns` | Re-check a mailbox's domain | `MailboxAccount` | | `swarmhit.emailAccounts.testDeliverability(emailAccountId)` | `POST /email-accounts/{id}/deliverability-test` | Run a deliverability test | `MailboxAccount` | | `swarmhit.emailAccounts.getStats(emailAccountId, params?)` | `GET /email-accounts/{id}/stats` | Mailbox performance | `MailboxStats` | | `swarmhit.emailAccounts.cancelManaged(emailAccountId, params?)` | `POST /email-accounts/{id}/cancel-managed` | Cancel a bought mailbox | `CancelManagedEmailAccountResponse` | | `swarmhit.emailAccounts.setPicture(emailAccountId, params)` | `POST /email-accounts/{id}/picture` | Set a bought mailbox's picture | `MailboxAccount` | ## swarmhit.emailAccounts.emails | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.emailAccounts.emails.send(emailAccountId, params)` | `POST /email-accounts/{id}/emails` | Send an email Retry-safe. | `DirectEmail` | | `swarmhit.emailAccounts.emails.list(emailAccountId, params?)` | `GET /email-accounts/{id}/emails` | List emails sent through the API | `page of DirectEmail` | ## swarmhit.emailAccounts.hostedLinks | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.emailAccounts.hostedLinks.create(params)` | `POST /email-accounts/hosted-link` | Create a hosted connect link for a mailbox | `HostedLink` | | `swarmhit.emailAccounts.hostedLinks.get(hostedLinkId)` | `GET /email-accounts/hosted-link/{id}` | Get a mailbox hosted connect link | `HostedLink` | ## swarmhit.emailAccounts.warmup | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.emailAccounts.warmup.get(emailAccountId)` | `GET /email-accounts/{id}/warmup` | Get a mailbox's warmup | `WarmupState` | | `swarmhit.emailAccounts.warmup.update(emailAccountId, params)` | `PATCH /email-accounts/{id}/warmup` | Change the warmup strategy | `WarmupState` | | `swarmhit.emailAccounts.warmup.start(emailAccountId, params?)` | `POST /email-accounts/{id}/warmup/start` | Start warmup | `WarmupState` | | `swarmhit.emailAccounts.warmup.pause(emailAccountId)` | `POST /email-accounts/{id}/warmup/pause` | Pause warmup | `WarmupState` | | `swarmhit.emailAccounts.warmup.resume(emailAccountId)` | `POST /email-accounts/{id}/warmup/resume` | Resume warmup | `WarmupState` | | `swarmhit.emailAccounts.warmup.stop(emailAccountId)` | `POST /email-accounts/{id}/warmup/stop` | Stop warmup | `WarmupState` | | `swarmhit.emailAccounts.warmup.overrideHold(emailAccountId, params)` | `POST /email-accounts/{id}/warmup/hold-override` | Send before warmup is done | `WarmupState` | | `swarmhit.emailAccounts.warmup.getStats(emailAccountId, params?)` | `GET /email-accounts/{id}/warmup/stats` | Warmup daily series | `GetEmailAccountWarmupStatsResponse` | ## swarmhit.domains | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.domains.search(params)` | `GET /domains/search` | Search domains to buy | `SearchDomainsResponse` | ## swarmhit.managedDomains | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.managedDomains.list(params?)` | `GET /managed-domains` | List the domains you bought | `ListManagedDomainsResponse` | | `swarmhit.managedDomains.create(params)` | `POST /managed-domains` | Use a domain you already own | `ManagedDomain` | | `swarmhit.managedDomains.get(managedDomainId, params?)` | `GET /managed-domains/{id}` | Get one domain | `ManagedDomain` | | `swarmhit.managedDomains.delete(managedDomainId)` | `DELETE /managed-domains/{id}` | Remove an own domain | `RemoveOwnDomainResponse` | | `swarmhit.managedDomains.check(managedDomainId)` | `POST /managed-domains/{id}/check` | Check an own domain now | `ManagedDomain` | ## swarmhit.mailboxBilling | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.mailboxBilling.get()` | `GET /mailbox-billing` | What you pay for bought mailboxes | `MailboxBilling` | ## swarmhit.mailboxOrders | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.mailboxOrders.quote(params)` | `POST /mailbox-orders/quote` | Price an order | `MailboxOrderQuote` | | `swarmhit.mailboxOrders.list(params?)` | `GET /mailbox-orders` | List mailbox orders | `page of MailboxOrder` | | `swarmhit.mailboxOrders.create(params)` | `POST /mailbox-orders` | Buy domains and mailboxes Retry-safe. | `MailboxOrder` | | `swarmhit.mailboxOrders.get(mailboxOrderId, params?)` | `GET /mailbox-orders/{id}` | Get a mailbox order | `MailboxOrder` | ## swarmhit.callTasks | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.callTasks.list(params?)` | `GET /call-tasks` | List call tasks | `page of CallTask` | | `swarmhit.callTasks.get(callTaskId)` | `GET /call-tasks/{id}` | Get a call task | `CallTask` | | `swarmhit.callTasks.complete(callTaskId, params)` | `POST /call-tasks/{id}/complete` | Log a call | `CallTask` | | `swarmhit.callTasks.skip(callTaskId)` | `POST /call-tasks/{id}/skip` | Skip a call | `CallTask` | | `swarmhit.callTasks.stop(callTaskId, params?)` | `POST /call-tasks/{id}/stop` | Stop the lead's sequence | `CallTask` | ## swarmhit.whatsappAccounts | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.whatsappAccounts.list(params?)` | `GET /whatsapp-accounts` | List WhatsApp numbers | `page of WhatsappAccount` | | `swarmhit.whatsappAccounts.link(params)` | `POST /whatsapp-accounts` | Link a WhatsApp number | `LinkWhatsappAccountResponse` | | `swarmhit.whatsappAccounts.get(whatsappAccountId)` | `GET /whatsapp-accounts/{id}` | Get a WhatsApp number | `WhatsappAccount` | | `swarmhit.whatsappAccounts.update(whatsappAccountId, params)` | `PATCH /whatsapp-accounts/{id}` | Change a number's name, caps, warm-up or sender | `WhatsappAccount` | | `swarmhit.whatsappAccounts.delete(whatsappAccountId)` | `DELETE /whatsapp-accounts/{id}` | Remove a WhatsApp number | `RemoveWhatsappAccountResponse` | | `swarmhit.whatsappAccounts.getLink(whatsappAccountId)` | `GET /whatsapp-accounts/{id}/link` | Follow a link | `WhatsappLinkState` | | `swarmhit.whatsappAccounts.relink(whatsappAccountId, params?)` | `POST /whatsapp-accounts/{id}/relink` | Scan again | `RelinkWhatsappAccountResponse` | | `swarmhit.whatsappAccounts.cancelLink(whatsappAccountId)` | `POST /whatsapp-accounts/{id}/cancel-link` | Cancel a link | `CancelWhatsappLinkResponse` | | `swarmhit.whatsappAccounts.pause(whatsappAccountId)` | `POST /whatsapp-accounts/{id}/pause` | Pause a WhatsApp number | `WhatsappAccount` | | `swarmhit.whatsappAccounts.resume(whatsappAccountId)` | `POST /whatsapp-accounts/{id}/resume` | Resume a WhatsApp number | `WhatsappAccount` | | `swarmhit.whatsappAccounts.getStats(whatsappAccountId)` | `GET /whatsapp-accounts/{id}/stats` | WhatsApp number performance and health | `WhatsappStats` | ## swarmhit.pendingComments | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.pendingComments.list(params?)` | `GET /pending-comments` | List AI comment drafts | `page of PendingComment` | | `swarmhit.pendingComments.get(pendingCommentId)` | `GET /pending-comments/{id}` | Get a comment draft | `PendingComment` | | `swarmhit.pendingComments.update(pendingCommentId, params)` | `PATCH /pending-comments/{id}` | Edit a comment draft | `PendingComment` | | `swarmhit.pendingComments.approve(pendingCommentId, params?)` | `POST /pending-comments/{id}/approve` | Approve a comment draft | `PendingComment` | | `swarmhit.pendingComments.reject(pendingCommentId)` | `POST /pending-comments/{id}/reject` | Reject a comment draft | `PendingComment` | | `swarmhit.pendingComments.regenerate(pendingCommentId, params?)` | `POST /pending-comments/{id}/regenerate` | Re-draft a comment | `PendingComment` | ## swarmhit.pendingVoiceNotes | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.pendingVoiceNotes.list(params?)` | `GET /pending-voice-notes` | List AI voice notes awaiting review | `page of PendingVoiceNote` | | `swarmhit.pendingVoiceNotes.get(pendingVoiceNoteId)` | `GET /pending-voice-notes/{id}` | Get a voice note awaiting review | `PendingVoiceNote` | | `swarmhit.pendingVoiceNotes.approve(pendingVoiceNoteId)` | `POST /pending-voice-notes/{id}/approve` | Approve a voice note | `PendingVoiceNote` | | `swarmhit.pendingVoiceNotes.reject(pendingVoiceNoteId)` | `POST /pending-voice-notes/{id}/reject` | Reject a voice note | `PendingVoiceNote` | | `swarmhit.pendingVoiceNotes.regenerate(pendingVoiceNoteId, params?)` | `POST /pending-voice-notes/{id}/regenerate` | Re-record a voice note | `PendingVoiceNote` | ## swarmhit.workspaces | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.workspaces.list()` | `GET /workspaces` | List workspaces | `OrganizationWorkspaces` | | `swarmhit.workspaces.create(params)` | `POST /workspaces` | Create a workspace | `OrganizationWorkspaceDetail` | | `swarmhit.workspaces.get(workspaceId)` | `GET /workspaces/{id}` | Get a workspace | `OrganizationWorkspaceDetail` | | `swarmhit.workspaces.update(workspaceId, params)` | `PATCH /workspaces/{id}` | Update a workspace | `OrganizationWorkspaceDetail` | | `swarmhit.workspaces.delete(workspaceId)` | `DELETE /workspaces/{id}` | Delete a workspace | `DeleteWorkspaceResponse` | ## swarmhit.workspaces.members | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.workspaces.members.list(workspaceId)` | `GET /workspaces/{id}/members` | List a workspace's members | `ListWorkspaceMembersResponse` | | `swarmhit.workspaces.members.invite(workspaceId, params)` | `POST /workspaces/{id}/members` | Invite a member to a workspace | `InviteWorkspaceMemberResponse` | | `swarmhit.workspaces.members.update(workspaceId, memberId, params)` | `PATCH /workspaces/{id}/members/{memberId}` | Update a workspace member's role | `UpdateWorkspaceMemberResponse` | | `swarmhit.workspaces.members.remove(workspaceId, memberId, params?)` | `DELETE /workspaces/{id}/members/{memberId}` | Remove a workspace member or revoke an invite | `RemoveWorkspaceMemberResponse` | ## swarmhit.webhooks | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.webhooks.list(params?)` | `GET /webhooks` | List webhook endpoints | `page of Webhook` | | `swarmhit.webhooks.create(params)` | `POST /webhooks` | Create a webhook endpoint | `Webhook` | | `swarmhit.webhooks.get(webhookId)` | `GET /webhooks/{id}` | Get a webhook endpoint | `Webhook` | | `swarmhit.webhooks.update(webhookId, params)` | `PATCH /webhooks/{id}` | Update a webhook endpoint | `Webhook` | | `swarmhit.webhooks.delete(webhookId)` | `DELETE /webhooks/{id}` | Delete a webhook endpoint | `DeleteWebhookResponse` | | `swarmhit.webhooks.test(webhookId)` | `POST /webhooks/{id}/test` | Send a test delivery | `TestWebhookResponse` | | `swarmhit.webhooks.listDeliveries(webhookId, params?)` | `GET /webhooks/{id}/deliveries` | List recent deliveries | `page of WebhookDelivery` | ## swarmhit.webhooks.deliveries | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.webhooks.deliveries.list(params?)` | `GET /webhooks/deliveries` | List the delivery log | `page of WebhookDelivery` | | `swarmhit.webhooks.deliveries.resend(deliveryId)` | `POST /webhooks/deliveries/{id}/resend` | Replay a delivery | `WebhookDelivery` | ## swarmhit.embed.sessions | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.embed.sessions.create(params)` | `POST /embed/sessions` | Create an embedded session | `EmbedSession` | ## swarmhit.embed.users | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.embed.users.revokeSessions(userId)` | `DELETE /embed/users/{id}/sessions` | End a user's embedded sessions | `RevokeEmbedSessionsResponse` | ## swarmhit.blocklist | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.blocklist.list(params?)` | `GET /blocklist` | List the blocklist | `page of BlocklistEntry` | | `swarmhit.blocklist.add(params)` | `POST /blocklist` | Add to the blocklist | `BlocklistAddResult` | | `swarmhit.blocklist.remove(params)` | `POST /blocklist/remove` | Remove from the blocklist | `RemoveBlocklistEntriesResponse` | | `swarmhit.blocklist.delete(blocklistId)` | `DELETE /blocklist/{id}` | Remove one blocklist entry | `RemoveBlocklistEntryResponse` | ## swarmhit.blocklist.settings | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.blocklist.settings.get()` | `GET /blocklist/settings` | Get the blocklist settings | `BlocklistSettings` | | `swarmhit.blocklist.settings.update(params)` | `PATCH /blocklist/settings` | Update the blocklist settings | `BlocklistSettings` | ## swarmhit.countries | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.countries.list()` | `GET /countries` | List countries | `ListCountriesResponse` | ## swarmhit.timezones | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.timezones.list()` | `GET /timezones` | List timezones | `ListTimezonesResponse` | ## swarmhit.voices | Method | Endpoint | What it does | Returns | | --- | --- | --- | --- | | `swarmhit.voices.list()` | `GET /voices` | List voices | `ListVoicesResponse` | # Organizations and workspaces (/smart-api/sdk/organizations) An organization, such as an agency, holds several workspaces, one per client. The organization's API key can act on any workspace inside it. Tell the SDK which workspace, and every request runs there as if it used that workspace's own key. See [Organization](/smart-api/organization) for how organizations, seats and members work. ## Act on a workspace [#act-on-a-workspace] Make a client per workspace with `withOptions`: ```ts const swarmhit = new Swarmhit(); // the organization's key const acme = swarmhit.withOptions({ workspace: 'ws_acme' }); const globex = swarmhit.withOptions({ workspace: 'ws_globex' }); const acmeCampaigns = await acme.campaigns.list(); await globex.webhooks.create({ url: 'https://example.com/hooks/globex', events: ['message.received'] }); ``` Or name the workspace for a single request: ```ts await swarmhit.campaigns.list({}, { workspace: 'ws_acme' }); ``` The SDK sends the workspace id in the `X-Swarmhit-Workspace` header. A workspace's own key cannot act on another workspace: the API refuses the request with an `AuthenticationError`. An organization's key that names a workspace outside the organization gets the same error. The `workspaces` endpoints always manage the key's organization, whatever the header says. ## List and manage workspaces [#list-and-manage-workspaces] ```ts const org = await swarmhit.workspaces.list(); console.log(`${org.available} of ${org.pool} seats free`); for (const ws of org.workspaces) { console.log(ws.id, ws.name, `${ws.sendersUsed}/${ws.seatAllocation} senders`); } // A new client workspace with two seats from the pool. const client = await swarmhit.workspaces.create({ name: 'Initech', seatAllocation: 2 }); ``` `swarmhit.workspaces.members` lists and manages the people with access to one of the organization's workspaces. ## Run the same job for every client [#run-the-same-job-for-every-client] ```ts const { workspaces } = await swarmhit.workspaces.list(); for (const ws of workspaces) { const client = swarmhit.withOptions({ workspace: ws.id }); const stats = await client.stats.get(); console.log(ws.name, stats); } ``` # Lists and pagination (/smart-api/sdk/pagination) List methods return one page at a time. To read a whole list, iterate it with `for await`. The SDK requests the next page when you reach the end of the current one. ```ts for await (const lead of swarmhit.leads.list({ search: 'acme' })) { console.log(lead.firstName, lead.company); } ``` You can stop at any time with `break`. The SDK then requests no further page. ## Working with one page [#working-with-one-page] `await` the call to get a single page: ```ts const page = await swarmhit.leads.list({ search: 'acme', limit: 100 }); page.data; // the leads on this page page.hasNextPage(); // true when the page is full if (page.hasNextPage()) { const next = await page.getNextPage(); } ``` To go through whole pages rather than single items, use `iterPages()`: ```ts for await (const p of page.iterPages()) { await saveBatch(p.data); // up to 100 at a time } ``` ## Page size and the end of a list [#page-size-and-the-end-of-a-list] * `limit` sets the page size: 1 to 100, 50 by default. A larger value is treated as 100, and `page.paging.limit` says how many the API applied. * The API pages by position (`offset`) and doesn't say whether more items follow. So `hasNextPage()` is true when a page is full, and the read ends on the first shorter or empty page: a list whose size is an exact multiple of `limit` ends with one empty request. * Pages are read by position. A list that changes while you read it (a chat that gets a new message moves to the top, a record deleted or added) can make one item appear twice or be skipped. Deduplicate by id when that matters. ## Database searches and post comments [#database-searches-and-post-comments] People and company searches in the B2B database, and a post's comments, page with a cursor instead of an offset. You use them the same way, with `for await` or `getNextPage()`. The SDK passes the cursor along and sends the same filters for every page. Every database result costs credits. Set a `limit`, and `break` out of the loop once you have enough instead of iterating a broad search to the end. ```ts const people = []; for await (const person of swarmhit.database.people.search({ filters: { titles: ['CTO'] }, limit: 25 })) { people.push(person); if (people.length === 100) break; } ``` ## Lists that are not paged [#lists-that-are-not-paged] A few short lists return everything at once, without pages: * The reference data: `swarmhit.countries.list()`, `swarmhit.timezones.list()` and `swarmhit.voices.list()`. * An organization's workspaces (`swarmhit.workspaces.list()`) and a workspace's members (`swarmhit.workspaces.members.list(workspaceId)`), and your managed domains (`swarmhit.managedDomains.list()`). # Recipes (/smart-api/sdk/recipes) Each recipe is a working script. Names such as `senderId`, `campaignId` or `crm` stand for your own values and code. Every recipe assumes a client created with your key: ```ts import Swarmhit from '@swarmhit/swarmhit-sdk'; const swarmhit = new Swarmhit(); // reads SWARMHIT_API_KEY ``` ## Launch a LinkedIn campaign [#launch-a-linkedin-campaign] Pick the connected senders, create the campaign with its sequence and schedule, add leads, then start it. ```ts const senders = []; for await (const account of swarmhit.accounts.list()) { if (account.status === 'active') senders.push(account.id); } const campaign = await swarmhit.campaigns.create({ name: 'Founders, Q4', linkedinAccountIds: senders, schedule: { timezone: 'Europe/Paris', activeHours: { from: '09:00', to: '18:00' }, activeDays: [1, 2, 3, 4, 5], // Monday to Friday }, sequence: { steps: [ { type: 'visit_profile', nextDelayHours: 24 }, { type: 'invite', config: { note: 'Hi {{firstName}}, I enjoyed your post about {{company}}.', fallbackNote: 'Hi, I enjoyed your recent post.', // for leads missing a variable }, branchDelays: { no: 336 }, // give up after 14 days without an answer }, { type: 'message', config: { text: 'Thanks for connecting, {{firstName}}!', fallbackText: 'Thanks for connecting!' } }, ], }, }); await swarmhit.campaigns.leads.add(campaign.id, { leads: [ { profileUrl: 'https://www.linkedin.com/in/ada-lovelace', firstName: 'Ada', company: 'Analytical Engines' }, { profileUrl: 'https://www.linkedin.com/in/grace-hopper', firstName: 'Grace', company: 'Navy' }, ], }); await swarmhit.campaigns.update(campaign.id, { status: 'active' }); ``` A campaign is created paused, so nothing is sent until you activate it. Activation checks the sequence: every message using a `{{variable}}` needs its fallback. The [Campaigns guide](/smart-api/campaigns) explains every step type and setting. ## Import a LinkedIn search into a campaign [#import-a-linkedin-search-into-a-campaign] Run a LinkedIn or Sales Navigator search through one of your senders, and enroll everyone it finds in a campaign: ```ts const leadImport = await swarmhit.leadImports.create({ accountId: senderId, url: 'https://www.linkedin.com/search/results/people/?keywords=founder%20saas', campaignId: campaign.id, maxLeads: 500, tags: ['saas-founders'], }); // Imports run in the background. Poll it, or listen for the // lead_import.completed webhook instead. let current = leadImport; while (current.status === 'pending' || current.status === 'running') { await new Promise((resolve) => setTimeout(resolve, 30_000)); current = await swarmhit.leadImports.get(leadImport.id); } console.log(`${current.status}: ${current.progress.importedLeads} leads imported`); ``` ## React to replies [#react-to-replies] When a lead replies, tag them, mark their interest and alert your team. This handler runs inside a webhook endpoint (see [Receiving webhooks](/smart-api/sdk/webhooks)): ```ts import type { WebhookEvent } from '@swarmhit/swarmhit-sdk'; async function onEvent(event: WebhookEvent) { if (event.event !== 'message.received' || !event.data.leadId) return; const { campaignId, leadId, message, from } = event.data; // tags replaces the lead's manual tags, so keep the ones it has. const lead = await swarmhit.leads.get(leadId); await swarmhit.leads.update(leadId, { tags: [...(lead.tags ?? []), 'replied'] }); if (campaignId) { await swarmhit.campaigns.leads.setInterest(campaignId, leadId, { interest: 'interested' }); } await postToSlack(`${from.firstName} ${from.lastName} replied: ${message.text}`); } ``` A reply stops the lead's sequence on its own, so you don't need to pause it. ## Sync campaign results to your CRM [#sync-campaign-results-to-your-crm] Read every lead that replied, with its profile, and upsert it in your CRM. Run it on a schedule: ```ts for await (const row of swarmhit.campaigns.leads.list(campaignId, { status: 'replied' })) { if (!row.leadId) continue; const lead = await swarmhit.leads.get(row.leadId); await crm.upsertContact({ linkedin: lead.profileUrl, email: lead.email, name: `${lead.firstName} ${lead.lastName}`, company: lead.company, stage: row.interest === 'interested' ? 'opportunity' : 'contacted', }); } ``` ## Send a one-off invitation or message [#send-a-one-off-invitation-or-message] Outside any campaign, you can act on one profile through one sender. The idempotency key makes the call safe to repeat: run the script twice within 24 hours and the person still gets one invitation. ```ts await swarmhit.accounts.connections.send( senderId, { profileUrl: 'https://www.linkedin.com/in/ada-lovelace', note: 'Hi Ada, loved your talk!' }, { idempotencyKey: 'invite-ada-2026-10' }, ); // Later, once connected: await swarmhit.accounts.messages.send(senderId, { profileUrl: 'https://www.linkedin.com/in/ada-lovelace', text: 'Thanks for accepting, Ada!', }); ``` These actions count against the sender's daily limits, like campaign steps. See [Direct actions](/smart-api/direct-actions). ## Build a list from the B2B database [#build-a-list-from-the-b2b-database] Find people by title and company size, save them to a list, and add the list to a campaign: ```ts const list = await swarmhit.lists.create({ name: 'CTOs, 50-200 people' }); const leads = []; for await (const person of swarmhit.database.people.search({ filters: { titles: ['CTO'], headcountRanges: ['51-200'] }, limit: 50, })) { if (person.linkedinUrl) { leads.push({ profileUrl: person.linkedinUrl, firstName: person.firstName, lastName: person.lastName, company: person.company?.name ?? undefined }); } if (leads.length === 200) break; // each result returned uses credits } await swarmhit.lists.leads.add(list.id, { leads }); await swarmhit.campaigns.leads.add(campaign.id, { listId: list.id }); ``` ## Check sender health every morning [#check-sender-health-every-morning] ```ts for await (const account of swarmhit.accounts.list()) { if (account.status !== 'active') { await alert(`${account.fullName}: ${account.status} (${account.error ?? 'no detail'})`); continue; } const stats = await swarmhit.accounts.getStats(account.id); console.log(account.fullName, stats.limits); } ``` Listen for the `account.status_changed` and `account.paused` webhooks to hear about problems as they happen instead. # Retries, timeouts and idempotency (/smart-api/sdk/retries) Networks fail and servers get busy. The SDK retries what is safe to retry, so a brief outage does not reach your code. It never makes a request run twice. ## What is retried [#what-is-retried] The SDK retries a request that is safe to repeat (see [Never twice](#never-twice)) up to `maxRetries` times, 2 by default, when: * it got no answer: a network error, a DNS or TLS failure, or the timeout ran out; * the API answered `408`, `500`, `502`, `503` or `504`; * the API answered `429` and said how long to wait (`Retry-After`); * the API answered `409` with the code `idempotency_in_progress`: an earlier attempt with the same idempotency key is still running. The SDK asks again after 5 seconds. Between attempts, the SDK waits 0.5 s, then 1 s, then 2 s, doubling up to 8 s. It adds some randomness so that many clients don't retry at the same moment. When the API says how long to wait (`Retry-After`), the SDK waits exactly that, if it is a minute or less. If the wait is longer, it throws the error at once for you to schedule. A `429` without `Retry-After` is thrown at once. It comes from a sender's limit or from LinkedIn throttling the sender: retrying within seconds would not help, and with LinkedIn it makes things worse. Other `4xx` answers are not retried: the same request would fail the same way. ## Never twice [#never-twice] Retrying is only safe if the retry cannot repeat the action. The SDK sorts every method into one of three groups: * **Reads, updates and deletes** are retried as they are. Doing them twice gives the same result as doing them once. * **The endpoints that take an `Idempotency-Key`**: one-off LinkedIn actions (messages, InMails, invitations, profile visits), sending an email or an inbox reply, and mailbox orders. The SDK generates a key and sends it with every attempt. The API answers a retry with the first result, so nothing is sent or charged twice. [All methods](/smart-api/sdk/methods) marks these methods *Retry-safe*. * **Any other POST**, such as creating a campaign or adding leads, is not retried. After a dropped connection it may have gone through, so the SDK throws and you decide. ## Your own idempotency keys [#your-own-idempotency-keys] The SDK's keys protect only the retries within one call. Your own code may repeat a call too: a job runs again after a crash, or a queue delivers a message twice. To make those repeats safe, pass a key derived from your data: ```ts await swarmhit.accounts.messages.send( senderId, { profileUrl: lead.profileUrl, text: `Welcome aboard, ${lead.firstName}!` }, { idempotencyKey: `welcome-${lead.id}` }, ); ``` A key is scoped to the endpoint and the account (or chat) it acts on, and kept for 24 hours. When you send a key again: * **The first attempt succeeded**: the API returns its result instead of sending a second message. The body is not compared, so a reused key replays the first response even if the body differs: use a new key for a new message. * **The first attempt is still running**: the API answers `409` `idempotency_in_progress`, and the SDK waits and asks again. * **The first attempt was refused with a `4xx`**: the key is freed, and the request runs again. Except on an inbox reply refused with a `400` that passes on a LinkedIn, WhatsApp or mail provider failure (code `provider-unreachable`, `provider-408`, or `provider-` and a 5xx status): the reply may have gone out, so it is treated like a server error below, unless the request provably never reached the provider. * **The first attempt ended in a server error**, such as LinkedIn failing mid-way: the API answers `409` `idempotency_failed` rather than risk a duplicate, as its outcome couldn't be confirmed. Check whether the action went through before you try again with a new key. A send can outlast the timeout: LinkedIn is slow at times, and the API keeps going after the SDK gave up. If it still runs when the retries are used up, the error carries the key. Ask again with it later, and the API answers with the first attempt's outcome instead of sending twice: ```ts try { await swarmhit.accounts.messages.send(senderId, { profileUrl, text }); } catch (err) { if (err instanceof SwarmhitError && err.idempotencyKey) { // later: the same call, with { idempotencyKey: err.idempotencyKey } } } ``` ## Timeouts [#timeouts] `timeout` is the time one attempt may take: 60 seconds by default. The SDK retries a request that times out, like a network error, when it is safe to. So a call can take up to about `timeout × (maxRetries + 1)`, plus the waits between attempts. ```ts const swarmhit = new Swarmhit({ timeout: 20_000, maxRetries: 3 }); ``` ## Request options [#request-options] Every method takes these as its last argument, for that one request: ```ts await swarmhit.accounts.getStats(accountId, { days: 90 }, { timeout: 120_000, // ms per attempt maxRetries: 0, // fail fast signal: AbortSignal.timeout(30_000), // cancel the whole call, retries included idempotencyKey: 'my-key', // on endpoints that accept one headers: { 'X-Correlation-Id': correlationId }, workspace: 'ws_123', // see Organizations }); ``` A `signal` cancels the call wherever it is, including between retries. The call then rejects with `APIUserAbortError`. # Versions and runtimes (/smart-api/sdk/versions) ## Runtimes [#runtimes] | Runtime | Supported | | ------------------------------- | ----------------------------------------------------------------------------------- | | Node.js | 20 and later | | Bun, Deno | Current releases | | Cloudflare Workers, Vercel Edge | Yes | | Browsers | No: call the API from your server (see [Browsers and apps](/smart-api/sdk/browser)) | The package has no dependencies. It ships ES modules and CommonJS, with TypeScript types for both. ## Versions [#versions] The SDK follows semantic versioning: * **1.x** calls the API's **v1** (`https://app.swarmhit.com/api/v1`). * A minor or patch release adds methods, fields and fixes. It never breaks your code. * A new major version of the API will come with a new major version of the SDK. Within v1, the API only adds things: endpoints, fields and enum values. So the SDK's types accept enum values they do not know yet, and a new field your code does not read never gets in the way. When you pass a [logger](/smart-api/sdk/configuration#logging), the SDK logs each method the API marks as deprecated, once, with its sunset date. The SDK is in beta until 1.0.0. The [changelog](https://www.npmjs.com/package/@swarmhit/swarmhit-sdk?activeTab=versions) lists every release. ## Coming from the REST API [#coming-from-the-rest-api] The SDK calls the same `/api/v1` endpoints, with the same API key, and answers with the same fields, minus the `{ "data": … }` envelope. You can move an integration to it one call at a time. # Receiving webhooks (/smart-api/sdk/webhooks) Webhooks tell your server when something happens in Swarmhit: a lead replied, an invitation was accepted, a sender was paused. The SDK creates endpoints and checks that each delivery really comes from Swarmhit. The [Webhooks guide](/smart-api/webhooks) lists every event and how deliveries are retried. ## Create an endpoint [#create-an-endpoint] ```ts const webhook = await swarmhit.webhooks.create({ url: 'https://example.com/hooks/swarmhit', events: ['message.received', 'connection.accepted', 'campaign.finished'], }); console.log(webhook.secret); // whsec_… store it as SWARMHIT_WEBHOOK_SECRET ``` The URL must be reachable from the public internet. Use HTTPS. To send a test delivery, call `swarmhit.webhooks.test(webhook.id)`. ## Verify every delivery [#verify-every-delivery] Anyone can send a request to your URL, so check its signature before you trust it. `swarmhit.webhooks.unwrap` verifies the delivery and returns the parsed event. It throws a `WebhookVerificationError` if the signature is missing or wrong. The signature is the `X-Swarmhit-Signature` header: an HMAC-SHA256 hex digest of the raw body, keyed by the endpoint's signing secret (the whole `whsec_…` string). Verify the **raw body**, exactly as it arrived. If a framework has already parsed the JSON, the bytes have changed and the signature will not match. ```ts import express from 'express'; import Swarmhit from '@swarmhit/swarmhit-sdk'; const swarmhit = new Swarmhit(); const app = express(); // express.raw keeps the body as bytes for this route. app.post('/hooks/swarmhit', express.raw({ type: 'application/json' }), async (req, res) => { let event; try { event = await swarmhit.webhooks.unwrap(req.body, req.headers, { secret: process.env.SWARMHIT_WEBHOOK_SECRET!, }); } catch { return res.sendStatus(400); } if (event.event === 'message.received') { console.log(`${event.data.from.firstName} replied: ${event.data.message.text}`); } res.sendStatus(200); }); ``` ```ts title="app/hooks/swarmhit/route.ts" import Swarmhit from '@swarmhit/swarmhit-sdk'; const swarmhit = new Swarmhit(); export async function POST(request: Request) { let event; try { event = await swarmhit.webhooks.unwrap(await request.text(), request.headers, { secret: process.env.SWARMHIT_WEBHOOK_SECRET!, }); } catch { return new Response('invalid signature', { status: 400 }); } if (event.event === 'connection.accepted') { // … } return new Response('ok'); } ``` A receiver that makes no API calls can import only the verification code, without the client: ```ts import { unwrapWebhook } from '@swarmhit/swarmhit-sdk/webhooks'; export default { async fetch(request: Request, env: { SWARMHIT_WEBHOOK_SECRET: string }) { try { const event = await unwrapWebhook(await request.text(), request.headers, { secret: env.SWARMHIT_WEBHOOK_SECRET, }); // handle event.event … return new Response('ok'); } catch { return new Response('invalid signature', { status: 400 }); } }, }; ``` Answer with a `2xx` quickly, and do slow work in the background. Delivery is at-least-once: Swarmhit retries a delivery that fails or times out, so the same event may reach you twice. Each delivery has a stable id in its `X-Swarmhit-Delivery` header. Store the ids you have handled, to skip duplicates. The signature carries no timestamp, so it can't tell a replayed delivery from a new one: deduplicating on `X-Swarmhit-Delivery` covers that too. ## Typed events [#typed-events] `unwrap` returns a `WebhookEvent`: a union of every event type. Checking `event.event` narrows it, so `event.data` has the right fields for that event: ```ts import type { WebhookEvent } from '@swarmhit/swarmhit-sdk'; function handle(event: WebhookEvent) { switch (event.event) { case 'message.received': return saveReply(event.data.leadId, event.data.message.text); case 'campaign.finished': return notifyTeam(event.data.campaignId); default: return; // an event you don't handle, or one added later } } ``` To check a signature without parsing the body, use `swarmhit.webhooks.verify(…)`, or `verifyWebhook` from `@swarmhit/swarmhit-sdk/webhooks`. It takes the same arguments and throws on failure. # API info (/smart-api/reference/general/getApiInfo) **JavaScript SDK:** [`swarmhit.info()`](/smart-api/sdk/methods#client) Returns API metadata and the workspace the request acts on (the key's own, or the one `X-Swarmhit-Workspace` names), including its effective plan, seat usage per channel, whether it is an organization or a workspace inside one, its tracking domain and this period's email allowance. Handy for verifying a key works and what a header resolves to. # Service health (/smart-api/reference/general/getHealth) **JavaScript SDK:** [`swarmhit.health()`](/smart-api/sdk/methods#client) Public service health check. This is the only endpoint that does not require an API key, so uptime monitors get a real status code instead of the `401` every other path returns without one. Returns `200` when the API is serving and its database is reachable, and `503` when it is not. The body carries no workspace or account data. Intended for external monitoring, not for polling before each request. Results are cached briefly, so a burst of calls returns the same verdict. # Workspace stats (/smart-api/reference/general/getStats) **JavaScript SDK:** [`swarmhit.stats.get()`](/smart-api/sdk/methods#stats) Campaign, lead-progress and sender-account counts broken down by status (plus a paused-sender count), plus outreach totals. # List workspaces (/smart-api/reference/organization/listWorkspaces) **JavaScript SDK:** [`swarmhit.workspaces.list()`](/smart-api/sdk/methods#workspaces) For an organization workspace, lists the workspaces inside it with each one's seat allocation, connected senders and member count, plus the organization's seat pool (`pool` / `allocated` / `available`). When the connected workspace is not an organization, returns `isOrganization: false` and an empty `workspaces` list. # Create a workspace (/smart-api/reference/organization/createWorkspace) **JavaScript SDK:** [`swarmhit.workspaces.create(params)`](/smart-api/sdk/methods#workspaces) Creates a workspace inside the connected organization. Optionally grant it seats from the organization pool with `seatAllocation`. Allocating more than the pool has available is rejected with `400`, unless the organization's seats are added on demand (pay as you go): then the missing seats are added to the subscription instead. Requires the connected workspace to be an organization (otherwise `403`). # Get a workspace (/smart-api/reference/organization/getWorkspace) **JavaScript SDK:** [`swarmhit.workspaces.get(workspaceId)`](/smart-api/sdk/methods#workspaces) Returns one workspace inside the connected organization, including its members, the organization managers granted access via the organization (read-only), and pending invites. # Update a workspace (/smart-api/reference/organization/updateWorkspace) **JavaScript SDK:** [`swarmhit.workspaces.update(workspaceId, params)`](/smart-api/sdk/methods#workspaces) Updates any subset of a workspace's `name`, `avatar` (empty string clears it) and `seatAllocation`. Lowering the allocation below the seats the workspace already uses (its LinkedIn sender accounts) is rejected with `400`, and so is over-allocating the organization pool, unless the organization's seats are added on demand (pay as you go): then the missing seats are added to the subscription instead. # Delete a workspace (/smart-api/reference/organization/deleteWorkspace) **JavaScript SDK:** [`swarmhit.workspaces.delete(workspaceId)`](/smart-api/sdk/methods#workspaces) Removes the workspace from the organization. Rejected with `400` while it still has connected sender accounts or mailboxes, disconnect them first. # List a workspace's members (/smart-api/reference/organization/listWorkspaceMembers) **JavaScript SDK:** [`swarmhit.workspaces.members.list(workspaceId)`](/smart-api/sdk/methods#workspaces.members) The workspace's own `members` and pending `invites`, plus the read-only `orgMembers` (organization owners/admins granted access to the workspace). # Invite a member to a workspace (/smart-api/reference/organization/inviteWorkspaceMember) **JavaScript SDK:** [`swarmhit.workspaces.members.invite(workspaceId, params)`](/smart-api/sdk/methods#workspaces.members) Sends an invite (by email) into the workspace. Role defaults to `member`. # Update a workspace member's role (/smart-api/reference/organization/updateWorkspaceMember) **JavaScript SDK:** [`swarmhit.workspaces.members.update(workspaceId, memberId, params)`](/smart-api/sdk/methods#workspaces.members) Change the role of a workspace member or a pending invite. Set `type` to `user` (default; `memberId` is the user id) or `invite` (`memberId` is the invite email). Organization members cannot be changed here. # Remove a workspace member or revoke an invite (/smart-api/reference/organization/removeWorkspaceMember) **JavaScript SDK:** [`swarmhit.workspaces.members.remove(workspaceId, memberId, params?)`](/smart-api/sdk/methods#workspaces.members) Removes a member from the workspace, or revokes a pending invite. Pass `?type=invite` (with the invite email as `memberId`) to revoke an invite; `type` defaults to `user`. The workspace's owner, organization members and the last admin cannot be removed here (400). # List campaigns (/smart-api/reference/campaigns/listCampaigns) **JavaScript SDK:** [`swarmhit.campaigns.list(params?)`](/smart-api/sdk/methods#campaigns) # Create a campaign (/smart-api/reference/campaigns/createCampaign) **JavaScript SDK:** [`swarmhit.campaigns.create(params?)`](/smart-api/sdk/methods#campaigns) Creates a campaign (paused by default). You can set everything up front: the sender pool (`linkedinAccountIds`), the outreach `sequence` (its steps), the send `schedule`, `aiInterestEnabled`, the email `unsubscribe` line and `tracking`, the lead-exclusion rules (`excludeListIds` plus the `exclude*` flags), and the initial `status`. Anything omitted falls back to a sensible default (empty sequence, default 9-19 Mon-Fri schedule, paused). Then add leads with `POST /campaigns/{id}/leads` and set `status` to `active`. Created `active`, it needs a sequence with something to send in every step, or it is a `400` with `code: empty-step`, and its senders are checked as on activation (see `PATCH /campaigns/{id}`). Sequence wiring: omit `id`/`parent`/`branch` on every step to have them auto-wired into a linear chain in array order (each step attaches to the previous step's natural continuation — the "yes" branch of a connection request or condition, but the "no"/still-no-reply branch after a message, voice note, InMail or email since their "yes"/Replied branch ends the sequence, otherwise its single child). To build a branching tree, give each step a stable `id` and set `parent`/`branch`. Once the lead is connected — the "yes" branch of a connection request (`invite`, "Accepted") or of an `if_connected` condition ("Connected") — the `invite`, `inmail`, `if_connected` and `if_open_profile` steps can't sit below it (the lead is already connected, so a connection request / InMail can't fire and a repeat connected / open-profile check is redundant). To send an InMail only when a request goes unanswered, wire it onto the invite's "no" (Still not accepted) branch, not the linear/"yes" continuation. The `inmail` step's `config.whenUnable` (`auto` | `pause` | `skip`, default `auto`) picks what happens when the InMail can't be sent (the sender has no premium LinkedIn plan, or it is out of InMail credits and cannot InMail this lead for free; only Sales Navigator and Recruiter senders InMail open profiles free): `auto` pauses the sender when out of credits and otherwise continues to the still-no-reply branch, `pause` always pauses the sender, `skip` always continues. # Get a campaign (/smart-api/reference/campaigns/getCampaign) **JavaScript SDK:** [`swarmhit.campaigns.get(campaignId)`](/smart-api/sdk/methods#campaigns) Returns the campaign including its outreach `sequence` (the tree of steps). # Update a campaign (/smart-api/reference/campaigns/updateCampaign) **JavaScript SDK:** [`swarmhit.campaigns.update(campaignId, params)`](/smart-api/sdk/methods#campaigns) Updates any subset of a campaign's writable fields: `name`, `status` (set `active` to start sending, `paused` to stop), the sender pool (`linkedinAccountIds`), the outreach `sequence`, the send `schedule`, `aiInterestEnabled`, the email `unsubscribe` line and `tracking`, and the lead-exclusion rules (`excludeListIds` plus the `exclude*` flags). `sequence` and `schedule` are replaced wholesale — send the complete object you want, not a partial patch of it. A full `sequence` replacement can update the content of any step or A/B variant. For variant A, keep the step's legacy flat copy/media fields identical to the corresponding values in `config.variants[0]`; conflicting values are rejected with `400`. Updating a later variant only requires changing that variant object. For a single existing step or variant, prefer `PATCH /campaigns/{id}/sequence/{stepId}` so unrelated sequence structure cannot be overwritten. Once a campaign has leads in progress, the `sequence` may only be edited safely: you can change step messages and delays and append new steps, but removing, reordering or retyping existing steps is rejected with `400` (a lead's progress is pinned to a step id). Duplicate the campaign to rework its sequence from scratch. An `active` campaign must keep at least one sender: sending an empty `linkedinAccountIds` while the campaign is (or is being set) `active` is rejected with `400`. A campaign won't go `active` while a step has nothing to send: a `message`, `inmail`, `email` or `whatsapp_message` variant with no text (a file alone will do on a `message` or `whatsapp_message`), a `voice_note` without its recording, an `ai_voice_note` without its script, or the first `email` of a path without a subject. That is a `400` with `code: empty-step`: `message` names the steps by their number in the sequence, and `details.steps` lists them (`[{ id, type }]`) to fix with `PATCH /campaigns/{id}/sequence/{stepId}`. Once a campaign has been launched, a `sequence` that empties a step is refused the same way. With accounts of senders in its pools, a campaign won't go `active`, nor an active one change its pools, while a sender (or an account without one on the primary channel: LinkedIn when the sequence has a LinkedIn step or no step, else email, else WhatsApp) has no account on a channel the sequence uses and no account without a sender is there to share: a `400` with `code: sender-no-mailbox` or `sender-no-whatsapp`. Attaching senders with `senderIds` has its own refusals, listed there. # Delete a campaign (/smart-api/reference/campaigns/deleteCampaign) **JavaScript SDK:** [`swarmhit.campaigns.delete(campaignId)`](/smart-api/sdk/methods#campaigns) Permanently deletes the campaign and removes every lead's membership in it. This cannot be undone. # Duplicate a campaign (/smart-api/reference/campaigns/duplicateCampaign) **JavaScript SDK:** [`swarmhit.campaigns.duplicate(campaignId)`](/smart-api/sdk/methods#campaigns) Clones the campaign's blueprint — name (suffixed " (copy)"), outreach `sequence`, send `schedule`, sender pool (`linkedinAccountIds`), mailbox pool (`emailAccountIds`), the lead-exclusion rules and the email and AI settings (`unsubscribe`, `tracking`, `aiInterestEnabled`) — into a fresh campaign that starts `paused` with no leads and zeroed stats. Use it to rework a `sequence` that's locked because leads are already in progress, or to spin up a variant of a working campaign. # Update one campaign sequence step (/smart-api/reference/campaigns/updateCampaignStep) **JavaScript SDK:** [`swarmhit.campaigns.updateStep(campaignId, stepId, params)`](/smart-api/sdk/methods#campaigns) Partially updates one existing sequence step's copy and/or media while preserving its id, wiring, delays and other A/B variants. Use the step and variant ids returned by `GET /campaigns/{id}`. When `variantId` is omitted, variant A is updated; pass another existing `variantId` to update that variant without changing variant A. This endpoint is preferred over replacing the full sequence for copy, attachment and recorded-voice edits, except for a recorded voice note that records per sender: those recordings are replaced through the campaign's sequence. On a launched campaign, an update that leaves the step with nothing to send is a `400` with `code: empty-step` (see `PATCH /campaigns/{id}`). # Rebalance a sender's leads (/smart-api/reference/campaigns/rebalanceCampaignSender) **JavaScript SDK:** [`swarmhit.campaigns.rebalanceSender(campaignId, accountId)`](/smart-api/sdk/methods#campaigns) Move a paused or failed sender's not-yet-contacted leads in this campaign onto the campaign's other available senders, evenly. Leads the sender already contacted stay pinned to it. Returns how many leads `moved` and how many `receivers` took them; `receivers: 0` means no other sender was available and nothing moved (still `200`). `422` when the account is not in the campaign's sender pool. # List the leads in a campaign (/smart-api/reference/leads-in-campaigns/listLeadsInCampaign) **JavaScript SDK:** [`swarmhit.campaigns.leads.list(campaignId, params?)`](/smart-api/sdk/methods#campaigns.leads) The leads in a campaign and each one's progress (stage, step, invite sent/accepted, replied, label), newest first. The filters combine: for example `stage=invited&accountId=…` lists the leads whose connection request from that account waits for an answer, and `reachedStepId=…&label=interested` the interested leads among those a step reached. Step ids are the `sequence.steps[].id` of `GET /campaigns/{id}`. # Add leads to a campaign (/smart-api/reference/leads-in-campaigns/addLeadsToCampaign) **JavaScript SDK:** [`swarmhit.campaigns.leads.add(campaignId, params)`](/smart-api/sdk/methods#campaigns.leads) Adds leads from a `leads` array of `{ profileUrl, ... }` objects (or `{ email, ... }` when the workspace has the email channel, `{ phone, ... }` with the WhatsApp channel, see `LeadInput`), or imports a whole lead list with `listId`. The workspace lead is upserted from the fields you pass and enrolled in one call — you don't need to create leads separately first. Adding makes no LinkedIn call; at first contact, LinkedIn's `firstName`, `lastName`, `headline` and picture replace the values you sent (other fields are kept). Leads already in the campaign are skipped, and so are items with neither a valid `profileUrl` nor (with the email channel) a valid `email` nor (with the WhatsApp channel) a `phone` with its country code. Leads on the workspace blocklist are not enrolled, nor left on the campaign's own list, and count as `blocklisted`; leads an exclusion rule keeps out are not enrolled and count as `excluded`. Max 1000 leads per request. A `leads` array returns `{ inserted, excluded, blocklisted, listId }` (skipped items are not counted); a `listId` import returns `{ inserted, skipped, excluded, blocklisted, listId, listName }`, where `skipped` counts the list's leads already in the campaign. Every enrolled lead is also saved to the campaign's own list, created on the first enrollment and named "'s leads" (exposed as `listId` on the campaign). That list and the campaign stay in step: adding leads to it enrolls them, removing leads from it removes them from the campaign. Leads imported with `listId` keep their source list too. The response's `listId` is the campaign's own list for a `leads` array, or the source list for a `listId` import. # A lead's progress in a campaign (/smart-api/reference/leads-in-campaigns/getLeadInCampaign) **JavaScript SDK:** [`swarmhit.campaigns.leads.get(campaignId, leadId)`](/smart-api/sdk/methods#campaigns.leads) Returns the lead's progress in this campaign, including its full step `history`. # Set a lead's label in a campaign (/smart-api/reference/leads-in-campaigns/setLeadInterest) **JavaScript SDK:** [`swarmhit.campaigns.leads.setInterest(campaignId, leadId, params)`](/smart-api/sdk/methods#campaigns.leads) Sets the lead's `label` in this campaign, or clears it with `null`. A label set here is never changed by AI; with none, AI labels the lead's next reply (when the campaign has `aiInterestEnabled`). `interest` still works as before labels: `interested` or `not_interested`, and `null` clears the label. Pass one of the two. Only the label is editable here. Update lead fields and `customVariables` via `PATCH /leads/{id}` — they're shared across every campaign the lead is in. # Remove a lead from a campaign (/smart-api/reference/leads-in-campaigns/removeLeadFromCampaign) **JavaScript SDK:** [`swarmhit.campaigns.leads.remove(campaignId, leadId)`](/smart-api/sdk/methods#campaigns.leads) Removes the lead from this campaign and from the campaign's own list. The underlying lead is kept. # Retry a lead in a campaign (/smart-api/reference/leads-in-campaigns/retryLeadInCampaign) **JavaScript SDK:** [`swarmhit.campaigns.leads.retry(campaignId, leadId)`](/smart-api/sdk/methods#campaigns.leads) Re-queues a lead for the executor, clearing its error and wait. Meant for a `failed` or stuck lead: the status is not checked, so any lead is put back in the queue. # Pause a lead in a campaign (/smart-api/reference/leads-in-campaigns/pauseLeadInCampaign) **JavaScript SDK:** [`swarmhit.campaigns.leads.pause(campaignId, leadId)`](/smart-api/sdk/methods#campaigns.leads) Freezes the lead's sequence so the executor stops contacting it (status becomes `paused`). It is resumable: step progress and timers are kept, so resuming continues from where it left off. Applies only to a `pending` or `running` lead; on any other it changes nothing and still returns 200. A manual message does not pause a lead by itself: to pause when replying from the inbox, send it with `sequenceAction: pause`. # Resume a paused lead in a campaign (/smart-api/reference/leads-in-campaigns/resumeLeadInCampaign) **JavaScript SDK:** [`swarmhit.campaigns.leads.resume(campaignId, leadId)`](/smart-api/sdk/methods#campaigns.leads) Moves a `paused` lead back to `pending`, continuing its sequence from where it left off. A lead waiting on an out-of-office reply (`waitReason: out_of_office`) goes at the campaign's next sending time instead. No-op on any other lead. # Change when a lead out of office goes again (/smart-api/reference/leads-in-campaigns/rescheduleLeadInCampaign) **JavaScript SDK:** [`swarmhit.campaigns.leads.reschedule(campaignId, leadId, params)`](/smart-api/sdk/methods#campaigns.leads) For a lead waiting on an out-of-office reply (`waitReason: out_of_office`, see the campaign's `outOfOffice`). A `waitUntil` date holds its next step until the campaign's first sending time from then on; `null` lets it go at the next sending time and ends the wait. Refused with `400` for a lead that isn't waiting out of office, and for a `paused` one: resume it first (it then keeps waiting until its return date), then change the wait. # Stop a lead in a campaign for good (/smart-api/reference/leads-in-campaigns/stopLeadInCampaign) **JavaScript SDK:** [`swarmhit.campaigns.leads.stop(campaignId, leadId)`](/smart-api/sdk/methods#campaigns.leads) Terminally stops the lead's sequence (status becomes `stopped`) so the executor never contacts it again. The lead stays in the campaign with its history intact; use `DELETE /campaigns/{id}/leads/{leadId}` to remove it entirely. Not resumable. No-op on a lead that is already done, failed or replied. # List leads (/smart-api/reference/leads/listLeads) **JavaScript SDK:** [`swarmhit.leads.list(params?)`](/smart-api/sdk/methods#leads) # Find emails or phones for many leads (/smart-api/reference/leads/enrichLeads) **JavaScript SDK:** [`swarmhit.leads.enrichMany(params)`](/smart-api/sdk/methods#leads) Queues a work email and/or mobile phone lookup for each of the given leads (`leadIds`, up to 5000) or every lead of a list (`listId`) and returns at once. A lead is skipped when it already has the value, was looked up before with nothing found, or has nothing to look it up by (a phone needs a LinkedIn profile; an email needs one, or a first name, last name and company). Lookups run in the background, usually within minutes: each result lands on the lead (`email` / `phone`, and `enrichment..status`) and fires `lead.enriched`. Charged per result found, at the Email finder or Phone finder price (Settings → Credits usage, for owners and admins); nothing for a miss. Out of credits is a 400 with `code: warning-no-credits` when asking, and a lookup reached after the credits ran out ends `failed` with `reason: no_credits`. # Get a lead (/smart-api/reference/leads/getLead) **JavaScript SDK:** [`swarmhit.leads.get(leadId)`](/smart-api/sdk/methods#leads) # Update a lead (/smart-api/reference/leads/updateLead) **JavaScript SDK:** [`swarmhit.leads.update(leadId, params)`](/smart-api/sdk/methods#leads) Edit a lead's fields and/or replace its manual `tags`. # Delete a lead (/smart-api/reference/leads/deleteLead) **JavaScript SDK:** [`swarmhit.leads.delete(leadId)`](/smart-api/sdk/methods#leads) # Get a lead's activity (/smart-api/reference/leads/getLeadActivity) **JavaScript SDK:** [`swarmhit.leads.getActivity(leadId)`](/smart-api/sdk/methods#leads) The lead's campaign timeline across every campaign it is in, newest first and capped at 200 events: step results and events such as `invite_sent` (with `result.noteDropped` when it went without its note), `invite_note_sent` (that note sent as a message once they accepted), `invite_accepted`, `reply_received`, `paused`, `interest_changed` (a label that changes `interest`) or `label_changed` (one that leaves it as it was). Inbox messages are not part of it; read them through the inbox endpoints. # Find a lead's email or phone (/smart-api/reference/leads/enrichLead) **JavaScript SDK:** [`swarmhit.leads.enrich(leadId, params?)`](/smart-api/sdk/methods#leads) Looks up the lead's work email (by default) and/or mobile phone now and returns the lead with what was found saved on it. A value the lead already has is kept, and not charged or looked up again. A hit usually answers in a couple of seconds; a miss only once every source was tried, which can take up to a minute. Charged per result found, at the Email finder or Phone finder price (Settings → Credits usage, for owners and admins); nothing for a miss. `enrichment..status` says how each lookup ended. A lookup already running on this lead is a 409 with `code: lookup_running`. # List lead lists (/smart-api/reference/lists/listLists) **JavaScript SDK:** [`swarmhit.lists.list(params?)`](/smart-api/sdk/methods#lists) # Create a lead list (/smart-api/reference/lists/createList) **JavaScript SDK:** [`swarmhit.lists.create(params?)`](/smart-api/sdk/methods#lists) # Get a lead list (/smart-api/reference/lists/getList) **JavaScript SDK:** [`swarmhit.lists.get(listId)`](/smart-api/sdk/methods#lists) # Rename a lead list (/smart-api/reference/lists/updateList) **JavaScript SDK:** [`swarmhit.lists.update(listId, params)`](/smart-api/sdk/methods#lists) # Delete a lead list (/smart-api/reference/lists/deleteList) **JavaScript SDK:** [`swarmhit.lists.delete(listId)`](/smart-api/sdk/methods#lists) Deletes the list. The leads in it are kept; only their membership is removed. # List a list's leads (/smart-api/reference/lists/listListLeads) **JavaScript SDK:** [`swarmhit.lists.leads.list(listId, params?)`](/smart-api/sdk/methods#lists.leads) # Add leads to a list (/smart-api/reference/lists/addListLeads) **JavaScript SDK:** [`swarmhit.lists.leads.add(listId, params)`](/smart-api/sdk/methods#lists.leads) Upserts the workspace leads from the `{ profileUrl, ... }` objects you pass and attaches them to this list in one call — you don't need to create leads separately first. Existing leads are matched on their LinkedIn identifier and updated with any new fields. With the email channel, an item with only an `email` is matched on that address instead, and with the WhatsApp channel one with only a `phone` on that number (see `LeadInput`). Max 1000 leads per request. If the list is a campaign's own list (the campaign's `listId`), the leads are also enrolled in that campaign, subject to its exclusion rules; leads already in the campaign are skipped. `enrolled` in the response is how many were enrolled (0 for any other list). # Remove a lead from a list (/smart-api/reference/lists/removeListLead) **JavaScript SDK:** [`swarmhit.lists.leads.remove(listId, leadId)`](/smart-api/sdk/methods#lists.leads) Takes the lead off the list; the lead itself is kept. If the list is a campaign's own list (the campaign's `listId`), the lead is also removed from that campaign. 404 only for an unknown list: a lead that is not on it still answers 200. # List lead imports (/smart-api/reference/lead-imports/listLeadImports) **JavaScript SDK:** [`swarmhit.leadImports.list(params?)`](/smart-api/sdk/methods#leadImports) # Start a LinkedIn search import (/smart-api/reference/lead-imports/createLeadImport) **JavaScript SDK:** [`swarmhit.leadImports.create(params)`](/smart-api/sdk/methods#leadImports) Kicks off a paginated import from a LinkedIn / Sales Navigator search against the connected `accountId`. Returns immediately with the import `running` — the worker fetches the first page within a minute. Each stored lead counts toward the sender's daily search-import limit; when it is reached the import waits until midnight UTC and stays `running`. An unknown or disconnected `accountId`, a Sales Navigator search on a sender without Sales Navigator, or an unknown `listId` / `campaignId` is a 400 (an unknown id has `code: not-found`), as is sending both or neither of `url` and `params`. Provide **either**: - `url`: a copied LinkedIn search URL (classic or Sales Navigator), or - `params`: a structured filter blob (`{ api, category, keywords, ... }`). Imported leads land in `listId` if provided; otherwise a new list is auto-created and returned in the response. With `campaignId` instead, they land in that campaign's own list (created if it doesn't exist yet) and are enrolled in the campaign page by page, subject to its exclusion rules. # Get a lead import (/smart-api/reference/lead-imports/getLeadImport) **JavaScript SDK:** [`swarmhit.leadImports.get(leadImportId)`](/smart-api/sdk/methods#leadImports) # Delete a terminal lead import (/smart-api/reference/lead-imports/deleteLeadImport) **JavaScript SDK:** [`swarmhit.leadImports.delete(leadImportId)`](/smart-api/sdk/methods#leadImports) Only allowed when status is `completed`, `failed` or `cancelled`. # Cancel a running lead import (/smart-api/reference/lead-imports/cancelLeadImport) **JavaScript SDK:** [`swarmhit.leadImports.cancel(leadImportId)`](/smart-api/sdk/methods#leadImports) # Resume a paused or failed lead import (/smart-api/reference/lead-imports/resumeLeadImport) **JavaScript SDK:** [`swarmhit.leadImports.resume(leadImportId)`](/smart-api/sdk/methods#leadImports) # Search people in the database (/smart-api/reference/database/searchPeopleDatabase) **JavaScript SDK:** [`swarmhit.database.people.search(params)`](/smart-api/sdk/methods#database.people) Search the people database by named filters. At least one filter with a value is required (`excludeExisting` alone does not count); unknown filter keys are rejected. Array filters match any of their values (25 values at most each); text filters are fuzzy; numeric and boolean filters must be JSON numbers and booleans (a string is ignored). Every result returned consumes workspace AI credits, at the Database search price (Settings → Credits usage, for owners and admins); saved people hidden by `excludeExisting` are not charged. Paginate by passing the returned `nextCursor` back as `cursor` (filters must stay identical between pages); a cursor that can no longer be resumed is a 400. Out of credits is a 400 with `code: warning-no-credits`; a filter problem is a 400 with `code: warning-database-query`. # Search companies in the database (/smart-api/reference/database/searchCompaniesDatabase) **JavaScript SDK:** [`swarmhit.database.companies.search(params)`](/smart-api/sdk/methods#database.companies) Search the company database by named filters. At least one filter with a value is required; unknown filter keys are rejected. `sort.dir` defaults to `desc`, and an unknown `sort.key` is ignored. Every result returned consumes workspace AI credits, at the Database search price (Settings → Credits usage, for owners and admins). Paginate by passing the returned `nextCursor` back as `cursor`. Out of credits is a 400 with `code: warning-no-credits`. # Enrich a person (/smart-api/reference/database/enrichPersonDatabase) **JavaScript SDK:** [`swarmhit.database.people.enrich(params)`](/smart-api/sdk/methods#database.people) Full database profile for one person, from their LinkedIn URL: experience, education, skills, follower counts, and with `includeEmail` their verified work email. Consumes workspace AI credits on a match, at the Person enrichment price, plus the Email finder price when `includeEmail` finds an email (Settings → Credits usage, for owners and admins); a 404 (no match) is not charged. When the person is already a workspace lead with an email, `includeEmail` returns that email without a new lookup, and a newly found email is stored on the lead. Without `includeEmail` the response omits the `contact` availability flags. For the email alone, `POST /database/people/find-email` is cheaper. Out of credits is a 400 with `code: warning-no-credits`. # Find a person's email (/smart-api/reference/database/findEmailDatabase) **JavaScript SDK:** [`swarmhit.database.people.findEmail(params)`](/smart-api/sdk/methods#database.people) A person's verified work email, from their LinkedIn URL or from a first name, last name and company (name or domain); send all of them when known. Nothing is saved, except on a workspace lead with that LinkedIn URL, whose email is returned free when it has one and kept when one is found (to look up leads, see `POST /leads/{id}/enrich`). Charged at the Email finder price (Settings → Credits usage, for owners and admins) when an email is found; `email: null` when none is, free. A miss can take up to a minute. Out of credits is a 400 with `code: warning-no-credits`. # Find a person's phone (/smart-api/reference/database/findPhoneDatabase) **JavaScript SDK:** [`swarmhit.database.people.findPhone(params)`](/smart-api/sdk/methods#database.people) A person's mobile phone numbers, primary first, from their LinkedIn URL. Nothing is saved, except on a workspace lead with that LinkedIn URL, whose phone is returned free when it has one and kept when one is found. Charged at the Phone finder price (Settings → Credits usage, for owners and admins) when a number is found; an empty `phones` array when none is, free. Out of credits is a 400 with `code: warning-no-credits`. # Enrich a company (/smart-api/reference/database/enrichCompanyDatabase) **JavaScript SDK:** [`swarmhit.database.companies.enrich(params)`](/smart-api/sdk/methods#database.companies) Full database record for one company (headcount and growth, funding, revenue estimate, the number of open job listings) from its website domain (preferred), LinkedIn URL or name; when several are sent, the first in that order is used. Consumes workspace AI credits on a match, at the Company enrichment price (Settings → Credits usage, for owners and admins); a 404 (no match) is not charged. Out of credits is a 400 with `code: warning-no-credits`. # List valid filter values (/smart-api/reference/database/databaseSuggestions) **JavaScript SDK:** [`swarmhit.database.suggestions(params)`](/smart-api/sdk/methods#database) Suggest valid values for a search filter (free, no credits). People fields: `title`, `keyword`, `seniority`, `department`, `function`, `company`, `industry`, `companyType`, `hqCountry`, `country`, `city`, `state`, `continent`, `language`, `skill`, `school`, `degree`, `fieldOfStudy`, `headcountRange`. Company fields: `industry`, `country`, `headcountRange`, `lastRoundType`, `investor`, `technology`, `name`. `headcountRange` returns a closed eight-value set on both entities. # List supported filters (/smart-api/reference/database/databaseCapabilities) **JavaScript SDK:** [`swarmhit.database.capabilities()`](/smart-api/sdk/methods#database) The filters, company sorts and suggestion fields the database supports for this workspace. The set can be narrower than the full filter list documented on the search endpoints; a search using a filter that is not listed here is rejected with 400 and the filter named. Free, no credits. An entity is `null` when the database is unavailable for it. # List senders (/smart-api/reference/senders/listSenders) **JavaScript SDK:** [`swarmhit.senders.list(params?)`](/smart-api/sdk/methods#senders) Oldest first, each with its accounts. # Create a sender (/smart-api/reference/senders/createSender) **JavaScript SDK:** [`swarmhit.senders.create(params)`](/smart-api/sdk/methods#senders) Creates a sender, with its accounts when given. An account linked here leaves the sender it had. An unknown id refuses the request (`400`) and creates nothing. # Get a sender (/smart-api/reference/senders/getSender) **JavaScript SDK:** [`swarmhit.senders.get(senderId)`](/smart-api/sdk/methods#senders) # Update a sender (/smart-api/reference/senders/updateSender) **JavaScript SDK:** [`swarmhit.senders.update(senderId, params)`](/smart-api/sdk/methods#senders) Only the fields given are written (at least one). A list replaces the sender's accounts of that kind (`[]` unlinks them all), and `linkedinAccountId` replaces its LinkedIn account (`null` unlinks it). An account linked here leaves the sender it had. # Delete a sender (/smart-api/reference/senders/deleteSender) **JavaScript SDK:** [`swarmhit.senders.delete(senderId)`](/smart-api/sdk/methods#senders) Its accounts are unlinked and stay in their campaigns. # List sender accounts (/smart-api/reference/accounts/listAccounts) **JavaScript SDK:** [`swarmhit.accounts.list(params?)`](/smart-api/sdk/methods#accounts) Connected LinkedIn accounts, with their health, daily limits and usage, pause state (`paused` / `pausedReason`), invite-limit cooldown (`cooldownUntil`) and InMail credit balance (`inmailCredits`). Credentials are never returned. With `archived=true`, the archived accounts instead, newest first: each is an `ArchivedAccount` (`status: archived`, `archivedAt`, `campaignIds`). Bring one back with POST /accounts/{id}/restore. Archived accounts are kept for 90 days, then deleted permanently. # Connect a sender (/smart-api/reference/accounts/connectSender) **JavaScript SDK:** [`swarmhit.accounts.connect(params)`](/smart-api/sdk/methods#accounts) Connect a LinkedIn sender with its email/username and password; optionally a proxy and country. The sender lands in the effective workspace (the one named by the X-Swarmhit-Workspace header, if set), consuming a seat from its allocation. Credentials are stored encrypted and never returned. The outcome and later changes arrive via the account.* webhooks. To connect a sender without ever handling their password, create a hosted connect link instead (POST /accounts/hosted-link) and send the person to it. A sender can also be connected from an existing browser session: send `liAt` (the LinkedIn session cookie value) and the `userAgent` of the browser it came from, instead of `username` / `password`. The session is stored encrypted and `connectionMethod` is `cookie`; checkpoints and webhooks behave the same. A browser session cannot re-establish itself, so these senders never reconnect on their own: repair one by POSTing a fresh `liAt` to POST /accounts/{id}/reconnect. Password sign-in with a `totpSecret` is the lower-maintenance path. `proxy` and `country` may accompany `liAt` exactly as they do a password sign-in, and a reconnect keeps the sender's stored proxy unless it sends a new one. Add a 2FA setup key (`totpSecret`) for automatic (infinite) login: the sign-in then completes hands-free, solving a 2FA challenge from the secret. Anything else fails with 400 and an account.connection_failed webhook rather than leaving a half-connected sender holding a seat. Without a `totpSecret` the connect is interactive. If LinkedIn asks for a verification step the response is 200 with `status` `checkpoint_required` and a `checkpoint` object describing it; answer it on POST /accounts/{id}/checkpoint within five minutes. An account.checkpoint_required webhook fires at the same time. On failure the error body carries a stable `error.code` to branch on and a plain-language `error.message` describing what went wrong. Codes for this endpoint: `invalid-credentials` (wrong email/password), `invalid-session` (the `liAt` browser session was rejected; copy a fresh one), `rate-limited` (LinkedIn is throttling sign-in; retry later), `proxy-failed` (the proxy sent with the request did not respond, so the sign-in never reached LinkedIn), `country-unavailable` (accounts in the requested `country` cannot be connected at the moment; pick a nearby country), `provider-timeout` / `provider-unreachable` (LinkedIn answered too slowly, or could not be reached at all; safe to retry), `checkpoint-code-rejected` (the account's 2FA setup key is wrong, so the authenticator code is rejected), `checkpoint-restricted` (LinkedIn has restricted the account; clear it on linkedin.com and connect again), `account-restricted` (the same, found at sign-in, before any verification step), `sign-in-in-progress` (a sign-in for this account is already running; finish it or wait a few minutes), `unsupported-verification` (automatic login was requested but the account signs in with a method other than authenticator 2FA — connect it without a `totpSecret` and answer the challenge instead), `verification-required` / `verification-failed` (a verification step that can't be completed automatically), `invalid-totp-secret` (the 2FA key isn't a valid authenticator key), `already-connected`, `no-plan`, and `seat-limit-reached`, and `connection-failed` for anything else, where `error.message` carries the specific reason. Every one of them is a 400. When the attempt reached LinkedIn, the account.connection_failed webhook carries the same `code`; refusals made before signing in (`invalid-totp-secret`, `no-plan`, `seat-limit-reached`, and `already-connected` for a password sign-in) come back on the response only. # Create a hosted connect link (/smart-api/reference/accounts/createHostedConnectLink) **JavaScript SDK:** [`swarmhit.accounts.hostedLinks.create(params)`](/smart-api/sdk/methods#accounts.hostedLinks) Mint a one-time URL where a person connects their own LinkedIn account on a page Swarmhit serves, so your product never handles their LinkedIn password. Send them to `url`; they sign in, clear any verification step LinkedIn raises, and are redirected back to your `redirectUrl`. The sender lands in the effective workspace (the one named by the X-Swarmhit-Workspace header, if set) and consumes a seat from its allocation. The seat is checked when the link is created, so a full workspace fails here (400 `seat-limit-reached`, or `no-plan` without an active plan) rather than at the end of the person's sign-in. It is checked, not reserved; a workspace whose seats are added on demand is not refused. The page carries your organization's name and picture by default; both can be overridden, and a default return URL set, in your organization settings — so `redirectUrl` only has to be sent when a particular link should land somewhere else. With `accountId` the link REPAIRS that sender instead of adding one: it keeps its id, its campaigns and its seat, and no seat is consumed. The exception is a change of sign-in method (a browser-session sender signing back in with a password, or the reverse): the sender then comes back under a new id, its campaign pools move to it and the old one is removed, so read the id from `account_id` on the redirect. An `accountId` that is not a sender in the workspace is a 400 with `code: account-not-found`. A failed sign-in attempt does not end the link: the person can try again until it expires or they cancel, and each attempt LinkedIn refuses fires account.connection_failed. You learn the outcome three ways, and can use any of them: the redirect back carries `?hosted_link_id=&status=connected|failed|expired|cancelled &account_id=&external_id=`; the account.connected / account.connection_failed webhooks carry `hostedLinkId` and `externalId`; and GET /accounts/hosted-link/{id} returns the current state. `url` is returned once, here — the token inside it is stored only as a hash and cannot be read back. # Get a hosted connect link (/smart-api/reference/accounts/getHostedConnectLink) **JavaScript SDK:** [`swarmhit.accounts.hostedLinks.get(hostedLinkId)`](/smart-api/sdk/methods#accounts.hostedLinks) What became of a link: `pending` while it is still open (a failed sign-in attempt leaves it open), `connected` once a sender came out of it (`accountId` names it), `failed` when the person cancelled on the page (`error.code: cancelled`), `expired` once its window closed. Never returns the link URL or its token. # Get a sender account (/smart-api/reference/accounts/getAccount) **JavaScript SDK:** [`swarmhit.accounts.get(accountId)`](/smart-api/sdk/methods#accounts) # Rename an account or set its sender (/smart-api/reference/accounts/renameAccount) **JavaScript SDK:** [`swarmhit.accounts.update(accountId, params)`](/smart-api/sdk/methods#accounts) Sets the account's `label` (`null` or an empty string clears it) and its `senderId` (`null` unlinks it), whichever are given. A sender has one LinkedIn account at most: a sender that already has another is refused with `400`, `code: sender-has-linkedin`. # Disconnect a sender (/smart-api/reference/accounts/disconnectSender) **JavaScript SDK:** [`swarmhit.accounts.disconnect(accountId, params?)`](/smart-api/sdk/methods#accounts) Disconnect a LinkedIn sender account: revoke its session, remove it from the workspace and free its seat. The inverse of connecting a sender (POST /accounts). From then on GET /accounts/{id} answers 404 and GET /accounts no longer lists it; no webhook is sent, unless an endpoint subscribes to `account.archived`. The account is archived: its conversations, settings (limits, warm-up) and history stay, listed by GET /accounts?archived=true, and POST /accounts/{id}/restore brings it back under the same id. Its login secrets (session cookie, password, 2FA key) are not kept: a restored account is reconnected like any other. Archived accounts are kept for 90 days, then deleted permanently; pass `permanent=true` to delete right away. With `permanent=true` the account is deleted for good, live or archived: its conversations and settings are erased (`account.deleted`). What it did in campaigns stays with the campaigns. Only a key created by an owner or admin of the workspace can do this (403 `not-allowed` otherwise). # Restore an archived account (/smart-api/reference/accounts/restoreAccount) **JavaScript SDK:** [`swarmhit.accounts.restore(accountId, params?)`](/smart-api/sdk/methods#accounts) Bring an archived account back under the same id, with its conversations, settings (limits, warm-up) and history, within 90 days of `archivedAt`: after that it is deleted permanently. It takes a seat, with the same check as connecting a sender (400 `seat-limit-reached` or `no-plan` when there is none), and comes back disconnected (`status: error`). Its login secrets were not kept, so reconnect it before it sends again: POST /accounts/{id}/reconnect with its `password` (and `totpSecret`), or with a fresh `liAt` for a browser-session (`cookie`) account, or a hosted connect link for it (POST /accounts/hosted-link with `accountId`). When the same LinkedIn account was connected again in the workspace after it was archived, it can't be restored next to it: 400 `already-connected`, as connecting it twice gives. It rejoins its old campaigns only with `{"campaigns": true}`: then the campaigns it was in that still exist take it back, and its own leads there go on once it is reconnected. `account.restored` is sent to the endpoints subscribed to it. # Move a stopped sender onto a new IP (/smart-api/reference/accounts/pickAccountNewIp) **JavaScript SDK:** [`swarmhit.accounts.pickNewIp(accountId)`](/smart-api/sdk/methods#accounts) For a sender on a managed connection that was stopped because the connection left its country or internet provider, or went down (`stoppedReason` `proxy_drift` / `proxy_down`): moves it now to a fresh connection in its own country, instead of waiting for the automatic retry. 409 `not_supported` when the sender has no managed connection (its own proxy); 503 `no_ip_available` when none is free in its country right now (try again in a few minutes). # Get a sender's limits (/smart-api/reference/accounts/getAccountLimits) **JavaScript SDK:** [`swarmhit.accounts.limits.get(accountId)`](/smart-api/sdk/methods#accounts.limits) Every cap on this sender, by the same keys as `limits` on GET /accounts/{id}/stats: the value in force (`limit`), how much of it is used (`used`), and the range it can be set within on the sender's LinkedIn plan (`default`, `min`, `max`). `tier` names that plan, and the ranges are the ones Swarmhit currently applies to it. Daily caps count today and reset at 00:00 UTC. Of the rolling caps, `connectionRequestsPerWeek` counts the invitations sent in the last `weeklyWindowDays` days and `pendingInvitationsMax` the invitations still awaiting a reply. Change the caps with PATCH on this path. `warmup` is its LinkedIn warm-up: whether it is on (`status`), its own setting (`mode`), the ramp it follows and, while it warms up, its `day`, the day's lower caps (`today`) and when it reaches full speed (`fullSpeedAt`). While it warms up, `warmup.today` holds the caps that bind each day, for campaigns and direct actions alike, not `limit`: `limit` keeps the account's own cap, so writing it back never saves a warm-up value. # Change a sender's limits (/smart-api/reference/accounts/updateAccountLimits) **JavaScript SDK:** [`swarmhit.accounts.limits.update(accountId, params)`](/smart-api/sdk/methods#accounts.limits) Send only the caps to change, by key; the others keep their value. Each value is saved as a whole number within the range GET returns for the sender's plan: a value above `max` is saved as `max`, one below 0 as 0, and a fraction is rounded. Send `null` to drop the sender's own value: the cap then follows its plan's `default` again, also when the plan or its default changes later. The response has the same shape as GET, with the values saved, so read `limit` to see what was applied. 400 with `code: invalid-limit` for an empty body, an unknown key, or a value that is neither a number nor null. `warmup` sets its own LinkedIn warm-up, alone or with caps: `inherit` (its workspace's warm-up settings decide), `off` (full speed now), `on` (start now on the workspace's ramp, even when those settings are off) or `custom` (start now on its own `ramp`). An account already warming keeps its day. 400 with `code: invalid-warmup`, and every reason in `details.errors`, for a setting that can't be taken; nothing is saved then, caps included. # Get the LinkedIn warm-up settings (/smart-api/reference/accounts/getAccountWarmupPolicy) **JavaScript SDK:** [`swarmhit.accounts.warmup.get()`](/smart-api/sdk/methods#accounts.warmup) The LinkedIn warm-up settings the workspace's LinkedIn accounts follow. With a warm-up, an account starts at `ramp.start` connection requests a day and adds `ramp.step` every `ramp.everyDays` days until it reaches its own cap (or its plan's default, when that is higher); its other daily caps (messages, InMails, profile views, follows, likes and comments) get the same share of their own value. Search imports, the weekly limits and the plan's ranges are not changed. An organization key without the `X-Swarmhit-Workspace` header reads the organization's settings, which cover its own accounts and every workspace inside it that has no settings of its own; with the header, that workspace's. `source` says where the values come from: the workspace itself (`workspace`), its organization (`organization`), or nothing set anywhere (`default`, which is off: accounts run as they always have). # Change the LinkedIn warm-up settings (/smart-api/reference/accounts/updateAccountWarmupPolicy) **JavaScript SDK:** [`swarmhit.accounts.warmup.update(params)`](/smart-api/sdk/methods#accounts.warmup) Sets the acting workspace's own settings (the organization's, without the header). Send only the fields to change: the others keep the value in force, so a workspace that follows its organization starts from the organization's values. A workspace's own settings replace its organization's as a whole; send `{"inherit": true}`, alone, to drop them and follow the organization again. `autoStart` decides whether an account starts warming up on its own, once, when it is connected for the first time: `never`, `new` (when it looks new, by `newAccount`) or `all`. Turning it on never slows accounts that are already connected; start or stop one by hand with PATCH /accounts/{id}/limits. A value out of range is refused, never clamped: 400 with `code: invalid-warmup` and every reason in `details.errors`. # Sync account information (/smart-api/reference/accounts/syncAccount) **JavaScript SDK:** [`swarmhit.accounts.sync(accountId)`](/smart-api/sdk/methods#accounts) Refresh this sender account's LinkedIn profile (name, headline, picture, public identifier) and paid-plan flag from LinkedIn, then return the updated account. Useful right after connecting, or when the stored identity looks stale. Fails with 400 when the account is not connected yet or LinkedIn returns no profile. # Reconnect a sender account (/smart-api/reference/accounts/reconnectAccount) **JavaScript SDK:** [`swarmhit.accounts.reconnect(accountId, params?)`](/smart-api/sdk/methods#accounts) Reconnect a sender whose LinkedIn session broke. `status` is `reconnected`, `checkpoint_required` or `failed`. In every case the sender keeps its id, its campaigns and its seat. For a credentials / infinite-login sender this retries the saved sign-in and runs one attempt now. If its saved provider account no longer exists, a replacement is created from the stored credentials and attached to the same sender. If the person changed their password (or 2FA), pass a new `password` (and `totpSecret`) to replace the stored credentials first. Any credential field (`totpSecret`, `username`, `country`, `proxy`) needs `password` alongside it (400 otherwise). An omitted `totpSecret` or `proxy` keeps the stored one, but `country` is replaced, so send it again if the sender has one. Seats are not checked here: a sender reconnected beyond the plan's seats is parked again within minutes. For a `cookie` sender, omit `liAt` to sign in again with its saved browser session, or pass a fresh `liAt` (and ideally the `userAgent` it came from) to replace it. A `liAt` from a different LinkedIn account, or one LinkedIn asks to verify, is refused with status `failed`. A legacy sender with no stored credentials at all can be repaired by passing `username` + `password`, which converts it into a credentials / infinite-login sender from then on. The alternative for any sender is a hosted connect link scoped to it (POST /accounts/hosted-link with `accountId`), which lets the person sign in themselves. A stored authenticator secret clears a 2FA challenge on its own. Any other challenge comes back as `checkpoint_required` with the `checkpoint` to answer on POST /accounts/{id}/checkpoint. # Get the pending sign-in challenge (/smart-api/reference/accounts/getAccountCheckpoint) **JavaScript SDK:** [`swarmhit.accounts.checkpoint.get(accountId)`](/smart-api/sdk/methods#accounts.checkpoint) The verification step this sender is waiting on, or `null` when there is none. `type` is 2FA, OTP, IN_APP_VALIDATION, PHONE_REGISTER or CAPTCHA (UNKNOWN for a step Swarmhit does not recognise); `input` says what answers it (`code`, `phone`, or `none` for a step completed outside the API: approved in the LinkedIn mobile app, or a captcha cleared on LinkedIn's own page); `expiresAt` is when LinkedIn drops the attempt, five minutes after it asked. IN_APP_VALIDATION and CAPTCHA are completed outside the API, so poll this endpoint (or listen for account.connected, which fires when the sender comes up) — it re-reads the live session and clears the challenge as soon as the sender is up. # Answer the pending sign-in challenge (/smart-api/reference/accounts/solveAccountCheckpoint) **JavaScript SDK:** [`swarmhit.accounts.checkpoint.solve(accountId, params)`](/smart-api/sdk/methods#accounts.checkpoint) Answer the verification step LinkedIn asked for. `code` is the verification code for 2FA and OTP, the phone number for PHONE_REGISTER (with its dialling code in brackets, e.g. `(+33)0612345678`), or the literal `TRY_ANOTHER_WAY` to make LinkedIn offer a different method — the way out of an in-app approval nobody can reach. `status` is `connected` once the sender is live, or `checkpoint_required` with the next `checkpoint` when LinkedIn chained another step. A rejected, expired or unanswerable challenge is a 400 whose `error.code` is one of `checkpoint_code_rejected`, `checkpoint_expired` (the five-minute window closed, so reconnect to start a fresh attempt), `checkpoint_rate_limited`, `checkpoint_restricted`, `checkpoint_no_code` (this type has nothing to type; approve it in the LinkedIn app or on the hosted page), `checkpoint_no_alternative`, `checkpoint_phone_format`, `checkpoint_empty_code`, `checkpoint_none` (nothing is pending), `checkpoint_account_gone` or `checkpoint_not_connected`. When the challenge itself was fine but the attempt could not reach LinkedIn, the 400 carries `proxy_failed` (the sender's proxy did not respond), `provider_timeout` or `rate_limited` instead. A 502 means LinkedIn could not be reached at all: retry. # Re-send the pending sign-in challenge (/smart-api/reference/accounts/resendAccountCheckpoint) **JavaScript SDK:** [`swarmhit.accounts.checkpoint.resend(accountId)`](/smart-api/sdk/methods#accounts.checkpoint) Ask LinkedIn to send the pending challenge again — a fresh emailed or texted code, or a new approval push to the mobile app, and restarts the five-minute window. 400 with `checkpoint_no_resend` when the pending type has nothing to re-send (a captcha, a phone step), or `checkpoint_expired` when the five-minute window already closed. # Open a live view of the sign-in browser (/smart-api/reference/accounts/createAccountLiveView) **JavaScript SDK:** [`swarmhit.accounts.createLiveView(accountId)`](/smart-api/sdk/methods#accounts) For a pending CAPTCHA (or a LinkedIn page with nothing to type) on a sender our own LinkedIn gateway runs, i.e. when `checkpoint.liveView` is true. Returns a single-use websocket URL, valid about ten minutes, streaming the sign-in browser: the server sends JSON text frames `{"type":"frame","data":"","meta":{"deviceWidth":…}}` and `{"type":"state","state":"live|finishing|ended","result":…}`; the client sends `{"type":"mouse","action":"mousePressed|mouseReleased|mouseMoved","x":…,"y":…}`, `{"type":"wheel","x":…,"y":…,"dx":…,"dy":…}`, `{"type":"key","key":…,"code":…,"keyCode":…}` or `{"type":"text","text":…}`. Coordinates are in the page's pixels (scale by `meta.deviceWidth`). When LinkedIn accepts the step the sender connects as usual (`account.connected`). Most integrators send their user to the hosted connect page instead, which shows this view itself. 409 with `live_view_unavailable` when there is no browser step to show, `live_view_not_ready` while the sign-in is still running (retry in a minute), and `live_view_expired` once it is no longer waiting. # Pause a sender (/smart-api/reference/accounts/pauseSender) **JavaScript SDK:** [`swarmhit.accounts.pause(accountId)`](/smart-api/sdk/methods#accounts) Pause the sender: campaigns stop acting from it and direct actions are rejected with 409 `code: sender_paused` until it is resumed. Its campaign leads stay assigned; use `POST /campaigns/{id}/senders/{accountId}/rebalance` to move the not-yet-contacted ones onto other senders. Pausing an already-paused sender is a no-op and still returns the account. Emits an `account.paused` webhook. # Resume a paused sender (/smart-api/reference/accounts/resumeSender) **JavaScript SDK:** [`swarmhit.accounts.resume(accountId)`](/smart-api/sdk/methods#accounts) Resume a paused sender so campaigns and direct actions can use it again. Resuming a sender that is not paused is a no-op and still returns the account. Emits an `account.resumed` webhook. # Social Selling Index (/smart-api/reference/accounts/getAccountSsi) **JavaScript SDK:** [`swarmhit.accounts.getSsi(accountId, params?)`](/smart-api/sdk/methods#accounts) LinkedIn's Social Selling Index for this sender: a 0-100 score built from four 0-25 pillars (professional brand, finding the right people, engaging with insights, building relationships), plus where the profile ranks against its industry and its network. The score is cached on the account and refetched once it is more than 12 hours old (LinkedIn recomputes it daily at most). Pass `refresh=true` to force a live read; that call fails with 400 when LinkedIn cannot be reached at that moment. `unavailable: true` means LinkedIn does not expose a score for this account's session, which is a state rather than an error. # Human rhythm and warm-up (retired) (/smart-api/reference/accounts/getAccountRhythm) Human rhythm was retired on 2026-10-15: every sender runs the standard pacing. Kept for compatibility, it always returns `{ "enabled": false }` for an existing sender (404 otherwise). # Skip or restore the warm-up (retired) (/smart-api/reference/accounts/setAccountRhythm) Human rhythm was retired on 2026-10-15, so there is no warm-up to skip. Kept for compatibility: a valid body (`skipWarmup` true or false) always answers 409, an invalid one 400. # Sender performance (/smart-api/reference/accounts/getAccountStats) **JavaScript SDK:** [`swarmhit.accounts.getStats(accountId, params?)`](/smart-api/sdk/methods#accounts) How this sender has performed over the last `days`: connection requests, acceptances, messages, replies and interested leads with period-over-period deltas, a daily activity series, all-time totals and rates, the per-campaign split, direct (non-campaign) action counts, and how much headroom is left against its daily and rolling-weekly caps. Campaign sends and direct API sends are both counted. Daily series buckets are calendar days on the server's clock; the daily caps themselves reset at 00:00 UTC. # List mailboxes (/smart-api/reference/email-accounts/listEmailAccounts) **JavaScript SDK:** [`swarmhit.emailAccounts.list(params?)`](/smart-api/sdk/methods#emailAccounts) Connected mailboxes, newest first, with their status, pause state (`paused` / `pausedReason`), daily limit and usage, and the gateway counters (`counters.sent`, `bounced`, `complained`). Credentials are never returned. A mailbox connects over the API through a hosted link (POST /email-accounts/hosted-link). # Create a hosted connect link for a mailbox (/smart-api/reference/email-accounts/createMailboxHostedConnectLink) **JavaScript SDK:** [`swarmhit.emailAccounts.hostedLinks.create(params)`](/smart-api/sdk/methods#emailAccounts.hostedLinks) The mailbox version of POST /accounts/hosted-link, and how the API connects a mailbox without handling its password: mint a one-time URL, send the person to it, and they connect on a page Swarmhit serves in your organization's branding, with one of the `providers` the link offers (the person picks when there are several). `google` and `microsoft` sign in on the provider's own page; `google_app_password` takes a Google app password, and `imap` the address, password and IMAP/SMTP servers of any other mailbox. `provider` is the one-value form. `buy_mailboxes` lets the person buy new domains and mailboxes on the page, up to `purchase.maxMailboxes` in all, charged to the card your organization pays with; no price is shown on the page. Offering it needs `managedMailboxes` on the workspace and a card on file (409 `no-payment-method`), and the API key's creator needs billing rights (403 `not-allowed`). The link then ends as `ordered`: the redirect carries `status=ordered&order_id=`, the mailboxes are set up over the next hours, and `mailbox_order.completed` and each mailbox's account.connected carry `hostedLinkId` and `externalId`. The mailbox lands in the effective workspace (X-Swarmhit-Workspace), which needs a paid plan, checked when the link is created; a mailbox takes no seat. The workspace needs the email channel (403 otherwise), and the server needs each provider set up (409 `provider-unavailable` otherwise). With `accountId` the link REPAIRS that mailbox (status `error`): it keeps its id, campaigns and seat, and offers only the mailbox's own provider, so `providers` and `provider` may be left out. A signed-in mailbox signs in again as the same address (a different one is refused on the page); a password mailbox keeps its address, servers and usernames and takes new passwords. A mailbox bought through Swarmhit reconnects by itself and is refused (409 `managed-mailbox`). You learn the outcome exactly as for a sender: the redirect back carries `?hosted_link_id=&status=connected|ordered|failed|expired|cancelled&account_id=&order_id=&external_id=` (`account_id` is the mailbox id); account.connected (with `data.channel: email`) and account.connection_failed carry `hostedLinkId` and `externalId`; and GET /email-accounts/hosted-link/{id} returns the current state. A failed attempt leaves the link open so the person can try again; only cancelling or expiry ends it without a mailbox. `url` is returned once, here. # Get a mailbox hosted connect link (/smart-api/reference/email-accounts/getMailboxHostedConnectLink) **JavaScript SDK:** [`swarmhit.emailAccounts.hostedLinks.get(hostedLinkId)`](/smart-api/sdk/methods#emailAccounts.hostedLinks) What became of a mailbox link: `pending` while it is open, `connected` once a mailbox came out of it (`accountId` names it), `failed` when the person cancelled, `expired` once its window closed. Never returns the link URL or its token. # Get a mailbox (/smart-api/reference/email-accounts/getEmailAccount) **JavaScript SDK:** [`swarmhit.emailAccounts.get(emailAccountId)`](/smart-api/sdk/methods#emailAccounts) # Set a mailbox's sender (/smart-api/reference/email-accounts/updateEmailAccount) **JavaScript SDK:** [`swarmhit.emailAccounts.update(emailAccountId, params)`](/smart-api/sdk/methods#emailAccounts) Sets the mailbox's `senderId` (`null` unlinks it). `senderId` is the only field. Needs the email channel enabled for the workspace (`403` otherwise). # Remove a mailbox (/smart-api/reference/email-accounts/removeEmailAccount) **JavaScript SDK:** [`swarmhit.emailAccounts.delete(emailAccountId)`](/smart-api/sdk/methods#emailAccounts) Remove the mailbox from the workspace: its email threads go with it, it leaves every campaign pool, and leads it was emailing move to the remaining mailboxes. Its threads are deleted. Connecting the same address again within the restore window (15 days by default, set by Swarmhit) brings it back to its campaigns, its leads and its settings, under a new id with `restoredFrom` set; after the window it comes back as a new mailbox. Needs the email channel enabled for the workspace (`403` otherwise). A mailbox bought through Swarmhit (`managed` set) is never removed this way: `409` with `code: managed-mailbox`; cancel it with POST /email-accounts/{id}/cancel-managed instead. # Pause a mailbox (/smart-api/reference/email-accounts/pauseEmailAccount) **JavaScript SDK:** [`swarmhit.emailAccounts.pause(emailAccountId)`](/smart-api/sdk/methods#emailAccounts) Pause the mailbox: campaigns stop emailing from it until it is resumed. Its campaign leads stay assigned. Pausing an already-paused mailbox is a no-op and still returns the mailbox. Emits an `account.paused` webhook with `channel: email`. # Re-check a mailbox's domain (/smart-api/reference/email-accounts/checkEmailAccountDns) **JavaScript SDK:** [`swarmhit.emailAccounts.checkDns(emailAccountId)`](/smart-api/sdk/methods#emailAccounts) Read the sending domain's SPF, DKIM and DMARC records now and return the mailbox with its updated `dnsHealth`. The records are also re-read once a day and right after a mailbox connects, so this is only needed after changing DNS. # Run a deliverability test (/smart-api/reference/email-accounts/testEmailAccountDeliverability) **JavaScript SDK:** [`swarmhit.emailAccounts.testDeliverability(emailAccountId)`](/smart-api/sdk/methods#emailAccounts) Send one ordinary email from the mailbox to our test inbox. Within a few minutes the mailbox's `deliverabilityTest` reports whether it landed in the inbox or in spam (or did not arrive within 10 minutes), SPF, DKIM and DMARC as the receiving server saw them, and Spamhaus answers for the sending server and the domain. Read the mailbox to follow it (`status` goes from `running` to `done` or `failed`). One test runs at a time per mailbox, and a mailbox can run 10 a day. # Resume a paused mailbox (/smart-api/reference/email-accounts/resumeEmailAccount) **JavaScript SDK:** [`swarmhit.emailAccounts.resume(emailAccountId)`](/smart-api/sdk/methods#emailAccounts) Resume a paused mailbox so campaigns can email from it again. A no-op on a mailbox that is not paused; returns the mailbox either way. Emits an `account.resumed` webhook with `channel: email`. # Mailbox performance (/smart-api/reference/email-accounts/getEmailAccountStats) **JavaScript SDK:** [`swarmhit.emailAccounts.getStats(emailAccountId, params?)`](/smart-api/sdk/methods#emailAccounts) How this mailbox has performed over the last `days`: emails sent, replies and bounces with period-over-period deltas, a daily series, all-time totals and rates, the per-campaign split, and the gateway counters. Emails are counted per message; replies and bounces per lead, and a reply is credited to the mailbox when the lead's latest message from the campaign was an email. # Cancel a bought mailbox (/smart-api/reference/mailbox-orders/cancelManagedEmailAccount) **JavaScript SDK:** [`swarmhit.emailAccounts.cancelManaged(emailAccountId, params?)`](/smart-api/sdk/methods#emailAccounts) Cancel a mailbox bought through Swarmhit (`connectedVia: managed`). It keeps working until `cancelAt` (its renewal date), then it is removed and stops being billed; the last mailbox of a domain also stops the domain's renewal. Not gated on the managed mailboxes feature. `409` with `code: not-managed` for a mailbox the workspace connected itself; `403` with `code: not-allowed` unless the key's creator may manage billing. The domain's admin mailbox (`managed.isAdmin`) is the one every other mailbox on the domain depends on: once it is cancelled, no mailbox can ever be added to that domain again. While other mailboxes still run on the domain, cancelling it needs `confirmAdmin: true`; without it the answer is `409` with `code: admin-mailbox` and the warning to show. # Set a bought mailbox's picture (/smart-api/reference/mailbox-orders/setManagedEmailAccountPicture) **JavaScript SDK:** [`swarmhit.emailAccounts.setPicture(emailAccountId, params)`](/smart-api/sdk/methods#emailAccounts) Set the profile picture of a mailbox bought through Swarmhit (`connectedVia: managed`), from a public `https://` link to a JPG or PNG that our supplier downloads. Gmail shows it next to the mailbox's emails, which can take up to a day. Returns the mailbox with its new `picture`. Not gated on the managed mailboxes feature. `409` with `code: not-managed` for a mailbox the workspace connected itself: its picture is its own account's. # Search domains to buy (/smart-api/reference/mailbox-orders/searchDomains) **JavaScript SDK:** [`swarmhit.domains.search(params)`](/smart-api/sdk/methods#domains) Domains matching a keyword (or one exact domain), with availability, whether Google Workspace and Microsoft 365 can host each, and the first-year price. Needs the managed mailboxes feature on the workspace (`403`, `code: feature-disabled`). # List the domains you bought (/smart-api/reference/mailbox-orders/listManagedDomains) **JavaScript SDK:** [`swarmhit.managedDomains.list(params?)`](/smart-api/sdk/methods#managedDomains) The workspace's bought domains and how many more mailboxes each can take (5 per domain on Google Workspace and Microsoft 365). Add mailboxes to one with a domain entry `{ name, existing: true, mailboxes }` in POST /mailbox-orders/quote (with `X-Swarmhit-Workspace` naming the domain's workspace when it is not the key's own). `canAdd: false` says why in `reason`. Not gated on the managed mailboxes feature. By default the list is the acting workspace's own, as it always was. Optional, for an organization's key acting as the organization (no `X-Swarmhit-Workspace`): `orgWide=true` adds the domains of every workspace inside it, and `workspaceId` picks one of them. With either one, each domain also carries `workspaceId` and `workspaceName`. # Use a domain you already own (/smart-api/reference/mailbox-orders/addOwnDomain) **JavaScript SDK:** [`swarmhit.managedDomains.create(params)`](/smart-api/sdk/methods#managedDomains) Use a domain the workspace already owns for bought mailboxes, instead of buying one: nothing is charged for the domain, only its mailboxes. Its DNS moves to Swarmhit: set the two `connection.nameservers` at the domain's registrar, replacing `connection.currentNameservers` (empty for a minute when our mail supplier is slow to answer: read the domain again). Its website records (A/AAAA at the root, `www`) are copied to its new DNS (`connection.websiteRecords`), so the site keeps working; nothing else is copied, so subdomains, other records and any email it has today stop working. The domain then connects on its own (`connection.state`: `nameservers` → `setting_up` → `ready`). Set the nameservers within about 12 hours: a pair not seen by then is replaced by a new one (`connection.nameserversChangedAt`, webhook `managed_domain.action_required` with `reason: nameservers_changed`). Follow it with GET /managed-domains/{id} or the `managed_domain.*` webhooks. Once `canAdd` is true, add mailboxes with a domain entry `{ name, existing: true, mailboxes }` in POST /mailbox-orders/quote. Nameservers not changed within 7 days: `expired`, add it again later. A domain another account had live here before needs support (`domain-taken`). Needs the managed mailboxes feature, billing rights (the key's creator) and a payment method on file. At most 5 domains connect at once, and 5 adds a minute. # Get one domain (/smart-api/reference/mailbox-orders/getManagedDomain) **JavaScript SDK:** [`swarmhit.managedDomains.get(managedDomainId, params?)`](/smart-api/sdk/methods#managedDomains) One of the workspace's domains, bought or own: its room for mailboxes, and for an own domain where its connection stands. With `orgWide=true`, an organization's key also finds its workspaces' domains, each naming its workspace; acting on one (check, delete) takes `X-Swarmhit-Workspace` naming that workspace. # Remove an own domain (/smart-api/reference/mailbox-orders/removeOwnDomain) **JavaScript SDK:** [`swarmhit.managedDomains.delete(managedDomainId)`](/smart-api/sdk/methods#managedDomains) Let go of a domain you own once no mailbox runs or is on its way there (cancel them first; 409 `domain-in-use` otherwise). Its DNS at Swarmhit is deleted, so the domain stops resolving until its nameservers are set back at the registrar. Billing rights. Not gated on the features. 409 `not-own-domain` for a domain bought here. # Check an own domain now (/smart-api/reference/mailbox-orders/checkOwnDomain) **JavaScript SDK:** [`swarmhit.managedDomains.check(managedDomainId)`](/smart-api/sdk/methods#managedDomains) Look at an own domain again now, e.g. right after changing its nameservers: the next check runs within a minute. Returns the domain as it is now. 409 `not-own-domain` for a domain bought here. # What you pay for bought mailboxes (/smart-api/reference/mailbox-orders/getMailboxBilling) **JavaScript SDK:** [`swarmhit.mailboxBilling.get()`](/smart-api/sdk/methods#mailboxBilling) Bought mailboxes and domains are billed apart from the plan, each purchase on its own date and monthly from then on. This returns the monthly total and next charge date, the card charged, each purchase (mailboxes, price, status, a `payUrl` when a payment failed, the last receipt) and each domain's renewal (date, price or an estimate, status, `payUrl` when declined). Same data as the **Mailboxes & domains** tab of Settings → Billing in Swarmhit. `403` with `code: not-allowed` unless the key's creator may manage billing. Not gated on the managed mailboxes feature. # Price an order (/smart-api/reference/mailbox-orders/quoteMailboxOrder) **JavaScript SDK:** [`swarmhit.mailboxOrders.quote(params)`](/smart-api/sdk/methods#mailboxOrders) Price domains and mailboxes before buying: the lines charged now (domain registration, the first month) and every month after, in cents. Returns a `quoteId`, valid for 30 minutes, that POST /mailbox-orders needs. Charges nothing. Domains no longer available are listed in `unavailable`. Needs the managed mailboxes feature (`403`). A domain entry with `existing: true` adds mailboxes to a domain the workspace already bought (GET /managed-domains): no domain charge, only the mailboxes, which are usually ready within minutes. Addresses already taken on such a domain are listed in `unavailableMailboxes`. Refusals: `404` `domain-not-owned`, `409` `domain-full` (`details.slotsLeft`), `409` `domain-admin-cancelled` (its admin mailbox was cancelled, so it takes no new mailbox), `409` `domain-not-ready` (an own domain still connecting, or whose nameservers moved away). `contact` is only required when the order registers a new domain. # List mailbox orders (/smart-api/reference/mailbox-orders/listMailboxOrders) **JavaScript SDK:** [`swarmhit.mailboxOrders.list(params?)`](/smart-api/sdk/methods#mailboxOrders) The workspace's orders, newest first, each with its domains and mailboxes and the step each mailbox is at. By default the list is the acting workspace's own, as it always was. Optional, for an organization's key acting as the organization (no `X-Swarmhit-Workspace`): `orgWide=true` adds the orders of every workspace inside it, and `workspaceId` picks one of them. With either one, each order also carries `workspaceId` and `workspaceName`. # Buy domains and mailboxes (/smart-api/reference/mailbox-orders/createMailboxOrder) **JavaScript SDK:** [`swarmhit.mailboxOrders.create(params)`](/smart-api/sdk/methods#mailboxOrders) Buy the quoted order: charges the workspace's card on file for the quote's `dueNowCents`, then registers the domains and creates the mailboxes. They arrive connected (no sign-in, `connectedVia: managed`) and warming unless `warmup.enabled` is false, usually within hours; follow the order on GET /mailbox-orders/{id} or the `mailbox_order.completed` / `mailbox_order.failed` webhooks. Send the `quoteId`; the order fields are optional and, when sent, must be exactly what was quoted (`409`, `code: quote-mismatch`). Buying the same quote again returns the same order with no second charge, and an `Idempotency-Key` replays the first answer to a retry. `402` with `code: no-payment-method` or `payment-failed` when the card cannot be charged; `409` with `code: quote-expired`, `price-changed` or `domain-unavailable` when the quote no longer holds (quote again); `409` with `code: payment-pending` when the charge is still being confirmed (the order appears in GET /mailbox-orders within minutes if it went through, so do not buy again); `403` with `code: not-allowed` unless the key's creator may manage billing (owner or admin of the paying workspace). Quoting needs the same right. For `existing` domains: `409` with `code: mailbox-unavailable`, `domain-full`, `domain-admin-cancelled` or `domain-not-ready`, and `404` with `code: domain-not-owned`. # Get a mailbox order (/smart-api/reference/mailbox-orders/getMailboxOrder) **JavaScript SDK:** [`swarmhit.mailboxOrders.get(mailboxOrderId, params?)`](/smart-api/sdk/methods#mailboxOrders) One order with every domain and mailbox and its step. By default only an order of the acting workspace, as it always was. With `orgWide=true`, an organization's key acting as the organization also finds the orders of the workspaces inside it, and the order carries `workspaceId` and `workspaceName`. # Get a mailbox's warmup (/smart-api/reference/warmup/getEmailAccountWarmup) **JavaScript SDK:** [`swarmhit.emailAccounts.warmup.get(emailAccountId)`](/smart-api/sdk/methods#emailAccounts.warmup) Warmup of a mailbox bought through Swarmhit: status, strategy, warmup day, current and target daily volume, health, inbox rate, and the campaign hold (`holdUntil`, `holdReason`). `limits.maxTarget` is the highest target volume this mailbox may warm to (the email tier's ceiling minus its campaign cap). `409` with `code: not-managed` for a mailbox the workspace connected itself: warmup is available for mailboxes bought through Swarmhit. # Change the warmup strategy (/smart-api/reference/warmup/updateEmailAccountWarmup) **JavaScript SDK:** [`swarmhit.emailAccounts.warmup.update(emailAccountId, params)`](/smart-api/sdk/methods#emailAccounts.warmup) Set the strategy: a preset name, or custom values. Only the values that changed are sent to the warmup network. Starting volume only applies to warmup day 1 and is refused afterwards (`409`, `code: starting-volume-locked`). Before warmup has started the strategy is stored and applied as soon as it does. An invalid strategy is `400` with `code: invalid-strategy` and `details.errors` listing each field. # Start warmup (/smart-api/reference/warmup/startEmailAccountWarmup) **JavaScript SDK:** [`swarmhit.emailAccounts.warmup.start(emailAccountId, params?)`](/smart-api/sdk/methods#emailAccounts.warmup) Add warmup to a bought mailbox that has none (an order placed without warmup, or warmup stopped earlier). Billed monthly. Optional `strategy`, the standard preset by default. `409` with `code: warmup-already-on` when it is already warming. # Pause warmup (/smart-api/reference/warmup/pauseEmailAccountWarmup) **JavaScript SDK:** [`swarmhit.emailAccounts.warmup.pause(emailAccountId)`](/smart-api/sdk/methods#emailAccounts.warmup) Pause the mailbox's warmup. Campaigns are not affected by a warmup pause. `409` with `code: warmup-not-running` when warmup has not started. # Resume warmup (/smart-api/reference/warmup/resumeEmailAccountWarmup) **JavaScript SDK:** [`swarmhit.emailAccounts.warmup.resume(emailAccountId)`](/smart-api/sdk/methods#emailAccounts.warmup) # Stop warmup (/smart-api/reference/warmup/stopEmailAccountWarmup) **JavaScript SDK:** [`swarmhit.emailAccounts.warmup.stop(emailAccountId)`](/smart-api/sdk/methods#emailAccounts.warmup) Stop the mailbox's warmup for good (cancelled at the warmup network). Start it again with POST /email-accounts/{id}/warmup/start. # Send before warmup is done (/smart-api/reference/warmup/overrideEmailAccountWarmupHold) **JavaScript SDK:** [`swarmhit.emailAccounts.warmup.overrideHold(emailAccountId, params)`](/smart-api/sdk/methods#emailAccounts.warmup) A warming mailbox is held out of campaigns until its hold ends (14 days and good health by default). `on: true` lets campaigns use it now, at a deliverability risk; `on: false` holds it again. # Warmup daily series (/smart-api/reference/warmup/getEmailAccountWarmupStats) **JavaScript SDK:** [`swarmhit.emailAccounts.warmup.getStats(emailAccountId, params?)`](/smart-api/sdk/methods#emailAccounts.warmup) One row per UTC day, oldest first: warmup emails sent and received, campaign sends, replies, bounces, inbox rate and health. Recorded once a day, so today is not in it yet. # Send a connection request (/smart-api/reference/direct-actions/sendConnectionRequest) **JavaScript SDK:** [`swarmhit.accounts.connections.send(accountId, params)`](/smart-api/sdk/methods#accounts.connections) Send a LinkedIn connection request from this sender account to a profile, with an optional note. Notes are a premium feature of up to 300 characters (a longer note is a 400 and nothing is sent); free sender accounts can't include one, so their invites are sent without the note and the returned action has `note: null`. The profile is tracked as a lead and a connection.accepted webhook fires when it is accepted. Returns 409 when this sender already has a pending invitation to the profile from an earlier direct request (invitations sent by campaigns are not checked), or 429 when the sender hit its daily cap, its weekly connection-request limit (a rolling 7 days, tier-based: 130 for free accounts, 150 for paid; admin-configurable) or its pending-invitation backlog (500 by default), unless the workspace has the limit bypass enabled. # Check connection status (/smart-api/reference/direct-actions/checkConnection) **JavaScript SDK:** [`swarmhit.accounts.connections.get(accountId, identifier)`](/smart-api/sdk/methods#accounts.connections) This sender's connection with a profile: status (not_connected / pending / connected), connection degree, whether it is an open profile, and whether the sender can message or invite them. A read; no profile view is registered and no cap is used. `pending` only reflects invitations this sender sent through direct actions: one sent by a campaign or from LinkedIn itself reads `not_connected`. Use the username in lower case: an identifier with capital letters or underscores is treated as LinkedIn's internal member id and refused with 400. # Withdraw a connection request (/smart-api/reference/direct-actions/withdrawConnectionRequest) **JavaScript SDK:** [`swarmhit.accounts.connections.withdraw(accountId, identifier)`](/smart-api/sdk/methods#accounts.connections) Withdraw a pending connection request from this sender to a profile. Safe to call when nothing is pending: `alreadyGone` is true when LinkedIn had no pending invitation to cancel. It also marks the logged invitation `withdrawn`. Uses no cap, and works while the sender is paused. # Message a connection (/smart-api/reference/direct-actions/sendDirectMessage) **JavaScript SDK:** [`swarmhit.accounts.messages.send(accountId, params)`](/smart-api/sdk/methods#accounts.messages) Send a LinkedIn message from this sender account to a profile it is connected to. To reply within an existing inbox conversation, use the inbox endpoint instead. Counts against the sender's daily message cap. # Send an InMail (/smart-api/reference/direct-actions/sendInmail) **JavaScript SDK:** [`swarmhit.accounts.inmails.send(accountId, params)`](/smart-api/sdk/methods#accounts.inmails) Send a LinkedIn InMail from this sender account to a non-connection. InMail requires a **premium LinkedIn plan** on the sender (free senders cannot send any). Sales Navigator and Recruiter senders InMail **open profiles** free up to a monthly allowance (about 800/month, plan-dependent); every other send, including classic Premium, draws the sender's InMail credit balance (visible as `inmailCredits` on the account). Counts against the sender's daily InMail cap. Fails with 409 `code: sender_paused` when the sender is paused, 409 `code: inmail_not_available` when the sender has no premium InMail access, and 429 `code: inmail_credits_exhausted` (`details.scope: inmail_credits`) when the sender is out of InMail credits. # Visit a profile (/smart-api/reference/direct-actions/visitProfile) **JavaScript SDK:** [`swarmhit.accounts.profiles.visit(accountId, params)`](/smart-api/sdk/methods#accounts.profiles) Visit a LinkedIn profile from this sender account, registering a real profile view (the target sees it). Counts against the sender's daily profile-view cap. # Follow a lead (/smart-api/reference/direct-actions/followProfile) **JavaScript SDK:** [`swarmhit.accounts.profiles.follow(accountId, params)`](/smart-api/sdk/methods#accounts.profiles) Follow a lead from this sender account — subscribe to their posts without sending a connection request. Counts against the sender's daily follow cap. # Like a post (/smart-api/reference/direct-actions/likeLastPost) **JavaScript SDK:** [`swarmhit.accounts.posts.like(accountId, params)`](/smart-api/sdk/methods#accounts.posts) Like a LinkedIn post from this account, or leave another reaction on it. Pass exactly one of `postUrl` (that post) or `profileUrl` (the lead's most recent post). Counts against the account's daily post-like cap, whatever the reaction. Returns 422 when the lead has no recent post, and 422 with `code: post_not_found` when `postUrl` is not a post's link (checked before anything else) or this account can't see the post. A post given by its link isn't tied to a lead: no lead is created, and the action's `profileUrl` is the post's author when that is a person. # List a post's comments (/smart-api/reference/direct-actions/listPostComments) **JavaScript SDK:** [`swarmhit.accounts.posts.listComments(accountId, params)`](/smart-api/sdk/methods#accounts.posts) The comments on a LinkedIn post, most recent first, as this account sees them, so you can pick one to reply to: pass its `id` as `commentId` to POST /accounts/{id}/post-comments. Replies to a comment are not listed; `replyCount` says how many it has. A silent read: no cap is used and nothing is logged as an action. Paginate by passing the returned `nextCursor` back as `cursor`. Returns 422 with `code: post_not_found` when `postUrl` is not a post's link or this account can't see the post. # Comment on a post (/smart-api/reference/direct-actions/commentLastPost) **JavaScript SDK:** [`swarmhit.accounts.posts.comment(accountId, params)`](/smart-api/sdk/methods#accounts.posts) Comment on a LinkedIn post from this account with the exact text you provide, or reply to one of its comments. Pass exactly one of `postUrl` (that post) or `profileUrl` (the lead's most recent post). With a `postUrl`, `commentId` replies to that comment instead (find one with GET /accounts/{id}/post-comments); a comment's own link (LinkedIn's "Copy link to comment", with `commentUrn=`) as the `postUrl` replies to that comment too. Posts at once. Counts against the account's daily post-comment cap. Returns 422 when the lead has no recent post, and 422 with `code: post_not_found` when `postUrl` is not a post's link (checked before anything else) or this account can't see the post. A reply that can't be made (a comment we can't read, or one on another post) is a 400: it never becomes a comment on the post. A post given by its link isn't tied to a lead: no lead is created, and the action's `profileUrl` is the post's author when that is a person. # Look up a profile (/smart-api/reference/direct-actions/lookupProfile) **JavaScript SDK:** [`swarmhit.accounts.profiles.get(accountId, identifier)`](/smart-api/sdk/methods#accounts.profiles) Resolve a LinkedIn profile by its public identifier (the username in linkedin.com/in/<identifier>) from this sender account. A silent read — no profile view is registered. # List direct actions (/smart-api/reference/direct-actions/listOutreachActions) **JavaScript SDK:** [`swarmhit.accounts.actions.list(accountId, params?)`](/smart-api/sdk/methods#accounts.actions) The direct actions (connection requests, messages, InMails, profile views, follows, post likes and post comments) taken from this sender account, newest first. A request that failed returned its error and was not logged, so the list only holds actions that went out. # Sales Navigator company search (/smart-api/reference/direct-actions/salesNavigatorAccountSearch) **JavaScript SDK:** [`swarmhit.accounts.salesNavigator.accountSearch(accountId, params?)`](/smart-api/sdk/methods#accounts.salesNavigator) Search Sales Navigator for companies (accounts) on this sender's session and get the results directly. Pass free-text `keywords`, or a pasted Sales Navigator company search URL (`url`) for a filtered search; `cursor` pages (25 results a page). Any other link (a people search, a classic LinkedIn search, a Sales Navigator page with no search in it) is a 400 with `code: invalid_search_url`, and nothing is sent to LinkedIn. Needs a Sales Navigator seat on the account — otherwise 422 (409 when the account is not connected or can't be used right now). # List saved Sales Navigator lists (/smart-api/reference/direct-actions/listSalesNavigatorLists) **JavaScript SDK:** [`swarmhit.accounts.salesNavigator.lists(accountId, params?)`](/smart-api/sdk/methods#accounts.salesNavigator) The account's saved Sales Navigator lists — lead lists by default, or account lists with `type=ACCOUNT`. Needs a Sales Navigator seat on the account — otherwise 422 (409 when the account is not connected or can't be used right now). # List emails sent through the API (/smart-api/reference/direct-actions/listDirectEmails) **JavaScript SDK:** [`swarmhit.emailAccounts.emails.list(emailAccountId, params?)`](/smart-api/sdk/methods#emailAccounts.emails) The emails this mailbox sent with POST /email-accounts/{id}/emails, newest first. Campaign emails and inbox replies are not listed. A request that failed returned its error and was not logged. # Send an email (/smart-api/reference/direct-actions/sendEmail) **JavaScript SDK:** [`swarmhit.emailAccounts.emails.send(emailAccountId, params)`](/smart-api/sdk/methods#emailAccounts.emails) Send one email from this mailbox to an address, outside any campaign. The body is plain text: the mailbox's signature is added, and its open and click tracking apply. The address is tracked as a lead (a lead that already has it is reused). A reply lands in the inbox, in the same thread, and fires `email.received` with `source: direct`; a bounce or spam report fires `email.bounced`. To follow up in the thread, reply in `chatId` with POST /inbox/chats/{id}/messages. Counts against the mailbox's daily email cap and the workspace's email allowance, like a campaign email. Fails with 409 `code: mailbox_paused`, `mailbox_warming` (warmup still holds the mailbox) or `mailbox_not_connected`, and with 409 `code: email_opted_out` or `email_bounced` for an address that unsubscribed, reported an email as spam or hard-bounced, or `blocklisted` for an address (or its domain) on the workspace blocklist: nothing is sent to it. # List AI comment drafts (/smart-api/reference/approvals/listPendingComments) **JavaScript SDK:** [`swarmhit.pendingComments.list(params?)`](/smart-api/sdk/methods#pendingComments) AI-drafted comments on leads' posts awaiting manual approval (from `comment_post` steps with review on), newest first. Defaults to status `pending`; pass `status=all` for every status. # Get a comment draft (/smart-api/reference/approvals/getPendingComment) **JavaScript SDK:** [`swarmhit.pendingComments.get(pendingCommentId)`](/smart-api/sdk/methods#pendingComments) # Edit a comment draft (/smart-api/reference/approvals/editPendingComment) **JavaScript SDK:** [`swarmhit.pendingComments.update(pendingCommentId, params)`](/smart-api/sdk/methods#pendingComments) Replace the draft comment text without approving it. An unknown id is a 400 with `code: warning-pending-comment-not-found`, and a draft that is no longer `pending` a 400 with `code: warning-pending-comment-decided`. # Approve a comment draft (/smart-api/reference/approvals/approvePendingComment) **JavaScript SDK:** [`swarmhit.pendingComments.approve(pendingCommentId, params?)`](/smart-api/sdk/methods#pendingComments) Approve a draft (optionally replacing its text) so the campaign posts it on its next run. An unknown id is a 400 with `code: warning-pending-comment-not-found`, and a draft that is no longer `pending` a 400 with `code: warning-pending-comment-decided`. # Reject a comment draft (/smart-api/reference/approvals/rejectPendingComment) **JavaScript SDK:** [`swarmhit.pendingComments.reject(pendingCommentId)`](/smart-api/sdk/methods#pendingComments) Reject a draft — the lead skips the comment step and continues. An unknown id is a 400 with `code: warning-pending-comment-not-found`, and a draft that is no longer `pending` a 400 with `code: warning-pending-comment-decided`. # Re-draft a comment (/smart-api/reference/approvals/regeneratePendingComment) **JavaScript SDK:** [`swarmhit.pendingComments.regenerate(pendingCommentId, params?)`](/smart-api/sdk/methods#pendingComments) Ask the AI to re-draft the comment, optionally with a new instruction. Keeps the same post context. An unknown id is a 400 with `code: warning-pending-comment-not-found`, and a draft that is no longer `pending` a 400 with `code: warning-pending-comment-decided`. # List AI voice notes awaiting review (/smart-api/reference/approvals/listPendingVoiceNotes) **JavaScript SDK:** [`swarmhit.pendingVoiceNotes.list(params?)`](/smart-api/sdk/methods#pendingVoiceNotes) Generated AI voice notes awaiting manual approval (from `ai_voice_note` steps with review on), newest first. `audioUrl` plays exactly what the lead would hear, and `script` is what it says. Defaults to status `pending`; pass `status=all` for every status. # Get a voice note awaiting review (/smart-api/reference/approvals/getPendingVoiceNote) **JavaScript SDK:** [`swarmhit.pendingVoiceNotes.get(pendingVoiceNoteId)`](/smart-api/sdk/methods#pendingVoiceNotes) # Approve a voice note (/smart-api/reference/approvals/approvePendingVoiceNote) **JavaScript SDK:** [`swarmhit.pendingVoiceNotes.approve(pendingVoiceNoteId)`](/smart-api/sdk/methods#pendingVoiceNotes) Approve a generated note so the campaign sends it on its next run. The audio is sent as it stands, so to change the wording call `regeneratePendingVoiceNote` first. An unknown id is a 400 with `code: warning-pending-voice-note-not-found`, and a note that is no longer `pending` a 400 with `code: warning-pending-voice-note-decided`. # Reject a voice note (/smart-api/reference/approvals/rejectPendingVoiceNote) **JavaScript SDK:** [`swarmhit.pendingVoiceNotes.reject(pendingVoiceNoteId)`](/smart-api/sdk/methods#pendingVoiceNotes) Reject a generated note. The lead continues down the "no reply" branch without it. An unknown id is a 400 with `code: warning-pending-voice-note-not-found`, and a note that is no longer `pending` a 400 with `code: warning-pending-voice-note-decided`. # Re-record a voice note (/smart-api/reference/approvals/regeneratePendingVoiceNote) **JavaScript SDK:** [`swarmhit.pendingVoiceNotes.regenerate(pendingVoiceNoteId, params?)`](/smart-api/sdk/methods#pendingVoiceNotes) Re-record the note, optionally rewriting what it says. The script and the audio are always replaced together, so there is no way to edit one without the other. New wording uses the workspace's credits; identical wording replays the cached audio for free. An unknown id is a 400 with `code: warning-pending-voice-note-not-found`, and a note that is no longer `pending` a 400 with `code: warning-pending-voice-note-decided`. # List call tasks (/smart-api/reference/call-tasks/listCallTasks) **JavaScript SDK:** [`swarmhit.callTasks.list(params?)`](/smart-api/sdk/methods#callTasks) The call tasks campaigns' `call` steps made for the team, newest first. Defaults to status `pending` (waiting for a call); pass `status=all` for every status. A task stays `pending` until someone logs the call (`done`), skips it (`skipped`) or stops the lead's sequence from it (`stopped`), or until it runs out at `dueAt` (`expired`), and the lead then moves on. It is `closed` when the lead left the step on its own: a reply, a pause, the campaign paused or deleted, or the lead removed (`closedReason` says which). `call_task.pending` fires when a task is made. # Get a call task (/smart-api/reference/call-tasks/getCallTask) **JavaScript SDK:** [`swarmhit.callTasks.get(callTaskId)`](/smart-api/sdk/methods#callTasks) # Log a call (/smart-api/reference/call-tasks/completeCallTask) **JavaScript SDK:** [`swarmhit.callTasks.complete(callTaskId, params)`](/smart-api/sdk/methods#callTasks) Log the call: how it went and an optional note. The lead moves on to its next step at once, as when the call is logged in the app. An unknown id is a 400 with `code: warning-call-task-not-found`, and a task that is no longer `pending` a 400 with `code: warning-call-task-decided`. A task whose lead already left the step on its own is closed, and returns a 400 with `code: warning-call-task-stale`. A task decided elsewhere at the same moment (in the app, or by another call) is a 400 with `code: warning-call-task-raced`: the first decision stands. # Skip a call (/smart-api/reference/call-tasks/skipCallTask) **JavaScript SDK:** [`swarmhit.callTasks.skip(callTaskId)`](/smart-api/sdk/methods#callTasks) Skip the call: the lead moves on to its next step at once, without one. An unknown id is a 400 with `code: warning-call-task-not-found`, and a task that is no longer `pending` a 400 with `code: warning-call-task-decided`. A task whose lead already left the step on its own is closed, and returns a 400 with `code: warning-call-task-stale`. A task decided elsewhere at the same moment (in the app, or by another call) is a 400 with `code: warning-call-task-raced`: the first decision stands. # Stop the lead's sequence (/smart-api/reference/call-tasks/stopCallTask) **JavaScript SDK:** [`swarmhit.callTasks.stop(callTaskId, params?)`](/smart-api/sdk/methods#callTasks) Stop the lead's sequence from the task, for example when the call showed they are not interested: the lead ends with status `stopped` (and `lead.status_changed` fires). An optional outcome and note record the call on the task. A lead whose sequence can no longer be stopped is a 400 with `code: warning-call-task-stop-failed`, and the task stays `pending`. An unknown id is a 400 with `code: warning-call-task-not-found`, and a task that is no longer `pending` a 400 with `code: warning-call-task-decided`. A task whose lead already left the step on its own is closed, and returns a 400 with `code: warning-call-task-stale`. A task decided elsewhere at the same moment (in the app, or by another call) is a 400 with `code: warning-call-task-raced`: the first decision stands. # List inbox chats (/smart-api/reference/inbox/listInboxChats) **JavaScript SDK:** [`swarmhit.inbox.chats.list(params?)`](/smart-api/sdk/methods#inbox.chats) Ordered by most recent activity. With `replied=true`, ordered by the lead's most recent reply instead. Each thread carries its lead's `label` in the campaign. # Get an inbox chat (/smart-api/reference/inbox/getInboxChat) **JavaScript SDK:** [`swarmhit.inbox.chats.get(chatId)`](/smart-api/sdk/methods#inbox.chats) # List messages in a chat (/smart-api/reference/inbox/listChatMessages) **JavaScript SDK:** [`swarmhit.inbox.chats.messages.list(chatId, params?)`](/smart-api/sdk/methods#inbox.chats.messages) # Send a message in a chat (/smart-api/reference/inbox/sendChatMessage) **JavaScript SDK:** [`swarmhit.inbox.chats.messages.send(chatId, params)`](/smart-api/sdk/methods#inbox.chats.messages) Sends as the chat's sender account, or from the mailbox on an email chat (a reply in the same thread, to the address that last wrote). Email chats take `text` only for now: `attachments` or a `voiceMessage` on one is a `400`. # Mark a chat read (/smart-api/reference/inbox/markChatRead) **JavaScript SDK:** [`swarmhit.inbox.chats.markRead(chatId)`](/smart-api/sdk/methods#inbox.chats) Clears the unread count for the chat (and every sibling thread of the same person), locally and on LinkedIn. # Star or unstar a chat (/smart-api/reference/inbox/setChatFavorite) **JavaScript SDK:** [`swarmhit.inbox.chats.setFavorite(chatId, params)`](/smart-api/sdk/methods#inbox.chats) # Pull a chat's latest messages from LinkedIn (/smart-api/reference/inbox/syncChatMessages) **JavaScript SDK:** [`swarmhit.inbox.chats.sync(chatId, params?)`](/smart-api/sdk/methods#inbox.chats) New messages normally arrive on their own; this pulls the latest page of the thread now. `limit` 1–250, default 50. `notFound` when LinkedIn no longer has the thread; email chats answer `skipped: email` and WhatsApp chats `skipped: whatsapp`. # Pull a sender's recent chats from LinkedIn (/smart-api/reference/inbox/syncInboxChats) **JavaScript SDK:** [`swarmhit.inbox.sync(params)`](/smart-api/sdk/methods#inbox) Fetches the sender's most recent LinkedIn conversations now and keeps the ones with a workspace lead or campaign lead. `limit` 1–100, default 50. 409 `account_not_connected` when the sender has no live LinkedIn connection; 503 `provider_unavailable` when LinkedIn can't be reached right now. # Unread messages, replies, favourites and labels (/smart-api/reference/inbox/getInboxCounts) **JavaScript SDK:** [`swarmhit.inbox.getCounts(params?)`](/smart-api/sdk/methods#inbox) The Unibox badges, workspace-wide or for one sender (`accountId`) or one channel (`channel`). # List webhook endpoints (/smart-api/reference/webhooks/listWebhooks) **JavaScript SDK:** [`swarmhit.webhooks.list(params?)`](/smart-api/sdk/methods#webhooks) # Create a webhook endpoint (/smart-api/reference/webhooks/createWebhook) **JavaScript SDK:** [`swarmhit.webhooks.create(params)`](/smart-api/sdk/methods#webhooks) Registers an endpoint that receives the subscribed events. The response includes a `secret` — store it to verify delivery signatures. # List the delivery log (/smart-api/reference/webhooks/listWorkspaceWebhookDeliveries) **JavaScript SDK:** [`swarmhit.webhooks.deliveries.list(params?)`](/smart-api/sdk/methods#webhooks.deliveries) Every event delivery across all of the workspace's endpoints, newest first, with the full `payload` on each row. Filter by event, status or endpoint. Rows are dropped 7 days after creation, so poll or export to keep a permanent record. # Replay a delivery (/smart-api/reference/webhooks/resendWebhookDelivery) **JavaScript SDK:** [`swarmhit.webhooks.deliveries.resend(deliveryId)`](/smart-api/sdk/methods#webhooks.deliveries) Re-queues a past delivery to its endpoint's current URL as a fresh delivery (a new `id`, so a consumer that dedupes will re-process it). The endpoint must still exist and be enabled. # Get a webhook endpoint (/smart-api/reference/webhooks/getWebhook) **JavaScript SDK:** [`swarmhit.webhooks.get(webhookId)`](/smart-api/sdk/methods#webhooks) # Update a webhook endpoint (/smart-api/reference/webhooks/updateWebhook) **JavaScript SDK:** [`swarmhit.webhooks.update(webhookId, params)`](/smart-api/sdk/methods#webhooks) # Delete a webhook endpoint (/smart-api/reference/webhooks/deleteWebhook) **JavaScript SDK:** [`swarmhit.webhooks.delete(webhookId)`](/smart-api/sdk/methods#webhooks) # Send a test delivery (/smart-api/reference/webhooks/testWebhook) **JavaScript SDK:** [`swarmhit.webhooks.test(webhookId)`](/smart-api/sdk/methods#webhooks) Fires a one-off `webhook.test` delivery and returns the result immediately. Does not retry or affect the endpoint's health counters. # List recent deliveries (/smart-api/reference/webhooks/listWebhookDeliveries) **JavaScript SDK:** [`swarmhit.webhooks.listDeliveries(webhookId, params?)`](/smart-api/sdk/methods#webhooks) The recent delivery attempts for a webhook — useful for debugging an integration. Rows are dropped 7 days after creation. # List the blocklist (/smart-api/reference/blocklist/listBlocklistEntries) **JavaScript SDK:** [`swarmhit.blocklist.list(params?)`](/smart-api/sdk/methods#blocklist) The workspace blocklist, newest first: people and companies never enrolled or contacted, on any channel. A lead matching an entry is not added to a campaign (it counts as `blocklisted`); a lead already in a campaign stops as soon as the entry is added, or at its next step (status `stopped`, with a `blocklisted` entry in its `history`), and its open call tasks close; a direct action or email to them is refused with 409 `code: blocklisted`. A reply in an existing conversation, from the inbox or through `POST /inbox/chats/{id}/messages`, still goes out. # Add to the blocklist (/smart-api/reference/blocklist/addBlocklistEntries) **JavaScript SDK:** [`swarmhit.blocklist.add(params)`](/smart-api/sdk/methods#blocklist) Adds up to 1000 values: `values` as typed, each read as an email address, a domain (`acme.com`, `@acme.com` or a link to the site) or a LinkedIn profile link, or `entries` with their `type` given. An address also blocks its +tag forms (`jane@acme.com` blocks `jane+news@acme.com`). A domain covers its subdomains and matches email addresses only, so a lead with no email (LinkedIn only) is matched by its profile link. A personal email provider's domain (`gmail.com`) or a public suffix (`co.uk`) is refused: it would block everyone there. A LinkedIn profile is matched by its public identifier or its member id, whichever the lead is known by; a Sales Navigator link or id is refused (no profile read gives it back), as is an internationalized domain name for now. A value already on the list counts as `alreadyBlocked`; one that reads as none of these, or is refused, is returned in `invalid` with why. The leads in campaigns that the new entries match stop at once: up to 500 before the response (`stopped`), any more right after it, in the background (`stopping`). # Remove from the blocklist (/smart-api/reference/blocklist/removeBlocklistEntries) **JavaScript SDK:** [`swarmhit.blocklist.remove(params)`](/smart-api/sdk/methods#blocklist) Removes up to 1000 entries: `values` read as when they were added, or `entries` as `{ type, value }` (as `GET /blocklist` lists them). Entries not on the list are ignored; values that read as none are returned in `invalid`. Leads the entries already stopped stay stopped. Takes an owner or admin: the API key's creator, of this workspace or of its organization (403 `code: not-allowed` otherwise). # Get the blocklist settings (/smart-api/reference/blocklist/getBlocklistSettings) **JavaScript SDK:** [`swarmhit.blocklist.settings.get()`](/smart-api/sdk/methods#blocklist.settings) # Update the blocklist settings (/smart-api/reference/blocklist/updateBlocklistSettings) **JavaScript SDK:** [`swarmhit.blocklist.settings.update(params)`](/smart-api/sdk/methods#blocklist.settings) Takes an owner or admin, as removing entries does (403 `code: not-allowed` otherwise). # Remove one blocklist entry (/smart-api/reference/blocklist/removeBlocklistEntry) **JavaScript SDK:** [`swarmhit.blocklist.delete(blocklistId)`](/smart-api/sdk/methods#blocklist) Leads it already stopped stay stopped. Takes an owner or admin, as removing entries does (403 `code: not-allowed` otherwise). # List countries (/smart-api/reference/reference/listCountries) **JavaScript SDK:** [`swarmhit.countries.list()`](/smart-api/sdk/methods#countries) The ISO 3166-1 alpha-2 countries accepted as a sender-account `country`. # List timezones (/smart-api/reference/reference/listTimezones) **JavaScript SDK:** [`swarmhit.timezones.list()`](/smart-api/sdk/methods#timezones) The IANA timezones accepted by a campaign schedule `timezone`. `offset` is the zone's current UTC offset and shifts with daylight saving, so treat `id` as the stable value. # List voices (/smart-api/reference/reference/listVoices) **JavaScript SDK:** [`swarmhit.voices.list()`](/smart-api/sdk/methods#voices) The voices an `ai_voice_note` step can speak in: the shared library plus any voice this workspace cloned from its own recording. Put `id` into the step's `config.voiceId` for all senders, or into an item in `config.voiceAssignments` for sender-specific voices. These are synthesized voices only: a recorded `voice_note` step carries its own audio, per sender, and takes nothing from here. Cloned voices cost more credits per voice note than library voices. Cloning needs an audio upload and is done in Swarmhit (Settings → Voices), not through the API. # List WhatsApp numbers (/smart-api/reference/whatsapp-accounts/listWhatsappAccounts) **JavaScript SDK:** [`swarmhit.whatsappAccounts.list(params?)`](/smart-api/sdk/methods#whatsappAccounts) Linked WhatsApp numbers, newest first: status, connection, today's caps (the warm-up applied), usage, warm-up, and WhatsApp's own limits on the number (its new-chat quota and its block on new chats). # Link a WhatsApp number (/smart-api/reference/whatsapp-accounts/linkWhatsappAccount) **JavaScript SDK:** [`swarmhit.whatsappAccounts.link(params)`](/smart-api/sdk/methods#whatsappAccounts) Start linking a number: show the returned QR code to the person holding the phone (WhatsApp, Settings, Linked devices, Link a device), or with `method: code` give them the pairing code to type on the phone. The QR changes about every 20 seconds: follow GET /whatsapp-accounts/{id}/link until it reads `active`, when `account.connected` also fires. A number already linked (in this workspace or another) is refused once scanned, and its new device leaves the phone. Needs the WhatsApp channel enabled for the workspace (`403` otherwise). Rate-limited: 10 links an hour per workspace, 3 pairing codes a day per phone number. # Get a WhatsApp number (/smart-api/reference/whatsapp-accounts/getWhatsappAccount) **JavaScript SDK:** [`swarmhit.whatsappAccounts.get(whatsappAccountId)`](/smart-api/sdk/methods#whatsappAccounts) # Change a number's name, caps, warm-up or sender (/smart-api/reference/whatsapp-accounts/updateWhatsappAccount) **JavaScript SDK:** [`swarmhit.whatsappAccounts.update(whatsappAccountId, params)`](/smart-api/sdk/methods#whatsappAccounts) Only the fields given are written. A value out of range is refused with `400` and the reason, never clamped. # Remove a WhatsApp number (/smart-api/reference/whatsapp-accounts/removeWhatsappAccount) **JavaScript SDK:** [`swarmhit.whatsappAccounts.delete(whatsappAccountId)`](/smart-api/sdk/methods#whatsappAccounts) Unlink the number: its device leaves the phone, its chats leave the inbox, and it leaves every campaign pool (leads it never wrote to move to the remaining numbers). # Follow a link (/smart-api/reference/whatsapp-accounts/getWhatsappLink) **JavaScript SDK:** [`swarmhit.whatsappAccounts.getLink(whatsappAccountId)`](/smart-api/sdk/methods#whatsappAccounts) Where a link stands: `pending` with the current checkpoint (the QR changes about every 20 seconds), `expired` (nothing was scanned in time: POST /whatsapp-accounts/{id}/relink), `active` with the number, or `error` with its `code` (`duplicate`: the number is already linked). # Scan again (/smart-api/reference/whatsapp-accounts/relinkWhatsappAccount) **JavaScript SDK:** [`swarmhit.whatsappAccounts.relink(whatsappAccountId, params?)`](/smart-api/sdk/methods#whatsappAccounts) A new QR code (or pairing code) for a number that was logged out, or for a link that expired. Its campaigns and chats carry on once it is linked again. Scanned by another phone than its own number, the link is undone. # Cancel a link (/smart-api/reference/whatsapp-accounts/cancelWhatsappLink) **JavaScript SDK:** [`swarmhit.whatsappAccounts.cancelLink(whatsappAccountId)`](/smart-api/sdk/methods#whatsappAccounts) Give up a link before the scan. A number that was linked before goes back to needing a scan. # Pause a WhatsApp number (/smart-api/reference/whatsapp-accounts/pauseWhatsappAccount) **JavaScript SDK:** [`swarmhit.whatsappAccounts.pause(whatsappAccountId)`](/smart-api/sdk/methods#whatsappAccounts) Campaigns stop sending from the number until it is resumed; its leads stay assigned, and replies still arrive. Emits `account.paused` with `channel: whatsapp`. # Resume a WhatsApp number (/smart-api/reference/whatsapp-accounts/resumeWhatsappAccount) **JavaScript SDK:** [`swarmhit.whatsappAccounts.resume(whatsappAccountId)`](/smart-api/sdk/methods#whatsappAccounts) # WhatsApp number performance and health (/smart-api/reference/whatsapp-accounts/getWhatsappAccountStats) **JavaScript SDK:** [`swarmhit.whatsappAccounts.getStats(whatsappAccountId)`](/smart-api/sdk/methods#whatsappAccounts) The last 30 days of campaign messages from the number (sent, new chats, answered, delivered, read), today's usage against today's caps, and a 0-100 health score with its reasons. # Create an embedded session (/smart-api/reference/embedded-components/createEmbedSession) **JavaScript SDK:** [`swarmhit.embed.sessions.create(params)`](/smart-api/sdk/methods#embed.sessions) Starts a session for one of your users, so your page can show them the Swarmhit inbox, tasks and campaigns. Call it from your backend, never from the browser: it takes your API key. Pass the returned `clientSecret` to your page, which hands it to the embedded components. On the page: load `https://app.swarmhit.com/embed.js`, place an element such as ``, and call `Swarmhit.init({ fetchClientSecret })` with a function that asks your backend for a secret. The integration guide has the full walkthrough. The elements are ``, `` and ``. Each one calls `fetchClientSecret` when it loads and again when its session ends, so the function must return a fresh secret every time. The secret works once and only for 5 minutes. The session it starts lasts 12 hours. The user reaches only the LinkedIn accounts in `accountIds`: their conversations, and the campaigns whose every sender is one of them. They reach none of your settings, accounts or billing. `permissions` says what they may do there, for example `["inbox", "tasks", "campaigns.analytics", "campaigns.leads"]`. A screen's name (`inbox`, `tasks`, `campaigns`) grants all of that screen; an edit permission includes its read. Screens and tabs the user lacks are not shown, and Swarmhit refuses anything outside them, so the list is the boundary, not a display setting. Campaigns they create send from their accounts. Your latest session for a user sets what all of their open components reach, so a call with fewer accounts or permissions narrows the screens they already have open. # End a user's embedded sessions (/smart-api/reference/embedded-components/revokeEmbedSessions) **JavaScript SDK:** [`swarmhit.embed.users.revokeSessions(userId)`](/smart-api/sdk/methods#embed.users) Ends every session of one of your users at once: secrets not used yet stop working and their open components are signed out. Call it when a user leaves your platform or loses access. `revoked` is false when that user never had a session. # Health of the LinkedIn side (/raw-api/reference/health/health) No key needed. 503 `{ ok: false, db: false }` when the database does not answer. Deploys read `build`, `browsers` and `signIns`. # Health of the WhatsApp side (/raw-api/reference/health/whatsappHealth) No key needed. Served by the process that holds the WhatsApp connections. # Health of the email side (/raw-api/reference/health/emailHealth) No key needed. Whether the mail engine answers (read at most every 10 s). 503 when the database does not answer, or when the engine is configured and does not answer. Deploys never read it: an engine blip does not fail a deploy. # List LinkedIn accounts (/raw-api/reference/linkedin-accounts/listLinkedinAccounts) The accounts as full `Account` objects (no secrets), newest first, a page of `limit` (default 100, at most 250); `cursor` gives the next page, null on the last. # Connect a LinkedIn account (/raw-api/reference/linkedin-accounts/linkedinConnectAccount) Credentials or cookie. A credentials connect signs in and answers when it reaches a stop: signed in (201), a checkpoint (202), or an error. It can take up to about 150 s. See *Connecting a LinkedIn account* in the introduction. A refused connect (wrong password, throttled, proxy) keeps nothing: retrying it reuses the same device and IP. # Get a LinkedIn account (/raw-api/reference/linkedin-accounts/getLinkedinAccount) From Swarmhit's own data: no call to LinkedIn. # Reconnect a LinkedIn account (/raw-api/reference/linkedin-accounts/linkedinReconnectAccount) Signs the same account in again (same id, device and IP), with credentials or a cookie; the same answers as a connect, `AccountReconnected` instead of `AccountCreated`. A restricted account, or one held by its proxy, is reconnected only with `reason: "manual"` (a person asked); otherwise 403 / 503. A `proxy` or a first `country` sent here is used from this sign-in on. # Delete a LinkedIn account (/raw-api/reference/linkedin-accounts/deleteLinkedinAccount) Ends its session, browser profile and proxy session, and erases it. # Answer a checkpoint (/raw-api/reference/linkedin-accounts/solveLinkedinCheckpoint) Types `code` (an email/SMS code, an authenticator code, or a phone number for `PHONE_REGISTER`) into the sign-in still waiting on the checkpoint page. The answer is the next stop: signed in (201 `AccountCreated`, also for a reconnect), another checkpoint (202: LinkedIn chains them), or an error. A code LinkedIn did not accept is 401 `code-rejected` (`errors/invalid_checkpoint_solution`): the sign-in still waits for the right one. 408 `no-attempt` means the sign-in is gone: connect again. # Ask for a new checkpoint code (/raw-api/reference/linkedin-accounts/resendLinkedinCheckpoint) Clicks LinkedIn's "resend" on the waiting checkpoint. Answers the next stop, like *Answer a checkpoint*. # Open a new live view (/raw-api/reference/linkedin-accounts/mintLinkedinLiveView) A new single-use token for the browser of the sign-in waiting on a CAPTCHA or an unknown page (the previous one was used or expired). 408 `no-attempt` when nothing waits in a browser. # Pick a new IP (/raw-api/reference/linkedin-accounts/recoverLinkedinAccount) For an account its connection stopped (`proxy_drift`, `proxy_down`), or one not yet signed in: moves it to a new sticky IP in its country now instead of waiting for the scheduled recovery. 400 for an account in use or held for another reason; 503 when no IP was found or the account uses its own proxy. # Pause a LinkedIn account (/raw-api/reference/linkedin-accounts/pauseLinkedinAccount) For billing holds, or a sender switched off. Nothing reaches LinkedIn for it until resumed: its calls answer 409 `paused`. No event is sent. # Resume a LinkedIn account (/raw-api/reference/linkedin-accounts/resumeLinkedinAccount) Back to where the pause found it (a hold under the pause comes back too). Then read the account: `OK`, or `CREDENTIALS` and it needs a reconnect. A no-op on an account that is not paused. # Hosted auth link (/raw-api/reference/linkedin-accounts/linkedinHostedLink) A link to a page we host where your own user connects a LinkedIn account (`type: create`), or reconnects one of yours (`type: reconnect` with `reconnect_account`), a hosted sign-in: you never see their password. Send them to `url`; it works until `expiresOn` (default 1 hour, at most 7 days) and ends once an account is connected or they cancel. A sign-in under way at the expiry may finish its verification step for 30 more minutes. When an account is connected, `notify_url` (https, public) receives `{ "status": "CREATION_SUCCESS" | "RECONNECTED", "account_id": "lg_…", "name": "" }` (up to 3 tries), then the page sends the person to `success_redirect_url`; a cancel sends them to `failure_redirect_url`. Check the account with `GET /api/v1/accounts/{id}`: the notification is not signed. Your webhook gets the account's usual events too. The page signs in exactly as `POST /api/v1/accounts` does (the same checkpoints, the live view for a CAPTCHA). A reconnect takes the account's own email only. `country` places a new account's IP (else the person's own location). At most 10 sign-in tries per link. # The signed-in member (/raw-api/reference/linkedin-users-and-invitations/getLinkedinOwner) The account's own profile and plans. Cached for an hour; a plan bought or ended shows within it (and as a `gateway.plan_changed` event). # A member's profile (/raw-api/reference/linkedin-users-and-invitations/getLinkedinProfile) `identifier` is a public identifier (`jane-doe`) or a provider id (`ACoA…`). `notify=true` makes the read a visit the member sees (counted in the `visit` cap, once a day per profile). `open_profile=1` also reads whether the member takes free InMails (`is_open_profile`): the member's own setting, also for a connection, when the account has a Sales Navigator seat; without one, known only for a member you are not connected to (LinkedIn shows a connection's setting nowhere else), else `null`. # Connections (/raw-api/reference/linkedin-users-and-invitations/listLinkedinRelations) The connections, newest first, a page of `limit` (at most 40, as LinkedIn's connections page loads). Pass `cursor` back for the next page; `null` on the last. Some accounts can page through their newest 200 connections only: past them the list ends (`cursor` is `null`). # Followers (/raw-api/reference/linkedin-users-and-invitations/listLinkedinFollowers) The account's own followers, as LinkedIn lists them (newest first), a page of `limit` (at most 100), read from LinkedIn 10 at a time with a short pause between, so a full page takes several seconds. Pass `cursor` back for the next page; `null` on the last. A company that follows the member is left out. `user_id` may only name the account itself (422 otherwise): LinkedIn shows no one else's followers; another member's are a people search with the `followerOf` filter. # People followed (/raw-api/reference/linkedin-users-and-invitations/listLinkedinFollowing) The people the account follows, in the relations' shape, a page of `limit` (at most 100), read from LinkedIn 10 at a time with a short pause between. Pass `cursor` back for the next page; `null` on the last. The list names each person by provider id only: `public_identifier` is `null` and `public_profile_url` links by provider id; `first_name` and `last_name` split the one name LinkedIn shows at its first space; `created_at` is `null`. # Send an invitation (/raw-api/reference/linkedin-users-and-invitations/sendLinkedinInvitation) A write (`invite`, plus a `visit` when the profile is opened on the way). A note over 300 characters is refused (422 `too_many_characters`), never cut. LinkedIn's refusals: 422 `errors/cannot_resend_yet` (its limit, or too soon to invite again: cool the sender down) and `errors/already_invited_recently` (already pending: treat as sent). # Pending sent invitations (/raw-api/reference/linkedin-users-and-invitations/listLinkedinSentInvitations) The invitations the account sent that are still pending, a page of `limit`. Pass `cursor` back for the next page; `null` on the last. # Withdraw an invitation (/raw-api/reference/linkedin-users-and-invitations/withdrawLinkedinInvitation) A write (`withdraw`). 404 when it is no longer pending. # Pending received invitations (/raw-api/reference/linkedin-users-and-invitations/listLinkedinReceivedInvitations) The invitations the account received and has not answered yet, a page of `limit`. Pass `cursor` back for the next page; `null` on the last. # Handle a received invitation (/raw-api/reference/linkedin-users-and-invitations/acceptLinkedinInvitation) `action` `accept` (the default) accepts it; `decline` ignores it, as the invitation manager's Ignore does (the inviter is not told). Both are writes under the `accept` daily cap. With `shared_secret` (from `GET /users/invite/received`) it is used as given; without it, the invitation must be among the newest 100 received, 404 otherwise. # A member's comments (/raw-api/reference/linkedin-posts/listLinkedinUserComments) The comments a member wrote, newest first, one page of `limit` posts commented on (a post commented twice gives two comments). Pass `cursor` back for the next page; `null` on the last. An account connected with email and password only: an account connected with a cookie (`access_token`) answers 422. A sandbox account answers however it connected. A public identifier is resolved to the provider id first. # A member's reactions (/raw-api/reference/linkedin-posts/listLinkedinUserReactions) The posts a member reacted to, newest first, one page of `limit`. `author` is the post's author. `value` is the reaction when the member is the account itself; for another member LinkedIn names it only in words ("… loves this", in the account's language), so it is `null`. An account connected with email and password only: an account connected with a cookie (`access_token`) answers 422. A sandbox account answers however it connected. A public identifier is resolved to the provider id first. # A member's posts (/raw-api/reference/linkedin-posts/listLinkedinPosts) Their own posts (no reposts), newest first, a page of `limit`; pass `cursor` back for the next, `null` on the last. A public identifier is resolved to the provider id first. `is_company=true` answers 422: a company's posts are not offered yet. # Create a post or a repost (/raw-api/reference/linkedin-posts/createLinkedinPost) Multipart (with an image) or JSON. A public post (visible to anyone, comments from anyone), a write under its own daily cap (`post`, 20). `text` up to 3000 characters; one JPEG, PNG or GIF image in `attachments`; `repost` (a post id or urn) shares that post, with `text` as its commentary or empty. Not offered yet, each answering 422: mentions (`{{n}}` in the text, `mentions`), more than one image, a video, `external_link`, `include_job_posting`, `targeted_audience`, `as_organization`, `location`, `post_type`. A post needs `text`, an image or a `repost` (400 otherwise). # React to a post or a comment (/raw-api/reference/linkedin-posts/reactToLinkedinPost) A write (`reaction`). Unknown `reaction_type` values react `like`. `as_organization` answers 422. # Retrieve a post (/raw-api/reference/linkedin-posts/getLinkedinPost) One post as the account sees it (its own reaction in `user_reacted`; `impressions_counter` for the author only). `postId` is the activity's number as a link carries it, or a `urn:li:activity` / `ugcPost` / `share` urn. 404 when the account cannot see it. # Who reacted to a post (/raw-api/reference/linkedin-posts/listLinkedinPostReactions) The reactions: each reactor with LinkedIn's reaction type, a page of `limit`; a comment's with `comment_id`. Pass `cursor` back for the next page; `null` on the last. # A post's comments (/raw-api/reference/linkedin-posts/listLinkedinPostComments) The post's own comments (replies are a comment's, with `comment_id`), newest first by default, a page of `limit`. Pass `cursor` back for the next page; `null` on the last. # Comment on a post, or reply to a comment (/raw-api/reference/linkedin-posts/commentOnLinkedinPost) A write (`comment`), JSON or multipart. `comment_id` replies to that comment. `mentions`, `external_link`, `as_organization` and attachments answer 422: not offered yet. # List conversations (/raw-api/reference/linkedin-messaging/listLinkedinChats) Newest first, read from LinkedIn as the member's inbox scrolls (pages of 20, up to 10 pages a call). `cursor` is a time anchor to continue from. `unread=true` keeps only unread ones of the page read. # Send a message to a member, or an InMail (/raw-api/reference/linkedin-messaging/startLinkedinChat) A message to a connection (in your existing conversation with them when there is one), or an InMail with `linkedin.inmail` (`linkedin[inmail]` in multipart). A Sales Navigator InMail (`linkedin.api: sales_navigator`) goes through the Sales Navigator inbox to the lead's Sales Navigator profile; its `chat_id` is that conversation, which `GET`/`POST /api/v1/chats/{id}/messages` and `PATCH /api/v1/chats/{id}` then read, answer and mark read. A write (`message` or `inmail`). JSON, or multipart with `attachments` / `voice_message` files. # Get a conversation (/raw-api/reference/linkedin-messaging/getLinkedinChat) From Swarmhit's chat record (no call to LinkedIn). # Mark a conversation read or unread (/raw-api/reference/linkedin-messaging/patchLinkedinChat) Only `setReadStatus` is supported on LinkedIn; any other action is 422. # Delete a chat (/raw-api/reference/linkedin-messaging/deleteLinkedinChat) A write (`message`). The conversation leaves the member's inbox; LinkedIn's delete is one-sided, the other member keeps it. # A conversation's attendees (/raw-api/reference/linkedin-messaging/listLinkedinChatAttendees) From the chat record; read from the inbox when it has none yet. # A conversation's messages (/raw-api/reference/linkedin-messaging/listLinkedinMessages) Newest first, pages of 20 read from LinkedIn (up to 10 a call); `cursor` is a time anchor. A Sales Navigator InMail conversation is read from the Sales Navigator inbox, 10 messages a page; its messages carry `subject`, its people are Sales Navigator ids (`ACw…`) and their files are not listed. # Send a message in a conversation (/raw-api/reference/linkedin-messaging/sendLinkedinMessage) A write (`message`). JSON, or multipart with files. Use `unless_replied_since` on automated follow-ups. In a Sales Navigator InMail conversation the reply goes through Sales Navigator, text only (a file is 422). # React to a message (/raw-api/reference/linkedin-messaging/reactToLinkedinMessage) A write (`reaction`). The message must be among the conversation's 50 most recent (404 otherwise). # List all messages (/raw-api/reference/linkedin-messaging/listAllLinkedinMessages) The account's messages newest first across its chats, its chats read in the order of their last activity, only as many as the page needs. A call looks through the account's 60 most recently active chats at most. # Sync a chat from its beginning (/raw-api/reference/linkedin-messaging/syncLinkedinChat) Always SYNC_DONE: Swarmhit reads a chat's history from LinkedIn whenever it is asked. # List all attendees (/raw-api/reference/linkedin-messaging/listLinkedinAttendees) Each person of the account's chats once, newest chat first, from the chats Swarmhit listed. # Retrieve an attendee (/raw-api/reference/linkedin-messaging/getLinkedinAttendee) The person as their newest chat with the account shows them (404 when none). # List the 1-to-1 chats with an attendee (/raw-api/reference/linkedin-messaging/listLinkedinAttendeeChats) The account's one-to-one chats with that person. # List the messages of an attendee (/raw-api/reference/linkedin-messaging/listLinkedinAttendeeMessages) What that person wrote in their one-to-one chat with the account. # Download an attendee's picture (/raw-api/reference/linkedin-messaging/getLinkedinAttendeePicture) The person's profile picture, its bytes with LinkedIn's image type, loaded on the account's session. 404 when the person has no picture or no chat with the account. # Resync an attendee's chats (/raw-api/reference/linkedin-messaging/syncLinkedinAttendee) Always SYNC_DONE: chats are read from LinkedIn whenever asked. # Retrieve a message (/raw-api/reference/linkedin-messaging/getLinkedinMessage) One message by its id, any Swarmhit read, sent or streamed in the last 90 days (404 otherwise; read its chat first). # Edit a message (/raw-api/reference/linkedin-messaging/editLinkedinMessage) A write (`message`). The member's own message, its new text; LinkedIn takes an edit for an hour after sending. # Delete a message (/raw-api/reference/linkedin-messaging/deleteLinkedinMessage) A write (`message`). The member's own message, deleted for everyone; LinkedIn allows it for an hour after sending. # Add a reaction to a message (/raw-api/reference/linkedin-messaging/addLinkedinMessageReaction) A write (`reaction`), the member's own reaction. # Remove a reaction from a message (/raw-api/reference/linkedin-messaging/removeLinkedinMessageReaction) A write (`reaction`), the member's own reaction taken back. # Download an attachment (/raw-api/reference/linkedin-messaging/downloadLinkedinAttachment) The file's bytes with LinkedIn's content type. Only `attachmentId` (from the message's `attachments[].id`) is read. # Search people (/raw-api/reference/linkedin-search/searchLinkedin) A pasted search URL (classic people search, or Sales Navigator people or companies), or search params. A classic search reads one page of LinkedIn's results, with no totals; a Sales Navigator search runs on the account's seat, 25 a page, with totals. One page a call; pass `cursor` for the next. A filter Swarmhit cannot map is 422: paste the URL instead. # Search filter ids (/raw-api/reference/linkedin-search/getLinkedinSearchParameters) The ids a search filter takes, by typing `keywords` into LinkedIn's filter picker. Fewer than 2 characters answers no items. `REGION` and `SALES_INDUSTRY` (Sales Navigator's names) are the location and industry pickers, whose ids a Sales Navigator search takes. # A company (/raw-api/reference/linkedin-search/getLinkedinCompany) `id` is the company's id or its universal name. # Search companies (Sales Navigator) (/raw-api/reference/linkedin-search/searchLinkedinCompanies) A Sales Navigator company search URL, or free-text `keywords`. It runs a company search only: any other link (a people or classic search, a Sales Navigator page with no search in it, a blank `query`, a port or user name, over 4,000 characters) is a 400 `bad-request` and nothing runs. Items have `type: COMPANY`; paged like the people search. Needs a Sales Navigator seat (422 `feature_not_subscribed`). # Saved Sales Navigator lists (/raw-api/reference/linkedin-search/listLinkedinSalesLists) Lead lists, or account lists with `type=ACCOUNT`. Needs a seat. # InMail credits (/raw-api/reference/linkedin-search/getLinkedinInmailBalance) Per pool: Premium credits and Sales Navigator credits (separate pools; null for one the member does not have). 422 `feature_not_subscribed` without Premium. # Save a Sales Navigator lead (/raw-api/reference/linkedin-search/performLinkedinActionOnUser) An action on a member: Sales Navigator's `saveLead`, a write (`save` daily cap), on the account's seat. `user_id` is the lead's Sales Navigator id (`ACw…`) or its `urn:li:fs_salesProfile:(…)` from a Sales Navigator search; `list_id` (from `GET /linkedin/lists`) also adds it to that list. Recruiter's pipeline actions answer 422: no account here has a Recruiter seat. # Raw LinkedIn request (/raw-api/reference/linkedin-search/linkedinPassthrough) A raw LinkedIn passthrough: a `GET` of any linkedin.com path (Sales Navigator `/sales-api/` paths on the seat's session) answers LinkedIn's JSON as is (`{ data }` for text); a write is allowed only for follow (`followingStates` of a profile, counted as `follow`). Anything else is 422. # List WhatsApp numbers (/raw-api/reference/whatsapp-accounts/listWhatsappAccounts) Newest first, with totals per status. # Link a WhatsApp number (/raw-api/reference/whatsapp-accounts/linkWhatsappAccount) Starts a link and answers once WhatsApp shows something (a few seconds): a QR to scan, or a pairing code with `pairing_phone_number`. Both are 201 `Checkpoint`. The QR rotates about every 20 s: read the current one from `GET /accounts/{id}` (`gateway.checkpoint`). When the phone accepts, the account goes `OK` (events `CREATION_SUCCESS`, `OK`). 503 `whatsapp-connecting` when WhatsApp did not answer: try again. # Get a WhatsApp number (/raw-api/reference/whatsapp-accounts/getWhatsappAccount) # Reconnect a WhatsApp number (/raw-api/reference/whatsapp-accounts/reconnectWhatsappAccount) A number still linked (connected, reconnecting or paused) is reconnected on its login and never relinked: 201 `AccountReconnected` (a paused one is resumed). Only a number whose link is gone gets a new `Checkpoint` to scan; `proxy` or `country` here moves it first. 503 while its proxy holds it (use `/recover`). # Change a number's proxy (/raw-api/reference/whatsapp-accounts/patchWhatsappAccount) A new `proxy`, `country` or `ip`: the number reconnects through it once its exit IP checks out. A number held by its proxy is let go. # Delete a WhatsApp number (/raw-api/reference/whatsapp-accounts/deleteWhatsappAccount) Unlinks the device on the phone (unless `provider_session_revoke=false`; a number not connected right now is not unlinked there, WhatsApp drops an idle linked device after 30 days), erases its login and its stored chats, messages and contacts, and sends `AccountStatus` `DELETED`. 503 `whatsapp-connecting` for a moment during an update of Swarmhit: retry. # Restart a number's connection (/raw-api/reference/whatsapp-accounts/restartWhatsappAccount) Reconnects an active number on its login. Not active: `AccountFailedToRestart`. # Pick a new IP (/raw-api/reference/whatsapp-accounts/recoverWhatsappAccount) A number its connection holds, or one waiting for its next scan, moves to a new session in its country now (two checks 30 s apart). 400 on a number that is connected or held for another reason; 503 when no IP was found in its country right now, or the number uses its own proxy (fix it on the proxy side). # Pause a number (/raw-api/reference/whatsapp-accounts/pauseWhatsappAccount) The link is kept, the connection closed; `AccountStatus` `STOPPED` (reason `paused`) is sent. Sends answer 409 `paused` until resumed. # Resume a number (/raw-api/reference/whatsapp-accounts/resumeWhatsappAccount) Back to where the pause found it, and reconnected when that was active. A no-op on a number not paused. # Resync a whole number (not supported) (/raw-api/reference/whatsapp-accounts/syncWhatsappAccount) Not offered for WhatsApp: always 422. Use `GET /chats/{id}/sync`. # Answer a checkpoint (not for WhatsApp) (/raw-api/reference/whatsapp-accounts/whatsappCheckpoint) Always 422: the pairing code is typed on the phone, nothing is sent back. # Resend a checkpoint (not for WhatsApp) (/raw-api/reference/whatsapp-accounts/whatsappCheckpointResend) Always 422: reconnect for a new QR or pairing code. # Hosted auth link (not offered) (/raw-api/reference/whatsapp-accounts/whatsappHostedLink) Always 422: show the QR from `POST /accounts`. # List chats (/raw-api/reference/whatsapp-chats/listWhatsappChats) From Swarmhit's stored history, newest activity first. A chat is listed from its first message. # Start a chat (/raw-api/reference/whatsapp-chats/startWhatsappChat) One attendee: a message to that person (their chat is reused when it exists; a first message counts as a new chat against the caps and WhatsApp's quota, and the number is checked to be on WhatsApp first). Two or more: a new group named `subject`, with an optional first message. JSON, or multipart for files. Answers 201. # Get a chat (/raw-api/reference/whatsapp-chats/getWhatsappChat) With its last message (`lastMessage`). # Act on a chat (/raw-api/reference/whatsapp-chats/patchWhatsappChat) `action`: `setReadStatus`, `setMuteStatus`, `setArchiveStatus`, `setPinnedStatus` (`value` true/false), `addParticipant`, `removeParticipant` (`value` a JID; groups only), `setLabel` (`value` a label id), `getInviteLink` (groups; answers `invite_link`). 400 for an unknown action or a missing `value`. # Delete a chat (/raw-api/reference/whatsapp-chats/deleteWhatsappChat) Deleted for the number on WhatsApp too (the phone included), and from Swarmhit's stored history. Needs the number connected. # A chat's messages (/raw-api/reference/whatsapp-chats/listWhatsappChatMessages) From Swarmhit's stored history, newest first; readable while the number reconnects. # Send a message in a chat (/raw-api/reference/whatsapp-chats/sendWhatsappMessage) JSON, or multipart for files. `quote_id` replies to a message of this chat. Answers 201. # A chat's attendees (/raw-api/reference/whatsapp-chats/listWhatsappChatAttendees) One-to-one: the person and the number itself. A group: its participants, read from WhatsApp (needs the number connected). # Fetch older history for a chat (/raw-api/reference/whatsapp-chats/syncWhatsappChat) Asks the phone for older messages; they arrive later. Poll: `SYNC_STARTED`, `SYNC_RUNNING`, then `SYNC_DONE` once. # List attendees (/raw-api/reference/whatsapp-attendees/listWhatsappAttendees) The people the numbers talk to (their contacts as WhatsApp reports them, and anyone they messaged), from Swarmhit's stored contacts, newest first. Numbers known not to be on WhatsApp are left out. # Get an attendee (/raw-api/reference/whatsapp-attendees/getWhatsappAttendee) # An attendee's picture (/raw-api/reference/whatsapp-attendees/getWhatsappAttendeePicture) The image bytes, fetched from WhatsApp. A `wac_…` chat id gives a group's picture. 404 when there is none or privacy hides it. # An attendee's chats (/raw-api/reference/whatsapp-attendees/listWhatsappAttendeeChats) `id` may be a comma-separated list (50 at most); each must exist. One-to-one chats only. # An attendee's messages (/raw-api/reference/whatsapp-attendees/listWhatsappAttendeeMessages) Messages they sent, in every chat. `id` may be a comma-separated list (50 at most). # Fetch older history with an attendee (/raw-api/reference/whatsapp-attendees/syncWhatsappAttendee) As `GET /chats/{id}/sync`, for the one-to-one chat with this person (`SYNC_DONE` when there is none). # List messages (/raw-api/reference/whatsapp-messages/listWhatsappMessages) Across chats, from Swarmhit's stored history, newest first. # Get a message (/raw-api/reference/whatsapp-messages/getWhatsappMessage) # Edit a message (/raw-api/reference/whatsapp-messages/editWhatsappMessage) Only a message this number sent (403 otherwise), within 15 minutes of sending it (422 after). # Delete a message (/raw-api/reference/whatsapp-messages/deleteWhatsappMessage) This number's own message: deleted for everyone. Someone else's: deleted for this number only. # Forward a message (/raw-api/reference/whatsapp-messages/forwardWhatsappMessage) Into another chat of the same number that already has a conversation (start new ones with `POST /chats`). # React to a message (/raw-api/reference/whatsapp-messages/reactToWhatsappMessage) Adds this number's reaction to a message, replacing its previous one. An empty `reaction` removes it. 404 on a deleted message. # Download an attachment (/raw-api/reference/whatsapp-messages/downloadWhatsappAttachment) The media, fetched from WhatsApp on demand (nothing is kept), served as a download of the type the message declares. `attachmentId` is `.0`. 422 over 32 MB. # Relations (not for WhatsApp) (/raw-api/reference/whatsapp-users/whatsappRelations) Always 422: relations are LinkedIn connections; a number's people are `GET /chat_attendees`. # The number's own profile (/raw-api/reference/whatsapp-users/getWhatsappOwner) # Edit the number's profile (/raw-api/reference/whatsapp-users/editWhatsappOwner) Multipart (for `picture`) or JSON. `headline` is the name (25 characters), `summary` the about (139), `picture` a JPEG or PNG of 5 MB at most. # Is this number on WhatsApp (/raw-api/reference/whatsapp-users/getWhatsappUser) A phone number, a phone JID or a LID. 404 when it is not on WhatsApp. Each check of a phone counts in the daily `checks` cap; the answer is kept a week. # Simulate LinkedIn on a sandbox account (/raw-api/reference/sandbox/simulateLinkedin) Sandbox only (a `gw_test_` key, a sandbox account): what LinkedIn would do on its own, so your sandbox webhook receives the same event, in the same shape, as on a live account. A live account answers 403. Every field but `account_id` and `kind` is optional: a sandbox person, chat, message or post is picked when it is left out. | `kind` | What happens | Fields it reads | |---|---|---| | `message_received` | A person writes: in `chat_id`, else to the account in their conversation (`provider_id`) or a new one. | `chat_id`, `provider_id`, `text` | | `message_edited` | The other person edits their message (`message_id`, else their newest). | `message_id`, `chat_id`, `provider_id`, `text` | | `message_deleted` | The other person deletes their message. | `message_id`, `chat_id`, `provider_id` | | `message_reaction` | The other person reacts to the account's message (its newest, by default). | `message_id`, `chat_id`, `provider_id`, `reaction` (an emoji) | | `message_read` | The other person reads the account's message (told once per message). | `message_id`, `chat_id`, `provider_id` | | `new_relation` | A pending invitation the account sent (`provider_id`, else the oldest) is accepted. `invitation_accepted` is the same. | `provider_id` | | `invitation_received` | Someone invites the account; it shows in `GET /users/invite/received`. | `provider_id`, `text` | | `post_comment` | Someone comments on one of the account's three newest posts. | `post_id`, `provider_id`, `text` | | `post_reaction` | Someone who has not yet reacts to one of those posts. | `post_id`, `provider_id`, `reaction` (`like`, `celebrate`, `support`, `love`, `insightful`, `funny`) | | `profile_view` | Someone views the account's profile (one view per person a day). | `provider_id` | The change shows when read back: a message in its chat, an edit or a deletion in it, a comment or a reaction on the post. `message_edited`, `message_deleted`, `message_reaction`, `message_read`, `post_comment`, `post_reaction` and `profile_view` reach your webhook only when your company receives live events, as on a live account. # Simulate WhatsApp on a sandbox number (/raw-api/reference/sandbox/simulateWhatsappMessage) Sandbox only (a `gw_test_` key, a sandbox number): what a person does to the number, arriving as WhatsApp's own would, so your sandbox webhook gets the same event as live. A live number answers 403. | `kind` | What happens | |---|---| | `message_received` (default) | `from` writes to the number. | | `message_reaction` | `from` reacts (`reaction`, an emoji) to the number's message (`message_id`, else its newest to them). | | `message_read` | `from` reads the number's message (told once per message). | | `message_edited` | `from` edits their message (`message_id`, else their newest) to `text`. | | `message_deleted` | `from` deletes their message for everyone. | `message_delivered` follows each message the number sends by itself, a moment later. Reads follow too: `GET /messages` shows the reaction, the read, the edit or the deletion. # Email: play an event on a sandbox mailbox (/raw-api/reference/sandbox/simulateEmail) With a `gw_test_` key, on a sandbox mailbox: a mail arrives (`mail_received`), the last sent one bounces, is complained about, opened, clicked or fails, or the mailbox changes `status` (`state`: connected, authenticationError, connectError, disconnected). The events reach your sandbox webhook. 403 for a live mailbox. # List mailboxes (/raw-api/reference/email-accounts/listEmailAccounts) Newest first, with totals per status. Deleted mailboxes are not listed. # Connect a mailbox (/raw-api/reference/email-accounts/connectEmailAccount) IMAP/SMTP with a password (`MAIL`), or Google / Microsoft with your OAuth app's tokens (`GOOGLE_OAUTH`, `OUTLOOK`, with `oauth_app_id`), or a service app's delegated access (no token). Every host you name must be public (400 otherwise). A password login is checked first: a refused one is 401 `errors/invalid_credentials` and nothing is created. The mailbox starts `CONNECTING`; `CREATION_SUCCESS` arrives when the engine has connected it. No history is synced: events start from now. With a client key, connects, reconnects and login checks together are limited to 120 per 10 minutes per client (429 with `retry_after_ms`). # Check an IMAP/SMTP login (/raw-api/reference/email-accounts/verifyEmailAccount) Swarmhit's own. The same body as a `MAIL` connect; tries the IMAP and SMTP logins and creates nothing. 200 whatever the outcome: each leg says whether it worked and why not. `imap_access_token` / `smtp_access_token` (or `access_token`) in place of the passwords check an OAuth login (XOAUTH2) on the servers named: Google's token when the IMAP host is Gmail's (`gmail.com`, `googlemail.com`), else Microsoft's. The engine gets 15 s to answer. Limited with connects (120 per 10 minutes per client key, 429). # Look up a domain's mail servers (/raw-api/reference/email-accounts/emailAutoconfig) Swarmhit's own. The IMAP and SMTP servers of an address's domain, and whether its provider needs an app password; `null` where unknown. A domain (or its `autoconfig.` host) that resolves inside a private network is refused (400). # Get a mailbox (/raw-api/reference/email-accounts/getEmailAccount) # Reconnect a mailbox (/raw-api/reference/email-accounts/reconnectEmailAccount) New credentials or settings for the same mailbox, in place (same id), with the same body as a connect. `provider` may differ from the mailbox's type (a password mailbox moved to your Google or Microsoft OAuth app, or back): the old login is replaced and the account's `type` follows. The next `OK` is sent as `RECONNECTED`; a refused reconnect changes nothing. # Change a mailbox's proxy (/raw-api/reference/email-accounts/patchEmailAccount) The proxy its IMAP and SMTP connections go through (a public host only); `null` clears it. # Delete a mailbox (/raw-api/reference/email-accounts/deleteEmailAccount) The engine forgets it (its credentials included) and `DELETED` is sent. A mailbox the engine already lost is deleted all the same. `provider_session_revoke` is accepted and ignored. # Restart a mailbox's connection (/raw-api/reference/email-accounts/restartEmailAccount) The engine drops its IMAP connection and opens a new one. # Hosted connection link (not offered) (/raw-api/reference/email-accounts/emailHostedLink) Not offered for email yet: always 422 `errors/not_supported`. Connect with `POST /email/api/v1/accounts`. # Get a mailbox's owner (/raw-api/reference/email-users/getEmailOwner) The mailbox owner's profile: `GMAIL` with its send-as aliases (usable as `from`), `OUTLOOK`, or `IMAP` with its servers. # List emails (/raw-api/reference/email-messages/listEmails) Live from the mailbox, newest first. One folder at a time: `folder` (its `provider_id` or `emf_` id), else `role`'s folder, else All Mail where the mailbox has it (Gmail), else INBOX. Items are `1_meta` (no body); `include_body=true` (ours) fetches the bodies when `limit` is 25 or less. The filters other than the folder search the folder. # Send an email (/raw-api/reference/email-messages/sendEmail) JSON, or multipart with each nested field (`to`, `cc`, `bcc`, `from`, `custom_headers`, `tracking_options`) as a JSON string and each attachment a file part. `custom_headers` may hold `X-*`, `List-Unsubscribe`, `List-Unsubscribe-Post`, `Reply-To`, `Content-Type` (text/plain makes a text-only mail) and, ours, `In-Reply-To` and `References`; anything else, or a line break, is 400 `errors/invalid_headers`. At most 500 recipients and 25 MB of attachments. `Idempotency-Key` makes a retry answer the first send (keys are per mailbox: the same key on two mailboxes is two sends). A mailbox that is not `OK` is still queued (`gateway.account_status` says its status). 404 means the mailbox is gone; a `reply_to` that names no mail is 422 `errors/parent_mail_not_found`. # Address book (not offered) (/raw-api/reference/email-messages/listEmailContacts) Not offered: it needs contacts scopes no mailbox grants. Always 422 `errors/not_supported_by_provider`. # Get an email (/raw-api/reference/email-messages/getEmail) A `2_full` email, its bodies included. Never marks it as read. The id is an `emm_` id, or the engine's own (`provider_id`) with `account_id`. # Delete an email (/raw-api/reference/email-messages/deleteEmail) To the Trash; `permanent=true` deletes it for good. # Mark read or unread, or move an email (/raw-api/reference/email-messages/updateEmail) `unread` wins over everything else. `folders`: one folder name for IMAP and Outlook (a move; the email's id changes with its folder, the new one is `gateway.email_id`). On Gmail, the folder names (as `folders` lists them; a system label such as `\Starred` as it is) become the mail's labels: the ones named are added; of the others, only user labels and INBOX, Trash and Spam are removed. Important, Starred, Draft and Sent stay unless named; All Mail is no label. # Download an attachment (/raw-api/reference/email-messages/getEmailAttachment) The file's bytes, as a download (`Content-Disposition: attachment`). # Save a draft (/raw-api/reference/email-messages/createEmailDraft) The same body as a send (no recipient needed), saved in the mailbox's Drafts folder. # List a mailbox's folders (/raw-api/reference/email-folders/listEmailFolders) Not paginated. Gmail's labels are its folders. # Get a folder (/raw-api/reference/email-folders/getEmailFolder) By its `emf_` id, or its `provider_id` (path) with `account_id`. # List OAuth apps (/raw-api/reference/email-oauth-apps/listEmailOauthApps) Your Google and Microsoft apps, newest first. Never a secret. # Register an OAuth app (/raw-api/reference/email-oauth-apps/createEmailOauthApp) Swarmhit's own. The app the engine refreshes your Google or Microsoft mailboxes' tokens with (`kind: oauth`), or one that reaches a domain's mailboxes itself (`kind: service`: Google domain-wide delegation with `service_client` and `service_key`; Microsoft app-only with `tenant_id`). `route: api` uses the Gmail API / Graph, `imap` IMAP and SMTP. The secrets go to the engine only and are never answered. # Get an OAuth app (/raw-api/reference/email-oauth-apps/getEmailOauthApp) # Delete an OAuth app (/raw-api/reference/email-oauth-apps/deleteEmailOauthApp) 409 while a mailbox still uses it. # Account status (/raw-api/reference/events/eventAccountStatus) An account's status changed (both channels). `OK`: working. `CREDENTIALS`: the session ended, reconnect it. `STOPPED` with `reason`: held (`restricted`: a person must look; `proxy_drift` / `proxy_down`: wait, `OK` follows; `paused`, WhatsApp only). `DELETED`: the account is gone (a LinkedIn account only when it was deleted from the console, not by your own `DELETE`). WhatsApp also sends `CREATION_SUCCESS`, `RECONNECTED`, `CONNECTING` (a drop that lasted), `ERROR` and `SYNC_SUCCESS`. A mailbox sends `CREATION_SUCCESS`, `RECONNECTED`, `OK`, `CONNECTING`, `CREDENTIALS`, `ERROR`, `STOPPED` and `DELETED`, with `gateway: { state, event, reason }` (the mail engine's state, what reported it, its error code). A status is sent only when it changes; keep the newest `emitted_at`. # LinkedIn: message received (/raw-api/reference/events/eventLinkedinMessageReceived) A new message in a LinkedIn conversation, usually within seconds, including the account's own (`is_sender`), typed on LinkedIn or sent through the API. One missed then arrives at the next inbox check; a message seen twice is still one event. `message_id` and `chat_id` match the ids of `GET /api/v1/chats/{id}/messages`. Sales Navigator InMail replies come as this event too, with `subject`, for a seat that sent an InMail through the API in the last 60 days. # LinkedIn: new connection (/raw-api/reference/events/eventNewRelation) A member accepted the account's invitation (or a new connection appeared), usually within a minute or two. Once per person. Connections the account already had when Swarmhit first looked are not sent. # LinkedIn: invitation received (/raw-api/reference/events/eventInvitationReceived) Someone invited the account to connect, usually within a minute or two (LinkedIn's My Network badge is the cue; a check every few hours is the fallback). Once per invitation. Invitations already there when Swarmhit first looked are not sent. Accept it with `POST /api/v1/users/invite/received/{invitation_id}`. # LinkedIn: checkpoint (/raw-api/reference/events/eventCheckpoint) Gateway event. A sign-in reached a checkpoint nobody's call was waiting for (LinkedIn asked for a code after a CAPTCHA, or a queued sign-in). Answer it as a connect's 202. # LinkedIn: sign-in failed (/raw-api/reference/events/eventSignInFailed) Gateway event. A sign-in ended in failure after its call had returned (a live view, a queued sign-in). `code` is an error code. # LinkedIn: session cookie (/raw-api/reference/events/eventSessionCookies) Gateway event. LinkedIn issued or rotated the session cookie (`li_at`). A secret: store it encrypted. # LinkedIn: plan changed (/raw-api/reference/events/eventPlanChanged) Gateway event. The member bought or ended Premium, or got or lost a Sales Navigator seat. # Exit IP changed (/raw-api/reference/events/eventProxy) Gateway event, both channels (`account_type: WHATSAPP` on numbers). `gateway.proxy_drift`: the exit left its country or could not be confirmed (the account is held); `gateway.proxy_changed`: a new IP in the same country and ISP; `gateway.proxy_recovered`: moved to a new IP after a hold. # WhatsApp: message received (/raw-api/reference/events/eventWhatsappMessageReceived) A new message in a chat, the number's own included (`is_sender`): `message_received`. # WhatsApp: message delivered, read, edited or deleted (/raw-api/reference/events/eventWhatsappMessageChanged) A message delivered (`message_delivered`), read (`message_read`, with `read_by`), edited (`message_edited`, with the new `message`) or deleted (`message_deleted`, with `deleted_for`). # WhatsApp: reaction (/raw-api/reference/events/eventWhatsappReaction) A reaction added or taken back (`message_reaction`); an empty `reaction` is a removal. # WhatsApp: message failed (/raw-api/reference/events/eventWhatsappMessageFailed) Gateway event. WhatsApp refused a message the number sent after first accepting it (`code`, e.g. 463: the number may not reach this person yet). Once per message. # WhatsApp: limits changed (/raw-api/reference/events/eventWhatsappLimits) Gateway event. WhatsApp's new-chat quota (`gateway.whatsapp_quota`, sent when its `status` changes) or its block on starting new chats (`gateway.whatsapp_block`, sent when the block starts or ends) changed for a number. # Email: mail received (/raw-api/reference/events/eventEmailMailReceived) A new mail in a mailbox's inbox: `mail_received`. Its threading headers, auto-reply and bounce flags are under `gateway`. # Email: mail sent (/raw-api/reference/events/eventEmailMailSent) A mail sent through `POST /emails` has left: `mail_sent`, `origin: api`, `tracking_id` as the send answered. The engine knows only the envelope: the body and subject are empty. `gateway.provider_message_id` is the server's own Message-ID when it rewrote it (Gmail API, Graph). # Email: mail opened (/raw-api/reference/events/eventEmailMailOpened) The tracking pixel of a mail sent with `tracking_options.opens` loaded: `mail_opened`, with the send's `label` and `custom_domain`. Opens by mail clients' proxies and scanners come as they are: telling them apart is yours. # Email: link clicked (/raw-api/reference/events/eventEmailLinkClicked) A tracked link was clicked: `mail_link_clicked`, with its `url`. # Email: mail bounced (/raw-api/reference/events/eventEmailBounced) Swarmhit's own: `gateway.mail_bounced`, a delivery report for a mail sent from the mailbox. `action: failed` is final, `delayed` a warning. `response` carries the server's status and words; classifying them is yours. # Email: spam complaint (/raw-api/reference/events/eventEmailComplaint) Swarmhit's own: `gateway.mail_complaint`, a recipient reported a mail as spam (an ARF report). # Email: mail failed (/raw-api/reference/events/eventEmailFailed) Swarmhit's own: `gateway.mail_failed`, the engine gave up sending a queued mail after its retries.