---
title: "The connect link"
description: "Send your users to BankSync to connect a bank and install your app: the URL, every parameter, the three intents, the return_to allowlist rule, and the round trip back with code and state."
section: "Apps"
canonical: "https://banksync.io/docs/apps/connect-link"
specs: ["apps"]
operationBindings: [{"capability":"apps","operation":"apps.listing.get"},{"capability":"apps","operation":"apps.install.approve"}]
---

You have a product. Your user needs their bank data in it. The connect link is the URL you send them to: BankSync shows them who is asking and for what, signs them in or signs them up, walks them through connecting their bank, takes their approval, and hands them back to you with an authorization code.

```
https://app.banksync.io/connect/<slug>
```

`<slug>` is your app's slug. You need no BankSync workspace id to build the link, and you should not try to invent one — a third party cannot know which workspace a browser is signed in to. You address your own user by an identifier you choose.

> **Before you start:** 'You need a registered BankSync app with at least one client, and every URL you will send users back to must already be registered as a redirect URI on that client. See Register an OAuth app, then Building a BankSync app.'

> **The page is public on purpose:** 'The connect link renders before anyone signs in. That is the point: the person following it has usually never heard of BankSync, and they should learn who your app is and what it will read before they are asked to make an account. They never see a BankSync workspace, dashboard or navigation at any step.'

## The three intents

One URL, three jobs. `intent` picks which.

| `intent`   | What happens                                                                                                   | Ends at                                                      |
| ---------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| `install`  | Gate → sign in or sign up → connect a bank → hand off to BankSync's OAuth authorization endpoint → consent.    | Your `return_to` (or `redirect_uri`) with `code` and `state` |
| `add_bank` | Skips the gate and the consent screen. Runs the bank step, then returns. For a user who already installed you. | Your `return_to`                                             |
| `reauth`   | Skips the gate and the consent screen. Reconnects the bank named by `reauth=<bankId>`, then returns.           | Your `return_to`                                             |

`install` is the default: a link with no `intent` is the classic "connect your bank to my app" case. `add_bank` and `reauth` skip the gate deliberately — the user already installed your app and already saw what it can read, and re-showing the consent panel to someone repairing a broken connection just teaches them to click past it.

> **add\_bank and reauth never reach the OAuth endpoint:** 'They connect a bank and return. They do not mint a code, and they do not go through consent — you already hold a grant for that user. If you need a fresh authorization, use intent=install.'

## Parameters

Every parameter is a query parameter on the connect link. Values are read as strings even when they look like numbers or booleans, so an `external_user_id` of `00420` survives intact.

### Always accepted

| Parameter          | Notes                                                                                                                            |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
| `intent`           | `install`, `add_bank` or `reauth`. Anything unrecognised falls back to `install`.                                                |
| `state`            | Opaque value of yours, round-tripped back to you. Use it for CSRF protection and to carry your own context.                      |
| `return_to`        | Where to send the user when the flow finishes. See the rules below — they differ per intent.                                     |
| `external_user_id` | Your own identifier for this user. Printable ASCII, no whitespace, up to 255 characters.                                         |
| `email`            | Pre-fills sign-in and sign-up. Up to 320 printable ASCII characters, exactly one `@`, which may be neither first nor last.       |
| `country`          | Two letters, ISO-3166 alpha-2. Biases the bank picker toward that market.                                                        |
| `dub_click_id`     | Affiliate click attribution. Up to 128 printable ASCII characters, no whitespace. Falls back to the Dub cookie when you omit it. |

### `intent=install` also requires

These are the standard OAuth 2.1 parameters. The connect page forwards them verbatim to the authorization endpoint.

| Parameter               | Required | Notes                                                                                            |
| ----------------------- | -------- | ------------------------------------------------------------------------------------------------ |
| `client_id`             | yes      | Your client's id.                                                                                |
| `redirect_uri`          | yes      | Must **exact-string-match** a redirect URI registered on that client.                            |
| `code_challenge`        | yes      | PKCE. Not optional, even for confidential clients.                                               |
| `code_challenge_method` | no       | Defaults to `S256`, and must be `S256`. `plain` is refused.                                      |
| `scope`                 | no       | Space-separated. Narrowed to what your app registered; the user may narrow further, never widen. |

Miss one of the three required ones and the page stops with a message asking the user to get a fresh link from you — it does not guess.

#### The scope vocabulary

