Documentation
Guides

Analytics and reporting

Instrument checkout, interpret subscription recovery reporting, and manage saved cards with clear privacy and server-side boundaries.

Analytics and reporting

Use FloPay's merchant-owned instrument feed to measure checkout behavior, interpret subscription recovery metrics, and use the payment-method APIs to support secure saved-card experiences without exposing sensitive provider data.

Who this is for

This guide is for analytics engineers, checkout developers, and backend teams that need funnel events, subscription recovery reporting, privacy-safe metrics, or customer-facing saved-card management.

Choose a reporting or card-management workflow

Use onInstrument when your application needs an allowlisted checkout event feed. Use the trusted-server payment-method flow when customers need to add, list, replace, or remove a saved card. Keep consent, identity resolution, and secret-bearing operations on the boundaries described below.

Instrument the checkout funnel

onInstrument gives your application a feed of checkout funnel and failure signals that you can forward to your own analytics destination. It answers the question backend outcomes cannot: not "how many payments succeeded", but "where did the buyers who never paid drop out".

Upgrade @flopay/react and @flopay/shared to FloPay SDK 1.7.0 or later before wiring it up.

What the feed is

The SDK maintains a large internal checkout taxonomy for its own diagnostics. onInstrument is not that taxonomy. It is a small, allowlisted projection of it onto a stable public contract: seven lifecycle names and one error name with four phases, and nothing else.

That distinction is the point of the feature. The internal taxonomy carries no compatibility guarantee and changes freely; the projection is versioned and will not change shape under you. Build your funnel on the projection.

This is not the Flo-owned telemetry surface. The telemetry prop controls diagnostics that FloPay collects for its own operations. onInstrument is a separate, merchant-owned feed that goes only where you send it. See Independence from telemetry.

Wiring it up

Pass onInstrument to FloPayCheckout. This is the typical integration: the component owns its own provider, so one callback covers the whole checkout.

Forwarding the feed to your analytics
import { FloPayCheckout } from '@flopay/react';
import type { FloInstrumentEvent } from '@flopay/react';

function forwardCheckoutInstrument(event: FloInstrumentEvent) {
  window.analytics?.track(event.name, {
    schemaVersion: event.schemaVersion,
    gateway: event.gateway,
    phase: event.name === 'checkout_error' ? event.phase : undefined,
  });
}

export function CheckoutPage({ sessionId }: { sessionId: string }) {
  return (
    <FloPayCheckout
      sessionId={sessionId}
      onInstrument={forwardCheckoutInstrument}
      onComplete={() => router.push('/success')}
    />
  );
}

If you compose the payment surfaces yourself rather than using FloPayCheckout, pass the same callback to FloPayProvider instead:

Provider-composed checkout
<FloPayProvider flopay={flopayPromise} onInstrument={forwardCheckoutInstrument}>
  <YourOwnCheckoutSurface />
</FloPayProvider>

Use one or the other, not both for the same checkout. FloPayCheckout already forwards the instruments raised by the provider it owns.

You do not need to guard your callback. The SDK invokes onInstrument defensively and swallows anything it throws, so a broken analytics client cannot break a buyer's checkout. Wrapping the body in your own try / catch is not required: though you may still want it if you would rather log the failure than lose it silently.

The event shape

Every event is a FloInstrumentEvent: a discriminated union on name:

type FloInstrumentEvent =
  | { schemaVersion: 1; gateway?: 'stripe' | 'paypal'; name: FloInstrumentLifecycleName }
  | {
      schemaVersion: 1;
      gateway?: 'stripe' | 'paypal';
      name: 'checkout_error';
      phase: FloInstrumentErrorPhase;
    };
FieldTypeAlways presentMeaning
schemaVersion1YesContract version. Currently always 1; also exported as FLO_INSTRUMENT_SCHEMA_VERSION.
namelifecycle name or 'checkout_error'YesThe signal. See the catalog.
phaseFloInstrumentErrorPhaseOnly on checkout_errorWhich stage the checkout failed in.
gateway'stripe' | 'paypal'NoThe payment provider this event is attributable to.

gateway is optional and deliberately narrow. It is set only to stripe or paypal, and only when the event is attributable to that provider: hosted-vault events may omit it, and so may events raised before a gateway has been selected. Treat it as a nullable dimension in your analytics; do not write code that assumes it is present.

