Testing
Everything on Flint runs in test mode with a flint_test_... key: orders, checkout sessions, payments, refunds, subscriptions, invoices, payment links, promotions, and webhooks. Card charges run against Stripe test mode, so no money moves and no real card is needed.
If you're signed in to the docs, each request below can run against your own sandbox with your test key filled in, and the IDs from one response carry into the requests that follow.
Test and live traffic share one host, https://api.withflintpay.com. The key prefix decides the mode, and each test key is bound to one isolated sandbox. Sandboxes & test mode covers how keys bind to sandboxes and how to reset or create more.
What you need#
A test API key bound to a sandbox. Create one on the dashboard's API keys page, or let API & agent onboarding provision the merchant and mint its first key from your backend. Then confirm that the sandbox can take card payments:
curl "https://api.withflintpay.com/v1/capabilities?capability=accept_card_payments" \
-H "Authorization: Bearer YOUR_API_KEY"
{
"data": [
{
"capability": "accept_card_payments",
"domain": "payments",
"status": "ready"
}
]
}
status: "ready" means every card scenario below will work. While it is pending or blocked, the response's blocked_reasons and requirements name what is still due.
Test cards#
Use these numbers anywhere Flint collects a card: Flint-hosted checkout, payment links, invoices, and embedded Stripe Elements. Any future expiry date, any 3-digit CVC, and any ZIP code (for example 44444) work with all of them.
A decline never ends a checkout session. The buyer sees the failure inline and can retry with another card, and the order keeps its outstanding balance until a payment succeeds.
Flint processes card payments on Stripe, so every Stripe test card works in test mode and you don't need a Stripe account. The full Stripe list is in Test card numbers. Checkout pages opened in test mode show a test-mode banner.
Simulate a payment outcome#
Create an order, open its checkout page, pay with the card for your scenario, and read the result back from the API.
Simulate a decline or 3D Secure#
Repeat steps 1 and 2 for a fresh order, then pay with a decline card such as 4000 0000 0000 9995. Checkout shows the failure and lets the buyer retry in place. The verify call in step 4 returns the order still open and unpaid with paid_money at 0. A real decline produces the same pair: a retryable failure in checkout and an unpaid order in the API.
With 4000 0025 0000 3155, checkout opens a 3D Secure challenge instead. Complete it and the payment succeeds. Fail it and the payment declines. Test both branches, because real buyers abandon challenges.
Pay from the API with a test token#
To run card scenarios from a test suite or a terminal, skip the browser and pay the order directly. POST /v1/orders/{order_id}/pay accepts a Stripe test payment method token in payment_source.token:
| Token | What happens |
|---|---|
pm_card_visa | The payment succeeds and the order becomes paid. |
pm_card_chargeDeclined | The attempt fails with last_payment_error.code of card_declined. |
pm_card_chargeDeclinedInsufficientFunds | The attempt fails with insufficient_funds. |
pm_card_authenticationRequired | The attempt stops at requires_action with a 3D Secure step for the browser to complete. |
pm_card_threeDSecure2Required | Same, using 3D Secure 2. |
Create an order as in step 1, then pay it:
curl -X POST https://api.withflintpay.com/v1/orders/ord_1kmn0aExample/pay \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: pay-testing-001" \
-d '{
"action": "pay",
"payment_source": {"token": "pm_card_visa"},
"expected_outstanding_money": {"amount": 2500, "currency": "USD"}
}'
{
"data": {
"order": {
"order_id": "ord_1kmn0aExample",
"status": "closed",
"payment_status": "paid",
"settlement_amounts": {
"paid_money": {"amount": 2500, "currency": "USD"},
"outstanding_money": {"amount": 0, "currency": "USD"}
}
},
"payment_attempt": {
"order_payment_attempt_id": "opat_1kmn0aExample",
"status": "succeeded",
"is_resumable": false
}
}
}
Read data.payment_attempt, not the HTTP status. A declined token also returns 200, with the attempt failed, the payment leg back at requires_payment_method, and last_payment_error naming the reason. expected_outstanding_money must match the order's current outstanding balance; if the order changed, the call fails with ORDER_CHANGED_REFRESH_REQUIRED instead of charging. Declines and payment attempts covers every attempt status and how to resume a 3D Secure attempt.
The CLI runs the same flow from your terminal, and flint help test-cards prints these tokens offline.
Test risk rules and reviews#
To test your own rules without affecting other sandbox payments, scope each test rule to a payment intent metadata key, such as {"attribute": "payment_intent_metadata.risk_test", "operator": "eq", "value": "block"}. Validate it with POST /v1/risk-previews, create it with an idempotency key, then create a payment intent with that metadata and confirm it with pm_card_visa. Each attempt keeps the rules and lists in force when it starts, so editing a rule afterward doesn't change that attempt's result.
A block rule makes the confirmation fail with 402 and PAYMENT_BLOCKED. The payment intent returns to requires_payment_method with last_payment_error.code set to payment_blocked. In checkout, the buyer sees a generic decline and can retry with another payment method on the same session. The buyer never sees the rule or the list.
With a review rule and a manual-capture payment, you can test the capture gate. Confirm the payment and wait for review.opened. Capture fails with PAYMENT_REVIEW_OPEN while the review is open. Approving the review does not capture; submit the capture separately and confirm payment_intent.succeeded arrives. Decline the review instead and the hold is canceled and review.closed arrives. With automatic capture, the payment succeeds before the review opens, and declining refunds it.
These Stripe test cards exercise Flint's default controls:
| Card number | Risk scenario |
|---|---|
4000 0000 0000 9235 | Elevated risk. Flint's default rule opens a review, except on invoice and subscription renewal payments. |
4100 0000 0000 0019 | Highest risk, blocked by the card processor's fraud screening. The attempt fails with payment_blocked. |
4000 0000 0000 5423 | Payment succeeds, then receives an early fraud warning. |
Risk scoring is not enabled in every sandbox. GET /v1/risk-rules/attributes reports whether risk_score is available. When it isn't, payment intents leave risk.score out, which is not the same as a score of zero, so build test rules on risk_level or pre-authorization attributes instead. Risk controls covers the rule grammar and review actions.
Test ACH debit#
Bank debits use routing number 110000000 in test mode. Pick the account number for the outcome you need:
| Account number | Expected outcome |
|---|---|
000123456789 | Succeeds. |
000222222227 | Fails for insufficient funds. |
000111111113 | Fails because the account is closed. |
000111111116 | Fails because no account exists. |
000333333335 | Fails because the debit is not authorized. |
000555555559 | Succeeds, then creates a return dispute. |
000000000009 | Remains processing. |
Sandbox bank accounts verify instantly. Microdeposit account numbers and verification codes are not supported. Use 000000000009 to confirm that a processing payment leaves the order unpaid and offers no retry or cancel action, and that your integration fulfills only after payment_intent.succeeded or order.paid. ACH debit payments covers collection and returns.
Test refunds#
Refund the payment you simulated. Pass amount_money to refund part of the order, or omit it to refund everything. 500 is $5.00.
curl -X POST https://api.withflintpay.com/v1/refunds \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: refund-testing-001" \
-d '{
"order_id": "ord_1kmn0aExample",
"amount_money": {"amount": 500, "currency": "USD"},
"reason": "requested_by_customer"
}'
{
"data": {
"refund_id": "ref_1kmn0aExample",
"order_id": "ord_1kmn0aExample",
"status": "pending",
"amount_money": {"amount": 500, "currency": "USD"},
"refund_method": "original_payment",
"reason": "requested_by_customer"
}
}
Refunds run the same lifecycle in test mode as in live. The create response can already be succeeded, or pending with the outcome still to come. Treat refund.updated webhook events, or a re-fetch of the refund, as the outcome. Test the failures too: refunding an unpaid order returns NO_PAYMENTS_FOR_ORDER, and refunding more than what remains returns AMOUNT_EXCEEDS_REFUNDABLE. Refunds covers line-item refunds and the full lifecycle, and the Refunds API reference lists every field.
Test webhook deliveries#
Sandbox webhooks are real deliveries with real signatures, sent to any public HTTPS URL. To receive them on your machine, expose your local server through an HTTPS tunnel and register the tunnel URL, or run flint listen to forward sandbox events to localhost without a tunnel. To see the delivery records without a working handler, register a throwaway URL and inspect the failed attempts it produces.
curl -X POST https://api.withflintpay.com/v1/webhook-endpoints \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: webhook-testing-001" \
-d '{
"url": "https://example.com/webhooks/flint",
"enabled_events": [
"order.paid",
"refund.updated",
"subscription.payment_failed"
]
}'
{
"data": {
"webhook_endpoint_id": "whep_1kmn0aExample",
"url": "https://example.com/webhooks/flint",
"secret": "whsec_IZuqE1Q8V+xP3VgZ5vHrJBFM1WJ6+DpvV35+yRHKKc8=",
"enabled_events": [
"order.paid",
"refund.updated",
"subscription.payment_failed"
],
"enabled": true
}
}
This response is the only time Flint shows the secret. Store it now; your handler needs it to verify signatures. If you lose it, rotate the secret and store the replacement.
Now send a test event to the endpoint. A test event carries a small fixture payload for the event type you name, signed with your endpoint's secret, so it exercises your handler without you staging the underlying payment:
curl -X POST https://api.withflintpay.com/v1/webhook-endpoints/whep_1kmn0aExample/test-events \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: webhook-test-order-paid-001" \
-d '{"event_type": "order.paid"}'
{
"data": {
"webhook_event_id": "whev_1kmn0aExample",
"webhook_delivery_id": "wdel_1kmn0aExample",
"webhook_delivery_attempt_id": "watt_1kmn0aExample",
"attempt_number": 1,
"attempt_status": "delivered",
"delivery_status": "delivered",
"delivery_trigger": "test_event",
"status_code": 200,
"duration_milliseconds": 326
}
}
The response is the delivery attempt itself, with the HTTP status your endpoint returned. If your handler rejected the delivery, attempt_status is failed and the response includes an error_summary and a recommended_action. A test event is delivered once and never retried. Real events retry on a backoff schedule, up to 9 attempts over about three days.
Inspect any delivery's attempts by its webhook_delivery_id:
curl https://api.withflintpay.com/v1/webhook-deliveries/wdel_1kmn0aExample/attempts \
-H "Authorization: Bearer YOUR_API_KEY"
{
"data": [
{
"webhook_delivery_attempt_id": "watt_1kmn0aExample",
"attempt_number": 1,
"delivery_trigger": "test_event",
"status": "failed",
"status_code": 405,
"error_summary": "The webhook endpoint rejected the delivery.",
"recommended_action": "Inspect the endpoint response body and signature verification logic, then resend after fixing the handler.",
"duration_milliseconds": 63
}
]
}
After fixing your handler, replay that delivery with POST /v1/webhook-deliveries/{webhook_delivery_id}/resend. Real events from the payment walkthrough above appear in GET /v1/webhook-events; test events are hidden from that list unless you pass include=test_events. Signature verification, deduplication on the webhook-id header, and secret rotation are covered in Webhooks, and payload shapes in the Webhooks API reference.
Test subscriptions and renewals#
Billing runs on real schedules in a sandbox; there is no way to fast-forward the clock. Create test plans with a daily interval so renewal events arrive within a day, and cancel test subscriptions when you're done so they stop generating billing events.
curl -X POST https://api.withflintpay.com/v1/subscription-plans \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: plan-testing-001" \
-d '{
"name": "Test Daily Plan",
"billing_interval": "daily",
"billing_interval_count": 1,
"currency": "USD",
"line_items": [{
"name": "Test subscription item",
"quantity": 1,
"unit_price_money": {"amount": 500, "currency": "USD"}
}]
}'
{
"data": {
"subscription_plan_id": "plan_1kmn0aExample",
"name": "Test Daily Plan",
"status": "active",
"billing_interval": "daily",
"billing_interval_count": 1,
"currency": "USD",
"line_items": [...]
}
}
Subscribing a customer needs a saved payment method, which comes from a card-setup flow rather than a single API call. Follow Subscription billing for the API-driven flow, or create a subscription signup link for a hosted signup page you can complete in the browser.
Subscribe with 4242 4242 4242 4242. The first charge succeeds, the subscription becomes active, and subscription.payment_succeeded arrives again at the next daily renewal.
To rehearse a failed renewal, save a 4000 0000 0000 0341 card for the same customer, the card that saves but never charges, and point the subscription at it with PATCH /v1/subscriptions/{subscription_id} and payment_method_id. The next daily renewal fails, and you receive subscription.payment_failed and subscription.past_due, which is the dunning path your integration has to handle. Subscribing with the 0341 card from the start leaves the subscription incomplete instead, because its first payment never succeeds.
Clean up when you're finished:
curl -X POST https://api.withflintpay.com/v1/subscriptions/sub_1kmn0aExample/cancel \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{"cancel_immediately": true}'
{
"data": {
"subscription_id": "sub_1kmn0aExample",
"status": "canceled",
"canceled_at": "2026-07-03T02:45:00Z"
}
}
Test-mode errors#
Four errors are specific to test mode:
The full error envelope and every code are in Error handling.
Before you go live#
Work through this list in your sandbox before swapping in a live key:
- Every card scenario in the table above shows the buyer the outcome in your UI and lets them retry after a decline or an abandoned challenge.
- Fulfillment is driven by an
order.paidwebhook or a backend fetch, never by the browser redirect alone. - Your webhook handler verifies signatures and dedupes on the
webhook-idheader (Webhooks). - Retries of the same business action reuse the same
Idempotency-Key, so a network blip can't double-charge or double-create (Idempotency). - You log
request_idfrom API responses, so failures can be correlated with Flint support. - If you bill subscriptions, you've watched at least one renewal succeed and one fail (the
0341card) and handled both.
Going live is a key swap, not a code change: create a flint_live_... key in the dashboard, replace the test key, keep the same base URL, and register a production webhook endpoint. See Going live.
Next steps#
- Test card numbers: every Stripe test card that works on Flint, alongside the other major processors.
- CLI: run these scenarios from your terminal, and forward events to a local handler with
flint listen. - Sandboxes & test mode: where test data lives, resetting, and multi-sandbox setups for CI and QA.
- Declines and payment attempts: the recovery model for declines, 3D Secure, and lost responses.
- Webhooks: signature verification and production-grade handlers.
- Accept your first payment: a first test payment through a payment link, with one API request.
- Subscription billing: the full recurring billing flow.
- Error handling: the error envelope and every error code.
- Going live: moving from simulated payments to real ones.
