This is an archived copy of the SandyWP docs. It is kept online as a fallback and may be out of date. The maintained documentation lives at docs.sandywp.com.

API

A small HTTP/JSON API for managing WordPress sandboxes, Blueprints, and Templates. Everything the CLI, dashboard, and MCP connector do runs through these endpoints.

Base URL

https://app.sandywp.com

Authentication

Every endpoint on this page requires a personal API token, sent as a bearer token:

Authorization: Bearer swp_xxxxxxxxxxxxxxxxxxxx

Get a token with sandywp auth login, from the account menu in your dashboard (API keys), or by calling the tokens endpoint. Requests without a valid token return 401. See API keys for how to create and revoke them.

Full-access tokens vs. scoped MCP tokens

There are two kinds of bearer token, and only one of them can call the endpoints documented on this page:

  • Full-access tokens — a personal API token (from the account menu or sandywp auth login) or the CLI's own token. These reach every endpoint under /api/app/* and /api/account/*, exactly like your logged-in browser session.
  • Scoped OAuth tokens — minted when you connect an MCP client (Claude, or any other MCP-capable agent) via /connect/authorize. These are deliberately restricted to /mcp, /api/app/imports*, and /api/account/me. A scoped token presented to any Blueprint, Template, or Sandbox REST endpoint on this page is treated as unauthenticated and gets a 401, even if the underlying scope (for example blueprints:write) would otherwise allow the action.
An MCP client manages Blueprints, Templates, and sandboxes through the MCP tool catalog over the /mcp JSON-RPC endpoint instead for example sandywp_bake_blueprint mirrors POST /api/app/blueprints/:id/bake. Each MCP tool enforces its own fine-grained scope (blueprints:read, blueprints:write, sites:write, …) chosen when the user approves the connection. This page documents the REST surface a full-access token calls directly; the CLI (docs) is the recommended wrapper for scripts and CI.

Conventions

  • Requests and responses are JSON. Send Content-Type: application/json on writes.
  • Sandboxes, Blueprints, and Templates are addressed by their id in the API. The CLI resolves the friendlier slug to an id for you.
  • Errors use a consistent envelope:
{
  "error": {
    "code": "plan_limit_reached",
    "message": "Your free plan allows 2 active sandboxes.",
    "details": { "plan": "free", "limit": 2, "activeSites": 2 }
  }
}

Some actions require a paid plan and fail with HTTP 402 and an error code such as blueprint_requires_paid_plan or template_sharing_requires_paid_plan — each is called out below. See Plans & limits.

Async by default. Provisioning and Blueprint execution happen on a worker, not inline in the request. An endpoint that starts one returns immediately with the resource in a non-terminal state (a site "creating", a Template "building", a Blueprint run "pending"/"running") poll the matching GET endpoint until it reaches a terminal status. The Blueprint → Template → demo workflow below is a full worked example.

Account

GET /api/account/me

Returns the authenticated user.

curl https://app.sandywp.com/api/account/me \
  -H "Authorization: Bearer $SANDYWP_TOKEN"
{ "id": "user_123", "email": "[email protected]" }
GET /api/account/usage

Returns your plan and current sandbox usage.

{ "email": "[email protected]", "plan": "free", "limit": 2, "activeSites": 1 }

Webhooks

GET /api/account/webhooks/demo-launches

Reads the selected workspace's Demo Launches webhook configuration and its latest delivery status.

PUT /api/account/webhooks/demo-launches

Creates or updates the callback. The signing secret is returned only when the endpoint is first created or rotated. Send an empty url only when disabling an existing endpoint; its saved URL is retained.

curl -X PUT https://app.sandywp.com/api/account/webhooks/demo-launches \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://hooks.example.com/sandywp/demo-ready","enabled":true}'
{
  "webhook": {
    "id": "demohook_123",
    "url": "https://hooks.example.com/sandywp/demo-ready",
    "enabled": true,
    "secretConfigured": true,
    "lastDelivery": null
  },
  "secret": "whsec_…"
}
POST /api/account/webhooks/demo-launches/test

Sends a signed test payload to the enabled callback.

POST /api/account/webhooks/demo-launches/rotate

Replaces the HMAC-SHA256 signing secret and returns the new value once.

DELETE /api/account/webhooks/demo-launches

Removes the callback, secret, and stored delivery history.

SandyWP sends demo.ready after the launched sandbox is ready to serve its URL and demo.expired when the demo lease ends. Both payloads use the same shape; the type, status, and occurredAt fields identify the lifecycle transition. Requests include x-sandywp-signature: sha256=…, x-sandywp-event, x-sandywp-delivery, and an idempotency-key. Failed deliveries retry with backoff and become terminal after eight attempts. The email field is null when the launch link does not collect an email. consent is always a boolean and is false when the checkbox is disabled or left unchecked; consentLabel is the configured checkbox text or null when consent is disabled.
{
  "id": "demo_ready_site_123",
  "type": "demo.ready",
  "occurredAt": "2026-08-02T12:00:02.000Z",
  "workspaceId": "workspace_123",
  "templateId": "template_123",
  "templateName": "Product demo",
  "siteId": "site_123",
  "siteUrl": "https://demo.example.com/site-123",
  "email": "[email protected]",
  "consent": true,
  "consentLabel": "I agree to receive product updates.",
  "status": "ready",
  "source": "template_launch"
}

Expiration payloads use the same fields with type: demo.expired and status: expired.

{
  "id": "demo_expired_site_123",
  "type": "demo.expired",
  "occurredAt": "2026-08-02T13:00:02.000Z",
  "workspaceId": "workspace_123",
  "templateId": "template_123",
  "templateName": "Product demo",
  "siteId": "site_123",
  "siteUrl": "https://demo.example.com/site-123",
  "email": "[email protected]",
  "consent": true,
  "consentLabel": "I agree to receive product updates.",
  "status": "expired",
  "source": "template_launch"
}

Verifying the signature

Your callback URL is publicly reachable, so verify every request before acting on it. Compute HMAC-SHA256 over the raw request body using your signing secret and compare the result with the x-sandywp-signature header.

import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';

const app = express();

