Docs

Start with your first app, preview its site, connect a domain, or install ShipyardKit. The full API reference follows.

1. Import your first app, or add it manually

A workspace is the account where you manage your apps. Your studio profile is its public identity; your own name is fine for both.

  1. Start Free with one app and no card. Choose your workspace address and use the same Google email when signing in.
  2. Finish the Studio step, then search Apps by app name, developer name, or App Store URL. Review the results and select only the apps you want to import within your plan limit.
  3. Import and daily metadata sync use the U.S. storefront only. If your app is not available there, choose Add manually, enter its name, URL, platforms, and description, then save. You maintain those details yourself; manual setup does not add non-U.S. metadata sync.
  4. Wait for import completion before continuing. If a search or import fails, follow its retry message; your saved apps remain in the workspace.

2. Review your app before publishing

  1. Open the app’s details and review its title, description, icon, links, platforms, status, and visibility. Keep visibility Private while preparing it.
  2. Use its landing-page preview to check the page and links. A preview is for review; changing visibility or status controls what visitors can see.
  3. When ready, choose Public visibility and the appropriate status, then save. Coming Soon and Active have different public behavior; check the live page before announcing it.
  4. Open the public app page signed out. If it is unavailable, check visibility, status, and website maintenance settings. Keep posts as drafts until they are ready to publish separately.

3. Connect your own domain

  1. Your Shipyard subdomain works without a custom domain. Custom domains require Studio or Pro Studio.
  2. In workspace Settings, find Custom Domain, enter your domain, then choose Save & Connect.
  3. At your DNS provider, add the exact records shown by Shipyard. Keep unrelated mail and service records intact.
  4. Return to Shipyard and refresh domain status. Wait for DNS and certificate readiness, then open the HTTPS address. For a pending or failed check, use the displayed explanation or contact support.

4. Make your first ShipyardKit connection

  1. Save an app in your workspace, then open its ShipyardKit setup and download the generated bundle. Copy the workspace base URL and product slug from that setup.
  2. Follow the bundle’s README and INSTALL_GUIDE in your app repository. Add its Swift package to the app target and configure ShipyardClient with your workspace URL, product slug, and stable per-install identifier.
  3. Follow the platform-specific instructions to connect Roadmap and daily sync. The app uses ShipyardKit’s session flow; do not embed an admin or service API key.
  4. Build and run the app, then return to ShipyardKit setup to check connection status. Read the reported failure before retrying. Only submit a test request when you intend it to create real workspace content.
Shipyard Developer Docs

Shipyard API Reference

The Shipyard API gives you direct access to products, posts, requests, assets, and analytics. All write routes require a bearer token. Read routes for public data are open. Every endpoint below is documented with request shapes, field descriptions, and example payloads.

Production Base URL
https://your-workspace.startshipyard.com
Auth Header
Authorization: Bearer <SERVICE_API_KEY>
Public Vars
TURNSTILE_SITE_KEY for the promo-code claim, request, and waitlist widgets

Automate your workflow

The API is designed to integrate with your existing development tools. Pull approved roadmap items into your IDE, generate tasks from community votes, pipe analytics into your own dashboards, or connect AI tools to the hosted planner MCP endpoint.

Example: Pull all approved roadmap items via GET /v1/roadmap, filter by status, and feed them into your IDE or task runner to scaffold implementation work automatically.
MCP: Connect Shipyard to any MCP-compatible tool so your AI assistant can read planner work, create future features, update AI workflow status, and manage checklist tasks.

Auth and verification

Write routes and privileged reads require a bearer token in the Authorization header. Public read routes for products, posts, and public roadmap data do not require auth. Desktop apps can call GET /v1/settings/api-key/current to check when the current database-backed service key expires.

Create a scoped service API key in your workspace settings for trusted integrations.
Keep service API keys in trusted server or local-tool configuration. Never embed them in public browser code or distributed apps.
Use only the scopes and product access your integration needs.
An absent bearer permits public reads; a supplied invalid bearer returns 401. A valid bearer without the required scope returns 403 with requiredScope. Errors always use Cache-Control: no-store.
Use your workspace subdomain or active custom domain as BASE_URL. Replace your-workspace with the workspace slug. The central app.startshipyard.com host serves sign-in and workspace management; it does not infer an API tenant.
Key expiration: Use expiresAt, expiresInDays, and rotationRecommended from /v1/settings/api-key/current to warn users before local integrations need a regenerated key.
Reference

Authentication

Service API key metadata for desktop apps, local tools, and trusted integrations.

GET /v1/settings/api-key/current
Bearer auth

Return metadata for the service API key used on this request.

  • Works with any valid database-backed service API key, including api:*, planner:*, and the least-privilege studio:sync key.
  • This endpoint introspects only the current bearer key and never returns the raw secret.
  • Use expiresAt, expiresInSeconds, expiresInDays, and rotationRecommended to warn users before local apps lose API access.
  • rotationRecommended becomes true during the final 14 days before expiration.
  • Use a service API key created in workspace settings to retrieve expiration metadata.
Request
curl "$BASE_URL/v1/settings/api-key/current" \
  -H "Authorization: Bearer $SERVICE_API_KEY"
Response
{
  "ok": true,
  "serverTime": "2026-06-22T12:00:00.000Z",
  "apiKey": {
    "id": "jwk_shipcapsule_ab12cd34",
    "name": "ShipCapsule",
    "scopes": ["api:write"],
    "allowedProductSlugs": ["markettrail"],
    "status": "active",
    "createdAt": "2026-06-22T12:00:00.000Z",
    "expiresAt": "2026-09-20T12:00:00.000Z",
    "expiresInSeconds": 7776000,
    "expiresInDays": 90,
    "rotationRecommended": false,
    "rotationRecommendedAt": "2026-09-06T12:00:00.000Z",
    "lastUsedAt": null,
    "lastUsedPath": null,
    "lastUsedMethod": null,
    "usageCount": 0
  }
}
Reference

ShipyardKit

Tenant-neutral SDK release metadata for native integration health checks.

GET /v1/shipyardkit/version
Public

Return the latest supported ShipyardKit SDK version.

  • No workspace credential is required because the SDK release is global rather than tenant-specific.
  • ShipyardKit 0.25.0 remains server-compatible without a contract-version request header.
  • ShipyardKit 0.25.3 declares API contract version 1 so storage-provider changes remain behind the HTTP/JSON API.
  • Responses may be cached for five minutes.
  • Native tools should retain the last successful value and use their embedded SDK version as an offline fallback.
Request
curl "$BASE_URL/v1/shipyardkit/version"
Response
{
  "latestVersion": "0.25.3",
  "minimumSupportedVersion": "0.25.0",
  "apiContractVersion": "1"
}
Reference

Planner MCP

Hosted MCP access for AI clients that need to read planner work, create future features, manage checklist tasks, and mark selected work as testing needed.

GET /mcp
Bearer auth

Return basic MCP endpoint metadata.

  • Requires a database-backed service API key with a planner scope, such as planner:read, planner:write, planner:ai-status, planner:tasks, or planner:create.
  • Generic api:read/api:write keys are not accepted for MCP. Legacy api:full is not user-creatable and never implies dedicated sensitive scopes.
  • Admin-created service keys can be restricted to comma-separated product slugs.
  • This is useful as a quick authenticated health check for MCP clients and setup scripts.
  • The MCP endpoint is hosted on the same Worker as the app and resolves workspace context the same way the stable API does.
Request
curl "$BASE_URL/mcp" \
  -H "Authorization: Bearer $SERVICE_API_KEY"
Response
{
  "name": "shipyard-planner",
  "version": "1.0.0",
  "transport": "streamable-http",
  "endpoint": "/mcp"
}
POST /mcp
Bearer auth

Call the hosted planner MCP server over JSON-RPC.

  • Reads require planner:read or a planner mutation scope that implies read.
  • create_planner_item requires planner:create or planner:write.
  • set_ai_work_status and mark_testing_needed require planner:ai-status or planner:write.
  • Planner task mutations require planner:tasks or planner:write.
  • Supported MCP methods: initialize, ping, tools/list, tools/call, and notifications/initialized.
  • MCP returns curated planner/product fields instead of raw admin database rows.
  • MCP writes are recorded in the operator audit log with service key identity, target id, changed fields, IP, and user agent.
  • MCP rejects obvious credentials in AI context, notes, summaries, and task titles; existing context is redacted on MCP reads when it matches common secret patterns.
  • MCP enforces conservative AI status transitions. AI cannot move do_not_touch (shown as No AI Action in admin) directly to ready.
  • AI work status is internal planner state and never changes public roadmap status by itself.
  • AI work statuses: do_not_touch, clarification_needed, ready, in_progress, testing_needed, blocked.
  • AI clarification requests are stored separately from human-authored aiWorkContext so AI questions do not overwrite instructions.
  • do_not_touch is shown as No AI Action in admin and is the default so existing planner items are not AI-workable by accident.
  • list_ai_work intentionally includes No AI Action/do_not_touch items so AI clients can show the full app planner while treating those items as non-selectable.
  • Available tools: list_products, get_product_planner, list_ai_work, create_planner_item, update_planner_item, set_ai_work_status, request_clarification, mark_testing_needed, list_planner_tasks, create_planner_task, update_planner_task, delete_planner_task.
