Widget tokens
Mint short-lived widget JWTs with POST /v1/tokens, choose between the connect and app scopes, and keep a long browser session alive without exposing an API key.
The widget must never see an API key or an application client_secret. Your
backend mints a short-lived JWT and hands only that to the browser. There are
two ways to mint it, and which one applies depends on how the application was
created:
| How the application was created | You have | Mint with |
|---|---|---|
| You signed up in the console (most customers; every console signup gets an application) | An API key from API & SDK. The console never shows you the application's client secret. | POST https://app.customdomain.ai/api/widget-token with Authorization: Bearer <API key> |
The application was created over the API (POST /v1/applications, or tenants:provision) | The client_secret in that creation response | POST /v1/tokens with the secret in the body |
The Install tab on the console's Widget page generates code for both. Its Authenticate with switch defaults to API key.
An API key and a client secret are different credentials. Do not swap them:
| API key | Application client secret | |
|---|---|---|
| Looks like | cd_live_ or cd_test_ plus 32 hex characters (older keys: sk_live_ / sk_test_) | sk_ plus 32 hex characters |
| Where you get it | API & SDK: the workspace's default key at the top (owners can view it any time), or Create key (admins and owners; shown once). | The response of POST /v1/applications (shown once). A console-created application never shows it. |
| Sent as | Authorization: Bearer … | client_secret in the body of POST /v1/tokens |
| What it does | Calls the API for your whole workspace | Mints widget tokens for one application, nothing else |
Never put an API key in the client_secret field: POST /v1/tokens always
answers 401 with unknown application or secret. See
Troubleshooting.
With an API key (console accounts)
Create a key on the console's API & SDK page with Create key, keep it in an environment variable on your server, and call the console's token endpoint:
curl -X POST https://app.customdomain.ai/api/widget-token \
-H "Authorization: Bearer $CUSTOMDOMAIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "application_id": "app_...", "domain": "app.customer.com" }'
# → 200 { "application_id": "app_...", "token": "eyJ…", "expires_in": 900, "token_type": "Bearer" }application_idis optional and defaults to your account's first application.domainis optional; pass it to bind the token to one hostname.- The API key stays on your server. Only the 15 minute token reaches the browser.
- This endpoint mints the default
connectscope only. It takes noscopeorend_user_ref. For anappscope token, usePOST /v1/tokenswith a client secret. - Creating a key needs a card on file (the workspace's 14 day free trial; nothing
is charged until it ends). Without one, Create key and
POST /v1/applications/{id}/keysanswer402 billing_required: an owner or admin starts the trial under Billing. The workspace's default key needs no card, and neither do your first ten domains.
| Status | error | Meaning |
|---|---|---|
401 | unauthorized | The Authorization: Bearer key is missing, revoked or not accepted. |
403 | no_application | The key's workspace has no applications. |
403 | forbidden | application_id belongs to another workspace. |
503 | no_widget_credentials | The application was not created in the console, so the console has no secret for it. Mint with POST /v1/tokens and that application's client secret instead. |
500 | unseal_failed | The console could not open its stored copy of the secret. Try again, then contact support. |
502 | mint_failed | The token service did not return a token. Try again shortly. |
The console keeps each console-created application's client secret sealed and
uses it for this endpoint, the widget preview and Try it live. Do not
rotate that secret (POST /v1/applications/{id}/client-secret:rotate): the
console's copy is not updated, so all three stop working for that application.
With the client secret (/v1/tokens)
For an application you created over the API, exchange its client_secret for a
token:
curl -X POST https://api.customdomain.ai/v1/tokens \
-H "Content-Type: application/json" \
-d '{
"application_id": "<APP_ID>",
"client_secret": "<CLIENT_SECRET>",
"domain": "app.customer.com"
}'
# → 201 { "token": "eyJ…", "token_type": "Bearer", "expires_in": 900, "scope": "connect" }POST /v1/tokensis not bearer-authenticated. It authenticates by theclient_secretin the body, which is why it must run server-side only. A wrong or unknown secret is401.domainbinds the token to one hostname: a domain-bound token may only act on that host.domains(a list) allows several hosts in one session. Two things need the binding: the secure sign-in (POST /v1/connections/{id}/browser:start) and removing the domain take a token bound to the domain and, when a customer connected it, to that customer'send_user_ref. The secure sign-in refuses a token bound to no domain, or only to other domains, with403 token_not_bound(its domain check says the same inrails.cloud_browser.reason, so the widget never offers a sign-in it cannot start), and a token for a domain another customer connected with403 token_wrong_end_user. Removing the domain, and reading its monitoring, services or timeline, answer403 forbiddento the same tokens, which the widget reports asSessionError.end_user_ref(optional) is your own id for the end user the token is for. It is stored on the connection, shown in the console, sent with every webhook asuser_id, and owns any API key the secure sign-in makes for that user. Ids that start withconsole:are reserved: the console's own Try it live mints its tokens asconsole:<member id>, and the console never counts such a connection as a customer. Do not start your ids with it.end_user_email(optional) is the end user's address for end user notices. It is never put in the token; a connection created with the token takes it, unless the connection already has an address (the stored one stays) or belongs to another end user than this token'send_user_ref(it stores none).- A token acts only on its own customer's connections. A connection that names a
customer is the token's when that is the token's
end_user_ref. Another customer's token, or a token with noend_user_refwhen the connection names one, answers403 token_wrong_end_useron creating a connection for a domain that customer connected, on every other route that takes the connection's id (reading it, its records, certificate, diagnosis, SSL and Power; applying records; starting a sign in, Domain Connect or the Route 53 role; rechecking, re-applying, turning off its managed mode withDELETE /v1/connections/{id}/grant), and on every managed DNS and nameserver move route. Removing the domain, and reading its monitoring, services or timeline, keep their own rule and answer403 forbidden. A connection that names no customer (you made it with your API key) is any token's on those routes. For managed DNS and nameserver moves it is a token's only when its domain is one the token is bound to (domainordomains); a token with noend_user_refand nodomaincounts only the domain of the connection the call is about, and a token with anend_user_refand nodomainowns none of those. If you create connections with anend_user_ref, mint each widget token with the sameend_user_refand thedomainit is for. A token with noend_user_refnow gets403on a connection that names one, including a connection that token created by sendingend_user_refin the request body. Share links keep working, under one rule (see Email these instructions to someone else): a share link's token owns exactly what the token that made it owned, and may touch only the shared domains. It acts as the end user who shared it, for the connections that end user owned: a link made by a token that names no customer and is bound to nodomaincounts only the one connection a call is about as its own, so a zone that links several of the shared domains is not its own, and a link made by a token that names a customer and is bound to nodomainowns none of your connections that name no customer. It reaches only the shared domains, so a customer who is bound to nodomainand shares one of their domains gives a link for that domain and none of their others. A widget token can share only a domain whose connection it owns by the rules above (otherwise403 token_wrong_end_userand no link; a domain with no connection yet can be shared). A share link sets its domain up as its sharer could (the secure sign-in, managed DNS, a nameserver move, the monitoring, services and timeline reads). It cannot remove the domain (DELETE,:disconnectand the sign-in to remove answer403 forbidden, whether or not the connection names a customer), cannot set the customer's contact address on any connection, cannot renew the customer's key (a sign-in in reconnect mode answers403 forbidden), is never offered the customer's saved keys, and a key made at a sign-in it starts is not the customer's own: it never replaces a key the customer already keeps, and it is never offered to the customer on another domain. A reconnect link is the customer's own and its token stays the customer, with two changes: one whose key was replaced by another, or whose connection is gone, now answers404like an expired link (it used to open the plain flow), and a sign-in in reconnect mode that is not the customer's own session on a key the customer's own sign-in made (a reconnect link on a connection that names no customer, an API key without the console's signed forward, the console) is refused with403 forbiddenbefore it starts. - Only the creation response shows a client secret. If you lost it, rotate it
(
POST /v1/applications/{id}/client-secret:rotate, needskeys:manage), except for a console-created application (see above).
Lifetime
- The default lifetime is 15 minutes (
expires_inis in seconds). Adding DNS records by hand often takes longer than that, so give the widget a way to get a fresh token. See Keeping a session alive.
Scopes
The optional scope field decides what the token may do. Leave it out for the
token you hand to an end user.
| Scope | Who holds it | What it can do |
|---|---|---|
connect (default) | Your end user's browser | Run the connect flow for a domain: check it, create the connection, read its records and status, diagnose its DNS, apply records, and start sign-in or one-click setup. |
app | Your server, or an admin screen only your team can reach | Everything connect can, plus acting as the whole application: listing every connection, webhooks, SSL certificates, Power, billing usage, the audit log, registrar orders and direct purchases, and bulk creation. |
A connect token that calls an application-wide endpoint gets 403. Mint an
app token (a POST /v1/tokens body, so it needs the client secret), or use an
API key, for those calls:
{ "application_id": "<APP_ID>", "client_secret": "<CLIENT_SECRET>", "scope": "app" }Never hand an app token to an end user. It can read every other customer's
domains in the application.
What a widget token can do
A widget JWT is accepted only on the browser-facing Connect endpoints:
POST /v1/domains:check, POST /v1/connections, GET /v1/connections/{id},
GET /v1/connections/{id}/records, GET /v1/connections/{id}/diagnose,
POST /v1/connections/{id}:recheck, the three apply rails (/apply,
/oauth:start, /domainconnect:start), POST /v1/monitor:check, and the
registrar search, quote and checkout endpoints. With scope app it also reaches
the application-wide endpoints listed above. No widget token can create
applications, manage keys, or manage members: those need an API key or the
management key.
Tokens for a test mode application
A token minted with the client_secret of a test mode
application carries a signed sandbox: true claim, which is how the widget knows to
say so and to ask for a .test name. Nothing else about minting changes. The claim is
never what grants or refuses anything: the control plane decides from the application
on every call, and a test mode token can run the connect flow for its own .test
names and read its own webhooks, and nothing else (403 sandbox_not_allowed). A token
of a live application has no such claim.
Keeping a session alive
Pass getToken to open(). When the widget's token expires mid-setup it calls
getToken, swaps in the new token and carries on, so the person adding records
never sees an error:
async function mintToken() {
// Your own endpoint, which mints the token server-side (see above).
const res = await fetch("/api/customdomain-token", { method: "POST" });
return (await res.json()).token;
}
customdomain.open({
applicationId: "app_123",
token: await mintToken(),
getToken: mintToken,
});Without getToken, an expired session tells the user they can close the
window. Nothing is lost: the server keeps checking DNS and the connection goes
live on its own once the records appear.
See The widget SDK for how the browser uses the token, and Create a connection for the calls it drives.
Reference
Reference for the eight window.customdomain methods, window events and callbacks, the open() config, widget statuses, white-label options and theming tokens.
Webhook events and delivery
Register HTTPS endpoints to receive signed webhook events as connections progress, with the event catalog, the payload envelope and how delivery retries work.