Documentation
Guides

Fraud, risk, and compliance

Collect address verification data and separate payment authorization from capture without weakening checkout safety.

Fraud, risk, and compliance

Use address verification to give your payment provider better risk signals, and use manual capture when fulfillment must happen only after a separate business decision.

Who this is for

This guide is for developers and operations teams that configure fraud controls, collect billing-address signals, or authorize card payments before inventory, fulfillment, or risk checks are complete.

Choose the control you need

Enable AVS when the provider should compare buyer address data during payment confirmation. Choose manual capture when your server must place a temporary authorization first and capture it only after an approved fulfillment step. These controls can be used independently or together.

Collect address verification data

AVS validates the buyer's billing address against the address on file with their card issuer. When enabled, the SDK collects address fields, forwards them to Stripe as billing_details, and Stripe runs AVS checks automatically. Stricter address checks improve authorization rates and reduce rebill failures.

enableAVS accepts either a boolean (the legacy default) or an AVSFieldConfig object that controls which fields are shown: globally or per country.

Quick Start

The simplest form: country dropdown + postal code, shown for every buyer:

import { FloPayCheckout } from '@flopay/react';

<FloPayCheckout
  sessionId="sess_abc123"
  enableAVS
  onComplete={(result) => console.log('Payment succeeded:', result)}
/>

Configurable AVS

Pass an object to control each field independently. Each field accepts:

  • true: always visible
  • false or omitted: hidden
  • string[]: visible only when the buyer's country matches one of these ISO 3166-1 alpha-2 codes
<FloPayCheckout
  sessionId={sessionId}
  enableAVS={{
    country:        true,            // always show
    postal_code:    true,            // always show
    address_line_1: ['US', 'CA'],    // street address only for US/CA buyers
    address_line_2: ['US', 'CA'],    // optional apt/suite line
    city:           ['US', 'CA'],
    state:          ['US', 'CA'],    // dropdown for US/CA, free text elsewhere
  }}
  onComplete={handleSuccess}
/>

Country codes are normalized (case- and whitespace-insensitive), so ['us', ' ca '] behaves the same as ['US', 'CA'].

Available fields

KeyField
countryCountry dropdown (ISO 3166-1 alpha-2)
postal_codeZIP / postal code
address_line_1Street address
address_line_2Apt, suite, unit (optional even when visible)
cityCity / town
stateState / province / region: dropdown for US (50 states) and CA (13 provinces), free text otherwise

All visible fields are required at submit time, except address_line_2, which is always optional.

Backward compatibility

The boolean form keeps working exactly as before:

Prop valueBehavior
enableAVS={false} (or omitted)No AVS fields
enableAVS={true}Country + postal code (the legacy default)
enableAVS={{ … }}Custom per-field config

Visible-Only Data Flow

Hidden fields are never sent to Stripe or to your backend, even if React state was populated earlier (for example, the buyer typed a city, then changed the country to one where city is hidden). Each send site: billing_details, the /process request, and 3DS retries: gates every field through isAVSFieldVisible(config.field, currentCountry).

This means:

  • The data you see in Stripe matches the fields the buyer actually saw.
  • Country-scoped configs cannot leak data from one country into another.
  • Switching country at the form mid-edit clears the now-hidden fields from outgoing payloads automatically.

Where the Address Goes

DestinationFields
Stripe PaymentMethod.billing_details.addressline1, line2, city, state, country, postal_code
Stripe Customer.addresssame six
Stripe PaymentIntent.shipping.addresssame six
Backend /process request accountDataaddressLine1, addressLine2, city, state, country, zip
Database checkout_session rowuser_address_line_1, user_address_line_2, user_address_city, user_address_state, user_address_country, user_address_zip

3DS Retries Preserve All Fields

When Stripe demands a 3DS challenge after tokenization, the SDK includes the full billing_details object: country, postal code, line1, line2, city, state: on the retry confirmation. The PaymentMethod minted during authentication therefore carries the same address the buyer entered before the challenge.

Pre-fill from GEO/IP

