Building a BankSync app
Register an app, declare a manifest, take installs through the connect link, and receive signed events on your endpoint — the developer reference for the BankSync apps platform.
17 min read
On this page
A BankSync app is your product, installed into someone else's BankSync workspace. It reads the bank data that user chose to give it, and — if you ask for it — receives that data on your own endpoint as it syncs. The unit you build against is the install: one app in one workspace, holding a grant against banks the user picked.
This page is the developer reference for that model. The hosted flow your users go through is documented separately in the connect link; getting your app listed is covered in listing and publishing.
Where this is up to
Before you start
The model in one pass#
| Noun | What it is |
|---|---|
| App | What the user installed: name, slug, logo, listing, verification status. One per product. |
| Client | Credentials your software authenticates with — a client_id, and a client_secret for confidential clients. |
| Manifest | An immutable, versioned declaration of what the app needs and contributes. The install resolver reads nothing else. |
| Install | App × Workspace. Holds the grant, the banks the user chose, your external_user_id, and everything the app created there. |
| Endpoint | One HTTPS URL per app, with its own signing secret, where subscribed events are delivered. |
Two capabilities exist in v1:
- Read — the user's grant over the public
/v1API. Ordinary OAuth 2.1 authorization-code with PKCE. - Receive — an app-owned feed destination that delivers rows to your endpoint as they sync.
The user is BankSync's customer, not yours
Register the app#
An app is an OAuth app plus a manifest. Registration, client credentials, redirect URIs and secret rotation work exactly as documented in Register an OAuth app — create the app under Developers → Apps, then add a confidential or public (PKCE) client under Credentials.
Two things are specific to the apps platform:
- A publisher profile. Every app is published under a publisher owned by your workspace: a public developer name, an optional URL and a support email. One profile, shared by every app you publish. See listing and publishing.
- The Developer Agreement. You accept a versioned agreement before your first app is created, and again when the version changes. App creation is refused until you have.
Your redirect URIs matter more here than in a plain OAuth app: the connect link's return_to must exact-match one of them, so register every URL you will send users back to.
The manifest#
The manifest is what your app declares about itself. It is the single source of truth for the install screen, for the install resolver, and for the listing's derived "countries and data types" copy — you never write that copy yourself.
{ "schemaVersion": 1, "capabilities": { "read": true, "receive": true, "tables": true, "feeds": true, "views": false }, "connectivity": { "countries": ["GB"], "dataTypes": ["transactions"], "accountTypes": ["depository", "credit"], "historyDays": 730 }, "tables": [ { "key": "tax-summary", "name": "Tax summary", "kind": "freeform", "fields": [ { "key": "period", "label": "Period", "kind": "text" }, { "key": "net_total", "label": "Net total", "kind": "number" } ] } ], "feedTemplates": [ { "key": "tx-to-acme", "name": "Transactions to Acme", "source": "sync", "dataType": "transactions", "bankSlot": { "match": "connected_in_flow" }, "destination": { "kind": "app_endpoint" }, "enrichments": [] } ], "events": ["install.created", "install.revoked", "feed.run_completed"], "egress": { "read": true, "receive": true }}Fields#
| Field | Rules |
|---|---|
schemaVersion | Must be 1. |
capabilities | Five booleans — read, receive, tables, feeds, views. All five required. This is what the install screen asks the user to consent to. |
connectivity.countries | Up to 50 two-letter country codes. |
connectivity.dataTypes | At least one feed data type. The bank-sync set is transactions, balances, holdings, trades, orders, loans; the enum also carries the document types receipts, invoices and documents. |
connectivity.accountTypes | Optional: depository, credit, investment, loan. |
connectivity.historyDays | Optional, 1–3650. How far back you ask to read. |
connectivity.providerInstances | Optional, up to 20. Narrows which providers the bank step offers. |
tables | Up to 20 declarations. Each has a key (^[a-z0-9-]{2,40}$), a name, a kind (typed or freeform), an optional resourceType, and up to 200 fields. |
feedTemplates | Up to 20. Each carries a bank slot, a destination, optional field mappings and schedule, and up to 10 enrichment steps. A step that sends — alert, monitor, budget, recurring, fees, anomaly, digest — has its destination kinds and hosts shown to staff at review and to the user on the consent screen. |
events | Which of the event types below you subscribe to. |
egress | read and receive booleans, plus an optional per-market markets map to narrow yourself below the platform default. |
Rules the parser enforces#
These are refusals at publish time, not warnings:
- 64 KB. The whole manifest, measured as UTF-8 bytes of its canonical (key-sorted) JSON — not string length. A manifest is fetched on every install and every install-screen render.
- Egress may never exceed capability.
egress.readrequirescapabilities.read;egress.receiverequirescapabilities.receive. A manifest that moves data it never asked permission to touch is refused. - No credential-shaped keys. A table or field key matching
credential,token,secret,password,passphrase,apikey,api_key,private_key,access_keyorclient_secret(as a whole underscore-delimited word) is refused. Domain nouns likeaccount_id,bank_nameandsourceare fine — this list only blocks credential words. - Keys are unique within
tables, within each table'sfields, and acrossfeedTemplates. - Feed templates must resolve. A template whose destination is
declared_tablemust name atableKeythe same manifest declares. Otherwise the failure would surface in the user's workspace as an empty feed instead of as your mistake. - Enrichment steps are held to the same rules as a hand-made one. Every step in
feedTemplates[].enrichmentsgoes through the validatorPOST /v1/enrichmentsuses: the config must belong to the step's owntypeand must be valid for its data type, and any destination it names must satisfy the outbound webhook policy —https://only, no credentials embedded in the URL, no private, reserved or unresolvable host, and onlyX-*custom headers. SMS destinations are refused because nothing delivers them yet. A step that does not pass is refused at publish, and — for manifests published before this rule existed — refused again at install, where it is skipped and reported rather than created. - Field keys match
^[a-z][a-z0-9_]{0,63}$; table and template keys match^[a-z0-9-]{2,40}$. - A market map can only narrow.
egress.marketsmay switch a market off for your app; it can never switch on a market BankSync has closed.
Versions are immutable and content-addressed
Publishing returns a reviewRequired flag. On a verified app, widening your scopes or your egress does not go live immediately — it re-enters review, and the console shows the version as pending rather than current.
The install lifecycle#
From link to live install
You send the user to the connect link
https://app.banksync.io/connect/<slug>with your OAuth parameters plusstate,return_toandexternal_user_id. See the connect link for every parameter.They see who is asking
The install screen renders your identity and your manifest: scopes, what leaves and to whom, the countries and data types you declared, and your history depth. You do not write this copy.
They sign in and connect a bank
Existing users sign in; new users get an account and a personal workspace. The bank step runs on the same page, filtered by your connectivity profile.
They approve
A workspace owner or admin approves, picks which accounts you may read, and decides separately whether you see their email address.
The install is recorded
An install row is created for your app in their workspace, the grant is attached to it, and the
external_user_id,stateanddub_click_idyou sent are written onto it. The install pins the manifest version the user just consented to, so a later publish cannot widen an install retroactively.The resolver materialises what you declared
Your declared tables, feed templates and their enrichment steps are created in that workspace, in a background workflow rather than in the consent request. Every resource is addressed by an id derived from the install and your declared key, so a retry adopts what already exists instead of creating a second copy — and a resolve that fails half way through resumes where it stopped. A feed template whose bank slot cannot be filled still materialises, as a feed with no source you can see and repair, rather than being silently skipped.
They come back to you
BankSync redirects to your
return_towithcodeand yourstate. Exchange the code at/tokenfor tokens, exactly as in any OAuth 2.1 flow.
Later, the same link serves intent=add_bank and intent=reauth&reauth=<bankId>: both skip the gate and the consent screen, run the bank step, and return the user to your return_to.
What you can see about an install#
This is the privacy boundary, and it is enforced by the response schema rather than by convention:
| You see | You never see |
|---|---|
externalUserId — the identifier you supplied | The workspace id |
state — the value you attached | The workspace name |
createdAt | Which bank they connected |
bankCount — how many, never which | Any account identity or number |
health — healthy, attention or unknown | Any balance or transaction outside your scopes |
lastUsedAt — reserved; nothing writes it yet, so it is always null | |
installerEmail — only if they ticked the consent line, otherwise null |
When a user revokes, their email is removed from your view of the install.
externalUserId and state are the values you put on the connect link, written onto the install row when the user approves. That is what makes reconciliation your side possible without a workspace id: list your installs, join on your own user identifier, and you know which of your accounts completed. dub_click_id is persisted on the install too, so an install that arrived through a partner link keeps its attribution — it is not part of this projection, though, because it is BankSync's record of the referral rather than yours.
Reading data#
Reads go through the ordinary public API with the user's access token. Two things differ from an API key:
- Account scoping is enforced at the resource server. The accounts the user ticked at consent are applied on every read; you cannot widen them by asking for more.
- Revocation fails closed. A revoked grant stops working within cache propagation, not when the token expires. Build for a
401arriving mid-session.
Your app also gets its own rate bucket per workspace, so a noisy app cannot exhaust a workspace's whole API budget.
The endpoint#
One endpoint per app, in v1 — not one per install. You set its URL and its event subscriptions from the Endpoint tab of the developer console.
| Operation | What happens |
|---|---|
| First save | Creates the endpoint and returns the signing secret once. It is never returned again. |
| Update | Changes the URL or the subscription list, and re-arms an endpoint that tripped. Returns no secret. |
| Rotate | Returns a new secret once, and an expiry for the old one seven days out. Until then every delivery carries a signature for each, so a rotation is not an outage. |
| Send test event | Delivers an endpoint.test event and reports back whether it was delivered, the HTTP status (null on DNS, TLS or timeout failures), and the duration in milliseconds. |
The secret is held two ways, because it does two jobs. A SHA-256 hash proves a secret you paste back to us is the one we issued. An AES-256-GCM ciphertext, under a key that lives outside the database, is what a delivery is actually signed with — a hash cannot produce a signature, so an endpoint stored only as a hash could never send you anything verifiable.
Your endpoint's subscription list is intersected with your current manifest's events at save time, plus endpoint.test, which is always allowed. Subscribe to something your manifest does not declare and the save is refused with a 400 naming the events and telling you to publish a manifest version that declares them first. The reason is consent: the install screen renders manifest.events, and that list is what the user agreed to have sent. Without the intersection you could ship a manifest declaring nothing, pass review, and then widen the endpoint to everything with one save — no review, no re-consent. An app with no published manifest gets endpoint.test and nothing else.
The endpoint carries a state you can see in the console: active, disabled_failing, or disabled_by_owner, which is written when the owning workspace is deleted rather than by anything you do. A consecutive-failure counter, the last delivery time, its HTTP status and its error are all recorded, and the delivery log is paginated. Delivery attempts are kept for 90 days and then swept nightly, so pull anything you need for your own records inside that window.
A failing endpoint trips, and saving it again re-arms it
Verifying a delivery#
App deliveries are signed, in the same wire format BankSync's webhook destinations already use: the Standard Webhooks envelope, so an existing verifier library works unchanged. That format is documented in full — headers, retry table, per-data-type row schemas — in the webhooks developer reference.
Every delivery carries three headers:
| Header | Value |
|---|---|
webhook-id | The delivery id, which is also the envelope's id. Derived deterministically, so a retry reuses it — dedupe on this. |
webhook-timestamp | Unix seconds at send time. |
webhook-signature | v1,<base64 HMAC-SHA256> over the exact string {webhook-id}.{webhook-timestamp}.{raw body}. During a rotation window the header carries one space-separated value per secret. |
The HMAC key is the base64-decoded payload of your whsec_ secret, not the prefixed string. Sign the raw request body: re-serialising the JSON changes the bytes and breaks the comparison.
Alongside those, banksync-app-id and banksync-install-id identify the install, and banksync-external-user-id carries your own identifier for that user when you supplied one — omitted entirely rather than sent empty, so "no id was supplied" and "the id is empty" stay distinguishable.
Turn signature verification on
Events#
Subscribe to any subset of these in your manifest and on the endpoint. The names are a closed list: an unrecognised value is refused at publish time rather than accepted and then silently never firing.
| Event | Fires when | Live |
|---|---|---|
install.created | A user finished an install of your app. | Yes |
install.revoked | A user disconnected you, or you deleted the app. | Yes |
connection.created | A bank was connected for your install. | No |
connection.requires_reauth | A bank connection needs the user to reconnect. | No |
connection.disconnected | A bank connection went away. | No |
feed.run_completed | One of your feeds finished a run. | Yes |
feed.run_failed | One of your feeds failed a run. | Yes |
endpoint.test | You clicked Send test event. | Yes |
The three connection events do not fire yet
install.revoked is the one you must handle: an app cannot comply with a withdrawal of consent it was never told about. It carries reason — user when they disconnected you, app_deleted when you deleted your own app — plus remainingGrants, which is zero when the install is gone entirely rather than one credential of several. It is dispatched after the revocation has already landed, so by the time you receive it your tokens are dead.
Remove-the-app does not send install.revoked
One delivery per install activation, not per install: a user who revoked and came back gets a second install.created, because the delivery id is derived from the install's update time as well as its id. Every delivery id is deterministic and reused across BankSync's own retries, so dedupe on webhook-id rather than assuming exactly-once.
Revocation and uninstall, from your side#
Two different user actions end your access. Both cut tokens.
- Disconnect (from the user's connected-apps list) revokes the grant. The revocation is written to a list every resource server consults on every request, so your tokens stop working within propagation rather than at token expiry. You get an
install.revokedwithreason: user. - Remove the app revokes every grant the install holds first, and only then removes what your app put in the workspace: its feeds are paused and deleted, its enrichment steps deleted, its delivery connection removed, and its tables kept or deleted according to the choice the user made in the dialog. A kept table has your app's stamp cleared — the workspace owns it outright afterwards — and any rule of the user's own that referenced one of your deleted feeds is repaired rather than left pointing at nothing.
Both mean the same thing to your code: build for a 401 arriving mid-session, and do not retry it as a transient error.
Revocation is ordered access-first
Limits and things that do not exist yet#
- Ten active installs until you are verified. Enforced at the approval step: the eleventh install fails with
unauthorized_clientand HTTP 403. It applies while your app isunverifiedorin_review;draftapps are confined to the workspace that owns them, and verified apps are uncapped. - One endpoint per app, not per install.
- No
client_credentialsgrant. Every read is on behalf of a user who granted it. - No per-app metering. You see delivery counts and health, not a usage meter.
- No changelog, no deprecation or sunset flow. If you retire an app there is no built-in notice to installed users.
- No third-party widget code. Apps ship dashboard and widget templates; running your own widget code in someone else's workspace is not available.
- Co-developers are workspace admins. There is no separate app team model — anyone who needs to manage the app is an admin of the owning workspace.
Related guides#
- The connect link — the hosted install flow, every parameter, and a worked example.
- Listing and publishing your app — screenshots, the publisher profile, and what re-enters review.
- Register an OAuth app — the app record, clients, redirect URIs and secret rotation.
- Authentication — the OAuth 2.1 authorization-code + PKCE flow end to end.
- Webhooks developer reference — the envelope, retries and row schemas app deliveries are modelled on.
- Apps you install — what your users see on the other side.
Use this page with your AI assistant
Every BankSync doc is available as plain Markdown for agents and LLMs.