Scopes are `resource:action`, and both halves come from a closed catalog. The resources are `workspaces`, `banks`, `feeds`, `jobs`, `enrichments`, `dashboards`, `portals`, `integrations` and `tables`, each with `:read` and `:write` — except `workspaces:write`, which is reserved and is never granted to a third-party app.

You declare the scopes your app needs when you register it, and a value outside the catalog is refused there with `Every scope must be a known, non-reserved scope`. On the link itself, `scope` is intersected with the catalog and with what your app registered, so an unrecognised scope is dropped rather than honoured — and when that leaves nothing, the user is redirected back to your `redirect_uri` with `error=invalid_scope`.

`banks:read` is the one most apps want: it covers bank connections, accounts, transactions, balances and holdings. There is no `transactions:read` and no `accounts:read` — reading transactions **is** `banks:read`, narrowed by the accounts the user ticks at consent.

### `intent=reauth` also requires

| Parameter | Notes                                                                                                         |
| --------- | ------------------------------------------------------------------------------------------------------------- |
| `reauth`  | The BankSync bank id to reconnect. It must be a bank on that user's account, or the page stops with an error. |

> **A malformed parameter is dropped, not fatal:** 'Each parameter is parsed independently and a bad value degrades to "not supplied" rather than to a router error page — one broken parameter cannot discard the good ones. An empty value (?state=) is treated as absent, because a zero-length token downstream reads as "supplied but blank".'

> **What survives the round trip:** 'external\_user\_id, state and dub\_click\_id are written onto the install when the user approves, on the row that outlives the grant. So your own user identifier and your own state come back on the builder install list, and an install that arrived through a partner link keeps its attribution. Neither external\_user\_id nor dub\_click\_id is ever written to logs — the consent log line carries the workspace id, and your identifier for a person must never sit beside it.'

## The `return_to` rules

`return_to` is written by you and read by a page a stranger followed, so it is a destination BankSync must not follow blindly. There are two regimes, and they differ because only one of them has a server-side allowlist to consult.

### On `intent=install`: exact match against your registered redirect URIs

The connect page forwards `return_to` untouched to the authorization endpoint, which checks it against the redirect URIs registered on your client. The rule there is literal:

> `return_to` must be **byte-for-byte identical** to one of the redirect URIs registered on your client.

No normalisation of any kind. Not on the trailing slash, not on case, not on percent-encoding, not on the query string.

| Registered URI                  | `return_to` sent                     | Result                       |
| ------------------------------- | ------------------------------------ | ---------------------------- |
| `https://acme.example/callback` | `https://acme.example/callback`      | Matches                      |
| `https://acme.example/callback` | `https://acme.example/callback/`     | **Refused** — trailing slash |
| `https://acme.example/callback` | `https://acme.example/Callback`      | **Refused** — case           |
| `https://acme.example/callback` | `https://acme.example/callback?u=42` | **Refused** — query string   |
| `https://acme.example/callback` | `https://ACME.example/callback`      | **Refused** — host case      |

Carry your per-user context in `state`, not in the `return_to` query string. If you need several return destinations, register each one exactly as you will send it.

A non-matching `return_to` is refused, never quietly swapped for `redirect_uri`: the user is redirected to your registered `redirect_uri` with `error=invalid_request` and `error_description=return_to is not a registered redirect URI for this client`. The check runs again when the user approves, so a redirect URI you delete mid-flow fails the install rather than sending your user somewhere unregistered.

Also validated on the same rules: `return_to` must be 1–2048 characters and an absolute `http:` or `https:` URL. Present-but-malformed is fatal, because a silently ignored `return_to` is how a user ends up stranded on a page that was never meant to be the destination.

### On `add_bank` and `reauth`: an https floor

These never reach the authorization endpoint, so there is no registered-URI list to check against. The connect page applies a local rule instead: **`https:` anywhere, `http:` only on loopback** (`localhost`, `127.0.0.1`, `::1`) so you can develop against your own machine. `javascript:`, `data:`, `file:`, a relative path and an unparseable string are all refused.

> **This floor is not an allowlist:** 'It proves the destination is an https URL. It cannot prove the destination belongs to you — that is exactly why the install path defers to the authorization endpoint instead. And refusal is total: a rejected return\_to stops the page with an error rather than falling back to the BankSync home page, because a silent fallback is how a blocked redirect becomes an invisible one.'

## What the user experiences

**The round trip, intent=install**

1. **They arrive from your product** — The gate loads your app's public listing — name, logo, publisher, links, verification badge —
   and the data-usage panel derived from your manifest: the scopes you asked for, what leaves
   BankSync and to whom, the countries and data types you declared. An unverified app shows a
   warning and a trust confirmation.
