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 inOAUTH_MANAGED_PROVIDERSand to setGRANT_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,managedstaysfalse, and:reapplyand the MCPreapply-connectiontool return409: drift is reported, not healed. Removing a domain does not need a grant, sodisconnect-domainworks 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.oauthonPOST /v1/domains:check:availablesays whether a sign-in runs for that domain, andreasonisoauth_client_not_registeredwhen 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:startwith{"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 syncapply_urland 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 saysmanaged: trueahead of time, and so does the domain check'srails.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.
- the async one-click consent: start it with
- 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 signedstateat 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 withGRANT_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.kindonGET /v1/connections/{id}says which access is kept (account,sign_in,domain_connect,dns_hosting,route53_roleornone), 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_missingalert.
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:
| 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): 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_URLis 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 (a401or403that survives a rotation, or a refused refresh token), the grant is dropped, the connection leaves managed mode witherror_codeoauth_reconnect_needed, andconnection.reapply.failedis emitted.:reapplythen answers409until 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 SPFTXTrecord, 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:
| 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; 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.
Domain forwarding
Redirect a connected domain to another URL, or send the apex to www, with a 301 issued at the edge that keeps the path and query and never contacts your origin.
Provider accounts
Connect a DNS provider or registrar account once, for some or all of its domains, with one time or continuous access, see who on your team can manage it, and find every change in the audit log.