# Create a connection

> POST /v1/connections creates a connection for a domain, replays safely if you retry, returns the DNS records to apply, and accepts a record set you supply.

Source: https://docs.customdomain.ai/docs/connect-flow/create-a-connection

A connection is created against an **application** and authorized by a
[widget JWT](/docs/authentication/widget-tokens) (or an API key for
server-side use). The only required field is the domain:

```bash
curl -X POST https://api.customdomain.ai/v1/connections \
  -H "Authorization: Bearer <WIDGET_JWT>" \
  -H "Content-Type: application/json" \
  -d '{"domain":"app.customer.com"}'
```

```js
const res = await fetch("https://api.customdomain.ai/v1/connections", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${token}`, // a widget JWT, or an API key on your server
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ domain: "app.customer.com" }),
});
const connection = await res.json(); // 201 when new, 200 when it already existed
```

Optional body fields:

| Field             | Meaning                                                                                                                                                                                                                                                                                                                                                                                          |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `application_url` | The [Power](/docs/connect-flow/routing) origin the edge proxies this connection to. Validated before anything is written (absolute `http` or `https`, no private or loopback addresses). Power is part of the Growth plan and up: on a plan without it the create is refused with `402 plan_upgrade_required` and nothing is stored.                                                             |
| `www_redirect`    | Also write the `www` record and redirect it. Defaults to `false`.                                                                                                                                                                                                                                                                                                                                |
| `override_spf`    | Replace an existing SPF record instead of merging into it. Honoured only when the provider's `spf_override_support` capability is `true`; otherwise the existing record is merged. Defaults to `false`.                                                                                                                                                                                          |
| `validate_dmarc`  | Accepted and echoed back, not acted on yet. Defaults to `false`.                                                                                                                                                                                                                                                                                                                                 |
| `validate_caa`    | Adds two apex CAA records that authorize Let's Encrypt (`issue` and `issuewild`) to the records the connection manages, next to any CAA records already there. Defaults to `false`.                                                                                                                                                                                                              |
| `monitor`         | Drift [monitoring](/docs/connect-flow/monitoring). On unless you send `false`.                                                                                                                                                                                                                                                                                                                   |
| `batch_id`        | Groups the domains connected in one multi-domain session.                                                                                                                                                                                                                                                                                                                                        |
| `end_user_ref`    | Your own id for the end user, shown as "Connected by" in the console. A widget token's end user must match.                                                                                                                                                                                                                                                                                      |
| `end_user_email`  | The end user's email address, so a [Move](/docs/connect-flow/end-user-notices) emails them the new records. Wins over one given when the token was minted. Only an API key replaces an address the connection already has: a widget token's create keeps the stored one, a widget token bound to another end user stores none, and a delegated agent token that sends one is refused with `403`. |
| `application_id`  | API key callers only: create on another application of the same workspace. A widget or agent token can only name its own.                                                                                                                                                                                                                                                                        |

Unknown fields are rejected with `400`. A workspace without an active or
trialing subscription gets `402` `billing_required`, and a plan at its domain
limit gets `402` `quota_exceeded`. A new workspace's
[first ten domains are free](/docs/billing/plans-and-quotas#the-first-ten-domains-are-free).

## Response

The response is the connection plus its authoritative desired record set:

```json
{
  "id": "con_…",
  "application_id": "app_…",
  "domain": "app.customer.com",
  "provider_id": "cloudflare",
  "setup_type": "automatic",
  "status": "pending",
  "created_at": "2026-07-07T12:00:00Z",
  "records": [
    { "type": "CNAME", "host": "app.customer.com", "value": "edge.customdomain.ai", "ttl": 3600 }
  ]
}
```

* `id` doubles as the `jobId` used by the widget and webhooks.
* `setup_type` (`automatic` / `manual` / `async` / `mcp`) is chosen from
  provider detection; it steers which [rail](/docs/connect-flow/overview#the-four-rails)
  the widget offers.
* `records` is the same set you can re-fetch any time from
  `GET /v1/connections/{id}/records`. Before a rail writes records, the default
  edge `CNAME` is synthesized server-side.
* An API key reads `end_user_ref` and `end_user_email` back. A delegated agent
  token's answer carries no `end_user_ref`, a replayed connection included, and it
  reads none on the connection list and the connection read either.

## Supplying your own records

Some record sets cannot be derived from the domain. The motivating case is email
verification: Amazon SES mints three Easy-DKIM `CNAME` selectors **per domain**,
so no template can know them. Replace the connection's desired set with your own
before the widget opens:

```bash
curl -X PUT https://api.customdomain.ai/v1/connections/con_…/records \
  -H "Authorization: Bearer cd_live_…" \
  -H "Content-Type: application/json" \
  -d '{"records":[
        {"type":"MX","host":"@","value":"inbound-smtp.us-east-2.amazonaws.com","priority":10},
        {"type":"TXT","host":"@","value":"v=spf1 include:amazonses.com ~all"},
        {"type":"CNAME","host":"sel1._domainkey","value":"sel1.dkim.amazonses.com"}
      ]}'
