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

# Balance Transfer

> Let borrowers allocate an approved loan across their liability accounts, then release the payoff payments from your backend.

export const ParamList = ({items = [], is_child = false}) => {
  return items.map(item => {
    const field_props = {
      id: Math.random().toString(),
      body: item.name,
      name: item.name,
      type: item.type,
      required: item.required
    };
    const enums = item.enums || [];
    const items = item.items || [];
    const has_items = items?.length > 0;
    const has_enums = enums?.length > 0;
    const should_default_open = item.defaultOpen || false;
    const render_child_item = () => {
      const child_props = {
        title: has_enums ? "Possible enum values" : "properties"
      };
      if (should_default_open) child_props.defaultOpen = true;
      const has_inline_enums = has_enums && enums.every(enum_item => typeof enum_item === 'string') && enums.map((enum_item, idx) => {
        const is_last = idx === enums.length - 1;
        const is_2nd_to_last = idx === enums.length - 2;
        return <>
            <code>{enum_item}</code>
            {is_last && ''}
            {is_2nd_to_last && ' or '}
            {!is_last && !is_2nd_to_last && ', '}
          </>;
      });
      const enum_list = has_enums && !has_inline_enums && <Accordion {...child_props}>
          {enums.map((enum_item, index) => <div key={`enum-${index}`}>
              <code>{enum_item.name}</code>
              <br />
              <p>{enum_item.description}</p>
            </div>)}
        </Accordion>;
      const item_list = has_items && <Expandable {...child_props}>
          <ParamList items={items || []} is_child />
        </Expandable>;
      return <>
          <p>
            {item.description}
            {has_inline_enums && [has_inline_enums.length > 1 ? ' One of ' : ' Must be ', ...has_inline_enums]}
          </p>

          {enum_list}
          {item_list}
        </>;
    };
    return is_child ? <ResponseField {...field_props}>{render_child_item()}</ResponseField> : <ParamField {...field_props}>{render_child_item()}</ParamField>;
  });
};

<Note>Mode: `balance_transfer`</Note>

Opal Balance Transfer lets a borrower direct the proceeds of an approved loan to the liability accounts they want to pay down.
This pre-built flow verifies the user's identity, connects their liability accounts, collects any account numbers needed to make those accounts payable, and asks the user how much of the loan to send to each account. When the user confirms, your backend releases the payments.

## What this mode does

Use this mode after your team has approved a loan, such as a debt consolidation or balance transfer loan, and knows the approved amount. You pass the approved loan terms when you create the token, and Opal guides the user through the following steps:

<Steps>
  <Step title="Identity verification">
    Opal verifies the user's identity. This step is skipped automatically if the Entity is already verified.
  </Step>

  <Step title="Account selection">
    Opal shows the user's liability accounts with their current balances. Only accounts that can receive a payment today, or that can become payable once the user provides the account number, are selectable.
  </Step>

  <Step title="Account verification">
    For each selected account that cannot receive payments yet, the user enters the account number. Accounts that are already payable skip this step. Accounts that still cannot receive payments after verification are removed from the allocation step.
  </Step>

  <Step title="Amount allocation">
    Opal prefills an amount for each account, starting with the highest balances, and shows how much of the loan remains to allocate. The user can edit or remove any amount, then confirms.
  </Step>

  <Step title="Release">
    Confirming does not move money. The session waits in `pending_release` until your backend calls [Release Payments](/reference/opal/release_payments). Method then creates one payment per allocated account.
  </Step>
</Steps>

```mermaid theme={null}
sequenceDiagram
  participant App as Your backend
  participant Method as Method API
  participant Opal as Opal (end user)
  App->>Method: POST /opal/token (mode: balance_transfer)
  Method-->>App: token, session_id
  App->>Opal: Launch Opal with the token
  Opal->>Method: Verify identity, select and verify accounts
  Opal->>Method: Allocate amounts and confirm
  Opal-->>App: balance_transfer.amounts.confirmed event
  App->>Method: GET /opal/sessions/{session_id}
  Method-->>App: bt.payout_status: pending_release
  App->>Method: POST /opal/sessions/{session_id} (release_payments)
  Method-->>App: bt.payout_status: released, bt.payment_ids
```

## Before you start

