# Reach a port in a workspace

> A private HTTPS URL for any port of a workspace. A request wakes the workspace, so previews, webhooks and OAuth callbacks always reach your app.

## What a port URL does

Any TCP port of a workspace can get its own HTTPS URL:

```text
https://3000-k7q2m4x9a3b5c6d7.shardflux.app
```

- **It wakes the workspace inside the request.** A running workspace answers at once. A parked one is woken by the
  request itself, and a suspended one is resumed while the request waits (up to 30 seconds), with its memory and
  processes as they were. Your dev server, API or app needs nothing special: it keeps running across every suspend.
- **Every port is private.** A request carries a port token, the cookie of a signed link, or arrives on the port's
  callback URL. Anything else is refused before it reaches the workspace, and a refused request never wakes it.
- **HTTP/1.1, streaming and WebSockets** pass through as they are. An open request or WebSocket keeps the workspace
  awake, and the [idle timeout](https://docs.shardflux.dev/concepts/lifecycle.md#automatic-suspend-when-idle) runs from the last request.

| Client | Version | Expose | Token | Link | Callback URL |
| --- | --- | --- | --- | --- | --- |
| TypeScript SDK | `@shardflux/sdk` **(0.14.0+)** | `workspace.ports.expose(3000)` | `ports.token(3000)` | `ports.link(3000)` | `ports.createCallbackUrl(3000)` |
| Python SDK | `shardflux` **(0.10.0+)** | `ws.ports.expose(3000)` | `ws.ports.token(3000)` | `ws.ports.link(3000)` | `ws.ports.create_callback_url(3000)` |
| CLI | `@shardflux/cli` **(0.9.0+)** | `shard ws port expose <key> 3000` | `shard ws port token` | `shard ws port link` | `shard ws port callback` |
| MCP server | `@shardflux/mcp` **(0.8.0+)** | `workspace_expose_port` | | `workspace_port_link` | |
| HTTP | `/v1` | `PUT /workspaces/{id}/ports/3000` | `POST .../ports/3000/tokens` | `POST .../ports/3000/links` | `POST .../ports/3000/callback` |

## Expose a port

Start your server so it listens on all interfaces (`0.0.0.0`), then expose its port:

```ts
await workspace.cell().exec.start({ argv: ['python3', '-m', 'http.server', '3000', '--bind', '0.0.0.0'] });
const { url } = await workspace.ports.expose(3000);
console.log(url); // https://3000-k7q2m4x9a3b5c6d7.shardflux.app
```

```python
port = ws.ports.expose(3000)
print(port.url)  # https://3000-k7q2m4x9a3b5c6d7.shardflux.app
```

```sh
shard ws port expose customer-42/main 3000
shard ws port ls customer-42/main
```

- Exposing is idempotent: exposing an exposed port returns it unchanged, and its tokens and links stay valid.
- The URL is stable for the workspace's whole life, across suspends, resumes and resets. A fork gets its own URLs
  and starts with no ports exposed.
- A server bound only to `127.0.0.1` is not reachable from outside the workspace. Most dev servers take `--host
  0.0.0.0`. Vite also checks the `Host` header: add `server: { host: true, allowedHosts: ['.shardflux.app'] }`.
- Your app sees the public `Host`, and `X-Forwarded-For`, `X-Forwarded-Proto: https` and `X-Forwarded-Host`, so it
  builds correct absolute URLs and secure cookies.

## Call it from code: port tokens

A port token authorizes requests to one port of one workspace. Send it as a bearer token:

```ts
const { token } = await workspace.ports.token(3000, { ttlSeconds: 3600 });
const res = await fetch(`${url}/api/health`, { headers: { Authorization: `Bearer ${token}` } });
```

```python
token = ws.ports.token(3000)
httpx.get(f"{port.url}/api/health", headers={"Authorization": f"Bearer {token.token}"})
```

```sh
curl -H "Authorization: Bearer $(shard ws port token customer-42/main 3000)" \
  https://3000-k7q2m4x9a3b5c6d7.shardflux.app/api/health
```

- Tokens start with `sfp_` and last 1 hour by default (`ttlSeconds` 60 to 86,400).
- When your app uses the `Authorization` header itself, send the token as `X-Shardflux-Token: sfp_...` instead: your
  `Authorization` header then reaches the app untouched. The platform's own credential never reaches your app.
- Closing the port revokes every token, link and callback URL of it, also when you expose it again.

## Open it in a browser: signed links

A signed link opens the port in a browser, for you or for your own users, until it expires:

```ts
const { url: link } = await workspace.ports.link(3000, { path: '/dashboard', ttlSeconds: 86400 });
```

```sh
shard ws port link customer-42/main 3000 --path /dashboard --ttl 86400
```

The link sets a cookie for that port's host only (`Secure`, `HttpOnly`, `SameSite=Lax`) and redirects to `path`.
Every page, script, image and WebSocket of the app then works in that browser for as long as the link is valid
(1 day by default, up to 7 days). Anyone with the link can open the port until then, so share it like a password.

## Webhooks and OAuth callbacks: the callback URL

Providers that call you back (GitHub and Stripe webhooks, Slack events, OAuth redirects) cannot send a token. Give
them the port's callback URL instead. It carries an unguessable secret in its path:

```ts
const { url: callback } = await workspace.ports.createCallbackUrl(3000);
// https://3000-k7q2m4x9a3b5c6d7.shardflux.app/__shardflux/callback/sfcb_.../
// Register `${callback}webhooks/github` with GitHub.
```

```sh
shard ws port callback customer-42/main 3000
shard ws port callback customer-42/main 3000 --revoke
```

- A request to `<callback URL><path>` reaches `/<path>` in your app (the query string is kept), and wakes the
  workspace like any other request. Your app verifies the provider's signature as usual.
- The URL is shown once. Creating it again replaces it (the previous URL stops working at once); `--revoke` /
  `revokeCallbackUrl()` removes it.

## What wakes the workspace and what keeps it awake

| The workspace is | A request |
| --- | --- |
| Running | Is answered at once |
| [Parked](https://docs.shardflux.dev/concepts/lifecycle.md#idle-running-workspaces-are-parked) | Wakes it inside the request, exactly as a tool call does |
| Suspended | Resumes it, with memory and processes as they were, and is answered as soon as it runs |
| Starting, resuming or suspending | Waits for that to finish, then as above |

- A request in progress or an open WebSocket keeps the workspace awake: it is not parked and not suspended while
  connected. The idle timeout counts from the end of the last request.
- A request waits at most 30 seconds for the workspace to run and the port to accept. A server that is still starting
  after a resume is waited for within that time.

## Billing

Bytes your app sends back through a port URL count as [outbound transfer](https://docs.shardflux.dev/limits.md#what-each-allowance-measures).
Requests into the workspace are inbound traffic and are free. A workspace woken by a request is billed like one woken
by a tool call.

## Limits and errors

Ports per workspace, token and link lifetimes, request rates and timeouts are listed in
[Limits](https://docs.shardflux.dev/limits.md#inbound-ports). Every refusal by the platform carries an `x-shardflux-reason` header (a response
without it came from your app); the reasons are in [Errors](https://docs.shardflux.dev/reference/errors.md#inbound-ports).
