---
title: "Building a BankSync app"
description: "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."
section: "Apps"
canonical: "https://banksync.io/docs/apps/apps-reference"
specs: ["apps"]
operationBindings: [{"capability":"apps","operation":"apps.developer.list"},{"capability":"apps","operation":"apps.developer.create"},{"capability":"apps","operation":"apps.developer.get"},{"capability":"apps","operation":"apps.developer.update"},{"capability":"apps","operation":"apps.developer.delete"},{"capability":"apps","operation":"apps.developer.submit-for-review"},{"capability":"apps","operation":"apps.manifest.get"},{"capability":"apps","operation":"apps.manifest.publish"},{"capability":"apps","operation":"apps.endpoint.get"},{"capability":"apps","operation":"apps.endpoint.update"},{"capability":"apps","operation":"apps.endpoint.rotate"},{"capability":"apps","operation":"apps.endpoint.test"},{"capability":"apps","operation":"apps.deliveries.list"},{"capability":"apps","operation":"apps.builder-installs.list"},{"capability":"apps","operation":"apps.clients.list"},{"capability":"apps","operation":"apps.clients.create"},{"capability":"apps","operation":"apps.clients.update"},{"capability":"apps","operation":"apps.clients.delete"},{"capability":"apps","operation":"apps.clients.rotate-secret"}]
---

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](/docs/apps/connect-link); getting your app listed is covered in [listing and publishing](/docs/apps/app-listing).

> **Where this is up to:** 'The install path is built end to end and the platform is live in production: the resolver creates the tables, feeds and enrichment steps your manifest declares, install, connection and feed events are delivered to your endpoint signed, a repeatedly failing endpoint trips and you get an email about it, and removing your app revokes your grants and takes its furniture back out. One gap worth knowing: connection.created is sent once per bank, when it first reports healthy, and there is no event for a connection RECOVERING from requires\_reauth. Each section below says which side of that line it is on.'

> **Before you start:** 'You need the Admin or Owner role in the BankSync workspace that will own the app. That workspace is also your sandbox: a draft app installs only into the workspace that owns it, so you can exercise the whole flow before anyone else sees it.'

## 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 `/v1` API. 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:** Every end user of your app holds their own BankSync account and accepts BankSync's terms on the install screen. You cannot hold a container of users on their behalf. This is not a design preference: Salt Edge's partner terms, Plaid's developer terms and Australia's CDR rules all put the consumer-facing relationship with the party that holds the provider agreement.

## 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](/docs/apps/registering-an-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:

1. **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](/docs/apps/app-listing).
2. **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.

```json
{
  "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.read` requires `capabilities.read`; `egress.receive` requires `capabilities.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_key` or `client_secret` (as a whole underscore-delimited word) is refused. Domain nouns like `account_id`, `bank_name` and `source` are fine — this list only blocks credential words.
- **Keys are unique** within `tables`, within each table's `fields`, and across `feedTemplates`.
- **Feed templates must resolve.** A template whose destination is `declared_table` must name a `tableKey` the 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[].enrichments` goes through the validator `POST /v1/enrichments` uses: the config must belong to the step's own `type` and 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 only `X-*` 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.markets` may switch a market off for your app; it can never switch on a market BankSync has closed.

> **Versions are immutable and content-addressed:** 'Publishing a manifest creates a version with a SHA-256 digest of its canonical JSON. Two byte-different uploads of the same declaration produce the same digest, so a re-serialised file is not a new version your users must re-consent to. An installed workspace stays on the manifest version it consented to until it accepts a new one.'

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**

1. **You send the user to the connect link** — https\://app.banksync.io/connect/\<slug> with your OAuth parameters plus state, return\_to
   and external\_user\_id. See the connect link for every parameter.
2. **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.
3. **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.
4. **They approve** — A workspace owner or admin approves, picks which accounts you may read, and decides separately
   whether you see their email address.
5. **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, state and dub\_click\_id you 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.
6. **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.
7. **They come back to you** — BankSync redirects to your return\_to with code and your state. Exchange the code at
   /token for 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 `401` arriving 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:** 'After 20 consecutive failed deliveries the endpoint flips to disabled\_failing and BankSync stops sending. A successful delivery resets the counter to zero, so only a sustained outage trips it. You are emailed when it happens — the app name, the endpoint host, the failure count and our description of the last failure — because the breaker is otherwise silent at exactly the person who has to fix it: nothing arrives, so your logs show nothing. Fix your server, then save the endpoint again from the Endpoint tab: a save from disabled\_failing sets the state back to active and the failure counter back to zero. Nothing else clears it, because a disabled endpoint is skipped before it is sent to and so can never produce the success that would.'

### Verifying a delivery

App deliveries are signed, in the same wire format BankSync's webhook destinations already use: the [Standard Webhooks](https://www.standardwebhooks.com) 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](/docs/api/webhooks-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:** 'Nothing reaches your endpoint unsigned. An endpoint with no usable ciphertext fails its delivery loudly and refuses its test send rather than falling back to an anonymous POST, so a handler that enforces verification cannot be starved of events by a degradation. Send test event is signed by the same code path as a real delivery — envelope, headers and signature are built identically — which is what makes a green probe worth anything.'

### 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:** Provider webhooks already derive them, but the dispatcher that would send them is injected by a composition root and no deployment supplies one — so a connection event is computed and then dropped. You can declare and subscribe to them (they are forward-compatible), but do not build a handler that a user's reauth depends on reaching. Until they land, a stale connection surfaces to you as feed.run\_failed on the next run.

`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:** The user's Disconnect and your own app deletion both dispatch it. Removing the app from the Integrations surface revokes your grants through the same machine door and runs the uninstall cascade, but dispatches nothing, so that withdrawal reaches you only as a 401 on your next read. Treat a 401 as terminal rather than transient; do not wait for an event that will not arrive.

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.revoked` with `reason: 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:** 'The uninstall revokes before it deletes, and it refuses to proceed at all if it cannot revoke — a removal that answered 200 without ending your access would be worse than one that errored, because the user stops looking. So an app whose feeds have vanished is an app whose tokens are already dead, never the other way round.'

## 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_client` and HTTP 403. It applies while your app is `unverified` or `in_review`; `draft` apps are confined to the workspace that owns them, and verified apps are uncapped.
- **One endpoint per app**, not per install.
- **No `client_credentials` grant.** 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](/docs/apps/connect-link) — the hosted install flow, every parameter, and a worked example.
- [Listing and publishing your app](/docs/apps/app-listing) — screenshots, the publisher profile, and what re-enters review.
- [Register an OAuth app](/docs/apps/registering-an-app) — the app record, clients, redirect URIs and secret rotation.
- [Authentication](/docs/api/authentication) — the OAuth 2.1 authorization-code + PKCE flow end to end.
- [Webhooks developer reference](/docs/api/webhooks-reference) — the envelope, retries and row schemas app deliveries are modelled on.
- [Apps you install](/docs/integrations/apps) — what your users see on the other side.
