Docs · Reference

HTTP API

Base URL https://api.froogcorp.com/v1. Authenticate with an API key from the dashboard as a bearer token. Keys expire after 90 days by default (choose up to a year, or never, when you create one), and an expired key gets 401 api_key_expired. Keys can be scoped to actions and sandboxes. Requests and responses are JSON, and errors look like {"error": {"code": "...", "message": "...", "request_id": "req_..."}}. Every response has an X-Request-Id header with the same id. Quote it when you report a problem.

Rate limit. Each account can send 10,000 requests per 60 seconds, to any endpoint: all its API keys, in-sandbox keys and dashboard sessions together, from any address. Requests without a valid key count per IP address instead (each /64 over IPv6), 10,000 per 60 seconds. Sandboxes on one froog host share its IPv4 address, so their requests without a key aren't counted together, and over IPv6 each sandbox counts by its own address. A request counts once when it arrives, so a long-running one counts once. Every response carries RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset (seconds until the window ends) and RateLimit-Policy (10000;w=60). Past the limit you get 429 account_rate_limited (or 429 ip_rate_limited without a key) with Retry-After in seconds.

$ curl -s https://api.froogcorp.com/v1/sandboxes \
    -H "Authorization: Bearer $FROOG_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"name": "ci-1", "size": "small"}'

$ curl -s https://api.froogcorp.com/v1/sandboxes/ci-1/exec \
    -H "Authorization: Bearer $FROOG_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"command": "uname -a", "timeout_secs": 60}'
{"exit_code":0,"stdout":"Linux froog-… 6.8.0 …\n","stderr":""}

Creating, starting, forking and restoring fail with 402 subscription_required without a plan, 402 insufficient_funds when this period's plan usage and the wallet are used up, and 429 trial_compute_limit_reached past the free trial's 25 hours. During the trial, no auto-stop or a timer over 2 hours fails with 403 trial_auto_stop_required, and an account whose card already had a trial elsewhere gets 402 trial_blocked. xlarge below the $100 plan fails with 403 plan_required, a paused plan with 402 plan_paused, and a closed account with 403 account_closed. In an organization, a member past their caps gets 429 member_limit_reached or 402 team_member_cap_reached. Past a start limit (per minute, hour or day, see GET /v1/limits) without extra starts they fail with 429 limit_reached and the headers Retry-After (seconds until a start is free again), RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset and RateLimit-Policy (e.g. 10;w=60). Creating, stopping, starting, forking and restoring return 202 Accepted and continue in the background. Poll GET /v1/sandboxes/{name} until state is running or stopped.

Retries. Send an Idempotency-Key header (any unique string up to 255 characters) with POST /sandboxes and POST /sandboxes/{name}/fork to make them safe to retry after a timeout or a dropped connection. A retry with the same key within 24 hours returns the first response, with the same sandboxes and status and an Idempotent-Replayed: true header, instead of creating more. The same key with a different request gets 422 idempotency_key_reused, and a retry while the first request is still running gets 409 idempotency_key_in_use. Errors aren't kept, so a retry after one runs again. Keys belong to the account. froog new and froog fork send one and retry with it when the connection drops.

Streamed exec. With "stream": true (or Accept: application/x-ndjson), POST /sandboxes/{name}/exec answers application/x-ndjson: one JSON object per line, as the output arrives. First {"type":"started","command_id":…}, then {"type":"stdout","data":…} and {"type":"stderr","data":…} chunks (UTF-8 text), then exactly one {"type":"exit","exit_code":0,"timed_out":false}, or {"type":"error","code":…,"message":…} if the connection to the sandbox broke. Errors before the command starts (not running, bad request) are ordinary JSON errors. Hanging up stops the command. It's in the history with mode stream. froog exec streams this way, and the SDKs have execStream (exec_stream in Python).

Watching files and ports. Two more NDJSON streams stay open while you listen (up to timeout_secs, 1-3600, default 3600, then reconnect). GET /sandboxes/{name}/files/watch?path=src&recursive=true sends {"type":"ready","method":"inotify"}, then {"type":"change","event":"create"|"modify"|"delete","path":…,"dir":…} for each change under the path (a file written and closed is modify, a move is a delete and a create), then {"type":"end","reason":"timeout"}. Paths come back as given: relative to ~ or absolute. It uses inotify where the image has inotify-tools, and otherwise scans every second ("method":"poll"), which sees changes up to a second late. A path that doesn't exist sends {"type":"error"}. GET /sandboxes/{name}/ports/watch sends ready, then {"type":"open"|"close","port":3000,"protocol":"tcp"} as TCP ports start or stop listening on any address (the ones already listening come first as open), read every second. Both need a running sandbox (or start an auto-start one), file.read and sandbox.read respectively, and each holds one of the sandbox's 8 command slots while open. SDKs: sb.files.watch(path) yields the changes. The flat watchFiles and watchPorts yield every event.

