> ## Documentation Index
> Fetch the complete documentation index at: https://developer.firstsales.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Headless Operations

> Run FirstSales from the API, CLI, and MCP without a browser: shared write rules and per-feature notes.

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

| Rule | Where it is documented |
| - | - |
| `Idempotency-Key` is required on some writes and optional on the rest. | [Idempotency](/docs/idempotency) |
| A reused key on another method, route, or body returns `409 idempotency_key_reused`. | [Idempotency](/docs/idempotency#rules) |
| An exact replay of a completed `/api/v1` write does not spend rate-limit budget. | [Idempotency](/docs/idempotency#replays-and-rate-limits) |
| Each key is limited to 600 authenticated requests per minute. | [Rate limits](/docs/rate-limits) |
| `--dry-run` sends nothing and redacts the key. | [CLI commands](/cli-reference/commands#dry-run) |

## 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.

| Status | Code | Meaning |
| - | - | - |
| `404` | `not_found` | The campaign is not in this workspace. |
| `409` | `workflow_node_missing` | The graph has no node to hold the lists or senders. |
| `409` | `workflow_changed` | The graph changed during the update. Read it again and retry. |
| `422` | `list_not_in_workspace` | A list id is not in this workspace. |
| `422` | `connector_not_eligible` | A connector is not a verified email sender in this workspace. |

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.

| Operation | Workspace route | Campaign route |
| - | - | - |
| List | `GET /blocked-domains` | `GET /campaigns/{campaignId}/blocked-domains` |
| Add | `POST /blocked-domains` | `POST /campaigns/{campaignId}/blocked-domains` |
| Remove | `DELETE /blocked-domains/{domain}` | `DELETE /campaigns/{campaignId}/blocked-domains/{domain}` |

* 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.

| Reason | Meaning |
| - | - |
| `lookup_failed` | The registry lookup failed. `cause` is `timeout`, `network_error`, `http_5xx`, `rate_limited`, `http_error`, `no_rdap_server`, or `invalid_response`. FirstSales retries after 1h, 2h, 4h, then every 6h. |
| `not_registered` | The domain is not registered. |
| `expired` | The registration has expired. |
| `not_eligible` | The domain cannot get a certificate. |

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.

| Status | Code | Meaning |
| - | - | - |
| `400` | `cal_com_key_invalid` | Cal.com rejected the key. |
| `400` | `cal_com_booking_url_mismatch` | The booking URL does not belong to this account. |
| `400` | `cal_com_event_not_owned` | The event type does not belong to this account. |
| `409` | `connector_exists` | The workspace already has a Cal.com connector. |
| Cal.com status | `cal_com_unavailable` | Cal.com returned an error while checking the key or event. The status follows Cal.com. |
| `502` | `cal_com_webhook_failed` | The booking webhook could not be registered. Nothing is saved. |
| `503` | `cal_com_not_configured` | Cal.com is not configured on this deployment. |

## 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

| Operation | Route | Scope |
| - | - | - |
| Create | `POST /contacts/{contactId}/emails` | `direct_email:write` |
| Read | `GET /contacts/{contactId}/emails/{emailId}` | `contacts:read` |
| Edit | `PATCH /contacts/{contactId}/emails/{emailId}` | `direct_email:write` |
| Cancel | `POST /contacts/{contactId}/emails/{emailId}/cancel` | `direct_email:write` |
| Approve | `POST /contacts/{contactId}/emails/{emailId}/approve` | `direct_email:approve` |

* 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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.