The connect link

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.

13 min read

On this page

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.

intentWhat happensEnds at
installGate → 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_bankSkips the gate and the consent screen. Runs the bank step, then returns. For a user who already installed you.Your return_to
reauthSkips 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#

ParameterNotes
intentinstall, add_bank or reauth. Anything unrecognised falls back to install.
stateOpaque value of yours, round-tripped back to you. Use it for CSRF protection and to carry your own context.
return_toWhere to send the user when the flow finishes. See the rules below — they differ per intent.
external_user_idYour own identifier for this user. Printable ASCII, no whitespace, up to 255 characters.
emailPre-fills sign-in and sign-up. Up to 320 printable ASCII characters, exactly one @, which may be neither first nor last.
countryTwo letters, ISO-3166 alpha-2. Biases the bank picker toward that market.
dub_click_idAffiliate 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.

ParameterRequiredNotes
client_idyesYour client's id.
redirect_uriyesMust exact-string-match a redirect URI registered on that client.
code_challengeyesPKCE. Not optional, even for confidential clients.
code_challenge_methodnoDefaults to S256, and must be S256. plain is refused.
scopenoSpace-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#

ParameterNotes
reauthThe 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 URIreturn_to sentResult
https://acme.example/callbackhttps://acme.example/callbackMatches
https://acme.example/callbackhttps://acme.example/callback/Refused — trailing slash
https://acme.example/callbackhttps://acme.example/CallbackRefused — case
https://acme.example/callbackhttps://acme.example/callback?u=42Refused — query string
https://acme.example/callbackhttps://ACME.example/callbackRefused — 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": … }:

SituationerrorStatus
Missing or malformed parameter at the authorization endpointinvalid_request400
response_type is not codeunsupported_response_type400
Unknown or disabled client_idinvalid_request400
redirect_uri is not registered for the clientinvalid_request400
Your app is unverified and already has ten active installsunauthorized_client403

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.

JavaScript
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.

JavaScript
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.

Use this page with your AI assistant

Every BankSync doc is available as plain Markdown for agents and LLMs.