Sandboxes and Test Clocks

A Sandbox is a tenant isolated from a LIVE account. It has its own TEST keys, objects, activity, simulated balances, and webhook endpoints. A Sandbox key can never read IDs from LIVE or another Sandbox, and simulations never call a payment processor, bank, blockchain, or tax provider.

Rollout is enabled per account. Once enabled, every public operation runs inside the isolated tenant: some use the normal model and others persist a deterministic local simulation. None call real providers.

Create and authenticate a Sandbox

An administrator can create up to five environments under Settings → Sandboxes. A Sandbox can start empty or copy branding, safe checkout settings, products, prices, custom fields, and coupons. Customers, payment methods, subscriptions, movements, credentials, and balances are never copied.

Empty Sandbox management state

The TEST key is shown once. Store it outside your code and use it against the same base URL as LIVE:

$export RECURRENTE_API="https://app.recurrente.com/api"
$export SANDBOX_KEY="sk_test_..."
$
$curl "$RECURRENTE_API/test" \
> -H "X-SECRET-KEY: $SANDBOX_KEY"

Every response and webhook created with this key belongs only to that Sandbox.

Sandbox TEST keys and webhooks

After entering the environment, a persistent banner keeps the Sandbox name visible and reminds the developer that its data, keys, and money are simulated. On mobile it sits below the fixed navigation so the environment context is never obscured.

Sandboxes do not appear in the global account selector. To enter one, return to the LIVE account and use Settings → Sandboxes → Enter, making every environment switch explicit.

Sandbox environment banner on desktop

Sandbox environment banner on mobile

Distinguish LIVE, legacy TEST, and named Sandboxes

CredentialData and webhooksUse
LIVE keyLIVE account and LIVE Svix applicationReal operations
Legacy TEST keyPrevious TEST behavior while routing is disabledMigration compatibility
Named Sandbox TEST keySandbox-only tenant and Svix applicationNew integrations and UAT suites

A LIVE key never changes tenants. A legacy TEST key routes to the default Sandbox only after Recurrente provisions it, verifies its keys and webhooks, and enables migration for that account. Disabling routing restores legacy behavior without deleting Sandbox data. New integrations should use a key created directly inside a named Sandbox.

What you can test

The public API is available with Sandbox keys, but it cannot send funds. Catalog and configuration resources live naturally inside the tenant. Charges, refunds, conversions, connected accounts, terminals, and tax configuration persist deterministic local states and balances. A platform’s children form a connected graph inside that same Sandbox and never mix with LIVE accounts.

A Sandbox balance is simulated, non-transferable, and non-redeemable. The dashboard does not offer Send. The API rejects account and phone transfers, checkout and subscription splits, stablecoin sends, and bank withdrawals. Internal currency conversions remain available because they do not move funds outside the ledger.

These simulations preserve the observable contract without contacting payment processors, banks, Bridge, blockchain, POS hardware, INFILE, or other real providers. See the parity matrix to distinguish native operations from local simulations.

Choose how time moves

A Sandbox subscription can use one of two timelines:

ModeWhen to use itHow billing runs
Real timeSmoke tests that can wait until the real billing dateThe hourly collector processes the subscription with the simulator
Test ClockDeterministic tests for months, renewals, and retriesBilling moves only when you call POST /test_clocks/{id}/advance

A Test Clock belongs to one Sandbox and is attached to a customer. Subscriptions created afterward for that customer inherit the clock. Effective time applies only to that customer during the request or job; other clocks, customers, and LIVE keep their own time.

Attach the customer to the clock before creating the subscription. A customer with an existing subscription cannot change clocks; create another customer to start a different timeline.

Tutorial: test a complete subscription lifecycle

1. Register the test webhook

Register the endpoint with the Sandbox key, not a LIVE key:

$curl "$RECURRENTE_API/webhook_endpoints" \
> -X POST \
> -H "X-SECRET-KEY: $SANDBOX_KEY" \
> -H "Content-Type: application/json" \
> -d '{
> "url": "https://uat.example.com/recurrente/webhooks",
> "description": "Subscription tests"
> }'

Store the signingSecret: it is returned only when the endpoint is created. This endpoint receives only TEST events from the authenticated Sandbox.

2. Create the clock and customer

$curl "$RECURRENTE_API/test_clocks" \
> -X POST \
> -H "X-SECRET-KEY: $SANDBOX_KEY" \
> -H "Content-Type: application/json" \
> -d '{
> "name": "Monthly subscription UAT",
> "frozen_at": "2026-07-01T00:00:00Z"
> }'

