Troubleshooting
What each failure means in practice, why an interactive connection stalls, why DNS never becomes ready, and the checks that usually explain both.
This page helps you diagnose a DomainKit integration that fails to apply a plan, loses a connection,
never finishes an interactive connection, or never reports DNS as ready. Every failure is a
DomainKit.Error carrying one reason. Failure reasons is the full table; this
page is what to do when one of them keeps happening.
The plan will not apply
Conflict. The zone already holds something incompatible. The reason carries the conflicting
operations, each with the requirement and what was found there. Show them: cname-collision,
exclusive-name, and spf-conflict mean a record has to move before the plan can run, and opaque means the provider
reports something at that name DomainKit will not touch. A cleanup plan conflicts differently:
missing and value-mismatch say the record a receipt names is gone or has been edited, and
cleanup leaves it alone.
Approve the rest with allowPartial and a conflict-free selection when the customer wants to make
progress before resolving the rest.
Stale. Provider state moved between planning and applying. Build a new plan and show it again;
the customer’s consent was for a digest that no longer describes the zone.
Expired. The plan or approval aged out. Plans live one hour by default; Provision.Policy
changes that.
Busy. Another apply holds the attempt lease, or another request holds the refresh lock. It is
retryable and usually resolves within the lease, two minutes by default.
The connection stops working
Reconnect. The provider will no longer refresh the credential. Nothing you can retry: the
customer connects the provider again. Connect.inspect reports the same thing as
status: "reconnect" so a settings screen can show it before an action fails.
Unauthenticated from a provider. The token was revoked or rotated at the provider, or the
customer pasted the wrong value. A token the provider rejects while connecting reads the same way
whatever the provider’s status: Cloudflare answers a malformed token with HTTP 400 code 6003 or
6111 and a rejected one with 401 or 403 from its verify endpoint, and Vercel answers 403. The reason
carries the provider’s own message, and the server answers 401.
Forbidden. The credential is valid and lacks the permission. A Cloudflare token needs
permission to read zones and to read and write DNS records (zone.read, dns.read, and dns.write
for OAuth scopes); check the token’s permissions and, for an account-owned token, that
the account id matches.
The interactive connection never completes
Check three things in order:
- The callback URL the provider redirects to matches the one registered. It is the mount path
plus
/callback/:provider. Behind an edge that rewritesHost, setcallbackBaseUrland use that same value at the provider. Identityreads a cookie. The callback is a top-level browser navigation, so anAuthorizationheader scheme yields no principal there and the flow fails at the last step.- The continuation is still alive. They live fifteen minutes by default, and they are spent
exactly once, so a replayed callback fails
NotFound.
A callback that lands somewhere unexpected is defaultReturnTo: a returnTo that does not resolve
to the callback’s own origin is refused rather than followed.
DNS never becomes ready
Read the evidence rather than the overall status. Each requirement carries one entry per source,
with the values that source returned and a detail explaining a status that is not satisfied.
- Provider satisfied, public DNS missing. Propagation, or a resolver caching the negative
answer.
nextCheckAtalready schedules the next look; the ladder is 15 seconds, 1 minute, 5 minutes, 30 minutes. - One resolver satisfied, another missing. The default quorum is
any, so this is still satisfied. SetVerify.Policy.quorumtoallwhen your product needs agreement. mismatch. A constraint is violated: an exclusive requirement met another record at the same name and type, or aDnsRecord.spfrequirement met a different SPF record or more than one. That outranks agreement anywhere else, and it needs a DNS change, not another check.- Everything
unknown. No source answered. CheckResolverFailedin your logs and whether the runtime can reachcloudflare-dns.comanddns.google.
Nameserver delegation is the usual root cause: a domain whose nameservers do not point at the connected zone will read correctly at the provider and never appear in public DNS.
Storage and keys
StorageFailed. The database call failed. It is retryable, and the message names the operation.
CryptoFailed. Sealing or opening a credential failed. On open, the usual cause is a custody
key that changed: stored ciphertext is bound to the key that sealed it, so rotating the key means
re-sealing.
A row that should exist reads as NotFound. Every query is scoped by the request’s Principal.
A row belonging to another tenant is absent, not forbidden, so check the ownerId your Identity
returned before looking at the database.
The UI renders less than you expect
Domain.useFlow reports only the capability groups the transport declares, so a surface written on
flow.capabilities offers only what the server can serve. If the plan action or the disconnect
option is missing, the transport was built with fewer capabilities than the server mounts, or those
routes are not mounted at all. Transport.capabilities(transport) reports what it
has, and DomainKit.useCapabilities() reports the same inside the tree.
Something answered that DomainKit did not send
A reply the transport cannot read as a DomainKit.Error becomes a ProviderUnavailable or
ProviderRejected naming the transport’s base URL. That means something in front of the server
answered: a proxy, a login page, a maintenance window. Check the raw response before looking at
DomainKit.