// express.raw() keeps the exact bytes SandyWP signed. Do not use express.json() here.
app.post('/sandywp/demo-ready', express.raw({ type: 'application/json' }), (req, res) => {
  const expected =
    'sha256=' +
    createHmac('sha256', process.env.SANDYWP_WEBHOOK_SECRET)
      .update(req.body)
      .digest('base64url');
  const received = req.get('x-sandywp-signature') ?? '';

  const a = Buffer.from(expected);
  const b = Buffer.from(received);
  if (a.length !== b.length || !timingSafeEqual(a, b)) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on req.get('idempotency-key') before acting on the event.
  console.log(event.type, event.siteUrl);
  res.sendStatus(200);
});

Three things to get right:

  • Hash the raw body bytes, before any JSON parsing. Re-serialized JSON will not match.
  • The digest is base64url, not hex, and the header value is prefixed with sha256=.
  • Compare in constant time (timingSafeEqual), never with ===.

Store the secret in your receiver's environment or secret manager, never in SandyWP and never in version control. Retries reuse the same idempotency-key, so key your deduplication on that header.

Tokens

POST /api/account/tokens

Creates a personal API token. The plaintext token is returned once store it now. Optional body: label.

curl -X POST https://app.sandywp.com/api/account/tokens \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"label":"ci-pipeline"}'
{
  "token": "swp_xxxxxxxxxxxxxxxxxxxx",
  "apiToken": {
    "id": "apitok_123",
    "label": "ci-pipeline",
    "createdAt": "2026-06-20T10:00:00.000Z",
    "lastUsedAt": null
  }
}
GET /api/account/tokens

Lists your active tokens (metadata only never the token value).

{
  "tokens": [
    { "id": "apitok_123", "label": "ci-pipeline", "createdAt": "…", "lastUsedAt": "…" }
  ]
}
DELETE /api/account/tokens/:id

Revokes a token. Returns { "success": true }.

Sandboxes

POST /api/app/sites

Creates a sandbox. All fields are optional; sensible defaults are used (latest WordPress, PHP 8.3, standard preset, auto worker).

FieldTypeDescription
siteNamestringDisplay name; the slug is derived from it
wordpressVersionstringe.g. latest
phpVersionstringe.g. 8.3
provisioningPresetstringstandard or debug
durationstringFixed: 1h, 1d, 1w, 2w, 1m, or permanent. Inactivity: 10m, 30m, 1h, or 1d
expirationModestringfixed (default), or inactivity to restart the selected duration whenever the sandbox is actively used. A tab left open in the background does not count as use. Permanent is fixed-only
templateIdstringCreate from a saved Template instead (see below); runtime fields are ignored, but siteName, duration, and expirationMode still apply
backgroundbooleanReturn immediately with status: "creating" instead of waiting for the sandbox to be ready
curl -X POST https://app.sandywp.com/api/app/sites \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"siteName":"my-sandbox","duration":"1d","expirationMode":"inactivity"}'
{
  "site": {
    "id": "site_abc123",
    "name": "my-sandbox",
    "slug": "my-sandbox",
    "status": "ready",
    "publicUrl": "https://my-sandbox.sandywp.dev",
    "wordpressVersion": "latest",
    "phpVersion": "8.3",
    "workerCode": "eu1",
    "adminUsername": "admin",
    "adminPassword": "SandyWP…",
    "magicLoginUrl": "https://my-sandbox.sandywp.dev/?sandywp_magic=…",
    "permanent": false,
    "expiresAt": "2026-06-27T10:00:00.000Z",
    "expirationMode": "inactivity",
    "idleTimeoutMinutes": 1440,
    "createdAt": "2026-06-20T10:00:00.000Z",
    "readyAt": "2026-06-20T10:00:02.000Z",
    "failureReason": null
  }
}
By default creation is synchronous: the request waits until the sandbox is ready and returns status: "ready" with the admin credentials and login URL. If provisioning takes longer than ~60s the response comes back with status: "creating" and HTTP 202 poll the status endpoint until it becomes ready. Pass background: true to skip the wait and return immediately.
GET /api/app/sites

Lists your sandboxes.

{ "sites": [ { "slug": "my-sandbox", "status": "ready", "publicUrl": "https://…", "adminUsername": "admin", "magicLoginUrl": "https://…" } ] }
GET /api/sites/:id/status

Returns a sandbox with its recent provisioning events and jobs useful for showing live progress.

{
  "site": { "slug": "my-sandbox", "status": "ready", "publicUrl": "https://…", "magicLoginUrl": "https://…" },
  "events": [ { "kind": "worker.job_event", "message": "Finalizing WordPress settings.", "createdAt": "…" } ],
  "jobs": [ { "type": "cold_provision_site", "status": "succeeded" } ]
}
POST /api/sites/:id/magic-login

Issues a one-time login link that opens the sandbox already signed in to wp-admin.

{
  "token": "…",
  "url": "https://my-sandbox.sandywp.dev/?sandywp_magic=…",
  "expiresAt": "2026-06-27T10:00:00.000Z"
}
DELETE /api/app/sites/:id

Deletes a sandbox. Returns the deleted site record.

GET /api/app/sites/:id

Same shape as GET /api/sites/:id/status above, addressed under the /api/app namespace.

PATCH /api/app/sites/:id

Sets whether a sandbox is permanent (never expires). Setting true requires a paid plan.

curl -X PATCH https://app.sandywp.com/api/app/sites/site_abc123 \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"permanent":true}'

Send name instead to rename the sandbox (2-80 characters). The rename changes the display name only: the slug, sandbox URL, and admin login stay the same. A body with a name field is treated as a rename; permanent is ignored in that case.

curl -X PATCH https://app.sandywp.com/api/app/sites/site_abc123 \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Client staging"}'
POST /api/app/sites/:id

Restores an expired sandbox (its data is retained until the retention window ends). Body: { "action": "restore" }.

curl -X POST https://app.sandywp.com/api/app/sites/site_abc123 \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"action":"restore"}'
POST /api/app/sites/:id/reset

Resets a ready sandbox back to a clean WordPress install in place — same site record, URL, and admin login, but every plugin, theme, upload, and post is wiped. Not supported for multisite. Irreversible. Async — poll GET /api/app/sites/:id and check the returned jobId against its jobs.

curl -X POST https://app.sandywp.com/api/app/sites/site_abc123/reset \
  -H "Authorization: Bearer $SANDYWP_TOKEN"
{ "site": { "id": "site_abc123", "status": "creating", "…": "…" }, "jobId": "job_777" }

PHP

See the PHP settings guide for the dashboard workflow.

GET /api/app/sites/:id/php-config

Returns the sandbox's current PHP ini values, defaults, memory-limit ceiling, PHP version, and any pending change.

