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

# Bank links

> Link your users' bank accounts once through a hosted widget, then reference them for payouts and conversions with opaque, revocable identifiers — no raw bank details ever cross the API.

The Bank Links API connects a user's bank accounts to their PWS account through a hosted linking widget. Your user authorizes their bank once; you get back stable, opaque identifiers for the connection and each shared account. Money movement — payouts today, with conversions referencing the same identifiers — uses those identifiers instead of account or routing numbers.

<Note>
  **Not what you're looking for?** Bank links connect a user's own bank accounts for automated money movement. For
  fiat-to-crypto purchases via a hosted checkout UI, see [Ramp](/tabs/developer-documentation/payments/ramp). For
  rule-driven fiat ↔ crypto conversion on deposits, see [Conversion
  rules](/tabs/developer-documentation/payments/conversion-rules).
</Note>

## The model

A **bank link** (`bank_link_id`, prefixed `bal_`) is one connection to one institution — created when your user authorizes their bank through the widget. It owns one or more **account links** (`account_link_id`, prefixed `al_`), one per bank account the user chose to share. Money always moves against an account link. Anything that acts on the connection — reconnecting it, unlinking it, or it expiring or being revoked — acts on the bank link and takes all of its accounts with it.

A second institution is a second, fully independent bank link.

**What is never returned:** provider identifiers, access tokens, account owner names, and raw account or routing numbers.

## How it works

A typical Bank Links integration follows three steps:

<Steps>
  <Step title="Create">Create a bank link for a linkable funding method. You get back a hosted-widget URL.</Step>
  <Step title="Link">Send your user to that URL. They authorize their bank and choose which accounts to share.</Step>
  <Step title="Read">Read the link to see which accounts you can move money against, and their identifiers.</Step>
</Steps>

## Base URL

```
https://api.services.payward.com
```

## Authentication

All requests require three headers:

| Header      | Description                                                     |
| ----------- | --------------------------------------------------------------- |
| `API-Key`   | Your public API key                                             |
| `API-Nonce` | Monotonically increasing nonce, included in the signature       |
| `API-Sign`  | HMAC signature over the request, computed with your private key |

See [Authentication](/tabs/developer-documentation/get-started/authentication) for how to generate the signature.

## Step 1 — Create a bank link

Create a bank link for a linkable funding method with [Create bank link](/api-reference/bank-links/create-bank-link). The response is `pending` with a hosted-widget URL:

```bash theme={null}
curl -X POST "https://api.services.payward.com/v1/accounts/AA23N84GGQN4WE6I/funds/bank-links" \
  -H "API-Key: $PWS_API_KEY" \
  -H "API-Nonce: $PWS_API_NONCE" \
  -H "API-Sign: $PWS_API_SIGN" \
  -H "Content-Type: application/json" \
  -d '{
    "method_id": "91908d86-9f12-48bd-a4de-f46d03b1c54b",
    "return_url": "https://partner.example.com/return",
    "external_reference": "user-4711-primary"
  }'
```

```json theme={null}
{
  "data": {
    "bank_link_id": "bal_7c99d922-0d8a-4f0a-8612-7f47d7e5c62d",
    "method_id": "91908d86-9f12-48bd-a4de-f46d03b1c54b",
    "status": "pending",
    "accounts_eligibility_status": "unknown",
    "next_action": {
      "type": "hosted_link",
      "widget_url": "https://bank-link.services.payward.com/link/bal_7c99d922-0d8a-4f0a-8612-7f47d7e5c62d",
      "expires_at": "2026-07-27T12:30:00Z"
    }
  }
}
```

| Field                | Required | Description                                                                                                                                |
| -------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `method_id`          | Yes      | The funding method to link — an id, never a name. Discover them with `GET /v1/accounts/{account_id}/funds/deposits/methods/{asset_symbol}` |
| `return_url`         | No       | Where the widget sends your user when it finishes. Absolute `https`, no userinfo; anything else is rejected                                |
| `external_reference` | No       | Your own correlation value                                                                                                                 |

A method that does not support linking is rejected with `bank_link_method_not_linkable`.

A `pending` link reports `accounts_eligibility_status: "unknown"` — it has no accounts to evaluate yet, so that is not a denial.

## Step 2 — Send your user to the widget

