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