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
- The three intents
- Parameters
- Always accepted
- intent=install also requires
- intent=reauth also requires
- The returnto rules
- On intent=install: exact match against your registered redirect URIs
- On addbank and reauth: an https floor
- What the user experiences
- The round trip back
- On approval
- On denial
- Errors that never reach a redirect
- A complete worked example
- Going direct to the authorization endpoint
- Limits worth knowing before you build
- Related guides
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
The page is public on purpose
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
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
What survives the round trip
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_tomust 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
What the user experiences#
The round trip, intent=install
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.
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.
emailpre-fills this step.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.
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.
They come back to you
BankSync redirects to
return_toif you sent one, otherwise toredirect_uri, withcodeand yourstate.
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_7Yh2Kqcode— a single-use authorization code prefixedbsac_, 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_7Yh2KqDenial returns to redirect_uri, not return_to
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.
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=GB2. 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.
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%2FdoneOr 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%2FdoneBoth skip the gate and the consent screen and return the user to return_to when the bank step finishes.
The account scoping is real
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_clientand HTTP 403, and the message names the cap. It applies while your app isunverifiedorin_review; adraftapp 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, andplainis 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 — the manifest, the install, and your endpoint.
- Authentication — the full OAuth 2.1 flow and token handling.
- Register an OAuth app — clients, redirect URIs and secret rotation.
- Listing and publishing your app — getting verified and listed.
- Apps you install — the same flow from your user's side.
Use this page with your AI assistant
Every BankSync doc is available as plain Markdown for agents and LLMs.