Because the union is discriminated, narrow on name before reading phase:

function describe(event: FloInstrumentEvent): string {
  if (event.name === 'checkout_error') {
    return `failed at ${event.phase}`; // `phase` only narrows here
  }
  return event.name;
}

The catalog

Seven lifecycle names describe progress through the checkout:

NameWhat it means for the buyer
checkout_mountThe checkout surface entered the page. The buyer has arrived.
sdk_loadedThe payment SDK finished initializing and a gateway is ready.
form_renderedThe payment form (hosted card fields or vault widget) is on screen and usable.
card_expandedThe buyer opened the card surface: in layout="buttons", they chose "Credit / Debit Card" over a wallet.
tokenizeThe buyer submitted their details and tokenization began. This is the first buyer-initiated commitment.
process_attemptA payment attempt was sent for processing.
3ds_challengeThe issuing bank required a 3-D Secure challenge, so the buyer was handed off to authenticate.

checkout_error carries a phase naming the stage that failed:

PhaseWhat failed
session_createThe checkout session could not be created, so no form was ever shown.
sdk_loadThe SDK or a gateway failed to initialize. The buyer saw a broken or empty checkout.
processA payment attempt failed during processing.
walletsA wallet or non-card payment attempt failed while creating its intent.

A checkout_error is a technical failure of the checkout, not a card decline. A buyer whose card is declined by their bank produces a process_attempt and a decline through onDecline: that is a working checkout with a negative outcome, and you generally want to count it separately from a checkout that broke.

Asserting catalog parity in a test

@flopay/shared exports the whole catalog as FLO_INSTRUMENT_CATALOG specifically so you can pin your funnel to it. Assert that every catalog entry has a home in your analytics, and a future SDK release that adds a signal fails your test rather than silently dropping out of your dashboard:

funnel.test.ts
import { FLO_INSTRUMENT_CATALOG } from '@flopay/shared';
import { FUNNEL_STEPS } from './funnel';

it('handles every instrument the SDK can emit', () => {
  const catalogKeys = FLO_INSTRUMENT_CATALOG.map((entry) =>
    'phase' in entry ? `${entry.name}:${entry.phase}` : entry.name,
  );

  expect(Object.keys(FUNNEL_STEPS).sort()).toEqual(catalogKeys.sort());
});

Error phases are separate catalog entries rather than a nested list, so this parity check covers each arm of your failure attribution independently.

Building a funnel

The catalog is ordered, but the events are not a strict sequence: a checkout can end at any step, and some steps repeat.

Once vs. repeatable. checkout_mount, sdk_loaded, form_rendered, and card_expanded arrive at most once per logical checkout. tokenize, process_attempt, and 3ds_challenge may arrive once per attempt, so a buyer who retries after a decline produces several of each. Counting raw occurrences will over-count retries as separate checkouts and understate your conversion rate.

A "logical checkout" is one mounted checkout running one session: the SDK keys deduplication on the sessionId (or on the inline createSession draft) currently in play. Swapping in a different session resets the once-only set, so the buyer's second session reports its own checkout_mount. Unmounting and remounting the component starts over too: if your page can do that, deduplicate on your own session identifier as well.

So aggregate per session, reducing the repeatable names to a boolean reached-or-not, and keep the attempt count as its own metric:

Reducing the feed to a funnel row
import type { FloInstrumentEvent } from '@flopay/react';

type FunnelRow = {
  reached: Set<FloInstrumentEvent['name']>;
  attempts: number;
  challenges: number;
  failedAt?: string;
};

function record(row: FunnelRow, event: FloInstrumentEvent): FunnelRow {
  row.reached.add(event.name);

  if (event.name === 'process_attempt') row.attempts += 1;
  if (event.name === '3ds_challenge') row.challenges += 1;
  if (event.name === 'checkout_error') row.failedAt = event.phase;

  return row;
}

Then read the funnel off reached, which is idempotent regardless of how many attempts the buyer made:

StepConditionWhat a drop-off here tells you
Arrivedcheckout_mountNot applicable
Loadedsdk_loadedYour buyers cannot reach the SDK. Check for a checkout_error at session_create or sdk_load.
Saw the formform_renderedThe SDK initialized but the form never painted.
Started payingtokenizeBuyers saw the form and left. This is a pricing, trust, or UX problem, not a technical one.
Submittedprocess_attemptTokenization is failing: usually invalid card entry.
Completedyour own onComplete handlerNot applicable

attempts > 1 is a retry signal: those buyers were declined at least once and tried again. Segmenting your completion rate by attempts separates "declined and gave up" from "declined and recovered", which are very different problems. Likewise challenges > 0 isolates the buyers who had to leave for a bank authentication step, historically the largest single source of late-funnel abandonment.

For layout="buttons", card_expanded splits the funnel by method: buyers with card_expanded chose the card path, and those without it went to a wallet or PayPal. Combine it with gateway to attribute the outcome.

The feed deliberately does not include a success event: onInstrument covers the path to payment, and the payment outcome itself is already yours through onComplete and your backend records. Join them on your own session or order identifier.

Independence from telemetry

telemetry={false} does not disable onInstrument. The two are unrelated. telemetry opts out of the Flo-owned diagnostics that FloPay collects for its own operations; onInstrument is a merchant-owned feed that exists only in your page and goes only where your callback sends it. Turning off Flo's telemetry does not turn off yours, and never wiring onInstrument does not turn off Flo's.

If you want no merchant analytics, omit onInstrument. Nothing is emitted when there is no callback to receive it.

Privacy guarantees

The feed is an allowlist, not a redaction pass. An event can only ever be assembled from the fields documented above, so it structurally cannot carry anything else. It never contains:

  • Card numbers, expiry dates, CVCs, or any other card detail
  • Payment tokens, nonces, client secrets, or publishable keys
  • Provider object identifiers: no Stripe payment intent, customer, or payment method ids, no PayPal order ids
  • Buyer identity: no email, name, address, user id, or IP
  • Amounts, currencies, product codes, or cart contents
  • Free-text error messages from any provider

That is why checkout_error reports a coarse phase rather than a message or a provider error code: a phase cannot leak. It also means the feed is safe to forward directly to a third-party analytics destination without a scrubbing step in between.

If you need the detail the feed withholds: decline codes, amounts, the buyer: take it from onDecline, onError, or your own backend records, where you control the destination.

Reference

Subscription recovery reporting

dunningRecovery is an additive strict subset of the existing successful rebill totals. recoveredRebillCount is already included in rebillCount, and recoveredRebillRevenue is already included in rebillRevenue. Do not add the recovered values to those totals again.

The Dashboard overview shows this contribution in the existing Rebill Sales and Rebill Revenue cards. For API reporting, request GET /v1/subscriptions/stats?from={instant}&to={instant} from a trusted server. Use the same client, period, and reporting currency when comparing totals with the recovered subset. An empty period returns zero recovered count and revenue rather than omitting dunningRecovery.

dunningTimingPerformance compares baseline and optimized campaign cohorts by attempts, successful recoveries, recovered revenue, and average time to recovery. Treat it as an aggregate timing comparison, not a campaign or customer feed.

Read Recover failed rebills automatically for attribution rules, exact retry schedules, lifecycle state, timing evidence, and webhook order.

Manage saved cards safely

Saved-card management deliberately splits authority:

  • your trusted server resolves the customer, creates setup sessions, lists cards, and removes cards using merchant credentials;
  • the browser receives only a setup sessionId and nonce, then uses FloPay's hosted card form;
  • neither FloPay's SDK nor your browser receives a user-owned API token, a reusable provider credential, a PAN, or a CVC.

Client Basic authentication uses the client UUID as the username and an active user-owned API token as the password. In the examples below, {base64(clientUuid:apiToken)} represents that encoded pair.

Before starting setup, show the customer what saving the card means and obtain affirmative consent for future off-session charges under your terms. Retain the consent text/version, customer identity, timestamp, and your business purpose. The authenticated FloPay setup session and provider authentication outcome are technical evidence, but they do not replace your consent record.

2. Resolve your customer id

The setup and list APIs use FloPay's customer UUID. If you store your own customer id, resolve it from your trusted server:

GET /v1/users?clientUserId=customer_123
Authorization: Basic {base64(clientUuid:apiToken)}