Pass any address fields the partner already knows about the buyer (typically resolved via a GEO/IP lookup or pulled from the partner's user profile) in the session's account object, and the SDK pre-fills the matching AVS inputs:

<FloPayCheckout
  createSession={{
    clientId: 'your-client-id',
    currency: 'EUR',
    products: [{ code: 'product-1', totalAmount: 29.99 }],
    account: {
      userId: 'user_1',
      email: 'user@example.com',
      // Resolved from GEO/IP / partner profile: any subset is fine.
      country: 'CA',
      city: 'Toronto',
      state: 'ON',
    },
    successUrl: '/success',
    cancelUrl: '/cancel',
  }}
  enableAVS={{
    country: true,
    postal_code: true,
    address_line_1: ['US', 'CA'],
    city: ['US', 'CA'],
    state: ['US', 'CA'],
  }}
  onComplete={handleSuccess}
/>

FloPayCheckout automatically forwards session.customer.{country, city, state} from the create-session response into the matching AVS inputs.

Not auto-forwarded (the buyer always types these themselves):

  • zip / postal_code
  • addressLine1 / addressLine2

SplitCardForm accepts zip, addressLine1, addressLine2 props for direct consumers, but FloPayCheckout does not pre-populate them. This matches an earlier product decision to avoid steering buyers toward an address Stripe AVS may then reject.

If no country is provided, the country dropdown defaults to US. The buyer can edit any prefilled value before submitting.

Note: Pre-filling does not bypass AVS validation. Stripe still runs the address check against the issuing bank: the prefill just saves the buyer typing.

State Derivation from Postal Code (US / CA)

When the form collects address_line_1 and postal_code but hides the state input, the SDK derives a state / province code from the buyer's postal code and sends it to Stripe billing_details.address.state anyway. This gives Stripe Radar a richer AVS signal without adding another required input.

Trigger conditions (all must hold):

  • address_line_1 is visible
  • postal_code is visible
  • state is not visible
  • Country is US or CA

When triggered, the SDK calls getStateFromPostalCode(country, postalCode), attaches the result to billing_details.address.state (and to the /process accountData), and the backend persists it onto the session row via an atomic UPDATE … WHERE user_address_state IS NULL. Values the partner already provided at session creation are never overwritten.

Resolution rules:

  • US: 5-digit ZIP → 2-letter USPS state code via the 3-digit Sectional Center Facility prefix table.
  • CA: A1A 1A1 → 2-letter ISO 3166-2:CA province code via Forward Sortation Area first letter. X resolves to NT (shared with NU).
  • Other countries: returns null: caller skips state derivation.
import { getStateFromPostalCode } from '@flopay/shared';

getStateFromPostalCode('US', '90210');     // 'CA'
getStateFromPostalCode('US', '10001');     // 'NY'
getStateFromPostalCode('US', '90210-1234');// 'CA' (handles ZIP+4)
getStateFromPostalCode('CA', 'M5V 2T6');   // 'ON'
getStateFromPostalCode('CA', 'V6B1A1');    // 'BC' (compact)
getStateFromPostalCode('GB', 'SW1A 1AA');  // null (not US/CA)

City derivation from postal code is not currently supported: Canadian postal codes don't map to a single city, and the US ZIP→city dataset is too large to bundle. If that becomes a priority we'll evaluate a CDN-loaded dataset or a backend-side lookup.

Postal Code Labels

The label adapts automatically based on the selected country:

CountryLabel
USZIP Code
GB, AU, NZPostcode
CAPostal Code
IEEircode
All othersPostal Code

The label updates in real time when the buyer changes country.

State / Province Dropdowns

For US and CA buyers, the SDK renders a dropdown populated from US_STATES and CA_PROVINCES. For all other countries, the field falls back to a free-text input labelled by getStateLabel(country) ("State", "Province", "County", "State / Territory", or "State / Province / Region").

import { US_STATES, CA_PROVINCES, getStateOptions, getStateLabel } from '@flopay/shared';

US_STATES.length;          // 51 (50 states + DC)
CA_PROVINCES.length;       // 13
getStateOptions('US');     // [{ code: 'AL', name: 'Alabama' }, …]
getStateOptions('GB');     // null: render a free-text input
getStateLabel('CA');       // 'Province'
getStateLabel('GB');       // 'County'

Field Layout

AVS fields can be arranged side-by-side (default) or stacked:

// Side-by-side (default): country and ZIP share one row when both are visible
<FloPayCheckout sessionId={sessionId} enableAVS onComplete={handleSuccess} />

// Stacked: each field on its own row
<FloPayCheckout
  sessionId={sessionId}
  enableAVS
  avsLayout="column"
  onComplete={handleSuccess}
/>

When the address fields are visible (address_line_1, address_line_2, city, state), they always render on their own rows above country/postal: buttons-layout still uses the row/column choice for the country and postal pair.

Theming

AVS fields inherit from the card input styles (cardInputBackground, cardInputColor, cardInputBorder). Each AVS field also has a dedicated style slot you can override:

<FloPayCheckout
  sessionId={sessionId}
  enableAVS={{ country: true, postal_code: true, address_line_1: ['US','CA'] }}
  layout="buttons"
  theme="bold-dark"
  buttonsStyles={{
    countrySelect:     { backgroundColor: '#1f2937', color: '#f9fafb' },
    zipInput:          { backgroundColor: '#1f2937', color: '#f9fafb' },
    addressLine1Input: { backgroundColor: '#1f2937', color: '#f9fafb' },
    addressLine2Input: { backgroundColor: '#1f2937', color: '#f9fafb' },
    cityInput:         { backgroundColor: '#1f2937', color: '#f9fafb' },
    stateInput:        { backgroundColor: '#1f2937', color: '#f9fafb' },
  }}
  onComplete={handleSuccess}
/>

The bold-dark and glass-dark theme bundles already include AVS field styles tuned for dark surfaces.

Testing & Selectors

E2E tests can target AVS fields via stable data-testid attributes:

Fielddata-testid
Country dropdownflopay-country
Postal / ZIPflopay-zip
Street addressflopay-address-line1
Apt / suiteflopay-address-line2
Cityflopay-city
State / provinceflopay-state

Props Reference

FloPayCheckout / SplitCardForm

PropTypeDefaultDescription
enableAVSboolean | AVSFieldConfigfalseEnable AVS. true shows country + postal; an object enables per-field, per-country rules
avsLayout'row' | 'column''row'Layout for the country/postal pair
countrystring'US'Pre-filled country code (ISO 3166-1 alpha-2)
zipstringNot applicablePre-filled postal code
onCountryChange(country: string) => voidNot applicableFires when country changes
onZipChange(zip: string) => voidNot applicableFires when postal code changes

ButtonsLayoutStyles (AVS slots)

PropertyDescription
countrySelectCountry dropdown container
zipInputZIP / postcode input container
addressLine1InputStreet address input
addressLine2InputApt / suite input
cityInputCity input
stateInputState dropdown or text input

Stripe Radar Configuration

After enabling AVS in the SDK, configure Stripe Radar to act on the results:

  1. Go to Stripe DashboardRadarRules
  2. Add rules based on the AVS check outcome:
    • Block if ::addressPostalCodeCheck:: = 'fail': block when ZIP doesn't match
    • Block if ::addressLine1Check:: = 'fail': block when street address doesn't match (only meaningful when address_line_1 is collected)
    • Review if ::addressPostalCodeCheck:: = 'unavailable': review when the issuer doesn't support AVS

AVS coverage varies by issuer and country. Some banks return unavailable for legitimate cards. Blocking on unavailable may reject good payments from regions with limited AVS support.

Shared Helpers

import {
  // Types
  AVSFieldConfig,

  // Resolve / inspect
  resolveAVSConfig,
  isAVSFieldVisible,
  isAVSEnabled,

  // Country helpers
  getPostalCodeLabel,
  COUNTRY_OPTIONS,
  getCountryByCode,

  // State / province helpers
  US_STATES,
  CA_PROVINCES,
  getStateOptions,
  getStateLabel,
} from '@flopay/shared';

resolveAVSConfig(true);                // { country: true, postal_code: true }
resolveAVSConfig(false);               // null
resolveAVSConfig({ country: true });   // { country: true } (returned as-is)

isAVSFieldVisible(['US', 'CA'], 'us'); // true (case-insensitive)
isAVSFieldVisible(['US'], 'GB');       // false
isAVSFieldVisible(true, 'XX');         // true

isAVSEnabled(true);                    // true
isAVSEnabled(false);                   // false
isAVSEnabled({});                      // false (no fields configured)
isAVSEnabled({ country: true });       // true

Analytics

Every checkout records which AVS fields were actually shown (resolved against the buyer's country). The public session parameter types document the AVS exposure, checkout surface, and layout fields available when sessions are created server-side.

Next Steps

Authorize now and capture later

Use manual capture when you need to confirm that a customer's funds are available at checkout but collect them only after stock or fulfillment is confirmed. Upgrade every FloPay SDK package used by the checkout to FloPay SDK 1.4.20 or later before opting in.

Authorized funds are not captured funds. An authorized result confirms a temporary hold; it is not payment and must not be recorded as revenue. Do not fulfill the order until capture succeeds and your integration receives item.purchased. Schedule capture or cancellation before authorizationExpiresAt.

If you omit captureMethod, or set captureMethod: 'automatic', immediate capture remains the default. FloPay uses the existing authorize-and-capture checkout behavior and a successful payment reports succeeded.

1. Create an authorization-only checkout

Set captureMethod: 'manual' on an item-only card checkout. The option is supported by Node, browser, detached, inline, and React session creation.

Manual capture preserves the normal cardholder authentication journey. If the issuing bank requires 3-D Secure (3DS), the buyer completes that challenge before the SDK can return authorized. A decline or incomplete authentication never creates a usable authorization.

import { FloPayCheckout } from '@flopay/react';

export function Checkout() {
  return (
    <FloPayCheckout
      createSession={{
        clientId: 'your-client-id',
        captureMethod: 'manual',
        currency: 'GBP',
        products: [{ code: 'order_123', quantity: 1 }],
        account: {
          userId: 'customer_123',
          email: 'customer@example.com',
        },
        successUrl: '/order/accepted',
        cancelUrl: '/checkout',
      }}
      onComplete={(result) => {
        if (result.status === 'authorized') {
          saveAuthorizationOnYourServer({
            paymentId: result.paymentId,
            sessionId: result.sessionId,
            authorizationExpiresAt: result.authorizationExpiresAt,
          });
        }
      }}
    />
  );
}

After any required cardholder authentication, the terminal SDK result is:

{
  status: 'authorized',
  paymentId: '3c54b6ac-7ad5-4e56-9c2c-5a80c2ef40d0',
  sessionId: 'fd4475e4-dc19-4439-b830-c9c8f67a35e7',
  authorizationExpiresAt: '2026-08-11T14:30:00.000Z',
}

Persist paymentId and authorizationExpiresAt with your order. The payment id is a FloPay UUID, not a provider PaymentIntent id.

Authorization expiry

FloPay returns the provider-derived authorizationExpiresAt deadline for that payment. Do not hard-code a universal authorization window: network, card, account, and provider rules can differ. Capture before the returned timestamp, allow operational margin for retries, and treat payment.authorization_expired as terminal.

If the order will not proceed, release the hold rather than waiting for expiry:

Client Basic authentication uses the client UUID as the username and an active user-owned API token as the password. The placeholder below represents that encoded clientUuid:apiToken pair.

PUT /v1/payments/{paymentId}/cancel
Authorization: Basic {base64(clientUuid:apiToken)}
Idempotency-Key: order_123-cancel
Content-Type: application/json

{}

2. Capture from a trusted server

When the order is ready to fulfill, call the merchant-authenticated API from trusted server code. Capture is intentionally not an SDK or browser operation.

PUT /v1/payments/{paymentId}/capture
Authorization: Basic {base64(clientUuid:apiToken)}
Idempotency-Key: order_123-full-capture
Content-Type: application/json

{}

Use the paymentId returned by the authorized checkout. A successful response is the payment resource with status: 'succeeded'; that is FloPay's captured and paid state.

Keep one stable, nonblank Idempotency-Key for this logical capture. If the response is lost or times out, retry the same request body with the same key. Do not create a fresh key for an uncertain outcome.

If capture fails, FloPay returns 502 Bad Gateway and emits payment.capture_failed. No payment is collected, and the authorization remains inspectable. Keep the order unfulfilled, re-read the payment, then decide whether to retry the same request with the same key or cancel the hold. See the error table.

Fulfill only after capture

For this item-only flow, treat item.purchased as the canonical fulfillment event. FloPay emits it only after the full capture reaches the existing successful-purchase path. There is deliberately no separate payment.captured event. Make your webhook handler idempotent and deduplicate deliveries by eventId.

Supported boundary

The first release supports one later capture for the full amount of an eligible one-time card checkout.

CapabilityAvailability
Immediate card captureSupported and the default
One later full card captureSupported with captureMethod: 'manual'
Partial captureUnsupported
Incremental authorizationUnsupported
Multiple capturesUnsupported
Subscription captureUnsupported
Non-card capture, including PayPal and alternative payment methodsUnsupported
Dashboard capture as an integration workflowUnsupported; use the trusted-server API

Do not use checkoutMode (full, confirm, or auto) to select capture behavior. It controls checkout presentation and saved-method behavior; only captureMethod selects immediate or manual capture.

On this page