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

# Building OAuth apps

> Register an app and implement the OAuth 2.0 authorization code flow with PKCE so other Synci users can share their data with you.

OAuth apps let **other Synci users** grant your application access to their financial data. Unlike a personal access token (which only reaches your own account), an OAuth app uses a consent flow: the user reviews the permissions you request, picks which accounts to share, and can revoke access at any time.

Synci implements **OAuth 2.0 authorization code grant with PKCE**.

## Which kind of app do you need?

Two self-serve app types share this API. You pick one when you create the app, and **the choice is permanent**: it decides who is billed for every bank connection the app creates.

| | **Standard** | **Managed users** |
| - | - | - |
| Your users | Have their own Synci account | Have no Synci account and never will |
| How they authorize | OAuth consent screen (this guide) | You register them server-to-server; they use a hosted portal |
| Who pays for connections | Each user's own Synci plan | **You**, out of your own plan |
| What you can request | Any scope | Bank-data scopes only |
| Redirect URIs | Required | Not used |

**Standard** is the right default: if the people you are building for already have Synci accounts, or would happily create one, this guide is all you need.

**Managed users** exists for products that want bank connections embedded in their own experience, where sending people off to create a Synci account would lose them. See [Managed apps](/guides/managed-apps). The flow is quite different from the one below.

```mermaid theme={"dark"}
flowchart TD
    A{Do your users have<br/>their own Synci accounts?} -->|Yes, or they'd create one| B[Standard app]
    A -->|No, and they never will| C[Managed app]
    B --> D[OAuth consent screen<br/>They pay for their connections]
    C --> E[Managed Users API + hosted portal<br/>You pay for their connections]
```

Building at high volume, or want a commercial arrangement other than a standard plan (usage-based billing, for example)? Those are done by agreement rather than self-serve: [get in touch](mailto:support@synci.io).

The rest of this guide covers **Standard** apps.

## Register your app

In the Synci dashboard, go to **Developers → Apps → New app**. You will set:

* **Name**: shown to users on the consent screen.
* **Redirect URIs**: up to 5 exact-match `https://` URLs where Synci sends users back after they approve. No wildcards, no trailing-slash differences. While your app is in **testing**, `http://` is also allowed for loopback hosts (`localhost`, `127.0.0.1`, `::1`) so you can develop locally; loopback URIs must be replaced with `https://` before you submit for review.
* **Permissions (scopes)**: the scopes your app may request. On any given authorization you can request a subset, never more. See the scope table in [Authentication](/guides/authentication#scopes).
* **Branding**: developer or company name, description, homepage, privacy policy, and logo. Required before you can submit for review.

### Client credentials

Every app is a **confidential** (server-side) client:

* **Client ID**: public identifier, safe to embed in URLs.
* **Client secret**: shown **once** at creation. Store it securely on your server and never expose it in a browser or mobile app. You can rotate it at any time (the previous secret stops working immediately).

<Info>
  Public or PKCE-only clients (browser-only or mobile apps without a backend) are not currently offered. If you need one, [get in touch](mailto:support@synci.io).
</Info>

## The authorization flow

<Steps>
  <Step title="Generate PKCE values and state">
    Create a random `code_verifier` (43 to 128 characters), then derive the challenge:

    ```text theme={"dark"}
    code_challenge = BASE64URL( SHA256( code_verifier ) )
    ```

    Also generate a random `state` string to protect against CSRF.
  </Step>

  <Step title="Redirect the user to the authorize endpoint">
    ```http theme={"dark"}
    GET https://api.synci.io/oauth/authorize
      ?response_type=code
      &client_id=YOUR_CLIENT_ID
      &redirect_uri=https://yourapp.com/callback
      &scope=accounts:read%20transactions:read
      &state=RANDOM_STATE
      &code_challenge=YOUR_CODE_CHALLENGE
      &code_challenge_method=S256
    ```

    Scopes are space-separated (URL-encoded as `%20`) and must be a subset of the scopes your app is registered for.

    The user signs in, reviews your app's branding and the requested permissions, and, when account-scoped permissions are requested, selects which accounts your app may access. Users approve the request as a whole; they cannot deny individual scopes.

    **On approval**, Synci redirects back to your redirect URI:

    ```text theme={"dark"}
    https://yourapp.com/callback?code=AUTH_CODE&state=RANDOM_STATE
    ```

    Verify that `state` matches what you sent before continuing. The code is single-use and expires after about 10 minutes.

    **On denial**, the redirect carries `?error=access_denied` instead of a code.
  </Step>

  <Step title="Exchange the code for tokens">
    From your backend, POST to the token endpoint. The body must be **`application/x-www-form-urlencoded`**, not JSON:

    <CodeGroup>
      ```bash Request theme={"dark"}
      curl -X POST https://api.synci.io/oauth/token \
        -H "Content-Type: application/x-www-form-urlencoded" \
        -d grant_type=authorization_code \
        -d client_id=YOUR_CLIENT_ID \
        -d client_secret=YOUR_CLIENT_SECRET \
        -d redirect_uri=https://yourapp.com/callback \
        -d code_verifier=YOUR_CODE_VERIFIER \
        -d code=AUTH_CODE
      ```

      ```json Response theme={"dark"}
      {
        "token_type": "Bearer",
        "expires_in": 3600,
        "access_token": "eyJ0eXAiOi...",
        "refresh_token": "def50200..."
      }
      ```
    </CodeGroup>

    Access tokens are valid for **1 hour**. Refresh tokens are valid for **30 days** and rotate on every use.
  </Step>

  <Step title="Call the API">
    ```bash theme={"dark"}
    curl "https://api.synci.io/api/v1/finance/accounts" \
      -H "Authorization: Bearer ACCESS_TOKEN" \
      -H "Accept: application/json"
    ```

    The token can reach exactly what the user approved: requests outside the granted scopes return `403`, and accounts the user did not select return `404`.
  </Step>

  <Step title="Refresh tokens">
    When the access token expires, exchange the refresh token (again form-urlencoded):

    ```bash theme={"dark"}
    curl -X POST https://api.synci.io/oauth/token \
      -H "Content-Type: application/x-www-form-urlencoded" \
      -d grant_type=refresh_token \
      -d client_id=YOUR_CLIENT_ID \
      -d client_secret=YOUR_CLIENT_SECRET \
      -d refresh_token=YOUR_REFRESH_TOKEN
    ```

    Each refresh returns a new access token **and** a new refresh token. Always store the newly returned refresh token; the one you just used is invalidated.
  </Step>
</Steps>

## App review lifecycle

| State | Who can authorize | Editing |
| - | - | - |
| **Testing** | You, plus the first 10 other people who authorize it | Changes take effect immediately |
| **Pending review** | No new authorizations | Locked while under review |
| **Approved** | Any Synci user; your app is marked verified | Changes to branding, scopes, and redirect URIs are queued for re-review before taking effect |
| **Rejected** | No one | Edit and resubmit; a reason is provided |

You can return an approved app to development at any time; this unpublishes it without revoking existing user connections. Deleting an app or rotating its secret requires password confirmation in the dashboard.

### The pre-review limit

An app that has not been reviewed is open to anyone, but only up to **10 people besides you**. That is enough to run a real beta, and it bounds how far an unvetted app can reach before Synci has looked at it.

* You never consume a slot, so 10 means 10 other people.
* **Revoking frees a slot**, and an abandoned authorization frees one when its refresh token lapses.
* Someone who already authorized can always re-authorize, even at the limit.
* Approval removes the limit entirely, and existing authorizations carry over.

Once the limit is reached, a new user sees an "app isn't taking new users yet" page rather than your consent screen. Your app payload reports where you stand:

```json theme={"dark"}
"early_users": { "used": 3, "limit": 10, "reached": false }
```

`limit` is `null` once approved.

## Good to know

<AccordionGroup>
  <Accordion title="The token endpoint is form-urlencoded">
    Per the OAuth spec, `/oauth/token` expects `application/x-www-form-urlencoded`. Sending JSON fails with `invalid_client`.
  </Accordion>

  <Accordion title="Redirect URIs match exactly">
    No wildcards, and `https://yourapp.com/callback` is not the same as `https://yourapp.com/callback/`.
  </Accordion>

  <Accordion title="Users can revoke your app at any time">
    From their Synci settings. All of its tokens stop working immediately and the user must authorize again.
  </Accordion>

  <Accordion title="Suspended apps are cut off immediately">
    Existing tokens are rejected and refresh is blocked.
  </Accordion>

  <Accordion title="Request minimal scopes">
    Users are more likely to approve an app that asks only for what it needs, and reviews go faster.
  </Accordion>
</AccordionGroup>

## Common errors

| Error | Cause |
| - | - |
| `invalid_client` | Wrong or missing `client_id` or `client_secret`, or the token request was sent as JSON instead of form-urlencoded. |
| `invalid_grant` | The authorization code expired or was already used, the `code_verifier` does not match the original `code_challenge`, or the refresh token was already rotated or revoked. |
| `invalid_scope` | A requested scope is not among the scopes your app is registered for. |
| `access_denied` (on the redirect) | The user declined the authorization request. |
| An "app isn't taking new users yet" page | Your app is unreviewed and has reached its 10-user pre-review limit. Submit for review to lift it. |
| `403` on an API call | The token lacks the required scope for that endpoint. |
| `404` on an account | The user did not grant your app that account. |


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