Checkout analytics
Forward a versioned, privacy-safe checkout funnel and failure feed to your own analytics with the onInstrument callback.
Checkout analytics
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.
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:
<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;
};| Field | Type | Always present | Meaning |
|---|---|---|---|
schemaVersion | 1 | Yes | Contract version. Currently always 1; also exported as FLO_INSTRUMENT_SCHEMA_VERSION. |
name | lifecycle name or 'checkout_error' | Yes | The signal. See the catalog. |
phase | FloInstrumentErrorPhase | Only on checkout_error | Which stage the checkout failed in. |
gateway | 'stripe' | 'paypal' | No | The 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:
| Name | What it means for the buyer |
|---|---|
checkout_mount | The checkout surface entered the page. The buyer has arrived. |
sdk_loaded | The payment SDK finished initialising and a gateway is ready. |
form_rendered | The payment form (hosted card fields or vault widget) is on screen and usable. |
card_expanded | The buyer opened the card surface — in layout="buttons", they chose "Credit / Debit Card" over a wallet. |
tokenize | The buyer submitted their details and tokenisation began. This is the first buyer-initiated commitment. |
process_attempt | A payment attempt was sent for processing. |
3ds_challenge | The 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:
| Phase | What failed |
|---|---|
session_create | The checkout session could not be created, so no form was ever shown. |
sdk_load | The SDK or a gateway failed to initialise. The buyer saw a broken or empty checkout. |
process | A payment attempt failed during processing. |
wallets | A 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:
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:
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:
| Step | Condition | What a drop-off here tells you |
|---|---|---|
| Arrived | checkout_mount | — |
| Loaded | sdk_loaded | Your buyers cannot reach the SDK. Check for a checkout_error at session_create or sdk_load. |
| Saw the form | form_rendered | The SDK initialised but the form never painted. |
| Started paying | tokenize | Buyers saw the form and left. This is a pricing, trust, or UX problem, not a technical one. |
| Submitted | process_attempt | Tokenisation is failing — usually invalid card entry. |
| Completed | your own onComplete handler | — |
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.