{
  "phpConfig": {
    "maxExecutionTime": 300, "maxInputTime": 300, "maxInputVars": 5000, "memoryLimitMb": 512,
    "allowUrlFopen": true, "postMaxSizeMb": 512, "uploadMaxFilesizeMb": 512,
    "sessionGcMaxlifetime": 1440, "outputBufferingBytes": 4096
  },
  "defaults": { "…": "… (same shape)" },
  "limits": { "memoryLimitMaxMb": 2048 },
  "pending": false,
  "lastJob": null,
  "phpVersion": "8.3",
  "phpVersionOptions": ["8.3", "8.2", "8.1", "7.4"],
  "versionSwitch": { "pending": false, "lastJob": null }
}
PUT /api/app/sites/:id/php-config

Updates one or more PHP ini values (async php_config job). All fields optional only the ones you send change.

FieldTypeDescription
maxExecutionTimenumberSeconds (10–300)
maxInputTimenumberSeconds (10–300)
maxInputVarsnumber100–10000
memoryLimitMbnumberPHP memory_limit
allowUrlFopenbooleanallow_url_fopen
postMaxSizeMbnumberpost_max_size
uploadMaxFilesizeMbnumberupload_max_filesize
sessionGcMaxlifetimenumberSeconds
outputBufferingBytesnumberoutput_buffering
curl -X PUT https://app.sandywp.com/api/app/sites/site_abc123/php-config \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"memoryLimitMb":1024,"maxExecutionTime":120}'
{ "phpConfig": { "…": "… (full updated SitePhpConfig)" }, "jobId": "job_222" }
PUT /api/app/sites/:id/php-version

Switches the sandbox's PHP runtime (async php_version job; the worker rolls back on failure). Body: { "phpVersion": string }.

curl -X PUT https://app.sandywp.com/api/app/sites/site_abc123/php-version \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"phpVersion":"7.4"}'
{ "phpVersion": "7.4", "jobId": "job_223" }

Debug

GET /api/app/sites/:id/debug

Returns the sandbox's WordPress debug settings, defaults, and any pending change.

{
  "debugOptions": { "wpDebug": true, "wpDebugLog": true, "wpDebugDisplay": false, "scriptDebug": false, "queryMonitor": true },
  "defaults": { "wpDebug": false, "wpDebugLog": false, "wpDebugDisplay": false, "scriptDebug": false, "queryMonitor": false },
  "pending": false,
  "lastJob": null
}
PUT /api/app/sites/:id/debug

Replaces the debug settings (async apply_debug_options job) — any field you omit is treated as off. Fields: wpDebug, wpDebugLog, wpDebugDisplay, scriptDebug, queryMonitor (all boolean; log/display only take effect when wpDebug is on).

curl -X PUT https://app.sandywp.com/api/app/sites/site_abc123/debug \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"wpDebug":true,"wpDebugLog":true,"queryMonitor":true}'
GET /api/app/sites/:id/debug/log

Tails wp-content/debug.log. Optional ?lines= query param (clamped server-side).

{ "content": "[29-Jul-2026 10:00:00 UTC] PHP Warning: …\n", "exists": true, "lines": 500 }
DELETE /api/app/sites/:id/debug/log

Clears the debug log. Returns { "ok": true }.

Database

See the Database guide for the dashboard workflow.

POST /api/app/sites/:id/database

Issues a short-lived Adminer URL to browse and edit the sandbox's database directly.

curl -X POST https://app.sandywp.com/api/app/sites/site_abc123/database \
  -H "Authorization: Bearer $SANDYWP_TOKEN"
{ "url": "https://my-sandbox.sandywp.dev/adminer/?token=…", "expiresAt": "2026-07-29T11:00:00.000Z" }

Email log

GET /api/app/sites/:id/email

Returns captured outgoing wp_mail messages for the sandbox.

{
  "messages": [
    { "id": "email_1", "sentAt": "2026-07-29T10:00:00.000Z", "to": ["[email protected]"], "subject": "New order", "html": "…", "text": "…", "headers": [], "attachments": [], "delivery": "sent", "error": "" }
  ],
  "exists": true,
  "enabled": true
}
PUT /api/app/sites/:id/email

Turns email capture on or off. Body: { "enabled": boolean }.

curl -X PUT https://app.sandywp.com/api/app/sites/site_abc123/email \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"enabled":true}'
DELETE /api/app/sites/:id/email

Clears the captured log. Returns { "ok": true }.

Plugins

POST /api/app/sites/:id/deploy-plugin

Installs & activates a plugin on a sandbox (async deploy_plugin job). Accepts one of three request shapes:

  • multipart/form-data with a file field a plugin ZIP
  • application/json { "pluginSlug": string } install from wordpress.org
  • application/json { "artifactId": string } reuse a previously uploaded ZIP
curl -X POST https://app.sandywp.com/api/app/sites/site_abc123/deploy-plugin \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"pluginSlug":"query-monitor"}'
curl -X POST https://app.sandywp.com/api/app/sites/site_abc123/deploy-plugin \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -F "[email protected]"
{ "jobId": "job_888", "pluginSlug": "query-monitor" }
GET /api/app/sites/:id/deploy-plugin?jobId=:jobId

Polls the deploy job's status.

curl "https://app.sandywp.com/api/app/sites/site_abc123/deploy-plugin?jobId=job_888" \
  -H "Authorization: Bearer $SANDYWP_TOKEN"
{ "job": { "id": "job_888", "type": "deploy_plugin", "status": "succeeded", "lastError": null } }

Files

GET / POST /api/app/sites/:id/fs/:op

See the File Manager guide. One route handles every operation the :op segment selects it, and path/from/to query params (relative to the WordPress root) select the target(s):

Method:opQuery paramsDescription
GETlistpathList one directory's entries
GETtreepathRecursive directory tree
GETreadpathRead a file's contents
GETdownloadpathStream a file/zip download
POSTwritepath + body { contentBase64 }Create/overwrite a file
POSTmkdirpathCreate a directory
POSTrenamefrom, toMove/rename
POSTcopyfrom, toCopy
POSTdeletepathDelete a file/directory
POSTuploadpath + multipart bodyUpload a file
curl "https://app.sandywp.com/api/app/sites/site_abc123/fs/list?path=wp-content%2Fplugins" \
  -H "Authorization: Bearer $SANDYWP_TOKEN"