JSON Body
Field Type Required Description
jsonrpc 2.0 Yes JSON-RPC version.
id string | number No Request id. Omit only for notifications.
method initialize | ping | tools/list | tools/call Yes MCP/JSON-RPC method.
params object No Method-specific params. tools/call expects name and arguments.
Request
curl -X POST "$BASE_URL/mcp" \
  -H "Authorization: Bearer $SERVICE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/call",
    "params": {
      "name": "list_ai_work",
      "arguments": {
        "productSlug": "atlas"
      }
    }
  }'
Response
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "{ ...planner payload... }"
      }
    ],
    "structuredContent": {
      "product": { "slug": "atlas" },
      "grouped": [
        { "status": "do_not_touch", "label": "No AI Action", "items": [] },
        { "status": "ready", "label": "Ready for AI", "items": [] },
        { "status": "clarification_needed", "label": "Clarification Needed", "items": [] },
        { "status": "testing_needed", "label": "Testing Needed", "items": [] }
      ]
    }
  }
}
POST /mcp (mark_testing_needed)
Bearer auth

Mark selected AI work as testing needed after implementation.

  • This is a tools/call request to /mcp, not a separate HTTP path.
  • Requires planner:ai-status or planner:write.
  • Updates aiWorkStatus to testing_needed and appends implementation/testing notes to aiWorkContext.
  • Allowed only from AI workflow states that can transition to testing_needed, such as in_progress.
  • Does not change request.status or product.workingVersionStatus.
JSON Body
Field Type Required Description
itemId string Yes Planner item id, passed inside params.arguments.
summary string No What changed.
testingNotes string No What the human/tester should verify.
Request
curl -X POST "$BASE_URL/mcp" \
  -H "Authorization: Bearer $SERVICE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 2,
    "method": "tools/call",
    "params": {
      "name": "mark_testing_needed",
      "arguments": {
        "itemId": "req_123",
        "summary": "Added compact dashboard layout and wired existing summary data.",
        "testingNotes": "Verify dashboard cards on mobile, desktop, and empty metrics."
      }
    }
  }'
Reference

Analytics

Authenticated Apple Connect sales summaries plus Cloudflare web analytics for admin and native/internal clients.

GET /v1/analytics
Bearer auth

Return the Apple Connect analytics dashboard payload used by the admin area.

  • This route requires bearer auth for scripts and native clients. Admin sessions may also access it when signed in.
  • The payload includes studio metadata, app filters, summary cards, trend data, top apps, recent breakdowns, and sync runs.
Query Parameters
Field Type Required Description
days 7 | 30 | 90 | 180 No Reporting window. Defaults to 30.
productId string | all No Specific app product id or all.
view day | week | month No Trend bucket size.
activity download | paid | iap | purchase | update | redownload | all No Activity class filter.
Request
curl "$BASE_URL/v1/analytics?days=30&view=day&activity=download" \
  -H "Authorization: Bearer $SERVICE_API_KEY"
GET /v1/requests/idempotency/:key
Bearer auth

Recover the exact Planner create committed for a mutation ID.

  • Used by native Planner clients to inspect an ambiguous create after a timeout, disconnect, or crash.
  • The lookup is workspace-scoped, never returns the stored mutation key or digest, and returns 404 when no create committed.
Request
curl "$BASE_URL/v1/requests/idempotency/$MUTATION_ID" \
  -H "Authorization: Bearer $SERVICE_API_KEY"
GET /v1/analytics/web
Bearer auth

Return Cloudflare edge analytics (visits, bandwidth, top paths) for the current workspace hostname.

  • Web analytics must be configured for the workspace. Check configured and available in the response before displaying metrics.
  • The workspace hostname list is derived server-side from the workspace slug + any active custom domain, including apex and www variants.
  • If hostname is supplied, it must match one of the allowed hostnames for this workspace.
  • Shared-host API clients should include ws=<workspaceSlug> so the request resolves the intended workspace.
  • The payload includes configured, available, error, hostname, days, totals, trend, topPaths, allowedHostnames, selectedHostname, cached, refreshedAt, and expiresAt.
  • totals.uniqueVisitors is a best-effort period unique-IP count; trend[].uniqueVisitors is daily bucket-level. Both may be null when Cloudflare does not expose the metric for the current token/schema.
Query Parameters
Field Type Required Description
days 7 | 30 No Reporting window. Defaults to 7.
hostname string No Optional hostname selector (must be allowed for this workspace).
ws string No Optional workspace slug override for shared-host API clients.
refresh 0 | 1 No Set to 1 to bypass the server-side cache.
Request
curl "$BASE_URL/v1/analytics/web?ws=acme-studio&hostname=www.acme.example&days=30" \
  -H "Authorization: Bearer $SERVICE_API_KEY"
Reference

Mobile Engagement

ShipyardKit Ask, announcement, Roadmap pull, and engagement count endpoints.

GET /v1/engagement/updates
Bearer auth

Return current Asks and announcements for a mobile app.

  • Requires a mobile feedback token from POST /v1/auth/mobile/public-session.
  • Pass product=:slug.
  • Returns the canonical asks and announcements collections.
  • Add history=1 to include non-live engagement items.
  • Records a Device Activity Event used by admin analytics.
Query Parameters
Field Type Required Description
product string Yes Product slug.
history 0 | 1 No Include non-live items.
Request
curl "$BASE_URL/v1/engagement/updates?product=atlas" \
  -H "Authorization: Bearer $MOBILE_FEEDBACK_TOKEN"
GET /v1/engagement/asks
Bearer auth

Return mobile Asks only.

  • Requires a mobile feedback token.
Query Parameters
Field Type Required Description
product string Yes Product slug.
Request
curl "$BASE_URL/v1/engagement/asks?product=atlas" \
  -H "Authorization: Bearer $MOBILE_FEEDBACK_TOKEN"
GET /v1/engagement/announcements
Bearer auth

Return mobile announcements only.

  • Requires a mobile feedback token.
Query Parameters
Field Type Required Description
product string Yes Product slug.
Request
curl "$BASE_URL/v1/engagement/announcements?product=atlas" \
  -H "Authorization: Bearer $MOBILE_FEEDBACK_TOKEN"
POST /v1/engagement/asks/:id/respond
Bearer auth

Submit a mobile Ask response.

  • Requires a mobile feedback token.
  • Body shape depends on Ask type.
JSON Body
Field Type Required Description
optionId string No Single-choice option id.
optionIds string[] No Multi-choice option ids.
ratingValue number No Star or numeric rating value.
textValue string No Open-text response.
POST /v1/engagement/announcements/:id/events
Bearer auth

Record a mobile announcement event.

  • Requires a mobile feedback token.
JSON Body
Field Type Required Description
eventType shown | dismissed | clicked Yes Announcement delivery event.
visibleMs number No Optional visible duration.
screenKey string No Optional client screen identifier.
GET /v1/engagement/counts
Bearer auth

Return engagement totals for native/admin dashboards.

  • Requires a service key with api:read.
  • activeDevicesAllTime is the distinct non-simulator Device Activity installation count.
  • deviceActivityEvents is the sum of stored Device Activity event counts.
  • deviceActivityDays is the stored product/install/day row count.
Request
curl "$BASE_URL/v1/engagement/counts" \
  -H "Authorization: Bearer $SERVICE_API_KEY"
Reference

Products

Product records, product-scoped post feeds, and public promo-code flows.

GET /v1/products
Public read, bearer for privileged access

List public products, or with auth include private ones.

  • Public callers only receive products with visibility=public.
  • Authenticated callers can also read private products and filter by visibility.
  • Pagination defaults to 50 rows and caps at 100. Responses include total, hasMore, and nextOffset; continue until hasMore is false.
  • Each product includes publicUrl for the live site path and supportUrl when the workspace Support page is enabled.
  • Each product includes top-level planning fields plus a planning object with productNumber, workingVersion, workingVersionStatus, and workingVersionProgress.
  • Each product includes latestUpdateAt and daysSinceLastUpdate from published non-FAQ product posts.
  • List responses include updatePriority for app products that are live, in progress, coming soon, beta, or ideas. Live app maintenance uses stale published updates plus synced downloads; stalled working versions and ideas use product/planner activity age.
  • updatePriorityNumber is the numeric queue rank to show in apps. It mirrors updatePriorityRank for older clients.
  • Use includePriority=0, includeLatestUpdates=0, or includeSupport=0 for lightweight product lists. Priority and days-since-update sorts force the enrichment they need.
  • Authenticated callers also receive an appStore sync summary with the synced live version kept separate from workingVersion.
