Skip to main content
Sailboxes have a public HTTPS API. The SDKs and the CLI are built on it, and you can call it directly from any language, or from curl. Every endpoint is listed under Reference → Sailbox → HTTP API. This page covers what applies to all of them.
The apps endpoints are at https://api.sailresearch.com/v1. Your key works on both.

Authentication

Send your API key as a bearer token on every request. Create keys in the dashboard. A key belongs to one organization and only ever sees that organization’s Sailboxes.
user_id is the member the key belongs to, or null for a key that belongs to the organization rather than a person. Compare it with a Sailbox’s created_by_user_id to tell your Sailboxes from a teammate’s. A private Sailbox can only be operated by the user whose key created it. An org admin can override some operations by sending an X-Sail-Owner-Override-Reason header, which is recorded in the audit log. See Access Control.

From inside a Sailbox

Code running inside a Sailbox can read that Sailbox, sleep it, schedule its wake, and manage its cron jobs without an API key. The SDKs do this for you: inside a Sailbox, a client built without an API key acts as the Sailbox itself. See guest identity. To call the API directly, send the Sailbox’s identity in two headers in place of the bearer token: X-Sail-Source-Sailbox-ID with the id from the SAILBOX_ID environment variable, and X-Sail-Source-Sailbox-Auth with the Sailbox’s auth token, which Sail sets in the Sailbox’s environment.
This credential belongs to one Sailbox and is accepted for that Sailbox only, on these operations: retrieve, sleep, and schedule a wake. Every other request made with it fails with a permission error, including any request about a different Sailbox. It works for a private Sailbox too, and every process inside the Sailbox holds it, so anything running there can put the Sailbox to sleep. The credential stops working once the Sailbox is terminated or has failed. From inside a Sailbox, manage cron jobs with the SDK or CLI, which identify the calling user automatically. A self-sleep needs care. The Sailbox usually suspends before the response returns, so if the call returns an error you cannot tell whether the sleep took effect. Do not send it again; check the Sailbox’s state after it next wakes instead. A request that also carries an Authorization header is served with that key’s authority, and the identity headers are ignored.

Example

Every Sailbox belongs to an app. Get an app id, then create a Sailbox in it:
What to know about that create:
  • It blocks until the Sailbox is up, which can take a few minutes while it waits for a machine.
  • Read status. A create that is accepted and then cannot bring the machine up still returns 200, with status set to failed and error_message saying why.
  • The Idempotency-Key makes it safe to retry. Send the same key and body again and you get the first answer back instead of a second Sailbox. Use a fresh key for every Sailbox you mean to create. See Retrying safely.
Terminate it when you are done:

Run commands on a Sailbox

