Skip to main content
The Developer API, the 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 position is exactly { "x": <number>, "y": <number> }.
Older campaigns whose stored graph held extra internal fields read the same way. No client change is needed.

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: includeListIds and/or senderConnectorIds, each 1-50 ids. Unknown fields are rejected.
  • Idempotency-Key is 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:read to list, blocked_domains:write to add or remove.
  • Add requires Idempotency-Key and returns 201 with { "domains": [...] }.
  • Remove takes an optional key and returns 200 with { "domain", "removed" }.
  • A public suffix (for example co.uk) is rejected with 400.
  • 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.
CLI: 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 returns 400 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.check audit 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-Key is optional.
  • The domain must already exist in the workspace, else 404 not_found.
  • Response: domainAuth plus verification { overallStatus, spf, dkim[], dmarc }. Each check has status (pass, fail, missing, or lookup_failed) and found.
The app route for the same check accepts browser sessions only. An API key there gets 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 returns byHealthGroup:
  • healthy: meets every threshold in healthyCriteria;
  • needsAttention: tier degraded, throttle, or critical;
  • 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-Key is required.
  • Body: apiKey (a Cal.com key, cal_[redacted]) plus eventTypeId and/or bookingUrl. Unknown fields are rejected.
  • Returns 201 with { "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:
  1. Strict body: bodyFormat is html or text, at most 10 CC and 5 BCC.
  2. Idempotency-Key is required.
  3. At most 10 real sends per 60 seconds.
  4. No To, CC, or BCC address may be on a blocked domain (422 blocked_domain) or suppressed (422 recipient_suppressed).
  5. The sender must be authenticated (422 sender_not_authenticated).
  6. A contact in an active sequence needs allowDuringSequence (409 active_sequence).
A scheduled time must be in the future (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 202 with status awaiting_approval. The workspace setting directEmailHeadlessApproval is require (default) or waive.
  • The wildcard scope * never grants direct_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 returns 409 not_approvable.
  • An unapproved email expires 24 hours after its scheduled time (approval_expired).

Write audit

Each headless write records an audit row with source write 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.