Query Parameters
Field Type Required Description
visibility public | private No Auth only.
type app | web No Optional product type filter.
status string No Optional status filter.
versionStatus string No Optional working version status filter. workingVersionStatus is also accepted.
workingVersion string No Optional exact working version filter.
productNumber string No Optional exact manual product identifier filter.
minProgress number No Minimum manual working-version progress.
maxProgress number No Maximum manual working-version progress.
includePriority 0 | 1 No Defaults to 1. Set to 0 to skip update-priority ranking unless using update_priority sort.
includeLatestUpdates 0 | 1 No Defaults to 1. Set to 0 to skip latestUpdateAt and daysSinceLastUpdate unless required by priority or days-since-update sort.
includeSupport 0 | 1 No Defaults to 1. Set to 0 to skip supportUrl lookup.
sort string No name_asc, name_desc, product_number_asc, product_number_desc, update_priority_asc, update_priority_desc, working_version_asc, working_version_desc, progress_asc, progress_desc, days_since_update_asc, days_since_update_desc, status, version_status, newest, or oldest.
Request
curl "$BASE_URL/v1/products?sort=progress_desc&versionStatus=in_progress" \
  -H "Authorization: Bearer $SERVICE_API_KEY"
GET /v1/products/:slug
Public read, bearer for privileged access

Fetch one product by slug.

  • Private products return 404 unless a bearer token is present.
  • The response includes publicUrl for the live site path and supportUrl when the workspace Support page is enabled.
  • The response includes the same product planning fields, planning object, and update-age fields as the list endpoint.
  • updatePriorityNumber is the numeric queue rank to show in apps. It mirrors updatePriorityRank for older clients.
  • Use includePriority=0, includeLatestUpdates=0, or includeSupport=0 to skip optional enrichment.
  • Authenticated callers can pass includePlanner=1 to include private planner items grouped by working version, future versions, and unassigned backlog.
Query Parameters
Field Type Required Description
includePlanner 0 | 1 No Auth only. Include grouped private planner items for this product.
includePriority 0 | 1 No Defaults to 1. Set to 0 to skip update-priority ranking.
includeLatestUpdates 0 | 1 No Defaults to 1. Set to 0 to skip latestUpdateAt and daysSinceLastUpdate unless priority is included.
includeSupport 0 | 1 No Defaults to 1. Set to 0 to skip supportUrl lookup.
Request
curl "$BASE_URL/v1/products/atlas?includePlanner=1" \
  -H "Authorization: Bearer $SERVICE_API_KEY"
POST /v1/products
Bearer auth

Create a product.

  • Only one studio product is allowed.
  • Slug is generated from name when omitted and must stay unique.
  • If iconKey is provided without iconUrl, the API stores /assets/:iconKey.
JSON Body
Field Type Required Description
name string Yes Human-readable product name.
slug string No Optional custom slug.
type app | web | studio No Product kind.
description string No Optional description.
primaryColor string No Hex brand accent.
secondaryColor string No Hex support color.
templateKey string No Landing template key.
appStoreUrl string No App Store link.
websiteUrl string No Marketing or product URL.
status string No Defaults to active.
visibility public | private No Defaults to private.
productNumber string No Optional manual product identifier.
workingVersion string No Manually tracked next version.
workingVersionStatus string No Manual App Store/release workflow status.
workingVersionProgress number No Manual percent complete from 0 to 100.
platforms string[] No Supported platform labels.
metadata object No String-only metadata bag.
iconKey string No Stored asset key.
iconUrl string No Explicit public icon URL.
iconVariants object No String-only icon variant map.
Request
curl -X POST "$BASE_URL/v1/products" \
  -H "Authorization: Bearer $SERVICE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Relay Notes",
    "slug": "relay-notes",
    "type": "app",
    "description": "Launch notes and private planning in one place.",
    "status": "active",
    "productNumber": "7",
    "workingVersion": "1.2",
    "workingVersionStatus": "in_progress",
    "workingVersionProgress": 40,
    "visibility": "private",
    "platforms": ["iphone", "ipad", "mac"],
    "metadata": { "tagline": "A calmer shipping surface" }
  }'
GET /v1/products/:slug/posts
Public read, bearer for privileged access

List posts for one product.

  • status defaults to published.
  • Any non-published status requires a bearer token.
  • Each returned post includes productSlug, productName, productUrl, and publicUrl.
Query Parameters
Field Type Required Description
status string No Defaults to published.
type string No Optional post type filter.
limit number No Max 100.
offset number No Pagination offset.
Request
curl "$BASE_URL/v1/products/atlas/posts?type=release&limit=10"
GET /v1/products/:slug/promo-code/status
Public

Check whether promo-code claiming is enabled and whether inventory is available.

Request
curl "$BASE_URL/v1/products/atlas/promo-code/status"
Response
{
  "product": {
    "slug": "atlas",
    "name": "Atlas"
  },
  "promoCodes": {
    "enabled": true,
    "available": true,
    "cooldownHours": 24,
    "buttonLabel": "Get Promo Code",
    "description": "Limited App Store promo codes are available for this app.",
    "turnstileRequired": true
  }
}
POST /v1/products/:slug/promo-code/claim
Public

Claim one App Store promo code when the product allows it.

  • Returns 400 when Turnstile verification fails or is missing.
  • Returns 404 when promo claiming is disabled for the product.
  • Returns 409 when no codes remain.
  • Returns 429 during the cooldown window.
JSON Body
Field Type Required Description
turnstileToken string No Accepted for JSON requests.
cf-turnstile-response string No Accepted for form submissions too.
Request
curl -X POST "$BASE_URL/v1/products/atlas/promo-code/claim" \
  -H "Content-Type: application/json" \
  -d '{"turnstileToken":"<CF_TURNSTILE_TOKEN>"}'
Response
{
  "code": "ABCD-1234-EFGH",
  "product": {
    "slug": "atlas",
    "name": "Atlas",
    "appStoreUrl": "https://apps.apple.com/..."
  }
}
PATCH /v1/products/:slug
Bearer auth

Patch product metadata.

JSON Body
Field Type Required Description
name string No Optional updated name.
description string No Optional updated description.
primaryColor string No Updated brand accent.
secondaryColor string No Updated support color.
templateKey string No Updated landing template.
appStoreUrl string No Updated App Store URL.
websiteUrl string No Updated product URL.
status string No Updated status.
visibility string No Updated public/private visibility.
productNumber string No Updated manual product identifier.
workingVersion string No Updated manually tracked next version.
workingVersionStatus string No Updated manual App Store/release workflow status.
workingVersionProgress number No Updated manual percent complete from 0 to 100.
platforms string[] No Updated platforms.
metadata object No Updated string metadata bag.
iconKey string No Updated asset key.
iconVariants object No Updated variant map.
Request
curl -X PATCH "$BASE_URL/v1/products/atlas" \
  -H "Authorization: Bearer $SERVICE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"description":"Updated description","status":"active"}'
Reference

Posts

Release notes, updates, metrics, and post image attachments.

GET /v1/posts
Public read, bearer for privileged access

List posts across the studio or for one product.

  • status defaults to published.
  • Non-published statuses require a bearer token.
  • Each returned post includes productSlug, productName, productUrl, and publicUrl.
Query Parameters
Field Type Required Description
product string No Product slug.
type string No Post type filter.
status string No Defaults to published.
limit number No Max 100, default 50.
offset number No Pagination offset.
Request
curl "$BASE_URL/v1/posts?product=atlas&type=release&limit=5"
GET /v1/posts/:productSlug/:postSlug
Public read, bearer for privileged access

Get one post by the same slug shape used by the public site.

  • Published posts are public.
  • Draft posts are hidden from unauthenticated callers.
  • The response includes both post.publicUrl and product.publicUrl.
GET /v1/posts/:id
Public read, bearer for privileged access

Legacy id-based detail read kept for backward compatibility.

  • Prefer /v1/posts/:productSlug/:postSlug for new clients.
GET /v1/posts/:id/metrics
Public read, bearer for privileged access

Read just the metrics for one post.

  • Draft post metrics require bearer auth.
Request
curl "$BASE_URL/v1/posts/post_abc123/metrics"
POST /v1/posts
Bearer auth

Create a post.

  • productSlug is the main field, and sourceSlug is accepted as an alias.
JSON Body
Field Type Required Description
productSlug string Yes Target product slug.
sourceSlug string No Alias for productSlug.
type string Yes Post type.
title string Yes Post title.
summary string No Short summary.
body string No Long-form body.
version string No Optional version label.
status string No Defaults to published.
publishedAt string No ISO timestamp.
visibility everywhere | app_only No Visibility surface.
ctaLabel string No Optional CTA label.
ctaUrl string No Optional CTA target.
availabilityStart string No Optional release window start.
availabilityEnd string No Optional release window end.
availabilityLabel string No Optional availability label.
displayConfig object No Display settings for structured layouts.
metrics array No Optional metrics array to seed on create.
images array No Optional image attachment array to seed on create.
Request
curl -X POST "$BASE_URL/v1/posts" \
  -H "Authorization: Bearer $SERVICE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "productSlug": "atlas",
    "type": "release",
    "title": "Atlas 1.1 - Tidal Alerts",
    "summary": "Get notified when conditions hit your saved thresholds.",
    "body": "Full release notes here.",
    "version": "1.1.0",
    "visibility": "everywhere",
    "publishedAt": "2026-03-06T10:00:00Z"
  }'
