Endpoint scope map

Every endpoint, which auth types it accepts, and which permission it requires.

This page is the source of truth for what an API key can and cannot reach. Every route in the backend falls into one of three buckets:

  • API key accepted: works with both a JWT (dashboard user) and an API key with the listed permission.
  • JWT only: never reachable via an API key. Used for billing, governance, websocket bootstrap, danger-zone destruction, the OAuth onboarding flow, and admin tools.
  • Public: no auth required (webhooks with signature verification, health check, OAuth bouncer pages).

When an endpoint says "JWT permission: X / API permission: Y", the dual-auth middleware checks the relevant one based on which credential the caller used.

An agency API key (wmagency_) reaches every API key accepted route below in one client workspace of its agency, named by the Warmbly-Organization header, under the same permission as a workspace key; the exceptions are API key self-service and OAuth apps, which answer 403 agency_key_not_allowed. It never reaches a JWT only route or the MCP server, except the agency routes that say they accept it.

A member restricted to selected resources is held to a short list of routes whatever credential they use, and those routes filter to their grants. The reads are GET /campaigns, /campaigns-overview, /campaigns/:id with its /logs, /steps and /ab-variants, GET /analytics/dashboard (whose campaign_ids and folder_ids filters must name granted campaigns and folders), /analytics/campaigns/compare, /analytics/campaigns/:id with /daily and /hourly, GET /analytics/accounts and /analytics/accounts/:id, GET /emails and /emails/:id, and the unibox reads GET /unibox, /unibox/count, /unibox/overview, /unibox/thread, /unibox/thread/labels, /unibox/scheduled and /unibox/:id (which, for them, does not mark the message read). Their own account routes under /auth/*, /me/*, GET /organization, /organization/current, /organization/switch/:id, /invitations, GET /subscription, /subscription/features, GET /timezones and POST /getaway keep working. With a role that lets clients act, they can also call PUT /unibox/thread/labels, PATCH /unibox/seen, PATCH /unibox/folder, POST /unibox/reply, POST /unibox/snooze, DELETE /unibox/snooze and DELETE /unibox/scheduled/:task_id (with access_unibox), PATCH /campaigns/:id, POST /campaigns/:id/steps, PATCH and DELETE /campaigns/:id/steps/:sid and PATCH /campaigns/:id/step-layout (with manage_campaigns), and POST /campaigns/:id/start and /stop (with send_campaigns). Each write is checked against their grants before anything changes: a reply needs a granted sending mailbox and a thread_id whose messages all sit in granted mailboxes, inbox changes need every named message or conversation in granted mailboxes, PATCH /campaigns/:id refuses status, email_tags, sender_strategy, folders, tracking_domain, cc and bcc, steps accept only email, wait and end actions, and a campaign edit, a step change and a start each need the campaign to send only from granted mailboxes in an explicit list with no mailbox tags. Every other route, and a write without such a role, answers 403 member_access_restricted, and a campaign or mailbox outside their grants answers 404. An API key's own mailbox allowlist still applies on top: the key reaches the mailboxes on both lists.

All paths below are relative to the versioned base URL https://api.senditcold.com/v1 (for example /campaigns is https://api.senditcold.com/v1/campaigns). See versioning for details.

Restricted response data and workspace policies

  • GET /organization/current returns a restricted summary: total_campaigns and active_campaigns count granted campaigns; total_contacts counts distinct contacts in those campaigns; email_accounts counts granted mailboxes; and emails_sent_today counts confirmed campaign sends in both granted campaigns and granted mailboxes. member_count, product_description, icp_notes and voice_profile are omitted. Unrestricted responses keep their workspace-wide shape.
  • GET /analytics/dashboard intersects campaign and folder filters with grants before returning scope.campaign_count.
  • GET /campaigns/:id/logs keeps pagination and campaign activity, but restricted responses use safe messages and explicitly selected metadata. Mailbox addresses, caps, cross-campaign sending state, warmup health and account IDs outside mailbox grants are removed. Tracking domains, raw provider errors, unknown fields and nested metadata are not exposed. Granting a campaign alone does not grant its senders.
  • GET /auth/me and /unibox/overview include only categories applied to messages in accessible mailboxes for restricted members. Unrestricted members keep the workspace category registry. Inbox access is mailbox-based: granting a shared mailbox exposes its conversations from other campaigns too.
  • GET /unibox/overview includes drafts, the distinct conversation count across mailbox drafts and visible pending inbox-agent replies, scoped to accessible mailboxes. Older servers omit this field; during rolling upgrades the dashboard uses their mailbox-only Drafts count until it is available.
  • GET /unibox/thread/labels checks for a message in the selected organization and effective mailbox allowlist before listing labels. Unknown, cross-workspace and inaccessible threads have the same 404. An accessible thread with no labels returns an empty list.
  • GET /emails resolves a pagination cursor only inside the selected organization and effective mailbox allowlist. A cursor outside either boundary does not supply a timestamp for pagination.

Workspace creation and limit-request routes are exceptions to the route-wide refusal described above: they authorize the account or target workspace in the service. POST /organization refuses accounts whose memberships are all restricted with 403 member_access_restricted, unless they are instance administrators. Accounts with no memberships or at least one unrestricted membership remain subject to the existing creation limits. Submitting or listing /organization/:orgId/limit-requests, and deleting /limit-requests/:id, requires an unrestricted membership in the request's target organization. Switching the selected workspace does not bypass these checks.

Workspace notifications and owner-addressed realtime delivery also check current organization membership. Resource ownership alone does not authorize a workspace-wide event. Restricted, removed and unaccepted members are excluded from workspace notifications, including persisted feeds and deferred email/push delivery. Personal sign-in security notices remain available. See realtime authorization and revocation for the 30-second refresh bound on open resource channels.

These checks use existing grants on hosted and self-hosted instances and need no new migration or configuration. Existing unrestricted behavior is preserved.

API key accepted

Emails

PATCH /emails/:id validates warmup_start_time and warmup_end_time together: the end must be later than the start on the same day. A partial time patch uses the other stored endpoint; an invalid pair returns 400. Unrelated patches do not rewrite existing invalid hours. See warmup hours.

POST /emails/bulk/delete, /emails/bulk/warmup and /emails/bulk/status apply one action to up to 500 mailboxes through the same path as the single-mailbox routes and answer with a result per mailbox, so one failure does not stop the rest. Warmup and status calls set a state and are safe to retry; send an Idempotency-Key to replay a delete's first response. See acting on many mailboxes.

MethodPathAPI Permission
GET/emailsREAD_EMAILS
GET/emails/:idREAD_EMAILS
PATCH/emails/:idWRITE_EMAILS
PATCH/emails/tagsWRITE_EMAILS
POST/emails/bulk/deleteWRITE_EMAILS
POST/emails/bulk/warmupWRITE_EMAILS
POST/emails/bulk/statusWRITE_EMAILS
GET/emails/allowanceREAD_EMAILS
POST/emails/client-linksWRITE_EMAILS
GET/emails/client-linksREAD_EMAILS
GET/emails/client-links/:id/usesREAD_EMAILS
POST/emails/client-links/:id/revokeWRITE_EMAILS
GET/emails/:id/trackREAD_EMAILS
PATCH/emails/:id/trackWRITE_EMAILS
POST/emails/:id/track/verifyWRITE_EMAILS
PATCH/emails/:id/direct-trackingWRITE_EMAILS
GET/emails/:id/syncREAD_EMAILS
PUT/emails/:id/syncWRITE_EMAILS
GET/emails/:id/warmup-filter-tagREAD_EMAILS
PUT/emails/:id/warmup-filter-tagWRITE_EMAILS
DELETE/emails/:id/warmup-filter-tagWRITE_EMAILS
GET/emails/:id/certificatesREAD_EMAILS
GET/emails/:id/identityREAD_EMAILS
POST/emails/:id/identity/refreshWRITE_EMAILS
GET/emails/:id/auth-checkREAD_EMAILS
POST/emails/:id/auth-checkWRITE_EMAILS
GET/emails/:id/behaviorREAD_EMAILS
PUT/emails/:id/behaviorWRITE_EMAILS
GET/emails/:id/behavior/planREAD_EMAILS
POST/emails/:id/holdWRITE_EMAILS
POST/emails/:id/releaseWRITE_EMAILS
POST/emails/:id/resume-sendingWRITE_EMAILS
DELETE/emails/:idWRITE_EMAILS
POST/emails/:id/sendSEND_CAMPAIGNS

Mailbox connect (Partnership)

These routes connect mailboxes from the API and are part of the Partnership plan: any other workspace gets 403 partnership_plan_required. They take mailbox credentials, so they need CONNECT_EMAILS, which the Full access preset leaves out and no OAuth app can hold. For session callers the matching organization permission is Manage mailboxes.

MethodPathAPI Permission
POST/emails/connect/smtp-imapCONNECT_EMAILS
POST/emails/connect/smtp-imap/bulkCONNECT_EMAILS
POST/emails/connect/oauthCONNECT_EMAILS
POST/emails/connect/oauth/bulkCONNECT_EMAILS
POST/emails/connect/linksCONNECT_EMAILS
GET/emails/connect/linksCONNECT_EMAILS
GET/emails/connect/links/:idCONNECT_EMAILS
DELETE/emails/connect/links/:idCONNECT_EMAILS
GET/emails/oauth-appsCONNECT_EMAILS
PUT/emails/oauth-apps/:providerCONNECT_EMAILS
DELETE/emails/oauth-apps/:providerCONNECT_EMAILS

POST /emails/connect/smtp-imap takes the body of the SMTP/IMAP onboarding connect plus an optional warmup object (enabled, start, max, increase, reply_rate) and answers 201 with mailbox and warmup_note. The bulk form takes up to 50 accounts and one warmup, and answers 200 with a status per row, so a repeated batch skips what already connected. Both accept an Idempotency-Key.

POST /emails/connect/oauth hands over a sign-in the workspace's own OAuth app completed, so the mailbox owner signs in only once: provider, then either refresh_token or code with redirect_uri (and code_verifier when PKCE was used), an optional email the sign-in must belong to, and warmup. It answers 201 with mailbox, renewed and warmup_note for a new mailbox, and 200 with renewed: true when it replaced the token of a mailbox the workspace already had, whose warmup it leaves alone; a retry therefore lands on the same mailbox. The bulk form takes up to 50 grants and one warmup and answers 200 with a status per sign-in. Neither returns a token.

POST /emails/connect/links takes provider (gmail or outlook), an optional email, warmup, return_url (https) and expires_in_hours (1 to 168, default 72), and answers 201 with the link and its url, which is never returned again. GET /emails/connect/links returns data plus pagination with an opaque cursor and limit (1 to 100, default 25); an invalid cursor or limit is a 400. DELETE ends a link that has not connected and answers with the link; repeating it is safe. Creating a link is not idempotent: a retry mints a second link, and the first one simply expires unused.

PUT /emails/oauth-apps/:provider (gmail or outlook) takes app_name, client_id, client_secret and an optional redirect_url, checks the pair with the provider, and answers with the app; it states an absolute value, so a retry lands on the same state. An empty client_secret keeps the stored one for the same client_id. A different client_id, or DELETE, is refused with 409 oauth_app_in_use while mailboxes are signed in through the app. GET /emails/oauth-apps returns data (never the secrets) and callbacks, the redirect URI each provider must register.

On the Partnership plan, POST /emails/onboarding/oauth/start, POST /emails/onboarding/oauth/reauth/:id, inbox-vendor sign-in and POST /emails/grants/google/start, /emails/grants/microsoft/start and /emails/grants/:id/connect answer 422 partnership_oauth_app_required unless the sign-in can go through the workspace's own app.

Campaigns and sequences

Campaigns, steps and activity logs are scoped to the selected organization for session callers and the key's organization for API-key callers. Access depends on the permissions below, regardless of who created the campaign. Step updates and deletes also require the step to belong to the campaign in the URL. Campaign analytics, including daily, hourly and comparison endpoints, use this same organization boundary and require READ_ANALYTICS (or View analytics for session callers).

MethodPathAPI Permission
GET/campaignsREAD_CAMPAIGNS
GET/campaigns-overviewREAD_CAMPAIGNS
POST/campaigns-estimateREAD_CAMPAIGNS
POST/campaignsWRITE_CAMPAIGNS
GET/campaigns/:idREAD_CAMPAIGNS
PATCH/campaigns/:idWRITE_CAMPAIGNS
DELETE/campaigns/:idWRITE_CAMPAIGNS
POST/campaigns/:id/duplicateWRITE_CAMPAIGNS
POST/campaigns/:id/copy-toREAD_CAMPAIGNS and WRITE_CAMPAIGNS (plus READ_TEMPLATES and WRITE_TEMPLATES with template_ids), same workspace only
GET/campaigns/:id/attachmentsREAD_CAMPAIGNS
POST/campaigns/:id/attachmentsWRITE_CAMPAIGNS
DELETE/campaigns/:id/attachments/:attachmentIdWRITE_CAMPAIGNS
GET/campaigns/:id/segmentsREAD_CAMPAIGNS
PUT/campaigns/:id/segmentsWRITE_CAMPAIGNS
GET/campaigns/:id/advancedREAD_CAMPAIGNS
PATCH/campaigns/:id/advancedWRITE_CAMPAIGNS
GET/campaigns/:id/ab-variantsREAD_CAMPAIGNS
POST/campaigns/:id/ab-variantsWRITE_CAMPAIGNS
PATCH/campaigns/:id/ab-variants/:variantIdWRITE_CAMPAIGNS
DELETE/campaigns/:id/ab-variants/:variantIdWRITE_CAMPAIGNS
GET/campaigns/:id/ab-analysisREAD_ANALYTICS
GET/campaigns/:id/warmup-placementREAD_CAMPAIGNS
POST/campaigns/:id/preflightSEND_CAMPAIGNS
POST/campaigns/:id/test-emailSEND_CAMPAIGNS
POST/campaigns/:id/startSEND_CAMPAIGNS
POST/campaigns/:id/stopSEND_CAMPAIGNS
GET/campaigns/:id/placement-monitorREAD_CAMPAIGNS
PUT/campaigns/:id/placement-monitorSEND_CAMPAIGNS
DELETE/campaigns/:id/placement-monitorSEND_CAMPAIGNS
GET/campaigns/:id/leads/:contactId/holdREAD_CAMPAIGNS
POST/campaigns/:id/leads/:contactId/pauseWRITE_CAMPAIGNS
POST/campaigns/:id/leads/:contactId/resumeWRITE_CAMPAIGNS
GET/campaigns/:id/leads/:contactId/ccREAD_CAMPAIGNS + READ_CONTACTS
PUT/campaigns/:id/leads/:contactId/ccWRITE_CAMPAIGNS + READ_CONTACTS
GET/campaigns/:id/leads/:contactId/cc/suggestionsREAD_CAMPAIGNS + READ_CONTACTS
GET/campaigns/:id/logsREAD_CAMPAIGNS
GET/campaigns/:id/send-planREAD_CAMPAIGNS
GET/campaigns/:id/lead-timezonesREAD_CAMPAIGNS
GET/campaigns/:id/formsREAD_CAMPAIGNS
GET/POST/PATCH/DELETE/campaigns/:id/steps[/:sid]READ_CAMPAIGNS for GET, WRITE_CAMPAIGNS otherwise
PATCH/campaigns/:id/step-layoutWRITE_CAMPAIGNS
POST/generation/writeWRITE_CAMPAIGNS
POST/generation/editWRITE_CAMPAIGNS
POST/generation/ai-variableWRITE_CAMPAIGNS
GET/email-imagesREAD_CAMPAIGNS
POST/email-imagesWRITE_CAMPAIGNS
DELETE/email-images/:idWRITE_CAMPAIGNS

POST /campaigns/:id/attachments takes a multipart file and optional step_id. New uploads require matching supported content and a final extension: PDF, DOCX, XLSX, PPTX, CSV, TSV, TXT, PNG, JPG/JPEG, GIF, WebP or ZIP, up to 15 MiB. The server derives canonical outgoing MIME metadata, preserves bytes, and refuses unsupported, malformed or mismatched content with 400 invalid_attachment before storage or quota reservation. See format profiles, parser limits and existing-file compatibility. Client MIME declarations do not select the type. Duplication and workspace/admin archive restore preserve existing private files without claiming new-upload validation.

With CAMPAIGN_ATTACHMENT_DOWNLOAD_PROXY=true, attachment responses keep the url field and return a 15-minute API download link. GET /campaign-attachments/download/:token accepts that file-scoped capability, checks the current attachment and campaign tenant, and returns bytes as an attachment with X-Content-Type-Options: nosniff, Cache-Control: no-store and a sandboxed CSP. The capability contains no dashboard session token and grants no list, upload or delete access. Deleting the attachment ends the link. Treat the link as private until it expires; changing the instance auth secret also ends outstanding links.

POST /campaigns/:id/copy-to and POST /templates/copy-to copy a campaign's configuration (and any template_ids from the same workspace), or a list of templates, into up to 10 workspaces named in organization_ids, as drafts. The answer is 200 with one entry per workspace in data, each with a status of copied, partial or failed, the copies it made, the copied steps whose settings were cleared (review), any warnings, and an error with a stable code when that workspace got nothing. The caller needs view access in this workspace and the Manage campaigns permission, with access to the whole workspace, in each destination; a member restricted to selected resources cannot copy anything out and gets 403 member_access_restricted. A key or OAuth token reaches one workspace, so it can copy only into its own and gets 403 cross_workspace_copy_requires_session for any other; copy between workspaces from the dashboard or with a signed-in session. Send an Idempotency-Key to replay the first answer instead of copying again. See Copy campaigns to a client.

The hold, pause and resume calls under /campaigns/:id/leads/:contactId/ hold one contact's flow inside one campaign: an out-of-office auto-reply writes one automatically, and a member can write one by hand. The contact stays subscribed and stays a lead, so this is not an unsubscribe and not a suppression. Both writes state an absolute hold rather than applying a delta, and replacing a live hold keeps its original start, so a retry lands on the same row and neither needs an Idempotency-Key. See pause a lead.

The cc calls under the same path set the contacts copied on every email to one lead. Their answers carry contact names and addresses, so they need contact read access as well as the campaign scope. PUT sends the whole list, so a retry lands on the same state and needs no Idempotency-Key. See set a lead's CC.

GET /campaigns/:id/forms reports the forms this campaign links to and what its recipients did with them: personalized links handed out, who opened one, who started filling it in and who submitted. See the forms guide.

GET /campaigns/:id/send-plan returns today's sending plan: what the campaign is expected to send, and every limit that decided the number. For an active campaign the plan is computed in the background and served from a stored snapshot, so the read stays fast on a campaign of any size. computed_at is when that snapshot was walked, and stale is true when the campaign has since been edited, started, stopped, or has crossed into a new budget day and a refreshed plan is already being computed: the figures are the last good ones and a current plan follows within moments. A campaign that has no snapshot yet (a brand-new one) is computed on the request instead. See the send plan guide.

/email-images is the workspace's image library for email bodies. POST takes a multipart file field (PNG, JPG, GIF or WebP, up to 5 MB) and returns the row with the public url you put in an <img src>; those bytes count against the same storage quota as campaign attachments. GET is keyset-paginated newest first (?limit= 1 to 100, ?cursor= the opaque pagination.next_cursor). DELETE removes the object before the row and refuses with a 503 if storage will not take the delete, so a failed call changes nothing and can be retried; once it succeeds, an image in mail already sent stops loading.

Contacts

MethodPathAPI Permission
POST/contacts/searchREAD_CONTACTS
GET/contacts/custom-fieldsREAD_CONTACTS
GET/contacts/lookupREAD_CONTACTS (plus READ_UNIBOX with thread_id)
POST/contactsWRITE_CONTACTS
DELETE/contactsBULK_CONTACTS
PATCH/contactsBULK_CONTACTS
GET/contacts/verificationREAD_CONTACTS
POST/contacts/verificationBULK_CONTACTS
GET/contacts/:idREAD_CONTACTS
PATCH/contacts/:idWRITE_CONTACTS
DELETE/contacts/:idWRITE_CONTACTS
GET/contacts/:id/emailsREAD_CONTACTS
GET/contacts/:id/timelineREAD_CONTACTS
GET/contacts/:id/campaignsREAD_CONTACTS
GET/contacts/:id/segmentsREAD_CONTACTS
POST/contacts/exportREAD_CONTACTS
POST/contacts/import/previewWRITE_CONTACTS
POST/contacts/import/commitBULK_CONTACTS
POST/contacts/importsWRITE_CONTACTS
GET/contacts/importsREAD_CONTACTS
GET/contacts/imports/:idREAD_CONTACTS
PATCH/contacts/imports/:idWRITE_CONTACTS
POST/contacts/imports/:id/analyzeWRITE_CONTACTS
POST/contacts/imports/:id/startBULK_CONTACTS
POST/contacts/imports/:id/cancelBULK_CONTACTS
GET/contacts/imports/:id/failed.csvREAD_CONTACTS
GET/contacts/:id/notesREAD_CONTACTS
POST/contacts/:id/notesWRITE_CONTACTS
PATCH/contacts/:id/notes/:noteIdWRITE_CONTACTS
DELETE/contacts/:id/notes/:noteIdWRITE_CONTACTS
GET/contacts/:id/activitiesREAD_CONTACTS
GET/contacts/:id/dealsREAD_CRM
POST/contacts/:id/researchAI_RESEARCH
GET/contacts/:id/researchAI_RESEARCH
POST/contacts/research/batchAI_RESEARCH

GET /contacts/custom-fields lists the distinct custom-field keys used across your contacts (frequency-ranked), for building personalization pickers and import column mappers. It returns a flat string array under data, capped at 200 keys.

The import pair is two steps over the same file: preview parses it and suggests a column mapping (matching headers to your existing custom fields), commit applies the mapping you chose. Commit takes the stricter BULK_CONTACTS scope because one call writes up to 50,000 rows. A mapping problem, an unusable custom-field name or no email column, is a 400 on the whole request; see contacts.

AI contact research is charged to the workspace balance for the tokens and web searches it uses (a run that finds nothing is still charged for its searching) and only saves cited findings. See the AI contact research guide. The batch endpoint accepts up to 500 contact ids and drains in the background.

Meetings

MethodPathAPI Permission
GET/meetingsREAD_CONTACTS
GET/meetings/summaryREAD_CONTACTS
POST/meetingsWRITE_CONTACTS
DELETE/meetings/:idWRITE_CONTACTS

Meetings follow the contact scopes and the View contacts and Manage contacts member permissions, and are scoped to the workspace of the caller.

Lead finder

MethodPathAPI Permission
GET/leads/configREAD_CONTACTS
GET/leads/geocodeREAD_CONTACTS
POST/leads/searchWRITE_CONTACTS
POST/leads/queryREAD_CONTACTS
POST/leads/pointsREAD_CONTACTS
POST/leads/facetsREAD_CONTACTS
POST/leads/exportREAD_CONTACTS
PATCH/leadsBULK_CONTACTS
DELETE/leadsBULK_CONTACTS
POST/leads/enrichBULK_CONTACTS
GET/leads/enrichmentsREAD_CONTACTS
GET/leads/enrichments/:idREAD_CONTACTS
POST/leads/contactsBULK_CONTACTS
GET/leads/searchesREAD_CONTACTS
PATCH/leads/searches/:idWRITE_CONTACTS
DELETE/leads/searches/:idWRITE_CONTACTS
GET/leads/:idREAD_CONTACTS
PATCH/leads/:idWRITE_CONTACTS
POST/leads/:id/peopleWRITE_CONTACTS
PATCH/leads/:id/people/:personIdWRITE_CONTACTS
DELETE/leads/:id/people/:personIdWRITE_CONTACTS
POST/leads/:id/people/:personId/find-emailWRITE_CONTACTS

Leads are prospect contacts, so the lead finder uses the contact scopes and the View contacts and Manage contacts member permissions. POST /leads/search runs one page of a source search over an area (bbox, circle or polygon) and stores what it finds; send search_id alone to fetch the next page of the same search. POST /leads/query, /leads/points and /leads/facets are reads with a filter body; query pages with an opaque cursor and a limit from 1 to 200 and answers data plus pagination with a total. Bulk calls take a selection: lead_ids (at most 5,000), or all: true with a filter.

POST /leads/enrich starts a background run that reads each lead's website for emails, socials and (unless people is false) the people it names, then works out and checks their addresses, and answers 202 with the run; follow it at /leads/enrichments/:id. Each website read is charged to the workspace balance (the lead_website_read price, $0.02 at the default prices), refunded when the site never answers, and the run's charged_usd is what it cost so far, net of refunds. A site that answers 401, 403, 406, 429 or 503 is read through the instance's or the workspace's reader service when one is set up, and the lead's site.read_via names it. A site that still could not be read has site.unread: blocked, robots or unreachable. Unless web_search is false, the run also searches the web for a missing website and, when a site could not be read or published no address, for addresses at its domain shown elsewhere (source: "search" on the lead's emails). A search through the instance's provider charges the web_search price, refunded when it finds nothing; a workspace's own MCP search tool or Jina key costs nothing. GET /leads/config says what is set up in web_reader and web_search, each {available, sources, cost_usd}. GET /leads/:id carries the lead's people. POST /leads/:id/people adds a person by name or first_name and last_name, and without an email works out their address at the lead's domain on the spot for the lead_person_email price ($0.02 at the default prices), refunded when none turns up; find-email does the same for someone already listed. People are matched by name, so adding someone again lands on the same person. POST /leads/contacts takes who (business, people or both), include_guessed, segment_ids, category_ids, campaign_ids, skip_active_in_other_campaigns and keep_existing (leave contacts you already have as they are); POST /leads/export takes rows (leads or people). A search through a source the instance pays for charges the lead_search_page price for each page ($0.04 at the default prices), refunded when the search fails, and answers what it charged in charged_usd. GET /leads/config carries the current prices with the markup in dollars: enrich_cost_usd and person_email_cost_usd, and on each provider cost_usd per page; each is 0 when nothing is charged, as on a self-hosted instance without billing or a source billed to the workspace's own key. POST /leads/search and /leads/enrich honor Idempotency-Key. A paid address lookup, find-email or adding a person without an email, charges again when retried, so retry it only after reading the person back. The other writes set values or update existing contacts, so a retry lands on the same state. POST /leads/export answers the file itself (format is csv, xlsx or json).

Segments

MethodPathAPI Permission
GET/segmentsREAD_CONTACTS
GET/segments/fieldsREAD_CONTACTS
POST/segments/previewREAD_CONTACTS
POST/segmentsWRITE_CONTACTS
GET/segments/:idREAD_CONTACTS
PATCH/segments/:idWRITE_CONTACTS
DELETE/segments/:idWRITE_CONTACTS
POST/segments/:id/membersWRITE_CONTACTS
POST/segments/:id/members/lookupREAD_CONTACTS
GET/segments/:id/overridesREAD_CONTACTS
POST/segments/:id/add-to-campaignWRITE_CAMPAIGNS

Segments are saved contact audiences: a condition list plus per-contact manual overrides, evaluated live. Contact scopes cover them because a segment is a view over contacts; enrolling one into a campaign writes leads, so that call takes the campaign write scope, as does managing a campaign's linked segments (/campaigns/:id/segments above). POST /contacts/search and POST /contacts/export accept segment_ids to scope any contact query to a segment. See contacts for the condition format.

Forms

MethodPathAPI Permission
GET/formsREAD_CONTACTS
GET/forms/configREAD_CONTACTS
POST/formsWRITE_CONTACTS
GET/forms/:idREAD_CONTACTS
PATCH/forms/:idWRITE_CONTACTS
DELETE/forms/:idWRITE_CONTACTS
GET/forms/:id/submissionsREAD_CONTACTS
DELETE/forms/:id/submissions/:sidWRITE_CONTACTS
GET/forms/:id/statsREAD_CONTACTS
GET/forms/domainREAD_CONTACTS
PUT/forms/domainWRITE_CONTACTS
POST/forms/domain/verifyWRITE_CONTACTS
GET/forms/:id/links/:contactIDWRITE_CONTACTS
POST/forms/:id/assets/:kindWRITE_CONTACTS
DELETE/forms/:id/assets/:kindWRITE_CONTACTS

Hosted lead-capture forms; see the forms guide. A form's redirect_url must be on one of the workspace's own domains when it is set or changed (redirect_target_not_own_domain otherwise), and a form with one carries redirect_url_active, false when the saved address no longer qualifies and visitors see the success message instead. Contact scopes cover them because a form exists to create contacts. Minting a personalized link is a GET on top of an upsert, so retries always return the same token. The /forms/domain routes are the workspace-wide custom forms domain and additionally need the manage_settings organization permission for JWT callers; PUT resolves the record as part of saving, and only a verified domain is ever used to build a URL. A new domain verifies only with the workspace's ownership TXT record beside the CNAME; the status carries it as txt_name and txt_value, and a domain another workspace holds is refused with forms_domain_taken. On the custom domain the forms service serves only that workspace's forms, connect links and reports; the domain is also the workspace's white-label portal host. Form URLs are built on that domain or on FORMS_DOMAIN, never on an agency portal domain. The public page (/f/:public_id), its submit endpoint and the embed loader (/forms.js, also at /embed.js, which mounts connect pages and reports too) are served by the standalone forms service on its own host (FORMS_DOMAIN), not the API origin, and take no authentication; the unguessable public id is the capability, and the JSON endpoints additionally require the render token the page shell carries.

GET /forms/:id/submissions is keyset-paginated newest first, ordered by creation time and then id, so submissions that arrive in the same instant are neither skipped nor repeated at a page boundary. It answers data plus pagination (next_cursor, has_more); pass ?limit= (1 to 100, default 50) and ?cursor= the opaque pagination.next_cursor for the next page. A cursor this API did not issue returns 400. The earlier ?before= (an RFC 3339 timestamp, returning submissions strictly older than it) and the top-level has_more are still accepted and returned for existing clients; sending both cursor and before returns 400.

Lead sync

Saved Google Sheets sources that upsert contacts on demand. Gated under the contact write scope because a sync ultimately writes contacts. Nothing syncs on a timer: a source only runs when /lead-sync/sources/:id/sync is called.

MethodPathAPI Permission
GET/lead-sync/google/connectionWRITE_CONTACTS
POST/lead-sync/google/spreadsheetWRITE_CONTACTS
POST/lead-sync/google/previewWRITE_CONTACTS
GET/lead-sync/sources, /lead-sync/sources/:idWRITE_CONTACTS
POST/lead-sync/sourcesWRITE_CONTACTS
PATCH/lead-sync/sources/:idWRITE_CONTACTS
DELETE/lead-sync/sources/:idWRITE_CONTACTS
POST/lead-sync/sources/:id/syncWRITE_CONTACTS

A source's column_mapping is validated when the source is written, not on its next sync, so a mapping with no email column or an unusable custom-field name is a 400 on create or update. See integrations.

Unibox

MethodPathAPI Permission
GET/uniboxREAD_UNIBOX
GET/unibox/countREAD_UNIBOX
GET/unibox/threadREAD_UNIBOX
GET/unibox/:idREAD_UNIBOX
PATCH/unibox/seenWRITE_UNIBOX
PATCH/unibox/folderWRITE_UNIBOX
POST/unibox/replyWRITE_UNIBOX (plus READ_UNIBOX with forward_message_id)
POST/unibox/reply/draftREAD_UNIBOX
GET/unibox/compose/candidatesREAD_UNIBOX
POST/unibox/composeWRITE_UNIBOX
POST/unibox/compose/draftREAD_UNIBOX
GET/unibox/draftsREAD_UNIBOX
PUT/unibox/drafts/:idWRITE_UNIBOX
DELETE/unibox/drafts/:idWRITE_UNIBOX
GET/unibox/agent-draftsREAD_UNIBOX
POST/unibox/agent-drafts/:id/approveWRITE_UNIBOX
POST/unibox/agent-drafts/:id/discardWRITE_UNIBOX
PUT/unibox/ai-drafts/by-threadWRITE_UNIBOX

PATCH /unibox/folder re-files into inbox, archive or trash. Name the messages with email_ids, the conversations with thread_ids, or both; up to 500 of each. Prefer thread_ids when you have one: filing part of a conversation leaves it listed, because a thread shows wherever any message of it still sits. The move is then relayed to each mailbox with relay_folder_moves on (the default): the provider's copy is archived, moved to its trash, or put back in its inbox, after the response and best-effort, and the next sync will not undo a filing either way, because the provider's placement is tracked separately and followed only when the provider itself moves the message. sent, drafts and spam are placements a provider reaches rather than somewhere a person files mail, so they are rejected with a 400. See filing a conversation.

PATCH /unibox/seen takes the same two forms: email_ids for individual messages, thread_ids for whole conversations. folder sweeps one folder instead and cannot be combined with either. Unread by conversation marks only its newest received message (preferring one outside spam and trash), or its newest sent copy if it has no received message; otherwise sent copies stay read. Drafts are never marked unread. Explicit email_ids and folder sweeps never mark sent or draft copies unread.

GET /unibox lists every working folder by default. Spam, trash and archive stay out, so a conversation you file leaves every view rather than only the Inbox folder; pass include_archived=true for the All mail behaviour, or folder=archive to read the folder itself.

POST /unibox/snooze accepts thread_id for one conversation or thread_ids for up to 500. The single form answers with the snooze row, as before; the bulk form answers with data. DELETE /unibox/snooze?thread_id= accepts a comma-separated list.

POST /unibox/reply with forward_message_id forwards a stored message, which discloses it, so the key also needs READ_UNIBOX and, when it is restricted to certain mailboxes, the mailbox the message belongs to. See Forwarding a message.

POST /unibox/reply/draft returns an AI-drafted reply (it never sends) grounded in the thread, the contact, and your voice profile. It is charged to the workspace balance; see Balance and usage.

POST /unibox/compose/draft returns a grounded AI draft for a new email (it never sends): the recipient's contact record, correspondence history, and the workspace voice profile feed the prompt, and the response carries either text or a clarifying question plus a grounding report. Charged to the workspace balance like the reply draft; see Balance and usage.

POST /unibox/compose sends a brand-new outbound email (not a reply). body_html goes out as written; with body_plain left empty, the plain-text part is rendered from it, which POST /unibox/reply does too. Omit email_account_id (or pass "auto") and the backend picks the best mailbox for the first recipient; the response reports account_id, account_email, auto, and a picked_reason. Recipients on the workspace suppression list are rejected with a 400 before anything queues. GET /unibox/compose/candidates?to=<address> returns every active mailbox scored for that recipient (conversation history, remaining daily budget, domain-auth health) plus the resolved contact and suppression state; see composing email.

The /unibox/drafts endpoints hold autosaved compose drafts, scoped to the calling user within the organization. The draft id is client-generated, so PUT is idempotent (safe for debounced autosave and retries); deleting a missing draft is a no-op. A draft carries body (the plain text) and body_html, which is empty for a plain-text draft; each is capped at 100,000 characters.

GET /emails/:id/auth-check runs a live SPF/DKIM/DMARC lookup on the mailbox's sending domain and reports it without changing anything. POST /emails/:id/auth-check runs the same lookup and records the verdict against every active mailbox on that domain, which is what clears the send gate after a DNS fix, so it needs WRITE_EMAILS rather than READ_EMAILS. The write takes no Idempotency-Key: it is derived entirely from public DNS with no request body, so repeating it converges on the same result. See domain authentication.

GET /emails/:id/track reports the mailbox's stored tracking domain plus the CNAME value this deployment expects (cname_target, taken from its TRACKING_DOMAIN), and does no DNS work. It also carries the ownership TXT record (txt_name, txt_value) a host the workspace has not verified before needs beside the CNAME; until it is published the status is proof_missing. PATCH /emails/:id/track sets the domain and resolves it once; POST /emails/:id/track/verify re-resolves the saved one and records the verdict, which is what makes a record that has finished propagating start being used. Both writes are derived from public DNS with no request body, so they take no Idempotency-Key. Only a verified domain is used at send time; until then opens and clicks go through the shared tracking host. See custom tracking domain.

POST /emails/:id/hold keeps a mailbox out of campaign sending until POST /emails/:id/release puts it back; warmup is unaffected and the automatic rest logic never releases a hold. release is also the manual exit for a mailbox that is resting automatically. Both are bodyless and idempotent, so they take no Idempotency-Key. See holding a mailbox yourself.

POST /emails/:id/resume-sending lifts a provider pause on the mailbox: it ends a timed pause (rate_limited, daily_limit, sender_blocked, retry) and releases a permanent or domain_auth hold. It answers {"hold_released": bool, "cooldown_cleared": bool}, both false when nothing was paused. An authentication hold is cleared by reconnecting the mailbox, and an unconfirmed (unknown) or conflict hold keeps its own recovery, so this leaves both in place. If the provider still refuses, the next send pauses the mailbox again. It is bodyless and idempotent, so it takes no Idempotency-Key. The mailbox's send_pause in GET /analytics/accounts/:id says whether it applies (resumable). See when a provider pauses sending.

The /emails/client-links routes manage client connect links, each with JWT permission MANAGE_EMAILS. POST /emails/client-links takes label (optional), providers (any of gmail, outlook, smtp_imap and vendor, an inbox vendor account the client names), max_mailboxes (1 to 500), expires_in_days (1 to 90, default 7), tag_ids (up to 20 of the workspace's mailbox tags) and warmup, and answers 201 with the link and its url, on the agency's verified portal domain when it has one, else on PORTAL_DOMAIN (503 client_links_unavailable without it). The URL appears in that response only; send an Idempotency-Key so a retry replays it instead of minting a second link. GET /emails/connect-links lists links newest first with their status (active, expired, revoked, exhausted) and mailboxes_connected, paginated with cursor and limit (1 to 100). GET /emails/connect-links/:id/uses lists the mailboxes a link connected. POST /emails/connect-links/:id/revoke turns a link off; a repeat returns the revoked link, so it needs no Idempotency-Key.

GET /emails/allowance reports how many mailboxes the workspace holds (used), how many it may hold (allowance, null for unlimited), remaining, and the basis of the number: fair_use (the plan's daily sends divided by sends_per_mailbox), plan, override (an approved request), free (no paid plan: 0, or 10 for a workspace that kept the earlier free plan), or unlimited. pending_request is the open limit-increase request for mailboxes, if any. Every connect path refuses with mailbox_allowance_reached once remaining is 0. See mailbox allowance.

GET /emails/:id/identity reports which addresses the mailbox's provider will let it send as, which one it uses, and whether its signature was imported or written here; it contacts no provider. POST /emails/:id/identity/refresh re-reads that list from the provider and stores it, and with import_signature also replaces the stored signature with the one configured at the provider. Storing the list is what a send_as_email choice is validated against, so the refresh needs WRITE_EMAILS rather than READ_EMAILS; it takes no Idempotency-Key because it writes exactly what the provider currently says. Gmail only. See sending identity.

GET /emails/:id/sync includes folder_coverage_observed_at in its state when an updated worker reports folder coverage. This optional timestamp orders the skipped-folder counters independently of last_synced_at; it does not prove a completed sync. The response also reports, alongside the import state and budget, skip_folders (the folders the mailbox's sync leaves alone) and folders (what the worker last listed on the server, each with its name and the canonical folder it files under, INBOX first; empty for Gmail and Outlook). PUT /emails/:id/sync takes {"skip_folders": [...]} and replaces the list: names as the server lists them, matched without regard to case, each covering its subfolders. Mail already stored from a newly skipped folder is removed, the mailbox is re-shipped to its worker so the change applies on the next pass, and the response is the list as stored. It refuses INBOX and any sent, drafts, spam, trash or archive folder with 400 invalid_sync_folder, as it does a list of more than 50 names or a mailbox that is not IMAP. The body is the desired state, so it takes no Idempotency-Key. See folders you do not want synced.

GET /emails/:id/certificates reports, for an SMTP/IMAP mailbox, each server's host, port, tls_unverified (whether it is trusted on its own certificate rather than a certificate authority) and tls_pin (the SHA-256 fingerprint of the public key it is trusted on, lowercase hex). It contacts no server. A Gmail or Outlook mailbox answers 400, since it has no certificate choice. On POST /emails/onboarding/smtp-imap and PUT /emails/onboarding/smtp-imap/:id, the smtp and imap objects accept tls_unverified (boolean, default false) and tls_pin. A server whose certificate verifies keeps normal checking whatever tls_unverified says; one that does not is pinned to what the connection test saw, or to tls_pin when it is given and matches. See servers with their own certificate.

PATCH /emails/:id accepts save_to_sent (boolean) on SMTP/IMAP mailboxes: when true, which is the default, the worker files a copy of each outbound message in the mailbox's Sent folder. It has no effect on Gmail and Outlook mailboxes, whose APIs file their own copy. See keeping a copy of sent mail.

PATCH /emails/:id also accepts relay_folder_moves (boolean, default true), on every provider: when true, filing a conversation with PATCH /unibox/folder moves it in the mailbox too. See filing in the mailbox.

The same WRITE_EMAILS mailbox PATCH accepts test_mode (diagnostic, off, legacy), independent test_send_enabled/test_receive_enabled, positive shared_daily_limit/rolling_recipient_limit, and send_recovery_resolution. Repair resolution binds held_task_id/held_reason and requires either authentication_repaired with a newer definitive successful same-mailbox outbound evidence_task_id, or operator_provider_confirmation with a same-mailbox definitive applied evidence_task_id and non-empty confirmation_reference. Fresh inbox sync alone is not evidence. For held_reason: "unknown", explicit provider confirmation accepts operator_confirmed_sent (requires message_id, optional provider_msg_id/thread_id) or operator_confirmed_not_sent (no message/thread handles), with a non-empty confirmation_reference and no evidence_task_id. This queues an audited normal send result, not an immediate hold reset. The consumer reconciles it before reopening admission; other unknown tasks remain held. A timeout or missing Sent copy never permits automatic retry. Existing NULL participation remains legacy; new mailboxes default off. See send safety and exact resolution requirements.

DELETE /emails/:id releases the mailbox on SenditCold Cloud before removing the local mailbox. An enrolled mailbox's stored credential comes out of the pool, and a cloud-managed mirror's claim is released so the mailbox returns to the cloud workspace and can be adopted again. If the cloud cannot confirm the release, the delete returns 409 mailbox_cloud_unenroll_failed and keeps the mailbox record so the request can be retried safely. SenditCold attempts to restore the mailbox onto its worker immediately, and the worker reconciler may restore it later if that attempt fails. See mailboxes.

GET /unibox and GET /unibox/thread return message previews: each row carries snippet, a one-line summary, not the message body. Read a full message with GET /unibox/:id, which returns body_plain plus body_html. The HTML is sanitized before it leaves the API (scripts, event handlers, embedded frames, and unsafe URL schemes are removed), so it is safe to render, and links carry target="_blank" with rel="noopener". SenditCold open-tracking pixels are also removed from this display copy, including quoted history, so rendering it does not record a campaign open. Stored and delivered copies keep their pixels. body_truncated is true on the rare message whose stored body could not be read, where body_plain falls back to the snippet. The same response carries the full envelope (from, to, cc, bcc, ReplyTo, date, internal_date, message_id, in_reply_to, size), the mailbox it belongs to (email_id) and its canonical folder.

GET /unibox also accepts address=<value> (matches sender or recipient, for "every conversation with this person") and direction=sent|received (resolved against your own mailbox addresses).

The agent-drafts endpoints back the inbox agent: the agent drafts a suggested reply on an inbound human reply and holds it here for review. GET lists the pending drafts; approve sends one (optionally with an edited body) through the normal reply path and is safe to retry with an Idempotency-Key; discard dismisses it. Approving is the only path that sends. An approval claims the draft in one step and sends the draft as it stood at that moment, so a concurrent PUT /unibox/ai-drafts/by-thread either lands before the claim and is what gets sent, or comes after it and leaves the approved draft unchanged. An edited body replaces only the text; the recipient, subject and threading are the claimed draft's.

PUT /unibox/ai-drafts/by-thread lets an API key hand in a reply it wrote itself, for the team to review in the same Drafts view. Send thread_id, body (plain text, up to 8000 characters) and source (a short label naming the tool that wrote it, such as my-agent, up to 40 letters, digits, dots, dashes or underscores, kept as the draft's model). intent_class (up to 40 characters) and confidence (0 to 1) are optional. The draft answers the newest message on the thread that came from someone else: it replaces the body of the thread's pending draft (and points it at that message if it answered an older one, under a new id, so an approval of the version that answered the older message sends nothing), or creates a pending draft addressed to that sender with the subject Re: .... The response is {"id", "status": "pending", "created"}, and repeating the call is safe. It never sends and never changes an approved or discarded draft: 404 thread_not_found when the workspace has no inbound message on the thread, 409 already_sent when an approved draft already answers the newest inbound message, and 409 draft_discarded when a discarded one does. A resolved draft answers the message it was written for, so a reply that arrives later (even one synced after the approval but dated before it) gets a fresh pending draft. A key limited to certain mailboxes can only draft on threads that arrive in them.

Templates and CRM

MethodPathAPI Permission
GET/templates, /templates/:idREAD_TEMPLATES
POST/templates/scoreREAD_TEMPLATES
POST/templates/analyzeWRITE_TEMPLATES (it is charged to the workspace balance)
POST/PATCH/DELETE/templates[/:id]WRITE_TEMPLATES
POST/templates/copy-toREAD_TEMPLATES and WRITE_TEMPLATES, same workspace only
GET/crm/pipelines, /crm/pipelines/:idREAD_CRM
POST/PATCH/DELETE/crm/pipelines[/:id][/stages[/:stageId]]WRITE_CRM
GET/crm/deals, /crm/deals/:idREAD_CRM
POST/PATCH/DELETE/crm/deals[/:id]WRITE_CRM
GET/crm/tasks, /crm/tasks/:idREAD_CRM
POST/PATCH/DELETE/crm/tasks[/:id]WRITE_CRM
PATCH/DELETE/crm/tasks (bulk, over an id list or a whole filter)WRITE_CRM
GET/crm/task-typesREAD_CRM
POST/PATCH/DELETE/crm/task-types[/:id]WRITE_CRM
GET/crm/settings, /crm/metadata, /crm/owners, /crm/syncREAD_CRM
PUT/crm/settingsINTEGRATIONS
PUT/crm/owners/:externalIdINTEGRATIONS
POST/crm/sync, /crm/sync/retry, /crm/sync/discardINTEGRATIONS
GET/POST/crm/backfillINTEGRATIONS
GET/crm/contacts/:idREAD_CRM
POST/crm/contacts/:id/refreshREAD_CRM
POST/crm/contacts/:id/linkWRITE_CRM
PATCH/crm/contacts/:idWRITE_CRM
GET/crm/listsREAD_CRM
POST/crm/lists/preview, /crm/lists/importWRITE_CONTACTS

The /crm/settings through /crm/lists routes are CRM provider mode (HubSpot or Pipedrive), and act on the CRM the workspace runs on. For a dashboard session they gate on view_contacts to read, manage_contacts to edit a contact's CRM record or import a list, and manage_settings to change the mode, match owners, run a sync or copy records. POST /crm/sync/retry, POST /crm/sync/discard and POST /crm/backfill are naturally safe to retry: a requeued job is not queued twice and a copied record is never copied again. While HubSpot or Pipedrive is the workspace's CRM, writes to pipelines, stages and task types answer 409 crm_managed_externally. See CRM.

Analytics and audit

MethodPathAPI Permission
GET/analytics/* (dashboard, direct, inbox-tagging, deliverability, warmup, warmup/placement, campaigns, accounts, usage)READ_ANALYTICS
GET/audit-logsREAD_AUDIT_LOGS

Analytics reads use the selected workspace for JWT callers and the key's workspace for API-key callers. This includes warmup and usage totals, so teammates see the same workspace history regardless of which member connected a mailbox or created a campaign.

GET /analytics/warmup and GET /analytics/warmup/placement include detailed Cloud warmup reports for enrolled mailboxes on a linked self-hosted instance, alongside local warmup history. The optional email_id is always the local mailbox ID; workspace reports request only that workspace's enrollments. Cloud failures are returned as errors, not empty or partial reports. Daily warmup stats include active, whether that date had a warmup plan, so combining sources counts each active UTC date once.

Cloud exposes POST /pool-link/instance/analytics/warmup and POST /pool-link/instance/analytics/warmup/placement for instance tokens only, not API keys. Both accept { "remote_ids": ["uuid"], "from": "YYYY-MM-DD", "to": "YYYY-MM-DD" }, with 1 to 100 enrolled remote IDs and up to 366 inclusive UTC days. Invalid ranges or batch sizes return 400; any ID not enrolled by the calling instance refuses the whole request with pool_link_mailbox_not_found. The first returns daily counts, including active; the second returns raw daily, hosts, sent, unconfirmed and windows counts with instance-side remote IDs. Daily placement includes the lookback before from needed for rolling rates, hosts and send counts cover the requested range, and windows cover the current trailing window.

GET /analytics/inbox-tagging accepts limit, opaque cursor, and needs_review query parameters and returns data, aggregate summary, and pagination. Each row carries actions, the list of what the verdict was allowed to do (hold, stop, task, suppress), and summary.acted counts the rows that did anything. return_date (YYYY-MM-DD, or null) is the out-of-office return date the model was asked to confirm, with its answer under answers.return_date. Invalid cursors, limits, or booleans return 400.

GET /analytics/accounts returns account health and usage in the standard data plus pagination envelope with an opaque cursor. Pass email_ids (comma-separated, up to 200) to ask only for the mailboxes a page shows, or limit (1-1000, default 1000) and cursor to walk the whole inventory. Per-mailbox reads are batched across the page, so a page's cost does not grow with the total inventory, and a workspace with more mailboxes than one page reaches the rest through next_cursor instead of having them silently dropped. Invalid cursors, limits, or ids return 400.

Client reports

MethodPathAPI Permission
GET/reportsREAD_ANALYTICS
GET/reports/:idREAD_ANALYTICS
GET/reports/:id/previewREAD_ANALYTICS
POST/reportsMANAGE_REPORTS
PATCH/reports/:idMANAGE_REPORTS
DELETE/reports/:idMANAGE_REPORTS
POST/reports/:id/linkMANAGE_REPORTS
DELETE/reports/:id/linkMANAGE_REPORTS
PUT/reports/:id/scheduleMANAGE_REPORTS
DELETE/reports/:id/scheduleMANAGE_REPORTS

Client reports are saved, scoped views of the workspace's results that can be shared by link and emailed on a schedule. For a dashboard session, reads gate on view_analytics and writes on view_analytics and manage_campaigns; members limited to selected resources cannot reach these routes. A report's access has the same shape as a member's access (scope plus campaign_folder_ids, campaign_ids and email_account_ids), every id must belong to the workspace (400 invalid_report_scope otherwise), and folders are resolved to the campaigns they hold each time the report is read. GET /reports returns data plus pagination with an opaque cursor (limit 1 to 100, default 50). GET /reports/:id/preview and the public page take range (last_7_days, last_30_days, this_month, last_month; anything else is 400 invalid_report_range) and read the same queries as the analytics dashboard, in whole UTC days.

POST /reports/:id/link mints a 32-byte random token, stores only its SHA-256, and returns the url in that one response; calling it again replaces the link, so it is also the rotate action. expires_in_days is 1 to 365, default 90. DELETE /reports/:id/link turns off the share link and the link inside every scheduled email already sent. PUT /reports/:id/schedule takes recipients (1 to 10 plain addresses), cadence (weekly or monthly), weekday (0 is Sunday), month_day (1 to 28), hour, an IANA timezone and paused. Writes accept Idempotency-Key; minting a link without one mints a new link on every retry, which only replaces the previous one.

The public report page is served at /r/:token by the portal service, on PORTAL_DOMAIN or on an agency's verified portal domain. It takes no authentication and is answered by the backend's internal route GET /api/v1/internal/reports/:token. On a portal domain it serves only that agency's clients' reports. Without PORTAL_DOMAIN no link is minted (422 report_links_unavailable).

Inbox placement tests

A placement test sends real mail from one of the workspace's mailboxes, so starting and cancelling one takes the same permission as starting a campaign, while reading results is an analytics read. Everything here is scoped to the workspace: a test, a sending mailbox, a campaign, a step and a contact are each checked to belong to it before anything is sent.

MethodPathAPI Permission
GET/placement/overviewREAD_ANALYTICS
GET/placement/testsREAD_ANALYTICS
GET/placement/tests/:idREAD_ANALYTICS
POST/placement/testsSEND_CAMPAIGNS
POST/placement/tests/:id/cancelSEND_CAMPAIGNS
GET/placement/seedsREAD_EMAILS
PUT/placement/seeds/:email_account_idWRITE_EMAILS
GET/placement/batchesREAD_ANALYTICS
GET/placement/batches/:idREAD_ANALYTICS
GET/placement/batches/:id/sendersREAD_ANALYTICS
POST/placement/batches/previewSEND_CAMPAIGNS
POST/placement/batchesSEND_CAMPAIGNS
POST/placement/batches/:id/cancelSEND_CAMPAIGNS
GET/placement/coverageREAD_ANALYTICS

For session callers the matching organization permissions are View analytics for the reads (tests, batches, a batch's senders and coverage), Send campaigns for starting, previewing and cancelling a test or a batch, View campaigns for listing seed inboxes and Manage mailboxes for marking one. The campaign's placement monitor (/campaigns/:id/placement-monitor, listed under campaigns above) is read with View campaigns and changed with Send campaigns.

A key restricted to certain mailboxes can only start a test from one of them and only sees and marks those mailboxes as seeds. POST /placement/tests accepts an Idempotency-Key; PUT /placement/seeds/:email_account_id and PUT /campaigns/:id/placement-monitor state an absolute value, so a retry lands on the same state. GET /placement/tests takes limit (1 to 100, default 25), an opaque cursor and an optional campaign_id, and returns data plus pagination; an invalid cursor or limit is a 400. It leaves out the tests a batch started, which are read through their batch.

A placement batch runs the same test from many mailboxes. POST /placement/batches takes the copy, panel, tracking and pace a single test takes, plus exactly one of sender_account_ids or sender_scope ({"type": "campaign", "campaign_id": ...} or {"type": "workspace"}, with optional providers, domains, tag_ids, include_inactive and untested_days), an optional sample, on_unavailable (defer or skip) and max_credits for the whole batch. It answers 201 with the batch queued as soon as the senders are written down; the backend starts them a few at a time. POST /placement/batches/preview takes the same body and returns the counts it would come to (senders, tests, the most copies sent, free and paid tests, credits) without starting anything or writing a row. A key restricted to certain mailboxes can only name those in sender_account_ids (another is a 403), and a server-side scope resolves to those alone. POST /placement/batches accepts an Idempotency-Key; cancelling states an end state, so a retry answers placement_batch_not_running and changes nothing. GET /placement/batches and GET /placement/batches/:id/senders take limit and cursor like the tests list; the senders list also takes sort (worst, the default, best, email or status), status and q (a substring of the address), and an unknown value of any of them is a 400. See the placement endpoint reference.

Advisor

Recommendations about deliverability, mailbox configuration, warmup, campaign performance, copy, and list quality. See the Advisor guide.

GET /advisor/recommendations returns open and applied findings by default. Pass status=open for outstanding suggestions only, status=applied for applied fixes, or status=resolved for findings cleared by a recheck. Repeat status to request several states. The dashboard requests open findings so applied fixes leave its suggestions immediately. Campaign, step and mailbox changes trigger a background recheck that clears findings which no longer apply.

Reads are an analytics read of the workspace's sending posture. Applying or undoing a fix has no scope of its own: the change runs through the tool the fix actually uses and is refused if the caller lacks that tool's permission, so a key that can read recommendations cannot use them to make changes it could not make directly.

MethodPathAPI Permission
GET/advisor/recommendationsREAD_ANALYTICS
GET/advisor/summaryREAD_ANALYTICS
GET/advisor/settingsREAD_ANALYTICS
POST/advisor/refreshREAD_ANALYTICS
POST/advisor/recommendations/:id/snoozeREAD_ANALYTICS
POST/advisor/recommendations/:id/dismissREAD_ANALYTICS
POST/advisor/recommendations/:id/feedbackREAD_ANALYTICS
POST/advisor/recommendations/:id/applyREAD_ANALYTICS, plus the permission of the underlying change
POST/advisor/recommendations/:id/undoREAD_ANALYTICS, plus the permission of the underlying change

Apply and undo run the fix under the key's own scopes, and a key limited to certain mailboxes can only apply or undo a fix for one of them (403 otherwise). Changing Advisor settings (PATCH /advisor/settings) is JWT only, alongside the rest of organization settings. Silencing checks for a whole workspace is governance, and no read scope should be able to do it. The same applies to POST /advisor/recommendations/:id/agent-fix, which is JWT only and needs USE_AI: it acts as a named member and spends credits, and no API scope should let a key rewrite a workspace's campaigns unattended.

API key self-service

MethodPathAPI Permission
GET/api-keysAPI_KEYS
POST/api-keysAPI_KEYS, not OAuth tokens
GET/api-keys/permissionsAPI_KEYS
GET/api-keys/:idAPI_KEYS
PATCH/api-keys/:idAPI_KEYS, not OAuth tokens
DELETE/api-keys/:idAPI_KEYS, not OAuth tokens
DELETE/api-keys/:id/permanentAPI_KEYS, not OAuth tokens
DELETE/api-keys/selfnone

DELETE /api-keys/:id/permanent deletes a key that has already been revoked or has expired, taking its usage logs with it. A key that could still authenticate gets a 409, because revoking is what records that a credential was ended and why. See Ending a key.

POST /api-keys and PATCH /api-keys/:id require a recent confirmation (POST /auth/reauth) when called from a signed-in session, because a key outlives the session that made it and a stolen browser token should not be able to leave one behind or widen one. An API key calling them is unaffected: it has no session and no second factor to present, it was itself minted from a confirmed session, and the API_KEYS scope is the explicit grant that governs it. Automation and the CLI keep working.

Creating, editing, revoking and deleting keys is for a signed-in member or an API key; an OAuth app token gets 403 oauth_token_not_allowed. A new or edited key stays within its creator: a member cannot give it a permission their role does not cover, and an API key cannot give it a permission, mailbox or IP address the calling key does not have (403 api_key_permissions_exceed_caller). See what a role can delegate. GET /api-keys/permissions reports the caller's bound as grantable.

An agency API key cannot call any route in this section, DELETE /api-keys/self included (403 agency_key_not_allowed); its keys are managed under agencies. The same goes for the OAuth app routes below.

DELETE /api-keys/self revokes the key the request was made with, and is the one route here that needs no scope. A credential must always be able to end itself: requiring API_KEYS to sign out would leave a read-only key on a laptop someone is handing back live, which is what warmbly auth logout promises to prevent. A JWT caller gets a 400: there is no key in that request to end, only a session, which POST /auth/logout ends.

OAuth apps

Registering and managing the OAuth apps your workspace owns. The flow itself (authorize, token, revoke) is listed under JWT only and Public below. Every route here, and POST /oauth/application-logo, refuses an OAuth app token with 403 oauth_token_not_allowed.

MethodPathAPI Permission
GET/oauth/applicationsAPI_KEYS
POST/oauth/applicationsAPI_KEYS
GET/oauth/applications/:idAPI_KEYS
PATCH/oauth/applications/:idAPI_KEYS
DELETE/oauth/applications/:idAPI_KEYS
POST/oauth/applications/:id/rotate-secretAPI_KEYS
POST/oauth/applications/:id/logoAPI_KEYS
DELETE/oauth/applications/:id/logoAPI_KEYS
GET/oauth/applications/:id/webhook-secretAPI_KEYS
POST/oauth/applications/:id/webhook-secret/rotateAPI_KEYS
GET/oauth/applications/:id/webhook-endpointsAPI_KEYS
GET/oauth/applications/:id/webhook-deliveriesAPI_KEYS
GET/oauth/applications/:id/listingAPI_KEYS
PUT/oauth/applications/:id/listingAPI_KEYS
DELETE/oauth/applications/:id/listingAPI_KEYS

The listing routes publish an app to the community directory. PUT replaces the whole listing and DELETE succeeds when there is nothing to remove, so both are safe to retry. Browsing the directory is GET /integrations/community and GET /integrations/community/:slug, under /integrations/* below.

Operations

MethodPathAPI Permission
GET/PATCH/outreach/settingsWRITE_CAMPAIGNS
POST/deliverability/eventsWRITE_CAMPAIGNS
GET/suppressionsREAD_CONTACTS
POST/suppressionsWRITE_CONTACTS
DELETE/suppressions/:idWRITE_CONTACTS
GET/tasks/dlqSEND_CAMPAIGNS
POST/tasks/dlq/:id/replaySEND_CAMPAIGNS
GET/POST/PATCH/DELETE/webhooks[/:id]WEBHOOKS
POST/webhooks/:id/rotate-secretWEBHOOKS
POST/webhooks/:id/verifyWEBHOOKS
GET/webhooks/event-typesWEBHOOKS
GET/webhooks/deliveriesWEBHOOKS
GET/webhooks/:id/deliveriesWEBHOOKS
POST/webhooks/deliveries/:deliveryId/redeliverWEBHOOKS
GET/webhooks/throttle-dropsWEBHOOKS
GET/POST/DELETE/integrations/* (except /integrations/slack/*, which is JWT only, and /integrations/bookings)INTEGRATIONS
GET/integrations/bookingsREAD_CONTACTS
GET/PUT/integrations/salesforce/:id/settingsINTEGRATIONS
GET/integrations/salesforce/:id/overview, /metadata, /users, /list-views, /campaigns, /activityINTEGRATIONS
POST/integrations/salesforce/:id/import/previewINTEGRATIONS
GET/POST/PATCH/DELETE/integrations/salesforce/:id/import-sources[/:sourceId]INTEGRATIONS
POST/integrations/salesforce/:id/import-sources/:sourceId/runINTEGRATIONS
POST/integrations/salesforce/:id/activity/retry, /sync-nowINTEGRATIONS
GET/contacts/:id/salesforceREAD_CRM
POST/contacts/:id/salesforce/syncINTEGRATIONS
DELETE/contacts/:id/salesforce/links/:linkIdINTEGRATIONS
GET/integrations/apollo/:id, /listsINTEGRATIONS
PUT/integrations/apollo/:id/keyINTEGRATIONS
POST/integrations/apollo/:id/import/previewINTEGRATIONS
GET/POST/PATCH/DELETE/integrations/apollo/:id/import-sources[/:sourceId]INTEGRATIONS
POST/integrations/apollo/:id/import-sources/:sourceId/runINTEGRATIONS
GET/integrations/apollo/:id/enrichments[/:runId]INTEGRATIONS
POST/integrations/apollo/:id/enrichmentsINTEGRATIONS and BULK_CONTACTS
GET/PATCH/integrations/clay/:idINTEGRATIONS
GET/POST/PATCH/DELETE/automations[/:id]INTEGRATIONS
PATCH/automations/:id/layoutINTEGRATIONS

A signed-in member needs manage_settings to create, edit, enable or delete an automation, because a flow runs as the workspace; use_integrations is enough to list, open, test and read the run history.

Starting an Apollo enrichment writes contacts and spends the workspace's Apollo credits, so it needs BULK_CONTACTS as well as INTEGRATIONS (for a member, use_integrations and manage_contacts). Saving the key, imports and Clay settings need manage_settings; previewing and running an import need use_integrations. POST /integrations/inbound/clay/:secret is the URL a Clay table posts rows to. It takes no API key, because the secret in its path is the credential. | GET/POST/PATCH/DELETE | /warmup/routing[/:id] | WARMUP_ROUTING |

Retry safety

Mutating API requests may include an Idempotency-Key header. SenditCold stores the completed response for 24 hours per organization and key. A request is identified by the credential that sent it (the signed-in user, the API key, or the OAuth authorization), the method, the concrete path including its resource ids, the query and the body. Reusing the same key for the same request replays the original response with X-Idempotent-Replayed: true and does not run the operation again; reusing it for anything else, including the same route on a different resource or a different credential, returns 409 Conflict. A second request sent while the first is still running also gets 409 Conflict.

The outcome is recorded even when the client disconnects after the operation went through, so retry with the same key rather than assuming it failed. If SenditCold cannot write the outcome down, the key answers as still processing and, after ten minutes, as unknown. A request refused before it ran (401, 403 or 429) does not use up its key, so the same key works once the refusal is resolved; a refusal never clears an outcome an earlier attempt already recorded. A request that ended in a server error (5xx), or never finished, has an unknown outcome: for the 24 hours the key is kept it answers 409 with code: "idempotency_outcome_unknown" and does not run again. The operation may have happened even when a read shows no trace of it yet (a send can be delivered before its Sent copy appears), so retry with a new key only once you have established that the change did not happen.

Identity

MethodPathAPI Permission
GET/me(any authenticated key)

GET /me returns who the active credential belongs to: user_id, email, name, organization_id, organization_name, auth_type (api_key, oauth, or jwt), and the granted scopes. It requires no specific permission, so it is the right call for validating a connection and rendering a human-readable label. Unlike /auth/me (JWT only), it is reachable by API keys and OAuth tokens.

Labels

Folders (on campaigns), tags (on mailboxes) and labels (on contacts, inbox conversations and forms, managed through the /categories endpoints) are three registries with the same shape. Each belongs to the workspace, not to whoever created it. Every member sees the same set, whoever created it, and reads it from GET /auth/me, which returns folders, tags and categories for the session's selected workspace; there is no separate list endpoint and no read scope of its own. Changing a registry is gated like the records it labels, so it takes the API permission in the table below, or the matching JWT permission MANAGE_CAMPAIGNS, MANAGE_EMAILS or MANAGE_CONTACTS. Membership alone is read-only.

position is the order within its own registry, 0-based and contiguous. A move returns the full new ordering. A registry holds at most 100 entries.

MethodPathAPI Permission
POST/foldersWRITE_CAMPAIGNS
PATCH/folders/:idWRITE_CAMPAIGNS
PATCH/folders/:id/moveWRITE_CAMPAIGNS
DELETE/folders/:idWRITE_CAMPAIGNS
POST/tagsWRITE_EMAILS
PATCH/tags/:idWRITE_EMAILS
PATCH/tags/:id/moveWRITE_EMAILS
DELETE/tags/:idWRITE_EMAILS
POST/categoriesWRITE_CONTACTS
PATCH/categories/:idWRITE_CONTACTS
PATCH/categories/:id/moveWRITE_CONTACTS
DELETE/categories/:idWRITE_CONTACTS

Reference data

MethodPathAPI Permission
GET/plans(any authenticated key)
GET/timezones(any authenticated key)

JWT only

These never accept an API key. They depend on a human-bound session: billing flows, governance, OAuth onboarding, websocket bootstrap, and operational destruction.

With multiple dashboards sharing a backend, password-reset and invitation emails and billing returns use the initiating browser's exact configured dashboard origin. Without a trusted origin they use the primary dashboard. Mailbox, administrator-grant, integration and Slack identity OAuth callbacks recover their target from live server-side state, never from callback query parameters. Expired or consumed routing state is not forwarded to another dashboard.

  • POST /auth/login, /auth/login/confirm, /auth/register, /auth/register/confirm, /auth/refresh, /auth/reset-password, /auth/reset-password/session, /auth/reset-password/confirm
  • POST /auth/2fa/verify (exchange a pending login challenge and a single-use authenticator or recovery code for a session; an authenticator step at or before the last accepted step answers two_fa_code_used, while unavailable verification answers a server error)
  • POST /auth/reauth (re-prove the account holder behind a live session, for the changes that require a recent confirmation; a valid password can confirm even when a supplied authenticator step is used or superseded. Without a successful factor, an authenticator step at or before the last accepted step answers two_fa_code_used, while unavailable verification answers a server error)
  • GET /auth/reauth/methods (which ways this account can confirm: password, two-factor code, passkey, and the linked sign-in providers it can re-verify)
  • POST /auth/reauth/passkey/begin, /auth/reauth/passkey/finish (confirm with one of the account's own passkeys)
  • POST /auth/reauth/sso/begin, /auth/reauth/sso/finish (confirm through the Google, Apple or single sign-on identity linked to the account, in a browser window)
  • POST /auth/reauth/native/begin, /auth/reauth/native (confirm with a native Sign in with Apple or Google ID token carrying the session's nonce)
  • GET /auth/config (public deployment capabilities: which sign-in methods this backend has enabled, whether a login code step follows, whether signups are open, whether the instance still needs claiming)
  • GET /auth/portal?host= (public: whether host is a verified agency portal domain and, when it is, the agency brand the dashboard there shows (with the agency's icon_url, sign_in_image_url and theme when set, see portal theme), the main dashboard URL, whether the security check below is available and sign_in, where the agency's clients sign in: portal or warmbly (the main address). Always 200; anything else answers {"portal": false})
  • POST /auth/portal/handoff (JWT only, from the main dashboard address: host names a verified portal whose agency signs clients in on the main address, and the answer is a redirect_url on that host carrying a one-time code, valid for 60 seconds. end_session: true ends the calling session once the code exists, for a sign-in made only to reach the portal. A session signed in on a portal, or a request from any other origin, answers 403 portal_handoff_origin; any other host 404 portal_not_found. Each call issues a new single-use code, so a retry is safe without an Idempotency-Key)
  • POST /auth/portal/exchange (public, from the portal's own origin: trades code once for a session bound to that portal's agency, answered like a sign-in. A code that expired, was already spent, or is presented from another origin answers 400 portal_handoff_invalid, and any attempt spends it)
  • GET /auth/captcha-frame?origin= (public: an HTML security check rendered on the API's host for the dashboard on a verified portal domain to embed. Only that origin may frame it and only it receives the token, which goes through the same sign-in checks as on the main address; any other origin answers 400)
  • POST /auth/app-attest/challenge, /auth/app-attest/keys (the native app's App Attest challenge and one-time key registration, on their own per-IP budget APP_ATTEST_IP_RATE_LIMIT)
  • GET /auth/instance (JWT only: the running SenditCold version of a self-hosted instance and whether a newer release exists, for the dashboard's version pill; a hosted deployment answers self_hosted: false and nothing else)

Sent from an agency portal domain, POST /auth/register and /auth/register/confirm need an invite for the submitted address; without one they answer 403 portal_registration_closed.

POST /auth/register accepts an optional invite field carrying an invitation token. On a deployment running DISABLE_REGISTRATION=invite_only it is what permits the signup, and the account is created inside the inviting organization rather than in a new one. The token must resolve to a live invitation whose email equals the submitted address, otherwise the request is refused with invitation_invalid. Omitting it on a closed deployment returns registration_invite_only or registration_closed. See error codes.

Actions that need a recent confirmation

These changes require the session to have confirmed the account holder within the last five minutes, over and above the permission they already need:

ActionRoute
Create or edit an API keyPOST /api-keys, PATCH /api-keys/:id
Create or edit a webhook endpointPOST /webhooks, PATCH /webhooks/:id
Approve an app on the OAuth consent screenPOST /oauth/authorize
Approve a CLI sign-inPOST /auth/cli/codes/:code/approve
Add a passkeyPOST /auth/passkey/register/begin, /finish
Remove a passkeyDELETE /auth/passkey/credentials/:id
Turn on two-factor authenticationPOST /auth/2fa/enroll/start, /confirm (see below for accounts with no password)
Transfer a workspacePOST /organization/transfer-ownership
Invite or remove a member, change a member's role or change their access scopePOST /organization/members/invite, DELETE /organization/members/:id, PATCH /organization/members/:id, PUT /organization/members/:id/access
Create, edit or delete a custom rolePOST /organization/roles, PATCH /organization/roles/:id, DELETE /organization/roles/:id
Export or import a whole workspacePOST /organization/current/export, POST /organization/current/import
Add, change or remove agency staff, bring a workspace into an agency or detach one, take a workspace out of its agency, move AI balance into a client, start an agency checkout, top-up or AI balance purchase, switch its plan, change its automatic top-up, or open its billing portalPOST /agency/:agencyId/members, PATCH /agency/:agencyId/members/:userId, DELETE /agency/:agencyId/members/:userId, POST /agency/:agencyId/clients/attach, DELETE /agency/:agencyId/clients/:orgId, POST /agency/:agencyId/clients/:orgId/ai-balance, POST /organization/agency/leave, POST /agency/:agencyId/billing/checkout, POST /agency/:agencyId/billing/change, PUT /agency/:agencyId/billing/auto-topup, PUT /agency/:agencyId/billing/ai-auto-topup, POST /agency/:agencyId/billing/topup, POST /agency/:agencyId/billing/ai-topup, POST /agency/:agencyId/billing/portal
Create, edit or revoke an agency API keyPOST /agency/:agencyId/api-keys, PATCH /agency/:agencyId/api-keys/:keyId, DELETE /agency/:agencyId/api-keys/:keyId
Connect, re-key or remove an inbox vendor account an agency holdsPOST /agency/:agencyId/vendors, PATCH /agency/:agencyId/vendors/:id, DELETE /agency/:agencyId/vendors/:id
Schedule a workspace or account for deletionPOST /organization/current/danger-zone/delete, POST /me/danger-zone/delete
Approve a self-hosted instance's warmup pool link (always refused with 403 pool_link_closed)POST /pool-link/codes/:code/approve
Start a checkout, open the billing portal, change or cancel the plan, add money to the balance, save a card, change automatic reload or AI spend controlsPOST /subscription/checkout, POST /subscription/portal, POST /subscription/change-plan, POST /subscription/cancel, POST /subscription/balance/top-up, POST /subscription/balance/checkout, POST /subscription/balance/payment-method, PATCH /subscription/balance/auto-reload, PATCH /subscription/credits/settings, POST /pool-link/checkout (the retired Warmup plan's checkout, which always refuses with 409 warmup_plan_retired), POST /subscription/offers/:planId/checkout
Reveal or rotate a signing secretPOST /webhooks/:id/rotate-secret, GET /integrations/connections/:id/webhook-secret, POST /oauth/applications/:id/rotate-secret, GET /oauth/applications/:id/webhook-secret, POST /oauth/applications/:id/webhook-secret/rotate
Record, use or remove an admin grant over a whole domainPOST /emails/grants/google/finish, POST /emails/grants/microsoft/finish, POST /emails/grants/:id/connect, DELETE /emails/grants/:id
Admin panel: issue a fleet join token, grant admin permissions, export or import any workspace, issue a tester a new password, change a login-code exemptionPOST /admin/fleet/join-token, POST /admin/admins/:userId/grant, POST /admin/organizations/:id/exports, POST /admin/organizations/:id/imports, POST /admin/testers/:id/password, PUT /admin/users/:id/login-code-exempt

Each either hands out a credential that outlives the session that created it, reveals a secret, changes who can get in, moves money, cannot be reversed by the person it was done to, or reaches a whole domain's mail. Without a confirmation they answer 403 with code reauth_required; confirm and retry. See error codes.

GET /auth/reauth/methods says which ways the account can confirm, so a client offers only those:

{
  "password": false,
  "totp": false,
  "passkey": true,
  "providers": [{ "id": "google", "label": "Google" }],
  "native_providers": ["google"],
  "valid_for_seconds": 300
}
  • Password or code: POST /auth/reauth with password or a current two-factor or recovery code.
  • Passkey: POST /auth/reauth/passkey/begin returns WebAuthn request options limited to the account's own passkeys; pass options.publicKey to the browser and send the assertion to POST /auth/reauth/passkey/finish as credential. The authenticator must verify the user.
  • Linked identity in a browser: POST /auth/reauth/sso/begin with provider (one of providers[].id) returns url and binding. Open url in a window and keep binding. The provider returns that window to the dashboard's /reauth/complete with a single-use code, which only the session that began the flow can redeem: POST /auth/reauth/sso/finish with code and binding. The identity the provider verifies must already be linked to the account; another account is refused with reauth_identity_mismatch, never matched by email address.
  • Native app: POST /auth/reauth/native/begin returns a nonce. Pass it unchanged as the Sign in with Apple request nonce or the Google authorization nonce, then send the ID token to POST /auth/reauth/native with provider (apple or google). The nonce is single-use and bound to the session.

Each succeeds with { "valid_for_seconds": 300 } and confirms only the session that made the call.

This applies to session callers. An API key or OAuth token has no session to confirm and is not the threat here, so it passes straight through to the route's permission gate.

A completed sign-in counts as a confirmation for the first five minutes of the session. POST /auth/reauth answers an account with neither a password nor two-factor authentication 400 with code reauth_no_factor; it confirms with a passkey or its linked identity instead. An account with none of those signs in again. When password availability cannot be determined, reauthentication returns a server error with a request ID.

GET /auth/config gained two fields: invites_required (boolean, true when an invitation token is needed to create an account) and docs_url (string, the deployment's link to the accounts and access documentation, for a client to surface next to a refusal).

GET /auth/config also carries websocket_url and app_url (both strings, each omitted when the instance has none). They are the realtime gateway a developer client connects to and the dashboard origin a client sends someone to. Both are served here because on a self-hosted instance the host layout is whatever the operator chose, and there is no other way to discover it: the CLI reads them for warmbly events tail and warmbly browse.

POST /getaway is session-only and mints a ten-minute WebSocket ticket. Its url remains the direct gateway; the optional proxy_path is resolved against /v1 for browser connections. GET /realtime/socket/websocket upgrades a connection using that ticket, preserving the configured realtime service's origin checks and connection limits. It accepts no ordinary access tokens, API keys or OAuth tokens. See Realtime WebSocket.

Alongside them, api_url is this API's own public base (for a copyable example that names the right server), and brand is who the deployment says it is: name, and website_url, website_label, terms_url, privacy_url and support_email, each omitted when unset. On a self-hosted instance that configured no EMAIL_BRAND_* only name is present, and a client should render no link at all rather than substituting one of its own. See configuration.

GET /auth/config also carries gmail_oauth_connect (boolean): whether a new Gmail mailbox may be connected with Google sign-in. Both BOX_GOOGLE_CLIENT_ID and BOX_GOOGLE_CLIENT_SECRET are required. With them set, the capability defaults to true; explicit BOX_GOOGLE_OAUTH_CONNECT=false or an invalid value disables it. When false, offer the app-password route (POST /emails/onboarding/smtp-imap against Gmail's servers) rather than start an OAuth round trip that returns mailbox_gmail_oauth_disabled. The dashboard offers Google sign-in when this is true, and also on an instance linked to SenditCold Cloud, where the consent runs on SenditCold Cloud's client instead of a local one. Existing Google sign-in mailboxes are unaffected either way.

GET /auth/config also carries app_attest (boolean): whether this server accepts an App Attest proof from the native app in place of a Turnstile token on POST /auth/login, /auth/register, /auth/reset-password and /auth/reset-password/confirm. Read it with captcha: no captcha means nothing to send, a captcha with app_attest means the app sends a proof, and a captcha without it means email and password sign-in is unavailable in the app.

GET /auth/config also carries billing_enabled (boolean). It is false when the deployment runs with BILLING_PROVIDER=none, which is the self-host default: every feature is unlocked server-side, so the dashboard shows the workspace as self-hosted instead of on a free trial and hides the billing page. self_hosted alone does not imply this, because a self-hosted install may still run Stripe.

  • POST /auth/setup (first-run claim: exchanges the one-time token printed at boot for the owner account. Refused once any account exists)

  • GET /auth/providers, POST /auth/apple, POST /auth/google (native-app social sign-in)

  • POST /auth/oidc/begin, GET /auth/oidc/callback (generic OpenID Connect sign-in)

  • POST /auth/google/begin, GET /auth/google/callback, POST /auth/apple/begin, POST /auth/apple/callback (browser Sign in with Google and Sign in with Apple)

  • POST /auth/sso/exchange (swaps the single-use handoff code from any of those callbacks for the session; POST /auth/oidc/exchange is the older name for the same endpoint)

  • POST /auth/sso/link (completes a provider sign-in that resolved to an existing password account: takes that account's password, attaches the identity and returns the session. An issued initial password is refused here with password_change_required)

  • POST /auth/login/initial-password (answers a /auth/login result with password_change_required: takes its single-use pending_token and the holder's own password, replaces the issued initial password, signs out other sessions and continues the sign-in in the /auth/login response shape. Retrying is safe: the token is spent once, and a repeat answers password_change_expired)

  • POST /auth/logout, POST /auth/logout-all, GET /auth/me, PATCH /auth/me/onboarding

  • PUT /auth/me/product-tour (records that the signed-in user finished or skipped the dashboard's product tour, answering product_tour_completed_at, which GET /auth/me also carries. Naturally retry-safe: a repeat keeps the first time, so it takes no Idempotency-Key)

  • POST /auth/me/avatar, DELETE /auth/me/avatar

  • GET /auth/me/identities, DELETE /auth/me/identities/:provider (the Google, Apple and single sign-on identities linked to the signed-in user, and removing one. Removal requires a fresh confirmation, ends the other sessions that signed in through that provider, and is refused with last_sign_in_method when it would leave no way to sign in. No Idempotency-Key: a repeat converges and answers 204)

  • POST /auth/me/password/link (emails the signed-in user a single-use link to set a password, on the same budget as /auth/reset-password. No Idempotency-Key: a repeat mails another link and spends the budget, which is the cap on retries)

  • POST /emails/onboarding/oauth/start, POST /emails/onboarding/oauth/finish, POST /emails/onboarding/smtp-imap. oauth/start accepts an optional login_hint (an email address) that preselects that mailbox in the provider's sign-in, which is how an import's Sign in rows open. For Google and Microsoft dashboard flows (return: "web"), the browser's Origin is validated against exact configured dashboard origins and bound to the OAuth state; an untrusted origin answers 400 mailbox_oauth_return_origin. Requests without an Origin retain primary-dashboard routing while their state is live

  • POST /emails/onboarding/smtp-imap/bulk (up to 50 SMTP/IMAP rows in accounts, answered 200 with a per-row status of connected, skipped or failed and a code; rows past the workspace's mailbox allowance fail with mailbox_allowance_reached before any credential is dialled. Naturally retry-safe: an already connected mailbox is skipped, so it takes no Idempotency-Key)

  • The mailbox import routes under /emails/imports, all with JWT permission MANAGE_EMAILS. They carry passwords, so like onboarding they never accept an API key:

    • POST /emails/imports/preview: what an import of this input would do, row by row and domain by domain, without doing any of it
    • POST /emails/imports: store the rows and connect them in the background; answers 201 with the import
    • GET /emails/imports: the workspace's imports, newest first
    • GET /emails/imports/:id: one import with its counts and its failures grouped by cause
    • GET /emails/imports/:id/rows: an import's rows by line, filtered by status (comma-separated) and cause
    • PATCH /emails/imports/:id/rows/:line: correct one failed row, or give a row waiting for sign-in a password instead (password, app_password, username, smtp, imap), and queue it again
    • POST /emails/imports/:id/retry: requeue failed rows, all of them or those with one cause or the listed lines, optionally with one new password for all
    • POST /emails/imports/:id/cancel: stop the rows not yet started; rows already connecting finish
    • POST /emails/imports/:id/dismiss: hide the import from GET /emails/imports for the workspace, stopping it first when it is still running. Its rows and history stay, and GET /emails/imports/:id still reads it. Answers 204; a repeat changes nothing
    • GET /emails/imports/:id/failed.csv: every row that did not connect, as uploaded minus passwords, with status, problem and how_to_fix

    Preview and create take multipart/form-data: file (CSV, TSV or XLSX up to 10 MB) or text (a pasted list), mapping (a JSON object of column index to field, such as {"0":"email","1":"password"}; omit it to use the automatic mapping) and options (JSON: has_header, shared_password, on_existing of update or skip, save_mapping, and settings applied to every mailbox). One import takes up to 5,000 rows. The two lists answer data plus pagination with next_cursor and has_more; limit is at most 100 for imports and 200 for rows, and a cursor that is not one this API issued returns 400 with invalid_cursor. None of these takes an Idempotency-Key. Repeating a create makes a second import, whose rows find the first one's mailboxes already connected and update (or skip) them, so nothing is connected twice. A retry only requeues rows that failed, and a fix is refused for a row that neither failed nor waits for sign-in (a row its inbox vendor is still authorizing included), so repeating either connects nothing twice. Cancelling an import twice is a no-op. Progress is published as the realtime event MAILBOX_IMPORT_PROGRESS; see realtime. Error codes are under mailbox import refusals

  • Inbox vendor connections under /emails/vendors, JWT permission MANAGE_EMAILS. The key is sealed on the server and no response ever returns it:

    • GET /emails/vendors/catalog: the supported vendors, with each one's fields (key, label, secret, required, help) and key_help_url
    • GET /emails/vendors: the workspace's vendor connections with status, last_error and mailbox count
    • POST /emails/vendors: vendor, label and fields; the key is checked with the vendor first, answers 201. Repeating it makes a second connection
    • PATCH /emails/vendors/:id: rename, or replace fields after checking the new key with the vendor. Safe to repeat
    • DELETE /emails/vendors/:id: forget the key; the mailboxes it brought in stay connected. A repeat is a 404
    • GET /emails/vendors/:id/mailboxes: the vendor account's mailboxes, each marked connected when already in the workspace
    • POST /emails/vendors/:id/import: mailbox_ids or all, plus the same options as a file import; answers 201 with the import. Each call creates a new import; a repeat finds the first run's mailboxes connected and updates (or skips) them
    • GET /emails/vendors/catalog entries also carry signs_in, true for a vendor that can reconnect its Google mailboxes in place
    • POST /emails/vendors/:id/reconnect: optional email_account_id, or email_account_ids (up to 1000, not both); has the vendor sign in again as those Google mailboxes, or every Google mailbox imported from the connection, renewing or converting each in place. Answers 202 with data, one {email_account_id, email, status, message} per mailbox, status one of queued, trusting, signing_in, connected, failed. Safe to repeat: a mailbox whose sign-in is still open at the vendor is never asked again
    • GET /emails/vendors/:id/reconnect: the same data for the connection's last reconnect, empty when there is none
  • GET /emails/:id/signin, JWT permission MANAGE_EMAILS: how the mailbox signs in and what it can switch to. method is google_signin, google_grant, app_password or other; full_mail_access is true or false for a Google sign-in whose granted permissions are recorded, else null; personal marks a shared address such as gmail.com; managed a mailbox whose sign-in SenditCold Cloud holds. options lists only the switches that can succeed: google_signin (with the client_id a Workspace admin marks Trusted), app_password, vendor (connection_id, vendor, label) and grant (id, domain), each null or false when it cannot. auto_switch is true when the vendor will reconnect it on its own, and switch is the last vendor reconnect (status, message, automatic, at) or null. Read only

  • Admin grants under /emails/grants, JWT permission MANAGE_EMAILS. The four routes that record, use or remove a grant also need a recent confirmation:

    • GET /emails/grants/config: whether this instance takes Google and Microsoft grants, the Google google_client_id and google_scopes an administrator authorizes, and google_missing and microsoft_missing, the names of the instance settings still unset (empty when that provider is ready), and google_mailbox_client_id, the per-mailbox Google sign-in client a Workspace administrator marks Trusted (absent when the instance has no Google sign-in client)
    • GET /emails/grants, GET /emails/grants/:id: grants with their provider, tenant, covered domains, status and mailbox count
    • GET /emails/grants/migration: the workspace's mailboxes on per-mailbox Google sign-in that can optionally switch connection methods, grouped by domain: data is a list of domain, kind (workspace, which reconnects with the new Google sign-in, switches to an app password, or moves onto the domain's existing admin grant, or personal, a shared address such as gmail.com, which moves to an app password), grant_id when the workspace has an active Google grant covering the domain, and mailboxes (id, email, name, status, and vendor_connection_id with vendor when a healthy vendor connection can reconnect it); total counts the mailboxes. Only mailboxes without full mail access recorded are listed, and mailboxes whose sign-in SenditCold Cloud holds are not listed. Read only
    • POST /emails/grants/google/start: domain and admin_email (on that domain). New Google domain-wide delegation grants are retired, so this and the finish below only re-prove a domain the workspace already holds a Google grant for; any other domain is refused with 403 google_delegation_retired. Answers how this workspace proves it controls the domain: method signin with a Google sign-in url and state (valid for 15 minutes) when the instance has a Google sign-in app, else method dns. Both carry txt_name (_warmbly.<domain>) and txt_value (warmbly-verify=..., unique to the workspace and domain), because the DNS proof always works. Safe to repeat
    • POST /emails/grants/google/finish (recent confirmation): either state and code from the Google sign-in, which must be admin_email itself on that domain, or domain and admin_email once the TXT record is published. The directory must list admin_email as a super administrator. Answers 201. A state is single-use; repeating a DNS finish re-verifies and updates the same grant, one per domain
    • POST /emails/grants/microsoft/start: the admin consent url and state for a Global Administrator, valid for 15 minutes
    • POST /emails/grants/microsoft/finish (recent confirmation): state and code from the consent callback; answers 201. The organization recorded is the one the administrator consented for, as the redeemed sign-in reports it. The state is single-use, so a repeat is refused with mailbox_grant_state_invalid. One grant per tenant: consenting again updates the same grant
    • POST /emails/grants/:id/check: verify now, and restart the grant's stopped mailboxes when it passes. Safe to repeat
    • DELETE /emails/grants/:id (recent confirmation): remove the grant; every mailbox it connected stops. A repeat is a 404
    • GET /emails/grants/:id/users: the granted directory, each user marked enabled and connected, with email_account_id when connected. upgrade is true for a user whose mailbox is already here on its own sign-in with the same provider (Google or Microsoft, not held by SenditCold Cloud): connecting it moves that mailbox onto the grant in place
    • POST /emails/grants/:id/connect (recent confirmation): user_ids or all (every enabled user not yet connected, plus every user marked upgrade), plus import options; answers 201 with the import. Each call creates a new import; a repeat finds the first run's mailboxes connected and updates (or skips) them. A user whose mailbox is already here as a delegated mailbox is relinked to this grant, and one on its own sign-in is converted onto the grant in place, keeping its history, campaigns and warmup, without counting against the mailbox allowance
  • Sending domains under /emails/domains, JWT permission MANAGE_EMAILS. :domain is the bare domain:

    • GET /emails/domains: every domain the workspace sends from, with mailbox count, mail hosts, SPF, DKIM and DMARC, tracking hosts in use and the redirect. When a connected inbox vendor account holds the domain, vendor_domain carries vendor, connection_id, the current forwarding when the vendor reports it, can_forward, can_unforward, forwarding_reviewed (a change is applied later by the vendor's staff), can_dns and dns_types
    • POST /emails/domains/bulk: domains (1 to 100), tracking_label (one DNS label, such as link; each domain gets <label>.<domain>) and/or redirect_url, plus optional tracking_hosts and redirect_urls objects keyed by domain that give a listed domain its own host or website in place of the shared one. Each domain takes its vendor's path when the vendor's API can (writing the CNAME, forwarding the root) and is saved to wait for DNS otherwise. Optional served_by (instance or cloud) picks where a redirect no vendor forwards is served from; omitted keeps each domain's current choice. Answers data, one row per domain with tracking (host, via of vendor or dns, verified, mailboxes, cname_target, note when the vendor refused the record) and redirect (target_url, via of vendor, dns or cloud, verified, reviewed); a refused domain carries error and code on its row and never stops the others. Safe to retry: each setting is set, not added
    • GET /emails/domains/:domain/tracking-suggestion: the tracking host to offer, its status (active, found or suggested), the cname_target, and vendor_domain when a connected vendor account holds the domain
    • PUT /emails/domains/:domain/tracking: host for every mailbox on the domain (empty clears it); answers the verification result and the number of mailboxes changed. Idempotent
    • PUT /emails/domains/:domain/redirect: target_url, optional include_www (default true) and optional served_by (instance, or cloud on a self-hosted instance linked to SenditCold Cloud; omitted keeps the current choice, and a new redirect is served by this instance). target_url must be on one of the workspace's own domains, a confirmed website included (redirect_target_not_own_domain otherwise; see redirect destination refusals). Checks it once and answers the redirect with its records, served_by, serve_host (the tracking host that answers, which a proxy routes the domain to) and, once DNS verifies, reach: the last visit to the domain, with status (ok, not_reaching, https_error or unreachable), hint (not_routed, host_header, wrong_target, certificate, no_listener, edge_record when Cloudflare refuses the domain because its A or AAAA records point at Cloudflare's own addresses, edge_inactive when Cloudflare received the visit but has not activated the domain yet, or settling while the tracking service picks up a redirect that has just verified), a detail sentence, the proxy that answered when it named itself, and checked_at. A redirect served by this instance or Cloud for it also carries target_active, false with a target_inactive_reason when the saved target_url is no longer on the workspace's own domains and the domain answers as if it had no redirect. When the destination is the workspace's only through a confirmed website, the redirect carries website with domain, verified and record (the TXT record to publish at the website's DNS); while it is not verified, reach is absent because the domain is not served. Idempotent: a repeat keeps the same ownership value, so the TXT record stays valid
    • POST /emails/domains/:domain/redirect/verify: check DNS and visit the domain now; for a redirect SenditCold Cloud serves, Cloud checks and the answer is its verdict. A destination's website is looked at too. Safe to repeat
    • POST /emails/domains/:domain/redirect/website: target_url. Looks for the workspace's TXT record on the destination's website now and answers domain, verified and, when the website is not already one of the workspace's mailbox, tracking or forms domains, record (type, name, value, ok). A found record confirms the website for the workspace. Safe to repeat
    • DELETE /emails/domains/:domain/redirect: stop serving it, on SenditCold Cloud too when Cloud serves it. A repeat is a 404
    • PUT /emails/domains/:domain/vendor-forwarding: url; the vendor account holding the domain forwards its root there. An empty url removes the forwarding where can_unforward. Answers the updated vendor_domain. Idempotent
    • POST /emails/domains/:domain/vendor-tracking: host, a subdomain of the domain; the vendor writes its CNAME to this instance's tracking host, then every mailbox on the domain uses the host. Answers like PUT .../tracking. Safe to retry: the record is replaced, not added

    None of these takes an Idempotency-Key; the retry behavior of each is stated above. Changes to vendor connections, grants and redirects publish AUDIT_CREATED with entity_type mailbox_vendor, mailbox_grant or domain_redirect. Error codes are under mailbox source refusals

  • POST /emails/onboarding/oauth/reauth/:id, PUT /emails/onboarding/smtp-imap/:id (reconnect an existing mailbox after a credential change; JWT permission MANAGE_EMAILS)

  • POST /emails/onboarding/smtp-imap/:id/certificates/check and POST /emails/onboarding/smtp-imap/:id/certificates/confirm (JWT permission MANAGE_EMAILS), for an SMTP/IMAP mailbox that trusts a server on its own certificate. check runs a connection test and answers the shape of GET /emails/:id/certificates with observed filled in for each server; it stores nothing. confirm takes {"smtp_pin": "...", "imap_pin": "..."}, a SHA-256 fingerprint for each server the mailbox trusts on its own certificate, tests against exactly those, stores them and loads the mailbox back onto its worker, clearing a TLS_CERTIFICATE_CHANGED or TLS_CERTIFICATE_UNCONFIRMED error. 400 mailbox_certificate_not_pinned when neither server is trusted that way, 400 invalid_tls_pin for a missing or malformed fingerprint, and the connect errors (mailbox_tls_failed and friends) when the test fails. The body is the desired state, so it takes no Idempotency-Key. Publishes AUDIT_CREATED with entity_type email_account

  • POST /emails/onboarding/app-password/:id (JWT permission MANAGE_EMAILS): move a mailbox off per-mailbox Google sign-in onto Gmail's IMAP and SMTP with app_password, a 16-letter Google app password (spaces are ignored). The password is checked against Gmail's servers before anything is stored, with the same refusals as an SMTP/IMAP connect; on success the mailbox becomes smtp_imap in place, keeps its imported mail, campaigns and warmup, and answers 200 with the mailbox. 409 mailbox_not_google_signin for any other mailbox, including one already switched, so a repeat changes nothing and takes no Idempotency-Key; 400 app_password_invalid when the value is not 16 letters. Publishes AUDIT_CREATED with entity_type email_account

  • GET /oauth/authorize/details, POST /oauth/authorize (the consent flow: a human approves a third-party app, and the grant carries only the scopes the member's role covers). POST /oauth/authorize requires a recent confirmation (POST /auth/reauth), like creating an API key

  • GET /oauth/authorized-apps, DELETE /oauth/authorized-apps/:id (apps the user has authorized)

  • GET /oauth/workspace-authorizations, DELETE /oauth/workspace-authorizations/:id/members/:userId (every member's authorized apps, and revoking one; JWT permission MANAGE_API_KEYS or MANAGE_SETTINGS). A revoke succeeds when there is nothing to revoke and publishes AUDIT_CREATED with entity_type oauth_authorization

  • POST /getaway (websocket bootstrap)

  • GET /realtime/info

  • GET /me/danger-zone, POST /me/danger-zone/delete, DELETE /me/danger-zone/delete

  • GET /me/views/:view, PUT /me/views/:view, DELETE /me/views/:view (the signed-in member's own column layout and sort for a dashboard list in the current workspace; view is contacts or campaign_leads, or unibox_rail for the unibox scope rail, which saves a layout of favorites, hidden, order and section_order instead of columns. Personal to the session, so no API scope reaches it)

  • GET /invitations, POST /invitations/accept

  • All of /organization/* (create, switch, members, invitations, transfer ownership, avatar, danger zone)

    • GET /organization/members includes each member's access_scope (workspace or restricted) and access (scope, campaign_folder_ids, campaign_ids, email_account_ids); GET /organization carries the caller's own access_scope per workspace. GET /organization/invitations carries each invitation's access. It lists pending invitations; with ?include_expired=true it also lists the 50 most recently expired, each marked "expired": true, so they can be sent again by inviting the same address. Any other value returns 400
    • GET /organization/members/:id/access and PUT /organization/members/:id/access (JWT permission MANAGE_TEAM; the PUT also needs a recent confirmation). :id is the member's user id. The body is the whole scope, {"scope": "restricted", "campaign_folder_ids": [], "campaign_ids": [], "email_account_ids": []}, and replaces what was there, so a retry lands on the same state and takes no Idempotency-Key. scope: "workspace" clears the grants. Every id must belong to the workspace (400 invalid_access_resource), at most 500 per list, the owner cannot be restricted (403 owner_access_unrestricted), nobody can change their own access, and the caller must hold every permission the member's roles grant. Answers the member with access, and publishes AUDIT_CREATED with entity_type organization_member
    • POST /organization/members/invite accepts the same object as access, so an invitee joins already restricted. Ids are checked on invite and again on acceptance, which drops grants on anything deleted in between
    • GET /organization/access/suggested-senders?campaign_ids=&campaign_folder_ids= (JWT permission MANAGE_TEAM): the mailboxes those campaigns, and the campaigns in those folders, send from (explicit senders and sending-tag matches), each with the campaign_ids it sends for. A suggestion grants nothing
  • GET /website-tracking/settings, PATCH /website-tracking/settings, POST /website-tracking/settings/rotate-key (the website tracking snippet's consent mode, location precision, allowed hosts and retention; JWT permission MANAGE_SETTINGS. The rotate is bodyless and safe to repeat, each call issues a new key)

  • All of /subscription/* (checkout, portal, cancel, change-plan, preview-change, enterprise-inquiry, discounts, etc.)

  • All of /auth/cli/* except the two handshake routes below (GET /auth/cli/codes/:code, POST /auth/cli/codes/:code/approve, POST /auth/cli/codes/:code/deny: the browser half of warmbly auth login, where a signed-in member reviews the code a CLI is showing and authorizes it. Approving mints an ordinary API key, so it requires the MANAGE_API_KEYS organization permission and a fresh authentication, is session-only (an API key must not be able to mint another one this way), and caps the key to the scopes the approver's role allows. Looking up, approving or denying a code is limited to 30 a minute for each signed-in member, and is refused with a retryable 503 when that budget cannot be checked. GET /auth/cli/codes/:code?organization_id= adds granted_scopes and granted_scope_names: what approving into that workspace would grant)

  • All of /pool-link/* and /cloud-link/* (the self-hosted warmup pool link: approving an instance's code, listing and unlinking instances, GET /pool-link/offer, which always reports the retired Warmup plan unavailable, and POST /pool-link/checkout, which refuses with 409 warmup_plan_retired, and on a self-hosted instance the connect flow and mailbox enrollment). /cloud-link/* exists only on a self-hosted instance. Connections belong to the selected local workspace, but linking and unlinking them (/connect, /connect/poll, DELETE /cloud-link) and the linked cloud workspace's mailboxes (/workspace-mailboxes, .../adopt) take a platform admin holding manage_settings whose session presented a second factor (admin_mfa_required otherwise). Reading the link and putting a workspace's own mailboxes on it (GET /cloud-link, /mailboxes, enroll, pause, resume and the Google and Microsoft sign-in under /oauth/*) take the workspace's MANAGE_EMAILS. GET /cloud-link includes legacy_connected; mailbox rows include legacy when they still use the old connection. A normal DELETE /cloud-link only disconnects the selected workspace's scoped link. DELETE /cloud-link?legacy=true explicitly disconnects the legacy link across all workspaces and uses the same platform-admin permission. SenditCold Cloud links no new self-hosted instances: POST /pool-link/codes and POST /pool-link/codes/:code/approve refuse every request with 403 pool_link_closed, on any plan, so /connect and /connect/poll on an instance cannot complete. Existing connections keep working, and enrollments on them require workspace scope. /pool-link/codes/:code and its approve and deny routes share the CLI code's limit of 30 lookups a minute per signed-in member. POST /pool-link/codes and POST /pool-link/poll are public and per-IP rate limited: they are the device-code handshake an instance used before it had a token, and /pool-link/instance/* accepts only an instance token. /pool-link/instance/oauth/*, /pool-link/instance/mailboxes/:id/token, /pool-link/instance/workspace-mailboxes and /pool-link/instance/mailboxes/adopt are the cloud-managed mailbox surface (Google and Microsoft sign-in on SenditCold's OAuth apps, brokered access tokens); their instance-side counterparts are /cloud-link/oauth/* and /cloud-link/workspace-mailboxes/*. /pool-link/instance/redirects (list), and GET, PUT, DELETE and POST .../verify on /pool-link/instance/redirects/:domain, are the root redirects SenditCold Cloud serves for the calling instance; the instance drives them through PUT /emails/domains/:domain/redirect with served_by: "cloud", and GET /pool-link/instance carries redirects (available, host, limit, used)

  • All of /admin/*. DELETE /admin/instance/invitations/expired requires platform admin manage_organizations and an MFA-verified session. It removes only expired invitation records across all workspaces, leaves active invitations and membership unchanged, records an admin audit entry, and returns { "cleaned": true }. The admin panel confirms before calling it.

    • GET /admin/instance/health requires view_analytics and an MFA-verified admin session. Returns configuration checks, summary, additive execution_coverage (currently unreported) and operational_monitoring_path. Empty findings do not establish execution coverage or operational health.
    • Aggregate PostgreSQL monitoring sources, including sync and worker health samples, use one database reference for SQL thresholds, windows and source/metric observation clocks. That observation can be later than the snapshot's collection-start checked_at; cached snapshots keep their original clocks. Database observation is not provider recovery evidence.
    • GET /admin/instance/monitoring requires view_analytics and an MFA-verified admin session, not an organization API-key scope. Returns the read-only version "1" snapshot with checked_at, refresh_after, coverage and permission-shaped sources; HTTP responses use Cache-Control: no-store, while the backend shares a bounded one-minute collection cache. Each source has availability, coverage, nullable observation/scope measurements and metrics with nullable counts, affected mailboxes/organizations, evidence clocks, conditions and optional investigation paths. Missing permissions return unavailable source placeholders with permission_denied, not private metrics. Source bits and interpretation are listed under operational monitoring. Unavailable is not zero or an outage; a positive broker lag/pending observation remains unknown/info, not proof of failed throughput. This route never subscribes, replays, reconnects, clears holds or changes broker offsets/assignments.
    • GET /admin/analytics/overview requires view_analytics. total_emails_sent and emails_sent_today count confirmed warmup and other outbound sends, not dispatches. Today uses the UTC calendar day. A send-count database error fails the request instead of returning zero counters. See platform admin send counters.
    • GET /admin/organizations/:id/plan-offers (view_organizations) lists the private plans offered to a workspace as {"offers": [...]}, lapsed ones included, each with plan_id, reason, granted_by, granted_at, expires_at, plan and active. PUT /admin/organizations/:id/plan-offers/:planId (manage_organizations) offers or renews one with {"reason": "...", "expires_at": null}: the reason is required (at most 500 characters), an end date must be in the future, and a public plan or the Warmup plan is refused with 400. DELETE on the same path withdraws it and answers 204. Every offer change writes the admin audit log. These routes need an MFA-verified admin session like the rest of /admin/*, and repeating one is safe. See plans offered to a workspace.
    • GET /admin/organizations and GET /admin/organizations/:id require view_organizations. Their acquisition fields include the nullable referrer_host saved at signup, alongside UTM source, medium, campaign and landing path. The list accepts an exact referrer_host filter as well as its existing acquisition filters. An absent source is unknown, not proof of a direct visit. See signup source attribution.
    • GET /admin/warmup-content/overview includes effective generation controls and requires view_warmup_pool; PUT /admin/warmup-content/settings writes effective controls with manage_settings. These are platform-admin routes, not organization API-key scopes. See effective generation policy and stopping.
    • POST /admin/organizations/:id/sample-data: manage_testers and an MFA-verified admin session. Adds the reviewer sample dataset only to an active dedicated Test workspace. No request body. Returns 200 with organization_id, created (false when already seeded) and seeded_at; retries preserve edits and deletions. An ordinary workspace, revoked tester, or missing/expired managed Test grant returns 400; whether the owner's initial password is outstanding, replaced or expired does not matter. Inserts and the admin audit marker commit together. No mailbox connection, email delivery, external request or credit spend occurs.
    • GET /admin/testers: view_users and an MFA-verified admin session. Each tester in data may include test_workspace_id for its owned dedicated Test workspace (with several, the one that can still take sample data, else the oldest) and sample_data_seeded_at when populated. sample_data_eligible is true when that workspace passes the same check as POST /admin/organizations/:id/sample-data. These fields are absent for a tester merely attached to an ordinary workspace. Every tester carries created_as_tester, true for an account the tester flow created.
    • POST /admin/testers/:id/password: manage_testers, an MFA-verified admin session and a recent confirmation. No request body. Gives an account created as a tester a newly generated initial password, valid 48 hours and to be changed at first sign-in. It replaces whatever password the account held and signs out its sessions and remembered devices first. Returns 200 with user_id, password (shown only in this response), password_expires_at and password_change_required: true, and writes reissue_tester_password to the admin audit log. An account that does not exist or was not created as a tester returns 404 and nothing changes. Not idempotent by design: every call issues a different password, and only the last one works.
    • PUT /admin/users/:id/login-code-exempt: manage_testers, an MFA-verified admin session and a recent confirmation. Body {"exempt": true, "reason": "..."} excuses that account from the emailed login code; the reason is required (at most 500 characters) and the acting admin is recorded as the grantor. {"exempt": false} ends the exemption, clears the password of an account created as a tester and signs out every session the account holds. Both write the admin audit log, and setting the same state again is safe. An unknown account returns 404. GET /admin/users/:id and /preview carry login_code_exempt, login_code_exempt_reason and login_code_exempt_at
    • GET /admin/sends/holds: view_campaigns and an MFA-verified admin session. Lists mailboxes held from sending (up to limit, default 100, at most 500), switched-on first, with a summary of total, active, inactive and by_reason. Each row carries the held send's held_task_id, recipients, message_id and timestamps, and resolvable with a blocker when it is not. An unknown hold younger than ten minutes is a send in progress and is not listed.
    • POST /admin/sends/holds/:id/resolve (:id is the mailbox): stop_campaigns and an MFA-verified admin session. Body held_task_id, held_reason, outcome (sent or not_sent for an unknown hold, provider_confirmed for authentication, permanent or conflict), message_id (required for sent), optional provider_msg_id, and a one-line confirmation_reference. It applies the mailbox resolution in the mailbox's own organization and writes the admin audit log. An unknown verdict answers applied_by_consumer: true and lifts once the result consumer applies it. A repeat is refused rather than applied twice, so retrying is safe. Errors are send_hold_not_found, send_hold_changed and send_hold_not_resolvable.
    • GET /admin/mailboxes: view_users and an MFA-verified admin session. Optional mailbox_id filters by exact UUID; status=all includes inactive mailboxes. Node evidence links use /mailboxes?mailbox_id=UUID, regardless of loaded worker assignments or earlier browse filters. Deleted identities return an empty result, not a substitute mailbox.

POST /subscription/checkout, POST /subscription/portal, POST /subscription/cancel, POST /subscription/change-plan, POST /subscription/offers/:planId/checkout and GET /subscription/preview-change require the MANAGE_BILLING organization permission; reading the subscription, its limits, trial, features and offers needs only membership. POST /subscription/checkout and POST /subscription/change-plan sell public plans, and a private plan only to a workspace holding an open offer for it; anything else is 403 plan_offer_required. GET /subscription/offers returns {"offers": [...]}, the private plans the workspace has an open offer for, each with plan_id, name, currency, monthly_usd, yearly_usd, monthly_available, yearly_available, available (false while billing is off or the plan has no Stripe price for any period), warmup_only and expires_at. POST /subscription/offers/:planId/checkout takes {"interval": "month"} or "year" and resolves the Stripe price on the server: it answers {"checkout_url": "..."}, or {"changed": true} when the workspace already has an active Stripe subscription, which is switched to the plan in place. Without an open offer it is 403 plan_offer_required, and a period with no price is 503. Opening a checkout twice is harmless, so neither route takes an Idempotency-Key. POST /subscription/portal requires an existing Stripe billing customer. A workspace without one receives 400 with code bad_request and a message to complete checkout first; free and operator-granted plans alone do not create a billing customer.

POST /api/v1/internal/email-message-map/lookup is a node-token-only read endpoint for mailbox synchronization, not a customer API-key scope. The JSON body carries user_id, email_id and at most 200 message_ids (at most 1 MiB in total). A successful response is 200 with {"mappings": [...]}; missing IDs are omitted and an empty result is an empty array, not 404. Each returned mapping carries user_id, email_id, message_id, id and thread_id. Updated IMAP workers batch one envelope window at a time, without caching missing mappings across passes. Older workers keep using the single-message GET endpoint. Updated workers fall back to it only when the batch route returns 404 or 405, and recheck batch support after ten minutes, so an existing self-host keeps working through a rolling upgrade. Authentication failures, database errors and malformed successful responses remain failures, not evidence that mail is absent. No sync interval, arrival acknowledgment or send authority changes.

POST /cloud-link/mailboxes/:id/enroll takes an optional JSON body {"executor": "cloud"}. SenditCold Cloud warms only mailboxes whose credential it holds, so any other executor is refused with cloud_link_executor. An empty body keeps an enrolled mailbox as it is: one whose credential stayed on this server is refused with cloud_link_credential_required and stays out of the pool. executor cloud on such a mailbox sends its SMTP/IMAP credential to Cloud, which resumes its warmup; a Google or Microsoft one is refused with cloud_link_oauth_mailbox and is added again through SenditCold Cloud sign-in instead. Repeating a request is naturally safe. Mailbox rows carry executor, which is instance only on such a mailbox. On Cloud's instance-token-only POST /pool-link/instance/mailboxes, executor instance is refused with pool_link_executor, and an enrollment that carries a credential for such a mailbox hands it over; GET /pool-link/instance/standing reports it with paused credential_required. See mailboxes that kept their credential on your server.

DELETE /cloud-link/mailboxes/:id removes the selected workspace's enrollment. On Cloud, instance-token-only DELETE /pool-link/instance/mailboxes/:remoteId stops both diagnostic send and receive participation for a managed mailbox before releasing its link, retaining its Cloud mailbox, sign-in and history. A failed stop retains the link for retry; a failed unlink does not restart participation. Unmanaged enrollments delete their Cloud mailbox as before. Whole-instance DELETE /pool-link/instance and workspace-authorized instance unlinking preserve managed Cloud participation while releasing links. These removals are naturally retryable; a missing remote enrollment returns pool_link_mailbox_not_found, which the self-hosted client treats as already released.

PATCH /cloud-link/mailboxes/:id/participation is self-hosted JWT-only with MANAGE_EMAILS and organization ownership. It accepts typed mode (diagnostic or off), send, receive and optional positive shared/rolling limits. receive also authorizes probabilistic spam rescue, reads, importance and stars or flags on verified warmup messages where supported; it does not authorize replies or guarantee reputation improvement. Cloud mirrors it through instance-token-only PATCH /pool-link/instance/mailboxes/:remoteId/participation, scoped to the calling instance. These dedicated routes prevent an old Cloud silently ignoring additive consent fields; old state without participation is not explicit diagnostic consent. The existing native lifecycle routes select diagnostic send/receive on start/resume and complete off on stop while retaining history. See participation and send safety.

For locally warmed mailboxes, PATCH /emails/:id accepts warmup_days as a Monday-first bitmask (Monday = 1, Sunday = 64). A nonzero mask selects the exact warmup days: 127 includes every day and 96 selects weekends, independently of the sending plan's campaign working days. 0 follows the enabled sending plan's working days, or every day with Fixed schedule. Sending-plan hours and breaks still apply within the warmup window. Saving replaces the queued warmup time. See warmup schedules.

For a mailbox SenditCold Cloud warms, PATCH /emails/:id sends warmup_base, warmup_max, warmup_increase, warmup_reply_rate, warmup_start_time, warmup_end_time, warmup_days and warmup (as pause or resume) to SenditCold Cloud through instance-token-only PATCH /pool-link/instance/mailboxes/:remoteId, then stores the values Cloud reports. The rest of the patch is written locally only once Cloud has applied its part, and the timezone travels with it. test_mode, test_send_enabled, test_receive_enabled, shared_daily_limit and rolling_recipient_limit return 409 cloud_warmup_managed; use the participation route above. POST /emails/:id/warmup/start and /resume resume the mailbox on Cloud, /pause pauses it there, and /stop returns 409 cloud_warmup_managed. GET /cloud-link/mailboxes rows carry settings_observed_at, when the local copy last matched Cloud. See warmup settings and the refusals.

Cloud-managed OAuth additionally exposes POST /pool-link/instance/oauth/start-correlated, POST /pool-link/instance/oauth/finish-correlated and POST /pool-link/instance/mailboxes/adopt-correlated, all instance-token only and requiring protocol: 1. The authenticated instance response advertises managed_consent_protocol: 1. Legacy routes remain unchanged; absence of the capability cannot be bypassed with fields an old replica might ignore. See the Cloud route and recovery contract.

GET /pool-link/instance/workspace-mailboxes includes active delegated Google Workspace mailboxes alongside per-mailbox Google and Microsoft OAuth mailboxes, excluding mailboxes already enrolled to an instance. The adoption routes associate the selected Cloud mailbox ID without replacing its grant, history or settings. GET /pool-link/instance/mailboxes/:id/token mints delegated Google tokens through Cloud's domain grant, with the same instance, workspace, mailbox activity and pool-standing checks as OAuth token brokerage. An unavailable mailbox-status check returns a server error without issuing a token. Microsoft whole-organization grants are not adoptable. On the self-hosted side, POST /cloud-link/workspace-mailboxes/:id/adopt also reuses an existing active SMTP/IMAP mailbox with the same address in the selected workspace, switching it to brokered access and removing local credentials only after Cloud confirms. Local mailbox IDs, campaigns and imported messages remain. An unconfirmed enrollment on that connection can be replaced; an active enrollment, pending removal, vendor-managed mailbox or another connection cannot. This OAuth and delegation path uses the existing managed-consent protocol; the additional SMTP association marker is described below.

New clients request GET /pool-link/instance/workspace-mailboxes?include_smtp_imap=true to include existing SMTP/IMAP Cloud mailboxes. The default response keeps its earlier provider types for old clients. The self-hosted picker shows these only alongside an active local SMTP/IMAP copy in the selected workspace. Its same admin- and MFA-gated adoption route then calls instance-token-only POST /pool-link/instance/mailboxes/associate-smtp with remote_id, the explicitly selected Cloud email_account_id, and email. The instance response must advertise smtp_association_protocol: 1. This route associates records without transferring or comparing credentials, changing Cloud settings, or resuming paused warmup. Both copies keep their independent credentials. It refuses another workspace, a changed address, an inactive mailbox, an active Cloud campaign or an existing association elsewhere. Replay returns the same association and cannot revive a revoked remote ID. Cloud retains this independently owned mailbox and its history on unlink; the local app-password mailbox is also retained. Individual unlink stops Cloud participation, while whole-instance unlink preserves it. Migration 000322 adds the nullable selected Cloud ID to local enrollment rows for durable retries and independent local credential updates. This marker and links do not travel in workspace archives.

POST /internal/worker/warmup-dispatch is an execution-plane internal-token route, not a public JWT/API-key scope. It inspects, begins and durably finishes a task/mailbox/worker/nonce-bound command. Start repeats authoritative policy checks; unknown or denied authority cannot permit native execution. Receipt/header/model text supplies no authorization.

POST /internal/email-message-map/arrival uses the existing internal node token. Only 204 with X-Warmbly-Arrival-Durable: 1 acknowledges durable admission. Authoritative absence of the exact mailbox/user ownership returns 503 with X-Warmbly-Arrival-Ownership-Lost containing the requested mailbox UUID and no durable acknowledgment. Updated workers retire only that mailbox generation; the failed arrival remains unacknowledged, its stored body is retained and its provider cursor is not advanced. Ordinary 503 responses, transport failures, database failures and encryption failures remain retryable. Older workers ignore the new marker and continue holding the arrival; an upgraded backend and worker are both required for retirement. This does not clear send holds, resend messages or alter Kafka offsets.

POST /internal/worker/outbound-dispatch, /internal/worker/warmup-actions, and /internal/worker/diagnostic-auth use the existing execution-plane internal token, never customer API keys. They bind the current mailbox/worker and require protocol 2. Outbound start additionally rechecks task/organization/provider/nonce/recipients and current campaign or diagnostic policy. Diagnostic authorization/result submission binds one exact already-owned warmup receipt and short-lived nonce; it neither ingests unrelated inbox messages nor trusts event/header claims. No public scope or permission was added. Details and limits: send safety.

Warmup actions for a deleted, unassigned or no-longer-authorized mailbox return no permitted actions. The worker treats that authoritative denial as a no-op without touching the provider. Database or authority failures still return 503 and retry; denial does not authorize a send, clear a send hold, or acknowledge an arrival.

POST /internal/worker/warmup-actions optionally accepts filing_id for a filing-only action. filing_pending: true is returned only for permitted, pure filing work still durably queued for that exact mailbox and its current worker. On a failed filing, this proof lets an updated worker finish the command without a WarmupFiled completion event; the durable recovery queue remains responsible for retrying it. Missing proof or an older backend retains the existing command retries. Deploy the backend before workers to enable this behavior. Successful filing still requires a completion event, and publication failures remain retryable. No send, arrival or retention-delete authority is added.

Workspace branding

A workspace's white-label brand. Session only: an API key or OAuth token is refused.

MethodPathWho
GET/organization/brandAny member: brand (stored), inherited (the agency's, when set), effective (what public pages show, with source, agency_id, portal_host and full_white_label), portal_host, portal_source (agency or shared), portal_base_url and the instance's powered_by
PUT/organization/brandmanage_settings: display_name, accent_color (#rrggbb), website_url (https://) and hide_powered_by; replaces all four
DELETE/organization/brandmanage_settings: removes the brand so the workspace inherits its agency's again
POST/organization/brand/logomanage_settings: multipart file, PNG or JPG up to 2 MB and 1024px on each side
DELETE/organization/brand/logomanage_settings

Each write answers with the same body as the GET. PUT and the DELETEs set a state, so a retry lands on the same one; a retried logo upload replaces the logo it just stored. A name with a link answers 400 invalid_name, a bad colour 400 invalid_accent_color, a website that is not https:// 400 invalid_website_url, and turning hide_powered_by on without Agency Pro, Agency Scale or Enterprise 403 white_label_not_entitled (a value already stored saves unchanged). Every write is audited as workspace_brand.

Agencies

The /agency routes run an agency: its staff, its client workspaces, its shared send pool and its billing. An agency is not one workspace, so these routes check that the caller is on the agency's staff, not a workspace permission. Members of the staff can read; the owner and admins can change. An agency the caller is not on answers 404 agency_not_found.

Besides a signed-in session, the routes in the first rows of the table below accept an agency API key for its own agency, with the agency bit named there, acting as the owner or admin who created it. A workspace API key or OAuth token answers 403 agency_credential_required, a key on a session-only route 403 agency_key_not_allowed, and a key on another agency 404 agency_not_found.

Agency key permissionRoutes
AGENCY_READGET /agency (its own agency only), GET /agency/plans, GET /agency/:agencyId, GET /agency/:agencyId/members, GET /agency/:agencyId/clients, GET /agency/:agencyId/overview, GET /agency/:agencyId/mailboxes, GET /agency/:agencyId/brand
AGENCY_READ and READ_UNIBOXGET /agency/:agencyId/inbox, /inbox/thread, /inbox/messages/:messageId
AGENCY_MANAGE_CLIENTSPOST /agency/:agencyId/clients, POST /agency/:agencyId/clients/attach, PATCH and DELETE /agency/:agencyId/clients/:orgId
AGENCY_READ_USAGEGET /agency/:agencyId/usage, GET /agency/:agencyId/ai-balance
AGENCY_TRANSFER_AIPOST /agency/:agencyId/clients/:orgId/ai-balance
Session onlyPOST /agency, PATCH /agency/:agencyId, staff and invitation writes, every /billing/* route (checkout, top-ups and the portal open a browser page), brand, logo, icon, sign-in image and portal domain writes, and the /api-keys routes

A key passes the recent-confirmation check the session routes ask for, since it presents its credential on every call; moving AI balance still takes an Idempotency-Key and moves the money once per key.

MethodPathWho
GET/agencyAny signed-in user: their agencies, with role, client_count and billing_enabled. Also claims invitations sent to the account's address
GET/agency/plansAny signed-in user: the agency plans, their monthly_send_pool and agency_ai_allowance_usd
POST/agencyAny signed-in user; becomes the owner
GET/agency/:agencyIdStaff
PATCH/agency/:agencyIdOwner, admin (rename)
GET/agency/:agencyId/membersStaff: data (staff) and invitations
POST/agency/:agencyId/membersOwner (admin or member), admin (member): adds an account by email, or saves an invitation when no account uses it
PATCH/DELETE/agency/:agencyId/members/:userIdOwner for admins, owner or admin for members; anyone may remove themselves
DELETE/agency/:agencyId/invitations/:invitationIdOwner, admin
GET/agency/:agencyId/clientsStaff: each client with monthly_send_limit and monthly_ai_limit_usd
POST/agency/:agencyId/clientsOwner, admin: a new workspace from name and optional timezone
POST/agency/:agencyId/clients/attachOwner, admin who owns the workspace in organization_id
PATCH/agency/:agencyId/clients/:orgIdOwner, admin: monthly_send_limit (sends) and monthly_ai_limit_usd (dollars of the shared AI balance per period), each a number or null for no cap. Only the fields sent change; a body with neither is refused
POST/agency/:agencyId/clients/:orgId/ai-balanceOwner, admin: moves amount_usd ($1 to $5,000) from the agency's AI balance into the workspace's own. Answers amount_usd, agency_balance_usd and workspace_balance_usd; 402 agency_ai_balance_insufficient when the agency balance holds less
DELETE/agency/:agencyId/clients/:orgIdOwner, admin: detaches the workspace and keeps its data
GET/agency/:agencyId/usageStaff: pool size, top-ups, used, remaining, per-client use and caps, days left and a projection
GET/agency/:agencyId/ai-balanceStaff: the shared AI balance this period: allowance_usd and allowance_left_usd, purchased_usd, bought_this_period_usd, spent_usd, moved_usd, balance_usd, period_start, period_end, per-client clients (spent_usd, moved_usd, monthly_ai_limit_usd) and the top_up range. unmetered: true where the instance does not charge for AI
GET/agency/:agencyId/overviewStaff: the agency home. usage (as /usage), totals, attention (worst first, each with kind, severity, organization_id, count and rate) and clients. Each client carries access (full, restricted or none), pool_used and monthly_send_limit, and only the sections the caller's own membership in it allows: campaigns and mailboxes (view_campaigns), inbox (access_unibox), advisor (view_analytics). Cached for up to 20 seconds
GET/agency/:agencyId/inboxStaff: conversations across every client whose inbox the caller reads (access_unibox; a restricted member, only their granted mailboxes), newest first, as data plus pagination. Filters: client (comma-separated workspace ids), unread, needs_reply, positive (each true), q (search), cursor, limit (1 to 100, default 30)
GET/agency/:agencyId/inbox/threadStaff with inbox access in that client: organization_id and thread_id, the conversation's messages as GET /unibox/thread returns them, read-only
GET/agency/:agencyId/inbox/messages/:messageIdStaff with inbox access in that client: organization_id, one message with its body as GET /unibox/:id returns it, left unread
POST/agency/:agencyId/billing/checkoutOwner, admin: plan_id and interval (month or year)
GET/agency/:agencyId/billing/change/previewOwner, admin: plan_id and interval (month or year) in the query. What switching the agency's subscription to that plan and period would bill: proration_cents (negative is a credit), amount_due_cents on the next invoice, currency, the proration_date the amounts were calculated at and the from_price_id the subscription was on. Changes nothing
POST/agency/:agencyId/billing/changeOwner, admin: plan_id and interval, and optionally the preview's proration_date and from_price_id so the switch bills exactly what the preview showed (valid for 30 minutes, within the same billing period and from the same price). Moves the agency's subscription to another agency plan or billing period, prorated; every client workspace has the new plan at once. Answers the agency. A switch Stripe already holds but the agency does not yet show is completed, not refused
GET/agency/:agencyId/billing/auto-topupStaff: the automatic top-up, enabled, threshold_sends, max_packs, bought_this_period, pack_sends, pack_price_cents, the saved card (brand, last4) when there is one, card_missing when it was looked up and there is none, available, and failure (no_payment_method or card_declined) when it turned itself off
PUT/agency/:agencyId/billing/auto-topupOwner, admin: enabled, threshold_sends (0 to 10,000,000) and max_packs (1 to 20). When on, a pack is bought on the saved card once that many sends or fewer are left, up to that many packs a period. Answers the settings; a pool already under the threshold is topped up at once
GET/agency/:agencyId/billing/ai-auto-topupStaff: the shared AI balance's automatic top-up, enabled, threshold_usd, amount_usd, limit_usd, added_this_period_usd, the saved card when there is one, card_missing when there is none, available, min_usd, max_usd, and failure when it turned itself off
PUT/agency/:agencyId/billing/ai-auto-topupOwner, admin: enabled, threshold_usd ($0 to $5,000), amount_usd ($5 to $5,000) and limit_usd (at least amount_usd, at most $50,000). When on, amount_usd is added on the saved card once less than threshold_usd is left, never more than limit_usd in a period. Answers the settings; a balance already under the threshold is topped up at once
POST/agency/:agencyId/billing/topupOwner, admin: one 50,000-send pack
POST/agency/:agencyId/billing/ai-topupOwner, admin: amount_usd ($5 to $5,000) added to the agency's AI balance through checkout. Answers checkout_url
POST/agency/:agencyId/billing/portalOwner, admin
GET/agency/:agencyId/brandStaff: the agency's brand, full_white_label, can_manage, portal_domain (shaped like the forms domain status), portal_base_url, portal_sign_in, and the portal theme: icon_url and sign_in_image_url (empty strings when none is uploaded) and theme (always an object, {} when nothing is set)
PUT/agency/:agencyId/brandOwner, admin: the same body as PUT /organization/brand, plus an optional portal_sign_in (portal or warmbly, see where clients sign in) and an optional theme object (see portal theme). Leaving either out keeps what is stored; a theme replaces the whole stored theme. Any other portal_sign_in answers 400 invalid_portal_sign_in. agency_forbidden for other staff
POST/DELETE/agency/:agencyId/brand/logoOwner, admin: multipart file, as for a workspace logo
POST/DELETE/agency/:agencyId/brand/iconOwner, admin: the square browser tab icon. Multipart file, PNG up to 1 MB and 512px on each side
POST/DELETE/agency/:agencyId/brand/sign-in-imageOwner, admin: the image on the portal's sign-in page. Multipart file, PNG or JPG up to 4 MB and 2560px on each side
GET/agency/:agencyId/mailboxesStaff: mailboxes across the clients whose mailbox list the caller reads, newest first, as data plus pagination. Each carries organization_id, client, email, name, provider, status, erroring, warmup, warmup_state (watch, throttled, quarantined or blocked when held), vendor, campaign_limit and created_at. Filters: client (comma-separated workspace ids; one that is not a client answers 404 agency_client_not_found), attention=true (disconnected, erroring or held), q (part of an address), limit (1 to 200, default 50) and cursor
GET/agency/:agencyId/vendorsStaff, session only: the inbox vendor accounts the agency holds as data, each with vendor, label, status, mailboxes and the clients it is used in, never a key; and catalog, the supported vendors and what each asks for
POST/agency/:agencyId/vendorsOwner, admin, session only: vendor, fields (its API key) and an optional label. The key is verified with the vendor before it is stored. Accepts Idempotency-Key: send the same key again on a retry, or an account is connected twice
PATCH/agency/:agencyId/vendors/:idOwner, admin, session only: label, and fields to replace the key, which every client that uses the account reads from then on
DELETE/agency/:agencyId/vendors/:idOwner, admin, session only: forgets the key in the agency and in every client. Mailboxes it connected stay
PUT/agency/:agencyId/vendors/:id/clients/:orgIdStaff who manage mailboxes in that client, session only: the client workspace's connection to the account, made the first time, as a vendor connection. Its id in the answer, not the account's, is the :id to import from under /emails/vendors/:id. It holds no key of its own. 404 agency_client_not_found for a workspace that is not a client, 403 agency_client_mailboxes_forbidden without Manage mailboxes there
PUT/agency/:agencyId/brand/domainOwner, admin: domain, the portal host every client's links are built on (empty clears it); resolves it as part of saving. A malformed host answers 400 invalid_portal_domain, one another workspace or agency holds 409 portal_domain_taken
POST/agency/:agencyId/brand/domain/verifyOwner, admin: re-resolves the portal host
GET/agency/:agencyId/api-keysOwner, admin: the agency's API keys, newest first, as data plus pagination (cursor, limit 1 to 100)
GET/agency/:agencyId/api-keys/permissionsSigned in: the bits an agency key may hold (workspace, agency), presets and grantable
POST/agency/:agencyId/api-keysOwner, admin: name, permissions, optional allowed_ips, rate_limit_per_minute and expires_at. The answer carries secret, shown once. 400 invalid_agency_key_permissions for a bit an agency key cannot hold
PATCH/agency/:agencyId/api-keys/:keyIdOwner, admin: name and allowed_ips of an active key; 404 agency_api_key_not_found otherwise
DELETE/agency/:agencyId/api-keys/:keyIdOwner, admin: revokes the key. 204, also for a key already revoked

POST /agency, the client creates, adding staff, every checkout, creating an API key and moving AI balance honor Idempotency-Key; a move retried with the same key moves the money once. The other writes set a value, so a retry lands on the same state. Moving AI balance, like the billing routes and creating, editing or revoking an agency API key, asks a session for a recent confirmation. On an instance without billing (BILLING_PROVIDER=none) the billing routes and moving AI balance answer 503 agency_billing_disabled, the pool has no cap and AI is not charged. GET /organization/current carries agency (id, name, can_leave) on a client workspace, GET /organization carries the same agency on each membership row whose workspace is a client, and that workspace's POST /subscription/checkout answers 409 agency_billed.

Portal theme

The agency's portal theme is the theme object on its brand, plus two uploaded images. It is available on every agency plan. Every theme field is optional, and an empty string or a missing field means the default:

FieldValue
fontsystem, geometric or serif
cornerssharp or round
appearancelight or dark: the appearance a client sees before choosing one
sign_in_layoutsplit or cover
sign_in_backgroundaccent, dark or image (the uploaded sign-in image)
sign_in_headingPlain text, at most 60 characters
sign_in_messagePlain text, at most 200 characters
support_emailAn email address
support_url, terms_url, privacy_urlAbsolute https:// addresses, under the same rule as website_url
{
  "display_name": "Northwind Growth",
  "accent_color": "#0f766e",
  "website_url": "https://northwindgrowth.example",
  "hide_powered_by": false,
  "theme": {
    "font": "geometric",
    "corners": "round",
    "sign_in_layout": "split",
    "sign_in_background": "image",
    "sign_in_heading": "Welcome back",
    "support_email": "[email protected]",
    "terms_url": "https://northwindgrowth.example/terms"
  }
}

PUT /agency/:agencyId/brand without theme keeps the stored theme. With theme, the object replaces the stored one as a whole, so a field it leaves out is cleared and "theme": {} clears everything. PUT /organization/brand ignores theme: a workspace's own brand has none.

A value outside a field's list answers 400 invalid_theme. A heading or message that is too long, or that contains a link, a web or email address, markup or control characters, answers 400 invalid_brand_text; they follow the name rules with their own length bounds. A support, terms or privacy address that is not https://, or a support_email that is not an email address, answers 400 invalid_brand_link. Each message names the field. A refused PUT stores nothing.

icon_url and sign_in_image_url are set only by their upload routes and are never read from a PUT body. Each upload is stored under a new address, replaces the image before it and answers with the same body as GET /agency/:agencyId/brand; a DELETE clears the image and answers the same way, also when there was none. The upload refusals are the image codes a logo answers, worded for the image in question.

Clients read the theme from the public brand object: effective.brand on GET /organization/brand, the brand on connect and report pages, and brand on GET /auth/portal. It carries icon_url, sign_in_image_url and theme only when the brand shown is the agency's, each omitted when unset. GET /auth/portal always carries the agency's three, including for an agency that set no name, logo, colour or website.

POST /organization/agency/leave is the client workspace's side of detaching: its owner takes the current workspace out of its agency, with the same effect as DELETE /agency/:agencyId/clients/:orgId. It answers 204, including for a workspace in no agency, so a retry is safe. Anyone but the workspace's owner gets 403 agency_leave_not_owner. When the workspace's owner also owns the agency (can_leave is false), it answers 409 agency_leave_agency_owner: that owner detaches the workspace from the agency instead.

Discounts

Billing discount history is part of /subscription/*, so they are JWT only and never accept an API key. There is no API permission scope for them; browser callers are gated by the org permission below. The discount validation and checkout endpoints are listed under Subscription and billing in the reference.

MethodPathJWT permission
GET/subscription/discountsmanage_billing

Balance and usage

The workspace balance, adding money, automatic reload, spend controls, the activity log and invoices are part of /subscription/*, so they are JWT only and never accept an API key. Every call that charges a card or changes what is charged needs a recent confirmation. See the Balance and usage guide for how usage is priced, the monthly allowance and automatic reload. Amounts are US dollars in *_usd fields (up to six decimals); a card is only ever charged in whole cents.

MethodPathJWT permission
GET/subscription/balancemanage_billing
POST/subscription/balance/top-upmanage_billing
POST/subscription/balance/checkoutmanage_billing
POST/subscription/balance/payment-methodmanage_billing
PATCH/subscription/balance/auto-reloadmanage_billing
GET/subscription/balance/invoicesmanage_billing
GET/subscription/creditsmanage_billing
GET/subscription/credits/transactionsmanage_billing
GET/subscription/credits/usagemanage_billing
GET/subscription/credits/settingsmanage_billing
PATCH/subscription/credits/settingsmanage_billing
  • GET /subscription/balance is everything the Balance tab shows: balance_usd, monthly_usd, purchased_usd, monthly_allowance_usd, monthly_used_usd, next_reset_at, the saved card (payment_method), auto_reload, low_balance_usd, charge_in_progress, and the top_up bounds and presets. On an agency client it adds agency_usd, what the agency's shared AI balance would pay once the workspace's own runs out (absent when that is nothing). On a deployment with BILLING_PROVIDER=none it answers only {"unlimited": true}.
  • POST /subscription/balance/top-up {"amount_usd": 25} charges the saved card now (from $5 to $5,000) and is billed as a Stripe invoice. It answers 200 with status paid, or 202 with status processing and charge_in_progress true while the payment is still going through and SenditCold finishes it in the background. It honors Idempotency-Key; without one, a retry while the first payment is pending answers 409 charge_in_progress, so it never charges twice. A decline answers 402 with card_declined, authentication_required or payment_failed, which tells a client to offer Checkout instead; no saved card is 409 payment_method_required, and another payment still going through is 409 charge_in_progress. Needs an active paid plan.
  • POST /subscription/balance/checkout {"amount_usd": 25} returns a Stripe Checkout checkout_url for the amount; the payment saves the card for next time. POST /subscription/balance/payment-method returns a Checkout checkout_url that saves or replaces the card without charging it. Money is added only in the Stripe webhook, never in the call, and exactly once.
  • PATCH /subscription/balance/auto-reload takes enabled, threshold_usd ($0.01 to $1,000), amount_usd ($5 to $5,000, more than the threshold) and monthly_limit_usd (at least the amount, at most $50,000), and answers with the same body as GET /subscription/balance. An amount not above the threshold is 422 recharge_too_small, a monthly limit out of range is 422 monthly_limit_too_small, and turning it on with no saved card is 409 payment_method_required.
  • GET /subscription/balance/invoices returns data: the latest Stripe invoices for the workspace, subscription and balance alike, each with kind (subscription, top_up or auto_reload), status, amount_cents, currency, hosted_url and pdf_url.
  • GET /subscription/credits, /credits/transactions, /credits/usage and /credits/settings keep their legacy credit fields for older clients and carry the dollar figures next to them: balance_usd, monthly_usd, purchased_usd, monthly_allowance_usd and total_purchased_usd on the balance; amount_usd, cost_usd (the provider price before the markup), input_tokens, output_tokens, quantity, balance_after_usd and agency_delta_usd (the part of the amount the agency's shared AI balance paid, or got back) on each transaction; spent_today_usd, spent_week_usd, spent_month_usd, the limit_*_usd caps and amount_usd on each series and by_reason or by_model entry in usage; and the spend_limit_*_usd, member_limit_*_usd and low_balance_usd settings, which PATCH /subscription/credits/settings also accepts. GET /subscription/credits carries unlimited (boolean): true on a deployment with BILLING_PROVIDER=none, where the balance is bypassed and every amount is zero and meaningless.

POST /subscription/credits/checkout, which bought a fixed credit pack, is removed; add money with POST /subscription/balance/top-up or /balance/checkout instead.

AI assistant

Remie, the dashboard AI assistant, is JWT only: sessions are private to the member who started them. The message and approval runs stream over Server-Sent Events. Each tool Remie runs is gated by the member's own organization permission bits, so Remie can never do more than the member could by hand. See the Remie guide. (API-key and OAuth callers reach the same tools through the MCP server or the REST agent-tools surface, each tool gated by its own scope.)

MethodPathJWT permission
POST/ai/sessionsorganization member (use AI)
GET/ai/sessionsorganization member (use AI)
DELETE/ai/sessionsorganization member (use AI)
DELETE/ai/sessions/:idorganization member (use AI)
GET/ai/sessions/:id/messagesorganization member (use AI)
POST/ai/sessions/:id/messagesorganization member (use AI, SSE)
POST/ai/sessions/:id/approveorganization member (use AI, SSE)
GET/ai/tool-policiesmanage_settings
DELETE/ai/tool-policies/:toolmanage_settings

POST /ai/sessions/:id/approve takes {"decision": "approve" | "deny" | "always_allow", "tool_call_id": "..."}. tool_call_id is required and must be the call the session is waiting on (the tool_call_id of its approval_required event, or pending.tool_call_id from GET /ai/sessions/:id/messages); any other answers 409 approval_not_pending, and so does a second decision on the same call. always_allow saves a workspace policy only for a member with manage_settings and only for a tool that may be always allowed; otherwise it approves this one call. The approval_required event and pending carry arguments (every argument as indented JSON with sorted keys, capped at 16 KiB, with arguments_truncated), preview for a send (from, to, subject, body, body_html), and always_allow_offered.

GET /ai/tool-policies lists the tools the assistant runs without asking (tool_name, created_by, created_by_name, created_at); DELETE /ai/tool-policies/:tool revokes one. Both audit as ai_tool_policy.

AI skills

Org playbooks the AI features follow (see the AI skills guide). Gated on manage_settings for JWT callers, or the AI_AGENT scope for API keys. Creating, editing or deleting one with an API key also needs the member who created the key to hold manage_settings.

MethodPathPermission
GET/ai/skillsmanage_settings / AI_AGENT
POST/ai/skillsmanage_settings / AI_AGENT
PATCH/ai/skills/:idmanage_settings / AI_AGENT
DELETE/ai/skills/:idmanage_settings / AI_AGENT

Agent tools (REST)

The AI tool registry over plain HTTP for function-calling agents that do not speak MCP (see Agent tools). Like the MCP endpoint, it needs AI_AGENT (or use_ai for JWT callers); each tool then enforces its own permission, the list reflects only what the caller may use, and send-class tools are never exposed.

MethodPathPermission
GET/ai/toolsuse_ai / AI_AGENT, then the permission of each listed tool
POST/ai/tools/:name/calluse_ai / AI_AGENT, then the permission of the tool being called

Connected MCP servers

External MCP servers whose tools the assistant can use (see Connect MCP tools). JWT-only and manage_settings-gated; bearer tokens are sealed with the org key and never returned.

MethodPathJWT permission
GET/ai/connectionsmanage_settings
POST/ai/connectionsmanage_settings
PATCH/ai/connections/:idmanage_settings
DELETE/ai/connections/:idmanage_settings
POST/ai/connections/:id/refreshmanage_settings

PATCH /ai/connections/:id takes name, enabled, token and search_tool: the name of one discovered tool that becomes the workspace's web search for the lead finder and the assistant's search_web, or "" to clear it. A tool that is not on the server, or takes no search query, answers 400. Each field sets a value, so a retry lands on the same state.

Slack

The Slack panel's routes. They are session-only: linking binds a Slack account to the signed-in person, and an API key has no person to bind. Settings changes and link removals publish AUDIT_CREATED with entity_type integration.

MethodPathJWT permission
GET/integrations/slack/statusorganization member. app_configured, interactive_configured and my_link (always the caller's own) for everyone; connection, settings and missing_scopes only for manage_settings or use_integrations (otherwise absent, {} and []); links (every member's link) only for manage_settings
GET/integrations/slack/channelsmanage_settings or use_integrations. Public channels and the private channels the bot is in, filtered by q, at most 200, as data plus pagination
PUT/integrations/slack/settingsmanage_settings. Default channel, per-category routes, assistant_disabled, assistant_dm_only, inbox_channel (a channel id from the list; empty turns the inbox channel off) and inbox_scope (replies, the default, or all). The inbox channel must be one the bot can see and not a Slack Connect channel, else 400. Idempotent: the whole settings object is replaced
GET/integrations/slack/link/:codesigned in, any workspace. Previews the link a bot button carries: organization_id, organization_name, is_member, slack_team_id, slack_team_name, slack_user_id, slack_user_name and slack_user_avatar (as Slack reports them now; either may be empty), user_email (the caller's SenditCold email), email_matches (the Slack account's email is the caller's SenditCold email), verify_available (Sign in with Slack can confirm the link) and expires_at. An unknown or expired code is 404 slack_link_invalid
POST/integrations/slack/link/verifysigned in, member of the code's workspace (else 403 forbidden). code from the bot's button; answers 200 with url, a Sign in with Slack page for the code's Slack workspace. Slack returns to the integrations OAuth callback, which hands code and state to the opener. 503 slack_verify_unavailable when the Slack app has no client credentials
POST/integrations/slack/linksigned in, member of the code's workspace (else 403 forbidden). code from the bot's button, plus slack_code and state from Sign in with Slack when the caller's SenditCold email is not the email on the code's Slack account (without them that is 403 slack_link_email_mismatch, and the code stays usable). A Sign in with Slack result must be this caller's, for this code, and for the code's Slack account (403 slack_verify_failed or slack_verify_wrong_account). Answers 201 with the link and replaces any earlier link of that Slack account. The code is single-use, so a repeat is refused with 404 slack_link_invalid
PATCH/integrations/slack/linkorganization member, own link. dm_notifications. 404 slack_not_linked when the caller has no link in this workspace
DELETE/integrations/slack/linkorganization member, own link. Answers 204, also when there was no link, so a repeat is safe
DELETE/integrations/slack/links/:idmanage_settings. Removes any member's link. Answers 204

The two /integrations/slack/link routes that take a code do not need a workspace selected, because the code names one. GET /integrations/slack/status reports app_configured (the instance has a Slack app) and interactive_configured (it also has the signing secret, so the assistant and buttons work), so a client can tell what is available before calling anything else. The settings and channel routes answer 404 slack_not_connected until the workspace connects Slack. Error codes are under 404 and 503.

MCP server

POST /v1/mcp exposes the tool registry over the Model Context Protocol streamable-HTTP transport. It accepts an API key or an OAuth 2.1 access token holding AI_AGENT (an unauthenticated request gets the RFC 9728 discovery challenge). Each tool is gated by its scope, tools/list reflects only what the credential allows, and send-class tools are never exposed. Per-key rate limits apply.

Public

The dashboard's display-only POST /v1/dashboard-image endpoint accepts a session JWT and {"url":"https://..."} and returns image bytes. It is not API-key-capable. It reads owned public blobs through the active store, or fetches a public HTTPS image without forwarding browser credentials, cookies or referrer. The read rate limit applies; the body is limited to 16 KiB, the URL to 8,192 characters, the image to 5 MiB, and the request to ten seconds. Non-images, non-public destinations, unsafe redirects and unsupported ports are refused. Nothing is stored or changed, so retries need no idempotency key. Saved and sent emails retain the original URLs.

POST /v1/dashboard-image/public has the same body and image limits but accepts only this instance's owned public objects and the Slack sign-in avatar providers (avatars.slack-edge.com and secure.gravatar.com/avatar/). It is per-IP rate limited and cannot fetch arbitrary customer URLs anonymously.

  • GET /health
  • POST /auth/cli/code, POST /auth/cli/poll (the CLI device-code handshake, per-IP rate limited. Public by necessity: the CLI has no credential until the flow completes. POST /auth/cli/code returns device_code, user_code, verification_uri, verification_uri_complete, expires_in and interval; polling returns {"status":"pending"} until a member decides, then {"status":"approved"} carrying the minted key exactly once, or {"status":"denied"}. An unknown or expired device_code is a 404, so a poller cannot probe for live handshakes)
  • POST /webhook/stripe (Stripe signature)
  • POST /api/v1/integrations/hubspot/webhooks (HubSpot app webhooks, verified with the X-HubSpot-Signature-v3 signature over the app's client secret and refused when older than five minutes. A delivery only makes the next read of the named record come sooner; see HubSpot)
  • POST /api/v1/integrations/pipedrive/webhooks/:connectionId (Pipedrive webhooks SenditCold registers for one connection when a workspace switches to Pipedrive. Authenticated by HTTP Basic auth with a password derived from the connection and the app's client secret. A delivery only makes the next read of the named record come sooner; a connection no workspace runs its CRM on answers 410. See Pipedrive)
  • GET and POST /api/v1/integrations/pipedrive/app/panel, /app/enroll and /app/pause (the SenditCold JSON panel on Pipedrive person and deal pages and its two JSON modals). Pipedrive signs each call with a JWT over the app's client secret, passed as the token query value and pinned to HS256; the user and company it names must match the userId and companyId query values, and a repeated value is refused. The company selects the one workspace in Pipedrive mode with a live connection to it, and each call acts as the member matched to that Pipedrive user, with its permissions. See working from inside Pipedrive
  • POST /api/v1/integrations/hubspot/app/card, /app/enroll, /app/pause (the SenditCold card on HubSpot contact records) and POST /api/v1/integrations/hubspot/actions/enroll, /actions/campaigns (the "Add to SenditCold campaign" workflow action). HubSpot signs each request with the app's client secret (X-HubSpot-Signature-v3, over the public backend URL). The card routes take the portal and HubSpot user from the query values HubSpot appends, exactly one of each, and act as that user's matched member with its permissions; the webhook and workflow action routes refuse a request carrying those values. The portal selects the one workspace in HubSpot mode with a live connection to it. See working from inside HubSpot
  • POST /webhook/campaign, /webhook/email, /webhook/user-email (Google OIDC token from Cloud Tasks)
  • GET /addresses/google/callback, GET /addresses/outlook/callback (OAuth bouncer pages)
  • GET /addresses/link/:token, POST /addresses/link/:token/continue (the page a Partnership sign-in link opens, per-IP rate limited. The secret in the path is the only credential; it is single use, and the provider callback completes only in the browser that pressed Continue)
  • POST /oauth/token, POST /oauth/revoke (OAuth token endpoints, authenticated by the client's id and secret, or PKCE for public clients)
  • POST /oauth/register (OAuth dynamic client registration, RFC 7591; open and per-IP rate-limited)
  • GET /.well-known/oauth-authorization-server, GET /.well-known/oauth-protected-resource (OAuth discovery metadata)

On the backend's host but outside /v1, called by Slack and never by an API key or a session:

  • POST /api/v1/integrations/slack/events, POST /api/v1/integrations/slack/interactivity (Slack signature). Each request must carry a valid Slack X-Slack-Signature made with the instance's signing secret over a timestamp within five minutes, and its body is capped at 1 MiB. A bad signature is 401 unauthorized, and nothing in the body is parsed before the signature checks out. With no SLACK_SIGNING_SECRET set they answer 503 slack_not_configured. They answer Slack within its three-second limit and do the work afterwards. See Slack app

On the backend's host but outside /v1, called by Microsoft Graph or a Google Cloud Pub/Sub push subscription and never by an API key or a session:

  • POST /api/v1/mailbox-push/gmail (Gmail watch notifications from a Cloud Pub/Sub push subscription, used when GMAIL_PUSH_SERVICE_ACCOUNT is set). Each request must carry a Google-signed bearer token whose audience is this URL and whose sender is that service account, or it gets 401. The body is capped at 64 KiB. The answer is 204 once the mailbox's worker has been woken or when the body names no mailbox, 503 when the wake could not be sent, so Pub/Sub retries, and 404 when Gmail push is not configured. See realtime mailbox sync
  • POST /api/v1/mailbox-push/graph/:emailId (Microsoft Graph change and lifecycle notifications for one mailbox, used when GRAPH_PUSH is on). A request carrying validationToken gets the token echoed as text/plain, which is how Graph confirms a new subscription. Otherwise each notification item is accepted only with the client state issued for that mailbox, and items without it are ignored. The body is capped at 1 MiB. The answer is 202 once the mailbox's worker has been woken, also for a body with nothing to act on, and 503 when the wake could not be sent, so Graph retries. See realtime mailbox sync

On the portal service (the PORTAL_DOMAIN host and every verified agency portal domain), also public and rate-limited per source:

  • GET /r/:token (a shared client report, noindex, frameable by any site) and its JSON read GET /api/reports/:token, which requires the render token the page shell carries and is cached for a minute
  • GET /connect/:token (a client connect page, the same headers) and its JSON calls GET /api/connect/:token, POST /api/connect/:token/oauth, POST /api/connect/:token/oauth/result and POST /api/connect/:token/smtp-imap, each requiring the page's render token
  • GET /embed.js, the embed loader for connect pages and reports

The portal service answers 404 for /f/, /forms.js and any other /api/ path. An agency portal domain serves the dashboard on every other path; the shared PORTAL_DOMAIN serves nothing else. Behind it are the backend's internal GET /api/v1/internal/portals/:host, GET /api/v1/internal/reports/:token and POST /api/v1/internal/connect-links/*, which take PORTAL_EDGE_TOKEN and no other token. The forms service serves none of these pages.

On the tracking service (the TRACKING_DOMAIN host, not the API), also public and rate-limited per source:

  • GET /t/o/:task_id.png (open pixel), GET /c/:link_id (click redirect)
  • GET /tracking.js (the website tracking snippet), POST /p (page-view ingest; JSON body up to 8 KB, 429 over budget, 204 otherwise. Nothing in the request can name a contact)
  • A visit to a sending domain's root (or its www) that a workspace pointed here. On a verified domain every path except /health answers 302 to its website with Cache-Control: public, max-age=300, before any route above; an unknown host is a 404. The forms service answers a verified redirect domain the same way

Scheduled jobs

GET /admin/jobs requires admin view_analytics and returns registered loops and retained retired records in data. Each row includes its service, interval, last recorded status and error, execution counters and recorded schedule. retired_at, when present, is the timestamp of explicit retirement classification, not proof of when the last process stopped. Retired records remain visible in Jobs with their historical values; they are not current running, overdue, failed or successful jobs in operational monitoring.

POST /admin/jobs/:name/run requires admin manage_settings. It requests a registered loop to run at its next poll; it does not execute the job itself. Missing and explicitly retired records return 404 not_found. Retired loops cannot claim new scheduled slots or take manual requests. Retirement does not interrupt an already admitted run or erase its completion evidence.

Migration 286 classifies only the backend fleet_quarantine loop, which was removed from the application, as retired. Other old or stalled jobs remain active and retain their monitoring warnings. There is no age-based automatic retirement. Registering a loop again reactivates its record and clears any old pending run request, without resetting its execution counters or errors.

Platform node diagnostics

Backend-origin routes outside /v1 require a normal bearer session, live admin permissions and session MFA. Workspace API keys/OAuth API tokens cannot use them.

Method and pathExisting admin permissions
POST /admin/auth/device/describeAny nonzero admin permission
POST /admin/auth/device/decideAny nonzero admin permission + fresh auth
GET /admin/fleet/nodes/:id/logsVIEW_WORKERS
GET /admin/fleet/nodes/:id/brokerVIEW_WORKERS and VIEW_ANALYTICS

Public device starts/polls are POST /v1/auth/cli/admin/code and /poll. See exact auth schemas. There is no remote shell, arbitrary execution endpoint or admin API key. Mutations stay on existing permission-gated admin endpoints and audit trails.

Recent evidence

GET /admin/fleet/nodes/:id/logs accepts optional after/before (inclusive RFC3339 timestamps, after cannot follow before), level (info, warn, error), mailbox_id (mailbox account UUID, not folder/message) and limit (integer 1..200, default 200). Newest-first results contain at most one hour and 1,000 retained events per node; expired/count-evicted evidence cannot be recovered. This is allowlisted process evidence, not a full journal export.

Response 200 (Cache-Control: no-store):

{
  "node_id": "<node UUID>",
  "availability": "fresh", "coverage": "partial",
  "reason": "allowlisted_evidence_only", "observed_at": "2026-10-10T06:10:00Z",
  "capture": {
    "protocol": 1, "run_id": "<process UUID>", "started_at": "2026-10-10T06:00:00Z",
    "observed_at": "2026-10-10T06:09:58Z", "received_at": "2026-10-10T06:09:59Z",
    "dropped": 2
  },
  "events": [{
    "id": "<event UUID>", "observed_at": "2026-10-10T06:09:50Z",
    "event": "sync_control_plane_held", "level": "warn", "category": "control_plane",
    "mailbox_id": "<mailbox account UUID>", "http_status": 503, "count": 1
  }],
  "oldest_retained_at": "2026-10-10T06:09:50Z", "newest_retained_at": "2026-10-10T06:09:50Z",
  "retained_count": 1, "truncated": false, "retention_seconds": 3600, "max_events": 1000
}

capture is omitted until observed. Oldest/newest/count describe the entire retained window, not filtered rows. truncated means additional matching retained rows exist beyond the response limit. dropped counts known queue overflow/exhausted transport batches since that process's start, not lifetime loss. It resets on run_id. Crash loss, events before capture installation and lost acknowledgements cannot be measured completely: an exhausted batch is counted as dropped even if ingestion succeeded but its acknowledgement was lost. TTL/count eviction is intentional retention, not transport loss.

Availability / reasonMeaning
unavailable / log_credential_unavailableOld/manual worker or lost Redis enrollment state; re-enroll
unavailable / capture_not_observedEnrolled but no upgraded capture received; might be an old worker
unavailable / cache_unavailableHistory cannot be read, not zero errors
stale / no_recent_captureCapture observation older than two minutes; retained rows may still appear
fresh / no_recent_evidenceRecent empty capture, not proof of provider health
fresh / allowlisted_evidence_onlyFresh partial evidence, never complete/healthy inference

Fixed event schema: UUID id, RFC3339 observed_at, allowlisted event, canonical level/category, optional mailbox UUID and numeric count (0..1,000,000). Only sync_control_plane_held retains numeric HTTP status (100..599), including actual sync map/control-plane 503 responses.

EventCategory / levelObserved behavior
sync_control_plane_heldcontrol_plane / warnActual failed sync control-plane call; pass aborted, cursors held
sync_message_deferredcontrol_plane / warnUnpublished map cleanup failed, pass skipped
mailbox_provider_unreachableprovider / warnActual sync connection/transport failure
mailbox_provider_auth_failedprovider / warnActual sync auth/authorization failure
mailbox_provider_throttledprovider / warnActual sync throttle
mailbox_provider_sync_completedprovider / infoMeasured complete pass after prior transport failures
sync_folder_skippedsync_policy / infoCount of folders excluded by sync policy
sync_message_deferred_by_policysync_policy / infoCount of messages deferred in a sync pass
sync_skipped_folder_search_failedprovider / warnActual excluded-folder search failure
sync_legacy_arrivalcompatibility / warnExisting legacy arrival fallback used
worker_errorworker_runtime / errorWorker Zap error severity only, without arbitrary log text

History also reports generic_error_coverage: "unavailable" and generic_error_reason: "unhooked_runtime_sources". Generic runtime error capture currently hooks worker Zap only. Worker Zerolog and consumer standard-log/Zerolog errors are not captured. Explicit allowlisted sync/provider/control-plane decision hooks remain separate and unchanged. A fresh empty capture, including a consumer capture, never establishes zero runtime errors or health. These fields describe missing source coverage, not a current failure.

No addresses, subjects, bodies, headers, raw errors, folder names or provider credentials are forwarded. Unknown free-form fields are discarded; categories and levels are reconstructed before transport and again on ingestion/read. Unknown events are rejected. Evidence does not change sync leases, cursor handling, arrival admission or send authority.

Node ingestion

POST /api/v1/internal/fleet/nodes/:id/logs accepts only the enrollment-issued node-specific Authorization: Bearer <NODE_LOG_TOKEN>. The existing fleet join token authorizes issuance in env_b64. Shared broker/internal tokens are not accepted; hashes bind to exactly one registered node and are rechecked atomically at ingestion. Rejoin rotates; node deletion revokes and removes log state. This credential grants evidence submission only, not mailbox/command access.

There is no broker-key-to-log-credential refresh endpoint: the existing broker key authenticates the fleet, not ownership of a particular node UUID. Existing nodes must upgrade and rerun authenticated enrollment with the same UUID. The normal join script reads that UUID from the existing node.env, retains node.local.env and restarts the service; do not remove the configuration directory or delete the fleet row. Until capture is received, availability is unavailable, not healthy. Redis enrollment-hash loss requires the same safe re-enrollment, and no historical journals are imported. See upgrade order and coverage.

Request JSON (max 32 KiB, 32 events; events use the schema above):

{
  "protocol": 1, "batch_id": "<UUID, stable across retries>", "run_id": "<process UUID>",
  "started_at": "2026-10-10T06:00:00Z", "observed_at": "2026-10-10T06:09:58Z",
  "dropped": 2, "events": []
}

Empty batches report capture coverage only. Batch time must be within two minutes of receipt and no more than one minute ahead. Events must be within one-hour retention and no more than one minute ahead. started_at must be present and not after observation. Duplicate batch IDs are idempotent for one hour; older batches cannot overwrite newer capture metadata. Cadence is five seconds, queue 256 events, at most three attempts with bounded backoff and five-second HTTP deadlines. Capture never waits for network/queue space. Authenticated requests are capped at 30/node/minute. Success 204; errors use the standard envelope: 400 bad_request (invalid protocol/time/bounds/event), 401 unauthorized (missing/wrong-node/revoked credential), 404 not_found (absent registered node), 429 rate_limit_exceeded (Retry-After: 60) and 503 service_unavailable (dependencies).

Broker committed lag

GET /admin/fleet/nodes/:id/broker accepts a registered worker UUID, no topic/group overrides. Existing read-only scopes remain separate: commands observes worker-<UUID> / w.<UUID>; results observes consumer-group / jobs.worker-events, shared results, not attributable to that worker. No extra consumer scopes or offset mutations are created.

Response 200 has node_id, RFC3339 observed_at, commands, results and note. Each uses existing MonitoringSource: id, availability, coverage, optional reason, observed_at, checked_at, measured/expected scopes and metrics. Successful metrics are committed_lag (messages) and members, with count, observation/evidence timestamps, condition: "unknown", severity and cautionary note. Only this focused endpoint opts into lag partition detail:

{
  "broker": {
    "consumer_group": "worker-<UUID>", "topics": ["w.<UUID>"],
    "partitions": [{"topic":"w.<UUID>","partition":0,"earliest":10,"committed":16,"latest":22,"committed_lag":6}]
  }
}

Every requested topic/partition and committed/earliest/latest offset is validated; partition IDs must be nonnegative, max 128 partitions/scope, sorted by topic/partition. Missing commits, offsets outside retention, mismatched partitions, negative/overflowing lag, permission errors and timeouts are unavailable, never clamped to zero. Failed metrics have null count, no broker detail, unknown condition and attempted observation time. Reasons include dependency_missing, resource_absent, permission_denied, timeout, collection_failed; non-Kafka/unsupported buses return unsupported with empty metrics. One scope failure does not hide another successful scope. Overall collection deadline is eight seconds.

Lag is not delivery, provider health, throughput, uncommitted in-flight work, queue-drain time or an outage. Membership alone is not an outage either. Errors: 400 bad_request (UUID/non-worker/invalid log filters), 401 unauthorized, 403 admin_mfa_required/forbidden, 404 not_found, 503 service_unavailable (node registry). Log Redis read failure instead returns 200 with explicit unavailable coverage. Successful diagnostics use Cache-Control: no-store.

Internal warmup recovery

These control-plane endpoints use the existing node-internal credential, not an API key, dashboard JWT or node logging credential. Their base is /api/v1/internal, not the public API base above. They introduce no new node identity or credential exchange.

MethodInternal pathScope
POST/worker/warmup-actionsCheck current mailbox assignment and permitted actions; exact pending single-file work can return versioned filing recovery proof
POST/worker/warmup-actions/deferCommit a monotonic provider retry time for that exact pending filing under current mailbox assignment

Deferral accepts mailbox_id, worker_id, filing_id and an absolute provider_retry_at timestamp. Successful persisted: true is returned only after commit, with filing_recovery proof containing protocol 1, the same three IDs and the retained timestamp. Requesting a shorter time never shortens an existing provider hold or ordinary retry lease. There is no GET mutation or new filing-success event on deferral.

For exact filing commands, an upgraded backend returns no permitted actions when the pending filing is completed, missing, foreign or has a different action payload. Workers skip provider filing and do not emit a filing-success event for that denial. Untracked actions retain the existing admission behavior.

Invalid identifiers or time return 400; stale, deleted, foreign or disallowed work returns 403; unsupported authority or database failure returns 503. Responses use Cache-Control: no-store. Clients reject unmarked, malformed, unsupported-version or incorrectly scoped replies as durable deferral proof. Existing older-backend retry behavior is unchanged; an older worker does not implement the provider floor. See warmup reconnect recovery for backend-first upgrade and source-instance data handling.

Notes

  • When a route has both a JWT permission and an API permission listed, the dual-auth middleware checks the JWT user's organization role for browser callers and the key's permission bitmask for API key callers. They're independent gates: a user's role doesn't constrain what an API key can do beyond what was granted at creation.
  • Every API key request is logged to api_key_usage_logs with the endpoint pattern (e.g. /campaigns/:id), method, IP, user-agent, response status, and elapsed milliseconds. JWT requests are not logged here.
  • Rate limit categories (X-RateLimit-* headers) are scoped to the underlying user, not the key; every key issued by a user shares the user's quota.

See also

On this page