---
title: "Connect a custom domain"
method: POST
path: "/projects/v1/{project_id}/domains"
tags: ["Project domains"]
---

# Connect a custom domain

`POST /projects/v1/{project_id}/domains`

## Request

- Base URL: `https://api.run402.com`
- URL: `https://api.run402.com/projects/v1/{project_id}/domains`
- Auth: one of:
  - API key in header `SIGN-IN-WITH-X`
  - HTTP bearer
  - HTTP bearer

Connects a custom domain to the project: creates or replaces its desired ProjectDomain state for web hosting, email sending, email receiving, and custom mailbox addresses. The domain rides in the body; the same call on a connected domain replaces its desired state. The response separates desired, observed, and effective state and returns checks, DNS records, authority options, and canonical next_actions.

## Path parameters

- `project_id` string, required: Run402 project ID. Public IDs are not UUIDs.

## Headers

- `SIGN-IN-WITH-X` string, required

## Request body

- ProjectDomainConnectRequest
  - `domain` string, required: Lowercase DNS name to connect to the project.
  - `desired` ProjectDomainDesired, required
    - `web` object
      - `enabled` boolean
      - `target` string
      - `role` 'primary' | 'alias'
    - `email` object
      - `send` object
        - `enabled` boolean
      - `receive` ProjectDomainReceiveDesired
        - `enabled` boolean
        - `strategy` 'auto' | 'outbound_only' | 'forwarding_mode' | 'subdomain_mode' | 'full_receive_takeover'
        - `resolved_strategy` 'auto' | 'outbound_only' | 'forwarding_mode' | 'subdomain_mode' | 'full_receive_takeover'
        - `observed_mx_fingerprint` string, nullable
        - `mail_subdomain` string, nullable
      - `mailbox_addresses` ProjectDomainMailboxAddressIntent
        - `mode` 'primary' | 'alias' | 'managed' | 'none', required
        - `all_mailboxes` boolean: When true with mode primary or alias, every project mailbox resolves to <slug>@<domain>; addresses[] is then an override list. Only valid with full_receive_takeover or subdomain_mode receive strategies.
        - `addresses` object[], required
          - `local_part` string, required
          - `mailbox_slug` string, required
          - `create_mailbox` boolean
      - `activation` 'automatic_when_ready' | 'manual'
    - `authority` 'manual_dns' | 'hosted_dns_zone': Authority preference. `hosted_dns_zone` opts the domain into a Run402-hosted DNS zone: the customer-side work collapses to one nameserver change at the registrar; Run402 applies every in-zone record once delegation is observed. Absent means manual DNS.

## Response `200`

Custom domain connected

