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, sites:login, …) chosen when the user approves the connection. sites:read never returns magic-login URLs or admin passwords, and sandywp_site_status leaves them out for every token unless the call sets includeCredentials: true; only sandywp_magic_login (sites:login) mints wp-admin access. Library license keys are likewise only revealed by sandywp_get_plugin_license under its own licenses:read scope. sandywp_exec mirrors POST /api/app/sites/:id/exec, and sandywp_get_exec mirrors GET /api/app/sites/:id/exec/:execId. 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 reusable login link that opens the sandbox already signed in to wp-admin. Anyone with the link can enter wp-admin until expiresAt, so treat it like a password.

{
  "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" }
POST /api/app/sites/:id/exec

Runs a shell command or WP-CLI in a ready sandbox. Use your full-access swp_ API token. Both exec routes require exec:write, which full-access tokens and browser sessions hold, plus the workspace Role's internals capability.

FieldTypeDescription
commandstringRequired, non-empty shell command.
timeoutSecondsnumberOptional. Sync defaults to 60 seconds, up to 90. Background defaults to 600 seconds, up to 3600.
backgroundbooleanOptional, defaults to false. Set true to start a run without waiting.

Unknown fields, wrong types, and malformed JSON return 400. A synchronous call returns { stdout, stderr, code, timedOut } with HTTP 200. With background: true, the response is HTTP 202 with the run's ID.

{ "execId": "aaaaaaaaaaaaaaaaaaaaaaaa", "status": "running" }

Start WP-CLI in the background, then poll its run.

exec_id=$(curl -sf -X POST https://app.sandywp.com/api/app/sites/site_abc123/exec \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"command":"wp plugin list --format=json","background":true}' | jq -r '.execId')

curl -sf "https://app.sandywp.com/api/app/sites/site_abc123/exec/$exec_id" \
  -H "Authorization: Bearer $SANDYWP_TOKEN"
GET /api/app/sites/:id/exec/:execId

Returns the run's output so far. Poll until status is finished. While status is running, code is null. A timed-out run returns code: 124 and timedOut: true. Background runs are kept for one day.

{
  "execId": "aaaaaaaaaaaaaaaaaaaaaaaa",
  "status": "finished",
  "stdout": "[]\n",
  "stderr": "",
  "code": 0,
  "timedOut": false
}

Commands run as the web user in the WordPress webroot, with WP-CLI on PATH. Output is capped at 256 KB per stream. The exec bridge does not load WordPress, so it works when a plugin or theme fatals the site.

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.5", "8.4", "8.3", "8.2"],
  "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":"8.5"}'
{ "phpVersion": "8.5", "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}'
POST /api/app/sites/:id/debug/explain

Sends the newest part of the debug log to Claude and returns { "explanation": { summary, cause, fixSteps, culprit: { kind, name } } }. kind is plugin, theme, wordpress_core, php or unknown. Returns 409 debug_log_empty when there is nothing to explain and 429 ai_daily_limit once the workspace has used its AI requests for the day.

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 }.

Custom domain

Workspace plans can serve a sandbox on a domain they own. SSL is issued automatically once DNS points at the sandbox's server. Every call returns { "domain": { hostname, status, verifiedAt, url, target, records, lastError } }; status is pending_dns, activating, active, failed, or null when no domain is connected.

GET /api/app/sites/:id/domain

Returns the domain state and the DNS records to add: a CNAME to target for a subdomain, or the A records for a root domain. Keep the record DNS-only (not proxied) and remove any other A or AAAA records.

PUT /api/app/sites/:id/domain

Connects a domain. Body: { "hostname": "demo.example.com" }. If DNS already resolves to the sandbox's server it goes live straight away (async apply_custom_domain job). Returns 402 outside Workspace plans.

POST /api/app/sites/:id/domain/verify

Re-checks DNS for a pending_dns domain (or retries a failed one). lastError explains what is still missing.

DELETE /api/app/sites/:id/domain

Disconnects the domain and points WordPress back at the sandbox URL. Allowed on any plan.

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 }.

Email delivery

Sandboxes capture mail and never send it. To send it for real, save your own SMTP provider once for the workspace, then turn delivery on per sandbox. Mail leaves from your provider on your account, and is still recorded in the Email log.

PUT /api/account/email-relay

Saves the workspace relay. Body: { "provider": "elastic_email" | "sendgrid" | "custom", "username", "password", "fromEmail", "fromName" }. Elastic Email and SendGrid fill in the host, port and encryption; custom also needs host, port and encryption (starttls or ssl). SendGrid's username is always apikey. fromEmail must be a sender your provider has verified. Omit username or password to keep the stored one. Both are stored encrypted.

GET /api/account/email-relay

Returns { "relay": { provider, host, port, encryption, usernameHint, passwordHint, fromEmail, fromName, enabledSiteIds, updatedAt } }, or relay: null. The username and password are never returned.