Read the first exact match's id from the paginated data array. An empty array means there is no customer linked to this merchant. Never accept a FloPay user UUID supplied directly by an untrusted browser.

3. Create a setup session

Create the no-charge session from the same trusted server:

POST /v1/checkouts/sessions/setup
Authorization: Basic {base64(clientUuid:apiToken)}
Content-Type: application/json

{
  "userId": "3b4d9a11-0ce8-4a88-9cb1-b4f43d03d2b7",
  "successUrl": "https://merchant.example/account/cards/added",
  "cancelUrl": "https://merchant.example/account/cards"
}

userId is the resolved FloPay UUID. The customer must be linked to the authenticated merchant. Unknown and cross-merchant customers both return 404 Not Found before FloPay contacts a provider.

Return only the response's opaque sessionId and nonce to the browser. Do not forward the merchant Authorization header.

4. Mount the hosted setup form

Mount the setup-specific component from @flopay/react. It rejects purchase sessions and never displays payment-success wording:

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

export function AddCard({ sessionId, nonce }) {
  return (
    <FloPayCardSetup
      sessionId={sessionId}
      nonce={nonce}
      onComplete={(card) => {
        notifyYourServerThatSetupCompleted({
          sessionId,
          paymentMethodId: card.paymentMethodId,
        });
      }}
      onDecline={(decline) => showCardSetupDecline(decline)}
      onCancel={() => showCardSetupCancelled()}
    />
  );
}

The component mounts FloPay's hosted vault form and reports success only after provider verification, including any required 3DS step. A declined, abandoned, or authentication-incomplete setup never produces an active card and never changes an existing usable card.

Do not treat mount, submit, or action_required as success. A safe replace flow requires the exact payment-method id produced by the verified setup. If the released terminal result does not include that id, stop the replacement and reconcile it through support; never select “the newest row” because concurrent setup on another device can make that heuristic delete the wrong card.

5. Recognize saved cards safely

List only the customer's active methods from your trusted server:

GET /v1/payment-methods?userUuid=3b4d9a11-0ce8-4a88-9cb1-b4f43d03d2b7&status[eq]=active
Authorization: Basic {base64(clientUuid:apiToken)}

Display brand, lastFour, expiryMonth, and expiryYear. Keep the FloPay method id on your server for later removal; do not expose provider or vault identifiers. Customers may hold multiple active cards.

<li key={card.id}>
  {card.brand} ending {card.lastFour}: expires {card.expiryMonth}/{card.expiryYear}
</li>

6. Replace a card

Replace is composition, not a separate endpoint: complete setup for the new card, confirm that exact method is active, then call DELETE /v1/payment-methods/{oldMethodId} for the old card. FloPay reassigns eligible Stripe subscription/customer funding to the most recently charged or setup-verified replacement and verifies the reassignment before deleting the old method.

If setup fails, leave the old card unchanged. If deletion returns a conflict or gateway error, also leave the old card visible and explain the next action; never hide it optimistically.

7. Remove a card

DELETE /v1/payment-methods/{paymentMethodId}
Authorization: Basic {base64(clientUuid:apiToken)}
ResultCustomer-facing handling
204 No ContentRemoval finished or an owned deletion already finished. Refresh the list.
404 payment_method_not_foundShow a generic “card was not found” message. Missing and cross-merchant methods are intentionally indistinguishable.
409 payment_method_has_active_paymentAsk the customer to wait for the active payment attempt to finish.
409 payment_method_in_useAsk the customer to add and verify another card before removing this one.
409 payment_method_reassignment_requires_payer_actionPayPal funding needs an interactive authorization; do not imply a card replacement can resolve it.
502 payment_method_deletion_failedKeep the method visible. Follow retrySafe and resolution; retrying the same DELETE is idempotent.

The API deletes provider/vault artifacts through durable checkpoints and retains only a sanitised tombstone. Payment, refund, and approved audit history remain intact.

Security checklist

  • Run user resolution, setup-session creation, listing, and deletion only on your trusted server.
  • Show only brand, last four, and expiry. Never log or return raw card data.
  • Treat every 404 alike and do not probe whether another merchant owns an id.
  • Wait for a verified active method before replacing or removing the old one.
  • Keep consent evidence for future off-session use.

See the payment methods REST reference for the complete response and error contract.

On this page