curl -X POST "https://app.sandywp.com/api/app/sites/site_abc123/fs/write?path=wp-content%2Fnotes.txt" \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"contentBase64":"aGVsbG8="}'
Responses are proxied verbatim from the sandbox's own file-manager API (including streamed downloads), so the shape of a successful body varies by :op. A large (>256KB) or binary read is refused rather than dumped inline use download instead.

Git deployment

See the Git deployment guide for the dashboard workflow.

GET /api/app/sites/:id/repositories

Lists the Git repositories connected to a sandbox (one per site), each with its recent deployments.

{
  "repositories": [
    { "repository": { "id": "repo_1", "…": "…" }, "deployments": [ { "id": "dep_1", "status": "succeeded", "branch": "main", "commitSha": "a1b2c3d", "…": "…" } ] }
  ]
}
POST /api/app/sites/:id/repositories

Connects a Git repository for deploys. Private repos need an SSH URL; add the returned deployKeyPublic as a deploy key.

FieldTypeDescription
repoUrlstringRequired. HTTPS (public) or SSH (private) URL
destinationstringRequired. Where inside wp-content it deploys (plugin, theme, or wp-content root, per GitDeployDestination)
folderNamestringRequired. Target folder name
branchstringBranch to deploy (default main)
isPrivatebooleanWhether the repo is private (generates an SSH deploy key)
autoDeploybooleanDeploy automatically on push (via the returned webhookUrl)
curl -X POST https://app.sandywp.com/api/app/sites/site_abc123/repositories \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "repoUrl": "https://github.com/you/my-plugin.git",
    "destination": "plugin",
    "folderName": "my-plugin",
    "branch": "main",
    "autoDeploy": true
  }'
{
  "repository": {
    "id": "repo_1", "siteId": "site_abc123", "repoUrl": "https://github.com/you/my-plugin.git",
    "branch": "main", "destination": "plugin", "folderName": "my-plugin", "authKind": "https",
    "deployKeyPublic": null, "autoDeploy": true, "lastDeployedSha": null,
    "createdAt": "…", "updatedAt": "…"
  },
  "webhookUrl": "https://app.sandywp.com/api/webhooks/git/repo_1"
}
PATCH /api/app/sites/:id/repositories/:repoId

Updates branch, folderName, and/or autoDeploy.

curl -X PATCH https://app.sandywp.com/api/app/sites/site_abc123/repositories/repo_1 \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"autoDeploy":false}'
DELETE /api/app/sites/:id/repositories/:repoId

Disconnects the repository. Returns the removed repository.

GET /api/app/sites/:id/repositories/:repoId/branches

Lists the branch and tag names on the repository's remote (via git ls-remote). Read-only; falls back gracefully if the remote is unreachable.

{ "branches": ["main", "staging"], "tags": ["v1.0.0"] }
POST /api/app/sites/:id/repositories/:repoId/deploy

Triggers a deployment. Optional body deploys a specific ref for this run only, without changing the repository's configured branch.

FieldTypeDescription
branchstringRef to deploy for this run only
refTypestringbranch (default) or tag only affects how the run is labeled
curl -X POST https://app.sandywp.com/api/app/sites/site_abc123/repositories/repo_1/deploy \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"branch":"v1.2.0","refType":"tag"}'
{ "deployment": { "id": "dep_2", "repositoryId": "repo_1", "status": "pending", "trigger": "manual", "branch": "v1.2.0", "refType": "tag", "…": "…" } }

SSH

See the SSH access guide. SSH is off by default per sandbox.

GET /api/app/sites/:id/ssh

Reads the sandbox's SSH connection details (host, port, ready-to-copy command, active key count).

{
  "connection": {
    "available": true, "enabled": false, "host": "eu1.ssh.sandywp.dev", "port": 2222,
    "username": "site_abc123", "command": "ssh [email protected] -p 2222",
    "hostKeyFingerprint": "SHA256:…", "activeKeyCount": 1
  }
}
POST /api/app/sites/:id/ssh

One endpoint, two actions selected by action:

FieldTypeDescription
actionstringRequired. set-access or issue-ephemeral
enabledbooleanset-access only. Turn SSH on/off (default true)
ttlMinutesnumberissue-ephemeral only. Requested key lifetime clamped server-side to the sandbox's remaining life (24h ceiling)
curl -X POST https://app.sandywp.com/api/app/sites/site_abc123/ssh \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"action":"set-access","enabled":true}'
curl -X POST https://app.sandywp.com/api/app/sites/site_abc123/ssh \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"action":"issue-ephemeral","ttlMinutes":60}'
{
  "ephemeral": {
    "privateKey": "-----BEGIN OPENSSH PRIVATE KEY-----\n…\n-----END OPENSSH PRIVATE KEY-----",
    "publicKeyFingerprint": "SHA256:…",
    "connection": { "…": "… (same shape as above)" },
    "expiresAt": "2026-07-29T11:00:00.000Z"
  }
}
The private key is returned once and never stored. Requires SSH enabled on the sandbox first (set-access).

Blueprints

A Blueprint is a reviewable JSON build plan (steps, PHP/WordPress version, multisite) that you save once and reuse. Authoring, importing, and validating a Blueprint is free on every plan; actually running one bake, create-from-Blueprint, and apply-to-site requires a paid plan (blueprint_requires_paid_plan). See the Blueprints guide for the concepts and starter examples.

GET /api/app/blueprints

Lists your saved Blueprints.

curl https://app.sandywp.com/api/app/blueprints \
  -H "Authorization: Bearer $SANDYWP_TOKEN"
{
  "blueprints": [
    {
      "id": "bp_abc123",
      "name": "Plugin recipe",
      "slug": "plugin-recipe",
      "revision": 1,
      "schemaVersion": 2,
      "compatReport": [],
      "…": "… (same shape as GET /api/app/blueprints/:id)"
    }
  ]
}
POST /api/app/blueprints

Saves a new Blueprint. It's validated and compatibility-checked immediately, but an incompatible Blueprint still saves you fix it before running.

