> ## Documentation Index
> Fetch the complete documentation index at: https://docs.dynamic.xyz/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a custom payment UI

> Build a Flow payments UI with the JavaScript SDK: route by state, pay from a wallet, a deposit address, or an exchange, show outcomes, and handle quotes, cancel, expiry, and reloads.

> [!NOTE]
>
> Fireblocks Flow is an enterprise feature. [Contact us](https://www.dynamic.xyz/book-a-call) to enable it for your environment.

This guide builds the payer's side of a [Flow payment](https://docs.dynamic.xyz/docs/flow/payments.md): the page that loads a flow, lets the payer choose how to pay, and shows the result. Each section maps to a screen or a concern of that page. The examples call the client functions from `@dynamic-labs-sdk/client`, which work with any framework, and show the matching React hooks from `@dynamic-labs-sdk/react-hooks` next to them.

> [!TIP]
>
> In a React web app, the prebuilt [Flow payment widget](https://docs.dynamic.xyz/docs/javascript/building-ui/react-ui/flow-widget.md) renders these screens for you. Use this guide when you need full control, or a screen the widget does not provide, such as cancel or amount entry in `deposit` mode.

Complete [Get started](https://docs.dynamic.xyz/docs/javascript/reference/flow-getting-started.md) first. Your server creates the flow and passes its ID to this page, for example as a `flowId` URL parameter, so the payer can reload the page and resume.

## Load and route by phase

Load the flow before you show anything, and choose the screen from its current state instead of from what the page did last. A flow has three independent states: `executionState` tracks the payer's payment, `settlementState` tracks delivery to your destination, and `riskState` tracks screening. [Payment lifecycle and states](https://docs.dynamic.xyz/docs/flow/lifecycle-walkthrough.md) defines each one.

Collapse the states into a phase, checking the conditions in this order:

```ts theme={"system"}
import type { Flow } from "@dynamic-labs-sdk/client";

export type Phase = "success" | "failed" | "cancelled" | "expired" | "processing" | "pay";

const PAYMENT_STEP_STATES = new Set(["initiated", "source_attached", "quoted", "signing"]);

export const getPhase = (flow: Flow, { loadedInSigning }: { loadedInSigning: boolean }): Phase => {
  if (flow.settlementState === "completed") return "success";
  if (flow.executionState === "failed" || flow.settlementState === "failed") return "failed";
  if (flow.executionState === "cancelled") return "cancelled";
  if (flow.executionState === "expired") return "expired";
  // After a reload in signing, the payer may already have approved the payment
  if (flow.executionState === "signing" && loadedInSigning) return "processing";
  if (PAYMENT_STEP_STATES.has(flow.executionState)) return "pay";
  // broadcasted, source_detected, source_confirmed, or a state added later
  return "processing";
};
```

A flow keeps changing without any action from the page once the payment is on its way, and while an exchange payment is waiting for the exchange. [`shouldPollFlow`](https://docs.dynamic.xyz/docs/javascript/reference/client/should-poll-flow) tells you when to read it again. It returns `false` once the flow has ended, and while the flow waits for the payer in `initiated` or `source_attached`, because the functions the page calls return the updated flow.

```ts Client theme={"system"}
import { FlowNotFoundError, getFlow, shouldPollFlow, type Flow } from "@dynamic-labs-sdk/client";

const POLL_INTERVAL_MS = 3000;

// Reads the flow, renders it, and keeps reading while it can change on its own.
// Call it on load and after every action that returns a flow.
export const watchFlow = async (flowId: string, render: (flow: Flow) => void) => {
let flow: Flow;
try {
flow = await getFlow({ flowId });
} catch (error) {
if (error instanceof FlowNotFoundError) {
showPaymentNotFound(); // Wrong flow ID, or a flow from another environment
return;
}
throw error;
}
render(flow);

while (shouldPollFlow({ flow })) {
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
try {
flow = await getFlow({ flowId });
render(flow);
} catch {
// Keep the last flow on screen; the next read tries again
}
}
};

```

```tsx React theme={"system"}
import { FlowNotFoundError } from "@dynamic-labs-sdk/client";
import { useGetFlow } from "@dynamic-labs-sdk/react-hooks";
import { useRef } from "react";

export const PaymentPage = ({ flowId }: { flowId: string }) => {
  // Refetches every 3 seconds while shouldPollFlow is true, and stops on its own
  const { data: flow, error } = useGetFlow({ flowId });
  const firstStateRef = useRef<string | undefined>(undefined);

  if (!flow) {
    if (error instanceof FlowNotFoundError) return <PaymentNotFound />;
    return <Loading />;
  }

  firstStateRef.current ??= flow.executionState;
  const phase = getPhase(flow, { loadedInSigning: firstStateRef.current === "signing" });

  return <PaymentScreen flow={flow} phase={phase} />;
};
```

`useGetFlow` already polls, so do not pass your own `refetchInterval`. It keeps the newest flow it has seen, keeps showing it when a later read fails, and does not retry an error such as `FlowNotFoundError` that cannot succeed. The mutation hooks in this guide write the flow they return into the same cache, so every component that calls `useGetFlow` for the flow re-renders with it. To run code when a state changes, use `useSubscribeToFlowStateChange` from the [React hooks catalog](https://docs.dynamic.xyz/docs/javascript/reference/react-hooks-catalog.md), which polls the same way.

## Choose a payment method

In the `pay` phase with `executionState` `initiated`, let the payer choose a payment source: a wallet, an exchange, or a deposit address. [Payment sources](https://docs.dynamic.xyz/docs/flow/payment-sources.md) describes each one and which are screened.

```ts theme={"system"}
import type { Flow } from "@dynamic-labs-sdk/client";

const ALL_METHODS = ["wallet", "exchange", "deposit_address"];

export const getOfferedMethods = (flow: Flow) => {
  const offered = new Set<string>(flow.uiConfig?.enabledPaymentMethods ?? ALL_METHODS);
  return ALL_METHODS.filter((method) => offered.has(method));
};
```

`uiConfig.enabledPaymentMethods` lists the sources your server chose to show when it created the flow. It is for display only, and the API does not enforce it: a payer can still attach another source with direct requests, so do not rely on it for a compliance rule. The rest of `uiConfig` is also display data for your UI. Show `merchantName` and `merchantLogoUrl` if your server set them, and when `termsOfServicesUrl` is set, ask the payer to accept the terms before they confirm.

The payer can change their mind until the payment is sent. Attaching another source replaces the current one, so a back button on any `pay` screen returns to this choice.

## Wallet

A wallet payment takes four steps: connect a wallet, attach it, quote a token, and approve the payment in the wallet.

### Connect and attach the wallet

Connect the payer's wallet with your wallet connection UI. [Build a wallet picker](https://docs.dynamic.xyz/docs/javascript/reference/wallets/build-wallet-picker.md) and [Connect and verify a wallet](https://docs.dynamic.xyz/docs/javascript/reference/wallets/connect-and-verify-wallet.md) cover it, and [Compliance](https://docs.dynamic.xyz/docs/flow/compliance.md) describes checks your app can run at this point, such as verifying wallet ownership.

Then attach the wallet. Pass the wallet's address, its chain family, and the network it is on. `fromChainId` is required for wallet sources, and the request fails without it.

```ts Client theme={"system"}
import {
  attachFlowSource,
  FlowSourceBlockedError,
  getActiveNetworkId,
  type WalletAccount,
} from "@dynamic-labs-sdk/client";

export const attachWallet = async (flowId: string, walletAccount: WalletAccount) => {
  const { networkId } = await getActiveNetworkId({ walletAccount });
  try {
    const { flow } = await attachFlowSource({
      flowId,
      sourceType: "wallet",
      fromAddress: walletAccount.address,
      fromChainName: walletAccount.chain,
      fromChainId: networkId,
    });
    return flow;
  } catch (error) {
    if (error instanceof FlowSourceBlockedError) {
      // Screening blocked this wallet. Nothing was saved, so offer another wallet or source.
      showWalletBlocked();
      return undefined;
    }
    throw error;
  }
};
```

```tsx React theme={"system"}
import { FlowSourceBlockedError, getActiveNetworkId, type WalletAccount } from "@dynamic-labs-sdk/client";
import { useAttachFlowSource } from "@dynamic-labs-sdk/react-hooks";

export const PayWithWalletButton = ({ flowId, walletAccount }: { flowId: string; walletAccount: WalletAccount }) => {
  const { mutate: attachFlowSource, error, isPending } = useAttachFlowSource();

  const attach = async () => {
    const { networkId } = await getActiveNetworkId({ walletAccount });
    attachFlowSource({
      flowId,
      sourceType: "wallet",
      fromAddress: walletAccount.address,
      fromChainName: walletAccount.chain,
      fromChainId: networkId,
    });
  };

  return (
    <>
      <button onClick={attach} disabled={isPending}>Pay with this wallet</button>
      {error instanceof FlowSourceBlockedError && <p>This wallet can't be used for this payment.</p>}
    </>
  );
};
```

A blocked wallet stays blocked, so do not retry it. [Screening](https://docs.dynamic.xyz/docs/flow/screening.md) explains what is screened and when.

### Quote a token

List the tokens the payer holds on the wallet's network with [`getTokenBalances`](https://docs.dynamic.xyz/docs/javascript/reference/wallets/get-token-balances), and let them pick one. Filtering spam tokens from that list is one of the [compliance checks](https://docs.dynamic.xyz/docs/flow/compliance.md) your app can run. When the payer picks a token, quote it:

```ts Client theme={"system"}
import { getFlowQuote } from "@dynamic-labs-sdk/client";

export const quoteToken = (flowId: string, fromChainId: string, fromTokenAddress: string) =>
  getFlowQuote({ flowId, fromChainId, fromTokenAddress });
```

```tsx React theme={"system"}
import { useGetFlowQuote } from "@dynamic-labs-sdk/react-hooks";

export const TokenOption = ({ flowId, fromChainId, token }) => {
  const { mutate: getFlowQuote, isPending } = useGetFlowQuote();

  return (
    <button
      onClick={() => getFlowQuote({ flowId, fromChainId, fromTokenAddress: token.address })}
      disabled={isPending}
    >
      {token.symbol}
    </button>
  );
};
```

The quote can fail for reasons the payer can act on. [Handle Flow errors](https://docs.dynamic.xyz/docs/javascript/reference/flow-errors.md#quote-failures) shows how to read `FlowQuoteUnavailableError.quoteFailures`, which says why each of your settlement tokens could not be quoted, for example an amount below the route's minimum.

### Review the quote

Show the payer what they send, the fees, and how long the quote lasts. Quote amounts are integer strings in the token's smallest unit, so format them with the token's decimals from `fromTokenInfo` and `toTokenInfo`:

```ts theme={"system"}
import { formatTokenAmount, type Flow, type FlowTokenInfo } from "@dynamic-labs-sdk/client";

const formatAmount = (rawAmount: string, tokenInfo: FlowTokenInfo) =>
  `${formatTokenAmount({ rawAmount, decimals: tokenInfo.decimals })} ${tokenInfo.symbol}`;

export const getQuoteSummary = (flow: Flow) => {
  const { quote } = flow;
  if (!quote?.fromTokenInfo || !quote.toTokenInfo) return undefined;

  return {
    youSend: formatAmount(quote.fromAmount, quote.fromTokenInfo),
    delivered: formatAmount(quote.toAmount, quote.toTokenInfo),
    totalFeeUsd: quote.fees?.totalFeeUsd,
    expiresAt: new Date(quote.expiresAt),
  };
};
```

A wallet quote is valid for 60 seconds. Show a countdown to `quote.expiresAt`, and when it runs out, replace the confirm button with one that gets a new quote. A new quote for the same token reuses the quote's chain and token:

```ts theme={"system"}
import { getFlowQuote, type Flow } from "@dynamic-labs-sdk/client";

export const refreshQuote = (flow: Flow) =>
  getFlowQuote({
    flowId: flow.id,
    fromChainId: flow.quote?.fromChainId,
    fromTokenAddress: flow.quote?.fromToken,
  });
```

In `deposit` mode, also pass the payer's amount, as described in [Deposit mode amount entry](#deposit-mode-amount-entry).

### Send the payment

When the payer confirms, call [`submitFlowTransaction`](https://docs.dynamic.xyz/docs/javascript/reference/client/submit-flow-transaction). It prepares the transaction, switches the wallet to the right network, asks the payer to approve a token allowance when an ERC-20 payment needs one, asks them to sign and send the transaction, and reports it to the flow. `onStepChange` reports `approval` and `transaction`, so you can show which prompt the wallet is waiting for.

Treat any successful return as sent. When the wallet accepted the transaction but did not return its hash in time, `submitFlowTransaction` returns the flow as it is, still in `signing`, without reporting a hash. The transaction may still be on its way, so treat that return as sent too. Either way, move to the processing screen and do not offer the confirm button again.

Handle errors by class, and never by message text:

```ts Client theme={"system"}
import {
  FlowPrepareSigningError,
  getFlow,
  submitFlowTransaction,
  UserRejectedError,
  type FlowQuoteAmountCurrency,
  type WalletAccount,
} from "@dynamic-labs-sdk/client";

type SubmitParams = {
flowId: string;
walletAccount: WalletAccount;
// Deposit mode only: the amount the payer chose
amount?: string;
amountCurrency?: FlowQuoteAmountCurrency;
};

export const sendPayment = async ({ flowId, walletAccount, amount, amountCurrency }: SubmitParams) => {
  try {
    const flow = await submitFlowTransaction({
      flowId,
      walletAccount,
      amount,
      amountCurrency,
      onStepChange: (step) => showWalletPrompt(step), // "approval" or "transaction"
    });
    return { outcome: "sent", flow } as const;
  } catch (error) {
    if (error instanceof UserRejectedError) {
      // Nothing was sent, and the SDK already got a new quote. The payer can submit again.
      return { outcome: "declined" } as const;
    }
    if (error instanceof FlowPrepareSigningError && error.code === "quote_expired") {
      // Nothing was sent. Get a new quote, then let the payer confirm again.
      return { outcome: "quoteExpired" } as const;
    }
    if (error instanceof FlowPrepareSigningError && error.code === "insufficient_balance") {
      // Nothing was sent. The wallet can't cover the amount or the gas; offer another token.
      return { outcome: "insufficientBalance" } as const;
    }
    // Anything else: the payment may already be on its way. Read the flow instead of retrying.
    const flow = await getFlow({ flowId });
    return { outcome: "checkStatus", flow } as const;
  }
};
```

```tsx React theme={"system"}
import { FlowPrepareSigningError, UserRejectedError, type WalletAccount } from "@dynamic-labs-sdk/client";
import { useSubmitFlowTransaction } from "@dynamic-labs-sdk/react-hooks";

export const ConfirmPayment = ({ flowId, walletAccount }: { flowId: string; walletAccount: WalletAccount }) => {
  const { mutate: submitFlowTransaction, error, isPending, isSuccess, reset } = useSubmitFlowTransaction();

  if (isSuccess) return null; // The flow in the useGetFlow cache moves the page to processing

  const declined = error instanceof UserRejectedError;
  const quoteExpired = error instanceof FlowPrepareSigningError && error.code === "quote_expired";
  const insufficientBalance = error instanceof FlowPrepareSigningError && error.code === "insufficient_balance";
  const canRetry = !error || declined;

  return (
    <>
      {declined && <p>Payment not approved in your wallet.</p>}
      {quoteExpired && <RefreshQuoteButton flowId={flowId} onRefreshed={reset} />}
      {insufficientBalance && <p>Not enough balance for this token. Choose another token.</p>}
      {/* Any other error: show the flow's state from useGetFlow instead of a retry button */}
      {canRetry && (
        <button onClick={() => submitFlowTransaction({ flowId, walletAccount })} disabled={isPending}>
          Confirm payment
        </button>
      )}
    </>
  );
};
```

Retry only in two cases: after `UserRejectedError`, by submitting again, and after the `quote_expired` code, by getting a new quote and submitting it. After any other error, call `getFlow` and show the screen for the state it returns. A flow still in `quoted` was not sent, and the payer can confirm again. A flow in `signing` or later may have been sent, and the page shows it as processing. A second approval could send a second payment.

## Deposit address

With a deposit address, the payer sends funds from any wallet or exchange to an address generated for the payment. Nothing is connected or signed in your UI.

### Attach the source and get the address

Ask the payer for the chain family, network, and token they will send, and for a refund address on that chain family. Deposit addresses support EVM chains, Solana, Bitcoin, and TRON. Then attach the source and quote it. The quote returns the deposit address.

```ts Client theme={"system"}
import { attachFlowSource, getFlowQuote, type Chain } from "@dynamic-labs-sdk/client";

type DepositSelection = {
fromChainName: Chain; // "EVM", "SOL", "BTC", or "TRON"
fromChainId: string; // For example "8453" for Base
fromTokenAddress: string;
refundAddress: string; // The payer's address on the same chain family
};

export const startDeposit = async (flowId: string, selection: DepositSelection) => {
  await attachFlowSource({
    flowId,
    sourceType: "deposit_address",
    fromChainName: selection.fromChainName,
    fromChainId: selection.fromChainId,
    refundAddress: selection.refundAddress,
  });

return getFlowQuote({
flowId,
fromChainId: selection.fromChainId,
fromTokenAddress: selection.fromTokenAddress,
});
};

```

```tsx React theme={"system"}
import { useAttachFlowSource, useGetFlowQuote } from "@dynamic-labs-sdk/react-hooks";

export const StartDepositButton = ({ flowId, selection }) => {
  const { mutateAsync: attachFlowSource } = useAttachFlowSource();
  const { mutateAsync: getFlowQuote, isPending } = useGetFlowQuote();

  const start = async () => {
    await attachFlowSource({
      flowId,
      sourceType: "deposit_address",
      fromChainName: selection.fromChainName,
      fromChainId: selection.fromChainId,
      refundAddress: selection.refundAddress,
    });
    await getFlowQuote({
      flowId,
      fromChainId: selection.fromChainId,
      fromTokenAddress: selection.fromTokenAddress,
    });
  };

  return <button onClick={start} disabled={isPending}>Show deposit address</button>;
};
```

The refund address is where the deposit provider returns funds it does not accept. Dynamic does not initiate refunds, and a refund can fail, as [Failures and refunds](https://docs.dynamic.xyz/docs/flow/failures-and-refunds.md#refunds) explains. Pass `fromTokenAddress` for the token the payer will send: without it, the quote uses the chain's native token.

### Show the address

Show the deposit details only when they belong to the payer's current selection, so a stale address never stays on screen while a new quote loads:

```ts theme={"system"}
import { formatTokenAmount, type Flow } from "@dynamic-labs-sdk/client";

export const getDepositDetails = (flow: Flow, selection: { fromChainId: string; fromTokenAddress: string }) => {
  const { depositAddress, quote } = flow;
  const matchesSelection =
    flow.sourceType === "deposit_address" &&
    flow.fromChainId === selection.fromChainId &&
    flow.fromToken?.toLowerCase() === selection.fromTokenAddress.toLowerCase();
  if (!matchesSelection || !depositAddress || !quote?.fromTokenInfo) return undefined;

  return {
    depositAddress,
    amount: formatTokenAmount({ rawAmount: quote.fromAmount, decimals: quote.fromTokenInfo.decimals }),
    symbol: quote.fromTokenInfo.symbol,
    rawAmount: quote.fromAmount,
    decimals: quote.fromTokenInfo.decimals,
  };
};
```

Tell the payer to send exactly the amount shown, on the chain and in the token they selected. To let a wallet fill in both the address and the amount, encode them in a QR code with the chain's payment URI:

```ts theme={"system"}
import { formatTokenAmount } from "@dynamic-labs-sdk/client";

export const buildDepositUri = ({ chainName, tokenAddress, depositAddress, rawAmount, decimals }) => {
  const amount = formatTokenAmount({ rawAmount, decimals });
  switch (chainName) {
    case "BTC":
      return `bitcoin:${depositAddress}?amount=${amount}`;
    case "SOL":
      return `solana:${depositAddress}?amount=${amount}`;
    case "EVM":
      // EIP-681: a token transfer takes the raw amount; native ETH takes the value in wei
      return tokenAddress
        ? `ethereum:${tokenAddress}/transfer?address=${depositAddress}&uint256=${rawAmount}`
        : `ethereum:${depositAddress}?value=${rawAmount}`;
    default:
      return depositAddress;
  }
};
```

Wallets differ in which URI fields they read, so always show the address and amount as text the payer can copy too.

### Refresh the quote

The deposit address stays open until the flow ends, but the deposit provider guarantees the quoted amount for only 60 seconds. The quote's `expiresAt` shows the flow's expiry, not that 60-second window, so time the refresh from `quote.createdAt`. If the payer has not sent funds within 60 seconds, get a new quote for the same chain and token:

```ts theme={"system"}
import { getFlowQuote, type Flow } from "@dynamic-labs-sdk/client";

const DEPOSIT_QUOTE_VALIDITY_MS = 60_000;

export const scheduleDepositQuoteRefresh = (flow: Flow, onQuoted: (flow: Flow) => void) => {
  if (flow.executionState !== "quoted" || !flow.quote) return undefined;

  const refreshInMs = new Date(flow.quote.createdAt).getTime() + DEPOSIT_QUOTE_VALIDITY_MS - Date.now();
  return setTimeout(async () => {
    const refreshed = await getFlowQuote({
      flowId: flow.id,
      fromChainId: flow.quote?.fromChainId,
      fromTokenAddress: flow.quote?.fromToken,
    });
    onQuoted(refreshed);
  }, Math.max(refreshInMs, 0));
};
```

Cancel the timer when the flow leaves `quoted`, because a refresh is no longer allowed once the deposit is detected. A new quote for the same chain and token usually keeps the same address. If the address changes, show the new one. Funds sent later to the address can still arrive, but the amount delivered can differ from the original quote. In `deposit` mode, pass the payer's amount on every refresh.

### Wait for the deposit

The flow is polled while it is `quoted`, so the [load section](#load-and-route-by-phase) picks up the deposit. When the deposit is seen, the flow moves to `source_detected` and then to `source_confirmed`. From `source_detected`, the payer can no longer change the source, so hide the back button and show that the payment was received and is being confirmed.

If the payer switches to another chain or token, attach and quote again, which gives a new address. Funds already sent to an earlier address for the same flow can still complete the payment, so tell the payer which address is current and that funds already sent may still count.

## Exchange

With an exchange source, the payer pays from an exchange account through a purchase link, and the exchange confirms the payment. This section uses Coinbase as the example. Each exchange must be enabled for your environment in the [Dynamic Console](https://console.dynamic.xyz), and an exchange is available for a flow only when the flow settles in a token and on a network that exchange supports. For Coinbase, that is USDC.

### List the exchanges

Ask the flow which exchanges it supports, and show each one by `label` and `iconUrl`:

```ts Client theme={"system"}
import { getFlowSupportedExchanges } from "@dynamic-labs-sdk/client";

export const listExchanges = (flowId: string) => getFlowSupportedExchanges({ flowId });
```

```tsx React theme={"system"}
import { useGetFlowSupportedExchanges } from "@dynamic-labs-sdk/react-hooks";

export const ExchangeList = ({ flowId, onSelect }) => {
  // Fetched once per flow
  const { data: exchanges = [] } = useGetFlowSupportedExchanges({ flowId });

  return exchanges.map((exchange) => (
    <button key={exchange.id} onClick={() => onSelect(exchange)}>
      <img src={exchange.iconUrl} alt="" width={24} height={24} />
      {exchange.label}
    </button>
  ));
};
```

### Attach the exchange and open the purchase link

When the payer picks an exchange, attach it with the exchange's `key`. The returned flow carries the purchase link in `exchangeSource.metadata.url`.

```ts Client theme={"system"}
import { attachFlowSource, FlowExchangeSourceError, type Flow, type FlowExchange } from "@dynamic-labs-sdk/client";

export const getPurchaseUrl = (flow: Flow) => {
  const metadata = flow.exchangeSource?.metadata as { url?: unknown } | undefined;
  return typeof metadata?.url === "string" ? metadata.url : undefined;
};

export const attachExchange = async (flowId: string, exchange: FlowExchange) => {
  try {
    const { flow } = await attachFlowSource({
      flowId,
      sourceType: "exchange",
      exchangeProvider: exchange.key,
    });
    return flow;
  } catch (error) {
    if (error instanceof FlowExchangeSourceError) {
      // retryable is true only for exchange_unavailable
      showExchangeUnavailable({ canRetry: error.retryable });
      return undefined;
    }
    throw error;
  }
};
```

```tsx React theme={"system"}
import { FlowExchangeSourceError, type FlowExchange } from "@dynamic-labs-sdk/client";
import { useAttachFlowSource } from "@dynamic-labs-sdk/react-hooks";

export const PayWithExchange = ({ flowId, exchange }: { flowId: string; exchange: FlowExchange }) => {
  const { mutate: attachFlowSource, data, error, isPending } = useAttachFlowSource();
  const purchaseUrl = data ? getPurchaseUrl(data.flow) : undefined;

  if (purchaseUrl) {
    return (
      <button onClick={() => window.open(purchaseUrl, "_blank", "noopener,noreferrer")}>
        Open {exchange.label}
      </button>
    );
  }

  return (
    <>
      <button
        onClick={() => attachFlowSource({ flowId, sourceType: "exchange", exchangeProvider: exchange.key })}
        disabled={isPending}
      >
        Continue with {exchange.label}
      </button>
      {error instanceof FlowExchangeSourceError && (
        <p>{error.retryable ? "The exchange is unavailable. Try again." : "This exchange can't be used for this payment."}</p>
      )}
    </>
  );
};
```

Open the link with `window.open` inside the click handler of an "Open" button, as the React example does. Browsers can block a window that opens after an `await`, so do not open it at the end of the attach call. The `FlowExchangeSourceError` codes tell you why an exchange cannot be used, and [Handle Flow errors](https://docs.dynamic.xyz/docs/javascript/reference/flow-errors.md#flowexchangesourceerror) lists them.

### Wait for the exchange

After the payer opens the link, show a waiting screen and keep reading the flow. A flow in `source_attached` with an exchange source is polled, so the load section picks up the change. The exchange confirms the payment, and the flow moves to `source_confirmed` and then to settlement `completed`.

Do not call `broadcastFlow` for an exchange payment. The exchange reports the payment, and the page only reads the flow.

To let the payer open the exchange again, attach the exchange again for a new link. A purchase already completed through an earlier link can still reach your destination, so warn the payer before they start a second purchase.

## Processing

In the `processing` phase, the payment is on its way and the payer has nothing left to do. Keep reading the flow and show progress. Hide the back and cancel buttons.

| Source | Suggested steps |
| :- | :- |
| Wallet | Sent (`broadcasted`), received (`source_confirmed`), delivered (`settlementState` `completed`) |
| Deposit address | Received (`source_detected`), confirmed (`source_confirmed`), delivered (`settlementState` `completed`) |
| Exchange | Confirmed by the exchange (`source_confirmed`), delivered (`settlementState` `completed`) |

An exchange payment waits for the exchange in `source_attached`, which is still in the `pay` phase, so the waiting screen from the [exchange section](#wait-for-the-exchange) covers that step. Do not show success at `source_confirmed`. It means the payer's funds arrived, but delivery to your destination can still fail. The payment is done only when `settlementState` is `completed`.

## Outcomes

Show the final screen for the phase, and decide fulfillment on your server from [webhooks](https://docs.dynamic.xyz/docs/flow/webhooks.md) or a server read of the flow, not from this page.

| Phase | Screen |
| :- | :- |
| `success` | The payment is complete. Show what was delivered. |
| `failed` | Show `flow.failure.message`. When `flow.failure.code` is `REFUND_INITIATED`, say the payment is being refunded. |
| `cancelled` | The payment was cancelled. |
| `expired` | The payment expired. The payer needs a new payment from you, for example "Contact the merchant for a new payment link". |

A failed, cancelled, or expired flow is final. The same flow cannot be paid again, so a new attempt needs a new flow from your server. [Failures and refunds](https://docs.dynamic.xyz/docs/flow/failures-and-refunds.md) lists the failure codes and what to show for each.

On success, show the amount that was actually delivered with [`getFlowDeliveredAmount`](https://docs.dynamic.xyz/docs/javascript/reference/client/get-flow-delivered-amount). It returns `undefined` when settlement did not report an amount, so fall back to the quoted amount, and to the flow's requested amount when there is no quote:

```ts theme={"system"}
import { formatTokenAmount, getFlowDeliveredAmount, type Flow } from "@dynamic-labs-sdk/client";

export const describeDelivered = (flow: Flow) => {
  const delivered = getFlowDeliveredAmount({ flow });
  if (delivered) {
    const amount = formatTokenAmount({ rawAmount: delivered.amount, decimals: delivered.tokenInfo.decimals });
    return `${amount} ${delivered.tokenInfo.symbol} delivered`;
  }

  const { quote } = flow;
  if (quote?.toTokenInfo) {
    const amount = formatTokenAmount({ rawAmount: quote.toAmount, decimals: quote.toTokenInfo.decimals });
    return `${amount} ${quote.toTokenInfo.symbol} quoted`;
  }

  // An exchange payment has no quote. Show the amount the flow requested.
  return `${flow.amount} ${flow.currencyTokenInfo?.symbol ?? flow.currency} requested`;
};
```

The mode decides whether the delivered amount can be smaller than requested. In `deposit` mode, a short deposit is accepted and settles for what arrived, so show and credit the delivered amount. In `payment` mode, a short payment is not accepted, so a `completed` flow was paid in full. [Payment lifecycle and states](https://docs.dynamic.xyz/docs/flow/lifecycle-walkthrough.md#amounts-you-will-see) explains each amount.

If your server set `uiConfig.returnUrl`, you can send the payer back to your app from the final screen.

## Deposit mode amount entry

In `deposit` mode, the flow's `amount` is a suggestion, and the payer can choose how much to deposit. Show an amount field only when `flow.mode` is `deposit`; other modes reject an amount with `FlowQuoteAmountError` code `amount_not_editable`.

The payer can enter the amount in USD, or in the token they pay with as `SOURCE_TOKEN`, where `"0.5"` means 0.5 of that token. Pass `amount` and `amountCurrency` on every quote, including refreshes. A quote without them prices the flow's current amount in USD, so a token amount would not stay fixed. Pass both to `submitFlowTransaction` too, because it gets a new quote with them after the payer declines in the wallet.

```ts theme={"system"}
import {
  FlowQuoteAmountError,
  getFlowQuote,
  submitFlowTransaction,
  type FlowQuoteAmountCurrency,
  type WalletAccount,
} from "@dynamic-labs-sdk/client";

type DepositAmount = { amount: string; amountCurrency: FlowQuoteAmountCurrency };

export const quoteDepositAmount = async (
  flowId: string,
  token: { fromChainId: string; fromTokenAddress: string },
  depositAmount: DepositAmount
) => {
  try {
    return await getFlowQuote({ flowId, ...token, ...depositAmount });
  } catch (error) {
    if (error instanceof FlowQuoteAmountError && error.code === "amount_invalid") {
      // Malformed, too precise, or too small: at most 2 decimals in USD,
      // at most the token's decimals in SOURCE_TOKEN
      showInvalidAmount();
      return undefined;
    }
    throw error;
  }
};

export const sendDeposit = (flowId: string, walletAccount: WalletAccount, depositAmount: DepositAmount) =>
  submitFlowTransaction({ flowId, walletAccount, ...depositAmount });
```

A new amount replaces `flow.amount`. When the source is a wallet, changing the amount screens the wallet again, so the quote can fail with `FlowSourceBlockedError` even though the attach succeeded. Show amounts with `flow.currencyTokenInfo` when the flow is denominated in a token, and with the quote's `fromTokenInfo` and `toTokenInfo` otherwise.

## Cancel

The payer can cancel a flow in `source_attached`, `quoted`, or `signing` with [`cancelFlow`](https://docs.dynamic.xyz/docs/javascript/reference/client/cancel-flow). Cancelling needs the session token that attaching a source stored, so a flow in `initiated` cannot be cancelled from the page; your server can cancel it instead, as described in the [Flow payments API guide](https://docs.dynamic.xyz/docs/flow/api.md#cancel-a-flow). Show the cancel button only in those three states, and hide it as soon as the payer approves a wallet payment.

```ts Client theme={"system"}
import { cancelFlow, FlowSessionExpiredError, FlowStateConflictError, getFlow } from "@dynamic-labs-sdk/client";

export const cancelPayment = async (flowId: string) => {
  try {
    return await cancelFlow({ flowId });
  } catch (error) {
    if (error instanceof FlowStateConflictError || error instanceof FlowSessionExpiredError) {
      // Too late: the payment was sent, or the flow already ended. Show its real state.
      return getFlow({ flowId });
    }
    throw error;
  }
};
```

```tsx React theme={"system"}
import { useCancelFlow } from "@dynamic-labs-sdk/react-hooks";

export const CancelButton = ({ flowId }: { flowId: string }) => {
  // On a state conflict, the hook refetches the flow so the page shows its real state
  const { mutate: cancelFlow, isPending } = useCancelFlow();

  return (
    <button onClick={() => cancelFlow({ flowId })} disabled={isPending}>
      Cancel payment
    </button>
  );
};
```

A cancelled flow is final. Do not cancel after the payer declines in the wallet: the SDK already prepared a new quote, so the payer can try again or pick another way to pay on the same flow.

## Expiry timer

A flow expires at `flow.expiresAt` if the payment has not been sent by then. The flow is not read again while it waits for the payer in `initiated` or `source_attached`, so the page does not see the expiry until the next action fails. Run a timer on `expiresAt` and read the flow when it fires:

```ts theme={"system"}
import { getFlow, type Flow } from "@dynamic-labs-sdk/client";

export const scheduleExpiryCheck = (flow: Flow, render: (flow: Flow) => void) => {
  if (!flow.expiresAt) return undefined;
  const msUntilExpiry = new Date(flow.expiresAt).getTime() - Date.now();
  return setTimeout(async () => render(await getFlow({ flowId: flow.id })), Math.max(msUntilExpiry, 0));
};
```

Expiry applies only before the payment is sent. A flow in `broadcasted` or later does not expire, and the page keeps showing progress. An action on an expired flow fails, with `FlowStateConflictError` for attaching a source and with `FlowSessionExpiredError` for the actions that need the session token, because an expired flow's token stops working. In both cases, read the flow and show the expired screen. [Expiry and cancellation](https://docs.dynamic.xyz/docs/flow/expiry-and-cancellation.md) describes flow and quote expiry.

## Reload and another device

On every load, read the flow and route by phase, as the [load section](#load-and-route-by-phase) does. Do not repeat the last action just because the page reloaded.

When the payer attaches a source, the SDK stores the flow's session token on the device, and every later action sends it. Reading the flow needs only the flow ID.

* **Same browser or app.** The token is still stored, so the payer resumes from the flow's state. A wallet payment in `quoted` can resume at the review screen when the same wallet is connected, matching `fromAddress` and `fromChainName`. A deposit-address payment can restore the selection from `fromChainId` and `fromToken`. An exchange payment in `source_attached` shows the waiting screen, and opening the exchange again needs a new link.
* **Another device, or cleared storage.** The page can read the flow but has no token, so the next action fails with `FlowSessionTokenMissingError`. A token that the server no longer accepts fails with `FlowSessionExpiredError` instead, and the SDK removes it.

Both errors recover the same way. Read the flow, and if the payment has not been sent, attach the source again. Attaching issues a new token on this device, and the token on the other device stops working. After the payment is sent, there is nothing left for the payer to do, and the page only shows progress.

```ts theme={"system"}
import {
  attachFlowSource,
  FlowSessionExpiredError,
  FlowSessionTokenMissingError,
  getFlow,
  type AttachFlowSourceParams,
} from "@dynamic-labs-sdk/client";

const NOT_SENT_STATES = new Set(["initiated", "source_attached", "quoted"]);

export const isSessionLost = (error: unknown) =>
  error instanceof FlowSessionTokenMissingError || error instanceof FlowSessionExpiredError;

export const recoverSession = async (flowId: string, source: AttachFlowSourceParams) => {
  const flow = await getFlow({ flowId });
  if (!NOT_SENT_STATES.has(flow.executionState)) return flow; // Show the flow's state instead
  const { flow: attached } = await attachFlowSource(source);
  return attached;
};
```

A flow in `signing` on another device cannot be attached again without the original token, because the payer may already have approved the payment on the first device. Show it as processing. [Payment lifecycle and states](https://docs.dynamic.xyz/docs/flow/lifecycle-walkthrough.md#resuming-a-flow) explains why recovery works this way.

## State-to-screen table

| `executionState` | Source | Screen | Calls |
| :- | :- | :- | :- |
| `initiated` | None yet | Choose a payment method | `attachFlowSource`, `getFlowSupportedExchanges` |
| `source_attached` | Wallet | Pick a token | `getFlowQuote` |
| `source_attached` | Deposit address | Loading the address | `getFlowQuote` |
| `source_attached` | Exchange | Open the exchange, then wait | None. Poll the flow |
| `quoted` | Wallet | Review the quote and confirm | `submitFlowTransaction`, `getFlowQuote` after 60 seconds |
| `quoted` | Deposit address | Address, amount, and QR code | `getFlowQuote` after 60 seconds. Poll the flow |
| `signing` | Wallet | Waiting for the wallet; processing after a reload | None. Poll the flow |
| `broadcasted` | Wallet | Processing: sent | None. Poll the flow |
| `source_detected` | Deposit address | Processing: received | None. Poll the flow |
| `source_confirmed` | Any | Processing until settlement is `completed` or `failed` | None. Poll the flow |
| `cancelled` | Any | Cancelled | None |
| `expired` | Any | Expired | None |
| `failed` | Any | Failed, or refunded when `failure.code` is `REFUND_INITIATED` | None |

Settlement `completed` shows success, and settlement `failed` shows the failed screen, whatever the execution state. Until the payment is sent, the payer can also go back and attach another source, or cancel.

## Related

* [Handle Flow errors](https://docs.dynamic.xyz/docs/javascript/reference/flow-errors.md): every error class, its code, and how to recover.
* [Payment lifecycle and states](https://docs.dynamic.xyz/docs/flow/lifecycle-walkthrough.md): what each state means and what moves the flow.
* [Expiry and cancellation](https://docs.dynamic.xyz/docs/flow/expiry-and-cancellation.md): flow and quote expiry, and who can cancel.
* [Flow payments API guide](https://docs.dynamic.xyz/docs/flow/api.md): the HTTP requests behind these functions.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.