- ProjectDomain
  - `project_id` string, required: Run402 project ID. Public IDs are not UUIDs.
  - `domain` string, required
  - `status` 'action_required' | 'waiting' | 'active' | 'needs_repair' | 'failed', required
  - `desired` ProjectDomainDesired, required
    - `web` object
      - `enabled` boolean
      - `target` string
      - `role` 'primary' | 'alias'
    - `email` object
      - `send` object
        - `enabled` boolean
      - `receive` ProjectDomainReceiveDesired
        - `enabled` boolean
        - `strategy` 'auto' | 'outbound_only' | 'forwarding_mode' | 'subdomain_mode' | 'full_receive_takeover'
        - `resolved_strategy` 'auto' | 'outbound_only' | 'forwarding_mode' | 'subdomain_mode' | 'full_receive_takeover'
        - `observed_mx_fingerprint` string, nullable
        - `mail_subdomain` string, nullable
      - `mailbox_addresses` ProjectDomainMailboxAddressIntent
        - `mode` 'primary' | 'alias' | 'managed' | 'none', required
        - `all_mailboxes` boolean: When true with mode primary or alias, every project mailbox resolves to <slug>@<domain>; addresses[] is then an override list. Only valid with full_receive_takeover or subdomain_mode receive strategies.
        - `addresses` object[], required
          - `local_part` string, required
          - `mailbox_slug` string, required
          - `create_mailbox` boolean
      - `activation` 'automatic_when_ready' | 'manual'
    - `authority` 'manual_dns' | 'hosted_dns_zone': Authority preference. `hosted_dns_zone` opts the domain into a Run402-hosted DNS zone: the customer-side work collapses to one nameserver change at the registrar; Run402 applies every in-zone record once delegation is observed. Absent means manual DNS.
  - `observed` ProjectDomainObserved, required
    - `dns` object
    - `provider` object[]
    - `receive_tests` ProjectDomainReceiveTest[]
      - `id` string, required
      - `local_part` string, required
      - `address` string, required
      - `target_managed_address` string, required
      - `status` 'pending' | 'passed' | 'failed' | 'stale', required
      - `token` string, required: Place this token in the test email subject or body.
      - `created_at` string, date-time, required
      - `passed_at` string, date-time, nullable
    - `last_checked_at` string, date-time
  - `effective` ProjectDomainEffective, required
    - `web` object
    - `email` object
  - `authority` ProjectDomainAuthority, required
    - `recommended_mode` 'manual_dns' | 'provider_connect' | 'delegated_subdomain' | 'hosted_dns_zone' | 'registered_domain'
    - `options` object[]
      - `mode` string
      - `mutation_scope` 'none' | 'record' | 'subtree' | 'zone' | 'registration'
      - `status` 'available' | 'unavailable' | 'pending'
      - `external_required` boolean
      - `reason_code` string
  - `dns_records` ProjectDomainDnsRecord[], required
    - `id` string, required
    - `purpose` string, required
    - `type` 'A' | 'AAAA' | 'CNAME' | 'TXT' | 'MX' | 'NS', required
    - `name` string, required
    - `value` string, required
    - `priority` integer
    - `required` boolean, required
    - `status` 'unknown' | 'missing' | 'present' | 'conflict', required
    - `safety` object, required
      - `safe_to_auto_run` boolean
      - `confirmation_required` boolean
      - `destructive` boolean
      - `external_required` boolean
      - `conflict_policy` string
    - `bind` string: BIND-style zone-file line for copy/paste/import.
  - `checks` ProjectDomainCheck[], required
    - `id` 'domain.ownership' | 'web.dns' | 'web.probe' | 'email.send.dkim' | 'email.receive.mx' | 'email.receive.forwarding' | 'email.receive.route' | 'email.receive.test', required
    - `status` 'unknown' | 'pending' | 'passed' | 'failed' | 'drifted' | 'blocked', required
    - `blocking` boolean, required
    - `blocked_by` string[]
    - `reason_code` string
    - `summary` string
    - `checked_at` string, date-time
  - `next_actions` NextAction[], required: Ordered follow-ups; next_actions[0] is the recommended action. Empty array means nothing to do.
    - `type` string, required: Advisory machine-readable action type such as retry, authenticate, submit_payment, fund_wallet, renew_tier, check_usage, resume_deploy, edit_request, edit_migration, contact_support, upgrade_client, poll (poll the project events feed — appended when the envelope carries a correlated_platform_incident), or hand_to_member (on a commit/promote response that just activated a public site: show the human urls.site and urls.console, relay Run402's offer to promote the build on @run402com, and ask yes or no — see `credited_as` below).
    - `method` string
    - `path` string
    - `auth` string
    - `why` string
    - `credited_as` string, nullable: `hand_to_member` only. The caller's own live presence name in the project's default room, else that room's most recently active live presence, else `null` — in which case `why` tells the agent that joining the room with a name (`run402 rooms join --name ...`) is how the promotion credits it.
    - `body` object: `hand_to_member` only. A ready-to-send `POST /feedback/v1` template: `{ project_id, message: "promote: yes", handle? }`.
  - `next_action` NextAction, required
    - `type` string, required: Advisory machine-readable action type such as retry, authenticate, submit_payment, fund_wallet, renew_tier, check_usage, resume_deploy, edit_request, edit_migration, contact_support, upgrade_client, poll (poll the project events feed — appended when the envelope carries a correlated_platform_incident), or hand_to_member (on a commit/promote response that just activated a public site: show the human urls.site and urls.console, relay Run402's offer to promote the build on @run402com, and ask yes or no — see `credited_as` below).
    - `method` string
    - `path` string
    - `auth` string
    - `why` string
    - `credited_as` string, nullable: `hand_to_member` only. The caller's own live presence name in the project's default room, else that room's most recently active live presence, else `null` — in which case `why` tells the agent that joining the room with a name (`run402 rooms join --name ...`) is how the promotion credits it.
    - `body` object: `hand_to_member` only. A ready-to-send `POST /feedback/v1` template: `{ project_id, message: "promote: yes", handle? }`.
  - `alternate_actions` NextAction[], required: Deprecated alias of next_actions.slice(1); removed with the kychee-com/run402#548 client round.
    - `type` string, required: Advisory machine-readable action type such as retry, authenticate, submit_payment, fund_wallet, renew_tier, check_usage, resume_deploy, edit_request, edit_migration, contact_support, upgrade_client, poll (poll the project events feed — appended when the envelope carries a correlated_platform_incident), or hand_to_member (on a commit/promote response that just activated a public site: show the human urls.site and urls.console, relay Run402's offer to promote the build on @run402com, and ask yes or no — see `credited_as` below).
    - `method` string
    - `path` string
    - `auth` string
    - `why` string
    - `credited_as` string, nullable: `hand_to_member` only. The caller's own live presence name in the project's default room, else that room's most recently active live presence, else `null` — in which case `why` tells the agent that joining the room with a name (`run402 rooms join --name ...`) is how the promotion credits it.
    - `body` object: `hand_to_member` only. A ready-to-send `POST /feedback/v1` template: `{ project_id, message: "promote: yes", handle? }`.
  - `provenance` object, required
    - `project` 'server_control_plane'
    - `desired` 'server_control_plane'
    - `observed_dns` 'public_dns_resolvers'
    - `effective` 'run402_control_plane'
    - `local_cache` 'not_used'
  - `hosted_zone` ProjectDomainHostedZone: Custody disclosure for a Run402-hosted DNS zone. Present only when the domain opted into `hosted_dns_zone` authority.
    - `dns_hosting` 'run402_hosted'
    - `status` 'provisioning' | 'awaiting_delegation' | 'delegated' | 'releasing'
    - `ns_assigned` string[]: The nameserver pair to set at the registrar — the single customer-side action.
    - `imported_records` object[]: Pre-existing MX/TXT records copied into the hosted zone verbatim before the nameserver change was recommended (live mail routing is never silently dropped).
      - `type` string
      - `name` string
      - `value` string
      - `priority` integer
  - `created_at` string, date-time
  - `updated_at` string, date-time

## Other responses

- `400`: Invalid desired state
- `401`: Missing or invalid authentication. Run402-originated JSON bodies use the canonical ErrorEnvelope.
- `403`: Not authorized for this project, or the owning organization's tier does not include custom domains. The tier refusal (custom-domains-require-paid-tier) is `TIER_FEATURE_REQUIRED`, category `billing`, with `details: { feature: "custom_domain", domain, org_id, tier, required_tier: "hobby" }` and `next_actions` `renew_tier` (`POST /tiers/v1/hobby`, x402) then `create_checkout` (`POST /orgs/v1/{org_id}/checkouts` with `{ product: "tier", tier: "hobby" }`, card). It applies only to a domain no project of the organization already holds; managed `*.run402.com` / `*.run402.app` subdomains are free on every tier. Authorization and the lifecycle gate refuse first.
- `409`: Confirmation or conflict required, such as full MX takeover without observed-MX fingerprint confirmation

---

[API](https://skmtc.dev/run402/apis/run402-api.md) · [All operations](https://skmtc.dev/run402/apis/run402-api/llms.txt) · [OpenAPI document](https://skmtc.dev/run402/apis/run402-api/revisions/893607557f50?raw)
