# 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](#standard-preview-url) contains the sandbox ID and requires a separate authentication token
- [Signed preview URL](#signed-preview-url) embeds the authentication token directly in the URL, requiring no headers

## 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

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:

```python
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)
```

| **Parameter** | **Type** | **Description**                                                       |
| ------------- | -------- | --------------------------------------------------------------------- |
| **`public`** | **`bool`** | Makes 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.

```typescript
import { Daytona } from '@daytona/sdk';

const daytona = new Daytona();
const sandbox = await daytona.v2.create();

// Expose a port publicly
const endpoint = await sandbox.ports.expose(3000, { public: true });
console.log(endpoint.url);

// Change visibility of an already exposed port
await sandbox.ports.setPublic(3000, { public: false });

// List exposed ports
for (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. |

```bash
# Expose a port publicly
curl '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 port
curl '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 ports
curl '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. |

- **`PUT`** replaces the port configuration. It returns **`201`** when it exposes the port and **`200`** when the port was already exposed.
- Exposing a port and changing its visibility require an API key with the **`write:sandboxes`** permission.
- Listing exposed ports requires an API key with the **`read:sandboxes`** permission.


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

Stop exposing a port to remove its preview endpoint. The port's preview URL stops serving, and every [signed preview URL](#signed-preview-url) minted for that port stops resolving. The [standard preview token](#standard-preview-url) is not affected and keeps working for the sandbox's other private endpoints.

```python
from daytona import Daytona

daytona = Daytona()
sandbox = daytona.v2.create()
sandbox.ports.expose(3000)

sandbox.ports.unexpose(3000)
```

```typescript
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);
```

```bash
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

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.


```python
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.


```typescript
import { Daytona } from '@daytona/sdk'

const daytona = new Daytona()
const sandbox = await daytona.v2.create()

// Every TCP port with a listener inside the sandbox
const 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.


```bash
# Every TCP port with a listener inside the sandbox
daytona sandbox port list {sandboxId}
```

- The list includes the listeners of the Daytona daemon itself, not only your services.


```bash
# Every TCP port with a listener inside the sandbox
curl '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 **`Authorization`** bearer token.
- The port list includes the listeners of the Daytona daemon itself.


:::note
Standard and signed preview tokens are not interchangeable. The standard preview token is sent in the x-daytona-preview-token header. The signed preview token is embedded in the URL itself: it cannot be used as a standard preview token, and vice versa.
:::

## 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](https://www.daytona.io/docs/en/v2/regions.md). Sandboxes in a [custom region](https://www.daytona.io/docs/en/v2/bring-your-own-compute.md) 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.

```python
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.

```typescript
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 fetch
const 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 **`previewLink`** 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.


```bash
daytona sandbox preview-url {sandboxId} 3000
```

```bash
curl '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:sandboxes`** permission because the call can register a new endpoint.


### Authentication

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

```bash
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:

```bash
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

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.

## 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.

:::tip
Always set the expiration time explicitly. The default of 60 seconds is short due to security considerations. Most use cases should use at least 3600 (1 hour).
:::

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.

```python
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)
```

| **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_link`** succeeds without an error for an unknown or already expired token.

```typescript
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 URL
console.log(`Token: ${signedLink.token}`);  // Can be used to revoke access
console.log(`Expires at: ${signedLink.expiresAt}`);

// Use directly - no headers needed
const response = await fetch(signedLink.url);

// Revoke the token before expiry if needed
await 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.                                                                                      |

- **`expireSignedPreviewLink`** succeeds without an error for an unknown or already expired token.


```bash
# 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 needed
daytona 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.


```bash
# 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 needed
curl '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:sandboxes`** permission.
- The revoke call succeeds for an unknown or already expired token.


### Authentication

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

```bash
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](#unexpose-a-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

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](https://www.daytona.io/docs/en/v2/linux-sandboxes.md#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

| **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

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](#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.