POST /api/account/email-relay/test

Sends a test email through the saved relay to your own account address. Returns { "sent": true, "to" }, or 422 email_relay_test_failed with the provider's reason.

DELETE /api/account/email-relay

Removes the relay and turns delivery off on every sandbox that used it.

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

Reads or sets real delivery for a ready sandbox. Body: { "enabled": boolean }. Returns { "enabled", "relay": { provider, fromEmail } | null }. Turning it on returns 409 email_relay_not_configured until a relay is saved.

Plugins

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

Installs & activates a plugin on a sandbox (async deploy_plugin job). Accepts one of four 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 (kept for 24h)
  • application/json { "libraryPlugin": string } install a plugin from the workspace Library by its slug
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 } }

Library

Workspace-owned plugin ZIPs, typically premium or private plugins, that install on any ready sandbox by name with { "libraryPlugin": "slug" } above. An entry comes from an upload or from a GitHub repository the workspace's GitHub App can read; GitHub entries pull the repository's latest release .zip asset on every install.

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 '{"libraryPlugin":"gravityforms"}'
GET /api/app/plugin-library

Lists the workspace's library as { "plugins": [...] }.

POST /api/app/plugin-library

Adds an entry. Send multipart/form-data with a file (plugin ZIP, up to 50 MB) and optional name; uploading again under an existing name replaces the ZIP. Or send application/json { "githubRepository": "owner/name", "name"?: string }; the latest release must carry the plugin ZIP as an asset. Both shapes accept an optional licenseKey (see below).

curl -X POST https://app.sandywp.com/api/app/plugin-library \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -F "[email protected]" -F "name=gravityforms"
curl -X POST https://app.sandywp.com/api/app/plugin-library \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"githubRepository":"acme/acme-pro"}'
{ "plugin": { "id": "plib_1a2b", "slug": "acme-pro", "name": "acme-pro", "source": "github_release", "githubRepository": "acme/acme-pro", "filename": "acme-pro.zip", "sizeBytes": 482113, "version": "v2.4.0", "hasLicenseKey": false, "licenseKeyHint": null, "licenseKeyLastReadAt": null, "updatedAt": "2026-09-24T12:00:00.000Z" } }
DELETE /api/app/plugin-library/:id

Removes an entry (by id or slug) and its stored ZIP. Returns { "deleted": true }.

PUT / DELETE /api/app/plugin-library/:id/license-key

Stores (PUT { "licenseKey": string }, trimmed, 1–500 characters, one line) or removes (DELETE) the license key for an entry, by id or slug. Needs the reusables workspace capability. The key is write-only: it is encrypted at rest and no API response contains it, only hasLicenseKey, the masked licenseKeyHint, and licenseKeyLastReadAt. SandyWP never uses the key itself; an AI agent reads it with the MCP tool sandywp_get_plugin_license (scope licenses:read, every read audited) to activate the plugin on a sandbox.

curl -X PUT https://app.sandywp.com/api/app/plugin-library/gravityforms/license-key \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"licenseKey":"'"$GF_LICENSE_KEY"'"}'
{ "plugin": { "id": "plib_9f8e", "slug": "gravityforms", "hasLicenseKey": true, "licenseKeyHint": "••••ABCD", "licenseKeyLastReadAt": 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 select the target(s). Each is a path relative to the WordPress root, base64url-encoded (RFC 4648 §5, no padding), so file names stay opaque to web application firewalls on the way to the sandbox:

Method:opQuery paramsDescription
GETlistpathList one directory's entries
GETtreepathRecursive directory tree
GETreadpathRead a file's contents
GETdownloadpathStream one file as a download
GETzippathCompress a folder and stream the .zip (up to 256 MB uncompressed or 20,000 files)
POSTwritepath + body { contentBase64 }Create/overwrite a file
POSTmkdirpathCreate a directory
POSTrenamefrom, toMove/rename
POSTcopyfrom, toCopy
POSTdeletepathDelete a file/directory
POSTuploadpath + the raw file bytes as the bodyUpload a file
# path = base64url("wp-content/plugins")
curl "https://app.sandywp.com/api/app/sites/site_abc123/fs/list?path=d3AtY29udGVudC9wbHVnaW5z" \
  -H "Authorization: Bearer $SANDYWP_TOKEN"
# path = base64url("wp-content/notes.txt")
curl -X POST "https://app.sandywp.com/api/app/sites/site_abc123/fs/write?path=d3AtY29udGVudC9ub3Rlcy50eHQ" \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"contentBase64":"aGVsbG8="}'
Responses are proxied verbatim from the sandbox's file bridge (including streamed downloads), so the shape of a successful body varies by :op. The bridge does not load WordPress, so these operations keep working when a plugin or theme crashes the site. A read of a file over 5 MB is refused with 413; 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/generate

Asks Claude to write a Blueprint from { "prompt": "A WooCommerce store with three products" } (up to 2,000 characters). The result is checked with the same validator as /blueprints/validate and returned as { "blueprint": { name, description, source } } without saving; pass source to POST /api/app/blueprints to keep it. Returns 422 blueprint_generation_invalid when no valid Blueprint could be written and 429 ai_daily_limit once the workspace has used its AI requests for the day.

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.

When the Blueprint has a v1 landingPage that is a path inside the sandbox, such as /wp-admin/plugins.php, the bake sets it as the Template's Demo destination (launchLandingPath). A rebake applies it again and replaces the current value. Without a usable landingPage, the Template keeps its Demo destination.

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", "…": "…" } }
PATCH /api/app/templates/:id

Renames a Template. Send name (2 to 80 characters). Only the display name changes: the slug, the snapshot, and any public launch link stay the same. Returns { "template": … }. A missing Template, or one owned by someone else, returns 404 template_not_found. A name outside the limits returns 400 invalid_template_name.

curl -X PATCH https://app.sandywp.com/api/app/templates/tpl_xyz789 \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Client starter v2" }'
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, socialImageUrl, hidePoweredBy); null clears it. socialImageUrl is the image shown when the launch link is shared on social media or chat apps (1200×630 recommended); the share card also takes its title and description from headline and subhead
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" }
  ]
}