$ curl -sN https://api.froogcorp.com/v1/sandboxes/ci-1/exec \
    -H "Authorization: Bearer $FROOG_API_KEY" -H "Content-Type: application/json" \
    -d '{"command": "make test", "stream": true}'
{"type":"started","command_id":"cmd_…"}
{"type":"stdout","data":"ok  build\n"}
{"type":"exit","exit_code":0,"timed_out":false}
$ KEY=$(uuidgen)   # reuse it for every retry of this request
$ curl -s --retry 3 https://api.froogcorp.com/v1/sandboxes \
    -H "Authorization: Bearer $FROOG_API_KEY" \
    -H "Idempotency-Key: $KEY" \
    -H "Content-Type: application/json" \
    -d '{"name": "ci-2"}'
GET/sandboxes?label=&name=&name_prefix=&state=&sort=&limit=&cursor=List sandboxes, oldest first (sort=desc: newest first). Filters: label=KEY=VALUE or label=KEY (repeat to require several), name, name_prefix, state (comma-separated for several). Without limit you get every match. With limit (1-200), an X-Next-Cursor header comes back while more follow: pass it as cursor. The cursor is <created_at>_<id> of the page's last sandbox. Unknown parameters get 400 invalid_query
POST/sandboxesCreate: {name?, size?, ttl_secs?, count?, setup?, environment?, os?, from?, snapshots?, monitoring?, env?, files?, labels?, max_lifetime_secs?, delete_after_secs?, expires_in_secs?, repo?, auto_start?}. ttl_secs is the auto-stop timer: seconds after each start (default 3600, 0 = none, at most 30 days). auto_start: true starts it on a URL request or an API call while stopped (see auto-start). max_lifetime_secs stops it that long after each start and delete_after_secs deletes it after staying stopped that long (see lifetime). repo: {url, branch?, dir?} is cloned into it at boot (see one sandbox's repo). labels is a {KEY: VALUE} object (see labels). setup is a script run once after the first boot, environment: false starts without your environment (the default from a share code or its copy, true opts in), from starts from a saved snapshot (or a share code fsh_…), environment_name picks an environment (default: your default one), files: [{path, content, encoding?}] are written into ~ before it's running (encoding: "base64" for binary, up to 20, 1 MiB together)
GET/sandboxes/{name}Show one. Lists and this include period_cost_usd (its running time this billing period, at list prices), setup_status (none, running, done, failed) and setup_error, max_lifetime_secs and stop_at, delete_after_secs and delete_at, expires_at, repo and auto_start
GET/sandboxes/{name}/metricsRead now from a running sandbox: {sampled_at, cpu_count, cpu_percent, memory_used_bytes, memory_total_bytes, disk_used_bytes, disk_total_bytes, network_rx_bytes, network_tx_bytes, io_pressure}
GET/sandboxes/{name}/usageIts running time and cost: {name, from, to, seconds, cost_usd, lines: [{size, seconds, cost_usd}]}. ?since=&until= in unix seconds (default: this billing period so far)
PATCH/sandboxes/{name}Rename, change the auto-stop timer (a running sandbox then stops that long from now), labels, lifetime, auto-delete or auto-start: {name?, ttl_secs?, labels?, max_lifetime_secs?, delete_after_secs?, expires_in_secs?, auto_start?} (0 removes a lifetime or auto-delete). In labels, a value sets that label and null removes it. Labels you don't name stay
GET/saved-snapshotsYour saved snapshots: [{name, id, source, source_deleted, os, size, size_gb, status, created_at}]. Start one with POST /sandboxes {"from": NAME}. GET /limits has saved_snapshot_allowance: {included, used, extra, price_per_extra_day_usd}
GET · DELETE/saved-snapshots/{name}One saved snapshot (poll status after saving, until available) · remove it (and revoke its share codes)
GET · POST/saved-snapshots/{name}/sharesIts share codes: [{id, name, created_at, revoked_at, uses, last_used_at}] · make one: {id, name, code, created_at} (the code only here)
DELETE/saved-snapshots/{name}/shares/{id}Revoke a share code
GET/saved-snapshots/{name}/files · …/files/downloadInside a saved snapshot: ?path=P (default ~): a file's details or a folder's entries · a file's bytes or a folder as tar
GET/snapshots?sort=&limit=&cursor=Every snapshot of the account, newest first (sort=asc: oldest first), each with its sandbox. Paged like /sandboxes: with limit (1-200), X-Next-Cursor comes back while more follow
DELETE/sandboxes/{name}Delete it and its automatic snapshots (saved snapshots stay)
POST/sandboxes/{name}/execRun a shell command: {command, timeout_secs?, cwd?, env?, stream?, stdin?, stdin_b64?} (max 3600) → {exit_code, stdout, stderr, timed_out, command_id}. cwd is relative to ~ or absolute, env a {KEY: VALUE} object for this command only. Past its timeout the command is killed: timed_out: true, exit code 124, and the output until then. stream: true (or Accept: application/x-ndjson): see streamed exec. stdin (text) or stdin_b64 is the command's input, up to 1 MiB (see standard input)
POST/sandboxes/{name}/waitWait until something listens on a TCP port inside it: {port, timeout_secs?} (1-600, default 60) → {port, ready, waited_ms} (see wait for a port)
GET · POST/sandboxes/{name}/urlsList · add (or change) a URL: {port, public?, subdomain?, title?} (up to 50 per sandbox), private (with ?_token=) unless public: true
DELETE/sandboxes/{name}/urls/{port}Remove it
GET · POST/sandboxes/{name}/urls/{port}/tokensLive viewer tokens of a private URL · make one: {ttl_secs?, label?} → {id, token, url, label, created_at, expires_at} (see a token per viewer)
DELETE/sandboxes/{name}/urls/{port}/tokens/{id}Revoke a viewer token
GET/sandboxes/{name}/files?path=PA file's details, or a folder and its entries. Relative paths are relative to /home/user
GET · PUT/sandboxes/{name}/files/content?path=PDownload · upload raw bytes, up to 100 MiB (parent folders are created, and &mode=755 sets permissions)
PUT/sandboxes/{name}/files/archive?path=DIRUnpack a .tar.gz (raw bytes, up to 100 MiB) into a folder, created if missing. froog push uses it
DELETE/sandboxes/{name}/files?path=PDelete a file or empty folder, or with &recursive=true a folder and its contents
POST/sandboxes/{name}/files/move · /files/mkdir{from, to} · {path, mode?} (parents are created, mode is octal, e.g. "700", for the folder itself)
POST/sandboxes/{name}/files/permissionschmod: {path, mode, recursive?}, mode octal like "755". The owner stays the sandbox user
POST/sandboxes/{name}/files/download-url · /files/upload-url{path, expires_in?} → {url, method, expires_at}: a link that GETs the file, or takes a PUT of its raw bytes, with no Authorization header (15 minutes by default, 60 s to 24 h, up to 100 MiB). Hand it to a browser, a CI step or another service
GET/sandboxes/{name}/files/search?pattern=*.py&path=~Paths whose name matches a glob: {files, truncated, errors?} (at most 10,000, absolute)
GET/sandboxes/{name}/files/find?pattern=TEXT&path=~&regex=falseEvery line containing the text (or an extended regex): {matches: [{file, line, content}], truncated}. At most 1,000, lines cut at 500 characters, binary files skipped, symlinks not followed
GET/sandboxes/{name}/files/watch?path=&recursive=&timeout_secs=Stream file changes under a path as NDJSON (see watching)
GET/sandboxes/{name}/ports/watch?timeout_secs=Stream TCP ports starting or stopping to listen as NDJSON
POST/sandboxes/{name}/files/replace{paths, pattern, new_value}: replace every occurrence of a plain text, byte for byte, in up to 100 files (5 MiB each), with one {file, success, replacements?, error?} per file
GET · POST/sandboxes/{name}/jobsList · start a background command: {command, cwd?, env?, stdin?, stdin_b64?, stdin_open?}
GET/sandboxes/{name}/jobs/{id}?offset=NState, exit code and output from byte offset. Pass next_offset back to follow
POST/sandboxes/{name}/jobs/{id}/stopStop it (TERM, then KILL)
POST/sandboxes/{name}/jobs/{id}/stdinWrite to the input of a job started with stdin_open, and/or close it: {data?, data_b64?, close?} → 204 (see writing to a running job)
GET/sandboxes/{name}/command-history?mode=&sort=&limit=&cursor=Commands, newest first (sort=asc: oldest first). Pass next_cursor back as cursor for older ones
GET/sandboxes/{name}/command-history/{id}One record: text, mode, status, exit code, output sizes
GET/sandboxes/{name}/command-history/{id}/logs?stream=stdout|stderr&offset=N&encoding=base64Stored output from offset (≤1 MiB): {data, next_offset, size, done}
GET/sandboxes/{name}/processesProcesses, busiest first. A stopped sandbox answers with its last sample and live: false
GET/sandboxes/{name}/processes/samplesThe 12 busiest processes every 30 seconds, kept 7 days, newest first: ?since=&until= (unix seconds), limit (1-1000, default 120)
GET/sandboxes/{name}/processes/recordsProcesses seen in the 30-second checks, kept 72 hours: ?since=&until=&running=true&limit= (1-1000, default 200)
GET/sandboxes/{name}/metrics/historyCPU, memory and disk every 30 seconds, kept 7 days: ?since=&until=&step=
GET · PATCH/account/alertsWhich emails the account gets, and automatic rebuilds: {emails: {KIND: bool}, auto_rebuild}
POST/sandboxes/{name}/promptPrompt an agent and wait: {prompt, agent?, model?, effort?, fast?, new?, conversation_id?, attachments?, timeout_secs?}, or 400 provider_not_configured without a key or login
POST/sandboxes/{name}/promptsQueue a prompt (same body). 202 with the prompt. GET lists them (?conversation=)
GET/sandboxes/{name}/prompts/{id}Status, result, error. /events: its events (JSON, SSE or NDJSON)
GET/sandboxes/{name}/eventsEvery conversation's events (?conversation=, ?cursor=)
GET/sandboxes/{name}/conversations[/{id}]Conversations, or one with its turns. DELETE removes one
POST/sandboxes/{name}/steer, /interrupt{message, conversation_id?}. {conversation_id?}
GET/provider-modelsHarnesses, models, effort levels, fast mode (no key needed)
GET/subscriptionsClaude and ChatGPT sign-ins. POST /subscriptions/{provider}/login starts one
POST/sandboxes/{name}/stopRun ~/.froog/pre-stop, snapshot and stop: {force?} (true keeps the last good snapshot if this one fails). The answer's stop_id tracks it (stopping again while it's pending answers the same id)
GET/stops/{id}A stop: {id, sandbox, status, requested_at, ended_at, error}, status pending, failing (its snapshot failed: the sandbox keeps running, unbilled, and froog retries this stop, see Stopping safely), completed (stopped, or erased with snapshots off), superseded (a start cancelled a failing stop) or failed (the sandbox is in error, a start retries from its last snapshot). Auto-stops and billing stops get one too
POST/sandboxes/{name}/startStart from the latest snapshot: {size?, ttl_secs?, environment?, env?, environment_name?}. environment_name moves it to that environment's latest version. ttl_secs is a new auto-stop timer, counted from this start (0 = none), environment: false boots without your environment (true with it again), env adds to its own variables. All are kept for later starts. New variables or environment need a fresh boot, so such a start doesn't resume warm
POST/sandboxes/{name}/fork{name, snapshot?, count?, size?, env?, ttl_secs?, snapshots?, environment?, environment_name?} (env goes on top of the source's own variables, ttl_secs defaults to the source's, snapshots: false makes copies a stop erases, environment: false runs them without your environment, your logins removed, environment_name puts them on that environment's latest version instead of the source's)
POST/sandboxes/{name}/restore{snapshot} (id or label)
GET/sandboxes/{name}/snapshots?sort=&limit=&cursor=List snapshots, oldest first (sort=desc: newest first), paged like /snapshots
POST/sandboxes/{name}/snapshotsSave one: {label?}. With a new label past your plan's included saved snapshots and an empty wallet: 402 credit_required
DELETE/sandboxes/{name}/snapshotsDelete every automatic snapshot: {deleted, kept}. Saved snapshots stay, and so does the one a stopped sandbox starts from (kept)
GET/sandboxes/{name}/snapshots/{snap}One snapshot by id or label. latest is the newest available one
DELETE/sandboxes/{name}/snapshots/{snap}Delete a snapshot
GET/sandboxes/{name}/snapshots/{snap}/files · …/files/downloadInside a snapshot (the sandbox may be stopped): ?path=P (default ~): a file's details or a folder's entries · a file's bytes or a folder as tar (X-Froog-Entry-Kind: file|dir)
POST/sandboxes/{name}/desktopA link to the desktop in froog's viewer: {url, expires_at} (open within 10 minutes). ?theme=dark|light
POST/sandboxes/{name}/browserA link to the browser view (only Chrome). Body {"profile": "/abs/dir"} (optional), and ?theme=dark|light picks the theme
GET/sandboxes/{name}/sshSSH host, user, port and pinned host key
POST/sandboxes/{name}/touchRecord activity (last_active_at). It doesn't move the auto-stop deadline: PATCH ttl_secs does
GET · POST/inboxesList · make an email inbox, $2 a month {username?, name?, sandbox?} (Tokenmail)
GET · PATCH · DELETE/inboxes/{inbox}Show · rename or attach to a sandbox {name?, sandbox?} · delete (by address, the part before @, or id)
GET · POST/inboxes/billing · /inboxes/billing/checkoutSeats, inboxes and the subscription · a Checkout url that buys inboxes {quantity?}
GET/inboxes/{inbox}/messages?since=&limit=Received messages, newest first, with codes and links
GET · DELETE/inboxes/{inbox}/messages/{id}One message with text and html · delete; …/raw as received
GET/inboxes/{inbox}/wait?since=&after=&sender=&subject=&timeout=Wait for the next matching message (up to 120 s): {message}, null on timeout
GET · POST/webhooksList · add {url, events?, name?} (returns the signing secret once)
GET · PATCH · DELETE/webhooks/{id}Show · change {url?, events?, name?, enabled?} (the secret stays, "" removes the name) · remove
GET/webhooks/{id}/deliveries?limit=Delivery attempts of the last 30 days, newest first: [{event_id, event, sandbox?, attempt, status, response_code?, error?, duration_ms, created_at, next_attempt_at?}]. limit 1-200, default 50
POST/webhooks/{id}/rotate · /testNew secret · send a ping
GET · DELETE/account/connections · /account/connections/{id}Sign-in connections · remove one (not the last: 409 last_connection)
POST/account/connections/email · /email/verifySend a code to {email} (202) · add it with {email, code}
PUT/account/primary{connection}: its address gets the account's emails
GET · POST/github · /github/connectGitHub connection · a link that installs the app
GET · PUT · DELETE/github/repos · /github/token-repos · /github/installations/{id}Repositories the app sees · limit the sandbox token {repos} · unlink
POST/sandboxes/{name}/github-tokenA one-hour GitHub token for the sandbox {owner?}: {token, expires_at, owner, repositories}
GET · PUT/ci · /ci/settingsPushbox overview ?repo=&state=&limit= · public repositories {public_repos, public_users}
GET/ci/runs · /ci/runs/{run_id}Workflow runs, a page at a time ?workflow=&repo=&branch=&state=&q=&limit=&offset= · one run and its jobs
GET/ci/jobs/{id} · /ci/jobs/{id}/logsOne job · its log (text)
GET/envYour environment variables (names and masked previews)
PUT · DELETE/env/{name}Set {value} · remove a variable
GET · PATCH/environmentThe default environment: {name, default, version, repos, setup, repo_setup, files, updated_at} (file names and sizes) · change {repos?, setup?, repo_setup?} (setup: "" removes it, repo_setup: {"app": "npm ci"}, "" removes one). A change makes a new version
PUT · DELETE/environment/filesAdd or replace {path, content} · remove ?path=P
GET · POST/environmentsYour environments: [{name, default, version, repos, files, setup, created_at, updated_at, sandboxes: [{version, sandboxes}]}] · create {name, copy_from?, default?}
GET · PATCH · DELETE/environments/{env}One environment, like /environment · change {repos?, setup?, repo_setup?, name?, default?} (a rename or a new default makes no version) · delete it (not the default)
PUT · DELETE/environments/{env}/filesAdd or replace {path, content} · remove ?path=P
GET/environments/{env}/versions[{version, created_at, change, sandboxes}], newest first
POST/environments/{env}/upgradeMove its sandboxes to the latest version: {sandboxes?} (names, default all) → {environment, version, applied, at_next_start}
GET · POST/ssh-keysList · add {name, public_key}
DELETE/ssh-keys/{id}Remove a key
GET/accountYour account and limits
GET · POST/api-keysList · create {label?, ttl_days?, never_expires?, preset? | actions?, sandboxes?, labels?, environments?}. labels limits it to sandboxes with all of them (see label-limited keys), environments (names) to sandboxes of those (see environment-limited keys). Keys expire after 90 days unless you choose otherwise (up to 365, or never). Without a scope the key has full access. The key is returned once
DELETE/api-keys/{id}Revoke a key
GET · POST/orgsYour personal account and organizations · create one {name}
PATCH/orgs/activeThe active organization {org} (id, name or personal)
GET · PATCH · DELETE/orgs/{org}One organization · {name?, default_visibility?} · delete it
GET · PATCH · DELETE/orgs/{org}/members · /members/{member}Members and invites · {role?, max_running_sandboxes?, max_usage_usd?} · remove one
POST · DELETE/orgs/{org}/invites · /invites/{id}Invite {email, role?} · revoke
POST · PUT/orgs/{org}/leave · /transfer · /seatsLeave · {member} becomes the owner · {seats}
GET · POST/org-invites/{token} · /org-invites/acceptWhat an invite is for · accept it {token}
POST/sandboxes/{name}/share · /unshareShared with the organization · private to its creator
POST/api-keys/{id}/rotateA new secret for the key (returned once), with the same id, scope and expiry
GET/audit?action=&sandbox=&since=&until=&sort=&limit=&cursor=The audit log, newest first: {events, next_cursor} (needs account.admin)
GET/audit/export?format=csv|ndjsonEvery matching entry of the 90 days as one file, oldest first (same filters)
GET · PATCH/account/data-retention{zero_data_retention, snapshots_off, monitoring_off}. Changing needs a dashboard session, and turning on zero data retention needs confirmation: "delete stopped sandbox data" (the answer adds deletion_operation)
POST/account/closeClose the account, with the header X-Froog-Confirm-Close: EMAIL: 202 {account, operation}. A full-access key or a session
POST/account/reopenReopen it within 30 days: {account, operation}
GET/deletion-operations/{id}{id, kind, status, stage, created_at, updated_at, expected_by, error}
POST/dashboard-link{url, expires_at}: opens the dashboard signed in, once, within 15 minutes, after a "Continue as EMAIL?" page (opening the link alone signs nobody in). A full-access key or a session
GET · PUT · DELETE/telemetryThe OpenTelemetry endpoint: PUT {endpoint, headers} answers {telemetry, test: {ok, status, message}}
POST/feedbackSend feedback: {message}
GET/usageThis billing period: {lines: [{size, seconds, cost_usd}], saved_snapshot_days, snapshot_cost_usd, total_usd, included_usd, billable_usd, from, to, free_trial_until}. billable_usd is the part past the plan, paid from the wallet. ?month=YYYY-MM: that calendar month at list prices
GET/billing{plan, plans, rates, included_usd, used_usd, remaining_usd, wallet_usd, auto_topup_usd, auto_topup_failures, min_topup_usd, grace_until, wallet_bonus, extra_starts, keep_disks_until}. plan is {usd, status, period_start, period_end, trial_ends_at, cancel_at_period_end, max_sandboxes, saved_snapshots}, and status is trialing, active, past_due (a renewal's payment is being retried; the plan still works), unpaid (the first payment failed; the plan doesn't work until it's paid)…
POST · DELETE/billing/planChoose or change a plan {plan}, answers {url?, billing} with a Stripe Checkout url for the first plan · cancel at the end of the period (?keep_disks=true: pause)
PUT/billing/extra-starts{enabled}: extra starts on or off
GET/billing/wallet/quote?amount_usd=N{amount_usd, bonus_percent, bonus_usd, credited_usd}
POST/billing/trial/endEnd the free trial now and start paying for the plan
POST/billing/wallet/payAdd {amount_usd} to the wallet, charged to the saved card
PUT/billing/wallet/auto-topupAuto top-up amount {amount_usd}, 0 turns it off
POST/billing/portalA Stripe page URL to change the card or download invoices