Send command as a shell string or an argument array. A string supports cwd and background. With an array, the program runs directly.
The response streams newline-delimited JSON. Output bytes are base64 so every byte value is safe in JSON, and the last event includes the exit code:
A heartbeat event arrives every 30 seconds while the command runs. If the connection drops before exit, call POST .../exec/$EXEC_ID/wait with the id from the started event to get the result and a bounded tail of the output. A failure after started ends the stream with an error event whose error_code is a lowercase category such as unavailable or permission_denied.
  • To write standard input, set open_stdin or pty when you start the exec, then PUT .../exec/$EXEC_ID/stdin?offset=N&eof=true with the raw bytes. Writes include their byte offset, so an overlapping retry does not duplicate input. The response reports accepted_through. For interactive sessions, use the stdin stream.
  • To reconnect to a live exec, send the same idempotency_key with the highest seq you received for stdout and stderr. Reconnect is best-effort and does not guarantee exact replay. An exec id that does not fit a URL segment goes in the exec_request_id query parameter with - in the path.
  • The idempotency key can be up to 256 KiB of UTF-8, trimmed. Environment variable names match [A-Za-z_][A-Za-z0-9_]*, and names and values cannot contain NUL. The encoded request body can be up to 25 MiB, and up to 4 MiB after decoding.
  • The reference also lists cancel, PTY resize, and PTY resync.
  • PUT .../clipboard places the raw request body on the Sailbox’s clipboard, with Content-Type as its media type: text/plain or an image/* type. Parameters such as charset are ignored, so send text as UTF-8. The body, its media type, the Sailbox id, and a few bytes of framing together fit in 4 MiB, so a body only a little under 4 MiB may not. Clipboard writes do not wake the Sailbox. A paused Sailbox answers 409, so resume it first. One that is asleep, mid-transition, or still starting its clipboard answers 503, so retry with backoff. A Sailbox whose image has no clipboard answers 501.

Stream standard input

The stdin stream carries writes to one exec over a single WebSocket, instead of one PUT .../stdin request per write. Use it for interactive sessions. Start the exec with open_stdin or pty, then send GET .../exec/$EXEC_ID/stdin/stream with the standard WebSocket upgrade headers. A request refused before the upgrade gets the same JSON error a PUT .../stdin would. A paused Sailbox answers 409, so resume it first. The exec itself is checked on the first write: one that has ended, or that was started without open_stdin or pty, gets a 409 reply. Send each write as one binary message, then wait for its reply before you send the next. A write sent before the previous reply ends the stream: Offsets work the same way as on PUT .../stdin: an overlapping retry does not duplicate input. Each write gets one text message in reply:
  • {"accepted_through":N} when the write was applied. When N is lower than the end of your write, the Sailbox’s input buffer is full. Resend the rest from offset N after a short wait.
  • {"status":S,"error":{...}} when the write failed. status and error are the HTTP status and error body a PUT .../stdin would get. Sail then closes the stream with close code 1011. A failed write may still have been applied, so after a retryable error, open a new stream and resend the same bytes at the same offset.
  • A stream lasts at most 5 minutes. After that, the next write gets a 503 with code unavailable and nothing is written. Open a new stream and resend the same write.
  • If the Sailbox moves between machines, a later write on the stream fails with an error. Open a new stream and resend from the last accepted offset.
  • Sail pings every 30 seconds and drops a stream that does not answer within 10 seconds. Keep reading the WebSocket while you are not writing, so your WebSocket library can answer the pings.
  • A write carries at most 256 KiB of data. A larger message ends the stream with close code 1009. A text message or a malformed write gets a 400 reply and ends the stream.

Move files

Files stream in both directions without being buffered whole.
mode is decimal, 0 through 511, and create_parents defaults to true. A complete retry of an upload replaces the file safely. An interrupted one leaves the target unconfirmed. A download’s X-Sail-File-Mode header contains the mode, and Content-Length may be absent, so read until the response ends. For directories, run mkdir, find, tar, and rm through the command endpoint, which is what the SDKs do.

Share a file by URL

Mint a URL that serves one file without an API key, then hand it to a browser, curl, or a CDN:
The URL authorizes reading that one file until expires_at, an hour by default and up to seven days, and serves whatever the file holds when it is fetched. attachment: true serves it as a download instead of inline, and immutable: true marks it cacheable for a year, for a path you never overwrite. Fetch it with no header at all:
Content-Type comes from the file’s extension, Content-Length is its size, ETag changes whenever the file is written or replaced, and Last-Modified is its modification time.
  • A byte range answers 206.
  • If-None-Match answers 304 while the file is unchanged. A Range sent with If-Range gets the whole file instead when the file has changed, so a resume either continues the same version or starts over.
  • An expired or altered URL answers 403. Expiry does not touch copies already downloaded or cached.
  • A deleted file or a terminated Sailbox answers 404.
  • A paused Sailbox answers 409 until it is resumed. A sleeping one is woken before the first byte.
Share a file by URL in the filesystem guide covers caching and rewriting a file that has a live URL.
Keep the API key and the mint call on your server; only the minted URL reaches a browser or Cloudflare.A Worker that takes the minted URL as ?url= and returns a resized image:
  • The host check keeps the Worker from resizing arbitrary URLs, and the expiry check refuses an expired URL even when Cloudflare still holds a resized copy. Sail checks the signature on every fetch, so the Worker adds no authorization of its own and never holds your API key.
  • Do not set a cacheKey that drops the query, or a cached image would be served to a request Sail would have refused. Cloudflare caches the resized image under the full source URL, signature included, and follows the source’s caching rules with a one hour minimum. Mint the source URL with immutable for a path you never overwrite so the resized copies stay cached.
  • Give the source URL a lifetime that covers how long the page will reference it. After it expires, the Worker answers 403.
  • Cloudflare must be allowed to fetch from sailbox-api.sailresearch.com: enable transformations on your zone and add that host under Images > Transformations > Sources.
For video and other large files, return fetch(src) without the cf.image option and forward the client’s Range header, or point the client straight at the minted URL.

Connect to a port without publishing it

Publishing a port with a listener gives it a public hostname that any client can reach, subject to the access controls you set. A tunnel is for the other case: a server that should be private to you, such as a database, a debugger, or a web app you are still working on. Nothing is published. Your own connection is carried into the Sailbox over this API, with the same key and TLS as every other call, and it ends when you close it. Each tunnel forwards one TCP connection, so the natural use is a local port forwarder, the way ssh -L works: listen on a port on your machine and, for each connection you accept, open a tunnel and copy bytes both ways. Then psql -h localhost or a browser at http://localhost:3000 reaches the Sailbox. The interactive shell in the SDKs and the CLI uses tunnels this way to forward the Sailbox’s local servers and browser opens to your machine. To open a tunnel, send GET .../tunnel?port=N with the standard WebSocket upgrade headers. The program must be listening on 127.0.0.1 or on all interfaces. Sail authenticates the request, wakes a sleeping Sailbox, and connects to the port before answering 101, so a failure in any of those steps arrives as an ordinary JSON error. A paused Sailbox answers 409, so resume it first. A port with nothing listening is a 503 with code unavailable and the connection error as its message. Once the WebSocket is open, a failure ends it with close code 1011 and a reason of the form code: detail. Close code 1000 means the program in the Sailbox closed the connection. A Sailbox whose guest_schema_version is below 231 connects to the port only after answering 101, so a port with nothing listening there closes with code 1011 and that same unavailable reason. Upgrading the Sailbox changes that for every tunnel opened once the upgraded Sailbox is running.
  • Data in either direction is sent as binary messages. Messages you send are limited to 1 MiB. A larger one ends the tunnel with close code 1009.
  • The text message {"type":"eof"} closes the sender’s direction while the peer can keep sending, the way a TCP half-close does. A close frame ends the tunnel.
  • Sail pings every 30 seconds and drops a connection that does not answer within 10 seconds. The pings stop idle proxies from closing a tunnel that has no traffic.
  • A tunnel ends with close code 1011 if the Sailbox moves between machines. Reconnect when that happens.

What needs an SDK

These tasks happen outside this API:
  • Building an image with your own packages or files. Creating a Sailbox over HTTPS needs an image that is already built: a base image, or one an SDK built earlier from the same image block. Asking for an unbuilt image returns 409.
  • Turning on SSH inside a Sailbox for the first time. After that the rest is HTTPS.
Everything else, from the whole lifecycle to ports, custom domains, secrets, policies, metrics, and spend, is available over HTTPS. Volumes are in alpha, so those endpoints can still change.

Troubleshooting

Failures come back as an HTTP status and a JSON body with the same structure every time:
Match on the status and type. message is for people and can change.

Retrying safely

Every POST that creates or changes a Sailbox, a listener, or a volume takes an Idempotency-Key header. Generate one key per logical operation, any unique string up to 255 bytes, and send it on the first attempt and every retry. A retry with the same key, method, path, and byte-identical body does not run the request again. It gets the first response back, marked Idempotent-Replayed: true.
  • A key is remembered for at least 24 hours and is scoped to the API key that sent it.
  • Sail remembers 400 and 409 answers too, so after fixing a request send it under a fresh key. Reusing a key for a different request returns 409.
  • If the original is still running when the retry arrives, the retry waits for it. After 30 seconds it gets a 504. Retry again with the same key.
  • A 500, 503, or 504 usually means nothing happened and the same key runs the request again. Creating a Sailbox is the case to watch: the error can arrive after the Sailbox exists, and a retry can leave you with two. List your Sailboxes, then continue under a fresh key.
  • Terminating a terminated Sailbox, creating a volume that already exists, and registering a domain the same way twice are safe without a key. Domain registration ignores the header.
  • GET /sailboxes pages with limit (up to 100) and offset. Stop when has_more is false. app, status, and search filter, and manageable_by_caller=true hides private Sailboxes you cannot operate.
  • Resume returns 200 either way and reports resume_state: running, already_running, or terminal_unavailable, in which case error_message says why and you should create a new Sailbox.
  • status and resume_state are open sets and responses grow new fields. Match the values you care about and ignore the rest.
  • For its first ten minutes a new organization is capped on requests in flight. Going over the cap returns a 429 with Retry-After.