Sandbox Mode (sa_test_ keys)

Dry-run the whole approval flow — request, poll, webhook, signed receipt, verify — without a phone, a passkey, or a human. Receipts are real signatures that are permanently marked as sandbox.

Key Concepts

A sandbox key is an API key whose raw value starts with sa_test_ instead of sa_live_. It authenticates exactly like a live key (same scopes, same rate limits), but every request created with it is a dry-run: nothing is pushed to a device, nobody is emailed, and the request decides itself after a short delay. The receipt you get back is signed by the same Ed25519 key as a real approval, so the verification code you write against the sandbox is the code that runs in production.

Important
A sandbox receipt is never an authorization. The signed payload of a sandbox decision carries "sandbox": true and "method": "sandbox". Because those fields are inside the signed bytes they cannot be removed without breaking the signature. Verifiers must check for them — the public verify page shows a SANDBOX banner, and Clevername refuses sandbox receipts outside its staging environment.

Create a sandbox key

Dashboard → API Keys → tick Sandbox key → Create. Or via the API:

bash
curl -X POST https://signedapproval.net/api/v1/account/api-keys \
  -H "Authorization: Bearer <your session JWT>" \
  -H "Content-Type: application/json" \
  -d '{"name":"ci-dry-run","sandbox":true}'

# → { "raw_key": "sa_test_…", "key": { "sandbox": true, ... } }

Create a request with it

Same endpoint and body as a real request. Two optional fields are accepted only with a sandbox key (a live key gets 400 sandbox_fields_require_sandbox_key):

bash
curl -X POST https://signedapproval.net/api/v1/approvals/request \
  -H "Authorization: Bearer sa_test_…" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "Deploy v2.1.0 to production",
    "ttl_seconds": 600,
    "callback_url": "https://your-app.com/webhook/approval",
    "sandbox_decision": "approved",
    "sandbox_auto_decide_seconds": 5
  }'

# → { "request_id": "…", "status": "pending", "sandbox": true, "verify_url": "…" }
  • sandbox_decisionapproved (default) or rejected. Lets you exercise both branches of your handler.
  • sandbox_auto_decide_seconds — 0–300, default 5. The request decides itself this many seconds after creation.

What happens next

  • No push notification, no email, no delegate or co-approver fan-out.
  • At the deadline the request is decided on the next status read GET /api/v1/approvals/{id}, the /wait long-poll, or the dashboard. A caller polling or long-polling sees the decision appear at the deadline.
  • If callback_url is set, the normal signed webhook is delivered (payload also includes "sandbox": true).
  • The decision's method is sandbox; the status response and the verify response both expose sandbox: true.
  • Sandbox requests show a SANDBOX tag in the dashboard's pending and history lists.

Verifying — and refusing — sandbox receipts

Parse canonical_payload after checking the Ed25519 signature. In production, reject when payload.sandbox === true (or payload.method === "sandbox"):

TypeScript
const payload = JSON.parse(receipt.canonical_payload);
if (payload.sandbox === true && process.env.NODE_ENV === "production") {
  throw new Error("sandbox receipt is not an authorization");
}

If you verify through GET /api/v1/approvals/{id}/verify or the public verify page, the response includes "sandbox": true and the page shows a SANDBOX banner.

Note
Limits and billing. Sandbox requests are rate-limited like live ones and count toward your monthly approval quota. The SDKs accept sa_test_ keys without any other change.