2. **They sign in or sign up** — Existing BankSync users sign in. New ones create an account and get their own workspace, and
   email verification returns them to this page rather than dropping them somewhere else. email
   pre-fills this step.
3. **They connect a bank** — The bank picker runs on the connect page itself, not in the BankSync app. If their plan needs
   upgrading first, the billing step also runs here and returns them to the bank step.
4. **They approve** — BankSync hands off to its authorization endpoint. A workspace owner or admin approves, chooses
   which accounts you may read, and decides separately whether you see their email address. They
   can narrow the scopes you asked for; they cannot widen them.
5. **They come back to you** — BankSync redirects to return\_to if you sent one, otherwise to redirect\_uri, with code and
   your state.

For `add_bank` and `reauth` the flow is shorter: a signed-out arrival is sent through sign-up first, then the bank step starts by itself, and the user returns to `return_to` when it finishes.

## The round trip back

### On approval

```
https://acme.example/callback?code=bsac_9f2c…&state=sess_7Yh2Kq
```

- `code` — a single-use authorization code prefixed `bsac_`, valid for **60 seconds**. Exchange it promptly.
- `state` — present only if you sent one, returned verbatim.

The code is bound to the URI the user was actually returned to. If you sent `return_to`, exchange the code with **that** URI as `redirect_uri`, not the one you put in the original link.

### On denial

```
https://acme.example/callback?error=access_denied&error_description=User+denied&state=sess_7Yh2Kq
```

> **Denial returns to redirect\_uri, not return\_to:** 'The approval path honours return\_to. The denial path does not — a denied consent always returns to the registered redirect\_uri from the original request. If those two URLs differ in your integration, handle access\_denied on both.'

### Errors that never reach a redirect

Some failures cannot be redirected anywhere, because BankSync will not bounce a user to a URL it has not proved is yours. Those come back as an HTTP error with a JSON body of `{ "error": …, "error_description": … }`:

| Situation                                                    | `error`                     | Status |
| ------------------------------------------------------------ | --------------------------- | ------ |
| Missing or malformed parameter at the authorization endpoint | `invalid_request`           | 400    |
| `response_type` is not `code`                                | `unsupported_response_type` | 400    |
| Unknown or disabled `client_id`                              | `invalid_request`           | 400    |
| `redirect_uri` is not registered for the client              | `invalid_request`           | 400    |
| Your app is unverified and already has ten active installs   | `unauthorized_client`       | 403    |

Everything after that — an unregistered `return_to`, a suspended app, a client not permitted to use the authorization-code grant, an unknown scope, an unknown `resource` — redirects back to your registered `redirect_uri` with `error` and `error_description` set, plus `state` when you sent one.

Failures on the connect page itself (a link missing `client_id`, an unusable `return_to`, a `reauth` bank id that is not on the account) show the user a plain message telling them to go back to your app and try again. They are not redirected anywhere.

## A complete worked example

You are Acme Tax. Your app slug is `acme-tax`, your client id is `bsc_live_9d41…`, and you registered exactly one redirect URI: `https://acme.example/banksync/callback`.

**1. Build the link.** Generate a PKCE verifier and challenge and a `state` you can look up later.

```js
import crypto from 'node:crypto'

const base64url = (buf) => buf.toString('base64url')

const verifier = base64url(crypto.randomBytes(32))
const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())
const state = base64url(crypto.randomBytes(16))

// Store { verifier, state } against this user's session before you redirect.
await sessions.put(state, { verifier, userId: 'u_8813' })

const link = new URL('https://app.banksync.io/connect/acme-tax')
link.search = new URLSearchParams({
  intent: 'install',
  client_id: process.env.BANKSYNC_CLIENT_ID,
  redirect_uri: 'https://acme.example/banksync/callback',
  return_to: 'https://acme.example/banksync/callback', // exact match, byte for byte
  code_challenge: challenge,
  code_challenge_method: 'S256',
  scope: 'banks:read', // the transactions/accounts read scope — see the vocabulary above
  state,
  external_user_id: 'u_8813', // your id for this user
  email: 'sam@acme-customer.example', // pre-fills sign-in
  country: 'GB',
}).toString()

res.redirect(link.toString())
```

The link that produces (wrapped for readability):

