← Sandbox Gate
API
Tokens

Run Sandbox Gate from your own code

Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page measures failure-path coverage (every documented outcome against its trigger), holds every magic value to the published error catalog, catches a value that means two things, verifies the Luhn digit of every test card, measures event and time coverage, checks isolation, keys, reset, quotas, abuse controls and the path to live, and ranks the isolation and simulation rungs, then hands it all to the run as facts. The run writes the design or the cutover; the page then holds it to its own facts (sbreply.js): the ranked rungs, every outcome covered with a trigger, a real code and an operation, no invented card number, one meaning per value, every event fired, time simulated, _test_ and _live_ prefixes; the settled gate, a named pre-approval error, the four checklist steps, a canary confirming the event delivery, and exactly the five pitfalls.

Base URL and the response shape

Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }

The token is minted for this app (the guest call takes {"slug":"sandbox-gate"}), so no slug header is needed afterwards. Send it as Authorization: Bearer YOUR_TOKEN. The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required key (task, platform, sandbox, facts) is missing. The page always sends all five of task, platform, sandbox, carry, facts; carry is an empty string for design.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: both lanes are metered and need a personal sign-in.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field comes first

Two lanes, chosen by task, in the order the work runs - the sandbox, then going live:

A missing or unknown task is answered as design. Both lanes send the same five fields. The facts are the page's checks, one row per line, and a line starting "must:" is a rule the reply has to hold. The page's handoff carries the design's decisions into carry for the cutover lane.

The input