PATCH /v1/posts/:id
Bearer auth

Patch a post.

JSON Body
Field Type Required Description
type string No Updated post type.
title string No Updated title.
summary string No Updated summary.
body string No Updated body.
version string No Updated version label.
status string No Updated status.
publishedAt string No Updated ISO timestamp.
visibility string No Updated visibility.
ctaLabel string No Updated CTA label.
ctaUrl string No Updated CTA URL.
availabilityStart string No Updated availability start.
availabilityEnd string No Updated availability end.
availabilityLabel string No Updated availability label.
displayConfig object No Updated display config.
DELETE /v1/posts/:id
Bearer auth

Delete a post.

POST /v1/posts/:id/metrics
Bearer auth

Replace every metric row for a post.

JSON Body
Field Type Required Description
metrics array Yes Full replacement array.
Request
curl -X POST "$BASE_URL/v1/posts/post_abc123/metrics" \
  -H "Authorization: Bearer $SERVICE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"metrics":[{"label":"Downloads","value":5000},{"label":"MAU","value":1204}]}'
POST /v1/posts/:id/images
Bearer auth

Replace every image attachment for a post.

JSON Body
Field Type Required Description
images array Yes Full replacement array of asset references.
Request
curl -X POST "$BASE_URL/v1/posts/post_abc123/images" \
  -H "Authorization: Bearer $SERVICE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "images": [
      { "assetId": "asset_1", "platform": "iphone", "display": "feature", "caption": "Hero screen" },
      { "assetId": "asset_2", "platform": "ipad", "display": "gallery" }
    ]
  }'
Reference

Requests and Planner

Public roadmap items and private internal planning all share one request model.

GET /v1/requests/trash
Bearer auth

List deleted requests that can be restored within 30 days.

  • Requires api:read. Only deleted rows in the last 30 days are returned. Cache-Control: no-store.
  • Returns requests, total, limit, offset, hasMore, and nextOffset. Default and maximum limit are 200; page until hasMore is false.
Query Parameters
Field Type Required Description
limit integer No Default 200, maximum 200.
offset integer No Default 0.
product string No Optional product slug.
status string No Optional workflow status.
visibility public | private No Optional visibility.
origin user | internal No Optional origin.
GET /v1/requests
Public read, bearer for privileged access

List public roadmap items or, with auth, private planner items too.

  • Pagination defaults to 50 rows and caps at 100. Responses include total, requestsHasMore, deletedHasMore, hasMore, and nextOffset. On a delta read, page while hasMore is true, including when requests is empty; keep the first serverTime and advance the cursor only after both streams finish.
  • Public reads only include visibility=public.
  • Public reads hide waiting_review and archived by default.
  • Authenticated reads can filter by visibility and origin.
  • ShipyardKit Roadmap screens should render cachedItems() first, then call normal pullRoadmapDaily() and update visible groups only when fresh content is returned. Ordinary opens must not use force: true.
Query Parameters
Field Type Required Description
product string No Product slug.
status string No Status filter.
visibility public | private No Auth only.
origin user | internal No Auth only.
Request
curl "$BASE_URL/v1/requests?product=atlas&status=open"
POST /v1/requests
Public create, bearer for privileged fields

Create a public user request or, with auth, an internal planner item.

  • Public callers create waiting_review user requests.
  • Public request creation is intended for first-party browser flows and enforces same-origin checks.
  • When Turnstile is configured, public callers must include a valid turnstileToken or cf-turnstile-response value.
  • Anonymous mobile apps can submit by using a scoped bearer token from POST /v1/auth/mobile/public-session.
  • Authenticated callers can create private/internal planner items directly.
  • Authenticated native Planner clients should send the same 8-128 character mutation ID in the Idempotency-Key header and idempotencyKey body field; retries return the original row and conflicting reuse returns 409.
  • Public callers must provide productSlug or sourceSlug so the request can resolve to a product.
  • sourceSlug is accepted as an alias for productSlug.
JSON Body
Field Type Required Description
productSlug string No Preferred product slug field. Required for public callers unless sourceSlug is provided.
sourceSlug string No Alias for productSlug. Required for public callers when productSlug is omitted.
productId string No Useful for authenticated planner writes.
title string Yes Request title.
description string No Optional description.
status string No Auth only.
visibility public | private No Auth only.
origin user | internal No Auth only.
notes string No Auth only.
developerResponse string No Auth only. Public developer response text, preserving line breaks and bullets.
developerResponsePublic boolean No Auth only. When true, exposes developerResponse publicly.
itemType feature | bugfix No Public/mobile callers may choose feature or bugfix; authenticated callers may use planner item types.
releaseVersion string No Auth only.
sortOrder number No Auth only.
targetDate string No Auth only.
productName string No Auth only.
productSlugHint string No Auth only.
linkedPostId string No Auth only.
idempotencyKey string No Auth only. Stable Planner-create mutation ID; must match the Idempotency-Key header when both are present.
turnstileToken string No Public/browser flow when Turnstile is enabled.
cf-turnstile-response string No Public/browser alias for Turnstile.
Request
curl -X POST "$BASE_URL/v1/requests" \
  -H "Authorization: Bearer $SERVICE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "productSlug": "atlas",
    "title": "Tighten onboarding spacing",
    "status": "open",
    "visibility": "private",
    "origin": "internal",
    "itemType": "bugfix",
    "releaseVersion": "Next"
  }'
GET /v1/requests/:id
Public read, bearer for privileged access

Read one request or planner item.

  • Private, waiting_review, and archived items are hidden from unauthenticated callers.
Request
curl "$BASE_URL/v1/requests/req_abc123"
POST /v1/requests/bulk
Bearer auth

Bulk-create planner items.

  • Accepts either titles as an array or lines as a newline-delimited string.
JSON Body
Field Type Required Description
titles string[] No One title per item.
lines string No One line per item.
productSlug string No Optional product slug.
productId string No Optional product id.
visibility public | private No Defaults to private.
status string No Defaults to open.
itemType string No Defaults to update.
releaseVersion string No Optional release label.
targetDate string No Optional target date.
Request
curl -X POST "$BASE_URL/v1/requests/bulk" \
  -H "Authorization: Bearer $SERVICE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "productSlug": "atlas",
    "visibility": "private",
    "status": "open",
    "itemType": "update",
    "releaseVersion": "Next",
    "lines": "Fix bottom sheet spacing\nTweak roadmap copy\nReview promo button wording"
  }'
PATCH /v1/requests/bulk
Bearer auth

Bulk-update planner or request items by id.

JSON Body
Field Type Required Description
ids string[] Yes Item ids to update.
status string No Bulk status update.
visibility string No Bulk visibility update.
itemType string No Bulk item type update.
releaseVersion string No Bulk release version update.
targetDate string No Bulk target date update.
PATCH /v1/requests/:id
Bearer auth

Patch one request or planner item.

  • Enum fields are validated against the same tables the admin planner form uses, never coerced: an unknown status is 400 invalid_status, item type 400 invalid_item_type, visibility 400 invalid_visibility, each with an allowed array.
  • sortOrder must be a finite number (400 invalid_sort_order) and targetDate must be YYYY-MM-DD or null (400 invalid_target_date).
  • linkedPostId must name a post in this workspace, else 404 post_not_found. null or "" unlinks it.
  • productId, productName and productSlugHint move together. Linking a product clears the name snapshot and slug hint; productId null or "" unlinks and keeps the sent name, the existing name, or "Unlinked idea", recomputing the slug hint from it.
JSON Body
Field Type Required Description
title string No Updated title.
description string No Updated description. Also accepted as details; description wins when both are sent.
status string No Updated status.
linkedPostId string No Optional related post id.
visibility string No Updated visibility.
origin string No Updated origin.
notes string No Updated internal notes.
developerResponse string No Updated public developer response text.
developerResponsePublic boolean No Publishes or hides the developer response.
itemType string No Updated item type.
releaseVersion string No Updated release version.
sortOrder number No Updated order.
targetDate string No Updated target date.
productName string No Updated product name snapshot.
productSlugHint string No Updated slug hint.
productId string No Updated product link, or null to unlink.
POST /v1/requests/:id/move
Bearer auth

Move one planner item to another product and/or release and place it in that group.

  • A group is the set of items sharing a product and a release version; items with neither group together.
  • The destination group is renumbered 1..n in one batch, exactly as the admin board does, so a client can apply group.items and match the web byte for byte.
  • Absent fields keep their current value. Errors: 404 not_found, 404 product_not_found, 400 invalid_position, 400 invalid_sort_order.
JSON Body
Field Type Required Description
productId string | null No Destination product, or null to unlink.
releaseVersion string | null No Destination release version, or null for the backlog.
position top | bottom No Where in the destination group to land. Defaults to bottom.
sortOrder number No A 1-based slot in the destination group; wins over position.
Request
curl -X POST "$BASE_URL/v1/requests/req_abc123/move" \
  -H "Authorization: Bearer $SERVICE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"productId":"prod_abc123","releaseVersion":"1.4.0","position":"top"}'
