firstsales CLI, and Product MCP call the same /api/v1
operations. The rules on this page apply to all three.
Shared write rules
Campaign workflow read
GET /api/v1/organizations/{orgId}/workspaces/{wsId}/campaigns/{campaignId}/workflow
(scope campaigns:read) and the MCP tool campaign_workflow_get return the stored
graph as plain JSON:
- identifiers are hex strings and dates are ISO 8601 strings;
- every node
positionis exactly{ "x": <number>, "y": <number> }.
Contacts without an email
A contact email is unique within a workspace when it is present. A workspace can hold any number of contacts that have no email, so imports and creates of email-free contacts (for example, businesses found by place) no longer fail as duplicates. All paths below are relative to/api/v1/organizations/{orgId}/workspaces/{wsId}.
Workflow update
PATCH /campaigns/{campaignId}/workflow (scope campaigns:write) changes the lists a
campaign enrolls from and the mailboxes it sends from, without rebuilding the graph.
- Body:
includeListIdsand/orsenderConnectorIds, each 1-50 ids. Unknown fields are rejected. Idempotency-Keyis optional.- A sender must be a verified email connector in the same workspace.
- The response has the same shape as the workflow read.
CLI:
firstsales campaigns workflow get|update. The repeatable --include-list and
--sender flags build the body; mixing them with --data fails with ambiguous_body.
Blocked Domains
A blocked domain stops sends to that domain and all of its subdomains. Blocks exist at workspace level and at campaign level.- Scopes:
blocked_domains:readto list,blocked_domains:writeto add or remove. - Add requires
Idempotency-Keyand returns201with{ "domains": [...] }. - Remove takes an optional key and returns
200with{ "domain", "removed" }. - A public suffix (for example
co.uk) is rejected with400. - At send time a matching email is skipped with reason
blocked_domain. The contact stays enrolled; it is not excluded or cancelled. - Removing a block makes the domain eligible again. Emails skipped while the block was active are not re-sent. Bounce and unsubscribe suppressions stay in place.
firstsales blocked-domains list|add|remove, with --campaign for the campaign
routes.
Suppression check
POST /suppression/check (scope suppression:read) tells you whether addresses or
domains are suppressed, without exposing the suppression list.
- Body:
{ "values": ["[redacted]@example.com", "example.org"] }, 1-100 values. Any other shape returns400 bad_request. - Response:
{ "results": [{ "value", "suppressed", "reason"?, "matchedDomain"? }] }. - Own rate limit: 20 requests per minute per organization and key (
429). - Every call writes a
suppression.checkaudit row. The row does not store the values you checked. - There is no list or export route.
Company import
POST /companies/imports (scope companies:write) creates 1-10,000 companies in one
call. Company domains are stored as a lowercase host without scheme, www., path, port, or
trailing dot; other subdomains are kept. A value with no registrable domain is dropped
and the company is saved without one. The response is { created, duplicates, errors, notCreated }, where
each notCreated item has an index and a reason. The idempotency key comes from
the Idempotency-Key header, then the body idempotencyKey, then a hash of the rows.
Email authentication verify
POST /email-auth/verify (scope email_auth:write) runs a live SPF, DKIM, and DMARC
check for a sending domain.
- Body:
{ "domain": "example.com", "dkimSelector"?: "s1" }. Idempotency-Keyis optional.- The domain must already exist in the workspace, else
404 not_found. - Response:
domainAuthplusverification { overallStatus, spf, dkim[], dmarc }. Each check hasstatus(pass,fail,missing, orlookup_failed) andfound.
403 session_required; use the /api/v1 route.
Tracking-domain reasons
GET /tracking-domains/{domainId} (scope tracking:read) returns one tracking domain
with certificateEligibility: reason, cause, attempts, nextRetryAt, and
checkedAt. The verify response carries the same fields.
Older records may still read
registration_unavailable.
Connector health groups
The connector dashboard returnsbyHealthGroup:
healthy: meets every threshold inhealthyCriteria;needsAttention: tierdegraded,throttle, orcritical;notYetProven: on hold or without a tier yet.
healthyCriteria is { "minSends": 20, "maxBounceRate": 0.02, "minOpenRate": 0.2, "minReplyRate": 0.01 }. The /api/v1 dashboard returns both fields unchanged.
Cal.com connector
POST /connectors/cal-com (scope connectors:write) connects a Cal.com account
without the browser flow.
Idempotency-Keyis required.- Body:
apiKey(a Cal.com key,cal_[redacted]) pluseventTypeIdand/orbookingUrl. Unknown fields are rejected. - Returns
201with{ "connector" }. The response never includes the key.
Direct Email
Direct Email sends a one-off email to a contact outside a sequence. It can be saved as a draft, scheduled, or sent now. Send rules, checked in this order:- Strict body:
bodyFormatishtmlortext, at most 10 CC and 5 BCC. Idempotency-Keyis required.- At most 10 real sends per 60 seconds.
- No To, CC, or BCC address may be on a blocked domain (
422 blocked_domain) or suppressed (422 recipient_suppressed). - The sender must be authenticated (
422 sender_not_authenticated). - A contact in an active sequence needs
allowDuringSequence(409 active_sequence).
scheduled_at_must_be_future). After an
email is claimed for sending, edit and cancel return 409 already_sending. Credits
are charged at claim.
Today these routes accept browser sessions only; an API key gets
403 session_required.
Headless Direct Email and approval
- Create and approve require
Idempotency-Key. Keys are scoped to the API key. - By default a send or schedule from an API key returns
202with statusawaiting_approval. The workspace settingdirectEmailHeadlessApprovalisrequire(default) orwaive. - The wildcard scope
*never grantsdirect_email:approve. - An email cannot be approved by the key that created it (
403 self_approval_forbidden). - Approving twice returns
409 already_approved; a draft or cancelled email returns409 not_approvable. - An unapproved email expires 24 hours after its scheduled time (
approval_expired).
Write audit
Each headless write records an audit row with sourcewrite and the surface that
made it (api, cli, or mcp). Examples: campaign.workflow.update,
connector.cal_com.create, suppression.check. Audit rows never store API keys,
Cal.com keys, or checked values.