> ## Documentation Index
> Fetch the complete documentation index at: https://docs.synci.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Managed apps

> Offer bank connections inside your own product to people who have no Synci account, through the Managed Users API and the hosted connection portal.

A **Managed app** lets you offer bank connections inside your own product to people who do not have a Synci account and never will. You register each of your users with Synci through a server-to-server API, send them to a hosted portal to connect their bank, and then read their financial data with a token scoped to that one person.

You pay for their connections out of your own Synci plan. They never see Synci branding beyond the portal, never create a password, and never get a bill.

<Info>
  If your users already have (or would happily create) their own Synci accounts, you want a [Standard OAuth app](/guides/oauth) instead: it is simpler, and they pay for their own connections.
</Info>

## How it fits together

```mermaid theme={"dark"}
sequenceDiagram
    autonumber
    participant User as Your user
    participant App as Your app
    participant Synci
    participant Bank
    App ->> Synci: POST /managed/v1/users (email)
    Synci -->> App: user_id + access & refresh tokens
    App ->> Synci: POST /managed/v1/users/{user_id}/portal-sessions
    Synci -->> App: single-use portal URL (10 min)
    App ->> User: Send them to that URL
    User ->> Synci: Portal opens, already signed in
    User ->> Bank: Authorizes their bank
    Bank ->> Synci: Consent result
    Synci ->> User: Back to your return URL
    App ->> Synci: GET /api/v1/finance/accounts (Bearer)
    Synci -->> App: That user's accounts, balances, transactions
```

Two separate surfaces are involved, and it is worth keeping them straight:

| Surface | Base URL | Authentication | What it is for |
| - | - | - | - |
| **Managed Users API** | `https://api.synci.io/managed/v1` | HTTP Basic (`client_id`:`client_secret`) | Managing your users: register, list, update, delete, mint tokens, mint portal links |
| **Data API** | `https://api.synci.io/api/v1` | Bearer token for one user | Reading and managing that user's financial data |

The Managed Users API has its own reference: [Managed Users API](/api-reference/managed/introduction).

## Create the app

In the Synci dashboard, go to **Developers → Apps → New app** and choose **Managed users** as the app type.

<Warning>
  The choice is permanent. It decides who is billed for every connection the app creates, so it cannot be changed afterwards. If you pick wrong, create a new app.
</Warning>

You get a **client ID** and a **client secret** (shown once, prefixed `oac_`). Both are used as HTTP Basic credentials on the Managed Users API. Store the secret on your server; it is never safe in a browser or a mobile binary.

