# Sandbox & testing

Everything here works without touching live money. The goal: your first
successful call within minutes, and a full trial-to-renewal subscription
exercised without waiting a month of wall-clock time.

## Hosts

| Environment | Host |
| --- | --- |
| Production | `https://api.fynex.ai`. Whichever host serves you, the OpenAPI document's `servers` block lists that deployment's own origin first. |

The environment that matters is your **account's**, not the host's: a Fynex
account operates in demo mode or live mode, and test behaviour follows the
account. A demo account is a sandbox account on any host.

The OpenAPI document's `servers` block declares the origin of the deployment
serving it. Each entry is the bare origin; the `/billing-api/v1` prefix is
already part of every path in the document, so join a server with a path
exactly as written and do not add the prefix twice.

## Keys


Issue a **secret** key for your account in the Fynex dashboard: **Integration** in the
left-hand menu, then the API keys card. While your account is in demo mode the
key is `sk_test_…`; after go-live only `sk_live_…` keys authenticate and the
test key answers `401` from then on. A key is shown once, at creation — store
it server-side and never in a browser.

The same key authenticates every surface in these docs: the Billing API and
usage ingestion under `/billing-api/v1`, and the Payments API.

## Test cards

On a demo account, hosted payment pages (including the links
`POST /invoices/{invoiceId}/send` creates) accept these Visa sandbox numbers:

| Card number | Notes |
| --- | --- |
| `4111 1111 1111 1111` | Universal Visa test card |
| `4530 9100 0001 2345` | Visa |
| `4037 1122 3300 0001` | Visa |

For all of them: any future expiry, any 3-digit CVV, Latin cardholder name.
Paying an invoice's link with one of these drives the document through
`sent → paid` exactly as a live card would, so it is the way to test the
settlement-polling loop end to end.

## Time travel

Subscription flows — trial ending, first charge, renewal — run on billing
dates, and a sandbox that makes you wait 30 real days for a renewal is not a
sandbox. **Each contract can be given a test clock of its own and moved
forward**, with a `sk_test_` key and no help from us:

| Call | What it does |
| --- | --- |
| `GET /contracts/{contractId}/test-clock` | Reads what the billing engines treat as *now* for that one contract. |
| `POST /contracts/{contractId}/test-clock/advance` | Moves it forward to `to`, then runs that contract's subscription lifecycle, recurring invoicing and usage period close as of it. |

**Test-mode (demo) accounts only.** A live account answers `403` on both: a
live subscription's renewal is real revenue and a real document to a real
customer.

**One contract at a time.** The clock belongs to the contract in the path.
Advancing it never moves a sibling contract — yours or another account's — and
no scheduled pass sees a changed clock. That is why this is safe to publish.

**Forward only, capped at 366 days.** `to` must be after the contract's
current clock. Re-sending the instant the clock already sits at is a **no-op**:
`200` with `advanced: false`, nothing renewed and nothing issued twice, so the
call is safe to retry. A target in the past is `400` — the invoices an advance
mints are numbered and immutable, so a clock cannot be wound back.

### Worked example: a monthly subscription renewing in one call

Create the subscription today (`42` is a contract that already carries at
least one invoice — see the note below):

```bash
curl -s -X POST "$FYNEX_API_BASE/contracts/42/subscriptions" \
  -H "Authorization: Bearer $FYNEX_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"frequency":"monthly","priceMinor":4999,"currency":"EUR","startDate":"2026-01-01"}'
```

Ask where the contract's clock is:

```bash
curl -s "$FYNEX_API_BASE/contracts/42/test-clock" \
  -H "Authorization: Bearer $FYNEX_API_KEY"
# {"contractId":42,"now":"2026-01-01T09:15:00Z","simulated":false}
```

Advance it past the end of the first term — 31 days:

```bash
curl -s -X POST "$FYNEX_API_BASE/contracts/42/test-clock/advance" \
  -H "Authorization: Bearer $FYNEX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"to":"2026-02-01T00:00:00Z"}'
```