Response
{
  "request": { "id": "req_abc123", "productId": "prod_abc123", "releaseVersion": "1.4.0", "sortOrder": 1 },
  "group": { "productId": "prod_abc123", "releaseVersion": "1.4.0", "items": [{ "id": "req_abc123", "sortOrder": 1 }] }
}
POST /v1/requests/reorder
Bearer auth

Write a whole planner group's order in one call.

  • Every id must exist in the workspace and be in the named group, else 400 group_mismatch with an offendingIds array.
  • Ids the group contains but orderedIds omits keep their relative order and are appended after the listed ones, so a client that reordered a filtered view cannot silently drop rows.
  • 400 invalid_ordered_ids when the list is empty, over 200 entries, duplicated, or contains a blank id.
JSON Body
Field Type Required Description
productId string | null No The group's product id, or null.
releaseVersion string | null No The group's release version, or null.
orderedIds string[] Yes 1-200 unique request ids in their new order.
Request
curl -X POST "$BASE_URL/v1/requests/reorder" \
  -H "Authorization: Bearer $SERVICE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"productId":"prod_abc123","releaseVersion":"1.4.0","orderedIds":["req_c","req_a"]}'
Response
{ "ok": true, "items": [{ "id": "req_c", "sortOrder": 1 }, { "id": "req_a", "sortOrder": 2 }] }
POST /v1/requests/capture
Bearer auth

Text or a URL in, planner items out.

  • One line becomes one item. "title -- description" splits on the first " -- ", using the same parser as /admin/planner/capture, and new items take the next sort order in the product group, so a capture from a phone lands where a capture from the web lands.
  • dryRun returns the parsed titles without writing anything, so a client can show "this will make 4 items" before committing a paste.
  • Errors: 400 nothing_to_capture, 400 capture_too_large over 100 lines, 400 product_not_found, plus the same invalid_status / invalid_item_type / invalid_visibility / invalid_target_date bodies as PATCH.
JSON Body
Field Type Required Description
text string No One item per line, at most 100 lines.
url string No Optional. When text produces no lines, the URL becomes one item.
productId string | null No A product in this workspace, or null.
productName string No Only used when productId is null.
itemType string No Defaults to update. Validated.
status string No Defaults to open. Validated.
visibility public | private No Defaults to private.
releaseVersion string | null No Optional release version for every captured item.
targetDate YYYY-MM-DD | null No Optional target date for every captured item.
dryRun boolean No Defaults to false. When true, nothing is written and preview is returned.
Request
curl -X POST "$BASE_URL/v1/requests/capture" \
  -H "Authorization: Bearer $SERVICE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"text":"Fix the empty state -- it spins forever","dryRun":true}'
Response
{ "preview": [{ "title": "Fix the empty state", "description": "it spins forever" }] }
GET /v1/requests/:id/tasks
Bearer auth

List checklist tasks for one planner item.

POST /v1/requests/:id/tasks
Bearer auth

Create one checklist task.

JSON Body
Field Type Required Description
title string Yes Task title.
PATCH /v1/requests/:id/tasks/:taskId
Bearer auth

Update one checklist task.

JSON Body
Field Type Required Description
title string No Updated title.
isDone boolean No Completion state.
orderIndex number No Task order.
DELETE /v1/requests/:id/tasks/:taskId
Bearer auth

Delete one checklist task.

POST /v1/requests/:id/vote
Public

Vote or unvote on a public roadmap item.

  • Web clients get a jw_vote cookie.
  • Public browser callers must submit from the same origin and include a valid Turnstile token when Turnstile is configured.
  • Anonymous mobile clients can use a scoped bearer token from POST /v1/auth/mobile/public-session.
  • Native clients can pass voterToken.
  • Send {"unvote": true} to remove an existing vote.
JSON Body
Field Type Required Description
turnstileToken string No Public/browser flow when Turnstile is enabled.
cf-turnstile-response string No Public/browser alias for Turnstile.
voterToken string No Optional native-client token.
unvote boolean No Toggle to remove a vote.
Reference

Assets and Articles

Uploaded files plus help and knowledge-base article reads.

GET /v1/assets
Public

List asset records.

Query Parameters
Field Type Required Description
product string No Product slug.
type string No Asset type filter.
limit number No Max 100.
offset number No Pagination offset.
POST /v1/assets
Bearer auth

Upload an asset with multipart form data.

  • productSlug is the preferred field, and sourceSlug is accepted as an alias.
  • Either productSlug or sourceSlug is required.
Form Fields
Field Type Required Description
file File Yes Binary upload.
productSlug string No Preferred product slug. Required unless sourceSlug is provided.
sourceSlug string No Alias for productSlug. Required when productSlug is omitted.
type string No Asset kind, default cover.
title string No Optional display title.
Request
curl -X POST "$BASE_URL/v1/assets" \
  -H "Authorization: Bearer $SERVICE_API_KEY" \
  -F "file=@./screenshot.png" \
  -F "productSlug=atlas" \
  -F "type=screenshot"
GET /v1/assets/:id
Public

Read one asset record.

GET /v1/articles
Public read, bearer for privileged access

List published articles, or with auth include unpublished ones.

  • Public callers only receive published=true articles.
  • Authenticated callers can filter by published=true or published=false.
Query Parameters
Field Type Required Description
product string No Product slug.
published true | false No Auth only when filtering unpublished content.
limit number No Max 100.
offset number No Pagination offset.
GET /v1/articles/:slug
Public read, bearer for privileged access

Read one article by slug.

  • Unpublished articles return 404 unless a bearer token is present.
Reference

Waitlist

Public waitlist signup plus authenticated signup management and invite generation.

GET /v1/waitlist
Bearer auth

List waitlist signups.

  • Supports filtering by productSlug, productId, and status.
  • Returns count for the current page and totalCount for the full matching result set.
  • Each signup includes productSlug, productName, inviteTargetLabel, inviteDestinationUrl, and inviteUrl when present.
Query Parameters
Field Type Required Description
productSlug string No Filter by product slug.
productId string No Filter by product id.
status new | contacted | invited | converted No Optional status filter.
limit number No Defaults to 100, max 1000.
offset number No Pagination offset.
Request
curl "$BASE_URL/v1/waitlist?productSlug=atlas&status=new&limit=50" \
  -H "Authorization: Bearer $SERVICE_API_KEY"
Response
{
  "signups": [
    {
      "id": "wl_abc123",
      "productId": "prod_atlas",
      "email": "captain@example.com",
      "name": "Captain Nora",
      "status": "invited",
      "source": "api",
      "productSlug": "atlas",
      "productName": "Atlas",
      "inviteTargetLabel": "TestFlight",
      "inviteDestinationUrl": "https://testflight.apple.com/join/abc123",
      "inviteUrl": "https://app.startshipyard.com/waitlist/invite/wlinv_123"
    }
  ],
  "count": 1,
  "totalCount": 14
}
POST /v1/waitlist
Public create, bearer for privileged fields

Create or refresh a product waitlist signup.

  • Public browser submissions are intended for first-party flows and enforce same-origin checks.
  • When Turnstile is configured, public submissions must include a valid turnstileToken or cf-turnstile-response value.
  • Bearer-authenticated callers can create signups without same-origin or Turnstile requirements.
  • Duplicate emails per product are upserted rather than inserted again.
JSON Body
Field Type Required Description
productSlug string Yes Target product slug.
email string Yes Signup email address.
name string No Optional person name.
notes string No Optional freeform notes or custom answer.
company string No Honeypot field. Should stay empty.
turnstileToken string No Public/browser flow when Turnstile is enabled.
cf-turnstile-response string No Public/browser alias for Turnstile.
Request
curl -X POST "$BASE_URL/v1/waitlist" \
  -H "Authorization: Bearer $SERVICE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "productSlug": "atlas",
    "email": "captain@example.com",
    "name": "Captain Nora",
    "notes": "Interested in the beta."
  }'
Response
{
  "ok": true,
  "id": "wl_abc123"
}
POST /v1/waitlist/:id/invite
Bearer auth

Create or refresh an invite link for one signup.

  • Returns 400 when the product does not yet have a public destination such as TestFlight, App Store, website, or public product page.
Request
curl -X POST "$BASE_URL/v1/waitlist/wl_abc123/invite" \
  -H "Authorization: Bearer $SERVICE_API_KEY"
PATCH /v1/waitlist/:id
Bearer auth

Update one waitlist signup status.

JSON Body
Field Type Required Description
status new | contacted | invited | converted Yes Next signup status.
Request
curl -X PATCH "$BASE_URL/v1/waitlist/wl_abc123" \
  -H "Authorization: Bearer $SERVICE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status":"contacted"}'
DELETE /v1/waitlist/:id
Bearer auth

Delete one waitlist signup.

Request
curl -X DELETE "$BASE_URL/v1/waitlist/wl_abc123" \
  -H "Authorization: Bearer $SERVICE_API_KEY"
