---
name: shipsite
description: Publish static sites to ShipSite (shipsite.co) — instant hosting with a live vanity URL. Covers the full workflow: authentication, create, upload files directly to S3, finalize, incremental deploys, version history, rollback, password protection, analytics, and cleanup.
---

# ShipSite Publishing Skill

ShipSite gives any file or set of files a live public URL at `<slug>.shipsite.co` in seconds. Files go directly from the caller to S3 via presigned URLs — uploads bypass the server. Public reads go through the application to check expiry.

## Base URL

```
https://shipsite.co
```

---

## Authentication

ShipSite supports **anonymous** publishing (no account) and **authenticated** publishing (full management features). **Free sites expire 24 hours after creation.** Paid (US$3.99/month) and sponsored accounts create permanent sites by default, with 500 MB shared storage and 5 GB transfer per UTC calendar month. Use `"permanent": true` to require paid hosting (returns 402 without entitlement) or `"permanent": false` to explicitly create a temporary site. Permanent responses have `expiresAt: null`. Existing temporary sites are not upgraded automatically. Claiming, updates, and rollback do not reset this deadline. Duplicates inherit the source site’s deadline. Existing sites follow the same policy.

### Get an API key (one-time setup)

**Step 1 — Request a code:**
```
POST /api/auth/agent/request-code
Content-Type: application/json

{"email": "you@example.com"}
```

**Step 2 — Verify and receive your key:**
```
POST /api/auth/agent/verify-code
Content-Type: application/json

{"email": "you@example.com", "code": "ABCD-EFGH"}
```

Response:
```json
{"apiKey": "sk_..."}
```

> **Save the API key immediately.** It is shown only once and cannot be recovered.

### Using your API key

Add to every authenticated request:
```
Authorization: Bearer sk_your_api_key_here
```

---

## The Three-Step Publish Flow

Every publish — anonymous or authenticated — follows the same three steps:

```
1. POST /api/v1/publish          → get presigned upload URLs + versionId
2. PUT  <presigned S3 URL>       → upload each file directly to S3
3. POST /api/v1/publish/:slug/finalize → make the site live
```

### Step 1 — Create

```
POST /api/v1/publish
Content-Type: application/json
Authorization: Bearer sk_...   (omit for anonymous)

{
  "files": [
    {"path": "index.html", "size": 1024, "contentType": "text/html"},
    {"path": "style.css",  "size": 512,  "contentType": "text/css",  "hash": "sha256:<hex>"},
    {"path": "app.js",     "size": 8192, "contentType": "application/javascript"}
  ],
  "viewer": {
    "title": "My Project",
    "description": "Optional meta description",
    "ogImagePath": "og.png"
  },
  "spa": false
}
```

Set `"spa": true` when publishing a single-page app that handles its own routing. An unmatched browser navigation then serves `index.html` (status 200) rather than `404`, so reloading on a client-side route such as `/dashboard` works. Missing assets still return `404` — a script or stylesheet must never be served HTML. Defaults to `false`, which keeps real 404s for multi-page sites; change it later with `PATCH /api/v1/publish/:slug/metadata`.

Each file entry requires:
- `path` — relative path (e.g. `index.html`, `assets/logo.png`)
- `size` — byte count
- `contentType` — MIME type

Optional:
- `hash` — `sha256:<hex>` of the file contents. Enables incremental deploys: if the hash matches the previous version, the file is **skipped** and copied server-side (no re-upload needed).

**Response:**
```json
{
  "slug": "bright-canvas-a7k2",
  "siteUrl": "https://bright-canvas-a7k2.shipsite.co/",
  "expiresAt": "2026-03-30T12:00:00.000Z",
  "upload": {
    "versionId": "01JQMB4KZPX...",
    "uploads": [
      {
        "path": "index.html",
        "method": "PUT",
        "url": "https://shipsite-files.s3.amazonaws.com/sites/bright-canvas-a7k2/...",
        "headers": {"Content-Type": "text/html"}
      }
    ],
    "skipped": ["style.css"],
    "finalizeUrl": "https://shipsite.co/api/v1/publish/bright-canvas-a7k2/finalize",
    "expiresInSeconds": 3600
  }
}
```