* **Enable the mode.** Balance Transfer must be enabled for your team. Until it is, token creation with `mode: "balance_transfer"` fails with `INVALID_MODE`. Contact your CSM to enable access to this mode.
* **Choose a source account.** Payments are funded from a source account that your team can send payments from. Pass it when you release payments, or ask your CSM to configure a default source account for your team.
* **Confirm the payment description.** Released payments use a description configured for your team. Contact your CSM to set or change it.

## Parameters

All monetary values are in cents.

<ParamList
  items={[
{
  name: "loan_amount",
  type: "number",
  required: true,
  description:
    "The approved loan amount available to allocate across accounts. Must be greater than 0.",
},
{
  name: "minimum_payment",
  type: "number",
  required: true,
  description:
    "The minimum amount the user can allocate to any single account. Use 0 for no minimum. Must be less than or equal to maximum_payment.",
},
{
  name: "maximum_payment",
  type: "number",
  required: true,
  description:
    "The maximum amount the user can allocate to any single account. Must be greater than 0.",
},
{
  name: "residual_amount_max",
  type: "number",
  required: false,
  description:
    "The maximum amount of the loan that can remain unallocated when the user confirms. Use 0 to require the user to allocate the full loan. Omit to allow any unallocated amount.",
},
{
  name: "selection_type",
  type: "enum",
  required: false,
  description: (
    <>
      Whether the user can select a single account, multiple accounts, or
      all eligible accounts. Defaults to <code>multiple</code>.
    </>
  ),
  enums: ["single", "multiple", "all"],
},
{
  name: "account_filters",
  type: "object",
  required: false,
  description:
    "Filters to explicitly include or exclude accounts during selection. These work the same way as in Connect mode.",
  items: [
    {
      name: "account_filters.include",
      type: "object",
      required: false,
      description: "Accounts to explicitly include.",
      items: [
        {
          name: "account_filters.include.account_types",
          type: "string[]",
          required: false,
          description: "Only surface these liability types for selection.",
          enums: [
            { name: "credit_card" },
            { name: "auto_loan" },
            { name: "mortgage" },
            { name: "student_loan" },
            { name: "personal_loan" },
          ],
        },
        {
          name: "account_filters.include.products",
          type: "string[]",
          required: false,
          description: "Only surface accounts that support these products.",
          enums: [
            { name: "balance" },
            { name: "sensitive" },
            { name: "payoff" },
            { name: "update" },
            { name: "payment" },
          ],
        },
      ],
    },
    {
      name: "account_filters.exclude",
      type: "object",
      required: false,
      description: "Accounts to mark as ineligible.",
      items: [
        {
          name: "account_filters.exclude.account_types",
          type: "string[]",
          required: false,
          description: "Mark specific liability types as ineligible.",
          enums: [
            { name: "credit_card" },
            { name: "auto_loan" },
            { name: "mortgage" },
            { name: "student_loan" },
            { name: "personal_loan" },
          ],
        },
        {
          name: "account_filters.exclude.mch_ids",
          type: "string[]",
          required: false,
          description: "Mark accounts tied to the provided merchant IDs as ineligible.",
        },
        {
          name: "account_filters.exclude.unverified_account_numbers",
          type: "boolean",
          required: false,
          description: "Mark accounts with unverified account numbers as ineligible.",
        },
      ],
    },
  ],
},
{
  name: "skip_pii",
  type: "string[]",
  required: false,
  description: "The PII to skip during identity verification.",
  enums: ["name", "dob", "address", "ssn_4"],
},
]}
/>

## Allocation rules

Method validates every amount the user enters. Each allocation must be:

* At least `minimum_payment`.
* No more than the smallest of `maximum_payment`, the account's current balance, and the portion of `loan_amount` not yet allocated to other accounts.

When the user confirms, Method also checks that:

* At least one account has an allocation.
* The total allocated does not exceed `loan_amount`.
* If `residual_amount_max` is set, the unallocated amount (`loan_amount` minus the total allocated) does not exceed `residual_amount_max`.
* Every allocated account can still receive a payment.

Opal enforces the same rules in its interface, so the user cannot confirm until every rule is met. Any unallocated amount stays with your team, and Opal tells the user that your team handles it.

