Events
The Kit ships a typed event bus at @stridge/kit/events. Every orchestrator milestone (quote resolved, submission broadcast, settlement succeeded), every relevant UI interaction (dialog opened, method tile clicked, amount typed), and every provider-wide signal (support entry opened, terms link clicked) fires through it. Subscribers compose with the rest of the Kit and stay in lockstep with the FSM.
"use client"
import { useStridgeFlowEvents } from "@stridge/kit/events"
export function Analytics() {
useStridgeFlowEvents((event) => {
if (event.type === "deposit.quote.resolved") {
analytics.track("deposit_quote", {
flowId: event.flowId,
quote: event.payload.quote,
})
}
if (event.type === "withdraw.settlement.succeeded") {
analytics.track("withdraw_completed", { flowId: event.flowId })
}
})
return null
}Drop <Analytics /> anywhere inside the provider — the hook reads the active bus from context and re-subscribes when its handler changes.
Why a bus, not callbacks
Lifecycle events do not live on <DepositDialog /> / <WithdrawDialog />. The dialogs accept no onOpen / onSuccess / onError callback props. That's deliberate:
- Analytics, behavioural reactions, and observability all want the same firehose; duplicating five callbacks across every dialog instance burns prop surface and invites drift.
- Multiple subscribers can listen to the same event without race conditions — the dialog has no idea who's listening.
- Compound integrations don't have a dialog to attach callbacks to. The bus works the same way for compound and headless consumers.
The dialog's onError is a separate concern — that's the React error boundary's crash hook (an actual throw), not a flow event.
Naming convention
Every event type is a lowercase dot-segmented string of the form <flow>.<noun>.<verb-past>:
deposit.quote.resolvedwithdraw.settlement.succeededdeposit.method.clickedkit.support.clicked
Same noun.verb across flows means the same semantic concept — deposit.quote.resolved ↔ withdraw.quote.resolved. Verbs are past tense (resolved, not resolve), since events describe things that already happened.
Two tiers, four flow families
The bus has two delivery tiers (flow and ui) and four flow families (deposit, withdraw, activity, kit). They compose freely.
import type { StridgeEvent, FlowEvent, UiEvent } from "@stridge/kit/events"Tier (event.tier) | Examples |
|---|---|
"flow" | Orchestrator / driver domain milestones: deposit.quote.resolved, deposit.submission.broadcast, withdraw.settlement.succeeded, deposit.cancelled. Fire whether or not the user is looking at the dialog, as long as the orchestrator is mounted. |
"ui" | Dialog lifecycle and widget interactions: deposit.opened, deposit.method.clicked, withdraw.amount.changed, kit.support.clicked. Fire only when the relevant kit-rendered component is mounted. |
Flow family (event.flow) | Examples |
|---|---|
"deposit" | Every deposit.* event. |
"withdraw" | Every withdraw.* event. |
"activity" | Every activity.* event. The read-only activity surface is UI-only, so this family has no flow-tier events — subscribe via useStridgeEvents. |
"kit" | Provider-wide UI signals not tied to a flow session: kit.support.clicked, kit.terms.clicked. |
Every event carries:
interface StridgeEventBase {
type: string // "deposit.quote.resolved"
flow: "deposit" | "withdraw" | "activity" | "kit"
tier: "flow" | "ui"
timestamp: number // Date.now() at emit
flowId: string | null // session id for deposit/withdraw/activity, null for kit-family
payload: { /* event-specific */ }
metadata: Record<string, unknown> // frozen at <flow>.opened, always present
}flowId is minted at <flow>.opened, stable across every subsequent event in the same session, and retired after the terminal event (*.settlement.succeeded / *.settlement.failed / *.cancelled, or activity.closed for the read-only activity surface, which has no settlement). kit.* events carry flowId: null — they have no flow session to belong to.
Hooks
useStridgeFlowEvents(handler)
Subscribe to flow-tier events only — orchestrator milestones. The most common entry point for analytics, conversion funnels, and side-effects on settlement.
useStridgeFlowEvents((event) => {
switch (event.type) {
case "deposit.quote.resolved": break
case "deposit.submission.broadcast": break
case "deposit.settlement.succeeded": break
case "deposit.settlement.failed": break
case "deposit.cancelled": break
case "withdraw.quote.resolved": break
case "withdraw.submission.broadcast": break
case "withdraw.settlement.succeeded": break
case "withdraw.settlement.failed": break
case "withdraw.cancelled": break
// …and more — see "Event-type catalog" below
}
})UI-tier events (*.opened, *.closed, *.method.clicked, …) never leak in.
useStridgeFlowEvent(type, handler)
Single-event narrow variant. Use when you only care about one transition.
useStridgeFlowEvent("withdraw.settlement.succeeded", (event) => {
// `event.payload` is narrowed to the settlement success shape.
showToast(`Withdraw ${event.flowId} confirmed.`)
})useStridgeEvents(handler, options?) / useStridgeEvent(type, handler, options?)
Same shape as the flow-only versions, but subscribe to every event (flow + UI, every family). Reach for these when you want full observability — capturing every user interaction inside a session-replay tool, or distinguishing user-driven from system-driven activity.
Both accept an optional { replayOpenSession }. When true, a handler that mounts mid-flow (a code-split or Suspense-deferred integration) immediately receives the active <flow>.opened event(s), so it recovers the session start — and the flowId + frozen metadata minted there — instead of silently missing it:
useStridgeEvents((event) => {
analytics.track(event.type, { flowId: event.flowId, ...event.metadata })
}, { replayOpenSession: true })Only the <flow>.opened event is buffered (one per live flow, released at the terminal event) — never quote / settlement / interaction events. The replayed envelope is identical to the one live subscribers saw; it is not flagged as a replay. Because opened is tier: "ui", replay reaches the firehose hooks (useStridgeEvents) and the typed useStridgeEvent("<flow>.opened", …) — the flow-only hooks never carry it.
useStridgeEventBus()
Imperative access to the bus — for subscribing outside React's render cycle (e.g. from a side-effect store or a non-React analytics SDK). Returns null when no provider is mounted, so guard with if (!bus) return.
const bus = useStridgeEventBus()
useEffect(() => {
if (!bus) return
return bus.subscribeToFlowEvent("deposit.quote.resolved", (event) => {
mixpanel.track("quote_resolved", { ms: Date.now() - event.timestamp, ...event.metadata })
})
}, [bus])Bus methods — each returns an unsubscribe function:
subscribeToFlow(handler)/subscribeToFlowEvent(type, handler)— flow-tier only (headless-safe).subscribeToAll(handler, options?)/subscribeToEvent(type, handler, options?)— the firehose; accept{ replayOpenSession }.
Unlike the hooks, these subscriptions are not auto-cleaned on unmount — call the returned function yourself.
useCurrentFlowId(flow)
Returns the active flow's flowId or null. Handy for correlating a non-bus side-effect (an out-of-band fetch) with the bus events from the same session.
const flowId = useCurrentFlowId("deposit") // "deposit" | "withdraw"Common patterns
Conversion funnel
Flow-tier events for the orchestrator side; pair with useStridgeEvent for any UI signals you want to capture (open, method click, picker pick).
// Flow-tier milestones — the orchestrator's story.
useStridgeFlowEvents((event) => {
if (event.type === "deposit.quote.resolved") posthog.capture("deposit_quote")
if (event.type === "deposit.submission.broadcast") posthog.capture("deposit_broadcast")
if (event.type === "deposit.settlement.succeeded") posthog.capture("deposit_done")
if (event.type === "deposit.settlement.failed") posthog.capture("deposit_failed", { reason: event.payload.reason })
if (event.type === "deposit.cancelled") posthog.capture("deposit_cancelled", { phase: event.payload.phase })
})
// UI-tier signals — the user's clicks. Use `useStridgeEvent` for the typed narrowing.
useStridgeEvent("deposit.opened", () => posthog.capture("deposit_opened"))
useStridgeEvent("deposit.method.clicked", (event) => posthog.capture("deposit_method", { method: event.payload.method }))Refresh user data after settlement
useStridgeFlowEvent("deposit.settlement.succeeded", () => {
queryClient.invalidateQueries({ queryKey: ["balance"] })
})Cross-system correlation
useStridgeFlowEvent("withdraw.submission.broadcast", (event) => {
// Your backend recorded a withdraw broadcast keyed by flowId.
yourBackend.attachStridgeFlow(event.flowId, event.payload.tx)
})Session replay
import { redactEvent } from "@stridge/kit/events"
useStridgeEvents((event) => {
// redactEvent drops the debug-only `cause` and masks wallet addresses before
// the payload leaves for a third party — see "Observability" below.
const safe = redactEvent(event)
sessionReplay.track(safe.type, {
tier: safe.tier,
flow: safe.flow,
flowId: safe.flowId,
payload: safe.payload,
metadata: safe.metadata,
})
})Observability
The bus is the Kit's telemetry seam: it ships no analytics or error-tracking vendor of its own, so nothing lands in your bundle until you wire one up. Two paths, with different rules.
Error tracking (Sentry, Datadog, …)
Every *.failed event carries the shape your error tracker wants:
interface FailurePayloadBase {
code: FailureCode // bounded enum — clean error-grouping / fingerprinting
reason: string // localised, user-safe message
retryable: boolean // true for transient (network/timeout/rate_limited)
cause?: unknown // the raw Error (with stack) — debug only, never rendered
}code is a closed enum (network, timeout, rate_limited, unauthorized, insufficient_funds, invalid_input, quote_expired, tx_reverted, tx_dropped, settlement_lost, internal) — bounded cardinality is what keeps your error dashboard groupable and your metrics tags from exploding. cause is the original thrown Error with its stack, threaded through for exactly this purpose: report it directly.
import { useStridgeFlowEvents, KIT_VERSION } from "@stridge/kit/events"
// Tag the Kit release once, so a regression introduced by an upgrade is bisectable.
Sentry.setTag("stridge_kit_version", KIT_VERSION)
useStridgeFlowEvents((event) => {
if (event.type.endsWith(".failed")) {
Sentry.captureException(event.payload.cause ?? new Error(event.payload.reason), {
tags: { stridge_code: event.payload.code, stridge_flow: event.flow },
contexts: { stridge: { flowId: event.flowId, retryable: event.payload.retryable } },
})
}
})*.failed events all extend FailurePayloadBase, so the single endsWith(".failed") guard above covers quote, submission, and settlement failures across both flows. Note: settlement failures decoded from a driver failureKind enum carry no JS error, so cause is undefined there — fall back to reason as shown.
Do not redact on this path — you want the cause.
Forwarding to product analytics (Mixpanel, Amplitude, GA, …)
Two rules when piping events to a third-party analytics backend:
- Filter by
tier. Theuitier includes per-keystroke events (*.amount.changed,*.recipient.changed); forwarding them verbatim blows your event quota and adds no funnel value. Subscribe withuseStridgeFlowEvents(flow-tier only), or branch onevent.tier. - Redact first. Run each event through
redactEventbefore it leaves your app. It returns a forwarding-safe deep copy that drops the debug-onlycauseand masks EVM addresses (0x1234…cdef), so no wallet address or raw error reaches a third party.
import { redactEvent } from "@stridge/kit/events"
useStridgeFlowEvents((event) => {
const safe = redactEvent(event)
analytics.track(safe.type, { flowId: safe.flowId, ...safe.payload, ...safe.metadata })
})redactEvent(event, options?) takes { maskAddresses?: boolean; dropCause?: boolean } (both default true). It never mutates the input and leaves your own metadata untouched — you supplied it, so you own its privacy posture.
What's sensitive
| Field | In analytics forwarding | Why |
|---|---|---|
payload.cause | Dropped by redactEvent | Raw Error / Response — can carry request/response bodies and arbitrary nested data. Keep it only on the error-tracking path. |
| EVM addresses in payload (recipients, settlement parties) | Masked by redactEvent | PII under most privacy regimes once it reaches a third party. 64-hex tx hashes are left intact. |
metadata | Untouched | Consumer-supplied — scrub at the source if it holds anything sensitive. |
amounts, token symbols, chain ids, flowId, timestamp | Kept | Drive funnels; not PII on their own. |
Late-mounting integrations
If your analytics or replay integration is code-split or Suspense-deferred and may mount after a flow already opened, pass { replayOpenSession: true } (see Hooks) so it still anchors to the session start.
Event-type catalog
Every event type is exported under the FlowEvent, UiEvent, and StridgeEvent discriminated unions in @stridge/kit/events. Narrowing on event.type gives a fully-typed event.payload — your editor surfaces every available field without a lookup. The full exhaustive event surface, 1:1 with the kit's typed exports:
Deposit · flow tier — 12 events
Orchestrator and driver milestones. Fire while the orchestrator is mounted, whether or not a dialog screen is visible.
| Type | Payload | What happened |
|---|---|---|
deposit.quote.requested | { input: { asset, amount } } | Orchestrator asked the driver for a quote — first request or a regeneration after expiry / signature decline. |
deposit.quote.resolved | { quote: QuotePayload } | Driver returned a successful quote; orchestrator transitioned to ready. Reflect-and-render is safe. |
deposit.quote.failed | FailurePayloadBase | Quote fetch failed. Standard failure payload — same shape across every *.failed event. |
deposit.quote.expired | {} | Active quote went stale (TTL elapsed) before submission; orchestrator silently regenerates. Next event is typically deposit.quote.resolved. |
deposit.submission.broadcast | { tx: TxRef } | Wallet broadcast the deposit transaction; orchestrator transitioned into processing. Settlement polling started. |
deposit.submission.declined | {} | User refused the wallet signature prompt. Orchestrator regenerates the quote and lands the user back on the confirm step. |
deposit.submission.failed | FailurePayloadBase | Submission attempted but couldn't complete — network failure, backend rejection, broken wallet provider. |
deposit.settlement.resumed | { settlement: SettlementPendingPayload } | Background probe surfaced a pre-existing pending deposit on flow start. Deposit-only — withdraw has no equivalent. |
deposit.settlement.progressed | { settlement: SettlementPendingPayload } | Progressive pending update — confirmations advancing, partial fill credited. Fires once per update. |
deposit.settlement.succeeded | { settlement: SettlementSuccessPayload } | Terminal success. Closes the session; flowId retired after this event. |
deposit.settlement.failed | DepositSettlementFailurePayload (extends FailurePayloadBase with kind: SettlementFailureKind and optional txHash) | Terminal failure — driver-classified or FSM-escalated. |
deposit.cancelled | { phase: "quoting" | "confirming" | "submitting" | "processing" | "unknown" } | Flow ended before reaching a terminal state. payload.phase powers drop-off funnels without parsing step names. |
Deposit · UI tier — 14 events
Dialog lifecycle and widget interactions. Fire only when the relevant kit-rendered component is mounted.
| Type | Payload | What happened |
|---|---|---|
deposit.opened | { input: ResolvedOpenInput } | Dialog transitioned from closed to any open step. Mints the flowId and snapshots metadata. |
deposit.closed | { atStep: DepositStateName } | Dialog returned to closed. payload.atStep is the step at close time. |
deposit.step.changed | { from: DepositStateName; to: DepositStateName } | Logical step transition including back-presses. Same-step ctx shifts don't fire this. |
deposit.method.clicked | { method: "wallet" | "transfer" } | User picked a deposit method tile on the entry step. Fires on commit (picking advances the FSM). |
deposit.asset.clicked | { asset: BalanceItemPayload } | User picked an asset row on the AssetPicker step. |
deposit.amount.changed | { raw: string; numeric: number | null } | User typed in the amount field. Per keystroke, no debounce. |
deposit.max.clicked | {} | User clicked the Max preset in AmountEntry. |
deposit.amount.submitted | { amount: number } | User submitted the AmountEntry step. Next event is typically deposit.quote.requested. |
deposit.confirm.clicked | {} | User clicked Confirm on the ConfirmDeposit step. Fires unconditionally on click — even when disabled — so analytics can observe intent. |
deposit.submission.confirmed | {} | FSM transitioned to the submitting phase — the wallet prompt is about to fire. The FSM-validity twin of deposit.confirm.clicked. |
deposit.back.clicked | { fromStep: DepositStateName } | User clicked the Back chevron in the dialog header on a non-root step. Deposit-only. |
deposit.transfer.token.changed | { token: TransferCryptoToken } | Inside TransferCrypto, user picked a different source token. Changes the displayed address; doesn't advance the FSM. |
deposit.transfer.chain.changed | { chain: TransferCryptoChain } | Inside TransferCrypto, user picked a different source chain. |
deposit.transfer.address.copied | { address: string } | User clicked the copy-address button in TransferCrypto. |
Withdraw · flow tier — 11 events
Mirrors deposit one-to-one apart from the deposit-only settlement.resumed.
| Type | Payload | What happened |
|---|---|---|
withdraw.quote.requested | { input: WithdrawQuoteRequestInput } | Orchestrator asked the driver for a withdrawal quote. |
withdraw.quote.resolved | { quote: WithdrawalQuotePayload } | Driver returned a successful quote; orchestrator transitioned to ready. |
withdraw.quote.failed | FailurePayloadBase | Quote fetch failed. |
withdraw.quote.expired | {} | Active quote went stale before submission; orchestrator silently regenerates. |
withdraw.submission.broadcast | { tx: TxRef } | Host-broadcast transaction landed; orchestrator transitioned into inProgress. |
withdraw.submission.declined | {} | Host's WithdrawSubmitCallback called actions.decline() — soft-fail; the dialog offers retry. |
withdraw.submission.failed | FailurePayloadBase | Submission failed — broadcast error or host actions.fail(). |
withdraw.settlement.progressed | { settlement } | Settlement entity emitted a pending update. |
withdraw.settlement.succeeded | { settlement } | Terminal success. |
withdraw.settlement.failed | WithdrawSettlementFailurePayload | Terminal failure. |
withdraw.cancelled | { phase: "quoting" | "confirming" | "submitting" | "processing" | "unknown" } | Flow ended before reaching a terminal state. |
Withdraw · UI tier — 11 events
| Type | Payload | What happened |
|---|---|---|
withdraw.opened | {} | Dialog transitioned from closed to the form step. Mints the flowId and snapshots metadata. |
withdraw.closed | { atStep: WithdrawStateName } | Dialog returned to closed. |
withdraw.step.changed | { from: WithdrawStateName; to: WithdrawStateName } | Logical step transition. |
withdraw.recipient.changed | { value: string } | User typed in the recipient field. Per keystroke, no debounce. |
withdraw.amount.changed | { raw: string; numeric: number | null } | User typed in the amount field. |
withdraw.max.clicked | {} | User clicked the Max pill in the amount field. |
withdraw.receive.token.changed | { token: ReceiveTokenOptionPayload } | User picked a different receive token in the form. |
withdraw.receive.chain.changed | { chain: ReceiveChainPayload } | User picked a different receive chain in the form. |
withdraw.submit.clicked | {} | User clicked the form's submit button. UI signal; FSM commitment lands at withdraw.submission.confirmed. |
withdraw.submission.confirmed | {} | Form submission triggered the orchestrator transition to submitting. Fires just before the host's WithdrawSubmitCallback runs. |
withdraw.breakdown.clicked | { open: boolean } | User toggled the breakdown card. Withdraw-only — deposit has no breakdown card today. |
Activity · UI tier — 4 events
The standalone activity surface is a read-only settlement-history viewer — it has no quote, submission, or settlement of its own, so it emits no flow-tier events. All four are tier: "ui"; subscribe via useStridgeEvents (the flow-only useStridgeFlowEvents never sees them). The session flowId is minted at activity.opened and retired at activity.closed.
| Type | Payload | What happened |
|---|---|---|
activity.opened | { atStep: ActivityStateName } | Surface opened. atStep is "activityList" for open(), or "activityDetail" for a resolved direct-entry (open({ settlementId }) / open({ txHash })). Mints the flowId and snapshots metadata. |
activity.closed | { atStep: ActivityStateName } | Surface returned to closed. payload.atStep is the step at close time. Terminal event — flowId retired after this. |
activity.step.changed | { from: ActivityStateName; to: ActivityStateName } | List ↔ detail navigation (including back-presses), bracketed by the "closed" pseudo-step on open / close. Mirrors deposit.step.changed / withdraw.step.changed. |
activity.settlement.selected | { settlementId: string; via: "list" | "direct" } | A receipt's detail view was opened — by tapping a list row (via: "list") or by imperative direct-entry (via: "direct"). Fires on every entry into the detail step, including a pivot from one receipt straight to another. |
Kit family · UI tier — 2 events
Provider-wide signals that don't belong to a flow session. flowId is null.
| Type | Payload | What happened |
|---|---|---|
kit.support.clicked | { context: SupportOpenContext } | Customer clicked the in-dialog "Get help" affordance. context carries which screen / failure triggered the click. |
kit.terms.clicked | { context: TermsSelectContext } | Customer clicked the Terms link. context carries which terms surface fired (e.g. the agreement footer vs the inline disclaimer). |
Type discipline
*.failedevents all extend the sharedFailurePayloadBase({ code, reason, retryable, cause? }) so a generic error handler (if (event.type.endsWith(".failed"))) works without per-event branching.codeis a bounded enum andcausecarries the rawError— see Observability for the error-tracking recipe.deposit.settlement.failedandwithdraw.settlement.failedextend that base with settlement-specific fields (kind, optionaltxHash) for richer triage.flowIdis a non-nullstringfor everydeposit.*,withdraw.*, andactivity.*event;nullfor everykit.*event.- The deposit and withdraw flow-tier catalogues are symmetric apart from the deposit-only
deposit.settlement.resumed(background probe for pre-existing pending deposits). - The
activity.*family is UI-only (no flow-tier variants) because the surface is read-only — it has no quote / submission / settlement to report.useStridgeFlowEventsnever sees it; reach foruseStridgeEvents. - Payload shapes referenced above (
QuotePayload,SettlementPendingPayload,SettlementSuccessPayload,BalanceItemPayload,TxRef,ResolvedOpenInput,DepositStateName,WithdrawStateName,ActivityStateName,TransferCryptoToken,TransferCryptoChain,ReceiveTokenOptionPayload,ReceiveChainPayload,SupportOpenContext,TermsSelectContext) all re-export from@stridge/kit/types— hover the event union in your editor for the full field surface.
Metadata correlation
Pass metadata on the dialog and the value is frozen at <flow>.opened and attached to every subsequent event in the session:
<DepositDialog metadata={{ source: "trade-sidebar", experiment: "A" }} />Then in your subscriber:
useStridgeFlowEvents((event) => {
// event.metadata is the frozen object you passed at open time.
analytics.track(event.type, { ...event.payload, ...event.metadata })
})metadata is always present on the envelope — an empty object when no metadata prop was supplied. Use it for experiment ids, page ids, surface ids — anything that segments the same flow into reportable cohorts.
Next
- Drop-in dialogs — the
metadataprop that rides every event. - Headless integration — drive the FSM yourself; the bus fires identically.
- UI primitives & types —
@stridge/kit/typesfor exhaustive event payload narrowing.