PR snapshots

A PR snapshot saves a tested sandbox for one GitHub pull request, so reviewers can open their own copy from a link in the pull request. It is a hidden Template tied to a repository and branch: it never shows in GET /api/app/templates, and the Template endpoints answer 404 for its id.

Each visit to its launchUrl starts a fresh sandbox funded by your plan, with plugin and theme installs disabled. The sandbox ends 60 minutes after the reviewer's last activity and never lives longer than 24 hours. SandyWP deletes the PR snapshot when GitHub reports the pull request closed (the SandyWP for GitHub app must be installed on the repository), or 60 days after it was saved. Agents usually do this over MCP with sandywp_create_pr_snapshot, which needs only the sites:write scope.

POST /api/app/pr-snapshots

Snapshots a ready sandbox in the workspace for repo + branch. Async: the snapshot starts as building, so poll GET /api/app/pr-snapshots?repo=…&branch=… until status is ready. Delete the source sandbox only after that: deleting it mid-snapshot fails the snapshot. Requires the Templates permission and a paid plan (402 template_sharing_requires_paid_plan).

One PR snapshot exists per workspace, repository and branch. Calling again replaces it and keeps the same launchUrl, so the link already in the pull request keeps working. While the new snapshot builds, the launch page says the demo is being prepared.

FieldTypeDescription
siteIdstringRequired. A ready sandbox in the workspace
repostringRequired. GitHub owner/repo the pull request targets. Stored lowercased
branchstringRequired. The pull request head branch, exactly as on GitHub
landingPathstringOptional. Where reviewers land after auto-login. Default /wp-admin/
curl -X POST https://app.sandywp.com/api/app/pr-snapshots \
  -H "Authorization: Bearer $SANDYWP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"siteId":"site_abc123","repo":"acme/widget","branch":"fix/login-redirect","landingPath":"/wp-admin/plugins.php"}'
{
  "snapshot": {
    "id": "tpl_pr1",
    "name": "acme/widget @ fix/login-redirect",
    "repo": "acme/widget",
    "branch": "fix/login-redirect",
    "status": "building",
    "failureReason": null,
    "launchUrl": "https://sandywp.com/launch/3tWm9Qe0bA1zYk2cR7xV",
    "launchCount": 0,
    "sourceSiteId": "site_abc123",
    "expiresAt": "2026-12-11T09:00:00.000Z",
    "createdAt": "2026-10-12T09:00:00.000Z"
  }
}
GET /api/app/pr-snapshots

Lists the workspace's PR snapshots, newest first. Optional repo and branch query filters.

curl "https://app.sandywp.com/api/app/pr-snapshots?repo=acme/widget&branch=fix/login-redirect" \
  -H "Authorization: Bearer $SANDYWP_TOKEN"
{ "snapshots": [ { "id": "tpl_pr1", "status": "ready", "launchUrl": "https://sandywp.com/launch/3tWm9Qe0bA1zYk2cR7xV", "…": "…" } ] }
DELETE /api/app/pr-snapshots/:id

Deletes a PR snapshot and its stored snapshot, and returns it as snapshot. The launch link stops working; sandboxes reviewers already started keep running until they expire. A missing id, a Template id, or another workspace's PR snapshot returns 404 pr_snapshot_not_found.

curl -X DELETE https://app.sandywp.com/api/app/pr-snapshots/tpl_pr1 \
  -H "Authorization: Bearer $SANDYWP_TOKEN"

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.
  • OAuth connections (MCP connectors, the Cloner plugin) act only in the workspace picked on the consent screen. A header naming another workspace returns 403.
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.