<Note>
  Choose `residual_amount_max` based on how you plan to disburse leftover funds. Use `0` if the full loan must go to the user's liability accounts. Omit it if you disburse any remainder to the borrower another way, such as a deposit to their bank account.
</Note>

## Create a token

You can create a balance transfer token using one of the supported patterns:

* Existing entity: provide `entity_id` + `mode` + `balance_transfer`
* Create new entity: provide `entity` + `mode` + `balance_transfer`
* Resume session: provide `session_id` only (omit `mode` and `balance_transfer`)

### Existing Entity

```json theme={null}
{
  "entity_id": "ent_...",
  "mode": "balance_transfer",
  "balance_transfer": {
    "loan_amount": 1000000,
    "minimum_payment": 5000,
    "maximum_payment": 500000,
    "residual_amount_max": 0,
    "account_filters": {
      "include": {
        "account_types": ["credit_card"]
      }
    }
  }
}
```

### Create New Entity

```json theme={null}
{
  "entity": {
    "type": "individual",
    "individual": {}
  },
  "mode": "balance_transfer",
  "balance_transfer": {
    "loan_amount": 1000000,
    "minimum_payment": 0,
    "maximum_payment": 1000000
  }
}
```

### Response

```json theme={null}
{
  "token": "otkn_...",
  "valid_until": "2026-09-23T22:50:53.024Z",
  "session_id": "osess_..."
}
```

Store the `session_id`. You need it to check the session status and release payments.

## Launch Opal

<Tabs>
  <Tab title="React (Web)">
    ```tsx theme={null}
    import { OpalProvider, useOpal } from "@methodfi/opal-react";

    function Screen() {
      const { open } = useOpal({
        env: "dev",
        onEvent: (e) => {
          if (e.type === "balance_transfer.amounts.confirmed") {
            notifyBackend(e.data);
          }
        },
        onExit: (e) => {},
      });
      const start = async () => {
        const { token } = await getTokenFromBackend();
        open({ token });
      };
      return <button onClick={start}>Allocate Your Loan</button>;
    }
    ```
  </Tab>

  <Tab title="React Native">
    ```tsx theme={null}
    import { OpalProvider, useOpal } from "@methodfi/opal-react-native";

    function Screen() {
      const { open } = useOpal({
        env: "dev",
        onEvent: (e) => {
          if (e.type === "balance_transfer.amounts.confirmed") {
            notifyBackend(e.data);
          }
        },
        onExit: (e) => {},
      });
      const start = async () => {
        const { token } = await getTokenFromBackend();
        open({ token });
      };
      return <Button title="Allocate Your Loan" onPress={start} />;
    }
    ```
  </Tab>
</Tabs>

## Events

In addition to the default session events and the identity, connect, and account verification events, this mode emits the following events. Match them on `event.type`.

| Event type                           | When you might see it                                                    | `data`                                                        |
| ------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------- |
| `balance_transfer.flow.started`      | The allocation step started.                                             | `null`                                                        |
| `balance_transfer.step.started`      | A screen in the allocation step opened.                                  | `step`: `select`, `amount_entry`, `success`, or `empty`       |
| `balance_transfer.step.completed`    | A screen in the allocation step completed.                               | `step`, plus `account` (the account ID) for `amount_entry`    |
| `balance_transfer.amounts.confirmed` | The user confirmed their allocations and closed the confirmation screen. | `entity_id` and `allocations` (account ID to amount in cents) |
| `balance_transfer.flow.completed`    | The allocation step completed.                                           | `null`                                                        |

```json theme={null}
{
  "type": "balance_transfer.amounts.confirmed",
  "mode": "balance_transfer",
  "object": "amounts",
  "action": "confirmed",
  "timestamp": "2026-09-23T22:50:53.024Z",
  "data": {
    "entity_id": "ent_...",
    "allocations": {
      "acc_hmap9mbgfLcf9": 500000,
      "acc_YXDrjADGjC76U": 350000
    }
  }
}
```

If none of the selected accounts can receive payments after verification, Opal shows an empty state and the session ends with `opal.session.exited`.

<Warning>
  Use events to update your interface, not to authorize money movement. Before you release payments, retrieve the session from your backend and confirm that `bt.payout_status` is `pending_release`.
</Warning>

For the full list of events, see [Opal Events](/opal/events).

## Check the session status

