# Managed connections

> Opt a connection into managed mode so the control plane can re-apply and revert its records with a stored, encrypted grant, and see which providers support it.

Source: https://docs.customdomain.ai/docs/connect-flow/managed-connections

> **Availability:** managed mode needs a grant the control plane can keep, and
> there are two ways to get one. An **async one-click grant** needs the DNS
> provider to onboard us (`OAUTH_DC_<PROVIDER>_CLIENT_ID`); no provider has
> issued one to customdomain.ai yet. A **managed sign-in grant** needs the
> deployment to list the provider in `OAUTH_MANAGED_PROVIDERS` and to set
> `GRANT_ENCRYPTION_KEY`; the hosted service does both for Cloudflare,
> DigitalOcean, DNSimple, Linode, Netlify, Porkbun, Vercel and WordPress.com,
> so a sign-in there is kept by default. Where neither applies, every connection is use-once,
> `managed` stays `false`, and `:reapply` and the MCP `reapply-connection` tool
> return `409`: drift is reported, not healed. Removing a domain does not need a
> grant, so `disconnect-domain` works on any connection (see
> [Removing a domain](/docs/connect-flow/offboarding)).

> **Which sign-ins the hosted service has on is confirmed per provider.** The
> providers named on this page are the ones the product supports and the hosted
> service is set up for. Whether each provider's OAuth app is registered there is
> confirmed one provider at a time, so before you tell a customer that DigitalOcean,
> Linode (Akamai Cloud) or Azure DNS connects with a sign-in, read `rails.oauth` on
> `POST /v1/domains:check`: `available` says whether a sign-in runs for that domain,
> and `reason` is `oauth_client_not_registered` when the app is not registered.
> Where a sign-in is not on, the provider's other
> [ways to connect](/docs/concepts/glossary#ways-to-connect) apply.

Most connections are **use-once**: a [rail](/docs/connect-flow/overview#the-four-rails)
writes the desired records a single time and the platform keeps no credential.
**Managed mode** is the exception. When a connection is managed, the
control plane holds a durable, **encrypted** grant for the customer's DNS
provider, so it can re-apply or revert the records **server side, without
asking the customer to consent again**. The grant comes from one of two rails:

* the **async one-click** protocol (the async variant of Domain Connect), for a
  provider that has onboarded us;
* a **provider sign-in** whose credential is kept: the token, key pair or
  consumer key a sign-in handed over, for a provider listed in
  `OAUTH_MANAGED_PROVIDERS` (see [Managed sign-in grants](#managed-sign-in-grants)).

## What "managed" means

* **Only through a consent.** A connection becomes managed only when the
  customer completes one of the two consents:
  * the **async one-click** consent: start it with
    `POST /v1/connections/{id}/domainconnect:start` with `{"managed": true}`
    (equivalently `{"rail":"async"}`). When the provider supports the async
    variant, the response is an async consent URL; otherwise the call falls
    through to the ordinary sync `apply_url` and the connection stays use-once;
  * a **sign-in** at a provider the deployment keeps grants for: start it with
    `POST /v1/connections/{id}/oauth:start`. Its response says `managed: true`
    ahead of time, and so does the domain check's `rails.oauth`. This is the
    default for those providers: the console's **Keep this sign-in** box starts
    ticked (untick it, or send `"access": "one_time"`, to use the sign-in once),
    and the widget keeps the sign-in and tells the customer so.
* **Encrypted grant.** The async callback (`GET /v1/connect/dc/callback`)
  exchanges the code for a durable grant against the provider API base that
  was pinned into the signed `state` at start (never re-derived from fresh
  discovery). The sign-in callback (`GET /v1/connect/oauth/callback`) keeps
  what the provider handed over. Both are stored encrypted at rest
  (AES-256-GCM with `GRANT_ENCRYPTION_KEY`; in the v2 format each token is also
  bound to its workspace and connection, see
  [Encryption keys](/docs/security/overview#encryption-keys-self-hosted)), one
  grant per connection. The
  browser token of the sync one-click rail is *not* a grant, and neither is a
  sign-in at a provider that is not listed: both are discarded right after the
  write.
* **Silent re-apply.** With the grant in hand the platform can recompute the
  connection's authoritative desired records and push any drift to the
  provider on demand, with no customer interaction.
* **Named on the domain's page.** `reach.kind` on `GET /v1/connections/{id}` says
  which access is kept (`account`, `sign_in`, `domain_connect`, `dns_hosting`,
  `route53_role` or `none`), and the console's **How CustomDomain™ reaches this
  domain** card says it in a line: &#x2A;*Kept by your IONOS account "Acme"**, **Kept by your
  Cloudflare sign-in**, **Kept by your one-click setup at GoDaddy**, **Through DNS
  hosting**, **Kept by your AWS access role** or **Not kept**. A domain reads **Managed**
  in the console whenever any of them is kept, not only a stored grant.
* **Auto-heal-ready.** Because re-apply is a single server-side call, managed
  connections are the substrate for automatic drift healing: when
  [Monitor](/docs/webhooks/overview#event-catalog) reports that a record was
  removed or repointed, a managed connection can be pushed back to its desired
  state instead of only emitting a `domain.record_missing` alert.

The [connection detail](/docs/connect-flow/create-a-connection#response) response
carries a `managed` boolean so your dashboard can tell the two kinds apart
(`false` is omitted on the wire). You *enable* managed mode by running one of
the consents above. You can turn it back **off** in two ways: keep the domain
connected but drop the grant with [disable managed](#disabling-managed-mode)
(`DELETE /v1/connections/{id}/grant`), or take the domain off entirely by
[disconnecting](/docs/connect-flow/offboarding) it.

## Managed sign-in grants

`OAUTH_MANAGED_PROVIDERS` is a comma list of provider ids (empty by default,
which keeps every sign-in one-time). The hosted service lists Cloudflare,
DigitalOcean, DNSimple, Linode, Netlify, OVHcloud, Porkbun, Vercel and
WordPress.com (OVHcloud's consent is not switched on there yet). For a listed provider, the credential a
[sign-in](/docs/dns/providers#one-click-nothing-to-paste) obtained is kept as
the connection's encrypted grant, in the same grant store the async one-click
rail uses, and the connection is marked `managed`. Without
`GRANT_ENCRYPTION_KEY` the store refuses the grant and the connect stays
one-time.

What each provider hands over, and how it stays usable:

| Provider                                                                                                                                  | Sign-in              | What is kept                                                                                                                                                                                 | How it stays valid                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| ----------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Cloudflare                                                                                                                                | `oauth2`             | access token, refresh token and expiry                                                                                                                                                       | refresh token rotation; `offline_access` is requested only for a managed connect                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| DigitalOcean, Akamai Cloud (Linode)                                                                                                       | `oauth2`             | access token, plus the refresh token and expiry when the provider returns them                                                                                                               | refresh token rotation                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| Google Cloud DNS                                                                                                                          | `oauth2`             | access token, the refresh token Google issues because a managed connect asks for offline access, its expiry, and the Google Cloud project that holds the zone                                | on the hosted service and on any deployment that registered its own Google client; until Google finishes reviewing the app, Google may show a notice that it has not verified the app yet. The access token is refreshed hourly and the refresh token stays the same; while the app is in Google's Testing status a refresh token expires after seven days, so the connection then needs a new sign-in. Revoking a Google sign-in ends the account's whole consent, so a disconnect revokes it only when no other connection keeps a sign-in of the same Google account |
| Azure DNS                                                                                                                                 | `oauth2`             | access token, the refresh token Microsoft issues because a managed connect asks for the `offline_access` scope, its expiry, and the Azure subscription and resource group that hold the zone | refresh token rotation, the new refresh token saved before use. Microsoft publishes no revocation endpoint, so a disconnect removes the records and drops the grant but cannot revoke it. A sign in without multifactor authentication cannot make changes, so such a connection needs a new sign-in                                                                                                                                                                                                                                                                    |
| DNSimple, Netlify, WordPress.com                                                                                                          | `oauth2`             | a long-lived access token                                                                                                                                                                    | nothing to rotate                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| Vercel                                                                                                                                    | `vercel_integration` | the integration's token and its team                                                                                                                                                         | nothing to rotate; keep the integration installed on that team                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| Porkbun                                                                                                                                   | `porkbun_handoff`    | the approved API key pair                                                                                                                                                                    | nothing to rotate; keep the key named CustomDomain™                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| OVHcloud                                                                                                                                  | `ovh_consent`        | the consumer key and its region (the application secret stays in the deployment's environment)                                                                                               | the validity chosen on OVHcloud's page, so choose **Unlimited**                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| Dynadot, Hostinger, Unstoppable Domains, XServer Domain, Namecheap, Spaceship, Muumuu Domain (only when listed in `MCP_SIGNIN_PROVIDERS`) | `mcp_oauth`          | access and refresh token of the client the control plane registered with the provider, and which client that is                                                                              | refresh token rotation through that client; Hostinger accepts a refresh only until about five and a half hours after the access token expires, so a kept Hostinger sign-in left idle longer needs a new sign-in. Namecheap publishes no revocation endpoint, so a disconnect removes the records but cannot revoke a kept Namecheap sign-in                                                                                                                                                                                                                             |

On the hosted service, the Azure DNS, Dynadot and Unstoppable Domains
sign-ins are on but are not in `OAUTH_MANAGED_PROVIDERS`, so they are used
once. Sign in with Google Cloud is on as well. OVHcloud's consent and the other `mcp_oauth` sign-ins are not switched on. Their rows describe what a deployment that keeps them
stores. Whether the DigitalOcean, Linode and Azure DNS sign-ins are on at a given
moment is the per provider confirmation in the note at the top of this page.

* **Re-apply.** `POST /v1/connections/{id}:reapply` (and the fleet reconciler)
  rewrites the records through the provider's DNS API with the kept credential,
  into the zone the sign-in wrote. It leaves out records a one-click start stored
  that still await the DNS provider's approval (see
  [Template variables](/docs/connect-flow/verify-and-go-live#template-variables)):
  nobody approved them yet.
* **Refresh rotation.** An access token at or past its expiry is rotated with
  its refresh token before the write, and a token the provider refuses is
  rotated once and the write retried. Cloudflare invalidates a refresh token as
  soon as it is used, so rotation is single flight per grant (in process, and
  through a lease in the shared store when `REDIS_URL` is set), and the rotated
  pair is saved before it is used: a rotated refresh token is never lost.
* **`oauth_reconnect_needed`.** When the provider no longer accepts the kept
  sign-in (a `401` or `403` that survives a rotation, or a refused refresh
  token), the grant is dropped, the connection leaves managed mode with
  `error_code` `oauth_reconnect_needed`, and `connection.reapply.failed` is
  emitted. `:reapply` then answers `409` until the customer signs in again: a
  new sign-in stores a fresh grant, clears the code and makes the connection
  managed again. The console's domain page says the sign-in stopped working
  and offers the sign-in button right there.
* **Disconnect revokes.** `DELETE /v1/connections/{id}` deletes the records the
  grant wrote (never a merged SPF `TXT` record, which carries the customer's own
  mechanisms), unless another connection still serves the domain, then
  withdraws the credential at the provider where the provider allows it:
  tokens are revoked at the provider's revocation endpoint and an OVHcloud
  consumer key is expired. Porkbun has no API to delete a key, and a Vercel
  token is the integration installation itself (shared by every domain of that
  team), so neither is touched: remove them at the provider if you want them
  gone.

To make an existing connection managed through its sign-in, run the sign-in
again: `POST /v1/connections/{id}/oauth:start` (with `region` for OVHcloud),
then let the customer approve. In the console, **Make this domain managed** on the
domain's **How CustomDomain™ reaches this domain** card opens that sign-in instead
of the async one-click consent. The card shows the button only where nothing is kept
yet and it can run: the provider signs in, or the deployment can offer one-click setup
for it (`managed_possible` in the `reach` that `GET /v1/connections/{id}` answers).

## Re-apply on demand

```bash
curl -X POST https://api.customdomain.ai/v1/connections/<ID>:reapply \
  -H "Authorization: Bearer <WIDGET_JWT>"
# → 200 { "connection_id": "<ID>", "reapplied": true, "records": [ … ] }
#   (records re-written at the provider; connection re-verified)
```

The `200` body is a re-apply **receipt**, not a connection object: it carries
`connection_id` (not `id`) plus the record set that was written. Fetch
`GET /v1/connections/{id}` if you need the connection itself.

`POST /v1/connections/{id}:reapply` needs access we keep: a stored grant (managed
mode), a continuous [connected account](/docs/connect-flow/provider-accounts)'s key, or
our own hosted zone ([DNS hosting](/docs/dns/managed-dns)). It recomputes the desired
record set, writes any drift through that access, re-verifies the connection, and
emits `connection.records.updated&#x60;. If the connection has none of them (it is not
managed, has no stored grant and no account behind it), it responds &#x2A;*`409`**. The
console's **Re-apply now** shows on the domain's card for any of these, whenever
`reach.can_reapply` is true.

If the push fails it emits `connection.reapply.failed`. When the provider reports
the grant was **revoked** (a `401` kill-switch: the customer withdrew consent at
their provider), the platform clears the stored grant and flags the connection for
re-consent; the next `:reapply` returns `409` until the customer authorizes
again. For a sign-in grant that flag is `error_code` `oauth_reconnect_needed`
(see [Managed sign-in grants](#managed-sign-in-grants)).

## Managed lifecycle events

Subscribe to these on a [webhook endpoint](/docs/webhooks/overview) to track the
managed lifecycle:

| Event                        | Fires when                                                                                                                                     |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `connection.records.updated` | A `:reapply` succeeded: the desired records were (re-)written and the connection re-verified.                                                  |
| `connection.reapply.failed`  | A `:reapply` push failed; if the grant was revoked, the connection is flagged for re-consent (`oauth_reconnect_needed` for a sign-in grant).   |
| `connection.disconnected`    | The connection was [disconnected](/docs/connect-flow/offboarding); for a managed connection the records were reverted through the grant first. |

## Disabling managed mode

To stop managing a connection **without disconnecting it**, drop the stored grant:

```bash
curl -X DELETE https://api.customdomain.ai/v1/connections/<ID>/grant \
  -H "Authorization: Bearer <WIDGET_JWT>"
# → 200 { "connection_id": "<ID>", "managed": false }
```

`DELETE /v1/connections/{id}/grant` deletes the encrypted grant and clears the
connection's `managed` flag, **leaving the connection and its applied records in
place**. A sign-in grant is also withdrawn at its provider first, the same way a
disconnect withdraws it. The records the provider already wrote stay live; the
platform simply stops holding a credential to re-apply or revert them, so the
connection reverts to the default use-once posture. A later consent can
re-enable managed mode.

This is deliberately **not** `DELETE /v1/connections/{id}`: that path reverts the
records at the provider and removes the whole connection (see below). Here nothing
is reverted and the connection survives. The call is **idempotent*&#x2A;: a connection
that is already unmanaged returns &#x2A;*`200`** with `managed: false`. It does not emit
a webhook event.

## Ending a managed connection

To take a managed domain off your product, [disconnect it](/docs/connect-flow/offboarding)
with `DELETE /v1/connections/{id}`. For a managed connection the platform first
**reverts** the records through the stored grant (an async one-click grant
reverts the applied template; a sign-in grant deletes the records it wrote and
is then revoked, see above), then deletes the connection and all its child state
(records, origin, monitor watch, and the grant itself), and emits
`connection.disconnected`.
