---
title: "POST /v1/installations/{id}/install"
method: POST
path: "/v1/installations/{id}/install"
---

# POST /v1/installations/{id}/install

`POST /v1/installations/{id}/install`

## Request

- The document declares no server URL.
- Auth: one of:
  - HTTP bearer
  - API key in cookie `__Secure-better-auth.session_token`

Deploy the relay and serve the zone. Safe to call again after a failure.

## Path parameters

- `id` string, required

## Request body

- InstallRequest: Run the install: deploy the relay into the authorised account, give it an address, and point the zone's mail at it. **Idempotent and re-entrant.** Every step is convergent, so calling this again after a partial failure resumes rather than repeats — and a re-run of a finished install touches nothing. Answers the progress immediately; poll `installStatus` for the rest.
  - `domain` string, required: The zone this run installs. Must already be on the installation — `addDomain` first. ⛔ **Named here rather than remembered from the authorization**, because since 2026-08-26 the founder chooses it AFTER authorising and an installation may carry several. The session used to hold it, which only worked while exactly one zone could ever have been meant.
  - `accept` AcceptableDanger[]: Pre-flight dangers the founder has been shown and has chosen to accept. ⛔ **NOT `foreign_mx`, and the asymmetry is the whole design.** The pre-flights are not equally serious and treating them as one list would be the mistake this field is most likely to invite: - `foreign_mx` — the domain receives mail somewhere else. Proceeding ENDS a business's email. There is no consent screen that makes that recoverable, and it stays an absolute wall. ⛔ Never add it here. - `apex_spf` — a second SPF record breaks sending for every message from the domain. Waivable, because accepting it REPLACES the existing record with the merge rather than telling somebody to go and do it. See the union below. - `hostname_taken` — something already answers at `foundermail.<domain>`. That hostname is OURS by construction: we define it, and nothing else has a reason to be there. In practice it is a record left by an earlier attempt at this same setup, which is precisely the case a wall strands a founder in — the install cannot proceed and cannot clean up after itself, so every retry meets the same refusal. - `events_taken` — the domain's Email Sending events already feed a subscription of theirs, and Cloudflare allows one per domain. Waivable, because accepting it REPLACES theirs with ours: the installer deletes it and subscribes the account's FounderMail queue. ⚠️ **The server enforces this, not the caller.** Anything else named here is ignored and the refusal stands — a client cannot widen what may be accepted by asking.

## Response `200`

The request has succeeded.

- InstallProgress: How far the install got, and what stopped it. ⛔ **Carries no credential, by construction.** The backend builds this from a cell that never holds one — see the `Installer` Durable Object. A founder's Cloudflare token and the installation's own signing key are the two things this shape must never be able to express.
  - `step` 'awaiting_authorization' | 'authorized' | 'connecting_account' | 'serving_zone' | 'activating_routing' | 'installed', required: `awaiting_authorization`: waiting for the founder at Cloudflare's consent screen. `authorized`: they approved it, and a credential is held for minutes — call `startInstall` now. ⛔ Reported so an AGENT can see it: until 2026-10-06 the approval left this at `awaiting_authorization`, so an agent had to ask a person "did it work?" while the credential burned down, and on that day it expired exactly so. Still a label and never the credential. `connecting_account`: deploying the relay and giving it an address — touches no mail. `serving_zone`: pointing a domain's mail at the relay. ⚠️ The destructive half. `activating_routing`: the domain is served and Cloudflare is switching its mail on — usually 5–15 minutes; keep polling. Mail sent before it finishes may bounce `550 5.1.1`, which is why the install does not say `installed` until Cloudflare says ready (P58). `failed` here names what Cloudflare last reported; installing again carries on waiting and redoes nothing. `installed`: done — Cloudflare accepts the domain's mail.
  - `state` 'waiting' | 'running' | 'failed' | 'done', required
  - `completed` string[], required: The steps that completed, in order — so a resumed install can show what it skipped.
  - `error` string: Why it stopped, when it stopped.
  - `refusals` Refusal[]: Things we refuse to do to the founder's account without being told to: an apex MX already serving their mail elsewhere, an SPF record enabling Email Routing would collide with, a hostname the relay's address would overwrite, a delivery-events subscription of theirs that Cloudflare will not let ours sit beside. Each says what proceeding would do.
    - `kind` 'zone_missing' | 'zone_inactive' | 'account_mismatch' | 'foreign_mx' | 'apex_spf' | 'hostname_taken' | 'events_taken', required: Which check refused. Machine-readable, so a console can render the fix rather than read it. `zone_missing` / `zone_inactive` / `account_mismatch` are blockers: we cannot proceed at all. `foreign_mx` / `apex_spf` / `hostname_taken` / `events_taken` are dangers: we could, and it would break something the customer already relies on.
    - `message` string, required: The refusal in words, complete on its own — this is what a log line carries.
    - `remedy` Remedy: The value a founder has to go and publish, when the fix IS a value. ⛔ **Separate from the message on purpose.** It used to live inside the prose — the SPF refusal read `\n replace with: v=spf1 …` — and the only way for a console to offer it as something copyable was to parse English out of a sentence. That is the bug the dashboard's own rule forbids ("screens branch on the status, never on the message"), because it breaks on a copy edit with nothing to catch it. A record typed out by hand from a paragraph is a record typed out wrong, and getting an SPF record wrong stops the domain's mail either way.
      - `label` string, required: What this value IS, e.g. `SPF record for acme.com`.
      - `value` string, required: The value to publish, verbatim and complete. Nothing has to be extracted from it.
      - `replaces` string: What it replaces, when it replaces something — so the founder finds the right record.
    - `records` string[]: The MX hosts the domain receives at today — `foreign_mx` only — so a console can say who still gets its mail without parsing `message`.

## Other responses

- `default`: An unexpected error response.

---

[API](https://skmtc.dev/foundermail/apis/org.md) - [All operations](https://skmtc.dev/foundermail/apis/org/llms.txt) - [OpenAPI document](https://skmtc.dev/foundermail/apis/org/revisions/8bbb36e378ab?raw)
