# Preview URLs (/docs/preview)

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

<PreviewVisual />

## Use cases [#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. Any process that listens for HTTP traffic on a port from 1 to 65535 can be previewed once its port is exposed.

- **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. The expiry ranges from 1 second to 7 days, with a default of 60 seconds.

- **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. Clients that control their HTTP headers use the [standard preview URL](#standard-preview-url) and send the token in a header.

- **Real-time applications.** WebSocket connections and streaming responses pass through preview URLs. Authenticated streams count as activity and reset the idle timeout. A browser WebSocket client cannot set headers, so it passes an API key as the daytona_token query parameter.

- **Custom domains.** A proxy you run in front of preview URLs serves them on your own domain, with your own authentication and error pages. The proxy forwards each request to the [standard preview URL](#standard-preview-url) with a credential, even for public ports, so the traffic counts as activity. Daytona strips the credential headers before the request reaches the sandbox.

## Authentication [#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:

<Tabs syncKey="language">
<TabItem label="Python" icon="seti:python">
```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.expose(3000, public=False)
```

| **Parameter** | **Type** | **Description**                                                       |
| ------------- | -------- | --------------------------------------------------------------------- |
| `public` | bool | Makes the endpoint public. On a port that is already exposed, it changes the visibility. The default is `False`, so `expose(3000)` creates a private endpoint. |

</TabItem>
<TabItem label="TypeScript" icon="seti:typescript">
```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.expose(3000, { public: false });
```

| **Parameter** | **Type**  | **Description**                                                       |
| ------------- | --------- | --------------------------------------------------------------------- |
| `public` | boolean | Makes the endpoint public. On a port that is already exposed, it changes the visibility. The default is `false`, so `expose(3000)` creates a private endpoint. |

</TabItem>
<TabItem label="API" icon="seti:json">
```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 }'
```

| **Field** | **Type**  | **Description**    |
| --------- | --------- | ------------------ |
| `public` | boolean | Makes the endpoint public. This field is required. |

</TabItem>
</Tabs>

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.

### List exposed ports [#list-exposed-ports]

List the preview endpoints of a sandbox, ordered by port number. The list returns one page at a time, and each page holds the cursor of the next one. Each endpoint has its port, visibility, URL, and creation time. A port with a listener but no endpoint is not listed; see [listening ports](#listening-ports).

<Tabs syncKey="language">
<TabItem label="Python" icon="seti:python">
```python
from daytona import Daytona

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

cursor = None
while True:
    page = sandbox.ports.list_endpoints(limit=50, cursor=cursor)
    for endpoint in page.items:
        print(endpoint.port, endpoint.public, endpoint.url, endpoint.created_at)
    cursor = page.next_cursor  # None on the last page
    if cursor is None:
        break

# Or iterate over every endpoint without handling page cursors
for endpoint in sandbox.ports.iter_endpoints():
    print(endpoint.port, endpoint.public, endpoint.url)
```

| **Parameter** | **Type** | **Description** |
| ------------- | -------- | --------------- |
| `limit` | int | Sets the page size, from `1` to `200`. A value outside the range raises `BadRequestError` before any request is sent. The default is `100`. |
| `cursor` | str | Resumes the list after the previous page. Pass the `next_cursor` of that page. Omit it for the first page. |

</TabItem>
<TabItem label="TypeScript" icon="seti:typescript">
```typescript
import { Daytona } from '@daytona/sdk';

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

let cursor: string | undefined;
do {
  const page = await sandbox.ports.listEndpoints({ limit: 50, cursor });
  for (const endpoint of page.items) {
    console.log(endpoint.port, endpoint.public, endpoint.url, endpoint.createdAt);
  }
  cursor = page.nextCursor; // undefined on the last page
} while (cursor);

// Or iterate over every endpoint without handling page cursors
for await (const endpoint of sandbox.ports.iterEndpoints()) {
  console.log(endpoint.port, endpoint.public, endpoint.url);
}
```

| **Parameter** | **Type** | **Description** |
| ------------- | -------- | --------------- |
| `limit` | number | Sets the page size, from `1` to `200`. A value outside the range throws `BadRequestError` before any request is sent. The default is `100`. |
| `cursor` | string | Resumes the list after the previous page. Pass the `nextCursor` of that page. Omit it for the first page. |

</TabItem>
<TabItem label="API" icon="seti:json">
```bash
# List exposed ports
curl 'https://api.daytona.com/sandboxes/{sandboxId}/ports?limit=50' \
  --header 'Authorization: Bearer <API_KEY>'

# Get one exposed port
curl 'https://api.daytona.com/sandboxes/{sandboxId}/ports/3000' \
  --header 'Authorization: Bearer <API_KEY>'
```

| **Parameter** | **Type** | **Description** |
| ------------- | -------- | --------------- |
| `limit` | integer | Sets the page size, from `1` to `200`. A value outside the range is set to the nearest bound. The default is `100`. |
| `cursor` | string | Resumes the list after the previous page. Pass the `next_cursor` of that page. Omit it for the first page. |

The list response has the following fields:

| **Field** | **Description** |
| --------- | --------------- |
| `ports` | Lists the endpoints of the page, ordered by port number. |
| `next_cursor` | The cursor of the next page, or `null` on the last page. |

</TabItem>
</Tabs>

### Unexpose a port [#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.

<Tabs syncKey="language">
<TabItem label="Python" icon="seti:python">
```python
from daytona import Daytona

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

sandbox.ports.unexpose(3000)
```

</TabItem>
<TabItem label="TypeScript" icon="seti:typescript">
```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);
```

</TabItem>
<TabItem label="API" icon="seti:json">
```bash
curl 'https://api.daytona.com/sandboxes/{sandboxId}/ports/3000' \
  --request DELETE \
  --header 'Authorization: Bearer <API_KEY>'
```

</TabItem>
</Tabs>

### Listening ports [#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.

<Tabs syncKey="language">
<TabItem label="Python" icon="seti:python">

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

</TabItem>
<TabItem label="TypeScript" icon="seti:typescript">

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

</TabItem>
<TabItem label="CLI" icon="seti:shell">

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

</TabItem>
<TabItem label="API" icon="seti:json">

```bash
# Every TCP port with a listener inside the sandbox
curl '{toolboxProxyUrl}/{sandboxId}/port' \
  --header 'Authorization: Bearer <API_KEY>'

# Check one port (3000 to 9999)
curl '{toolboxProxyUrl}/{sandboxId}/port/3000/in-use' \
  --header 'Authorization: Bearer <API_KEY>'
```

<Callout type="note" title="Notes">
- The request requires the `write:sandboxes` permission.
- `{toolboxProxyUrl}` is the `toolbox_proxy_url` field of the [sandbox](/docs/sandboxes#get-a-sandbox).
</Callout>

</TabItem>
</Tabs>

## Standard preview URL [#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](/docs/regions). Sandboxes in a [custom region](/docs/regions#custom-regions) 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.

<Tabs syncKey="language">
<TabItem label="Python" icon="seti:python">
```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}
)
```

<Callout type="note" title="Notes">
- The request requires the `write:sandboxes` permission.
- The port-scoped access token expires after 1 hour by default; call `preview_link` again to get a new one.
- The port-scoped access token is also accepted as an `Authorization` bearer token or as the `daytona_token` query parameter.
</Callout>

</TabItem>
<TabItem label="TypeScript" icon="seti:typescript">
```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 }
});
```

<Callout type="note" title="Notes">
- The request requires the `write:sandboxes` permission.
- The port-scoped access token expires after 1 hour by default; call `previewLink` again to get a new one.
- The port-scoped access token is also accepted as an `Authorization` bearer token or as the `daytona_token` query parameter.
</Callout>

</TabItem>
<TabItem label="CLI" icon="seti:shell">

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

</TabItem>
<TabItem label="API" icon="seti:json">
```bash
curl 'https://api.daytona.com/sandboxes/{sandboxId}/ports/3000/preview-url' \
  --request POST \
  --header 'Authorization: Bearer <API_KEY>'
```

</TabItem>
</Tabs>

### Authentication [#authentication-1]

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 a Daytona API key with the sandbox write permission as a bearer token in the Authorization header. Clients that cannot set headers, such as browser WebSocket connections, can pass the API key as a daytona_token query parameter instead:

```bash
curl "https://3000-a78d7657-ad90-4ad6-ac7d-d415a0dc98cc.proxy.daytona.works/?daytona_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 daytona_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 [#browser-sign-in]

When a private preview URL is opened in a browser without a credential, Daytona redirects the navigation to sign in. A user is authorized when they are a member of the organization that owns the sandbox and hold the sandbox read permission on the sandbox's project. 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. Membership and permissions are re-checked each time the session token is refreshed, so removing a member revokes access within one session token lifetime, 15 minutes by default.

Two paths are reserved on every preview hostname and are never forwarded to your application:

- `/.daytona-auth/accept` completes the sign-in
- `/.daytona-auth/logout` ends the session for that hostname

Session cookies are stripped before the request reaches your application.

## Signed preview URL [#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. Standard and signed preview tokens are not interchangeable: the standard preview token is sent in the x-daytona-preview-token header, and the signed preview token is embedded in the URL itself.

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.

<Tabs syncKey="language">
<TabItem label="Python" icon="seti:python">
```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. Set it explicitly: the 60-second default is short for security reasons, and most use cases need at least 3600 (1 hour). |

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

</TabItem>
<TabItem label="TypeScript" icon="seti:typescript">
```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. Set it explicitly: the 60-second default is short for security reasons, and most use cases need at least 3600 (1 hour). |

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

</TabItem>
<TabItem label="CLI" icon="seti:shell">

```bash
# Create a signed preview URL that expires in 1 hour
daytona sandbox port preview-url {sandboxId} 3000 --signed --ttl 1h

# Revoke the token before expiry if needed
daytona sandbox port preview-url expire {sandboxId} 3000 {token}
```

| **Flag** | **Type** | **Description**                                                      |
| -------- | -------- | -------------------------------------------------------------------- |
| `--ttl` | duration | Sets the lifetime of the signed URL as a Go duration such as `1h`, from `1s` to `168h`, which is 7 days. The default is `60s`. Set it explicitly: the 60-second default is short for security reasons, and most use cases need at least `1h`. This flag works only together with `--signed`. |

<Callout type="note" title="Notes">
- The requests require the `write:sandboxes` permission.
- Store the printed token to revoke the link later; the CLI does not show it again.
</Callout>

</TabItem>
<TabItem label="API" icon="seti:json">

```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`. Set it explicitly: the 60-second default is short for security reasons, and most use cases need at least 3600 (1 hour). |

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

</TabItem>
</Tabs>

### Authentication [#authentication-2]

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 [#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, daytona_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](/docs/linux-sandboxes#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 [#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 [#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) of that sandbox and port, 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 daytona_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.

## Permissions [#permissions]

Previews use the sandbox permissions of the project, which you can grant to an [organization role](/docs/organizations#roles) or an [API key](/docs/api-keys).

The [audit logs](/docs/audit-logs) record port exposes and unexposes, preview URL creation, and signed URL revocation.

| **Permission**        | **Grants**                                                                                                    |
| --------------------- | ------------------------------------------------------------------------------------------------------------- |
| **`read:sandboxes`**  | List and get exposed ports, and open private previews in a signed-in browser                                  |
| **`write:sandboxes`** | Expose and unexpose ports, create standard and signed preview URLs, and open private previews with an API key |
