---
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 — the server never proxies bytes.

## Base URL

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

---

## Authentication

ShipSite supports **anonymous** publishing (no account, 24-hour expiry) and **authenticated** publishing (permanent sites, full feature set).

### 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"
  }
}
```

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/",
  "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
  }
}
```

For **anonymous** publishes, the response also includes:
```json
{
  "claimToken": "abc123...",
  "claimUrl": "https://shipsite.co/claim?slug=...&token=...",
  "expiresAt": "2026-03-30T12:00:00.000Z",
  "anonymous": true,
  "warning": "IMPORTANT: Save the claimToken and claimUrl. They are returned only once."
}
```

> Save the `claimToken` if you may want to claim the site later. 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.

---

## 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 convert an anonymous site to your account (removes expiry, enables full features):

```
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
  "ttlSeconds": 86400           // set to null to remove expiry
}
```

### 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. 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. All versions are retained until you delete the site.

---

## 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

| Feature            | Anonymous       | Authenticated |
|--------------------|-----------------|---------------|
| Max file size      | 25 MB           | 5 GB          |
| Max files per site | 20              | 500           |
| Site expiry        | 24 hours        | Permanent     |
| 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.