Send them to `widget_url`. They pick their institution, authenticate with their bank, and choose which accounts to share. When they finish, the link becomes `linked`.

The widget URL works once. If you supplied a `return_url`, the widget returns your user to it when it is done — read the link to find out what was shared. If the session expires before they open it, or they abandon it partway, create a new bank link.

## Step 3 — Read the link

[Get bank link](/api-reference/bank-links/get-bank-link) returns one link. [List bank links](/api-reference/bank-links/list-bank-links) returns an account's links, newest first.

List is cursor-paginated. Use `page_size` to set the first page size (default 20, maximum 25). If a response includes `next_page_token`, pass that value as `page_token` to fetch the next page; the token carries its own page size, so a new `page_size` sent alongside it is ignored. An account with no links returns an empty body.

A link appears in the list once it has finished linking. Links still `pending` and links you have unlinked are not listed; `expired` ones are.

```json theme={null}
{
  "data": {
    "bank_link_id": "bal_7c99d922-0d8a-4f0a-8612-7f47d7e5c62d",
    "method_id": "91908d86-9f12-48bd-a4de-f46d03b1c54b",
    "status": "linked",
    "institution": { "name": "U.S. Bank" },
    "account_links": [
      {
        "account_link_id": "al_7cec1ea9-f52e-4766-b02b-6113eaaa4d4e",
        "account_last4": "0000",
        "status": "active",
        "eligibility_status": "eligible",
        "eligible_rails": ["ach"]
      }
    ],
    "accounts_eligibility_status": "eligible",
    "linked_at": "2026-07-27T12:04:11Z"
  }
}
```

`institution` is present once the link has completed and the provider named the bank; it is omitted on a `pending` link and whenever no name is available. Only `bank_link_id`, `method_id`, `status` and `accounts_eligibility_status` are always there.

`linked_at` is when the connection became active, so it appears once the link has finished linking and not before. `expires_at` is when the connection is due to expire, and appears only when the bank has told us a date — plenty of connections never carry one, so treat its absence as "no expiry reported" rather than "no expiry".

`account_links` lists the accounts currently shared with you, each with a `status` of `active`. An account your user stops sharing is dropped from the list on the next read rather than turning up as `removed`, so compare the ids you hold against what a read returns — an id you no longer see is gone. There are no webhooks for any of this; reading the link is how you find out.

`not_linked_accounts` lists accounts your user picked at their bank that could not be linked. They have no `account_link_id`, are excluded from `account_links` and from `accounts_eligibility_status`, and cannot move money. They are reported so you can tell your user an account they chose was left out; if a later reconnect links one, it becomes an ordinary account link. Each entry carries an `account_last4` and a `reason`: `missing_identity` when the provider could not verify the account holder, `missing_balance` when it could not read the balance, or `unknown`. Treat any reason you do not recognize as `unknown` — new ones may be added. The field is absent or empty when every selected account linked.

Four fields carry the eligibility story. Eligibility is computed fresh on every read — nothing is cached, so the answer can change between reads as ownership or rail capability changes.

* `eligibility_status` — this account's own verdict, always present. `eligible`: its owner is validated and at least one rail is known. `ineligible`: a check ruled it out, or the account is no longer linked. `unknown`: a check could not answer or has not run.
* `eligible_rails` — the transfer rails this account can use right now (for example `ach`, `rtp`). Omitted when there are none, so read it as absent-or-listed rather than expecting an empty array.
* `ineligibility_reasons` — why no rail is eligible, for example `name_mismatch` when the account holder does not match the PWS account's verified identity. Omitted when there are none. An account that could not be evaluated carries no reasons, so absence is not proof of eligibility.
* `accounts_eligibility_status` — the link-level fold of the accounts' own statuses, always present. `eligible`: at least one account has a validated owner and a usable rail. `ineligible`: every account is conclusively ruled out (a link that finished linking with no accounts is `ineligible` too). `unknown`: not established on this read — at least one account could not be fully evaluated. Treat `unknown` as "not verified yet", never as ineligible.

## Reconnecting

When a link reports `expired` — the connection lapsed, or your user or their bank revoked it — send your user back through the widget with [Reconnect bank link](/api-reference/bank-links/reconnect-bank-link). A link you have unlinked cannot be reconnected — link the account again instead.