Every create response includes an `expiresAt` timestamp. For **anonymous** publishes, the response also includes:
```json
{
  "claimToken": "abc123...",
  "anonymous": true,
  "warning": "IMPORTANT: Save the claimToken for finalizing and managing this site. It is returned only once. Free sites expire 24 hours after creation."
}
```

> Save the `claimToken` to finalize and manage the site during its 24-hour lifetime. It is only returned on creation.

### Step 2 — Upload files

For each entry in `upload.uploads`, PUT the file bytes to the presigned URL using the provided headers:

```
PUT <url from upload.uploads[n].url>
Content-Type: <value from upload.uploads[n].headers["Content-Type"]>

<raw file bytes>
```

- Files listed in `upload.skipped` do **not** need to be uploaded — they are copied server-side automatically.
- Presigned URLs expire after **1 hour**. If they expire, call `POST /api/v1/publish/:slug/uploads/refresh` to get fresh URLs.

### Step 3 — Finalize

```
POST /api/v1/publish/:slug/finalize
Content-Type: application/json

{"versionId": "01JQMB4KZPX...", "claimToken": "abc123..."}
```

For anonymous sites, include the `claimToken` from the create response. For authenticated sites, use your API key instead.

Markdown files (`.md`, `.markdown`) are automatically converted to styled HTML during finalization. The original `.md` file is preserved and a `.html` version is added.

The site is now live at `https://<slug>.shipsite.co/`.

**Response:**
```json
{
  "success": true,
  "slug": "bright-canvas-a7k2",
  "siteUrl": "https://bright-canvas-a7k2.shipsite.co/",
  "previousVersionId": null,
  "currentVersionId": "01JQMB4KZPX..."
}
```

---

## Serving Rules

When a browser hits `https://<slug>.shipsite.co/<path>`:

1. If `<path>/index.html` exists → serve it
2. If `<path>` is `/` and `index.html` exists → serve `index.html`
3. If only one file in the site → serve that file regardless of path
4. Otherwise → show a directory listing

Always include an `index.html` for sites intended to be browsed.

Hosted subdomains reserve `/robots.txt` to disallow crawling and send `X-Robots-Tag: noindex, nofollow, noarchive`. Uploaded files cannot override this policy. These are requests to compliant crawlers, not access controls or a guarantee that previously indexed URLs disappear.

---

## Update an Existing Site

```
PUT /api/v1/publish/:slug
Content-Type: application/json
Authorization: Bearer sk_...        (authenticated sites)

{
  "files": [...],
  "claimToken": "abc123..."          (anonymous sites only)
}
```

Same response shape as create. Provide file hashes to skip unchanged files.

---

## Incremental Deploys (Best Practice)

To minimise upload time and bandwidth:

1. SHA-256 hash each file before calling create/update.
2. Include `"hash": "sha256:<hex>"` in each file entry.
3. Files whose hash matches the previous version appear in `skipped` — do not upload them.
4. Files in `uploads` must be uploaded as normal.
5. On finalize, skipped files are copied server-side — no extra work needed.

This makes re-deploys of large sites near-instant when only a few files changed.

---

## Claim an Anonymous Site

To link an anonymous site to your account for management and sharing (the expiry deadline stays unchanged):

```
POST /api/v1/publish/:slug/claim
Content-Type: application/json
Authorization: Bearer sk_...

{"claimToken": "abc123..."}
```

---

## Metadata

```
PATCH /api/v1/publish/:slug/metadata
Content-Type: application/json
Authorization: Bearer sk_...

{
  "viewer": {
    "title": "New Title",
    "description": "Updated description"
  },
  "password": "secret"          // set to null to remove password protection
}
```

Expiry cannot be changed. Sending `ttlSeconds` (including `null` or `0`) returns `400`.

### Password protection

