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

# Payment methods

> Capture, list, replace, and detach the customer's payment methods.

import LearnHooksTip from '/snippets/learn-hooks-tip.mdx';

Hooks for the wallet surface of a customer portal — list saved methods and set-default / detach them, preflight what a detach would break, capture a new method, and atomically swap an in-use default. Together they cover the "you can't just delete the card that's paying for things" flow safely.

<LearnHooksTip />

Every hook returns loading/error state — reads via `isLoading: boolean`, mutations via `isPending: boolean` — plus `error: Error | null`. See [Introduction](/react/introduction). The sections below list each hook's specific returns.

## usePaymentMethods

Loads the customer's saved payment methods and exposes the two write actions a wallet UI needs: `setDefault(id)` and `detach(id)`. Both invalidate the customer scope on success so the list re-renders. Takes no arguments; the customer comes from `<UnitPayProvider>` context.

**Returns**

* `paymentMethods` — `PaymentMethod[]` — the customer's saved methods, or `[]` while loading or when none exist. Card fields (`cardLast4`, `cardBrand`, `expMonth`, `expYear`) are `null` for non-card types.
* `setDefault(paymentMethodId)` — `(id: string) => Promise<void>` — points the customer default at this method. Invalidates the customer scope on success.
* `detach(paymentMethodId)` — `(id: string) => Promise<void>` — removes the method. Invalidates the customer scope on success. Throws `PmInUseError` when the method is a default still powering an active sub or auto-topup rule (see the warning below).
* `isActing` — `boolean` — `true` while a `setDefault` or `detach` mutation is in flight.

```tsx theme={null}
import { usePaymentMethods } from '@unitpay/react';

export default function Wallet() {
  const { paymentMethods, setDefault, detach, isLoading, isActing } = usePaymentMethods();

  if (isLoading) return <p>Loading…</p>;

  return (
    <ul>
      {paymentMethods.map((pm) => (
        <li key={pm.id}>
          {pm.cardBrand} •••• {pm.cardLast4}
          <button onClick={() => setDefault(pm.id)} disabled={isActing}>Make default</button>
          <button onClick={() => detach(pm.id)} disabled={isActing}>Remove</button>
        </li>
      ))}
    </ul>
  );
}
```