Save the returned clock_... ID and use it when creating the customer:

$curl "$RECURRENTE_API/customers" \
> -X POST \
> -H "X-SECRET-KEY: $SANDBOX_KEY" \
> -H "Content-Type: application/json" \
> -d '{
> "email": "buyer@example.test",
> "full_name": "Sandbox Buyer",
> "test_clock_id": "clock_...",
> "metadata": { "test_case": "monthly-renewal" }
> }'

Save the customer ID (cus_...). Customer metadata is separate from the metadata that will persist on the subscription.

3. Create a recurring product

$curl "$RECURRENTE_API/products" \
> -X POST \
> -H "X-SECRET-KEY: $SANDBOX_KEY" \
> -H "Content-Type: application/json" \
> -d '{
> "product": {
> "name": "Monthly Sandbox Plan",
> "prices_attributes": [{
> "amount_in_cents": 2500,
> "currency": "USD",
> "charge_type": "recurring",
> "billing_interval": "month",
> "billing_interval_count": 1
> }]
> }
> }'

Save prices[0].id from the response (price_...).

4. Create and complete the initial checkout

$curl "$RECURRENTE_API/checkouts" \
> -X POST \
> -H "X-SECRET-KEY: $SANDBOX_KEY" \
> -H "Content-Type: application/json" \
> -d '{
> "items": [{ "price_id": "price_...", "quantity": 1 }],
> "customer_id": "cus_...",
> "metadata": {
> "integration_id": "sub-uat-123",
> "scenario": "renewal-and-recovery"
> },
> "success_url": "https://uat.example.com/success",
> "cancel_url": "https://uat.example.com/cancel"
> }'

Open the returned checkout_url and use:

FieldValueOutcome
Card4242 4242 4242 4242Success
Card4000 0000 0000 0002Decline
CVCAny three digitsAccepted
ExpirationAny future dateAccepted

After a successful payment, assert:

  • the checkout is paid;
  • the subscription is active and has test_clock_id, current_period_start, and current_period_end;
  • one invoice and payment intent succeeded;
  • integration_id and scenario metadata are present on the subscription and its webhooks;
  • payment_intent.succeeded and subscription.create include live_mode: false and the expected sandbox_id.

Checkout metadata survives renewals, retries, and cancellation. Metadata sent when creating the customer stays on the customer and is not copied automatically to the subscription.

5. Advance to the first renewal

Fetch the subscription and use its current_period_end as the clock destination:

$curl "$RECURRENTE_API/subscriptions/sub_..." \
> -H "X-SECRET-KEY: $SANDBOX_KEY"
$
$curl "$RECURRENTE_API/test_clocks/clock_.../advance" \
> -X POST \
> -H "X-SECRET-KEY: $SANDBOX_KEY" \
> -H "Content-Type: application/json" \
> -d '{ "frozen_at": "<current_period_end>" }'

Advance responds with 202 and runs asynchronously. Poll the clock until it moves from advancing to ready:

$curl "$RECURRENTE_API/test_clocks/clock_..." \
> -H "X-SECRET-KEY: $SANDBOX_KEY"

When it is ready, assert a new paid invoice, another payment_intent.succeeded, an updated period, and a simulated balance credit. If it is failed, inspect last_error before rerunning the scenario.

6. Test a decline and recovery

The outcome supplied while advancing applies to one charge and then resets to success:

$curl "$RECURRENTE_API/test_clocks/clock_.../advance" \
> -X POST \
> -H "X-SECRET-KEY: $SANDBOX_KEY" \
> -H "Content-Type: application/json" \
> -d '{
> "frozen_at": "<next current_period_end>",
> "next_charge_outcome": "decline"
> }'

After completion, assert payment_intent.failed, subscription.past_due, payment_retries: 1, and a next_payment_attempt_at. To test recovery, advance the same clock to that next_payment_attempt_at without next_charge_outcome:

$curl "$RECURRENTE_API/test_clocks/clock_.../advance" \
> -X POST \
> -H "X-SECRET-KEY: $SANDBOX_KEY" \
> -H "Content-Type: application/json" \
> -d '{ "frozen_at": "<next_payment_attempt_at>" }'

The pending invoice should become paid, the subscription should return to active, and another payment_intent.succeeded should be emitted.

7. Test a refund and cancellation

Use the pa_... ID from a payment_intent.succeeded event to issue a full refund:

$curl "$RECURRENTE_API/refunds" \
> -X POST \
> -H "X-SECRET-KEY: $SANDBOX_KEY" \
> -H "Content-Type: application/json" \
> -d '{ "payment_intent_id": "pa_..." }'

Assert a succeeded refund, refund.create, and the simulated balance reversal. Then cancel the subscription:

$curl "$RECURRENTE_API/subscriptions/sub_..." \
> -X DELETE \
> -H "X-SECRET-KEY: $SANDBOX_KEY"

Assert subscription.cancel, a cancelled status, and no new invoices after advancing past the next period.

Expected sequence

ScenarioMain webhooksObservable state
Initial paymentpayment_intent.succeeded, subscription.createcheckout paid, subscription active
Renewalpayment_intent.succeedednew paid invoice and billing period
Declinepayment_intent.failed, subscription.past_duepast_due, retry count, and next attempt date
Recoverypayment_intent.succeededinvoice paid and subscription active
Refundrefund.createrefund succeeded, simulated balance reversed
Cancellationsubscription.cancelsubscription cancelled, no future charges

Each message includes eventType, an idempotent eventId, and data.event_type. Verify its signature with the endpoint secret, deduplicate by eventId, and confirm that data.sandbox_id matches the environment under test.

Test a webhook without creating its domain flow

The tutorial above is the recommended subscription test because it validates natural objects, states, and events. If you only need to test signature verification, routing, retries, or idempotency for an event that is difficult to trigger, emit a fixture:

$curl "$RECURRENTE_API/test_helpers/webhook_events" \
> -X POST \
> -H "X-SECRET-KEY: $SANDBOX_KEY" \
> -H "Content-Type: application/json" \
> -d '{
> "event_type": "dispute.create",
> "data": {
> "status": "needs_response",
> "integration_case": "chargeback-v1"
> }
> }'

The helper accepts any event_type in the public catalog, persists an Activity, and delivers it through the same Svix endpoint. The 201 response contains the normalized base payload. Recurrente generates and replaces data.id and data.created_at; Svix delivery adds data.event_type and data.sandbox_id.

A fixture validates the delivery contract, but it does not create a dispute, withdrawal, or other domain graph. Use the real endpoint and assert its objects when you need an end-to-end behavior test.

Natural payloads include fields such as live_mode, metadata, and test_clock_id when that domain serializer publishes them. The helper does not invent those fields: it delivers the submitted fields plus event_type, sandbox_id, id, and created_at.

Test Clock rules

  • Each Sandbox can have up to ten clocks, and a customer can belong to at most one.
  • A clock can move only forward.
  • The maximum advance is twice the shortest billing interval among its subscriptions; without subscriptions, it is two years.
  • An advance processes renewals, retries, and resumptions before the destination in chronological order.
  • Clock states are ready, advancing, failed, and deleted.
  • A clock cannot be deleted while advancing or while it has subscriptions.
  • LIVE endpoints and endpoints from another Sandbox never receive its events.

Automation checklist

  • Use a dedicated Sandbox and TEST key per test suite or team.
  • Create unique data per case; never reuse LIVE IDs.
  • Register the webhook before starting checkout and store its signing secret.
  • Wait for ready before advancing the same clock again.
  • Use dates returned by the API (current_period_end and next_payment_attempt_at) instead of assuming fixed dates.
  • Assert objects and webhooks: status, amount, currency, metadata, sandbox_id, and test_clock_id.
  • Treat webhooks as retryable and idempotent.
  • Use fixtures only for consumer tests; use real flows for behavior tests.
  • End each scenario by canceling the subscription and archive the Sandbox when its data is no longer needed.

Archiving and retention

Archiving a Sandbox revokes its keys, blocks new requests, and marks its clocks as deleted. It does not purge customers, subscriptions, invoices, activities, or historical webhooks; those records are retained for audit and debugging. Create a new Sandbox for a clean suite and never use real customer data as fixtures.

Differences from Stripe Test Clocks

Recurrente follows customer attachment, asynchronous forward-only advances, and the two-interval maximum. It intentionally differs because:

  • clock state is observed by polling; separate clock-status webhooks are not published;
  • a clock with subscriptions cannot be deleted;
  • deleting or archiving retains the historical graph instead of deleting it;
  • clocks are not automatically deleted after a fixed retention period;
  • objects and event names follow Recurrente’s public contract.

See Stripe’s documentation on subscription simulations and advanced Test Clock API usage to compare the contracts.

See the endpoint reference and Sandbox parity matrix to learn which operations are native and which persist a local simulation.