API
Publish with no credentials at all, or authenticate for everything else. This is the API behind every other interface — anything the Web, CLI and SDK can do, you can do directly.
https://api.shipstatic.com
Deploy to your account
Deploying needs no account — POST /deployments with no Authorization header works, and the response carries a claim URL the user can visit to keep the site permanently. Everything else needs a credential: pass an API Key or a deploy token in the Authorization header. The prefix says which it is:
| Method | Format | Use case |
|---|---|---|
| API key | Bearer ship-{32 hex} |
Persistent access, full account |
| Deploy token | Bearer deploy-{32 hex} |
Scoped to deploys, optional TTL |
One slot takes either — the prefix is what tells the two apart, so there is no precedence to reason about.
Endpoints
Deployments
| Method | Path | Description |
|---|---|---|
POST |
/deployments |
Upload a new deployment (multipart) |
POST |
/deployments/:deployment/files |
Send more files to a deployment declared with a manifest (see Large deploys) |
GET |
/deployments |
List all deployments (paginated) |
GET |
/deployments/:deployment |
Get deployment details |
PATCH |
/deployments/:deployment |
Update labels |
DELETE |
/deployments/:deployment |
Delete deployment (async) |
POST /deployments accepts an optional password field (6–128 characters) to protect the deployment with an unlock page. Passwords are handled securely server-side and can never be read back through the API. Deployment responses expose password: boolean indicating whether protection is active.
Domains
| Method | Path | Description |
|---|---|---|
PUT |
/domains/:domain |
Create, link, switch, or label (upsert) |
GET |
/domains |
List all domains (paginated) |
GET |
/domains/:domain |
Get domain details |
DELETE |
/domains/:domain |
Delete domain |
POST |
/domains/validate |
Pre-flight availability check (name in the body) |
POST |
/domains/:domain/verify |
Trigger DNS verification |
GET |
/domains/:domain/dns |
Which DNS provider hosts the domain (Cloudflare, Namecheap, …) |
GET |
/domains/:domain/records |
The DNS records you need to add |
GET |
/domains/:domain/share |
Shareable DNS setup link |
Tokens
| Method | Path | Description |
|---|---|---|
POST |
/tokens |
Create token (full secret returned only here) |
GET |
/tokens |
List all tokens (short identifiers only) |
DELETE |
/tokens/:token |
Revoke token |
Account
| Method | Path | Description |
|---|---|---|
GET |
/account |
Get current account |
POST |
/account/claim |
Claim a public deployment with a claim hash |
GET |
/activities |
Account audit log (paginated) |
GET |
/labels |
Distinct labels in use across the account |
GET |
/limits |
Plan-based platform limits (max file size, file count, total size) |
GET |
/ping |
Health check |
Plans
| Method | Path | Description |
|---|---|---|
GET |
/plans |
The plan menu: every plan on sale, with its price and caps. Public, no credential. Its fields are described under Plans |
Examples
Anonymous public deploy
curl -X POST https://api.shipstatic.com/deployments \
-F 'files[]=@dist/index.html' \
-F 'files[]=@dist/styles.css'
Response (201):
{
"deployment": "happy-cat-abc1234",
"url": "https://happy-cat-abc1234.shipstatic.com",
"claim": "https://my.shipstatic.com/claim/...",
"files": 2,
"size": 4321,
"status": "success",
"password": false,
"via": "sdk",
"created": "...",
"expires": "..."
}
claim and expires are present only on anonymous deploys. Visiting claim while signed in transfers the deployment to your account.
Authenticated deploy
curl -X POST https://api.shipstatic.com/deployments \
-H 'Authorization: Bearer ship-your-api-key' \
-F 'files[]=@dist/index.html' \
-F 'labels=["production"]' \
-F 'password=hunter22'
Response is the same shape minus claim and expires.
List with pagination
List endpoints return a cursor, which is null on the last page. Pass a non-null one back as ?cursor= to fetch the next page.
curl -H 'Authorization: Bearer ship-your-api-key' \
'https://api.shipstatic.com/deployments?limit=50'
{
"deployments": [...],
"cursor": "eyJpZCI6ImhhcHB5LWNhdC1hYmMxMjM0In0"
}
Custom domain — full flow
# 1. Validate the name
curl -X POST -H 'Authorization: Bearer ship-your-api-key' \
https://api.shipstatic.com/domains/www.example.com/validate
# 2. Create + link to a deployment
curl -X PUT -H 'Authorization: Bearer ship-your-api-key' \
-H 'Content-Type: application/json' \
-d '{"deployment":"happy-cat-abc1234"}' \
https://api.shipstatic.com/domains/www.example.com
# 3. Get required DNS records
curl -H 'Authorization: Bearer ship-your-api-key' \
https://api.shipstatic.com/domains/www.example.com/records
# 4. After configuring DNS, trigger verification
curl -X POST -H 'Authorization: Bearer ship-your-api-key' \
https://api.shipstatic.com/domains/www.example.com/verify
Claim a public deployment
curl -X POST https://api.shipstatic.com/account/claim \
-H 'Authorization: Bearer ship-your-api-key' \
-H 'Content-Type: application/json' \
-d '{"claim":"<claim-hash-from-deploy-response>"}'
Returns the claimed deployment, now bound to your account.
Multipart upload shape (POST /deployments)
| Field | Type | Notes |
|---|---|---|
files[] |
File parts | One per file. The filename carries the path (webkitRelativePath style) |
manifest |
JSON-encoded array of { path, size, md5 } |
Optional. Lists every file of a deployment sent in several requests (see Large deploys). Without it, the files you send are the whole deployment |
labels |
JSON-encoded string[] |
Optional |
password |
string | Optional, 6–128 characters |
ttl |
number of seconds | Optional, needs a credential. The deployment is deleted when the time is up |
via |
string | Optional origin tag (cli, sdk, mcp, etc.) |
spa |
true or false |
Optional. Send true and we check whether the site is a single-page app: if its index.html is an empty app shell and you sent no ship.json, we add the routing config that serves every path from index.html. Leave it out or send false and we add nothing. The CLI, the SDK and the integrations send true for you |
The API works out each file's MD5 from what it receives, so a deploy in one request needs no manifest.
Every deployment needs an index.html at the top, the page visitors see first. A deployment without one, or with an empty one, is refused with 400 validation_failed, before anything is stored. An empty file is never stored, so any other empty file is left out rather than refused. Each path can appear only once: index.html and /index.html are the same file.
Large deploys
One request can carry at most 64 MB. The CLI and the SDK split a bigger site for you, and so does every tool built on them, so you only need this if you write your own client.
A bigger site goes in several requests:
- Declare it. Send
POST /deploymentswith amanifestfield listing every file: its path, its size in bytes and its MD5. If the manifest lists a rootship.json, send it in this request, because it sets up the site. If you sendspa=true, send the rootindex.htmlhere too, because it's the page we check. You can send other files here too. If every file is there, the answer is201and the site is live. If not, the answer is202, and the deployment'sstatusispending. - Send the rest. Send the other files to
POST /deployments/:deployment/filesasfiles[], in as many requests as you need. Each answer is202while files are still missing, and201when the last one arrives and the site goes live.
A few things to know:
- Each file must match the manifest: the same path, size and MD5. A file that doesn't is refused.
- Sending a file again is safe. It replaces itself with the same bytes. Once the site is live, the files route changes nothing and answers
200with the deployment. - It needs a credential. A deploy without an account always fits in one request, so it never needs the files route.
- If you stop partway, nothing is published. We delete the unfinished deployment after a while, and the files route then answers
404. Start again with a new declaration.
Status Codes
| Code | Meaning | Example |
|---|---|---|
200 |
Success | Read, update, synchronous delete |
201 |
Created | POST /deployments, POST /tokens |
202 |
Accepted | DELETE /deployments/:deployment (async cleanup), or a large deploy with files still to come |
400 |
Bad request | Invalid input, missing fields, or a request the platform refuses |
401 |
Authentication error | Missing or invalid credentials |
403 |
Forbidden | Account terminated, plan limit reached, operation not permitted |
404 |
Not found | Resource doesn't exist or belongs to another account |
409 |
Conflict | A domain already taken, a domain operation already in progress, a deployment a domain still points at |
413 |
Payload too large | A deploy request over 64 MB, or a JSON body over its own, smaller limit (the message says how big) |
415 |
Unsupported media type | A deploy sent as neither multipart/form-data nor application/json |
422 |
Unprocessable | The project failed to build, or the resource's state doesn't allow this operation |
429 |
Rate limited | Too many requests; retry after Retry-After seconds |
500 |
Server error | Something failed on our side |
503 |
Unavailable | The platform is in maintenance, or a service it depends on is briefly unavailable |
Errors
Every error answers the same shape:
{
"error": "not_found",
"message": "Deployment not found",
"status": 404
}
error is the type tag, and one of these:
| Tag | Status | Meaning |
|---|---|---|
validation_failed |
400 |
The input is malformed |
authentication_failed |
401 |
Credentials are missing or invalid |
forbidden |
403 |
Authenticated, but not allowed to do this |
not_found |
404 |
The resource doesn't exist, or isn't yours |
rate_limit_exceeded |
429 |
Too many requests; wait Retry-After seconds |
business_logic_error |
400, 409, 413, 415, 422 |
Well formed, but the platform refuses it; the message says why |
build_failed |
422 |
The project could not be built. details.log carries the tail of the build output. Fix the project; retrying gives the same answer |
maintenance |
503 |
The platform is closed on purpose. The message says when it reopens, so wait rather than retrying in a loop |
internal_server_error |
500, 503 |
Something failed on our side; a later retry may succeed |
message is written for the person using your tool, so show it as it is. Some errors add a details object with structured context.
Branch on error and status, never on the message text. Messages are rewritten for clarity; the tag and the status are the contract.
Pagination
List endpoints (/deployments, /domains, /tokens, /activities) return cursor-paginated results:
{ "deployments": [...], "cursor": "..." }
Pass the cursor as ?cursor=... to fetch the next page. cursor is always present, and null on the final page. Default page size is 50; pass ?limit=N to override (capped at 300).
Rate Limits
Per-account global limiter; deploy endpoints additionally limited per-credential to prevent abuse. On exhaustion, the API returns 429 with a Retry-After header (seconds). Anonymous public deploys have a tighter per-IP limit than authenticated requests.
Idempotent Deploys
POST /deployments accepts an optional Idempotency-Key header (up to 256 characters; a UUID is a good choice). The key names the deployment the request creates. Send the same key again within 24 hours and you get that deployment back as it is now, with 201 and Idempotency-Replay: true, instead of a second one. A key works only within the account that sent it. Without an account, the key is ignored and each request creates a new deployment. Use it whenever your client retries on timeout. Only a deployment that went live is answered this way, so retrying after an error deploys again. For a large deploy, send the key on the declaration. The files route takes none, because sending a file again is already safe.
Conventions
Content type. All requests and responses use application/json. Deployment upload also accepts multipart/form-data.
Domain names in paths are URL-encoded. The API normalizes casing and Unicode (e.g. Example.COM → example.com).
Async operations. Only deployment deletion is asynchronous (returns 202). All other deletes are synchronous (200).
Errors share one shape and one set of tags; see Errors.