<Warning>
  `detach()` throws **`PmInUseError`** when the method is the customer default and is still powering an active subscription or auto-topup rule (server `409 pm_in_use`). The error carries `paymentMethodId`, `activeSubscriptionIds`, `autoTopupAccountIds`, and `requestId` from `error.details` — render the block reason and route the user to the replace flow. Preflight it with [`usePaymentMethodDependencies`](#usepaymentmethoddependencies) so you never show a "Remove" button that's doomed to throw.
</Warning>

```tsx theme={null}
import { usePaymentMethods, PmInUseError } from '@unitpay/react';

async function remove(detach: (id: string) => Promise<void>, id: string) {
  try {
    await detach(id);
  } catch (e) {
    if (e instanceof PmInUseError) {
      // e.activeSubscriptionIds / e.autoTopupAccountIds → offer the replace flow.
      console.warn('Still in use:', e.activeSubscriptionIds);
      return;
    }
    throw e;
  }
}
```

<Expandable title="PaymentMethod object">
  ```json theme={null}
  {
    "id": "pm_01j9x...",
    "customerId": "cus_01ktxbxs2re8nsdfmzxw0gh988",
    "organizationId": "org_01j9x...",
    "paymentProviderId": "psp_01j9x...",
    "providerMethodId": "pm_1Pabc...",
    "type": "card",
    "status": "active",
    "cardLast4": "4242",
    "cardBrand": "visa",
    "expMonth": 12,
    "expYear": 2028,
    "createdAt": "2026-01-04T10:00:00.000Z",
    "updatedAt": "2026-06-20T08:15:00.000Z"
  }
  ```
</Expandable>

## usePaymentMethodDependencies

The preflight query for a "remove card" confirmation modal. It returns whether the method is the customer default and, if so, the active subscriptions and auto-topup credit accounts that a detach would orphan. `data.blocksDetach` mirrors the server-side guardrail — when `true`, route the user to the replace flow instead of letting them click "Delete".

**Parameters**

<ParamField body="paymentMethodId" type="string" required>
  The method to inspect. The query is disabled while this is `null` / `undefined`.
</ParamField>

<ParamField body="options.enabled" type="boolean">
  When `false`, the query is disabled — useful for a modal that fetches on open (`enabled: isOpen`). Defaults to `true`.
</ParamField>

**Returns** — a TanStack `UseQueryResult<PaymentMethodDependencies, Error>` (short `staleTime` of 10s, because dependency state changes whenever subs / auto-topup rules change). `data` once loaded is `PaymentMethodDependencies`:

* **`paymentMethodId`** `string`
* **`isDefault`** `boolean` — whether this is the customer default.
* **`blocksDetach`** `boolean` — `true` when detaching would orphan an active sub or auto-topup rule.
* **`activeSubscriptions`** — `Array<{ id, status, planId, collectionMethod, currentBillingPeriodEnd }>`.
* **`autoTopupAccounts`** — `Array<{ id, creditCurrencyId, autoTopupThreshold, autoTopupAmount, autoTopupPackageId }>`.

```tsx theme={null}
import { usePaymentMethodDependencies } from '@unitpay/react';

export default function ConfirmDeletePM({
  paymentMethodId,
  isOpen,
}: {
  paymentMethodId: string;
  isOpen: boolean;
}) {
  const { data, isLoading } = usePaymentMethodDependencies(paymentMethodId, {
    enabled: isOpen,
  });

  if (isLoading || !data) return <p>Checking…</p>;

  if (data.blocksDetach) {
    return (
      <p>
        This card powers {data.activeSubscriptions.length} subscription(s) and{' '}
        {data.autoTopupAccounts.length} auto-topup rule(s). Replace it instead.
      </p>
    );
  }

  return <button>Delete card</button>;
}
```

<Expandable title="PaymentMethodDependencies object">
  ```json theme={null}
  {
    "paymentMethodId": "pm_01j9x...",
    "isDefault": true,
    "blocksDetach": true,
    "activeSubscriptions": [
      {
        "id": "sub_01k2x6...",
        "status": "active",
        "planId": "plan_01k2x5...",
        "collectionMethod": "charge_automatically",
        "currentBillingPeriodEnd": "2026-07-31T23:59:59.999Z"
      }
    ],
    "autoTopupAccounts": [
      {
        "id": "ca_01k2x9...",
        "creditCurrencyId": "ccur_01k2x4...",
        "autoTopupThreshold": 1000,
        "autoTopupAmount": 5000,
        "autoTopupPackageId": "pkg_01k2x3..."
      }
    ]
  }
  ```
</Expandable>

## useSetupPaymentMethod

Mutation that captures a new payment method via a PSP SetupIntent. It always resolves to a `requires_form` [`SettleOutcome`](/react/settlement) — the server mints a SetupIntent and returns the PSP client envelope, which you mount in `<PaymentForm mode="save_only">`. After the customer completes setup and the PSP webhook attaches the method, the payment-methods cache is invalidated so the next render shows it.

**Parameters**

<ParamField body="options" type="HandleSettlementOptions">
  Settlement callbacks dispatched after the mutation resolves — wire `onRequiresForm` to mount the inline form. See the [Settlement model](/react/settlement).
</ParamField>

The `setupPaymentMethod(input?)` alias accepts an optional input:

<ParamField body="input.successUrl" type="string">
  Where to send the customer after a successful hosted-mode setup.
</ParamField>

<ParamField body="input.cancelUrl" type="string">
  Where to send the customer if they abandon a hosted-mode setup.
</ParamField>

<ParamField body="input.uiMode" type="'embedded' | 'hosted'">
  Whether to render the form inline (`embedded`) or redirect to the PSP-hosted page (`hosted`).
</ParamField>

**Returns** — a TanStack `UseMutationResult` extended with `setupPaymentMethod(input?)`, which triggers the SetupIntent and resolves to a `requires_form` outcome whose `client.token` is the Stripe Elements `clientSecret`.

```tsx theme={null}
import { useSetupPaymentMethod } from '@unitpay/react';
import { useState } from 'react';

export default function AddCard() {
  const [clientSecret, setClientSecret] = useState<string | null>(null);

  const { setupPaymentMethod, isPending } = useSetupPaymentMethod({
    onRequiresForm: (result) => setClientSecret(result.client.token),
  });

  if (clientSecret) {
    // Mount Stripe Elements in save-only mode with this client secret.
    return <PaymentForm mode="save_only" clientSecret={clientSecret} />;
  }

  return (
    <button onClick={() => setupPaymentMethod()} disabled={isPending}>
      {isPending ? 'Starting…' : 'Add a card'}
    </button>
  );
}
```

## useReplacePaymentMethod

Mutation that performs an atomic "swap A for B" — it flips the customer default to the new method and detaches the old one in one server transaction. It's the resolution path when `usePaymentMethodDependencies` reports `blocksDetach`: the old default is still powering active subs / auto-topup rules, so you can't just detach it.

**Parameters** — the hook takes no arguments; the replacement pair goes to the `replacePaymentMethod(input)` alias:

<ParamField body="input.oldPaymentMethodId" type="string" required>
  The method being replaced — typically the customer's current default.
</ParamField>

<ParamField body="input.newPaymentMethodId" type="string" required>
  The method to become the new default. Must already be attached and active (capture one first with [`useSetupPaymentMethod`](#usesetuppaymentmethod)).
</ParamField>

**Returns** — a TanStack `UseMutationResult` extended with `replacePaymentMethod(input)`, which runs the swap and resolves to a `ReplacePaymentMethodResult`. On success it invalidates all customer-scoped queries (the PM list, default pointer, sub references). The result carries:

* **`customerId`** `string`
* **`oldPaymentMethodId`** `string`
* **`newPaymentMethodId`** `string`
* **`becameDefault`** `boolean` — `true` iff the default pointer actually moved this call.
* **`clearedOldDefault`** `boolean` — `true` iff the old method was the default (and is now cleared).

```tsx theme={null}
import { useReplacePaymentMethod } from '@unitpay/react';

export default function SwapCard({ oldId, newId }: { oldId: string; newId: string }) {
  const { replacePaymentMethod, isPending } = useReplacePaymentMethod();

  return (
    <button
      disabled={isPending}
      onClick={async () => {
        const result = await replacePaymentMethod({
          oldPaymentMethodId: oldId,
          newPaymentMethodId: newId,
        });
        if (result.becameDefault) alert('Card replaced.');
      }}
    >
      {isPending ? 'Replacing…' : 'Replace card'}
    </button>
  );
}
```

<Expandable title="ReplacePaymentMethodResult object">
  ```json theme={null}
  {
    "customerId": "cus_01ktxbxs2re8nsdfmzxw0gh988",
    "oldPaymentMethodId": "pm_01j9x...",
    "newPaymentMethodId": "pm_01k2xa...",
    "becameDefault": true,
    "clearedOldDefault": true
  }
  ```
</Expandable>

## See also

<CardGroup cols={2}>
  <Card title="Invoices" icon="file-invoice" href="/react/invoices">
    Collect payment with `usePayInvoice` once a method is on file.
  </Card>

  <Card title="Settlement model" icon="money-bill-transfer" href="/react/settlement">
    The `requires_form` outcome and `client` envelope behind card capture.
  </Card>

  <Card title="Error handling" icon="triangle-exclamation" href="/react/errors">
    The SDK error model and `PmInUseError`.
  </Card>

  <Card title="Customer" icon="user" href="/react/customer">
    `setupPayment()` on `useCustomer` mints a hosted-link alternative.
  </Card>
</CardGroup>