```
https://app.banksync.io/connect/acme-tax
  ?intent=install
  &client_id=bsc_live_9d41...
  &redirect_uri=https%3A%2F%2Facme.example%2Fbanksync%2Fcallback
  &return_to=https%3A%2F%2Facme.example%2Fbanksync%2Fcallback
  &code_challenge=E9Melhoa2Owvm...
  &code_challenge_method=S256
  &scope=banks%3Aread
  &state=sess_7Yh2Kq
  &external_user_id=u_8813
  &email=sam%40acme-customer.example
  &country=GB
```

**2. The user does their part.** They see Acme Tax, its logo, its publisher, and a line saying it will read their transactions and accounts from the banks they pick. They sign up, connect their UK bank, tick two accounts, approve.

**3. Handle the return.**

```js
app.get('/banksync/callback', async (req, res) => {
  const { code, state, error, error_description } = req.query

  // Denial and pre-approval failures both land here.
  if (error) return res.status(400).send(`BankSync returned ${error}: ${error_description}`)

  // Never trust a state you did not issue.
  const session = await sessions.take(state)
  if (!session) return res.status(400).send('Unknown state')

  const tokens = await fetch('https://auth.banksync.io/token', {
    method: 'POST',
    headers: { 'content-type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code,
      // The URI the user was actually returned to — your return_to.
      redirect_uri: 'https://acme.example/banksync/callback',
      client_id: process.env.BANKSYNC_CLIENT_ID,
      client_secret: process.env.BANKSYNC_CLIENT_SECRET, // confidential clients only
      code_verifier: session.verifier,
    }),
  }).then((r) => r.json())

  await saveTokensFor(session.userId, tokens)
  res.redirect('/dashboard?connected=1')
})
```

**4. Confirm what you got.** Call `GET /v1/whoami` with the access token. It returns the workspace id and name, the granted scopes, the plan tier, the authentication method, and — for an app token — `appId` and `installId`. That is your handle on the install.

**5. Later, send them back for more.** Another bank:

```
https://app.banksync.io/connect/acme-tax
  ?intent=add_bank
  &return_to=https%3A%2F%2Facme.example%2Fbanksync%2Fdone
```

Or to repair a connection that needs reconnecting:

```
https://app.banksync.io/connect/acme-tax
  ?intent=reauth
  &reauth=bank_7Q2f...
  &return_to=https%3A%2F%2Facme.example%2Fbanksync%2Fdone
```

Both skip the gate and the consent screen and return the user to `return_to` when the bank step finishes.

> **The account scoping is real:** 'The accounts the user ticked are enforced at the resource server on every read, not just recorded. An access token whose authorization details cannot be decoded is refused with a 403 rather than treated as unscoped — and a token that carries an empty account list reads nothing at all, rather than everything.'

## Going direct to the authorization endpoint

You do not have to use the hosted page. `https://auth.banksync.io/authorize` accepts the same connect parameters — `return_to`, `external_user_id`, `email`, `country`, `intent`, `dub_click_id` — alongside the standard OAuth ones, plus `resource`, `authorization_details` (RFC 9396, max 16 KiB) and `request_uri` for a pushed authorization request. It is the right choice when your user already has a BankSync account and a connected bank, and all you need is a grant.

What you give up is everything the hosted page adds: the identity gate, sign-up, the inline bank step, and the paywall handling. The `intent` parameter is accepted and validated there but changes nothing — the intents are implemented by the hosted page, not by the authorization endpoint.

## Limits worth knowing before you build

- **Ten active installs while unverified.** The eleventh install fails at the approval step with `unauthorized_client` and HTTP 403, and the message names the cap. It applies while your app is `unverified` or `in_review`; a `draft` app can only be installed into the workspace that owns it, and verified apps are uncapped.
- **Authorization codes live 60 seconds.** Exchange immediately; do not queue.
- **Only owners and admins can approve.** For a client portal, only the parent workspace's owner or admin can. A viewer or editor cannot complete an install.
- **PKCE always.** There is no way to skip `code_challenge`, and `plain` is refused.
- **Scopes only narrow.** The user's approval is intersected with what your app registered and with what the platform allows.

## Related guides

- [Building a BankSync app](/docs/apps/apps-reference) — the manifest, the install, and your endpoint.
- [Authentication](/docs/api/authentication) — the full OAuth 2.1 flow and token handling.
- [Register an OAuth app](/docs/apps/registering-an-app) — clients, redirect URIs and secret rotation.
- [Listing and publishing your app](/docs/apps/app-listing) — getting verified and listed.
- [Apps you install](/docs/integrations/apps) — the same flow from your user's side.