FieldTypeDescription
sourcestring | objectRequired. The Blueprint JSON document either a JSON string or an already-parsed object (sourceJson is accepted as a legacy alias)
namestringDisplay name (default "Untitled Blueprint")
descriptionstringOptional description
curl -X POST https://app.sandywp.com/api/app/blueprints \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Plugin recipe",
    "description": "Installs and configures the plugins we ship to clients.",
    "source": { "version": 2, "additionalStepsAfterExecution": [] }
  }'
{
  "blueprint": {
    "id": "bp_abc123",
    "userId": "user_123",
    "name": "Plugin recipe",
    "slug": "plugin-recipe",
    "description": "Installs and configures the plugins we ship to clients.",
    "sourceJson": { "version": 2, "additionalStepsAfterExecution": [] },
    "schemaVersion": 2,
    "revision": 1,
    "compatReport": [],
    "hoistedPhpVersion": null,
    "hoistedWordpressVersion": null,
    "hoistedMultisite": false,
    "createdAt": "2026-07-01T10:00:00.000Z",
    "updatedAt": "2026-07-01T10:00:00.000Z"
  }
}
GET /api/app/blueprints/:id

Returns one Blueprint, including its compatReport findings and hoisted PHP/WordPress/multisite settings.

{
  "blueprint": {
    "id": "bp_abc123",
    "userId": "user_123",
    "name": "Plugin recipe",
    "slug": "plugin-recipe",
    "description": "Installs and configures the plugins we ship to clients.",
    "sourceJson": { "version": 2, "additionalStepsAfterExecution": [] },
    "schemaVersion": 2,
    "revision": 1,
    "compatReport": [],
    "hoistedPhpVersion": null,
    "hoistedWordpressVersion": null,
    "hoistedMultisite": false,
    "createdAt": "2026-07-01T10:00:00.000Z",
    "updatedAt": "2026-07-01T10:00:00.000Z"
  }
}
PATCH /api/app/blueprints/:id

Updates a Blueprint. All fields are optional only the ones you send change; replacing source bumps revision by 1.

curl -X PATCH https://app.sandywp.com/api/app/blueprints/bp_abc123 \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"description":"Updated description"}'
DELETE /api/app/blueprints/:id

Deletes a Blueprint. Returns 409 blueprint_run_in_progress while a run is still pending/running for it. Templates already baked from it are kept.

curl -X DELETE https://app.sandywp.com/api/app/blueprints/bp_abc123 \
  -H "Authorization: Bearer $SANDYWP_TOKEN"
POST /api/app/blueprints/import

Fetches a Blueprint JSON document from a URL (a GitHub blob link is rewritten to its raw file automatically) and returns it without saving it. Pass the returned source straight into POST /api/app/blueprints or the validate endpoint below. If the URL serves a ZIP bundle instead of JSON, this endpoint returns 415 blueprint_url_is_bundle — send the same URL as bundleUrl to the upload endpoint below.

POST /api/app/blueprints/upload

Creates a Blueprint from a multipart ZIP bundle. Send the archive as bundle, or send a bundleUrl field and SandyWP downloads the archive itself, with optional name and description fields. The ZIP must contain exactly one blueprint.json at its root or inside one top-level directory. The original bundle is kept in private object storage so local assets resolve when a worker executes that revision. Its stored compressed bytes count against the account's plan storage. Limits: 50 MiB compressed, 250 MiB expanded, and 2,000 entries.

FieldTypeDescription
urlstringRequired. The Blueprint URL to fetch
curl -X POST https://app.sandywp.com/api/app/blueprints/import \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://raw.githubusercontent.com/example/project/main/blueprint.json"}'
{
  "source": "{\n  \"version\": 2,\n  …\n}",
  "sourceUrl": "https://raw.githubusercontent.com/example/project/main/blueprint.json"
}
POST /api/app/blueprints/validate

Validates a Blueprint document and reports its SandyWP compatibility without saving it. The only endpoint on this page that needs no authentication at all — a bare token is not required. Pass adapt: true to also rewrite unsupported-but-fixable parts (legacy Playground GitHub proxy URLs, browser-only steps, …) and get back the adapted source plus a list of changes; sourceUrl resolves relative bundled resources (e.g. ./theme.zip) during adapt.

FieldTypeDescription
sourcestring | objectRequired. The Blueprint JSON document to check
sourceUrlstringOriginal URL of the document (only used together with adapt)
adaptbooleanReturn an adapted, SandyWP-compatible source instead of just a report
curl -X POST https://app.sandywp.com/api/app/blueprints/validate \
  -H "Content-Type: application/json" \
  -d '{"source": {"version": 2, "additionalStepsAfterExecution": []}}'
{
  "valid": true,
  "schemaVersion": 2,
  "stepCount": 0,
  "hoisted": {
    "phpVersion": null,
    "requestedPhpVersion": null,
    "wordpressVersion": null,
    "requestedWordpressVersion": null,
    "multisite": false
  },
  "findings": []
}

With adapt: true:

curl -X POST https://app.sandywp.com/api/app/blueprints/validate \
  -H "Content-Type: application/json" \
  -d '{
    "source": { "steps": [{ "step": "login", "username": "admin" }] },
    "sourceUrl": "https://raw.githubusercontent.com/example/project/main/blueprint.json",
    "adapt": true
  }'
{
  "valid": true,
  "schemaVersion": 1,
  "stepCount": 0,
  "hoisted": { "…": "…" },
  "findings": [],
  "source": { "steps": [] },
  "changes": [
    { "path": "$.steps[0]", "code": "step_removed", "message": "The \"login\" step is handled by SandyWP automatically and was removed." }
  ]
}
POST /api/app/blueprints/:id/bake

Runs the Blueprint on a fresh scratch sandbox and snapshots the result into a reusable Template. Requires a paid plan. Async — the response returns the Template (status: "building") and the run (status: "pending") immediately — poll GET .../runs/:runId until the run is succeeded or failed, then re-fetch the Template.

FieldTypeDescription
namestringTemplate name (default: the Blueprint's name, or the existing Template's name when rebaking)
descriptionstringTemplate description
templateIdstringRebake this existing Blueprint-backed Template in place instead of minting a new one — keeps its slug and public launch URL stable. Omit it to create a new Template
curl -X POST https://app.sandywp.com/api/app/blueprints/bp_abc123/bake \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Plugin recipe (baked)"}'
{
  "template": {
    "id": "tpl_xyz789",
    "name": "Plugin recipe (baked)",
    "slug": "plugin-recipe-baked",
    "status": "building",
    "sourceBlueprintId": "bp_abc123",
    "sourceBlueprintRevision": 1,
    "launchEnabled": false,
    "launchToken": null,
    "…": "…"
  },
  "run": {
    "id": "bpr_111",
    "blueprintId": "bp_abc123",
    "mode": "bake",
    "status": "pending",
    "steps": [],
    "createdAt": "2026-07-01T10:00:00.000Z"
  }
}