```

```js
const res = await fetch(`https://api.customdomain.ai/v1/connections/${connectionId}/records`, {
  method: "PUT",
  headers: {
    Authorization: `Bearer ${process.env.CUSTOMDOMAIN_API_KEY}`, // API key only
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    records: [
      { type: "MX", host: "@", value: "inbound-smtp.us-east-2.amazonaws.com", priority: 10 },
      { type: "TXT", host: "@", value: "v=spf1 include:amazonses.com ~all" },
      { type: "CNAME", host: "sel1._domainkey", value: "sel1.dkim.amazonses.com" },
    ],
  }),
});
```

The supplied set then behaves exactly like ours everywhere: the OAuth rail writes
it with the customer's provider credential, the widget renders it for manual
setup, and the propagation poller verifies it before the connection goes live.
This is the way to give one customer records of their own. The SDK's
`dnsRecords` option cannot do it: it only carries DKIM records for the widget's
DKIM screen and ignores the rest, so the browser is never trusted with the
record list.

Three constraints follow from that trust boundary:

* **API key only.** A widget JWT lives in a browser; it may not choose the
  records written into a customer's zone.
* **Hosts must resolve inside the connection's zone.** `@`, `_dmarc`, and
  `sel1._domainkey` are anchored to the connection's domain; a foreign FQDN is
  rejected.
* **The Domain Connect rail refuses a supplied set** (`400`). That rail applies
  our *published templates* at the provider, so it cannot write records it did
  not author; use the OAuth rail or manual setup for these connections.
* **Secure sign-in refuses it too** (`422 browser_unsupported_records`), before
  the customer signs in. The customer gets the manual steps instead.

The replacement is atomic and rejected with `409` once records have been applied.
`GET /v1/connections/{id}/records` returns the resulting authoritative set, and
the connection carries `records_source: "integrator"`.

## Idempotency

`POST /v1/connections` is idempotent per application + domain. If the calling app
already has a `pending`, `propagating` or `live` connection for the domain, that
connection is returned with `200` (instead of `201&#x60;), so it is safe to call on every
widget open. If the only existing connection is &#x2A;*`failed`**, it is **revived**
(status back to `pending`, diagnosis cleared, a fresh verification window) and
returned with `200` rather than a duplicate being created. The explicit retry is
`POST /v1/connections/{id}:recheck` (`409` if a different active connection
exists for the domain). This is also what makes an abandoned setup resumable:
the console's **Finish setup** / **Resume** reopens the guided flow against the
existing connection at the right step. A widget token never gets another
customer's connection: when the existing connection (live, or failed and about to
be revived) names a different `end_user_ref` than the token's, or names one while
the token names none, the call answers `403 token_wrong_end_user` and shows nothing
of it, and a failed connection is not revived. Mint the token with that customer's
`end_user_ref`. A connection that names no customer, your API key, an `app` scope
token and a delegated agent token are still answered with the existing connection.
A domain-bound widget JWT may only create a connection for the hostname it was
minted for. Hosting-platform subdomains
(`*.vercel.app` and the rest of the platform list) are refused with `422
platform_subdomain`.

Next: [apply the records and go live](/docs/connect-flow/verify-and-go-live).
