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.

Note:

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
curl "https://api.withflintpay.com/v1/capabilities?capability=accept_card_payments" \
  -H "Authorization: Bearer YOUR_API_KEY"
Response
{
  "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.

  • Success
    Payment succeeds immediately; the order becomes paid.
  • Requires authentication
    A 3D Secure challenge opens; complete it and the payment succeeds.
  • 3D Secure challenge
    Same challenge flow; complete it to succeed, fail it to decline.
  • Decline: insufficient funds
    Checkout shows a payment-failed message; the order stays open.
  • Decline: generic
    Same, simulating a generic issuer decline.
  • Decline: expired card
    Same, simulating an expired card.
  • Decline: incorrect CVC
    Same, simulating a bad security code.
  • Decline: processing error
    Same, simulating a processor-side failure.
  • Saves, then fails
    Attaches to a customer, but every charge fails. Use it to rehearse renewal failures and dunning.

Every card in Test card numbers also works in test mode.

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.

  1. Create an order#

    Amounts are integers in the currency's minor unit, so 2500 is $25.00. The line item is defined on the order itself, with nothing to ship and tax turned off, so the total stays $25.00. The Orders API reference describes each field.

    cURL
    curl -X POST https://api.withflintpay.com/v1/orders \
      -H "Content-Type: application/json" \
      -H "Authorization: Bearer YOUR_API_KEY" \
      -H "Idempotency-Key: order-testing-001" \
      -d '{
        "line_items": [{
          "name": "Test tote bag",
          "quantity": 1,
          "unit_price_money": {"amount": 2500, "currency": "USD"}
        }],
        "tax": {"enabled": false}
      }'
    
    Response
    {
      "data": {
        "order_id": "ord_1kmn0aExample",
        "status": "open",
        "payment_status": "unpaid",
        "pricing_amounts": {
          "total_money": {"amount": 2500, "currency": "USD"}
        },
        "settlement_amounts": {
          "paid_money": {"amount": 0, "currency": "USD"},
          "outstanding_money": {"amount": 2500, "currency": "USD"}
        }
      }
    }
    

    Save data.order_id. To run the walkthrough again with a new order, change the idempotency keys, for example from 001 to 002.

  2. Create a checkout session#

    A checkout session is one hosted payment page for one order. It collects the order's outstanding balance, so you don't send the amount again. tip.enabled: false keeps the tip prompt off so the total stays $25.00.

    cURL
    curl -X POST https://api.withflintpay.com/v1/checkout-sessions \
      -H "Content-Type: application/json" \
      -H "Authorization: Bearer YOUR_API_KEY" \
      -H "Idempotency-Key: checkout-testing-001" \
      -d '{
        "order_id": "ord_1kmn0aExample",
        "tip": {"enabled": false},
        "redirects": {
          "success_redirect_url": "https://example.com/thanks",
          "cancel_redirect_url": "https://example.com/checkout"
        }
      }'
    
    Response
    {
      "data": {
        "checkout_session": {
          "checkout_session_id": "cs_1kmn0aExample",
          "status": "open",
          "surface": "hosted",
          "order_id": "ord_1kmn0aExample",
          "expires_at": "2026-09-05T17:04:05Z",
          "url": "https://checkout.withflintpay.com/checkout/cs_1kmn0aExample#checkout_token=..."
        },
        "checkout_access": {
          "checkout_auth_token": "ckat_v1..."
        }
      }
    }
    
  3. Pay with the card for your scenario#

    Warning:

    If your sandbox can't take card payments yet, hosted checkout says "No payment methods are available for this checkout." and asks the buyer to contact the merchant instead of showing a card form. Check GET /v1/capabilities?capability=accept_card_payments; once it reports ready, reload checkout.

    Open data.checkout_session.url in a browser, exactly as returned. The #checkout_token fragment is what admits the buyer. Checkout shows the test-mode banner and a $25.00 total. Fill in the buyer details it asks for and pay with the card for your scenario.

    With 4242 4242 4242 4242 the payment succeeds and checkout redirects to your success_redirect_url with csId and orderId appended as query parameters.

  4. Verify the outcome#

    Confirm the result from your backend rather than trusting the browser redirect:

    cURL
    curl https://api.withflintpay.com/v1/orders/ord_1kmn0aExample \
      -H "Authorization: Bearer YOUR_API_KEY"
    
    Response
    {
      "data": {
        "order_id": "ord_1kmn0aExample",
        "status": "closed",
        "payment_status": "paid",
        "settlement_amounts": {
          "paid_money": {"amount": 2500, "currency": "USD"},
          "refunded_money": {"amount": 0, "currency": "USD"},
          "outstanding_money": {"amount": 0, "currency": "USD"}
        },
        "checkout_session_ids": ["cs_1kmn0aExample"]
      }
    }
    

    payment_status: "paid" with outstanding_money.amount at 0 means the payment landed. An order can also be closed without payment, so check payment_status rather than status. If you registered a webhook endpoint (below), an order.paid event was delivered too.

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:

TokenWhat happens
pm_card_visaThe payment succeeds and the order becomes paid.
pm_card_chargeDeclinedThe attempt fails with last_payment_error.code of card_declined.
pm_card_chargeDeclinedInsufficientFundsThe attempt fails with insufficient_funds.
pm_card_authenticationRequiredThe attempt stops at requires_action with a 3D Secure step for the browser to complete.
pm_card_threeDSecure2RequiredSame, using 3D Secure 2.

Create an order as in step 1, then pay it:

cURL
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"}
  }'
Response
{
  "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 numberRisk scenario
4000 0000 0000 9235Elevated risk. Flint's default rule opens a review, except on invoice and subscription renewal payments.
4100 0000 0000 0019Highest risk, blocked by the card processor's fraud screening. The attempt fails with payment_blocked.
4000 0000 0000 5423Payment 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 numberExpected outcome
000123456789Succeeds.
000222222227Fails for insufficient funds.
000111111113Fails because the account is closed.
000111111116Fails because no account exists.
000333333335Fails because the debit is not authorized.
000555555559Succeeds, then creates a return dispute.
000000000009Remains 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
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"
  }'
Response
{
  "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
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"
    ]
  }'
Response
{
  "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
  }
}
Warning:

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
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"}'
Response
{
  "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
curl https://api.withflintpay.com/v1/webhook-deliveries/wdel_1kmn0aExample/attempts \
  -H "Authorization: Bearer YOUR_API_KEY"
Response
{
  "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#

Warning:

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
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"}
    }]
  }'
Response
{
  "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
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}'
Response
{
  "data": {
    "subscription_id": "sub_1kmn0aExample",
    "status": "canceled",
    "canceled_at": "2026-07-03T02:45:00Z"
  }
}

Test-mode errors#

Four errors are specific to test mode:

  • 401 on any request: your test key isn't bound to a sandbox. Use a dashboard-issued key or mint one from a sandbox; see the sandbox-bound key requirement.
  • 404 when fetching a resource that exists in the other mode. Test and live data never mix. Check which mode created the resource and use the matching key.
  • 401: the sandbox your key is bound to was archived or is inactive. Mint a new key in an active sandbox.
  • 401: the key's mode doesn't match how it was bound, for example a live key bound to a sandbox. Create a fresh key for the mode you need from the dashboard.

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.paid webhook or a backend fetch, never by the browser redirect alone.
  • Your webhook handler verifies signatures and dedupes on the webhook-id header (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_id from 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 0341 card) 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#

Was this helpful?