```json
{
  "clock": { "contractId": 42, "now": "2026-02-01T00:00:00Z", "simulated": true },
  "advanced": true,
  "lifecycle": { "renewed": 1, "trialsActivated": 0, "cronDisabled": false },
  "invoices": { "issued": 1, "sent": 1, "skipped": 0, "failed": 0, "passDisabled": false }
}
```

The renewal is now visible on the two reads that matter — the subscription's
new term, and the document it earned:

```bash
curl -s "$FYNEX_API_BASE/subscriptions?contractId=42" \
  -H "Authorization: Bearer $FYNEX_API_KEY"
# currentPeriodStart moves to 2026-02-01, currentPeriodEnd to 2026-03-01

curl -s "$FYNEX_API_BASE/invoices?contractId=42" \
  -H "Authorization: Bearer $FYNEX_API_KEY"
# one more invoice, origin "recurring", for the new term
```

`GET /contracts/{contractId}/invoices/upcoming` before the advance, and the
issued document after it, should agree — which is the cheapest end-to-end
check that your contract is configured the way you think it is.

### What it does not advance

The clock moves the **subscription state machine**, the **recurring
invoice** and the **close of the contract's ended usage periods** (reported in
the `usage` block: each period that ended by `to` becomes a pending usage line).
It does *not*:

- close per-seat periods or mint usage invoices,
- accept usage events dated after real time,
- send dunning e-mail for a subscription that went `past_due`,
- run off-session auto-charge on an issued invoice,
- move the issue date: an invoice raised by an advance is dated at REAL time
  (so its number stays in the real month's gapless sequence) while its period
  is the simulated term — read the period, not the date, to check the renewal.

Those stay on real time in their own scheduled passes, and every advance
repeats the list in its `notAdvanced` field. Do not write a test that waits on
one of them after an advance.

### Usage periods closed ahead of real time

An advance closes the contract's usage periods that have ended **by `to`** —
at most those; a period ending after `to` waits for a later advance (or for
real time). Because the close happens at the simulated date, it can close a
period whose real events have not all arrived yet. A real event you send
afterwards for that period is still accepted, but it cannot join the closed
period's usage line: it is recorded as a late event flagged
`requires_correction` and goes to the correction backlog, where reopening the
period is an explicit backoffice act. **Send a period's events before you
advance past its end.**

A period whose close failed (`usage.failed`), or a close that failed as a whole
(`usage.error` — the lifecycle and invoicing still ran and the answer is still
`200`), is retried by an advance to a **later** instant. Re-sending the same
instant is a no-op and retries nothing.

### When an advance renews but issues nothing

Three operator switches decide whether the parts above run at all, and the
response says which one stopped:

| Response field | Switch | Symptom |
| --- | --- | --- |
| `lifecycle.cronDisabled` | `billing.subscription.lifecycle_cron` | Nothing renews, no matter how far the clock moves. |
| `invoices.passDisabled` | `billing.recurring.invoice_pass` | The term renews, and no document appears on `GET /invoices`. |
| `usage.passDisabled` | `billing.usage.close_pass` | An ended usage period stays open; `usage.periodsClosed` stays `0`. |

All three default to **off** — see the table below. There is also one data
precondition the clock cannot supply: the recurring lane continues a
contract's existing invoicing pattern, so a contract with **no prior invoice**
is reported in `invoices.skipped` rather than issued. Raise the first document
once with `POST /invoices` (or let a contract you already invoiced carry the
subscription), and every later advance issues by itself.

Recurring invoicing is also switched on per account and currency. A **demo**
account has it from the start; on a live account Fynex enables it. A contract
whose account is not enabled is reported in `invoices.skipped` as well.

Metered usage is priced per contract: register the metric once
(`POST /usage/metrics`) and price it on the contract
(`PUT /usage/contracts/{contractId}/metrics/{metricName}/price`) before you send
its events. The MCP endpoint offers both as `create_usage_metric` and
`set_usage_price`.

The staff-operated clock is a different thing and is not this. It shifts the
lifecycle dates of test-mode *payment-link* subscriptions (the recurring
hosted-checkout product), estate-wide, and never touches the billing
subscriptions this reference describes.

## What a fresh sandbox has switched off

Several billing capabilities sit behind operator feature flags that default to
**off**, because each one moves money or issues documents on a schedule. A
sandbox where they are off looks broken — subscriptions never charge, usage
periods never close into invoices, no invoice is ever raised, credits report
`501` — when it is merely unconfigured. If a flow below does nothing, ask Fynex
to confirm the flag before debugging your integration:

| Flag | What it gates |
| --- | --- |
| `billing.subscription.lifecycle_cron` | The pass that ends trials, charges renewals and applies scheduled plan changes. |
| `billing.usage.close_pass` | The pass that closes usage periods and mints usage invoice lines. |
| `billing.recurring.invoice_pass` | The pass that turns due subscription terms into issued invoices. |
| `billing.usage.invoice_pass` | The pass that composes closed usage lines into issued invoices. Separate from the recurring one on purpose — a seller may be ready to auto-bill terms and not metered usage, or the reverse. |
| `billing.proration.invoice_binding` | Whether a subscription's unbilled mid-term proration is put on the invoice its term is billed on — a charge as an extra line, a credit as a discount on the recurring line, any remainder as a non-expiring `proration` credit lot. While off, a mid-term change is still recorded against the contract but never reaches a document. |
| `billing.invoice.auto_charge` | Off-session collection: the loop that charges a customer's saved card for a sent or overdue invoice. Without it a document is only ever paid by someone visiting its link or making a transfer. |
| `payment_links.recurring_billing` | Recurring charging through hosted payment links. |
| `billing.credits` | Stored-value credits; while off, the credit endpoints answer `501` and invoices draw no credit down. |
| `billing.promotions.subsidy_payout` | Credits each seller the platform-funded part of a promotional order (a platform coupon's discount, platform credit the buyer spent) to their main wallet, the way the order's cash is credited. While off, the subsidy is only recorded as owed on the order's funding record. |
| `billing.promotions.checkout` | Buyer-facing coupon activity at checkout: quoting and applying a code or held coupon, the saved-coupon picker, and a payment link's preset code. While off, those requests answer `409` with `reason: "promotions_disabled"`, a link is charged its plain price, and claiming into the wallet still works; holds made before the flag was switched off are still honoured. |
| `billing.promotions.subscriptions` | Attaching new coupons to subscriptions: at a subscription link's checkout, from a link's preset code, through the seller API and by Fynex staff. While off, those requests answer `409` with `reason: "promotions_disabled"`; coupons attached before it was switched off keep discounting their renewals on the terms they were attached with. |
| `billing.credits.wallet` | Promotional credit in the consumer coupon wallet; while off, the wallet answers `200` with `state: "credits_disabled"` instead of a balance, credit coupons cannot be claimed, and checkout refuses a credit opt-in. Coupon discounts work either way. |
| `billing.usage.alert_webhook` | Delivery of `UsageAlertFired` when a contract crosses a usage-limit threshold. |

**Closing a period is not the same as billing it.** The close pass and the
invoice passes are different switches: with only `billing.usage.close_pass` on,
`GET /contracts/{contractId}/usage` moves and periods close, and no invoice
ever appears on `GET /invoices`. That pairing is the single most common reason
a sandbox looks like it has stopped halfway.

## A sandbox session that proves the loop

The target this page is written against is the one in our own onboarding
PRD: **a first transaction under 30 minutes, self-serve** — from a fresh
sandbox key to an invoice on `GET /invoices` without talking to anyone at
Fynex. The steps below are that path; if one of them cannot be done inside
that budget, the gap is ours, not yours.

1. `GET /contracts` — key, host and network path all work.
2. Register a metric and price it (see **Usage ingestion**), send a few
   events, and watch `GET /contracts/{contractId}/usage` move.
3. Create a subscription in the dashboard with a trial that has already
   ended, and let the lifecycle pass run — the renewal invoice appears on
   `GET /invoices`.
4. `POST /invoices/{invoiceId}/send`, pay the link with a test card, and poll
   the invoice to `paid`.

That is the whole billing loop — metering, rating, issuance, collection,
settlement — without a real card or a real month.