Set `password` to a non-empty string to lock the site behind a passcode. Visitors see a dark-mode password prompt and must enter the correct code to view any content. The cookie persists for 24 hours so they don't need to re-enter on every visit.

```
# Lock a site
PATCH /api/v1/publish/:slug/metadata
{"password": "hunter2"}

# Unlock (remove password)
PATCH /api/v1/publish/:slug/metadata
{"password": null}
```

API clients can bypass the browser prompt by sending the password in the `x-site-password` header:

```
GET https://<slug>.shipsite.co/
x-site-password: hunter2
```

---

## Site Details

```
GET /api/v1/publish/:slug
Authorization: Bearer sk_...
```

Returns metadata, status, current/pending version IDs, and the full file manifest.

---

## List All Sites

```
GET /api/v1/publishes
Authorization: Bearer sk_...
```

---

## Delete a Site

```
DELETE /api/v1/publish/:slug
Authorization: Bearer sk_...
```

Permanently removes the site and all its files from S3.

---

## Duplicate a Site

```
POST /api/v1/publish/:slug/duplicate
Authorization: Bearer sk_...
```

Server-side copy to a new slug with the same expiry deadline as the source. No re-upload required. Useful for staging/preview forks.

---

## Version History

```
GET /api/v1/publish/:slug/versions
Authorization: Bearer sk_...
```

Response:
```json
{
  "slug": "bright-canvas-a7k2",
  "versions": [
    {"versionId": "01DEF...", "createdAt": "2026-03-29T10:00:00Z", "fileCount": 5, "current": true},
    {"versionId": "01ABC...", "createdAt": "2026-03-28T09:00:00Z", "fileCount": 3, "current": false}
  ]
}
```

Every publish or update creates a new immutable version. Versions are available only until the site expires or is deleted.

---

## Rollback

```
POST /api/v1/publish/:slug/rollback
Content-Type: application/json
Authorization: Bearer sk_...

{"versionId": "01ABC..."}
```

Instantly reverts to any previous version. Files are already in S3 — no re-upload needed.

Response:
```json
{
  "success": true,
  "slug": "bright-canvas-a7k2",
  "siteUrl": "https://bright-canvas-a7k2.shipsite.co/",
  "previousVersionId": "01DEF...",
  "currentVersionId": "01ABC..."
}
```

---

## Download as ZIP

```
GET /api/v1/publish/:slug/download
Authorization: Bearer sk_...
```

Returns a `.zip` archive of the current version. Use for backups or migrating to another host.

---

## Analytics

```
GET /api/v1/publish/:slug/analytics?days=30
Authorization: Bearer sk_...
```

Query params:
- `days` — 1 to 90 (default: 30)

Response:
```json
{
  "slug": "bright-canvas-a7k2",
  "period": {"startDate": "2026-02-27", "endDate": "2026-03-29", "days": 30},
  "daily": [
    {
      "date": "2026-03-29",
      "views": 142,
      "uniqueVisitors": 89,
      "topPaths": [["/", 98], ["/about.html", 44]],
      "topReferrers": [["google.com", 34], ["twitter.com", 12]]
    }
  ],
  "totals": {"views": 4210, "uniqueVisitors": 1893}
}
```

IP addresses are SHA-256 hashed before storage. Analytics data is auto-deleted after 90 days.

---

## Share via Email

Send a branded email to someone with a link to your site:

```
POST /api/v1/publish/:slug/share
Content-Type: application/json
Authorization: Bearer sk_...

{
  "senderName": "Kent",
  "recipientName": "Alice",
  "recipientEmail": "alice@example.com",
  "message": "Check out my new project!"
}
```

- `senderName` — your name, max 64 chars
- `recipientName` — recipient's name, max 64 chars
- `recipientEmail` — valid email address, max 254 chars
- `message` — optional personal note, max 500 chars

Requires auth. Site must be active. Returns `422` if the recipient has unsubscribed from ShipSite emails. Rate limited to 10 shares per hour per user.

---

## Refresh Upload URLs

If presigned URLs have expired before all files are uploaded:

