Skip to content
DomainKit
Esc
↑↓navigate↵open⌘Jpreview
On this page

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:

  1. The callback URL the provider redirects to matches the one registered. It is the mount path plus /callback/:provider. Behind an edge that rewrites Host, set callbackBaseUrl and use that same value at the provider.
  2. Identity reads a cookie. The callback is a top-level browser navigation, so an Authorization header scheme yields no principal there and the flow fails at the last step.
  3. 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. nextCheckAt already 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. Set Verify.Policy.quorum to all when your product needs agreement.
  • mismatch. A constraint is violated: an exclusive requirement met another record at the same name and type, or a DnsRecord.spf requirement 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. Check ResolverFailed in your logs and whether the runtime can reach cloudflare-dns.com and dns.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.

Last updated on

Was this page helpful?