CustomDomain Docs

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.

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

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

Most connections are use-once: a rail 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).

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), 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: 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 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 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 (DELETE /v1/connections/{id}/grant), or take the domain off entirely by disconnecting 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 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:

ProviderSign-inWhat is keptHow it stays valid
Cloudflareoauth2access token, refresh token and expiryrefresh token rotation; offline_access is requested only for a managed connect
DigitalOcean, Akamai Cloud (Linode)oauth2access token, plus the refresh token and expiry when the provider returns themrefresh token rotation
Google Cloud DNSoauth2access token, the refresh token Google issues because a managed connect asks for offline access, its expiry, and the Google Cloud project that holds the zoneon 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 DNSoauth2access 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 zonerefresh 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.comoauth2a long-lived access tokennothing to rotate
Vercelvercel_integrationthe integration's token and its teamnothing to rotate; keep the integration installed on that team
Porkbunporkbun_handoffthe approved API key pairnothing to rotate; keep the key named CustomDomain™
OVHcloudovh_consentthe 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_oauthaccess and refresh token of the client the control plane registered with the provider, and which client that isrefresh 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): 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

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's key, or our own hosted zone (DNS hosting). It recomputes the desired record set, writes any drift through that access, re-verifies the connection, and emits connection.records.updated. If the connection has none of them (it is not managed, has no stored grant and no account behind it), it responds 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 lifecycle events

Subscribe to these on a webhook endpoint to track the managed lifecycle:

EventFires when
connection.records.updatedA :reapply succeeded: the desired records were (re-)written and the connection re-verified.
connection.reapply.failedA :reapply push failed; if the grant was revoked, the connection is flagged for re-consent (oauth_reconnect_needed for a sign-in grant).
connection.disconnectedThe connection was disconnected; 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:

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: a connection that is already unmanaged returns 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 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.

On this page