Skip to content

Preview URLs

View as Markdown

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:

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.

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 publicly
endpoint = sandbox.ports.expose(3000, public=True)
print(endpoint.url)
# Change visibility of an already exposed port
sandbox.ports.set_public(3000, public=False)
# List exposed ports
for endpoint in sandbox.ports.list_endpoints():
print(endpoint.port, endpoint.public, endpoint.url)
ParameterTypeDescription
publicboolMakes the endpoint public. The default is False, so expose(3000) creates a private endpoint.
  • set_public on a port that is not exposed exposes it with the requested visibility.

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.

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)

Unexposing a port that is not exposed returns 404.

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 sandbox
ports = 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.

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 requests
response = 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_link again to refresh the port-scoped access token.
  • A port-scoped access token also works as an Authorization bearer token.
  • A port-scoped access token is also accepted as the rlp_token query parameter.

Authenticate by sending the token in the x-daytona-preview-token header:

Terminal window
curl -H "x-daytona-preview-token: 2fa41c900320a20409fd42dbc5629d38" \
https://3000-a78d7657-ad90-4ad6-ac7d-d415a0dc98cc.proxy.daytona.works

Private 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:

Terminal window
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.

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/accept completes the sign-in
  • /.rlp-auth/logout ends the session for that hostname

Session cookies are stripped before the request reaches your application.

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 URL
print(f"Token: {signed_link.token}") # Can be used to revoke access
print(f"Expires at: {signed_link.expires_at}")
# Use directly - no headers needed
response = requests.get(signed_link.url)
# Revoke the token before expiry if needed
sandbox.ports.expire_signed_preview_link(3000, signed_link.token)
ParameterTypeDescription
expires_in_secondsintSeconds, 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:

FieldDescription
urlThe signed preview URL, in the form https://{port}-{token}.{daytonaProxyDomain}.
tokenThe 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_atThe expiry time of the token.
  • expire_signed_preview_link succeeds without an error for an unknown or already expired token.

The token is embedded in the URL itself, so no additional headers are required:

Terminal window
curl https://3000-<token>.proxy.daytona.works

The 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 404 for 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.

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.

StatusBodyCause
302Private endpoint without a credential; browser navigation redirected to sign in
401unauthorizedPrivate endpoint without a valid credential (non-browser request)
404not foundHostname does not match the {port}-{sandboxId} or {port}-{token} shape, or the signed token is invalid, expired, revoked, or minted for a different port
404unknown preview endpointThe port is not exposed on this sandbox
404unknown sandboxThe port is exposed but the sandbox cannot be routed from this preview domain, for example because it runs in another region
409sandbox not runningThe sandbox is not running
409sandbox failed to startThe sandbox’s last start failed

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.