Test mode
Run the widget, the API and your webhooks end to end on made up .test names, before a customer has a domain. Nothing is looked up, served, billed or counted.
Test mode lets you build and check your whole integration before a customer has a
domain, and before you have added a card. A test mode application runs the real
connect flow: the widget's screens, the API calls and the webhooks. The one thing it
makes up is DNS. It connects names that end in .test, decides how each one ends,
and counts for nothing. It does not use up your free domains or your plan's allowance,
it is never billed, and nothing is looked up, written to a DNS provider or served.
Use it to:
- see the widget in your product, with your look, from the first screen to Live;
- write and check your webhook receiver against real, signed deliveries;
- try each ending a customer can reach: live, a name another account already has, and DNS that never shows up;
- run the same checks in CI or a preview environment with no real domain.
It does not try a DNS provider's sign in, real DNS, certificates or hosting. Those need a real domain in a live application.
Make a test mode application
In the console, each of these makes one, switches the console to it, and needs no card:
- Try test mode in the application switcher.
- Applications, then New application, then Test mode.
- Try test mode, next to Try the widget with your own domain on the go-live list on Overview.
- Try it in test mode, on the Preview tab of Embed.
With the API, send "sandbox": true when you create the application:
curl -X POST https://api.customdomain.ai/v1/applications \
-H "Authorization: Bearer <API_KEY>" \
-H "Content-Type: application/json" \
-d '{"name":"Test mode","sandbox":true}'
# → 201 { "id": "app_…", "sandbox": true, "environment": "development", "client_secret": "…", … }sandboxis fixed when the application is made and never changes. Leaveenvironmentout: a test mode application is always a development application.- A workspace holds up to 3 test mode applications, and each holds up to 100 test
domains. Past that, the answer is
409 sandbox_limit. - It has its own
client_secret, andPOST /v1/applications/{id}/keysmakes its API keys. Neither needs a card, because nothing a test mode application holds can reach anything that costs money or serves a site. - A key's prefix does not tell you the mode. A test mode application's keys start
with
cd_test_, and so does any key made for a staging or development environment, which works on real domains like acd_live_key does. The application'ssandboxfield is what decides:GET /v1/applicationsandGET /v1/applications/{id}carry"sandbox": truefor a test mode application and no field for a live one.
Run the widget on it
Mint a widget token with the test application's client_secret, the way you do for
any application (Widget tokens), and open the
widget with it. The token carries a signed sandbox: true, and the widget:
- shows a Test mode strip across the top of the modal;
- asks for a
.testname, withwww.acme.testas the example, and says what to type when someone enters a real one; - goes straight to the manual setup, which says there is nothing to add: a simulated DNS finds the records;
- leaves out the ways to hand the setup to someone else and hosted DNS, which need a real domain.
All of those words are translated into every language the widget speaks. The widget does not decide any of it. It reads the signed claim to know what to say, and the control plane enforces everything on every call.
In the console, the same widget opens from the Preview tab of Embed, and Connect a test domain on Domains connects a name without the widget.
The name picks the ending
There is nothing to switch. The first part of the name decides how the run ends, and the simulated DNS keeps time from when the domain was added or last re-checked.
| You connect | What happens |
|---|---|
www.acme.test, or any name that does not start with conflict or timeout | About 15 seconds after it is added, the simulated DNS shows its records and it goes live. connection.live fires. |
conflict.acme.test | It is pending at once with domain_already_connected, the way a name another account already has connected would be, and it never goes live. |
timeout.acme.test | Its records never appear. After 60 seconds it fails with propagation_timeout and connection.failed fires. Re-check starts a new run, which ends the same way. |
The first part has to be exactly conflict or timeout: conflict-2.acme.test goes
live. A name that does not end in .test is refused with 400 sandbox_domain_required
in a test mode application, and a .test name in a live application with
400 sandbox_domain_reserved, so the two never mix. .test is reserved by the
internet standards for testing and can never be a real domain, so a test domain cannot
collide with a customer's.
The widget keeps showing the records as not found for a timeout name. The failure is
on the connection, which you read with GET /v1/connections/{id} or in the
connection.failed event, the same way you would for a real domain that never
resolved.
What the API does in test mode
The calls are the ones you already use, with the same bodies, errors and statuses:
# 1. Mint a token for the test application (your server, as always).
curl -X POST https://api.customdomain.ai/v1/tokens \
-H "Content-Type: application/json" \
-d '{"application_id":"<TEST_APP_ID>","client_secret":"<TEST_CLIENT_SECRET>","domain":"www.acme.test"}'
# 2. Connect a name, with that token or with a key of the test application.
curl -X POST https://api.customdomain.ai/v1/connections \
-H "Authorization: Bearer <TEST_APP_KEY>" \
-H "Content-Type: application/json" \
-d '{"domain":"www.acme.test"}'
# → 201 { "id": "…", "status": "pending", "sandbox": true, "records": [ … ], … }
# 3. About 15 seconds later.
curl https://api.customdomain.ai/v1/connections/<ID> -H "Authorization: Bearer <TEST_APP_KEY>"
# → { "status": "live", "sandbox": true, … }POST /v1/domains:checkanswers with the manual rail only,"sandbox": true, and the reasontest_modeon the rails that need a real provider. Nothing is detected.- A connection has
"sandbox": true. It is copied from the application when the connection is made and never changes.application_url, if you send one, is accepted and ignored: there is nothing to serve. - The records are the ones a live domain would be shown, so your own screens that print them can be checked as they are. They are never looked up.
DELETE /v1/connections/{id}removes a test domain at once, withconnection.disconnected. Deleting a test mode application removes its test domains with it.GET /v1/connections:lookup?domain=www.acme.testfinds a test domain by its exact name, with a key or a token of the test application, or with a workspace key.
What a test credential can reach
A key or widget token of a test mode application can run the connect flow and read its
own webhooks, and nothing else: domains:check, connections (create, list, read,
records, diagnose, recheck, remove, lookup), webhooks and webhook-deliveries,
tokens, the catalogs the widget reads (plans, providers, templates, config),
and GET /v1/applications, which lists only its own application. Every other route
answers 403 sandbox_not_allowed before it does anything, including billing, members,
the audit log, SSL, Power, sign in with a DNS provider, bulk creation, sharing, domain
purchases and agents. A test credential never reaches a live domain of the same
workspace, which answers as if it were someone else's.
That also means a test key left in a repository, a CI log or a screenshot is worth nothing.
The wall works the other way too. A test domain is served only on those same routes, whoever asks, so a workspace key cannot start SSL, Power or a provider sign in on a name that has no DNS. And an AI agent cannot be given a test mode application: consent and grants are for live applications.
Webhooks
A test mode application has its own endpoints, made with its own key or token, and its
events reach only those. Every event about a test domain carries "sandbox": true; a
live event carries no such field (Webhooks). They are signed
and retried like any other, so your receiver can be checked end to end. A receiver that
writes to a real customer's account should read the field and skip them.
GET /v1/webhooks with a workspace key leaves a test mode application's endpoints out
unless you pass ?app_id= naming it, so a test endpoint is never mistaken for a live
one.
What it does not count
A test domain is left out of everything that counts, bills or serves real domains:
- your free domains, your trial, your plan's allowance and your bill, and usage metering;
- the workspace's totals and every list that covers all of your applications. Domains and Activity show a test application's rows only while the console is switched to it, marked Test mode, and they are never in a total;
- notifications and email: a test domain going live rings no bell, sends no push and sends no email;
- the edge, certificates, monitoring and DNS provider accounts: none of them has anything to do for a made up name, and a test domain has no Edge, Certificate, History or Traffic view, only Overview and DNS;
- the go-live checklist rows: a token minted for a test application does not count as your server minting a real one, and a test domain is not a customer.
Move to a real domain
A test mode application is a place to rehearse, so nothing carries over. When you are
ready, switch the console to your live application (the application switcher), mint
tokens with its client secret, and connect a real name. Your server code does not change
apart from the credentials. If you read sandbox on webhook events, real events simply
do not carry it.
Choosing a custom-domain solution (2026)
An evaluation guide for custom-domain onboarding: build vs. buy, what "provider coverage" really means, and the questions to ask any vendor including us.
Notifications
What CustomDomain™ tells you about, in the console and on your phone or computer, who gets it, and how to turn each part off.