```
POST /api/v1/publish/:slug/uploads/refresh
Authorization: Bearer sk_...
```

Returns fresh presigned URLs for the pending version.

---

## Limits

ShipSite offers free temporary hosting and paid permanent hosting. The 24-hour limit is a maximum lifetime, not a guarantee of availability or content retention. Keep your own backups. Warranty and liability exclusions apply to the extent permitted by law; see the [Terms of Service](https://shipsite.co/terms).

If we detect malicious content, we may remove the site immediately, without prior notice, regardless of payment or sponsorship. See the [Terms of Service](https://shipsite.co/terms).

Expired sites return `410 Gone` while their records await cleanup, then `404 Not Found`. They cannot be updated, claimed, duplicated, downloaded, or reactivated. Storage cleanup runs asynchronously after expiry. Keep your own copies.

| Feature            | Anonymous       | Free account  |
|--------------------|-----------------|---------------|
| Max file size      | 25 MB           | 5 GB          |
| Max files per site | 20              | 500           |
| Site expiry        | 24 hours        | 24 hours      |
| Rate limit         | 5/hour per IP   | 200/hour      |
| Version history    | —               | Full          |
| Rollback           | —               | Any version   |
| ZIP download       | —               | Current only  |
| Analytics          | —               | 90-day window |

---

## Common Workflows

### Publish a folder of files (authenticated)

```javascript
import { createHash, readFileSync, readdirSync, statSync } from 'fs';
import { join, relative } from 'path';
import fetch from 'node-fetch';

const API_KEY = process.env.SHIPSITE_API_KEY;
const BASE    = 'https://shipsite.co';
const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${API_KEY}` };

function hashFile(path) {
  return 'sha256:' + createHash('sha256').update(readFileSync(path)).digest('hex');
}

function collectFiles(dir, base = dir) {
  const out = [];
  for (const name of readdirSync(dir)) {
    const abs = join(dir, name);
    if (statSync(abs).isDirectory()) { out.push(...collectFiles(abs, base)); continue; }
    const path = relative(base, abs).replace(/\\/g, '/');
    const data = readFileSync(abs);
    out.push({ path, size: data.length, contentType: guessMime(path), hash: hashFile(abs), data });
  }
  return out;
}

async function deploy(dir) {
  const files = collectFiles(dir);

  // Step 1: Create
  const create = await fetch(`${BASE}/api/v1/publish`, {
    method: 'POST', headers,
    body: JSON.stringify({ files: files.map(({ data, ...f }) => f) }),
  }).then(r => r.json());

  const { slug, upload } = create;

  // Step 2: Upload
  await Promise.all(upload.uploads.map(async ({ path, url, headers: h }) => {
    const file = files.find(f => f.path === path);
    await fetch(url, { method: 'PUT', headers: h, body: file.data });
  }));

  // Step 3: Finalize
  await fetch(upload.finalizeUrl, {
    method: 'POST', headers,
    body: JSON.stringify({ versionId: upload.versionId }),
  });

  console.log(`Live at: https://${slug}.shipsite.co/`);
  return slug;
}

function guessMime(path) {
  const ext = path.split('.').pop();
  return { html: 'text/html', css: 'text/css', js: 'application/javascript',
    json: 'application/json', png: 'image/png', jpg: 'image/jpeg',
    svg: 'image/svg+xml', ico: 'image/x-icon', woff2: 'font/woff2' }[ext] ?? 'application/octet-stream';
}
```

### Quick anonymous publish (single file)

```bash
# 1. Create
RESP=$(curl -sS https://shipsite.co/api/v1/publish \
  -H "content-type: application/json" \
  -d '{"files":[{"path":"index.html","size":22,"contentType":"text/html"}]}')

SLUG=$(echo $RESP | jq -r .slug)
URL=$(echo $RESP | jq -r '.upload.uploads[0].url')
VID=$(echo $RESP | jq -r '.upload.versionId')
FINALIZE=$(echo $RESP | jq -r '.upload.finalizeUrl')
CLAIM_TOKEN=$(echo $RESP | jq -r '.claimToken')

# 2. Upload
curl -sS -X PUT "$URL" -H "Content-Type: text/html" --data-binary "<h1>Hello world</h1>"

# 3. Finalize
curl -sS -X POST "$FINALIZE" \
  -H "content-type: application/json" \
  -d "{\"versionId\":\"$VID\",\"claimToken\":\"$CLAIM_TOKEN\"}"

echo "Live at: https://$SLUG.shipsite.co/"
echo "Claim token (save this!): $CLAIM_TOKEN"
```

---

## Error Responses

All errors return JSON:

```json
{"error": "Description of the problem"}
```

| Status | Meaning |
|--------|---------|
| 400    | Bad request (missing field, invalid path, version mismatch) |
| 401    | Missing or invalid API key |
| 403    | Forbidden (wrong owner, invalid claim token) |
| 404    | Site or version not found |
| 429    | Rate limit exceeded |
| 500    | Server error |

---

## Tips for Agents

- **Always finalize.** A site with status `pending` is not live. If publish is interrupted, call finalize or start over with a new create. For anonymous sites, pass the `claimToken` in the finalize body.
- **Save the claimToken.** For anonymous deploys, this is required for finalize, update, and claim. Store it alongside the slug — it is only returned once on creation.
- **Hash your files.** Even on the first deploy, computing and storing hashes means subsequent updates will only upload changed files.
- **Check `skipped` vs `uploads`.** Don't attempt to upload files listed in `skipped` — they are already handled server-side.
- **Presigned URLs expire in 1 hour.** For large sites or slow connections, call `/uploads/refresh` if needed before uploading remaining files.
- **One pending version at a time.** Calling update (PUT) again before finalizing replaces the pending version. Always finalize before the next update.


## Permanent hosting and billing

Visit https://shipsite.co/account to subscribe for US$3.99/month or sign in with an admin-sponsored account. Use the same API key for publishing. Existing free sites retain their original deadline; publish a new permanent site after subscribing. Previously expired sites stay expired.

- `GET /api/billing`: access status, usage, and limits.
- `POST /api/billing/checkout`: hosted Stripe Checkout URL.
- `POST /api/billing/portal`: manage payment details or cancel.
- `POST /api/billing/sync`: refresh payment status.
- `DELETE /api/v1/publish/:slug/pending`: abandon the current pending deployment.
- `DELETE /api/v1/publish/:slug/versions/:versionId`: delete a non-current permanent version, once no deployment is pending.

All these routes require Bearer authentication. Account storage includes all retained versions, metadata, and upload reservations. Permanent files have a 25 MB limit (Markdown 1 MB), 500 files per deployment. PUT uploads must match the declared Content-Length exactly. URLs last 15 minutes and can be refreshed during the first hour. Only one deployment per site may be pending. Full and incremental versions consume storage; delete obsolete versions to make room. Duplicate permanent sites using a new publish with the source files.

Monthly limits reset on the first day of each UTC calendar month: 5 GB transfer, 100,000 hosted/management requests, 1,000 deployments. Exports and interrupted downloads count toward transfer. Limits block further usage rather than creating overage charges. Canceling keeps hosting through the paid period; files remain offline for seven days before cleanup. Sponsored accounts bypass payment only, with the same quotas and abuse policy.

Sites owned by accounts an administrator has separately exempted load directly at their normal site URL without the banner or sandboxed frame. The exemption is granted independently of sponsorship and payment, so neither removes the banner on its own. This applies to temporary and permanent sites; expiry, password protection, suspension, and usage limits still apply. Removing the exemption, or revoking sponsorship, restores the normal framing behavior for subsequent visits. For other accounts and anonymous sites, browser visits use a ShipSite-controlled banner and a sandboxed content frame on a separate origin. Uploaded scripts cannot change the outer banner, open popups, navigate the top frame, or enter fullscreen. Root-relative links work within the content frame; sites relying on top navigation or popups must adapt. Non-browser API file requests continue to return raw content. The banner is attribution, not a claim that the content is safe.