Rebaking an existing Template in place (same launch URL, new content):

curl -X POST https://app.sandywp.com/api/app/blueprints/bp_abc123/bake \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"templateId":"tpl_xyz789"}'
GET /api/app/blueprints/:id/runs

Lists the runs (create/apply/bake attempts) for a Blueprint, most recent first.

curl https://app.sandywp.com/api/app/blueprints/bp_abc123/runs \
  -H "Authorization: Bearer $SANDYWP_TOKEN"
{
  "runs": [
    { "id": "bpr_111", "mode": "bake", "status": "succeeded", "startedAt": "2026-07-01T10:00:01.000Z", "finishedAt": "2026-07-01T10:00:04.000Z" }
  ]
}
GET /api/app/blueprints/:id/runs/:runId

Returns one run: its status, per-step results, and log. This is what you poll while a create/apply/bake is in progress.

curl https://app.sandywp.com/api/app/blueprints/bp_abc123/runs/bpr_111 \
  -H "Authorization: Bearer $SANDYWP_TOKEN"
{
  "run": {
    "id": "bpr_111",
    "blueprintId": "bp_abc123",
    "blueprintRevision": 1,
    "siteId": "site_scratch1",
    "jobId": "job_555",
    "mode": "bake",
    "status": "succeeded",
    "steps": [
      { "name": "runSQL", "status": "succeeded", "durationMs": 120 }
    ],
    "log": "…",
    "runnerVersion": "1",
    "failureReason": null,
    "startedAt": "2026-07-01T10:00:01.000Z",
    "finishedAt": "2026-07-01T10:00:04.000Z",
    "createdAt": "2026-07-01T10:00:00.000Z"
  }
}
POST /api/app/blueprints/:id/sites

Creates a new sandbox and runs the Blueprint into it. Requires a paid plan. Async — unlike plain POST /api/app/sites, this endpoint does not wait for the sandbox: it always returns immediately with the site "creating" and a run to poll (Blueprint steps can run longer than a plain provision).

FieldTypeDescription
siteNamestringDisplay name (default: the Blueprint's name)
durationstringFixed: 1h/1d/1w/2w/1m/permanent. Inactivity: 10m/30m/1h/1d
expirationModestringfixed or inactivity; Permanent is fixed-only
workerCodestringPin the sandbox to a specific worker/region
curl -X POST https://app.sandywp.com/api/app/blueprints/bp_abc123/sites \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"siteName":"client-demo"}'
{
  "site": { "id": "site_abc", "slug": "client-demo", "status": "creating", "…": "…" },
  "run": { "id": "bpr_222", "blueprintId": "bp_abc123", "mode": "create", "status": "pending", "…": "…" }
}
POST /api/app/sites/:id/blueprint

Applies a Blueprint to an existing ready sandbox (mode "apply"). Requires a paid plan. Fails with 409 if the sandbox already has another mutation (deploy, PHP change, another Blueprint run, …) in progress. Async — poll the run the same way as bake.

FieldTypeDescription
blueprintIdstringRequired. The Blueprint to run
curl -X POST https://app.sandywp.com/api/app/sites/site_abc/blueprint \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"blueprintId":"bp_abc123"}'
{ "run": { "id": "bpr_333", "blueprintId": "bp_abc123", "mode": "apply", "status": "pending", "…": "…" } }

Templates

A Template is a snapshot of a finished sandbox WordPress/PHP versions, plugins, themes, content, and database ready to restore in seconds. Templates come from either saving a ready sandbox or baking a Blueprint. See the Templates guide for the dashboard workflow.

GET /api/app/templates

Lists your Templates.

curl https://app.sandywp.com/api/app/templates \
  -H "Authorization: Bearer $SANDYWP_TOKEN"
{
  "templates": [
    {
      "id": "tpl_xyz789",
      "slug": "plugin-recipe-baked",
      "name": "Plugin recipe (baked)",
      "status": "ready",
      "wordpressVersion": "6.7",
      "phpVersion": "8.3",
      "launchExpirationMode": "fixed",
      "launchExpirationMinutes": null
    }
  ]
}
GET /api/app/templates/:id

Returns one Template, including its launch-link settings.

curl https://app.sandywp.com/api/app/templates/tpl_xyz789 \
  -H "Authorization: Bearer $SANDYWP_TOKEN"
{
  "template": {
    "id": "tpl_xyz789",
    "userId": "user_123",
    "name": "Plugin recipe (baked)",
    "slug": "plugin-recipe-baked",
    "description": null,
    "sourceSiteId": "site_scratch1",
    "artifactId": "art_1",
    "status": "ready",
    "failureReason": null,
    "wordpressVersion": "6.7",
    "phpVersion": "8.3",
    "sizeBytes": 41943040,
    "launchEnabled": false,
    "launchToken": null,
    "launchCount": 0,
    "launchQuotaMode": "visitor",
    "launchEntryMode": "anonymous",
    "launchExpirationMode": "fixed",
    "launchExpirationMinutes": null,
    "launchLandingPath": "/wp-admin/",
    "launchBranding": null,
    "launchProtected": true,
    "launchConsentEnabled": false,
    "launchConsentLabel": null,
    "launchConsentDefaultChecked": false,
    "sourceBlueprintId": "bp_abc123",
    "sourceBlueprintRevision": 1,
    "createdAt": "2026-07-01T10:00:00.000Z",
    "updatedAt": "2026-07-01T10:00:04.000Z"
  }
}
POST /api/app/templates

Snapshots a ready sandbox you own into a new Template (async template_snapshot job; poll GET /api/app/templates/:id until status is ready). Free on every plan. To bake a Template from a Blueprint instead of an existing sandbox, use POST /api/app/blueprints/:id/bake.

FieldTypeDescription
siteIdstringRequired. Must be your own ready sandbox
namestringRequired. Template name (min 3 alphanumeric characters once slugified)
descriptionstringOptional description
curl -X POST https://app.sandywp.com/api/app/templates \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"siteId":"site_abc123","name":"Client starter"}'
{ "template": { "id": "tpl_new1", "slug": "client-starter", "status": "building", "…": "…" } }
DELETE /api/app/templates/:id

Deletes a Template and its snapshot artifact.

curl -X DELETE https://app.sandywp.com/api/app/templates/tpl_xyz789 \
  -H "Authorization: Bearer $SANDYWP_TOKEN"

A Template id that doesn't exist — or one owned by someone else — returns 404 template_not_found in the standard error envelope. The two cases are deliberately indistinguishable so a Template id cannot be probed for existence.

POST /api/app/sites with templateId

Creates a sandbox by restoring a Template (fast snapshot restore no Blueprint steps to re-run). This is the same endpoint documented under Sandboxes above templateId is just one of its optional fields. Free on every plan (the normal sandbox-count and storage limits still apply). Synchronous by default, exactly like a plain create. duration and expirationMode use the same values as a blank-site creation.

curl -X POST https://app.sandywp.com/api/app/sites \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"templateId":"tpl_xyz789","siteName":"client-demo-2"}'
POST /api/app/templates/:id/launch

Manages the Template's public "launch this demo" link. enable and rotate bring a live token online and require a paid plan (402 template_sharing_requires_paid_plan); disable and metadata-only update stay free so a downgraded owner can always turn an existing link off. The public URL is <origin>/launch/<launchToken>. Template responses expose launchExpirationMode and launchExpirationMinutes; a null minute value means fixed demos default to 24 hours, while inactivity demos default to 1 hour. Both are capped by the configured hard demo lifetime.

FieldTypeDescription
actionstringRequired. One of enable, disable, rotate, update
quotaModestringupdate only. visitor (default; counts against the visitor's own quota) or owner
entryModestringupdate only. anonymous (default; auto-login) or email (capture an email first, recorded as a lead)
expirationModestringupdate only. fixed expires each demo after a fixed lifetime; inactivity renews the deadline when the visitor actively uses WordPress. A tab left open in the background does not renew it — background polling such as the WordPress heartbeat is ignored
expirationMinutesintegerupdate only. Fixed lifetimes use 60, 420, 1440, 4320, or 10080 minutes; inactivity uses 10, 30, 60, or 1440. Values above the server's configured maximum demo lifetime are rejected
landingPathstringupdate only. Sandbox-local path opened after auto-login, e.g. /, /shop/, /wp-admin/ (default)
brandingobject | nullupdate only. Custom copy/colors for the public landing page (eyebrow, headline, subhead, buttonLabel, logoUrl, brandName, accentColor, backgroundColor, backgroundImageUrl, hidePoweredBy); null clears it
protectedbooleanupdate only. Protected demo, true by default. Writes DISALLOW_FILE_MODS and DISALLOW_FILE_EDIT into each launched demo's wp-config.php, so visitors get wp-admin without the plugin/theme installer, the ZIP uploader, or the file editor. Applies to demos launched after the change
consentEnabledbooleanupdate only. Show a visitor consent checkbox before launching the demo
consentLabelstring | nullupdate only. Free-form text shown beside the consent checkbox, up to 500 characters
consentDefaultCheckedbooleanupdate only. Initial checkbox state on the public launch page; defaults to false
curl -X POST https://app.sandywp.com/api/app/templates/tpl_xyz789/launch \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"action":"enable","entryMode":"anonymous"}'
{
  "template": {
    "id": "tpl_xyz789",
    "launchEnabled": true,
    "launchToken": "dLN_rizhL4o-xHrh1jAAMfwu_DJATEiu",
    "launchQuotaMode": "visitor",
    "launchEntryMode": "anonymous",
    "launchExpirationMode": "fixed",
    "launchExpirationMinutes": null,
    "launchLandingPath": "/wp-admin/",
    "launchProtected": true,
    "launchConsentEnabled": false,
    "launchConsentLabel": null,
    "launchConsentDefaultChecked": false,
    "…": "…"
  }
}

Updating settings on an already-enabled link (free, no re-gate):

curl -X POST https://app.sandywp.com/api/app/templates/tpl_xyz789/launch \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "update",
    "landingPath": "/shop/",
    "branding": { "headline": "Try our starter kit" }
  }'

To free demo capacity after the visitor stops using WordPress, switch to inactivity expiration. Visible browser use and qualifying authenticated or state-changing WordPress requests renew the idle deadline; background polling does not, so a demo tab left open in a background tab still expires. The server's hard demo lifetime remains an upper bound:

curl -X POST https://app.sandywp.com/api/app/templates/tpl_xyz789/launch \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "update",
    "expirationMode": "inactivity",
    "expirationMinutes": 10
  }'