FieldRequiredWhat
taskyesdesign or cutover. If it is missing or neither, do design and set "task": "design".
platformyesthe platform's name and purpose and the interview: greenfield or retrofit, the domain, the budget, the gate to live, the existing test infrastructure, the timing, time-dependent state, async events, an end-user surface, read-mostly, per-tenant infrastructure, parallel teams, compliance; "not given" means not given.
sandboxyesthe test mode today: isolation, the published error codes, every documented outcome (outcome, code, trigger, operation, the page's findings), the events, the time-dependent flows, seed and reset, keys, quotas, abuse controls and the path to live.
carrysend it (empty for design)cutover task only: the design's decisions, when the design task ran first; may be empty.
factsyesthe page's checks, one line each as area | id | status | evidence, the coverage, the rungs and the carrier. A line starting "must:" applies to your reply.
retry_notenooptional; if present, your previous reply was unusable - follow it.

The rules the model applies

From the source skill, @samber/api-test-mode-design - the only standard the run applies:

House rules

The replies

design

Write the test-mode design.

  1. summary - one sentence, at most 40 words: what an integrator cannot validate in the sandbox today, and the first fix.
  2. isolation - rung (the facts' rung: toggle, separate or convention), level (one sentence: the isolation level as the docs should state it), why (one sentence).
  3. simulation - rung (the facts' rung: magic, endpoints or fixture), why (one sentence).
  4. catalog - the magic-value catalog, one object per documented outcome, every one covered: outcome (as listed), operation (the operation it applies to), trigger (the test value, or the endpoint path for endpoint), kind (magic or endpoint), code (a code from the published catalog, or ok). Keep a listed trigger that works; replace every trigger the page flags (no trigger, a broken check digit, two meanings) with a new one. When the page flags a code as not in the published catalog, keep the trigger and map the outcome to the closest published code. Never invent a card number - a new trigger is a token (tok_ plus lowercase words joined by _), an address, a persona or an endpoint path. One meaning per trigger and operation.
  5. events - one object per listed event type: event (as listed), trigger (one sentence: the endpoint or CLI command that fires it now).
  6. time - primitive (clock, aging or none - none only without time-dependent state), cap (one sentence: the advance per call and what one clock carries, or "" without a clock), flows (the listed time-dependent flows, each as listed).
  7. reset - seed (synthetic), mint (one sentence), reset (one sentence), periodic (one sentence).
  8. keys - test_prefix (with _test_), live_prefix (with _live_, the same type prefix), wrong_env (one sentence naming the error a wrong-environment key gets), hygiene (one sentence).
  9. quotas - posture (production-limits or aggregate-cap), why (one sentence).
  10. abuse - leaked_keys (short strings), card_testing (short strings: live-mode controls; [] outside payments).
  11. parity - one sentence: how sandbox configuration stays derived from production's.
  12. caveats - at most 4 short strings; [] when there is nothing to say.
{
  "task": "design",
  "summary": "one sentence",
  "isolation": { "rung": "toggle", "level": "one sentence", "why": "one sentence" },
  "simulation": { "rung": "magic", "why": "one sentence" },
  "catalog": [ { "outcome": "generic decline", "operation": "charge", "trigger": "4000000000000002", "kind": "magic", "code": "card_declined" } ],
  "events": [ { "event": "payment.succeeded", "trigger": "one sentence" } ],
  "time": { "primitive": "clock", "cap": "one sentence", "flows": [ "subscription renewal" ] },
  "reset": { "seed": "synthetic", "mint": "one sentence", "reset": "one sentence", "periodic": "one sentence" },
  "keys": { "test_prefix": "pl_test_", "live_prefix": "pl_live_", "wrong_env": "one sentence", "hygiene": "one sentence" },
  "quotas": { "posture": "production-limits", "why": "one sentence" },
  "abuse": { "leaked_keys": [ "a control" ], "card_testing": [ "a control" ] },
  "parity": "one sentence",
  "caveats": [ ]
}

The page holds it to its own facts (check() in sbreply.js; any warning makes the reply flagged): the reply answers the task it was sent; no URL; no backticks; no percentage; the ranked isolation and simulation rungs and a stated isolation level; every documented outcome in the catalog with a trigger, a kind, an operation and a code from the published catalog (or ok), no code silently changed; no invented card number and no card number that fails the Luhn check; one meaning per trigger and operation; at least one failure outcome; every listed event with a trigger that fires it now; a clock or aging when state is time-dependent, a cap on the clock, every listed flow, no waiting real time; a synthetic seed with minting, reset and periodic cleanup; a type prefix with _test_ and with _live_; a named wrong-environment error; a quota posture; leaked-key controls, and live card-testing controls for payments; parity.

cutover

Write the path from sandbox to live.

  1. summary - one sentence, at most 40 words.
  2. gate - shape (the facts' gate: kyc, review, partner, publisher, plan or none), error (one sentence: the named error a not-yet-approved environment returns - when the input names none, name the one you recommend in the published codes' naming style, as a recommendation), why (one sentence).
  3. checklist - the cutover steps in order: objects with step, owner (a role), check (how you know it is done). Credentials swapped everywhere, fresh live event endpoints and signing secrets, abuse rules on before the first live transaction, and a live canary that confirms the effect and the event delivery.
  4. canary - paths (the integration paths a canary covers, as short strings), reverse (one sentence: how each canary is reversed - or, where the action cannot be undone, how its effect is contained).
  5. pitfalls - exactly five objects, one per pitfall: kind (key-swap, cross-environment, sandbox-predicts-live, stale-webhooks or untested-failures, each once), text (one sentence: that pitfall in this platform's terms).
  6. go_back - two to four strings: the signals that send an integration back to the sandbox.
  7. metrics - two to four strings: what to track after launch.
  8. docs - the integrator-facing cutover guide as one plain-text string: the gate, the checklist, the canary and the five pitfalls.
  9. caveats - at most 4 short strings; [] when there is nothing to say.
{
  "task": "cutover",
  "summary": "one sentence",
  "gate": { "shape": "kyc", "error": "one sentence", "why": "one sentence" },
  "checklist": [ { "step": "one sentence", "owner": "integration engineer", "check": "one sentence" } ],
  "canary": { "paths": [ "one-off charge" ], "reverse": "one sentence" },
  "pitfalls": [ { "kind": "key-swap", "text": "one sentence" } ],
  "go_back": [ "one string" ],
  "metrics": [ "cross-environment incidents" ],
  "docs": "the guide as plain text",
  "caveats": [ ]
}

The page requires: the reply answers the task it was sent; no URL; no backticks; no percentage; the settled gate; a named pre-approval error, never an opaque 401; a checklist that swaps credentials, creates fresh live event endpoints and signing secrets, turns abuse rules on and runs a live canary that confirms the event delivery, each step with an owner and a check; canary paths and a reversal; exactly five pitfalls, one of each kind (key-swap, cross-environment, sandbox-predicts-live, stale-webhooks, untested-failures), each with its text; two to four go-back signals and metrics; and a guide covering the canary.

Worked examples

Three runs from the page's free samples (invented platforms; card numbers are public test numbers): the design for a payments API on a mode toggle, the cutover for the same API carrying that design, and the design for an SMS API whose sandbox is a mock server. Each input is exactly what the page sends; each reply is the saved reply the sample replays, shipped unedited - but the facts and long strings are shortened in this display only, and every cut is listed under the example. The last line is the page's own check of the full reply.

design - A payments API on a mode toggle

The page read it as: integrators will find bugs in live (10 fail, 4 warn, 8 pass); 7 of 8 outcomes triggerable; isolation by rank: toggle; simulation by rank: magic; failing checks: isodocs, coverage, realcodes, luhn, eventcov, timecov, wrongenv, hygiene, legible, checklist.

Input (sandbox is 2159 characters; the first 1399 are shown; facts is 2895 characters; the first 1391 are shown):

{
 "task": "design",
 "platform": "Platform: Payquorlo\nWhat it does: Card payments and subscriptions for online merchants; integrators build checkout and recurring billing against the API.\nMode: retrofit\nDomain: Payments-like (money moves)\nBudget: Weeks\nGate to live: KYC activation\nExisting test infrastructure: A staging environment\nTiming: A platform surface run for years\nTime-dependent state: yes; async events: yes; end-user surface: yes; read-mostly with no money, messages or sensitive data: no\nPer-tenant infrastructure: no; teams testing in parallel: no; compliance demands demonstrable isolation: no",
 "sandbox": "Isolation today: A mode toggle on the live account; isolation level stated in the docs: no; end-user surface mirrored: yes; config from production's infrastructure-as-code: yes\nPublished error codes:\n- card_declined (402) - the issuer declined the card\n- insufficient_funds (402) - not enough funds\n- expired_card (402) - the card has expired\n- incorrect_cvc (402) - the security code is wrong\n- authentication_required (402) - the issuer requires step-up authentication\n- rate_limited (429) - too many requests\nDocumented outcomes (outcome | code | trigger | operation | page's findings):\n- payment succeeds | ok | magic: 4242424242424242 | charge\n- generic decline | card_declined | magic: 4000000000000002 | charge\n- insufficient funds | insufficient_funds | magic: 4000000000009995 | charge\n- expired card | expired_card | magic: 4000000000000069 | charge\n- wrong CVC | incorrect_cvc | magic: 4000000000000128 | charge | fails the Luhn check - client-side validation rejects it before it reaches the API\n- step-up authentication | authentication_required | magic: 4000002500003155 | charge\n- fraud block | test_decline_fraud | magic: 4100000000000019 | charge | code not in the published catalog - a sandbox-only error shape\n- dispute opened | ok | no trigger | charge | no way to trigger it\nEvents (fireable on demand today):\n- payment.succeeded | yes\n- payment.failed | yes\n- dispute.created |\n\n[... 760 more characters cut from this display ...]",
 "carry": "",
 "facts": "The page's checks (10 fail, 4 warn, 8 pass, 0 n/a):\n- Isolation | model | pass | A mode toggle on the live account\n- Isolation | isodocs | fail | \"sandbox\" with no stated isolation level guarantees nothing\n- Isolation | mirrored | pass | mirrored\n- Isolation | parity | pass | same source\n- Failure paths | coverage | fail | 7 of 8 triggerable (87.5%); missing: dispute opened\n- Failure paths | failpaths | pass | 6 failure outcomes\n- Failure paths | realcodes | fail | fraud block (test_decline_fraud)\n- Failure paths | labels | pass | unambiguous\n- Failure paths | luhn | fail | 4000000000000128 (check digit should be 7)\n- Events and time | eventcov | fail | 2 of 4 fireable; missing: dispute.created, subscription.renewed\n- Events and time | clisig | warn | unsigned test events break the always-verify rule unless documented beside it\n- Events and time | timecov | fail | only by waiting: subscription renewal, trial expiry\n- Events and time | seed | pass | Synthetic data only\n- Events and time | reset | warn | Reset on request\n- Keys, quotas and abuse | prefix | pass | pl_test_ and pl_live_\n- Keys, quotas and abuse | wrongenv | fail | An opaque 401\n- Keys, quotas and abuse | hygiene | fail | test keys on a toggle still read and write real account configuration\n- Keys, quotas and abuse | quota | pass | The production limits, unchanged\n- Keys, quotas and abuse | carding | warn |\n\n[... 1504 more characters cut from this display ...]"
}

Reply:

{
 "task": "design",
 "summary": "Integrators cannot open a dispute, fire dispute.created or subscription.renewed, advance renewals or trial expiry, or reach the wrong-CVC path; first add a dispute token, CLI triggers for every event and a test clock.",
 "isolation": {
  "rung": "toggle",
  "level": "Test mode is a soft toggle on the merchant's one account: every object carries a test or live mode flag, pl_test_ keys see only test objects, pl_live_ keys see only live objects, and test mode cannot change live account configuration.",
  "why": "A budget of weeks rules the separate copy out of this pass, so the toggle stays until the first cross-mode leakage incident or a compliance regime that demands demonstrable isolation."
 },
 "simulation": {
  "rung": "magic",
  "why": "Every Payquorlo outcome, including the dispute, can be selected by the payment instrument in the charge request, and with weeks of budget documented test cards and tokens are the cheapest primitive that covers all eight."
 },
 "catalog": [
  {
   "outcome": "payment succeeds",
   "operation": "charge",
   "trigger": "4242424242424242",
   "kind": "magic",
   "code": "ok"
  },
  {
   "outcome": "generic decline",
   "operation": "charge",
   "trigger": "4000000000000002",
   "kind": "magic",
   "code": "card_declined"
  },
  {
   "outcome": "insufficient funds",
   "operation": "charge",
   "trigger": "4000000000009995",
   "kind": "magic",
   "code": "insufficient_funds"
  },
  {
   "outcome": "expired card",
   "operation": "charge",
   "trigger": "4000000000000069",
   "kind": "magic",
   "code": "expired_card"
  },
  {
   "outcome": "wrong CVC",
   "operation": "charge",
   "trigger": "tok_incorrect_cvc",
   "kind": "magic",
   "code": "incorrect_cvc"
  },
  {
   "outcome": "step-up authentication",
   "operation": "charge",
   "trigger": "4000002500003155",
   "kind": "magic",
   "code": "authentication_required"
  },
  {
   "outcome": "fraud block",
   "operation": "charge",
   "trigger": "4100000000000019",
   "kind": "magic",
   "code": "card_declined"
  },
  {
   "outcome": "dispute opened",
   "operation": "charge",
   "trigger": "tok_charge_disputed",
   "kind": "magic",
   "code": "ok"
  }
 ],
 "events": [
  {
   "event": "payment.succeeded",
   "trigger": "The existing CLI trigger for payment.succeeded fires it now, as does a charge with 4242424242424242."
  },
  {
   "event": "payment.failed",
   "trigger": "The existing CLI trigger for payment.failed fires it now, as does a charge with any decline value in the catalog."
  },
  {
   "event": "dispute.created",
   "trigger": "A new CLI trigger for dispute.created fires it now, and a charge with tok_charge_disputed succeeds and then opens a dispute that emits it."
  },
  {
   "event": "subscription.renewed",
   "trigger": "A new CLI trigger for subscription.renewed fires it now, and advancing the subscription's test clock past its renewal date emits it through the real billing path."
  }
 ],
 "time": {
  "primitive": "clock",
  "cap": "A test clock starts frozen and each advance call moves it forward by at most one billing interval, and one clock carries only the test customers and subscriptions attached to it, with a fixed ceiling on how many.",
  "flows": [
   "subscription renewal",
   "trial expiry"
  ]
 },
 "reset": {
  "seed": "synthetic",
  "mint": "Test-mode create calls and the catalog's tokens mint synthetic customers, cards, subscriptions and disputes on demand, never copies of live objects.",
  "reset": "A self-serve reset call deletes every test-mode object on the account and leaves live objects and live configuration untouched, replacing today's reset on request.",
  "periodic": "Test mode is not shared between merchants, so a scheduled purge of stale test objects is optional housekeeping, offered per account rather than imposed."
 },
 "keys": {
  "test_prefix": "pl_test_",
  "live_prefix": "pl_live_",
  "wrong_env": "A pl_test_ key used on a live object, or a pl_live_ key on a test object, gets a named key_environment_mismatch error stating which mode the key belongs to, instead of today's opaque 401.",
  "hygiene": "Test keys are secrets too: they stay server-side, are covered by secret scanning, and on the toggle they are scoped so they can no longer read or write the real account configuration."
 },
 "quotas": {
  "posture": "production-limits",
  "why": "Test mode already reuses the production limits unchanged, so integrators meet rate_limited in the sandbox exactly where they will meet it live."
 },
 "abuse": {
  "leaked_keys": [
   "secret keys used server-side only, never in client code",
   "secret scanning on the pl_test_ and pl_live_ prefixes",
   "scoped keys so a test key cannot touch account configuration",
   "one-step revoke and roll for a leaked key"
  ],
  "card_testing": [
   "live-mode edge bot challenges on checkout",
   "keep the existing live endpoint rate limits",
   "live cap on cards attached per customer",
   "live cap on consecutive declines per IP"
  ]
 },
 "parity": "Test mode already comes from the same infrastructure-as-code source as live; keep the mode flag, limits and new test-clock settings in that source so nothing is configured by hand on the test side.",
 "caveats": [
  "The toggle is a budget decision: revisit the separate copy after the first cross-mode leakage incident or when compliance demands it.",
  "CLI events are unsigned; document that beside the always-verify rule so integrators do not skip signature checks in live.",
  "Fraud block now returns card_declined, the closest published code; the sandbox-only test_decline_fraud shape is retired.",
  "4000000000000128 fails the Luhn check and is withdrawn; tok_incorrect_cvc replaces it in docs and example code."
 ]
}

What step 5 prints for it: toggle | magic | charge payment succeeds -> ok | charge generic decline -> card_declined | charge insufficient funds -> insufficient_funds | charge expired card -> expired_card | charge wrong CVC -> incorrect_cvc | charge step-up authentication -> authentication_required | charge fraud block -> card_declined | charge dispute opened -> ok

The page's check: kept - nothing to flag

cutover - A payments API on a mode toggle

The input carries the saved design's decisions (R.laneForm fills carry from the design reply), as the page's handoff does.

Input (sandbox is 2159 characters; the first 1399 are shown; facts is 2803 characters; the first 1391 are shown):

{
 "task": "cutover",
 "platform": "Platform: Payquorlo\nWhat it does: Card payments and subscriptions for online merchants; integrators build checkout and recurring billing against the API.\nMode: retrofit\nDomain: Payments-like (money moves)\nBudget: Weeks\nGate to live: KYC activation\nExisting test infrastructure: A staging environment\nTiming: A platform surface run for years\nTime-dependent state: yes; async events: yes; end-user surface: yes; read-mostly with no money, messages or sensitive data: no\nPer-tenant infrastructure: no; teams testing in parallel: no; compliance demands demonstrable isolation: no",
 "sandbox": "Isolation today: A mode toggle on the live account; isolation level stated in the docs: no; end-user surface mirrored: yes; config from production's infrastructure-as-code: yes\nPublished error codes:\n- card_declined (402) - the issuer declined the card\n- insufficient_funds (402) - not enough funds\n- expired_card (402) - the card has expired\n- incorrect_cvc (402) - the security code is wrong\n- authentication_required (402) - the issuer requires step-up authentication\n- rate_limited (429) - too many requests\nDocumented outcomes (outcome | code | trigger | operation | page's findings):\n- payment succeeds | ok | magic: 4242424242424242 | charge\n- generic decline | card_declined | magic: 4000000000000002 | charge\n- insufficient funds | insufficient_funds | magic: 4000000000009995 | charge\n- expired card | expired_card | magic: 4000000000000069 | charge\n- wrong CVC | incorrect_cvc | magic: 4000000000000128 | charge | fails the Luhn check - client-side validation rejects it before it reaches the API\n- step-up authentication | authentication_required | magic: 4000002500003155 | charge\n- fraud block | test_decline_fraud | magic: 4100000000000019 | charge | code not in the published catalog - a sandbox-only error shape\n- dispute opened | ok | no trigger | charge | no way to trigger it\nEvents (fireable on demand today):\n- payment.succeeded | yes\n- payment.failed | yes\n- dispute.created |\n\n[... 760 more characters cut from this display ...]",
 "carry": "Isolation: toggle - Test mode is a soft toggle on the merchant's one account: every object carries a test or live mode flag, pl_test_ keys see only test objects, pl_live_ keys see only live objects, and test mode cannot change live account configuration.\nSimulation: magic\nKeys: test pl_test_, live pl_live_; A pl_test_ key used on a live object, or a pl_live_ key on a test object, gets a named key_environment_mismatch error stating which mode the key belongs to, instead of today's opaque 401.\nEvents fired by: payment.succeeded, payment.failed, dispute.created, subscription.renewed\nTime: clock\nCard testing (live): live-mode edge bot challenges on checkout; keep the existing live endpoint rate limits; live cap on cards attached per customer; live cap on consecutive declines per IP\nCatalog: 8 outcomes, 6 of them failures",
 "facts": "The page's checks (10 fail, 4 warn, 8 pass, 0 n/a):\n- Isolation | model | pass | A mode toggle on the live account\n- Isolation | isodocs | fail | \"sandbox\" with no stated isolation level guarantees nothing\n- Isolation | mirrored | pass | mirrored\n- Isolation | parity | pass | same source\n- Failure paths | coverage | fail | 7 of 8 triggerable (87.5%); missing: dispute opened\n- Failure paths | failpaths | pass | 6 failure outcomes\n- Failure paths | realcodes | fail | fraud block (test_decline_fraud)\n- Failure paths | labels | pass | unambiguous\n- Failure paths | luhn | fail | 4000000000000128 (check digit should be 7)\n- Events and time | eventcov | fail | 2 of 4 fireable; missing: dispute.created, subscription.renewed\n- Events and time | clisig | warn | unsigned test events break the always-verify rule unless documented beside it\n- Events and time | timecov | fail | only by waiting: subscription renewal, trial expiry\n- Events and time | seed | pass | Synthetic data only\n- Events and time | reset | warn | Reset on request\n- Keys, quotas and abuse | prefix | pass | pl_test_ and pl_live_\n- Keys, quotas and abuse | wrongenv | fail | An opaque 401\n- Keys, quotas and abuse | hygiene | fail | test keys on a toggle still read and write real account configuration\n- Keys, quotas and abuse | quota | pass | The production limits, unchanged\n- Keys, quotas and abuse | carding | warn |\n\n[... 1412 more characters cut from this display ...]"
}

Reply (long strings shortened for display - see the cuts below):

{
 "task": "cutover",
 "summary": "Payquorlo gates live mode on KYC activation with a named error, then each merchant swaps to pl_live_ keys, registers fresh live webhooks, turns on card-testing controls and runs a reversed live canary per payment path.",
 "gate": {
  "shape": "kyc",
  "error": "A pl_live_ call from an account whose KYC activation is not complete gets a named account_not_activated error saying which activation step is outstanding, instead of today's opaque failure.",
  "why": "Payquorlo moves real money, so live mode opens only after the merchant passes KYC activation, and the integrator must be able to tell an unapproved account from a broken integration."
 },
 "checklist": [
  {
   "step": "Confirm KYC activation is complete for the account before any live call.",
   "owner": "merchant account owner",
   "check": "A live-mode read returns data rather than account_not_activated."
  },
  {
   "step": "Swap every pl_test_ key for its pl_live_ key in every server, job, secret store and deploy environment.",
   "owner": "integration engineer",
   "check": "A search of configuration and secret stores finds no pl_test_ key in any live deploy, and no call returns key_environment_mismatch."
  },
  {
   "step": "Register fresh live webhook endpoints and store their new signing secrets, never reusing test-mode endpoints or secrets.",
   "owner": "integration engineer",
   "check": "Each live endpoint is listed in live mode with its own signing secret, and signature verification passes on a live delivery."
  },
  {
   "step": "Turn on the live abuse rules before the first live transaction: edge bot challenges on checkout, endpoint rate limits, a cap on cards per customer and a cap on consecutive declines per IP.",
   "owner": "risk owner",
   "check": "Each rule shows as enabled in live mode before the canary charge is made."
  },
  {
   "step": "Run a small live canary on each integration path with a team-owned card and confirm both the effect and the event delivery.",
   "owner": "integration engineer",
   "check": "The canary charge or subscription appears in live mode and its payment.succeeded or subscription event reaches the live webhook endpoint with a valid signature."
  },
  {
   "step": "Reverse every canary and confirm the reversal.",
   "owner": "finance owner",
   "check": "Each canary charge shows as refunded and each canary subscription as cancelled in live mode."
  }
 ],
 "canary": {
  "paths": [
   "one-off checkout charge",
   "subscription signup with first billing",
   "live webhook delivery and signature check"
  ],
  "reverse": "Each canary charge is fully refunded and each canary subscription is cancelled immediately after its effect and its event delivery are confirmed."
 },
 "pitfalls": [
  {
   "kind": "key-swap",
   "text": "The key swap forgotten: one job or server still sends pl_test_ keys after launch, so live checkouts quietly create test objects."
  },
  {
   "kind": "cross-environment",
   "text": "A cross-environment paste: a pl_live_ key pasted into a test config or a pl_test_ object id used in live, now answered with key_environment_mismatch."
  },
  {
   "kind": "sandbox-predicts-live",
   "text": "Assuming the sandbox predicts live: a green test-mode run is necessary but never sufficient, since real issuers, step-up and KYC limits behave in ways test cards only approximate."
  },
  {
   "kind": "stale-webhooks",
   "text": "Stale live webhook endpoints: live events sent to a test-mode endpoint or verified with a test-mode signing secret, so payment.succeeded never lands."
  },
  {
   "kind": "untested-failures",
   "text": "Untested real-money failure paths: live declines, step-up authentication, refunds and disputes never exercised, so the first real one breaks checkout or billing."
  }
 ],
 "go_back": [
  "Any key_environment_mismatch error in live traffic",
  "A canary whose effect appears but whose event never reaches the live endpoint",
  "Live failure outcomes the integration does not handle, such as authentication_required or a dispute",
  "A Payquorlo API change that alters an outcome the integration relies on"
 ],
 "metrics": [
  "cross-environment incidents, target zero",
  "live webhook delivery and signature-verification failures",
  "live decline and authentication_required rates per integration path",
  "card-testing rule triggers in live mode"
 ],
 "docs": "Going live on Payquorlo. The gate: live mode opens only after KYC activation; until then every pl_live_ call returns account_not_activated naming the outstanding step. The checklist, in order: 1. confirm KYC activation is complete; 2. swap [... 1015 more characters cut from this display]",
 "caveats": [
  "A live dispute cannot be safely canaried; dispute handling rests on tok_charge_disputed and the dispute.created trigger in test mode.",
  "Test and live share one account on the toggle, so the key swap and key_environment_mismatch carry the isolation."
 ]
}

Cut from this display only (the saved reply and the check use the full text): docs (1252 characters, 237 shown).

What step 5 prints for it: merchant account owner: Confirm KYC activation is complete for t | integration engineer: Swap every pl_test_ key for its pl_live_ | integration engineer: Register fresh live webhook endpoints an | risk owner: Turn on the live abuse rules before the | integration engineer: Run a small live canary on each integrat | finance owner: Reverse every canary and confirm the rev

The page's check: kept - nothing to flag

design - An SMS API with a mock for a sandbox

The page read it as: integrators will find bugs in live (6 fail, 3 warn, 8 pass); 4 of 5 outcomes triggerable; isolation by rank: separate; simulation by rank: endpoints; failing checks: model, isodocs, coverage, labels, eventcov, reset.

Input (sandbox is 1595 characters; the first 1396 are shown; facts is 2482 characters; the first 1393 are shown):

{
 "task": "design",
 "platform": "Platform: Textwren\nWhat it does: An SMS and voice API; integrators send messages, look up numbers and receive delivery receipts by webhook.\nMode: retrofit\nDomain: Communications-like (messages reach people)\nBudget: Quarters\nGate to live: Partner vetting\nExisting test infrastructure: A mock server integrators were told to use\nTiming: A platform surface run for years\nTime-dependent state: no; async events: yes; end-user surface: no; read-mostly with no money, messages or sensitive data: no\nPer-tenant infrastructure: yes; teams testing in parallel: no; compliance demands demonstrable isolation: no",
 "sandbox": "Isolation today: A mock server; isolation level stated in the docs: no; config from production's infrastructure-as-code: no\nPublished error codes:\n- invalid_number (400) - the number is not a valid phone number\n- unreachable (422) - the carrier cannot reach the number\n- opted_out (422) - the recipient opted out\n- unknown_number (404) - no record for the number\nDocumented outcomes (outcome | code | trigger | operation | page's findings):\n- message queued | ok | magic: +15005550006 | send\n- invalid number | invalid_number | magic: +15005550001 | send\n- recipient opted out | opted_out | magic: +15005550004 | send | the same value means different things and the operation is not labelled\n- number not found | unknown_number | magic: +15005550004 | operation not labelled | the same value means different things and the operation is not labelled\n- carrier unreachable | unreachable | no trigger | send | no way to trigger it\nEvents (fireable on demand today):\n- message.delivered | no\n- message.failed | no; trigger: None - integrators wait for real events; decoupled state and delivery documented: na\nTime-dependent flows: none listed; clock: None - integrators wait\nSeed: Synthetic data only; reset: No reset; shared sandbox: yes\nKeys: test tb_test_, live tb_live_; wrong-environment key gets: A named wrong-environment error; test keys get secret hygiene: yes; sandbox quota: The production\n\n[... 199 more characters cut from this display ...]",
 "carry": "",
 "facts": "The page's checks (6 fail, 3 warn, 8 pass, 0 n/a):\n- Isolation | model | fail | A mock server - stateless, it tests assumptions and keeps simulating old behaviour\n- Isolation | isodocs | fail | \"sandbox\" with no stated isolation level guarantees nothing\n- Isolation | parity | warn | drift is the works-in-sandbox, fails-live root cause\n- Failure paths | coverage | fail | 4 of 5 triggerable (80%); missing: carrier unreachable\n- Failure paths | failpaths | pass | 4 failure outcomes\n- Failure paths | realcodes | pass | every code is in the catalog\n- Failure paths | labels | fail | +15005550004 - the same value means different things and the operation is not labelled\n- Events and time | eventcov | fail | 0 of 2 fireable; no trigger mechanism; missing: message.delivered, message.failed\n- Events and time | seed | pass | Synthetic data only\n- Events and time | reset | fail | No reset - a shared sandbox drifts\n- Keys, quotas and abuse | prefix | pass | tb_test_ and tb_live_\n- Keys, quotas and abuse | wrongenv | pass | A named wrong-environment error\n- Keys, quotas and abuse | hygiene | pass | server-side only, scanned\n- Keys, quotas and abuse | quota | pass | The production limits, unchanged\n- The path to live | legible | pass | explicit\n- The path to live | checklist | warn | missing: abuse rules on before the first live transaction; a live canary with confirmed effect and event\n\n[... 1089 more characters cut from this display ...]"
}

Reply:

{
 "task": "design",
 "summary": "Integrators cannot reach carrier unreachable, fire either delivery receipt, tell opted-out from not-found on +15005550004, or reset shared state; first replace the mock server with a per-tenant stateful sandbox and its simulation endpoints.",
 "isolation": {
  "rung": "separate",
  "level": "The Textwren sandbox is a separate per-tenant copy with its own host, tb_test_ keys, webhook endpoints and configuration, and no message or call placed in it ever reaches a real phone or carrier.",
  "why": "Textwren already runs per-tenant infrastructure, which makes a hard separate copy nearly free, and a stateful copy replaces a mock server that only tests integrators' assumptions and keeps simulating old behaviour."
 },
 "simulation": {
  "rung": "endpoints",
  "why": "A carrier failure and a delivery receipt are states and events that no number in a send request can encode, so integrators need endpoints that force a number's state and fire receipts now."
 },
 "catalog": [
  {
   "outcome": "message queued",
   "operation": "send",
   "trigger": "+15005550006",
   "kind": "magic",
   "code": "ok"
  },
  {
   "outcome": "invalid number",
   "operation": "send",
   "trigger": "+15005550001",
   "kind": "magic",
   "code": "invalid_number"
  },
  {
   "outcome": "recipient opted out",
   "operation": "send",
   "trigger": "+15005550004",
   "kind": "magic",
   "code": "opted_out"
  },
  {
   "outcome": "number not found",
   "operation": "lookup",
   "trigger": "+15005550009",
   "kind": "magic",
   "code": "unknown_number"
  },
  {
   "outcome": "carrier unreachable",
   "operation": "send",
   "trigger": "/sandbox/numbers/{number}/force_unreachable",
   "kind": "endpoint",
   "code": "unreachable"
  }
 ],
 "events": [
  {
   "event": "message.delivered",
   "trigger": "POST /sandbox/messages/{message_id}/deliver marks a queued sandbox message delivered and sends message.delivered to the tenant's sandbox webhook now."
  },
  {
   "event": "message.failed",
   "trigger": "POST /sandbox/messages/{message_id}/fail marks a queued sandbox message failed and sends message.failed to the tenant's sandbox webhook now."
  }
 ],
 "time": {
  "primitive": "none",
  "cap": "",
  "flows": []
 },
 "reset": {
  "seed": "synthetic",
  "mint": "Sandbox endpoints mint synthetic numbers, opt-out records and queued messages so each test starts from a known state.",
  "reset": "POST /sandbox/reset clears a tenant's sandbox messages, opt-outs and forced number states in one call, replacing today's absence of any reset.",
  "periodic": "Shared sandbox data is wiped on a published schedule so drift from earlier test runs never leaks into a later one."
 },
 "keys": {
  "test_prefix": "tb_test_",
  "live_prefix": "tb_live_",
  "wrong_env": "Keep the existing named wrong-environment error, so a tb_test_ key sent to the live host, or a tb_live_ key sent to the sandbox, says which environment it belongs to.",
  "hygiene": "Keep test keys server-side and under secret scanning, as today, and issue each tenant's sandbox keys from that tenant's own copy."
 },
 "quotas": {
  "posture": "production-limits",
  "why": "The sandbox already applies the production limits unchanged, so integrators hit the same throttling there as in live sending."
 },
 "abuse": {
  "leaked_keys": [
   "secret keys used server-side only",
   "secret scanning on the tb_test_ and tb_live_ prefixes",
   "scoped keys per operation",
   "one-step revoke and roll for a leaked key"
  ],
  "card_testing": []
 },
 "parity": "Build each tenant's sandbox copy from production's infrastructure-as-code, with only the carrier connection swapped for a simulator that never sends, so behaviour cannot drift from live.",
 "caveats": [
  "The mock server must be retired with a deprecation notice; integrators still pointed at it keep testing stale behaviour.",
  "+15005550004 now means opted out on send only; number not found moves to +15005550009 on lookup.",
  "Document that a message's state and its receipt delivery are decoupled, so a forced state and a fired event can be tested apart."
 ]
}

What step 5 prints for it: separate | endpoints | send message queued -> ok | send invalid number -> invalid_number | send recipient opted out -> opted_out | lookup number not found -> unknown_number | send carrier unreachable -> unreachable

The page's check: kept - nothing to flag

Build the input yourself

Load the engine in node (global.window = global; require("./sbkit.js"); require("./sbreply.js")), then:

const K = SboxKit, R = SboxReply;
const f = K.form(Object.assign(K.blank(), { name: "Acme", domain: "payments", codes: "card_declined | 402 | declined",
  outcomes: "approved | ok | 4242424242424242 | charge\ndeclined | card_declined | 4000000000000002 | charge", test_prefix: "ac_test_", live_prefix: "ac_live_" }));
// f.rows (every check: area, id, label, status, evidence), f.matrix (each outcome with its issues), f.coverage, f.iso, f.sim
// K.luhn("4242424242424242") - the check digit; K.catalogMd(f) - the test-value catalog as Markdown
const b = K.buildInput(f, "design");                         // or "cutover" (R.laneForm(form, "cutover", design) fills carry)
const wire = K.toWire(b.input);                              // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "design");
// R.check(reply, b)                                         // {status: kept or flagged, notes}

Get a token for the steps below from /tokens.html.

Step 1 — a tiny client helper

One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"   # from https://sandbox-gate.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
  curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
    ${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}

Step 2 — a token

A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.

# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"sandbox-gate"}' | jq -r '.data.token')

Step 3 — GET /me

Who the token is (subject_type: user or guest) and the credit balance.

api GET /me | jq '.data | {subject_type, credits}' 

Step 4 — POST /estimate

Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.

# input.json: the run input object - {"task": "design", "platform": "Platform: Payquorlo\nMode: retrofit\nDomain: Payments-like ...", "sandbox": "Isolation today: A mode toggle on the live account ...", "carry": "", "facts": "The page's checks (10 fail, 4 warn, ...):\n- Isolation | model | pass | ..."}
# - see the input table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}' 

Step 5 — POST /run, then poll the job

Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses sandbox-gate:<task>:<inputHash>:a<attempt> - the lane is part of the key, retry_note is left out of the hash - and its one automatic reformat retry is a new attempt number, named in the page as one extra run. A design reply carries summary, isolation, simulation, catalog, events, time, reset, keys, quotas, abuse, parity and caveats; a cutover reply carries summary, gate, checklist, canary, pitfalls, go_back, metrics, docs and caveats. The code below prints the gist of either: the two rungs and each test value with its code, or each cutover step with its owner.

KEY="sandbox-gate:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
  J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
  case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json   # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r 'if .task == "cutover" then (.checklist[] | "\(.owner): \(.step)") 
       else ("\(.isolation.rung), \(.simulation.rung)", (.catalog[] | "\(.trigger) -> \(.code)")) end' reply.json

Step 6 — POST /run-stream

The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.

# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: sandbox-gate:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json