Developers

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.resolved
  • withdraw.settlement.succeeded
  • deposit.method.clicked
  • kit.support.clicked

Same noun.verb across flows means the same semantic concept — deposit.quote.resolvedwithdraw.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:

  1. Filter by tier. The ui tier includes per-keystroke events (*.amount.changed, *.recipient.changed); forwarding them verbatim blows your event quota and adds no funnel value. Subscribe with useStridgeFlowEvents (flow-tier only), or branch on event.tier.
  2. Redact first. Run each event through redactEvent before it leaves your app. It returns a forwarding-safe deep copy that drops the debug-only cause and 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

FieldIn analytics forwardingWhy
payload.causeDropped by redactEventRaw 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 redactEventPII under most privacy regimes once it reaches a third party. 64-hex tx hashes are left intact.
metadataUntouchedConsumer-supplied — scrub at the source if it holds anything sensitive.
amounts, token symbols, chain ids, flowId, timestampKeptDrive 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.

TypePayloadWhat 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.failedFailurePayloadBaseQuote 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.failedFailurePayloadBaseSubmission 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.failedDepositSettlementFailurePayload (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.

TypePayloadWhat 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.

TypePayloadWhat 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.failedFailurePayloadBaseQuote 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.failedFailurePayloadBaseSubmission 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.failedWithdrawSettlementFailurePayloadTerminal failure.
withdraw.cancelled{ phase: "quoting" | "confirming" | "submitting" | "processing" | "unknown" }Flow ended before reaching a terminal state.

Withdraw · UI tier — 11 events

TypePayloadWhat 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.

TypePayloadWhat 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.

TypePayloadWhat 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

  • *.failed events all extend the shared FailurePayloadBase ({ code, reason, retryable, cause? }) so a generic error handler (if (event.type.endsWith(".failed"))) works without per-event branching. code is a bounded enum and cause carries the raw Error — see Observability for the error-tracking recipe.
  • deposit.settlement.failed and withdraw.settlement.failed extend that base with settlement-specific fields (kind, optional txHash) for richer triage.
  • flowId is a non-null string for every deposit.*, withdraw.*, and activity.* event; null for every kit.* 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. useStridgeFlowEvents never sees it; reach for useStridgeEvents.
  • 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

Was this page helpful?