> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.fluveo.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.fluveo.com/_mcp/server.

# Test playground (dev only)

> **Status: internal, dev only.** `https://checkout-test.devfluveo.com/checkout-test`
> exists only on the `dev` environment and accepts only **test** keys
> (`sk_test_…` / `rk_test_…`). It is not part of the public API, it is not
> promoted to production, and it may change or disappear without notice.

The playground is the fastest way for the team to make a real test payment
and see it in the dashboard: paste a merchant test key, type an amount, pick a
test card and press **Gerar transação**. It can also run the hosted checkout
end to end. Nothing here
bypasses the public `/v1` API — the page is a thin, host-gated relay inside the
dashboard app that makes the same calls you would make from your own server.

## How the team uses it

1. Open `https://checkout-test.devfluveo.com/checkout-test`. A red banner reads
   **TEST MODE ONLY — dev environment**.
2. Paste a merchant **test** secret key into the password field. Live keys
   (`sk_live_…`, `rk_live_…`) and processor credentials are refused with
   `400 live_key_not_allowed` before any upstream call.
3. Enter an amount (decimal, e.g. `42.42`) in `usd` and optionally a
   description.

### Gerar transação (a real test payment)

4. Pick a **test card** by what it should do: *Success*, *Declined (generic)*,
   *Declined (insufficient funds)* or *3D Secure required*. These are Stripe's
   published test cards; the playground is for **US Stripe Connect stores**,
   where they behave as named. The page never asks for a card number.
5. Optionally pick **Manual** capture, a receipt e-mail, a statement descriptor
   suffix and up to 20 metadata pairs.
6. **Gerar transação** sends `POST /v1/payment_intents` with
   `confirm=true`. The result panel shows the `pi_` id, status, amount, capture
   method, latest charge, the failure reason (a decline, or the payment's
   `last_payment_error`, for example after a failed 3D Secure), and an
   **Open in the dashboard** link to the payment. When a call fails, the page
   shows the API's HTTP status and error code.
7. With *3D Secure required* the 3DS page opens in a **new tab**. Finish it
   there; that tab says "3D Secure finished" and this tab reads the payment again
   on its own. **Refresh** does the same by hand and also lists the payment's
   events.
8. The panel offers **Capture** and **Cancel payment** for a manual payment
   waiting for capture, **Refund** for a succeeded one (leave the amount empty
   for the full amount). If a refund says `charge_not_settled`, wait about a
   minute and press it again: the same request is sent with the same
   `Idempotency-Key`, so it cannot refund twice. The same holds after no answer,
   a `409`, any `5xx`, or a success answer the page cannot read: pressing the
   button again re-sends the same request with the same key. The amount field
   empties when the payment, its status or its refund changes.

The playground is for US Stripe Connect stores: the store's Stripe Connect
binding processes the payment. Other processors are out of scope for now
(later branch).

### Outros testes (Checkout Session and Payment Link)

9. Optionally add a customer e-mail. **Pay on checkout** creates `POST /v1/checkout/sessions` (`mode=payment`,
   one `line_items[0]` from your amount) and redirects the browser to the
   returned hosted checkout URL on `pay.devfluveo.com`. Pay there with a
   [test card](/checkout/hosted-payment-page).
10. Fluveo sends you back to `/checkout-test/return?session_id=cs_…`. The
    return page re-reads the session (`GET /v1/checkout/sessions/{id}`) and
    shows the `cs_` id, the `pi_` id (and a `ch_` id when an event exposes one),
    `amount_total`, `currency`, `status` and `payment_status`, followed by
    that payment's events from `GET /v1/events?limit=100` (matched on
    `data.object.id`, since Stripe's list has no per-object filter).
11. **Generate Payment Link** posts the same amount and currency as one Stripe
    `line_items[0]` (its product name is the description, or `Payment link test`) to
    `POST /v1/payment_links` and shows the returned shareable `url` with a copy
    chip.
12. On the return page, press **Forget key** when you are done. Closing the tab has the same effect.

## Where the key goes — and where it never goes

* The key travels **once per action** from the browser to a route inside the
  dashboard app on this hostname, which forwards it as `Authorization: Bearer`
  to `api.devfluveo.com` and then drops it. The server does **not** store it:
  no cookie, no session, no database, no log line, no URL. Upstream error
  bodies are redacted before they are logged or shown.
* **Gerar transação** stores the key nowhere, browser included: it is read
  from the password field each time you press a button. The 3DS tab never sees
  it.
* Only the **Pay on checkout** flow keeps the key in the browser tab's
  `sessionStorage` between the checkout redirect and the return page. It dies
  with the tab and is never sent implicitly. If the tab lost it, the return page
  asks you to type it again.
* Responses are whitelisted projections of the API objects; the page never
  echoes request fields back.

## Limits (read before relying on it)

* **Dev only, test keys only.** The routes and pages answer solely on
  `checkout-test.devfluveo.com` with `FLUVEO_CHECKOUT_PLAYGROUND=1`; on
  `dashboard.devfluveo.com` and on any deployment without the flag every
  `/checkout-test` path is a `404`. Removing the infra opt-in is a complete kill
  switch.
* **Events may be unavailable.** Merchant-public Events are documented as
  unavailable (see [Checkout overview](/checkout/overview)). Any non-200 answer
  from `GET /v1/events` is shown as "Events unavailable", never as a failure of
  the session read. The `ch_` id depends on those events and may be missing.
* **Rate limit 10/min per client, per pod.** The dashboard runs two replicas,
  so a single client may get up to 20 requests per minute cluster-wide before
  seeing `429 rate_limited` with `retry-after`. Clients without the edge
  attestation share one bucket.
* **The playground hostname is not a fully separate site.** Its middleware host
  gate 404s the dashboard pages and every other matched path, but the
  dashboard's middleware matcher never sees `/api/auth/login`, `/api/auth/signup`,
  `/api/fluveo/*` or paths containing a dot, so those handlers also answer on
  `checkout-test.devfluveo.com`. Each is self-gated (credentials, dashboard
  session cookie, proxy scope) and carries no privilege beyond what
  `dashboard.devfluveo.com` already exposes; the assets under `/_next` and
  `/favicon` are served as well.
* **Reachability is an operator step.** The HTTPRoute ships with the dev edge
  and the Terraform-owned ACM certificate (`*.devfluveo.com`,
  `infra/terraform/envs/dev/alb.tf`) already covers the hostname; only the
  Cloudflare CNAME `checkout-test.devfluveo.com → <gateway-alb-hostname>` is
  created by an operator. Until then the hostname does not resolve; the
  app-side flag makes that harmless.
* **Payments, captures, cancels and refunds are real test-mode writes** on the
  store the key belongs to. They appear in that store's dashboard in test mode.
* **The 3DS page comes from Stripe Connect.** If the API gives no 3DS page, the
  panel says so.
* `rk_test_` keys are accepted by the page; whether the API issues them is an
  upstream question, and an upstream `401` is shown as `upstream_error`.

The decision record is ADR 0058 (checkout test playground) in the repository.