GET /api/app/templates/:id/leads

Lists the email leads captured by a Template's public launcher when entryMode is email.

curl https://app.sandywp.com/api/app/templates/tpl_xyz789/leads \
  -H "Authorization: Bearer $SANDYWP_TOKEN"
{
  "leads": [
    { "id": "lead_1", "templateId": "tpl_xyz789", "email": "[email protected]", "siteId": "site_demo1", "createdAt": "2026-07-02T09:00:00.000Z" }
  ]
}

Imports (clone & push)

An import clones a live WordPress site (or a local install, via sandywp push) into a sandbox. See Clone a site and Push a local site for the two front doors to this API. Uploading the archive is a separate step from creating the import row two upload protocols exist depending on the client.

GET /api/app/imports

Lists your imports.

{
  "imports": [
    { "id": "imp_1", "sourceKind": "cli_push", "sourceUrl": "https://old-site.example.com", "siteId": "site_abc123", "status": "ready", "sizeBytes": 52428800, "createdAt": "…", "updatedAt": "…" }
  ]
}
POST /api/app/imports

Creates an import row (status awaiting_upload) upload the archive next.

FieldTypeDescription
sourceUrlstringRequired. The source site's URL (drives the restore's search-replace)
siteIdstringRepush into this existing sandbox in place instead of creating a new one
declaredSizeBytesnumberBest-effort size estimate for a pre-upload storage-quota check
curl -X POST https://app.sandywp.com/api/app/imports \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"sourceUrl":"https://old-site.example.com"}'
{ "importId": "imp_2", "import": { "id": "imp_2", "status": "awaiting_upload", "sourceUrl": "https://old-site.example.com", "siteId": null, "…": "…" } }
GET /api/app/imports/:id

Full status of one import: the record, the sandbox once one exists (with its live URL and a magic-login link once ready), and restore progress while it's running.

{
  "import": { "id": "imp_2", "status": "restoring", "siteId": "site_def456", "…": "…" },
  "site": { "id": "site_def456", "status": "creating", "…": "…" },
  "siteUrl": null,
  "magicLoginUrl": null,
  "progress": { "message": "Restoring database…", "step": 2, "totalSteps": 5 }
}
DELETE /api/app/imports/:id