```bash theme={null}
curl -X POST "https://api.services.payward.com/v1/accounts/AA23N84GGQN4WE6I/funds/bank-links/bal_7c99d922-0d8a-4f0a-8612-7f47d7e5c62d/reconnect" \
  -H "API-Key: $PWS_API_KEY" \
  -H "API-Nonce: $PWS_API_NONCE" \
  -H "API-Sign: $PWS_API_SIGN" \
  -H "Content-Type: application/json" \
  -d '{ "return_url": "https://partner.example.com/return" }'
```

The link comes back in `pending` with a `widget_url`. Send your user there as you did on create; they re-authorize with their bank and the widget resumes the existing connection.

That URL is the same address create returned — reconnect puts a new session behind it, so an address you have seen before works again. Calling reconnect twice before the widget is opened returns that same session with its original `expires_at`, so a repeat call does not buy a full session lifetime.

The bank link itself does not change: `bank_link_id` and `method_id` stay the same. Its accounts re-synchronize against whatever your user shares this time — an account they share again keeps its existing `account_link_id`, even one that had been dropped earlier, a newly shared account appears as a new account link, and one they leave out disappears.

## Unlinking

[Unlink bank link](/api-reference/bank-links/unlink-bank-link) severs the whole connection:

* the link becomes `unlinked` and every account it had shared becomes `removed`. This response is the only place you see that status;
* the response is the final snapshot, and repeating the call returns the same one — unlinking is idempotent;
* the link then disappears from both reads: Get answers `404` with `bank_link_not_found`, and List omits it;
* money movement against its accounts stops working;
* unlinking a link that never finished linking cancels it the same way.

Unlinking deletes the connection at the provider, not just our record of it, so there is nothing left to reconnect to. Linking the same bank account again means creating a new bank link, and it comes back with a new `bank_link_id` and a new `account_link_id` for every account — the same bank account does not get its old ids back.

The provider can refuse to sever the connection — for example while transactions are still settling. That comes back as `409` with `bank_link_provider_rejected`, and the link keeps the status it had; retry once the condition clears. Do not treat a `DELETE` as unconditionally successful.

A link busy with an in-flight provider operation — an activation, a reconnect, or an earlier unlink attempt — returns `503`; retry shortly.

## Errors

Bank-link operations return the standard PWS error envelope with stable `error.code` values. These four are specific to bank links:

| Code                                    | Where                         | Meaning                                                   | What to do                                                         |
| --------------------------------------- | ----------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------ |
| `bank_link_method_not_linkable`         | 400 on create                 | The funding method does not support linking for this user | Check the method via funds discovery                               |
| `bank_link_not_found`                   | 404 on get, reconnect, unlink | Unknown link, already unlinked, or another account's      | Treat as gone; link again if needed                                |
| `bank_link_provider_rejected`           | 409 on unlink                 | The provider refused to sever the connection right now    | Retry once the condition clears                                    |
| `bank_link_activation_restart_required` | 409 on reconnect              | An activation failed and left the outcome unknown         | Create a new bank link and send your user through the widget again |

Whatever your user runs into inside the widget — an authorization that expires mid-flow, an institution already linked, a connection limit, a provider outage — the widget handles, and none of it reaches your API calls. The link's own state is the outcome.

## API reference

| Endpoint                                                                                                                    | Description                                                 |
| --------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| [`POST /v1/accounts/{account_id}/funds/bank-links`](/api-reference/bank-links/create-bank-link)                             | Create a bank link and get its hosted-widget URL            |
| [`GET /v1/accounts/{account_id}/funds/bank-links`](/api-reference/bank-links/list-bank-links)                               | List an account's bank links, newest first                  |
| [`GET /v1/accounts/{account_id}/funds/bank-links/{bank_link_id}`](/api-reference/bank-links/get-bank-link)                  | Get one bank link with its account links and eligibility    |
| [`POST /v1/accounts/{account_id}/funds/bank-links/{bank_link_id}/reconnect`](/api-reference/bank-links/reconnect-bank-link) | Send the user back through the widget for an `expired` link |
| [`DELETE /v1/accounts/{account_id}/funds/bank-links/{bank_link_id}`](/api-reference/bank-links/unlink-bank-link)            | Sever the connection and mark every account link `removed`  |
