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.
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.
"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:
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):
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_decision—approved(default) orrejected. 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/waitlong-poll, or the dashboard. A caller polling or long-polling sees the decision appear at the deadline. - If
callback_urlis set, the normal signed webhook is delivered (payload also includes"sandbox": true). - The decision's
methodissandbox; the status response and the verify response both exposesandbox: 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"):
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.
sa_test_ keys without any other change.