Cancels an in-progress import (keeps the row); add ?action=delete to permanently remove the row instead.

curl -X DELETE https://app.sandywp.com/api/app/imports/imp_2 \
  -H "Authorization: Bearer $SANDYWP_TOKEN"
curl -X DELETE "https://app.sandywp.com/api/app/imports/imp_2?action=delete" \
  -H "Authorization: Bearer $SANDYWP_TOKEN"

Simple upload (used by sandywp push)

PUT /api/app/imports/:id/upload

Appends one contiguous chunk at a byte offset. Send the chunk as the raw request body with an x-sandywp-offset header; offsets must be contiguous (no gaps, no overlap).

curl -X PUT https://app.sandywp.com/api/app/imports/imp_2/upload \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/octet-stream" \
  -H "x-sandywp-offset: 0" \
  --data-binary @archive.tar.gz
POST /api/app/imports/:id/complete

Seals the uploaded archive and starts the restore.

curl -X POST https://app.sandywp.com/api/app/imports/imp_2/complete \
  -H "Authorization: Bearer $SANDYWP_TOKEN"
{ "importId": "imp_2", "status": "restoring", "siteId": "site_def456" }

Direct-to-storage multipart upload (used by the Cloner plugin)

For large sites the Cloner plugin uploads parts directly to object storage instead of routing bytes through Main. This is a lower-level protocol you likely only need if you're building a custom uploader; sandywp push's simple chunked PUT .../upload above is the easier integration path.

POST /api/app/imports/:id/multipart/init

Begins a multipart upload. Optional body { "object": string } selects a named streamed object (e.g. database.sql.gz) for the newer multi-object protocol; omit it for the original single-archive protocol. Returns mode: "local" when object storage isn't configured (fall back to the simple upload above).

curl -X POST https://app.sandywp.com/api/app/imports/imp_2/multipart/init \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
{ "mode": "s3", "partSizeBytes": 8388608 }
POST /api/app/imports/:id/multipart/part

Returns a presigned URL for one part; the client PUTs the part bytes directly to that URL and reads back the ETag.

curl -X POST https://app.sandywp.com/api/app/imports/imp_2/multipart/part \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"partNumber":1}'
{ "url": "https://r2.example.com/…&X-Amz-Signature=…" }
POST /api/app/imports/:id/multipart/complete

Seals one multipart object. For the single-archive protocol this also starts the restore; for the multi-object protocol, the restore starts at .../multipart/finalize instead.

POST /api/app/imports/:id/multipart/abort

Aborts an in-progress multipart upload and frees its object-storage parts.

POST /api/app/imports/:id/multipart/finalize

Multi-object protocol only. Seals the import once manifest.json and every declared object are uploaded: validates the manifest, enforces the size cap, and starts the restore.

Blueprint → Template → demo: the CI workflow

The flow most CI pipelines actually want: bake a Blueprint into a Template, wait for it to finish, and publish a fresh public demo link every run reusing the same URL across runs by rebaking the same Template in place. This script needs only curl and jq:

#!/usr/bin/env bash
set -euo pipefail

BASE_URL="https://app.sandywp.com"
TOKEN="$SANDYWP_TOKEN"
BLUEPRINT_ID="bp_abc123"
# Leave empty on the first run. After it succeeds, capture the printed template id and set it
# as a CI variable/secret so every later run rebakes IN PLACE keeping the same /launch/<token> URL.
TEMPLATE_ID="${TEMPLATE_ID:-}"

echo "Baking Blueprint $BLUEPRINT_ID..."
bake=$(curl -sf -X POST "$BASE_URL/api/app/blueprints/$BLUEPRINT_ID/bake" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"templateId\":\"$TEMPLATE_ID\"}")
run_id=$(jq -r '.run.id' <<< "$bake")
template_id=$(jq -r '.template.id' <<< "$bake")
echo "Run $run_id queued for Template $template_id"

echo "Polling until the run finishes..."
status="pending"
while [[ "$status" != "succeeded" && "$status" != "failed" ]]; do
  sleep 3
  run=$(curl -sf "$BASE_URL/api/app/blueprints/$BLUEPRINT_ID/runs/$run_id" \
    -H "Authorization: Bearer $TOKEN")
  status=$(jq -r '.run.status' <<< "$run")
  echo "  status: $status"
done

if [[ "$status" == "failed" ]]; then
  echo "Bake failed: $(jq -r '.run.failureReason' <<< "$run")" >&2
  exit 1
fi

echo "Enabling the public launch link..."
launch=$(curl -sf -X POST "$BASE_URL/api/app/templates/$template_id/launch" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"action":"enable"}')
token=$(jq -r '.template.launchToken' <<< "$launch")

echo "Demo ready: $BASE_URL/launch/$token"
echo "TEMPLATE_ID for next run: $template_id"
Save the TEMPLATE_ID for next run line's value as a CI variable/secret. Leaving TEMPLATE_ID empty always mints a brand-new Template (and a new URL); setting it rebakes in place so https://app.sandywp.com/launch/<token> never changes.

Prefer not to hand-roll polling? sandywp blueprint bake <name> --wait in the CLI does exactly this bake, then poll, then print the result.

Prefer a ready-made client? The SandyWP CLI wraps every endpoint on this page, including Blueprints (sandywp blueprint …) and Templates (sandywp template …).

Workspaces, members, and roles

Workspace members share visibility of every sandbox. A member's assigned Role controls actions through five capabilities: create, internals, reusables, integrations, and manage.

  • GET /api/app/workspaces lists the caller's workspaces.
  • GET/POST /api/app/workspaces/:id/members lists members or invites one. PATCH/DELETE /api/app/workspaces/:id/members/:userId assigns a role or removes the member. Mutations require manage.
  • GET/POST /api/app/workspaces/:id/roles lists or creates roles. PATCH/DELETE /api/app/workspaces/:id/roles/:roleId updates or deletes one. Role mutations are owner-only; deleting an assigned role returns 409 role_in_use.
  • API and MCP calls authenticate as a workspace member and resolve that member's current Role on every request. Capability changes therefore apply without issuing a new token.
  • Send X-SandyWP-Workspace (or the legacy X-SandyWP-Organization) to select one of the caller's existing workspace memberships.
403 capability_required — the resource is visible, but the caller's current Role does not allow the requested action. The response message is Your role does not allow this.