Preview URLs
Daytona provides preview URLs for accessing services running in your sandboxes. Any process listening for HTTP traffic on ports 1 to 65535 can be previewed through a generated URL.
Daytona supports two types of preview URLs, each with a different authentication mechanism:
- Standard preview URL contains the sandbox ID and requires a separate authentication token
- Signed preview URL embeds the authentication token directly in the URL, requiring no headers
Use cases
Section titled “Use cases”Preview URLs are used when a service in a sandbox must be reachable over HTTP from outside the sandbox.
- Web application previews: a web server that an agent or a developer starts in the sandbox opens in a browser. Members of the organization sign in through the browser to reach a private port.
- Links for people without an account: a signed preview URL carries its token, so anyone with the link reaches the service without headers. Each link is valid for one port, expires at a set time, and can be revoked on its own.
- Embedded previews: a signed preview URL embeds the service in an iframe or an email. The token replaces the sandbox ID in the hostname, so the link does not reveal the sandbox ID.
- API and webhook tests: an HTTP API in the sandbox receives requests from external clients and services. A public port accepts requests from services that cannot send a credential.
- Real-time applications: WebSocket connections and streaming responses pass through preview URLs. Authenticated streams count as activity and reset the idle timeout.
- Custom domains: a proxy you run in front of preview URLs serves them on your own domain, with your own authentication and error pages.
- Port discovery: the listening ports of a sandbox show which port a server is bound to. An application finds the port of a server that an agent started, then requests its preview URL.
Authentication
Section titled “Authentication”Visibility is set per port. A port is exposed as a private preview endpoint when you first request its preview URL. Private endpoints require authentication; public ones do not.
Make a port public when exposing it, or change the visibility of an already exposed port:
from daytona import Daytona
daytona = Daytona()sandbox = daytona.v2.create()
# Expose a port publiclyendpoint = sandbox.ports.expose(3000, public=True)print(endpoint.url)
# Change visibility of an already exposed portsandbox.ports.set_public(3000, public=False)
# List exposed portsfor endpoint in sandbox.ports.list_endpoints(): print(endpoint.port, endpoint.public, endpoint.url)| Parameter | Type | Description |
|---|---|---|
public | bool | Makes the endpoint public. The default is False, so expose(3000) creates a private endpoint. |
set_publicon a port that is not exposed exposes it with the requested visibility.
import { Daytona } from '@daytona/sdk';
const daytona = new Daytona();const sandbox = await daytona.v2.create();
// Expose a port publiclyconst endpoint = await sandbox.ports.expose(3000, { public: true });console.log(endpoint.url);
// Change visibility of an already exposed portawait sandbox.ports.setPublic(3000, { public: false });
// List exposed portsfor (const endpoint of (await sandbox.ports.listEndpoints()).items) { console.log(endpoint.port, endpoint.public, endpoint.url);}| Parameter | Type | Description |
|---|---|---|
public | boolean | Makes the endpoint public. The default is false, so expose(3000) creates a private endpoint. |
# Expose a port publiclycurl 'https://api.daytona.com/sandboxes/{sandboxId}/ports/3000' \ --request PUT \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer <API_KEY>' \ --data '{ "public": true }'
# Change visibility of an already exposed portcurl 'https://api.daytona.com/sandboxes/{sandboxId}/ports/3000' \ --request PUT \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer <API_KEY>' \ --data '{ "public": false }'
# List exposed portscurl 'https://api.daytona.com/sandboxes/{sandboxId}/ports' \ --header 'Authorization: Bearer <API_KEY>'| Field | Type | Description |
|---|---|---|
public | boolean | Makes the endpoint public. This field is required. |
PUTreplaces the port configuration. It returns201when it exposes the port and200when the port was already exposed.- Exposing a port and changing its visibility require an API key with the
write:sandboxespermission. - Listing exposed ports requires an API key with the
read:sandboxespermission.
Each exposed port is returned with its port, visibility, URL, and creation time. Exposing a port that is already exposed returns the existing endpoint, with its visibility updated if it differs from the requested one.
Unexpose a port
Section titled “Unexpose a port”Stop exposing a port to remove its preview endpoint. The port’s preview URL stops serving, and every signed preview URL minted for that port stops resolving. The standard preview token is not affected and keeps working for the sandbox’s other private endpoints.
from daytona import Daytona
daytona = Daytona()sandbox = daytona.v2.create()sandbox.ports.expose(3000)
sandbox.ports.unexpose(3000)import { Daytona } from '@daytona/sdk';
const daytona = new Daytona();const sandbox = await daytona.v2.create();await sandbox.ports.expose(3000);
await sandbox.ports.unexpose(3000);curl 'https://api.daytona.com/sandboxes/{sandboxId}/ports/3000' \ --request DELETE \ --header 'Authorization: Bearer <API_KEY>'Unexposing a port that is not exposed returns 404.
Listening ports
Section titled “Listening ports”List the ports that have a listener inside the sandbox, or check whether one port has a listener. This inspects the sandbox’s network listeners and is separate from preview endpoints: a listening port is not reachable through a preview URL until it has an endpoint.
from daytona import Daytona
daytona = Daytona()sandbox = daytona.v2.create()
# Every TCP port with a listener inside the sandboxports = sandbox.ports.list()print(ports.items)
# Check one port (3000 to 9999)if sandbox.ports.is_in_use(3000): link = sandbox.ports.preview_link(3000)list()includes the listeners of the Daytona daemon itself, not only your services.
import { Daytona } from '@daytona/sdk'
const daytona = new Daytona()const sandbox = await daytona.v2.create()
// Every TCP port with a listener inside the sandboxconst ports = await sandbox.ports.list()console.log(ports.items)
// Check one port (3000 to 9999)if (await sandbox.ports.isInUse(3000)) { const link = await sandbox.ports.previewLink(3000)}list()includes the listeners of the Daytona daemon itself, not only your services.
# Every TCP port with a listener inside the sandboxdaytona sandbox port list {sandboxId}- The list includes the listeners of the Daytona daemon itself, not only your services.
# Every TCP port with a listener inside the sandboxcurl 'https://toolbox.daytona.com/toolbox/{sandboxId}/port'
# Check one port (3000 to 9999)curl 'https://toolbox.daytona.com/toolbox/{sandboxId}/port/3000/in-use'- Toolbox requests require your API key as an
Authorizationbearer token. - The port list includes the listeners of the Daytona daemon itself.
Standard preview URL
Section titled “Standard preview URL”Standard preview URLs contain the sandbox ID; the authentication token is separate.
The sandbox ID is the sandbox UUID and the proxy domain is the preview domain of the sandbox’s region. Sandboxes in a custom region get URLs under that region’s preview domain.
The token returned by the preview URL API is bound to the sandbox and grants access to every private preview endpoint on it. It does not expire and stays the same across sandbox restarts. With an API key, the SDKs return a sandbox access token scoped to the requested port instead, valid for 1 hour by default. Use standard preview URLs for programmatic access and API integrations where you control the HTTP headers.
Requesting a preview link for a port that is not yet exposed exposes it as a private endpoint. For a public endpoint, the returned token is an empty string because no credential is needed.
import requests
from daytona import Daytona
daytona = Daytona()sandbox = daytona.v2.create()
preview_info = sandbox.ports.preview_link(3000)
print(f"URL: {preview_info.url}")print(f"Token: {preview_info.token}")
# Use with requestsresponse = requests.get( preview_info.url, headers={"x-daytona-preview-token": preview_info.token})- With an API key, the SDK swaps the returned token for a sandbox access token scoped to this port.
- The port-scoped access token is valid for 1 hour by default.
- Call
preview_linkagain to refresh the port-scoped access token. - A port-scoped access token also works as an
Authorizationbearer token. - A port-scoped access token is also accepted as the
rlp_tokenquery parameter.
import { Daytona } from '@daytona/sdk';
const daytona = new Daytona();const sandbox = await daytona.v2.create();
const previewInfo = await sandbox.ports.previewLink(3000);
console.log(`URL: ${previewInfo.url}`);console.log(`Token: ${previewInfo.token}`);
// Use with fetchconst response = await fetch(previewInfo.url, { headers: { 'x-daytona-preview-token': previewInfo.token }});- With an API key, the SDK swaps the returned token for a sandbox access token scoped to this port.
- The port-scoped access token is valid for 1 hour by default.
- Call
previewLinkagain to refresh the port-scoped access token. - A port-scoped access token also works as an
Authorizationbearer token. - A port-scoped access token is also accepted as the
rlp_tokenquery parameter.
daytona sandbox preview-url {sandboxId} 3000curl 'https://api.daytona.com/sandboxes/{sandboxId}/ports/3000/preview-url' \ --request POST \ --header 'Authorization: Bearer <API_KEY>'- The preview URL call requires an API key with the
write:sandboxespermission because the call can register a new endpoint.
Authentication
Section titled “Authentication”Authenticate by sending the token in the x-daytona-preview-token header:
curl -H "x-daytona-preview-token: 2fa41c900320a20409fd42dbc5629d38" \ https://3000-a78d7657-ad90-4ad6-ac7d-d415a0dc98cc.proxy.daytona.worksPrivate preview endpoints also accept your Daytona API key as a bearer token in the Authorization header. Clients that cannot set headers, such as browser WebSocket connections, can pass the API key as an rlp_token query parameter instead:
curl "https://3000-a78d7657-ad90-4ad6-ac7d-d415a0dc98cc.proxy.daytona.works/?rlp_token=<API_KEY>"The x-daytona-preview-token header is accepted only as a header, not as a query parameter. All three credentials (the Authorization header, the x-daytona-preview-token header, and the rlp_token query parameter) are stripped before the request reaches your application.
Requests without a valid credential get 401, but browsers are redirected to sign in.
Browser sign-in
Section titled “Browser sign-in”When a private preview URL is opened in a browser without a credential, Daytona redirects the navigation to sign in. Any member of the organization that owns the sandbox is authorized. Only top-level navigations (GET or HEAD requests with a navigate fetch mode, or an Accept header containing text/html) trigger the redirect; API calls, fetch requests, and SDKs always receive 401.
The session is stored in a cookie scoped to that preview hostname and bound to the sandbox and port, so it does not carry over to other ports or sandboxes. Organization membership is re-checked on every request, so removing a member revokes access within seconds.
Two paths are reserved on every preview hostname and are never forwarded to your application:
/.rlp-auth/acceptcompletes the sign-in/.rlp-auth/logoutends the session for that hostname
Session cookies are stripped before the request reaches your application.
Signed preview URL
Section titled “Signed preview URL”The signed preview URL embeds the authentication token directly in the URL, eliminating the need for separate headers. Each call mints a new token that is valid for a single port and can be revoked individually before it expires. Set a custom expiry time for the token.
The token replaces the sandbox ID in the hostname, so the link does not reveal the sandbox ID. Tokens are 33 characters: the letter s followed by 32 lowercase hexadecimal characters.
Use signed preview URLs when sharing links with users who cannot set custom headers, embedding previews in iframes or emails, or creating time-limited shareable links.
import requests
from daytona import Daytona
daytona = Daytona()sandbox = daytona.v2.create()
# Create a signed preview URL that expires in 3600 seconds (1 hour)signed_link = sandbox.ports.signed_preview_link(3000, expires_in_seconds=3600)
print(f"URL: {signed_link.url}") # Token is embedded in the URLprint(f"Token: {signed_link.token}") # Can be used to revoke accessprint(f"Expires at: {signed_link.expires_at}")
# Use directly - no headers neededresponse = requests.get(signed_link.url)
# Revoke the token before expiry if neededsandbox.ports.expire_signed_preview_link(3000, signed_link.token)| Parameter | Type | Description |
|---|---|---|
expires_in_seconds | int | Seconds, from 1 to 604800 (7 days). Must be a positive integer; other values raise BadRequestError before any request is sent. When omitted, the server applies 60 seconds. |
The response has the following fields:
| Field | Description |
|---|---|
url | The signed preview URL, in the form https://{port}-{token}.{daytonaProxyDomain}. |
token | The token of the signed link. The token is returned only once, at mint time. Store the token if you need to revoke the link later. |
expires_at | The expiry time of the token. |
expire_signed_preview_linksucceeds without an error for an unknown or already expired token.
import { Daytona } from '@daytona/sdk';
const daytona = new Daytona();const sandbox = await daytona.v2.create();
// Create a signed preview URL that expires in 3600 seconds (1 hour)const signedLink = await sandbox.ports.signedPreviewLink(3000, { expiresInSeconds: 3600 });
console.log(`URL: ${signedLink.url}`); // Token is embedded in the URLconsole.log(`Token: ${signedLink.token}`); // Can be used to revoke accessconsole.log(`Expires at: ${signedLink.expiresAt}`);
// Use directly - no headers neededconst response = await fetch(signedLink.url);
// Revoke the token before expiry if neededawait sandbox.ports.expireSignedPreviewLink(3000, signedLink.token);| Parameter | Type | Description |
|---|---|---|
expiresInSeconds | number | Seconds, from 1 to 604800 (7 days). Must be a positive integer; other values throw BadRequestError before any request is sent. When omitted, the server applies 60 seconds. |
The response has the following fields:
| Field | Description |
|---|---|
url | The signed preview URL, in the form https://{port}-{token}.{daytonaProxyDomain}. |
token | The token of the signed link. The token is returned only once, at mint time. Store the token if you need to revoke the link later. |
expiresAt | The expiry time of the token. |
expireSignedPreviewLinksucceeds without an error for an unknown or already expired token.
# Create a signed preview URL that expires in 3600 seconds (1 hour)daytona sandbox preview-url {sandboxId} 3000 --signed --ttl 3600
# Revoke the token before expiry if neededdaytona sandbox preview-url expire {sandboxId} 3000 {token}| Flag | Type | Description |
|---|---|---|
--ttl | int | Sets the lifetime of the signed URL in seconds, from 1 to 604800, which is 7 days. It works only together with --signed. The default is 60. |
- The token is not shown again.
- Keep the token to revoke the link later.
# Create a signed preview URL that expires in 3600 seconds (1 hour)curl 'https://api.daytona.com/sandboxes/{sandboxId}/ports/3000/signed-preview-urls' \ --request POST \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer <API_KEY>' \ --data '{ "expires_in_seconds": 3600 }'
# Revoke the token before expiry if neededcurl 'https://api.daytona.com/sandboxes/{sandboxId}/ports/3000/signed-preview-urls/revoke' \ --request POST \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer <API_KEY>' \ --data '{ "token": "{token}" }'| Field | Type | Description |
|---|---|---|
expires_in_seconds | integer | Sets the lifetime of the signed URL in seconds, from 1 to 604800. Other values return 400. The default is 60. |
The response has the following fields:
| Field | Description |
|---|---|
url | The signed preview URL, in the form https://{port}-{token}.{daytonaProxyDomain}. |
token | The token of the signed link. The token is returned only once. Daytona stores only the hash of the token. |
expires_at | The expiry time of the token. |
- Both calls require an API key with the
write:sandboxespermission. - The revoke call succeeds for an unknown or already expired token.
Authentication
Section titled “Authentication”The token is embedded in the URL itself, so no additional headers are required:
curl https://3000-<token>.proxy.daytona.worksThe token is the whole credential and is re-validated on every request:
- The token is bound to its port; another port in the hostname returns
404. - A signed link never creates a browser session and never redirects to sign in. An invalid, expired, or revoked token returns
404for browsers and SDKs alike. - The port must remain exposed. Unexposing the port invalidates every signed link minted for it.
- Revocation takes effect on all proxies within about one second.
Minting a signed link for a port that is not yet exposed exposes it as a private endpoint. Minting never changes the visibility of a port that is already exposed.
Sandbox activity
Section titled “Sandbox activity”Only authenticated preview requests count as sandbox activity. A request is authenticated when it carries a valid Authorization header, x-daytona-preview-token header, rlp_token query parameter, browser sign-in session, or when it uses a signed preview URL.
- Authenticated requests and streams, including WebSockets, reset the idle timeout
- Anonymous requests to public preview endpoints never reset the sandbox’s idle timer
Any request to a sandbox that is not running receives 409.
Response codes
Section titled “Response codes”| Status | Body | Cause |
|---|---|---|
302 | Private endpoint without a credential; browser navigation redirected to sign in | |
401 | unauthorized | Private endpoint without a valid credential (non-browser request) |
404 | not found | Hostname does not match the {port}-{sandboxId} or {port}-{token} shape, or the signed token is invalid, expired, revoked, or minted for a different port |
404 | unknown preview endpoint | The port is not exposed on this sandbox |
404 | unknown sandbox | The port is exposed but the sandbox cannot be routed from this preview domain, for example because it runs in another region |
409 | sandbox not running | The sandbox is not running |
409 | sandbox failed to start | The sandbox’s last start failed |
Using your own proxy
Section titled “Using your own proxy”Place your own proxy in front of preview URLs to serve previews on a custom domain (e.g., preview.yourcompany.com), add your own authentication, or show branded error pages.
Your proxy resolves the sandbox and port from the incoming request and forwards it to the standard preview URL https://{port}-{sandboxId}.{daytonaProxyDomain}, attaching one of the following credentials for private endpoints:
X-Daytona-Preview-Token: {sandboxToken}or
Authorization: Bearer {apiKey}Both headers are stripped before the request reaches the sandbox. The request path and query string are forwarded unchanged (apart from the rlp_token parameter, which is removed), and WebSocket upgrades and streaming responses are proxied automatically.
Only authenticated requests keep sandboxes running. For proxy traffic to count as activity, attach a credential even for public endpoints.