# Test subscriptions

> A synthetic subscription in Stripe test mode — sign-up with a test card, your real emails at Checkbox's inbox, a test clock through trial end and renewal, and online cancellation — so every lifecycle check is observed, like a CMS preview.

A test run is a preview of your whole subscription lifecycle before your customers see it. Checkbox subscribes a test
customer in your billing platform's **test mode**, receives your emails at its own inbox, fast-forwards a Stripe test
clock through the trial end and the first renewal, cancels online, and reports every step with evidence, check results
and fixes your agent can apply. Nothing is charged and nobody has to log in.

## 1. Give Checkbox a Stripe test key {#credentials}

Create a **restricted key in test mode** (Stripe Dashboard → Developers → API keys) with:

| Permission | Access | Used for |
|---|---|---|
| Customers | Write | The test subscriber (attached to the run's test clock) |
| Checkout Sessions | Write | The mirrored test-mode Checkout Session |
| Subscriptions | Write | Reading trial, period and cancellation; creating the subscription if the hosted page cannot be completed |
| Test clocks | Write | Creating, advancing and deleting the run's clock |
| Customer portal | Write | A portal session for the online cancellation |
| Prices, Products | Read | Resolving the mirrored prices |
| Billing portal configuration | Read | Comparing the test portal with live |

Store it for the site. Live keys are refused (`test_key_not_test_mode`); the key is verified with Stripe, kept encrypted
and never returned (only a hint such as `rk_test_…a1b2`):

```bash
curl https://app.checkbox.site/v1/sites/site_3f9a1c2e7b4d4e0f9a8b7c6d5e4f3a2b/test_credentials \
  -u "$CHECKBOX_SECRET_KEY:" \
  -H "Content-Type: application/json" \
  -d '{ "type": "stripe_test", "secret_key": "rk_test_..." }'
```

`checkbox init` and `setup_checkbox` do this for you when the human ticks "use my Stripe test key" on the approval page.

## 2. Start a run {#start}

```bash
curl -X POST https://app.checkbox.site/v1/sites/site_3f9a1c2e7b4d4e0f9a8b7c6d5e4f3a2b/test_runs \
  -u "$CHECKBOX_SECRET_KEY:" \
  -H "Content-Type: application/json" \
  -d '{}'
```

Checkbox copies your checkout into a test-mode Checkout Session. The source is, in order: a config you registered with
[`createStripeCheckoutConfig`](https://checkbox.site/docs/api/test-runs#createStripeCheckoutConfig) (exactly what your code passes to
`stripe.checkout.sessions.create`), a test price you pass, or your latest live Checkout Session read through the
read-only connection. It also tells you whether the copy matches live (`stripe.matches_live`).

## 3. Follow it {#follow}

A run takes about 5 to 20 minutes, mostly waiting for emails. Poll `GET /v1/test_runs/{run}` every 15–30 seconds, or
subscribe to the `test_run.completed` / `test_run.failed` [webhooks](https://checkbox.site/docs/webhooks). List a site's runs:

```curl
curl -G https://app.checkbox.site/v1/sites/site_3f9a1c2e7b4d4e0f9a8b7c6d5e4f3a2b/test_runs \
  -u "$CHECKBOX_SECRET_KEY:" \
  -d limit=3
```
```node
import Checkbox from '@checkbox-site/node';

const checkbox = new Checkbox(process.env.CHECKBOX_SECRET_KEY);

const runs = await checkbox.testRuns.list('site_3f9a1c2e7b4d4e0f9a8b7c6d5e4f3a2b', { limit: 3 });
for (const run of runs.data) console.log(run.id, run.status, run.next_action?.type);
```
```python
import os
from checkbox import Checkbox

checkbox = Checkbox(os.environ["CHECKBOX_SECRET_KEY"])

runs = checkbox.test_runs.list("site_3f9a1c2e7b4d4e0f9a8b7c6d5e4f3a2b", limit=3)
for run in runs.data:
    print(run["id"], run["status"], (run.get("next_action") or {}).get("type"))
```

## Real checkout (Run proof) {#real-checkout}

`mode: "real_checkout"` is the proof run: the synthetic shopper buys through **your own checkout on your test
deployment**, so your Checkout Session code, webhook handlers and email code all run. Checkbox never creates the
subscription itself in this mode; a step passes only with evidence (a screenshot, an email at the run inbox, a Stripe
event). Reminder timing is not proven here (no test clock): prove it with a mirror run.

`POST /v1/sites/{site}/test_runs` with:

```json
{
  "mode": "real_checkout",
  "stripe": {
    "checkout_url": "https://staging.example.com/pricing",
    "pre_checkout_steps": [{ "action": "click", "text": "Subscribe" }],
    "test_account": { "login_url": "https://staging.example.com/login", "email": "{{inbox}}" },
    "cancel_url": "https://staging.example.com/account/cancel"
  }
}
```

- `{{inbox}}` as the test account email signs up with the run's own inbox, so your acknowledgment and cancellation
  emails reach Checkbox. In `checkout_steps`, `{{email}}` is the shopper's address (the same inbox).
- In the dashboard: **Proof → Run proof → Real checkout**. MCP: `start_test_run` with `mode`, `checkout_url`,
  `test_account` and `checkout_steps` (accepted at the top level too).

### Local development with `stripe listen` {#local}

On localhost your webhook endpoint is not registered in Stripe. Forward test-mode events to it while the run is going:

```text
stripe listen --forward-to localhost:3000/api/stripe/webhook
```

Use the `whsec_…` secret it prints as your app's webhook secret. The `webhooks` step then reports that no enabled
endpoint points at your app and names `stripe listen`: that is expected locally and is a warning, not a failure.

### Embedded checkout (Stripe Elements / Payment Element) {#checkout-steps}

When the card form is on your own page, the default steps (which wait for Stripe Checkout's `#cardNumber`) do not
apply. Pass the steps that pay, in order, as `stripe.checkout_steps` (dashboard: **On my own page (Stripe Elements)**):

```json
{
  "mode": "real_checkout",
  "stripe": {
    "checkout_url": "https://staging.example.com/checkout",
    "checkout_steps": [
      { "action": "fill", "selector": "#email", "value": "{{email}}" },
      { "action": "fill", "selector": "#card-number", "value": "4242424242424242" },
      { "action": "click", "text": "I agree to the automatic renewal terms" },
      { "action": "click", "text": "Pay and subscribe" }
    ]
  }
}
```

## Which checkout is mirrored {#checkout-config}

Mirror runs use, in order: `stripe.checkout_config` (a registered config, `scc_…`), the newest registered config, a
test price in `stripe.price`, `stripe.payment_link`, or your latest live Checkout Session. `"checkout_config": "none"`
skips the registered configs. Configs with inline `price_data` are mirrored as they are; when the hosted page cannot be
completed, the subscription is created through the API with the same `price_data`.

### Payment Links {#payment-links}

`stripe.payment_link` (`plink_…` or a `buy.stripe.com/test_…` URL) mirrors a test-mode link (mirror) or buys through it
(real checkout). The key needs **Payment Links: Read**. A live link is compared by its terms; buy through a test link.

### Prices that only exist in live mode {#copy-prices}

Live and test mode have separate prices. A mirrored live price with no test counterpart fails with
`price_not_in_test_mode`; the failure carries the exact `stripe prices create` command, or map ids with
`stripe.price_map` (live id → test id).

## Reminders and renewal {#reminders}

Mirror runs stop the test clock on each notice-window edge before the trial end and the first renewal and judge every
reminder's timing exactly (`in_window`, `too_early`, `too_late`). Real-checkout runs only wait for the reminder your
`customer.subscription.trial_will_end` handler sends (Stripe fires it 3 days before the trial ends); prove the timing
of reminders that need more notice with a mirror run.

## Cancellation {#cancellation}

With `stripe.cancel_url` (or a site test account) the shopper cancels in **your** flow; otherwise in the Stripe customer
portal. After the flow Checkbox reads the subscription in Stripe: only a subscription that is canceled or set to cancel
at period end counts. A page that says "cancelled" while Stripe still shows the subscription active or paused fails.

## Webhooks {#webhooks}

The `webhooks` step lists the enabled webhook endpoints that point at your app and the events Stripe sent. It needs
**Webhook Endpoints: Read** on the key; without it the step says so instead of guessing.

## Troubleshooting {#troubleshooting}

- `test_clock_failed` with "a Stripe API mock does not simulate time": the run talked to a mock (e.g. stripe-mock). It
  will fail the same way every time; run mirror proofs against a real Stripe test account, or use `real_checkout`.
- `test_clock_failed` on a real account: Stripe could not advance the clock; start a new run.
- `subscription_create_failed`: read `failure.message` (Stripe's reason); usually a missing permission.
- `real_checkout_not_completed`: the shopper could not finish your checkout; open the step's screenshots, then add
  `pre_checkout_steps` / `checkout_steps`.

## Test key permissions {#stripe-test-key}

See [step 1](#credentials) for the permissions table. Live keys are refused.

## Other billing platforms {#generic}

`type: "generic"` runs a test account or a test coupon through your checkout and cancellation without time travel.

## What a run does {#steps}

| Step | What happens | Evidence for |
|---|---|---|
| `credentials` | The test key works | |
| `checkout_config` | Mirror the checkout and compare it with live | |
| `checkout_page` | The synthetic shopper scans the hosted test-mode page | `signup.*` |
| `subscribe` | Complete the page with test card 4242; if the page cannot be completed, the subscription is created through the API and flagged | |
| `acknowledgment_email` | Wait for your email at the run's inbox | `confirmation.*` |
| `trial_reminder` | Move the test clock to every notice-window edge before the trial ends (from the legal catalog), collect mail after each stop | `reminder.trial_ending` |
| `renewal` | The same around the first renewal | `reminder.annual_renewal`, `reminder.charge_notice` |
| `cancel_online` | Open the customer portal (or your own cancel page) and cancel with the shopper | `cancel.*` |
| `cancellation_email` | Wait for the confirmation | `cancel.confirmation` |
| `cleanup` | Delete the test clock (Stripe deletes its customer and subscription with it) | |

**Exact timing.** The clock stops on window edges, so an email that arrives between two stops was sent inside, before
or after each state's window — `timing` is `in_window`, `too_early` or `too_late`, exactly, even though the run takes
minutes. The run is judged in test-clock time: a 35-day trial is evaluated as 35 days.

### The acknowledgment email {#acknowledgment}

Stripe sends no customer emails in test mode, so a run only sees emails **your** code or email provider sends. If your
acknowledgment comes from Stripe's own receipts, the run reports it as a warning with a fix — send the acknowledgment
from your `checkout.session.completed` webhook handler (or turn on [Checkbox Notices](https://checkbox.site/docs/guides/emails-and-notices#notices)) —
and it is not counted against your site. Each step's `fix` has `summary`, ordered `steps`, `where`, and the
`operation_id` to call to try again.

## What counts for monitoring {#provenance}

Everything a run observes is labelled `provenance: "test_run"`. Your emails always count (they are your real emails);
the hosted checkout counts only when the mirrored config matches live; the cancellation flow counts when it is your
own cancel page or a test portal configured like live. Missing evidence that is only missing because of test mode is
never recorded as a failure of your site.

Each response's `next_action` (`wait`, `fix_and_retry`, `apply_fixes`, `review_warnings`, `none`) tells your agent what
to do next. After applying fixes, start another run to confirm them.