A Managed app has **no redirect URIs**; the field is rejected. Nobody is sent through an OAuth consent screen, so there is nowhere to redirect to. What you configure instead is a **portal return URL** (see [App settings](#app-settings)).

### What a Managed app may request

A Managed app is restricted to bank-data scopes. These eight and no others:

`profile:read` · `accounts:read` · `accounts:write` · `transactions:read` · `transactions:write` · `sensitive:read` · `financial-connections:read` · `financial-connections:write`

Rules, transfer links, destinations and webhooks are deliberately out of reach: those are consumer-product features that belong to a Synci account, and your users do not have one. See the scope table in [Authentication](/guides/authentication#scopes).

## Connect a user, end to end

<Steps>
  <Step title="Authenticate to the Managed Users API">
    Standard HTTP Basic. Your client ID is the username, your client secret is the password.

    ```bash theme={"dark"}
    curl -u "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET" \
      https://api.synci.io/managed/v1/users \
      -H "Accept: application/json"
    ```

    | Response | Meaning |
    | - | - |
    | `401` `invalid_client` | Bad or missing credentials, or the app is not a Managed app. Deliberately indistinguishable, so this endpoint cannot be used to discover which client IDs exist. |
    | `403` `client_suspended` | The app has been suspended by Synci. |

    Rate limits are **300 requests/minute per client** and 600/minute per source address. Errors carry `error` and `message` keys.
  </Step>

  <Step title="Register the user">
    <CodeGroup>
      ```bash Request theme={"dark"}
      curl -X POST https://api.synci.io/managed/v1/users \
        -u "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET" \
        -H "Content-Type: application/json" \
        -d '{"email":"alice@example.com","external_user_id":"acct-9001"}'
      ```

      ```json Response (201 Created) theme={"dark"}
      {
        "user_id": "9f1c2e64-1f3a-4c77-9b21-8a4c5d6e7f80",
        "external_user_id": "acct-9001",
        "email": "alice@example.com",
        "email_verified": false,
        "onboarded_at": null,
        "limits": {
          "financial_connections": null
        },
        "created_at": "2026-08-28T09:12:00+00:00",
        "token_type": "Bearer",
        "expires_in": 3600,
        "access_token": "eyJ0eXAiOi...",
        "refresh_token": "def50200..."
      }
      ```
    </CodeGroup>

    | Field | Rules |
    | - | - |
    | `email` | **Required.** Valid address, max 255. |
    | `external_user_id` | Optional, max 255. Your own identifier for this person, and the easiest way to reconcile later. |
    | `name` | Optional, max 255. Defaults to a title-cased version of the email local part. |
    | `connection_limit` | Optional integer, 0 to 20. A per-user cap on financial connections. It caps **all** connection types, not just bank. |

    **`201 Created`** returns the user *and* their first token pair, unwrapped at the top level.

    `user_id` is an opaque UUID with no relationship to Synci's internal identifiers, and it is scoped to your app. It is the id you use everywhere else. Asking for another developer's user returns `404`, identical to asking for one that does not exist.

    Registration is atomic: if the token cannot be issued, the user is not created.

    | Status | `error` | When |
    | - | - | - |
    | `409` | `email_already_registered` | You already registered that address. Uniqueness is **per app**: another developer can register the same person, and an address belonging to a Synci account can still be registered. |
    | `409` | `external_user_id_taken` | You already used that `external_user_id`. |
    | `403` | `testing_limit_reached` | An unreviewed app hit the registration cap. The response includes a `limit` key. Counted across **all** your unreviewed apps, not per app. |
    | `402` | `subscription_required` | Your app is live but you have no Synci plan. Your users' connections draw on your plan's connection quantity, so there is nothing for them to draw on. |
  </Step>

  <Step title="Send them to the portal">
    Your user connects their bank in Synci's hosted **connection portal**. You do not build this, and you never touch their bank credentials.

    Mint a single-use link:

    <CodeGroup>
      ```bash Request theme={"dark"}
      curl -X POST https://api.synci.io/managed/v1/users/USER_ID/portal-sessions \
        -u "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET" \
        -H "Content-Type: application/json" \
        -d '{"return_url":"https://yourapp.com/banks/done"}'
      ```

      ```json Response theme={"dark"}
      {
        "data": {
          "url": "https://app.synci.io/portal/session?token=...&app_client_id=...",
          "expires_at": "2026-08-28T09:22:00+00:00"
        }
      }
      ```
    </CodeGroup>

    Send the user to `url`. They arrive already signed in, with no password, no code and no Synci account.

    <Frame caption="The Synci connection portal, as your user sees it.">
      <img src="https://mintcdn.com/synci/6HoxVjb_wmNxVtbF/images/connection-portal.png?fit=max&auto=format&n=6HoxVjb_wmNxVtbF&q=85&s=1b451430b4f44fda7403f9afad204033" alt="The Synci connection portal showing the app's name, a bank picker, and the user's connected accounts." width="3454" height="1986" data-path="images/connection-portal.png" />
    </Frame>

    Your app's name and logo appear here once it is approved; until then the portal tells the user it has not been reviewed yet and shows an initial avatar instead.

    <Warning>
      **The link lives 10 minutes and is strictly single-use.** Mint it at the moment you are about to redirect, not in advance. A replayed link is dead.
    </Warning>

    **`return_url` is optional.** Provide it to override the app default for this one visit, which is useful when you know which of your screens they left from. Omit it to use the app's configured `portal_return_url`.

    **An expired link degrades gracefully.** The app id rides along in the URL, so a user who opens a stale link is offered an emailed sign-in code by the portal instead of hitting a dead end. Nothing for you to build; see [When a link expires](#when-a-link-expires).
  </Step>

  <Step title="Read their data">
    Use the access token from registration. It is an ordinary Bearer token against the normal API:

    ```bash theme={"dark"}
    curl "https://api.synci.io/api/v1/finance/accounts" \
      -H "Authorization: Bearer THEIR_ACCESS_TOKEN" \
      -H "Accept: application/json"
    ```

    The token reaches **only that user's data**, and only within your app's scopes. Everything outside those scopes returns `403`; another user's data returns `404`.

    Unlike a Standard app (where the user picks which accounts to share on a consent screen), a Managed app's token sees **every account that user connects**, including ones connected later. There is no account picker in this lane; you registered them, and the grant is implicit in that.

    **Sensitive identifiers (IBAN, account and card numbers, account holder names) are redacted** unless your app holds `sensitive:read`. This is enforced server-side and cannot be switched off by the caller.
  </Step>

  <Step title="Mint fresh tokens as needed">
    Access tokens last **1 hour**, refresh tokens **30 days**. Refresh normally, or mint a new pair at any time:

    <CodeGroup>
      ```bash Managed Users API theme={"dark"}
      curl -X POST https://api.synci.io/managed/v1/users/USER_ID/tokens \
        -u "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET"
      ```

      ```bash OAuth token endpoint theme={"dark"}
      curl -X POST https://api.synci.io/oauth/token \
        -H "Content-Type: application/x-www-form-urlencoded" \
        -d grant_type=managed_user \
        -d client_id=YOUR_CLIENT_ID \
        -d client_secret=YOUR_CLIENT_SECRET \
        -d user_id=USER_ID
      ```
    </CodeGroup>

    A `user_id` that is not one of your users returns `400` with `"error": "invalid_request"`; the specific reason is in the `hint` field, not `error_description`.
  </Step>
</Steps>

## When a link expires

Portal links live 10 minutes, and people do not always click them in 10 minutes. Rather than showing a dead end, the portal offers to email that user a six-digit sign-in code, scoped to your app. They enter it and land in the same place the link would have taken them.

**There is nothing to build and nothing to call.** The portal owns this entirely: it decides when to offer a code, sends it, and handles the retries. Your part is already done when you mint the link, because the app id travels in the URL and is what lets the portal scope the code to your app rather than to the address in general.

Two things follow from it that are worth knowing:

* **A user who lets a link expire is not stuck.** You do not need to detect it or mint a replacement, though minting a fresh link is always safe if your own UI is bringing them back anyway.
* **`email_verified` flips to `true` when someone redeems a code**, and only then. A link you minted never sets it: you vouching for an address is not evidence that the person controls that mailbox, and the field would be worthless if it said otherwise. Read it as "has proven they can read this inbox", not as "we checked the address is valid".

## Managing users

| Endpoint | What it does |
| - | - |
| `GET /managed/v1/users` | List your users. Filter by `external_user_id`, `email`, `per_page` (default 50, max 200). |
| `GET /managed/v1/users/{user_id}` | One user, including `connections_count` (all connection types). |
| `PUT` or `PATCH /managed/v1/users/{user_id}` | Update `external_user_id` or `connection_limit`. Email cannot be changed. |
| `DELETE /managed/v1/users/{user_id}` | `204`. Hard-deletes them, cascades their connections, accounts and transactions, revokes every token, and frees their email address for re-registration. |

Request and response schemas for each are in the [Managed Users API reference](/api-reference/managed/introduction).

## See every connection at once

Rather than polling each user, list every connection your app pays for. **This is not bank-only:** your users' brokerage and crypto-exchange connections come back here too, and the `provider` filter is how you narrow to one kind.

```http theme={"dark"}
GET /managed/v1/connections
```

Filters: `user_id`, `external_user_id`, `status`, `provider`, `expiring_within_days`, `per_page`.

<Tip>
  `expiring_within_days` is the one to build on. Under PSD2 a bank consent lapses every 90 to 180 days, so a nightly `?expiring_within_days=14` sweep tells you exactly who needs to re-authorize before anything breaks.
</Tip>

## App settings

All Managed-only, all in **Developers → Apps → your app**, and all take effect **immediately**: none are review-gated.

| Setting | Default | What it does |
| - | - | - |
| **Portal return URL** | none | Where "Continue to ‹App›" sends a user who has finished in the portal. No button is shown if unset. |
| **Providers** | all we allow | Which bank-data providers your users may connect through: today GoCardless, Akahu and SnapTrade. A value can only narrow the set, never widen it. |
| **Countries** | all | Which countries your users may connect banks in (ISO-3166-1 alpha-2). |
| **Default connection limit** | Synci default | Bank connections per user. A per-user `connection_limit` overrides it. |
| **Email your users** | on | Whether Synci emails your users about expiring, failing and disabled connections. |
| **Transaction enrichment** | off | Merchant name, logo and category on transactions. Requires a Pro plan; no per-transaction charge. |
| **History depth** | provider default | How much transaction history a new connection requests, capped per institution. |
| **Data retention** | 365 days | How long Synci keeps your users' transaction data. |

The portal filters its bank picker on providers and countries, so a user never sees an institution they cannot connect.

### Before you turn off "email your users"

Synci's expiry emails are a large part of what you get here. A bank consent lapses every 90 to 180 days, and someone has to tell the user and give them somewhere to re-authorize. Off means that is yours to do.

<Warning>
  **There are no connection-lifecycle webhooks yet**, so "handle it yourself" means polling `GET /managed/v1/connections?expiring_within_days=...` and building your own re-consent prompt. Left on, your users get the mail, it names your app, and the link drops them straight into the portal to fix it.
</Warning>

## Billing

You pay for a Managed app the same way every other Synci customer pays: **a Basic or Pro subscription on your own account**, priced per bank connection. There is no separate developer invoice and nothing to reconcile.

Your subscription quantity is a single pool, shared between your own personal bank connections and every connection your users create across every Managed app you own. Buy 50, and your users can hold 50 between them.

**Registering users is free.** You only pay when one of them actually connects a bank.

Up to 200 connections are self-serve. Beyond that, or if you would rather be billed monthly on actual usage than prepay a quantity, [get in touch](mailto:support@synci.io): those are commercial agreements rather than something you can set up yourself.

When a connection is refused, the code tells you which ceiling you hit:

| `code` | Meaning |
| - | - |
| `no_subscription` | You have no active plan. Not "full", but never started. |
| `trial_limit_reached` | You are on a free trial; unreviewed apps share a small allowance. |
| `capacity_reached` | You have used the connections you subscribed for. Raise your plan quantity. |

## Review and going live

| State | What it permits |
| - | - |
| **Testing** | Real banks, real data, capped: a small number of registered users and, while you are on a free trial, 2 live connections across all your unreviewed apps. Counted per developer account, because creating another app is free. |
| **Pending review** | Under review by Synci. |
| **Approved** | Caps lifted; the portal stops showing your users an "unreviewed" notice and starts showing your logo. |
| **Rejected** | Edit and resubmit; a reason is given. |

To submit for review you need `developer_name`, `homepage_url` and `privacy_policy_url` set, and an active Synci plan. Paying lifts the trial cap immediately, reviewed or not: you bought the slots.

Until you are reviewed, the portal tells your users so and falls back to an initial avatar rather than your logo. An unvetted image never reaches a user.

## Your users are yours

* **Deleting a user is real**, and it frees their email address for re-registration.
* **They can revoke a bank connection themselves** from the portal at any time.
* **They are not a Synci account.** They cannot log in, cannot be reached by Synci sales, and see no Synci dashboard.
* **If your app is suspended or removed**, Synci emails them once to say your app can no longer access their bank connections.

## Common errors

| Status | When |
| - | - |
| `401` | Bad Basic credentials on the Managed Users API, or an expired or revoked Bearer token on the Data API. |
| `402` | No active Synci plan. Subscribe before registering users. |
| `403` | Connection capacity or trial ceiling reached, app suspended, or a scope your app does not hold. |
| `404` | The user or resource does not exist, or is not yours. |
| `409` | Email or `external_user_id` already used by your app. |
| `422` | Validation failed; the body lists per-field errors. |
| `429` | Rate limit exceeded. |

## Support

Email [support@synci.io](mailto:support@synci.io). Include your client ID (never your secret) and, where relevant, the `user_id`. Both are safe to quote.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.