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 a401, even if the underlying scope (for exampleblueprints:write) would otherwise allow the action.
/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/jsonon writes. - Sandboxes, Blueprints, and Templates are addressed by their
idin 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.
"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
/api/account/meReturns the authenticated user.
curl https://app.sandywp.com/api/account/me \
-H "Authorization: Bearer $SANDYWP_TOKEN" { "id": "user_123", "email": "[email protected]" } /api/account/usageReturns your plan and current sandbox usage.
{ "email": "[email protected]", "plan": "free", "limit": 2, "activeSites": 1 } Webhooks
/api/account/webhooks/demo-launchesReads the selected workspace's Demo Launches webhook configuration and its latest delivery status.
/api/account/webhooks/demo-launchesCreates 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_…"
} /api/account/webhooks/demo-launches/testSends a signed test payload to the enabled callback.
/api/account/webhooks/demo-launches/rotateReplaces the HMAC-SHA256 signing secret and returns the new value once.
/api/account/webhooks/demo-launchesRemoves the callback, secret, and stored delivery history.
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
/api/account/tokensCreates 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
}
} /api/account/tokensLists your active tokens (metadata only never the token value).
{
"tokens": [
{ "id": "apitok_123", "label": "ci-pipeline", "createdAt": "…", "lastUsedAt": "…" }
]
} /api/account/tokens/:idRevokes a token. Returns { "success": true }.
Sandboxes
/api/app/sitesCreates a sandbox. All fields are optional; sensible defaults are used (latest WordPress, PHP 8.3, standard preset, auto worker).
| Field | Type | Description |
|---|---|---|
siteName | string | Display name; the slug is derived from it |
wordpressVersion | string | e.g. latest |
phpVersion | string | e.g. 8.3 |
provisioningPreset | string | standard or debug |
duration | string | Fixed: 1h, 1d, 1w, 2w, 1m, or permanent. Inactivity: 10m, 30m, 1h, or 1d |
expirationMode | string | fixed (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 |
templateId | string | Create from a saved Template instead (see below); runtime fields are ignored, but siteName, duration, and expirationMode still apply |
background | boolean | Return 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
}
} 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./api/app/sitesLists your sandboxes.
{ "sites": [ { "slug": "my-sandbox", "status": "ready", "publicUrl": "https://…", "adminUsername": "admin", "magicLoginUrl": "https://…" } ] } /api/sites/:id/statusReturns 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" } ]
} /api/sites/:id/magic-loginIssues 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"
} /api/app/sites/:idDeletes a sandbox. Returns the deleted site record.
/api/app/sites/:idSame shape as GET /api/sites/:id/status above, addressed under the /api/app namespace.
/api/app/sites/:idSets 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"}' /api/app/sites/:idRestores 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"}' /api/app/sites/:id/resetResets 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.
/api/app/sites/:id/php-configReturns 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 }
} /api/app/sites/:id/php-configUpdates one or more PHP ini values (async php_config job). All fields optional only the ones you send change.
| Field | Type | Description |
|---|---|---|
maxExecutionTime | number | Seconds (10–300) |
maxInputTime | number | Seconds (10–300) |
maxInputVars | number | 100–10000 |
memoryLimitMb | number | PHP memory_limit |
allowUrlFopen | boolean | allow_url_fopen |
postMaxSizeMb | number | post_max_size |
uploadMaxFilesizeMb | number | upload_max_filesize |
sessionGcMaxlifetime | number | Seconds |
outputBufferingBytes | number | output_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" } /api/app/sites/:id/php-versionSwitches 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
/api/app/sites/:id/debugReturns 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
} /api/app/sites/:id/debugReplaces 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}' /api/app/sites/:id/debug/logTails 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 } /api/app/sites/:id/debug/logClears the debug log. Returns { "ok": true }.
Database
See the Database guide for the dashboard workflow.
/api/app/sites/:id/databaseIssues 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
/api/app/sites/:id/emailReturns 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
} /api/app/sites/:id/emailTurns 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}' /api/app/sites/:id/emailClears the captured log. Returns { "ok": true }.
Plugins
/api/app/sites/:id/deploy-pluginInstalls & activates a plugin on a sandbox (async deploy_plugin job). Accepts one of three request shapes:
multipart/form-datawith afilefield a plugin ZIPapplication/json { "pluginSlug": string }install from wordpress.orgapplication/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" } /api/app/sites/:id/deploy-plugin?jobId=:jobIdPolls 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
/api/app/sites/:id/fs/:opSee 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 | :op | Query params | Description |
|---|---|---|---|
| GET | list | path | List one directory's entries |
| GET | tree | path | Recursive directory tree |
| GET | read | path | Read a file's contents |
| GET | download | path | Stream a file/zip download |
| POST | write | path + body { contentBase64 } | Create/overwrite a file |
| POST | mkdir | path | Create a directory |
| POST | rename | from, to | Move/rename |
| POST | copy | from, to | Copy |
| POST | delete | path | Delete a file/directory |
| POST | upload | path + multipart body | Upload 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="}' :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.
/api/app/sites/:id/repositoriesLists 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", "…": "…" } ] }
]
} /api/app/sites/:id/repositoriesConnects a Git repository for deploys. Private repos need an SSH URL; add the returned deployKeyPublic as a deploy key.
| Field | Type | Description |
|---|---|---|
repoUrl | string | Required. HTTPS (public) or SSH (private) URL |
destination | string | Required. Where inside wp-content it deploys (plugin, theme, or wp-content root, per GitDeployDestination) |
folderName | string | Required. Target folder name |
branch | string | Branch to deploy (default main) |
isPrivate | boolean | Whether the repo is private (generates an SSH deploy key) |
autoDeploy | boolean | Deploy 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"
} /api/app/sites/:id/repositories/:repoIdUpdates 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}' /api/app/sites/:id/repositories/:repoIdDisconnects the repository. Returns the removed repository.
/api/app/sites/:id/repositories/:repoId/branchesLists 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"] } /api/app/sites/:id/repositories/:repoId/deployTriggers a deployment. Optional body deploys a specific ref for this run only, without changing the repository's configured branch.
| Field | Type | Description |
|---|---|---|
branch | string | Ref to deploy for this run only |
refType | string | branch (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.
/api/app/sites/:id/sshReads 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
}
} /api/app/sites/:id/sshOne endpoint, two actions selected by action:
| Field | Type | Description |
|---|---|---|
action | string | Required. set-access or issue-ephemeral |
enabled | boolean | set-access only. Turn SSH on/off (default true) |
ttlMinutes | number | issue-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"
}
} 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.
/api/app/blueprintsLists 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)"
}
]
} /api/app/blueprintsSaves a new Blueprint. It's validated and compatibility-checked immediately, but an incompatible Blueprint still saves you fix it before running.
| Field | Type | Description |
|---|---|---|
source | string | object | Required. The Blueprint JSON document either a JSON string or an already-parsed object (sourceJson is accepted as a legacy alias) |
name | string | Display name (default "Untitled Blueprint") |
description | string | Optional 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"
}
} /api/app/blueprints/:idReturns 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"
}
} /api/app/blueprints/:idUpdates 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"}' /api/app/blueprints/:idDeletes 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" /api/app/blueprints/importFetches 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.
/api/app/blueprints/uploadCreates 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.
| Field | Type | Description |
|---|---|---|
url | string | Required. 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"
} /api/app/blueprints/validateValidates 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.
| Field | Type | Description |
|---|---|---|
source | string | object | Required. The Blueprint JSON document to check |
sourceUrl | string | Original URL of the document (only used together with adapt) |
adapt | boolean | Return 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." }
]
} /api/app/blueprints/:id/bakeRuns 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.
| Field | Type | Description |
|---|---|---|
name | string | Template name (default: the Blueprint's name, or the existing Template's name when rebaking) |
description | string | Template description |
templateId | string | Rebake 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"}' /api/app/blueprints/:id/runsLists 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" }
]
} /api/app/blueprints/:id/runs/:runIdReturns 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"
}
} /api/app/blueprints/:id/sitesCreates 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).
| Field | Type | Description |
|---|---|---|
siteName | string | Display name (default: the Blueprint's name) |
duration | string | Fixed: 1h/1d/1w/2w/1m/permanent. Inactivity: 10m/30m/1h/1d |
expirationMode | string | fixed or inactivity; Permanent is fixed-only |
workerCode | string | Pin 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", "…": "…" }
} /api/app/sites/:id/blueprintApplies 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.
| Field | Type | Description |
|---|---|---|
blueprintId | string | Required. 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.
/api/app/templatesLists 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
}
]
} /api/app/templates/:idReturns 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"
}
} /api/app/templatesSnapshots 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.
| Field | Type | Description |
|---|---|---|
siteId | string | Required. Must be your own ready sandbox |
name | string | Required. Template name (min 3 alphanumeric characters once slugified) |
description | string | Optional 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", "…": "…" } } /api/app/templates/:idDeletes 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.
/api/app/sites with templateIdCreates 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"}' /api/app/templates/:id/launchManages 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.
| Field | Type | Description |
|---|---|---|
action | string | Required. One of enable, disable, rotate, update |
quotaMode | string | update only. visitor (default; counts against the visitor's own quota) or owner |
entryMode | string | update only. anonymous (default; auto-login) or email (capture an email first, recorded as a lead) |
expirationMode | string | update 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 |
expirationMinutes | integer | update 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 |
landingPath | string | update only. Sandbox-local path opened after auto-login, e.g. /, /shop/, /wp-admin/ (default) |
branding | object | null | update only. Custom copy/colors for the public landing page (eyebrow, headline, subhead, buttonLabel, logoUrl, brandName, accentColor, backgroundColor, backgroundImageUrl, hidePoweredBy); null clears it |
protected | boolean | update 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 |
consentEnabled | boolean | update only. Show a visitor consent checkbox before launching the demo |
consentLabel | string | null | update only. Free-form text shown beside the consent checkbox, up to 500 characters |
consentDefaultChecked | boolean | update 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
}' /api/app/templates/:id/leadsLists 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.
/api/app/importsLists your imports.
{
"imports": [
{ "id": "imp_1", "sourceKind": "cli_push", "sourceUrl": "https://old-site.example.com", "siteId": "site_abc123", "status": "ready", "sizeBytes": 52428800, "createdAt": "…", "updatedAt": "…" }
]
} /api/app/importsCreates an import row (status awaiting_upload) upload the archive next.
| Field | Type | Description |
|---|---|---|
sourceUrl | string | Required. The source site's URL (drives the restore's search-replace) |
siteId | string | Repush into this existing sandbox in place instead of creating a new one |
declaredSizeBytes | number | Best-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, "…": "…" } } /api/app/imports/:idFull 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 }
} /api/app/imports/:idCancels 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)
/api/app/imports/:id/uploadAppends 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 /api/app/imports/:id/completeSeals 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.
/api/app/imports/:id/multipart/initBegins 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 } /api/app/imports/:id/multipart/partReturns 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=…" } /api/app/imports/:id/multipart/completeSeals 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.
/api/app/imports/:id/multipart/abortAborts an in-progress multipart upload and frees its object-storage parts.
/api/app/imports/:id/multipart/finalizeMulti-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" 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.
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/workspaceslists the caller's workspaces.GET/POST /api/app/workspaces/:id/memberslists members or invites one.PATCH/DELETE /api/app/workspaces/:id/members/:userIdassigns a role or removes the member. Mutations requiremanage.GET/POST /api/app/workspaces/:id/roleslists or creates roles.PATCH/DELETE /api/app/workspaces/:id/roles/:roleIdupdates or deletes one. Role mutations are owner-only; deleting an assigned role returns409 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 legacyX-SandyWP-Organization) to select one of the caller's existing workspace memberships.
Your role does not allow this.