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, 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/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 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"
} /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" } /api/app/sites/:id/execRuns 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.
| Field | Type | Description |
|---|---|---|
command | string | Required, non-empty shell command. |
timeoutSeconds | number | Optional. Sync defaults to 60 seconds, up to 90. Background defaults to 600 seconds, up to 3600. |
background | boolean | Optional, 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" /api/app/sites/:id/exec/:execIdReturns 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.
/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.5", "8.4", "8.3", "8.2"],
"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":"8.5"}' { "phpVersion": "8.5", "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/explainSends 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.
/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 }.
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.
/api/app/sites/:id/domainReturns 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.
/api/app/sites/:id/domainConnects 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.
/api/app/sites/:id/domain/verifyRe-checks DNS for a pending_dns domain (or retries a failed one). lastError explains what is still missing.
/api/app/sites/:id/domainDisconnects the domain and points WordPress back at the sandbox URL. Allowed on any plan.
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 }.
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.
/api/account/email-relaySaves 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.
/api/account/email-relayReturns { "relay": { provider, host, port, encryption, usernameHint, passwordHint, fromEmail, fromName, enabledSiteIds, updatedAt } }, or relay: null. The username and password are never returned.
/api/account/email-relay/testSends 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.
/api/account/email-relayRemoves the relay and turns delivery off on every sandbox that used it.
/api/app/sites/:id/email/delivery/api/app/sites/:id/email/deliveryReads 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
/api/app/sites/:id/deploy-pluginInstalls & activates a plugin on a sandbox (async deploy_plugin job). Accepts one of four request shapes:
multipart/form-datawith afilefield a plugin ZIPapplication/json { "pluginSlug": string }install from wordpress.orgapplication/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" } /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 } } 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"}' /api/app/plugin-libraryLists the workspace's library as { "plugins": [...] }.
/api/app/plugin-libraryAdds 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" } } /api/app/plugin-library/:idRemoves an entry (by id or slug) and its stored ZIP. Returns { "deleted": true }.
/api/app/plugin-library/:id/license-keyStores (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
/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 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 | :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 one file as a download |
| GET | zip | path | Compress a folder and stream the .zip (up to 256 MB uncompressed or 20,000 files) |
| 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 + the raw file bytes as the body | Upload 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="}' :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.
/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/generateAsks 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.
/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.
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.
| 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/:idRenames 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" }' /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, 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 |
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" }
]
} 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.
/api/app/pr-snapshotsSnapshots 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.
| Field | Type | Description |
|---|---|---|
siteId | string | Required. A ready sandbox in the workspace |
repo | string | Required. GitHub owner/repo the pull request targets. Stored lowercased |
branch | string | Required. The pull request head branch, exactly as on GitHub |
landingPath | string | Optional. 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"
}
} /api/app/pr-snapshotsLists 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", "…": "…" } ] } /api/app/pr-snapshots/:idDeletes 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.
/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. - OAuth connections (MCP connectors, the Cloner plugin) act only in the workspace picked on the consent screen. A header naming another workspace returns
403.
Your role does not allow this.