Reference

Conditional Requests

ETag / If-None-Match revalidation and updatedAfter delta reads, for clients that keep a local copy.

GET /v1/products | /v1/posts | /v1/requests | /v1/reviews | /v1/roadmap | /v1/analytics* | /v1/member/*
Public read, bearer for privileged access

Cacheable reads carry a weak ETag; send it back as If-None-Match to get a bodyless 304.

  • Cacheable reads send Cache-Control: private, no-cache and Vary: Authorization. The client may store the body but must revalidate before every reuse, and no shared cache may store it at all.
  • Routes that must never live on a device keep Cache-Control: no-store and carry no ETag: /v1/auth/*, /v1/studio-remote/*, /v1/settings, /v1/billing, /v1/waitlist, /v1/export, every /v1/member/* write, every error response, and all of /admin and /operator. no-store forbids keeping the validator too, so those routes cannot be revalidated by design.
  • The tag is weak: W/"<32 hex>", the first 128 bits of the body SHA-256. Comparison follows RFC 9110 s13.1.2, so W/"x" and "x" match, * matches anything, and a comma-separated list is accepted.
  • A 304 repeats ETag, Cache-Control, Vary, the CORS headers and the security headers, and carries no body and no Content-Length.
  • No validator is attached to a write, an error, an HTML or streaming body, an already-encoded body, or a body over 4 MB.
Request
curl -i "$BASE_URL/v1/products" \
  -H "Authorization: Bearer $SHIPYARD_API_KEY" \
  -H 'If-None-Match: W/"7d1c0a1f5e0b4c2a9f3d8e6b1a4c7d20"'
Response
HTTP/1.1 304 Not Modified
ETag: W/"7d1c0a1f5e0b4c2a9f3d8e6b1a4c7d20"
Cache-Control: private, no-cache
Vary: Authorization
GET /v1/requests?updatedAfter=
Public read, bearer for privileged access

Delta reads: only the rows whose updatedAt is later than the cursor, plus tombstones.

  • Supported on GET /v1/requests, /v1/posts, /v1/products, and /v1/reviews/:productId.
  • Every one of them, plus the GET /v1/reviews rollup, returns serverTime. Use it as the next updatedAfter: it comes from the server clock, stepped back one second so a row committed during the read is not skipped. A row seen twice is free when the client merges by id.
  • GET /v1/requests returns deleted[] tombstones for soft-deleted requests to privileged callers, in the same 30-day window as GET /v1/requests/trash. GET /v1/reviews/:productIdOrSlug returns removed[] for reviews Apple has withdrawn. For request cursors older than 30 days, perform a full refresh before resuming deltas. Preserve the first page serverTime until all pages finish.
  • A delta read is ordered updatedAt ascending then id, so limit/offset page it; a full read keeps the route display order.
  • Anything that is not a full ISO-8601 timestamp is a 400, never a silently empty list. Tenant scoping is identical on both paths.
Query Parameters
Field Type Required Description
updatedAfter ISO-8601 No The serverTime from the previous response. Omit for a full read.
Request
curl "$BASE_URL/v1/requests?product=kapal&updatedAfter=2026-09-04T11:59:59.000Z" \
  -H "Authorization: Bearer $SHIPYARD_API_KEY"
Response
{
  "requests": [],
  "limit": 50,
  "offset": 0,
  "serverTime": "2026-09-04T12:14:22.000Z",
  "updatedAfter": "2026-09-04T11:59:59.000Z",
  "deleted": [{ "id": "req_01J", "deletedAt": "2026-09-04T12:03:11.000Z" }]
}
Reference

Member API

Bearer JSON endpoints for the native Shipyard Admin app: financials, runtime status, dashboard, cadence, planner, and Sketch.

GET /v1/member/financials/summary
Bearer auth

Revenue, costs, profit, monthly buckets, month-end projection, and the expense list.

  • Scope billing:read. Member tokens from the native sign-in flow and workspace-bound service API keys are both accepted; browser cookies are never consulted.
  • The workspace comes from the request Host. A bearer bound to another workspace is rejected 403 workspace_mismatch.
  • Responses carry Vary: Authorization. A successful GET carries Cache-Control: private, no-cache and an ETag so a native client can revalidate with If-None-Match; writes and error responses carry Cache-Control: no-store.
  • Dates are anchored to the workspace timezone and held back until a successful Apple sales report covers the day.
  • revenue.gross is always null: Apple sales reports stored by Shipyard carry developer proceeds only.
  • comparison compares the current calendar month to date against the same number of days in the previous month.
Query Parameters
Field Type Required Description
range month | six_months | one_year No Reporting window. Defaults to month.
product string No Optional product id; scopes expenses and Apple proceeds to one app.
Request
curl "$BASE_URL/v1/member/financials/summary?range=six_months" \
  -H "Authorization: Bearer $MEMBER_TOKEN"
Response
{
  "ok": true,
  "range": { "key": "six_months", "start": "2026-04-01", "end": "2026-09-01", "today": "2026-09-01" },
  "reportingCurrency": "USD",
  "revenue": { "gross": null, "net": 1284.55, "paidApp": 902.4, "iap": 382.15 },
  "costs": { "total": 611.28, "byExpense": [{ "id": "finexp_01hq", "name": "Cloudflare", "category": "hosting", "amount": 150 }] },
  "profit": 673.27,
  "comparison": { "previous": { "revenue": 38.9, "costs": 101.88, "profit": -62.98 }, "percent": { "revenue": 5.91 } },
  "buckets": [{ "key": "2026-04", "label": "Apr", "start": "2026-04-01", "end": "2026-04-30", "revenue": 210.4, "costs": 101.88, "profit": 108.52 }],
  "projection": { "monthEnd": { "revenue": 1236, "costs": 101.88, "profit": 1134.12, "reportedThrough": "2026-08-31" } },
  "expenses": [{ "id": "finexp_01hq", "name": "Cloudflare", "amount": 25, "cadence": "monthly", "nextDate": "2026-10-01", "status": "active" }],
  "products": [{ "id": "prod_01hq", "name": "MarketTrail", "slug": "markettrail" }]
}
POST /v1/member/financials/expenses
Bearer auth

Create a cost line.

  • Scope billing:manage.
  • The JSON body is validated by the same function as the admin financials form action.
  • Any invalid field returns 400 invalid_expense.
JSON Body
Field Type Required Description
name string Yes Up to 120 characters.
amount string | number Yes Positive, at most two decimals.
currency USD | EUR | GBP | CAD | AUD | JPY Yes Native currency of the cost.
cadence one_time | weekly | monthly | quarterly | yearly Yes Payment schedule.
category string Yes ai_tools, software, hosting, marketing, contractors, apple, legal, equipment, taxes, or other.
startsOn YYYY-MM-DD Yes First payment date.
endsOn YYYY-MM-DD No Optional end date, not before startsOn.
productId string No Optional app in this workspace; omit for a shared cost.
spreadAcrossMonths boolean No Recognise the payment daily across the following year.
notes string No Up to 500 characters.
Request
curl -X POST "$BASE_URL/v1/member/financials/expenses" \
  -H "Authorization: Bearer $MEMBER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Cloudflare","amount":"25.00","currency":"USD","cadence":"monthly","category":"hosting","startsOn":"2025-11-01"}'
Response
{ "ok": true, "expense": { "id": "finexp_01hq", "name": "Cloudflare", "amount": 25, "currency": "USD", "cadence": "monthly" } }
PATCH /v1/member/financials/expenses/:id
Bearer auth

Replace a cost line.

  • Scope billing:manage.
  • Full replacement: send the same complete body as the create call.
  • 404 expense_not_found when the id is not in this workspace.
Request
curl -X PATCH "$BASE_URL/v1/member/financials/expenses/finexp_01hq" \
  -H "Authorization: Bearer $MEMBER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Cloudflare","amount":"29.00","currency":"USD","cadence":"monthly","category":"hosting","startsOn":"2025-11-01"}'
Response
{ "ok": true, "expense": { "id": "finexp_01hq", "amount": 29 } }
DELETE /v1/member/financials/expenses/:id
Bearer auth

Delete a cost line permanently.

  • Scope billing:manage.
  • There is no expense trash.
Request
curl -X DELETE "$BASE_URL/v1/member/financials/expenses/finexp_01hq" \
  -H "Authorization: Bearer $MEMBER_TOKEN"
Response
{ "ok": true, "deleted": "finexp_01hq" }
GET /v1/member/runtime-status
Bearer auth

Queue health, review-sync state, and the newest twelve service jobs.

  • Scope api:read.
  • Returns queue health, current background jobs, and review-sync status for this workspace.
  • sync.state is error when any job is failed-but-retryable, else running, queued, or idle.
Request
curl "$BASE_URL/v1/member/runtime-status" \
  -H "Authorization: Bearer $MEMBER_TOKEN"
Response
{
  "ok": true,
  "shell": { "publicSiteUrl": "https://judeworks.startshipyard.com/", "waitingReviewCount": 3 },
  "counts": { "queued": 0, "running": 1, "failedRetryable": 0 },
  "sync": { "state": "running", "lastSuccessfulAt": "2026-09-01T15:04:00.000Z", "lastError": null },
  "jobs": [{ "id": "job_01hq", "type": "analytics.apple_sync", "status": "running", "attempts": 1, "maxAttempts": 5 }]
}
POST /v1/member/runtime-status/retry
Bearer auth

Re-queue failed service jobs so a phone that sees a failed sync can restart it.

  • Scope api:write.
  • Body is optional: jobId retries one job from the runtime-status jobs array; omitting it retries every retryable job, newest failure first, up to 25 per call.
  • Retryable means exactly what counts.failedRetryable counts: status is failed and attempts < maxAttempts.
  • Attempts are not reset, so a retry spends one of the remaining attempts and an exhausted job stays refused.
  • An analytics.apple_sync_run job also has its Apple sync run failed days reset to queued first, before the job is retried.
  • Retrying when nothing is retryable is a success with retried: 0, not an error.
  • 404 not_found when the id is not in this workspace; 409 not_retryable, carrying the offending job, when it has exhausted its attempts or is not failed at all.
Request
curl -X POST "$BASE_URL/v1/member/runtime-status/retry" \
  -H "Authorization: Bearer $MEMBER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jobId":"job_01hq"}'
Response
{
  "ok": true,
  "retried": 1,
  "jobs": [{ "id": "job_01hq", "type": "analytics.apple_sync_run", "status": "queued", "attempts": 3, "maxAttempts": 5 }],
  "syncRuns": [{ "runId": "asr_01hq", "totalDates": 12, "pendingDates": 4 }]
}
GET /v1/member/dashboard
Bearer auth

Portfolio and per-app rollups, Apple totals, and request/post counts in one call.

  • Scope analytics:read.
  • The window ends yesterday because Apple sales reports and device activity both land a day behind.
  • apple is null when the workspace has no App Store Connect connection, rather than reporting zeros.
  • An app carries null units and proceeds when no Apple row maps to it.
  • momentum and portfolio.weekOverWeek are percent change in active devices between the last two complete weeks.
  • Simulator traffic is excluded from every device count.
Query Parameters
Field Type Required Description
days number No 1 to 365. Defaults to 30.
Request
curl "$BASE_URL/v1/member/dashboard?days=30" \
  -H "Authorization: Bearer $MEMBER_TOKEN"
Response
{
  "ok": true,
  "days": 30,
  "range": { "startDate": "2026-08-03", "endDate": "2026-09-01" },
  "portfolio": { "activeDevices": 412, "activeToday": 96, "weekOverWeek": 11.7 },
  "apps": [{ "productId": "prod_01hq", "slug": "markettrail", "name": "MarketTrail", "status": "active", "activeDevices": 288, "units": 640, "proceeds": 902.4, "momentum": 9.4 }],
  "apple": { "units": 640, "proceeds": 902.4, "currency": "USD", "freshness": { "reportedThrough": "2026-08-31", "windowEnd": "2026-09-01" } },
  "requests": { "waitingReview": 3, "pending": 12 },
  "posts": { "scheduled": 2, "published7d": 4 }
}
GET /v1/member/cadence
Bearer auth

Publishing cadence buckets and trajectory from publication dates only.

  • Scope api:read.
  • No traffic or engagement data is involved.
  • Buckets are dense: a month with no posts is present with zeros.
  • trajectory ignores the in-progress month, so a partial month never reads as a slowdown.
Query Parameters
Field Type Required Description
months 6 | 12 | 24 | all No History window. Defaults to 12.
product string No Optional product id or slug.
Request
curl "$BASE_URL/v1/member/cadence?months=12" \
  -H "Authorization: Bearer $MEMBER_TOKEN"
Response
{
  "ok": true,
  "months": 12,
  "product": null,
  "summary": { "total": 48, "activeMonths": 12, "monthlyRate": 4, "annualizedRate": 48 },
  "buckets": [{ "month": "2026-08", "label": "Aug 26", "released": 2, "updates": 3, "other": 1, "posts": 6, "products": 3 }],
  "trajectory": { "direction": "up", "label": "Increasing 22%", "detail": "Last 3 complete months: 5.3/mo · previous 3: 4.3/mo" }
}
GET /v1/member/planner
Bearer auth

The grouped planner board with each item’s checklist tasks.

  • Scope api:read.
  • Items are serialized exactly as the privileged GET /v1/requests read, minus the idempotency bookkeeping columns, plus product and tasks.
  • At most 500 items are returned; counts.truncated says whether more exist.
  • release view keeps empty groups so the board renders its columns; status view drops them.
Query Parameters
Field Type Required Description
view release | status No Grouping. Defaults to release.
product string No Product slug, or __ideas__ for items with no product.
Request
curl "$BASE_URL/v1/member/planner?product=markettrail&view=release" \
  -H "Authorization: Bearer $MEMBER_TOKEN"
Response
{
  "ok": true,
  "view": "release",
  "product": { "id": "prod_01hq", "slug": "markettrail", "name": "MarketTrail", "workingVersion": "1.4" },
  "groups": [{ "key": "version:1.4", "kind": "current", "label": "1.4", "count": 1, "items": [{ "id": "req_01hq", "title": "Watchlist sync", "status": "in_progress", "tasks": [] }] }],
  "counts": { "total": 37, "returned": 37, "truncated": false, "byStatus": { "open": 20 } }
}
GET /v1/member/maps
Bearer auth

Every sketch in the workspace, newest change first.

  • Scope api:read.
  • nodeCount and edgeCount are computed from the stored document, so a list can say "23 objects" without shipping every document.
Request
curl "$BASE_URL/v1/member/maps" \
  -H "Authorization: Bearer $MEMBER_TOKEN"
Response
{
  "ok": true,
  "maps": [{ "id": "map_01hq", "name": "Kapal v2", "revision": 14, "nodeCount": 23, "edgeCount": 19, "updatedAt": "2026-09-04T18:02:11.000Z", "createdAt": "2026-07-01T09:00:00.000Z" }]
}
POST /v1/member/maps
Bearer auth

Create an empty sketch at revision 1.

  • Scope api:write.
  • 400 invalid_name when the name is empty or over 120 characters.
JSON Body
Field Type Required Description
name string Yes Up to 120 characters.
Request
curl -X POST "$BASE_URL/v1/member/maps" \
  -H "Authorization: Bearer $MEMBER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Kapal v2"}'
Response
{ "ok": true, "map": { "id": "map_01hq", "name": "Kapal v2", "revision": 1, "document": { "version": 1, "nodes": [], "edges": [] } } }
GET /v1/member/maps/entities
Bearer auth

Search the workspace for products and planner items a sketch can reference.

  • Scope api:read.
  • Entities are shaped exactly as the admin sketch editor's, so a node created on a phone and a node created on the web are the same node.
Query Parameters
Field Type Required Description
q string No Optional search text, up to 80 characters.
type product | planner No Omit for both. Anything else is 400 invalid_view.
Request
curl "$BASE_URL/v1/member/maps/entities?q=kapal&type=product" \
  -H "Authorization: Bearer $MEMBER_TOKEN"
Response
{ "ok": true, "entities": [{ "type": "product", "id": "prod_01hq", "name": "Kapal", "primaryColor": "#18181B", "href": "/admin/products/prod_01hq" }] }
POST /v1/member/maps/entities/planner
Bearer auth

Create a private planner item from a sketch node and keep it linked.

  • Scope api:write.
  • The web returns only entity. This route also returns the full privileged planner row, so a client can drop the new item straight into its planner cache without a refetch.
  • 400 invalid_title when the title is blank, 400 invalid_product when the product is not in this workspace.
JSON Body
Field Type Required Description
title string Yes Up to 300 characters.
productId string | null No A product in this workspace, or null for an unlinked idea.
status string No Defaults to planned.
itemType string No Defaults to feature.
Request
curl -X POST "$BASE_URL/v1/member/maps/entities/planner" \
  -H "Authorization: Bearer $MEMBER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title":"Fix empty state","productId":"prod_01hq"}'
Response
{
  "ok": true,
  "entity": { "type": "planner", "id": "req_01hq", "title": "Fix empty state", "status": "planned", "itemType": "feature" },
  "request": { "id": "req_01hq", "title": "Fix empty state", "visibility": "private", "sortOrder": 9999 }
}
GET /v1/member/maps/:id
Bearer auth

One sketch with its document and every entity the document references.

  • Scope api:read.
  • The document is normalized on the way out, so a client never has to defend against a shape the web wrote before the validator existed.
  • 404 map_not_found when the id is not in this workspace.
Request
curl "$BASE_URL/v1/member/maps/map_01hq" \
  -H "Authorization: Bearer $MEMBER_TOKEN"
Response
{
  "ok": true,
  "map": { "id": "map_01hq", "name": "Kapal v2", "revision": 14, "document": { "version": 1, "nodes": [], "edges": [], "viewport": { "x": 80, "y": 80, "zoom": 1 } } },
  "references": []
}
PUT /v1/member/maps/:id
Bearer auth

Save a sketch optimistically against the revision the client last read.

  • Scope api:write.
  • A 409 map_conflict carries the current revision and the current sketch INCLUDING its document. The admin web 409 does not: a browser can open a second tab to see what it lost to, a phone cannot, so the refusal has to contain the winning document for a "Keep mine / Take theirs" prompt.
  • 400 invalid_name, 400 invalid_revision, 400 invalid_document (the message is the document validator's own reader-facing text, e.g. "Sketches support up to 400 objects.").
  • 413 payload_too_large over 900 KB, 415 unsupported_media_type for a non-JSON content type, 404 map_not_found when the sketch does not exist.
JSON Body
Field Type Required Description
name string Yes Up to 120 characters.
revision number Yes The revision this client last read.
document object Yes The whole sketch document; normalized and capped at 400 nodes and 800 edges.
Request
curl -X PUT "$BASE_URL/v1/member/maps/map_01hq" \
  -H "Authorization: Bearer $MEMBER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Kapal v2","revision":14,"document":{"version":1,"nodes":[],"edges":[]}}'
Response
{ "ok": true, "revision": 15, "updatedAt": "2026-09-05T10:04:00.000Z" }
DELETE /v1/member/maps/:id
Bearer auth

Delete a sketch permanently.

  • Scope api:write. There is no sketch trash.
  • 404 map_not_found when the id is not in this workspace.
Response
{ "ok": true, "deleted": "map_01hq" }
GET /v1/member/maps/:id/history
Bearer auth

The 40 newest saved versions of a sketch, newest first.

  • Scope api:read.
Response
{ "ok": true, "revisions": [{ "revision": 14, "name": "Kapal v2", "createdAt": "2026-09-04T18:02:11.000Z" }] }
POST /v1/member/maps/:id/restore
Bearer auth

Restore an earlier version of a sketch; the current version stays in history.

  • Scope api:write.
  • The path is /restore, not the web's /history/restore: the member surface keeps one verb per path segment.
  • 409 map_conflict uses the same envelope as PUT. 404 revision_not_found when that version does not exist.
JSON Body
Field Type Required Description
sourceRevision number Yes The version to restore.
currentRevision number Yes The revision this client last read.
Response
{ "ok": true, "revision": 15, "name": "Kapal v2", "document": { "version": 1, "nodes": [], "edges": [] } }
POST /v1/member/maps/:id/duplicate
Bearer auth

Copy a sketch under "Copy of <name>".

  • Scope api:write.
  • Returns the new sketch rather than the web's redirect.
Response
{ "ok": true, "map": { "id": "map_01hr", "name": "Copy of Kapal v2", "revision": 1, "document": { "version": 1, "nodes": [], "edges": [] } } }
Reference

Member Sign-In

Native app sign-in support: which workspaces a signed-in user may open, and rotation of the 30-day member token.

GET /v1/auth/{mobile|studio-mobile|studio-mac}/workspaces
Public

List the workspaces the signed-in Clerk user may open from a native app.

  • Browser leg only: authenticated by the Clerk cookie session, restricted to same-origin requests, and never cached (Cache-Control: no-store, Vary: Cookie).
  • No bearer token is accepted and none is returned. The endpoint mints nothing.
  • Eligibility matches the sign-in callback: an active owner/admin membership on an active or trial workspace, matched by Clerk identity or an unclaimed email invitation.
  • On a workspace host the tenant pins the answer to at most one entry; on the canonical app host every eligible membership is returned, bounded to 25.
  • Returns 401 without a Clerk session, 403 cross-origin, 503 when mobile auth is unconfigured.
Request
curl "$BASE_URL/v1/auth/mobile/workspaces" -b "$CLERK_SESSION_COOKIE"
Response
{
  "workspaces": [
    { "slug": "judeworks", "name": "JudeWorks", "role": "owner" },
    { "slug": "northharbor", "name": "North Harbor", "role": "admin" }
  ]
}
POST /v1/auth/{mobile|studio-mobile|studio-mac}/refresh
Bearer auth

Rotate a live member token for a fresh 30-day token and revoke the presented one.

  • Member bearer only, scope api:read. Rate limited to 30 attempts per 15 minutes, like /exchange.
  • Call it on the workspace host returned as baseUrl: the workspace comes from the request Host, not from the token.
  • No request body. The response is exactly the /exchange shape with a new token and expiry; scopes, role, and native client carry over.
  • The presented token is revoked in the same conditional update that mints its successor, so exactly one concurrent refresh wins and the old token then fails ordinary authentication with a clean 401.
  • There is no eligibility window; clients refresh on launch when the stored session is within seven days of expiry.
  • Refusals are 401 with error token_expired, token_revoked, membership_inactive, or unauthorized. 429 rate_limited leaves the current token usable.
  • Refresh uses the existing member bearer token and does not require a browser sign-in session.
Request
curl -X POST "$BASE_URL/v1/auth/mobile/refresh" \
  -H "Authorization: Bearer $MEMBER_TOKEN"
Response
{
  "token": "shipyard_mobile.eyJ2IjoyLCJ0",
  "studioName": "JudeWorks",
  "baseUrl": "https://judeworks.startshipyard.com",
  "workspaceId": "ws_01hq",
  "memberId": "mem_01hq",
  "role": "owner",
  "scopes": ["api:read", "api:write", "billing:read"],
  "expiresAt": "2026-11-02T12:00:00.000Z"
}
Reference

Studio Remote

Content-free control plane for the running Shipyard Studio Host. Workspace scope comes from the signed member token, not the request Host.

GET /v1/studio-remote/notifications
Bearer auth

Recovery inbox: list the notification intents this member's own Hosts have queued.

  • Member bearer, scope api:read. Cache-Control: no-store, Vary: Authorization.
  • Rows are joined through the Host, so an intent is visible only to the workspace and member that owns that Host.
  • An unknown, foreign, or deleted hostId returns an empty list rather than 404: existence is never revealed.
  • The projection is the same content-free allowlist as GET /status. No title, body, project, task, source, log, diff, endpoint, or credential exists in these rows.
  • kind is one of hostOffline, captainNeedsAnswer, reviewReady, workBlocked, deploymentSucceeded, deploymentFailed, verificationWaiting. Intents are retained for seven days.
  • A returned intent is not evidence that a push was delivered or viewed.
Query Parameters
Field Type Required Description
hostId string No Optional opaque control-plane Host id.
since ISO-8601 No Return intents created strictly after this time. 400 invalidInput if unparseable.
limit number No 1-200, default 50. 400 invalidInput outside that range.
Request
curl "$BASE_URL/v1/studio-remote/notifications?limit=20" \
  -H "Authorization: Bearer $MEMBER_TOKEN"
Response
{
  "ok": true,
  "notifications": [
    {
      "id": "srint_01hq",
      "hostId": "srhost_01hq",
      "kind": "captainNeedsAnswer",
      "createdAt": "2026-09-02T18:04:00.000Z",
      "expiresAt": "2026-09-02T18:34:00.000Z",
      "status": "delivered",
      "openedAt": null
    }
  ]
}
GET /v1/studio-remote/status
Bearer auth

Current member/workspace ids and bounded Host lease summaries.

  • Member bearer, scope api:read.
  • Deliberately returns no workspace name, slug, billing, or account metadata, and no host endpoint, APNs token, or member email.
  • Pairing assertions, host leases, notification registration, and inbox recovery use this same workspace origin and bearer authorization.
Request
curl "$BASE_URL/v1/studio-remote/status" \
  -H "Authorization: Bearer $MEMBER_TOKEN"
Response
{
  "ok": true,
  "member": { "id": "mem_01hq", "workspaceId": "ws_01hq", "role": "owner", "status": "active" },
  "workspace": { "id": "ws_01hq" },
  "hosts": [
    { "hostId": "srhost_01hq", "status": "active", "protocolMajor": 1, "lastRenewedAt": "2026-09-02T18:00:00.000Z", "expiresAt": "2026-09-02T18:03:00.000Z" }
  ]
}
Reference

Roadmap Feed

A simplified public roadmap surface and authenticated planner grouping powered by the same request model.

GET /v1/roadmap
Public read, bearer for privileged access

List the public roadmap feed or authenticated planner groups.

  • Returns both roadmap and requests keys with the same rows.
  • Only public items are returned by default.
  • waiting_review and archived are hidden by default.
  • Authenticated planning=1 returns private/internal planner fields and hides only archived items by default.
  • grouped=1 returns a planner object grouped by current working version, future versions, and backlog.
Query Parameters
Field Type Required Description
product string No Product slug.
status string No Optional status filter.
visibility public | private | all No Auth only.
planning 0 | 1 No Auth only. Include public and private planner items.
grouped 0 | 1 No Return planner release groups.
group releaseVersion No Alternative way to request release-version grouping.
limit number No Max 100.
offset number No Pagination offset.
Request
curl "$BASE_URL/v1/roadmap?product=atlas&planning=1&visibility=all&grouped=1" \
  -H "Authorization: Bearer $SERVICE_API_KEY"