Retrieve the session with your secret API key to read the confirmed allocations and payout status.

```bash theme={null}
curl https://production.methodfi.com/opal/sessions/osess_zde3mW34pEHqV \
  -H "Method-Version: 2026-03-30" \
  -H "Authorization: Bearer sk_WyZEWVfTcH7GqmPzUPk65Vjc"
```

The `bt` object on the session tracks the balance transfer:

```json theme={null}
{
  "loan_amount": 1000000,
  "minimum_payment": 5000,
  "maximum_payment": 500000,
  "residual_amount_max": 150000,
  "allocations": {
    "acc_hmap9mbgfLcf9": 500000,
    "acc_YXDrjADGjC76U": 350000
  },
  "payout_status": "pending_release",
  "payment_ids": {},
  "released_at": null,
  "completed": true
}
```

| `payout_status`   | Meaning                                                                                       |
| ----------------- | --------------------------------------------------------------------------------------------- |
| `pending_amounts` | The user has not confirmed allocations yet.                                                   |
| `pending_release` | The user confirmed allocations. Method is waiting for your backend to release payments.       |
| `released`        | Your backend released the payments. `payment_ids` lists the payment created for each account. |

See [Retrieve Session](/reference/opal/retrieve_session) for the full response.

## Release payments

When your team is ready to fund the transfer, for example after the loan proceeds are available in your source account, release the payments.

```bash theme={null}
curl https://production.methodfi.com/opal/sessions/osess_zde3mW34pEHqV \
  -X POST \
  -H "Method-Version: 2026-03-30" \
  -H "Authorization: Bearer sk_WyZEWVfTcH7GqmPzUPk65Vjc" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "release_payments",
    "data": {
      "source": "acc_Zc4F2aTLt8CBt"
    }
  }'
```

Method creates one [Payment](/reference/payments/overview) per allocated account, from the source account to that liability account, for the allocated amount. Releasing payments behaves as follows:

* **Validation happens first.** Method re-checks the allocations, the source account, and every destination account before any money moves. If a check fails, the request returns an error, the session stays in `pending_release`, and you can retry after resolving the issue.
* **Releasing is idempotent.** Calling release again on a `released` session returns the session unchanged and creates no new payments.
* **Each payment is independent.** If Method cannot create the payment for one account, that account's entry in `payment_ids` is `null` and the other payments still go out. A released session cannot be released again, so handle any `null` entry separately, for example by creating a payment with the [Payments API](/reference/payments/create) or by contacting your CSM.

Track each payment with the [Payments API](/reference/payments/retrieve) or the `payment.create` and `payment.update` [webhooks](/reference/webhooks/overview).

See [Release Payments](/reference/opal/release_payments) for the full request and response.

## Resuming a session

If the user exits before confirming, create a new token with the `session_id` to resume. Opal keeps the user's progress through identity verification, account selection, and account verification. Amounts are saved only when the user confirms, so Opal prefills them again when the user returns to the allocation step.

## Errors

Errors use the `INVALID_REQUEST` type with one of the following sub types.

| Sub type                     | When it occurs                                                                                                                                                       |
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `INVALID_MODE`               | Token creation: Balance Transfer is not enabled for your team.                                                                                                       |
| `INVALID_MODE_CONFIG`        | Token creation: `loan_amount` is not greater than 0, or `minimum_payment` is greater than `maximum_payment`. Release: the session is not a balance transfer session. |
| `BT_NO_ALLOCATIONS`          | Release: the user has not confirmed allocations yet.                                                                                                                 |
| `BT_MISSING_SOURCE`          | Release: no `source` was provided and no default source account is configured for your team.                                                                         |
| `BT_ACCOUNT_NOT_PAYABLE`     | An allocated account can no longer receive payments.                                                                                                                 |
| `BT_AMOUNT_OUT_OF_BOUNDS`    | An allocation is outside the allowed range for its account.                                                                                                          |
| `BT_ALLOCATIONS_EXCEED_LOAN` | The total allocated exceeds `loan_amount`.                                                                                                                           |
| `BT_RESIDUAL_EXCEEDS_MAX`    | The unallocated amount exceeds `residual_amount_max`.                                                                                                                |

An invalid source account returns the same errors as [Create a Payment](/reference/payments/create).
