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

# Connecting User Accounts

> Send a user through BundleUp’s hosted OAuth flow, get a one-time code back, and exchange it for a connection ID.

Before you can call a user’s GitHub, Slack or HubSpot account, the user has to authorize your app. BundleUp hosts that flow for every provider. You redirect the user to BundleUp, the user approves on the provider’s consent screen, and BundleUp redirects back to you with a one-time `code`. Your backend exchanges the code for a **connection ID**.

**When to use this:** whenever a user connects an account in your product, for example from a “Connect GitHub” button in your settings page.

## How the flow works

```mermaid theme={null}
sequenceDiagram
    autonumber
    actor U as End user
    participant FE as Your frontend
    participant BA as BundleUp Auth
    participant P as Provider
    participant BE as Your backend

    U->>FE: Click "Connect GitHub"
    FE->>BA: Redirect to /authorize?client_id&integration_id&redirect_uri&state
    BA->>BA: Check client_id, integration, redirect_uri
    BA->>P: Redirect to the provider's consent screen (your OAuth app)
    U->>P: Approve
    P->>BA: Redirect to auth.bundleup.io/callback with the provider's code
    BA->>P: Exchange for access + refresh tokens
    BA->>BA: Encrypt tokens, create the connection
    alt Success
        BA->>FE: Redirect to redirect_uri?code=...&state=...
        FE->>BE: Pass the code to your backend
        BE->>BA: POST /connection (API key, code, redirect_uri)
        BA-->>BE: connection_id, external_id, integration_id
        BE->>BE: Store connection_id against the user
    else User denied, or the provider failed
        BA->>FE: Redirect to redirect_uri?error=...&error_description=...&state=...
    end
```

1. Your app sends the user to BundleUp’s `/authorize` URL with your client ID, the integration to connect, and where to send them afterwards.
2. BundleUp checks those values and sends the user to the provider’s consent screen, using your OAuth app.
3. The user approves. The provider sends them back to BundleUp, which exchanges the provider’s code for tokens, encrypts them and creates a connection.
4. BundleUp redirects the user to your `redirect_uri` with a one-time `code` (or an `error`).
5. Your backend exchanges the `code` for the connection ID, using your API key.

## Before you start

* An [integration](/docs/guides/integrations/creating) for the provider. Note its **identifier** (for example `github`).
* Your **redirect URI** registered in the dashboard under **Auth → Redirect URIs**. It must match exactly, including scheme, host, port, path and trailing slash.
* Your **client ID**, from the same **Auth** page.
* An **API key** on your backend.

## Step 1: Send the user to BundleUp

Build this URL and redirect the user’s browser to it:

```text theme={null}
https://auth.bundleup.io/authorize
  ?client_id=YOUR_CLIENT_ID
  &integration_id=github
  &redirect_uri=https://app.example.com/callback
  &external_id=user_42
  &state=Rk3jX9...
```

| Parameter | Required | Description |
| - | - | - |
| `client_id` | Yes | Your workspace’s client ID. |
| `integration_id` | Yes | The integration’s **identifier**, as set in the dashboard (for example `github`). |
| `redirect_uri` | Yes | Where to send the user afterwards. Must exactly match a registered redirect URI. |
| `external_id` | No | Your own reference, usually your user ID. Stored on the connection and returned when you exchange the code. |
| `state` | No | Any value. Returned unchanged on the final redirect, on success and on error. Use it to [prevent CSRF](#protect-the-flow-with-state). |

The SDKs build the URL for you:

<CodeGroup>
  ```typescript JavaScript theme={null}
  const url = client.auth.buildAuthorizationUrl({
    clientId: process.env.BUNDLEUP_CLIENT_ID,
    integrationId: 'github',
    redirectUri: 'https://app.example.com/callback',
    externalId: currentUser.id, // optional
    state, // optional
  });
  ```

  ```python Python theme={null}
  url = client.auth.build_authorization_url(
      client_id=os.environ["BUNDLEUP_CLIENT_ID"],
      integration_id="github",
      redirect_uri="https://app.example.com/callback",
      external_id=current_user.id,  # optional
      state=state,  # optional
  )
  ```

  ```ruby Ruby theme={null}
  url = client.auth.build_authorization_url(
    client_id: ENV['BUNDLEUP_CLIENT_ID'],
    integration_id: 'github',
    redirect_uri: 'https://app.example.com/callback',
    external_id: current_user.id, # optional
    state: state # optional
  )
  ```
</CodeGroup>

Building the URL needs no API key: `client_id` identifies your workspace but isn’t a secret, and BundleUp only redirects to URIs you registered. Each SDK also exposes the builder as a standalone helper, so you can build the URL in the browser or anywhere the API key must not live: `buildAuthorizationUrl` from `@bundleup/sdk`, `build_authorization_url` from `bundleup` in Python, and `BundleUp.build_authorization_url` in Ruby. Exchanging the code afterwards still needs the API key, so keep that step on your server.

If the `client_id`, `integration_id` or `redirect_uri` is missing or wrong, BundleUp shows an error page instead of redirecting. Nothing is sent to your redirect URI in that case, because BundleUp can’t trust it.

<Note>
  On the Free plan, users see a BundleUp screen for about a second before they’re sent to the provider. On Pro, they go straight to the provider.
</Note>

## Step 2: The user approves at the provider

The user sees the provider’s consent screen, showing **your** OAuth app’s name and the scopes configured on the integration. After they approve, the provider sends them back to BundleUp, which completes the token exchange and stores the tokens encrypted.

The user has **10 minutes** from starting the flow to finish it. After that, they need to start again.

## Step 3: Handle the redirect

BundleUp redirects the user to your `redirect_uri`.

**On success:**

```text theme={null}
https://app.example.com/callback?state=Rk3jX9...&code=Yx8k2...
```

| Parameter | Description |
| - | - |
| `code` | One-time code to exchange for the connection. Expires after **5 minutes**. Can be used **once**. |
| `state` | Your `state`, unchanged. Present only if you sent one. |

**On failure:**

```text theme={null}
https://app.example.com/callback?state=Rk3jX9...&error=access_denied&error_description=The+user+denied+access
```

| `error` | Meaning |
| - | - |
| *provider’s code* | The provider returned an error, passed through as-is. Most commonly `access_denied`: the user clicked Cancel. |
| `invalid_request` | The provider redirected back without an authorization code. |
| `token_error` | The provider’s token exchange failed. Often wrong client credentials on the integration. |

`error_description` carries a human-readable explanation. Show the user a friendly message and a way to try again.

## Step 4: Exchange the code for a connection ID

From your **backend**, send the code and the same `redirect_uri` to BundleUp with your API key:

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://auth.bundleup.io/connection \
    -H "Authorization: Bearer YOUR_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"code": "Yx8k2...", "redirect_uri": "https://app.example.com/callback"}'
  ```

  ```typescript JavaScript theme={null}
  const { connection_id, external_id, integration_id } =
    await client.auth.getConnectionFromCode({
      code,
      redirectUri: 'https://app.example.com/callback',
    });
  ```

  ```python Python theme={null}
  connection = client.auth.get_connection_from_code(
      code=code,
      redirect_uri="https://app.example.com/callback",
  )
  connection_id = connection["connection_id"]
  ```

  ```ruby Ruby theme={null}
  connection = client.auth.get_connection_from_code(
    code: code,
    redirect_uri: 'https://app.example.com/callback'
  )
  connection_id = connection['connection_id']
  ```
</CodeGroup>

```json 200 OK theme={null}
{
  "connection_id": "conn_abc123",
  "external_id": "user_42",
  "integration_id": "github"
}
```

`integration_id` here is the integration’s identifier. `external_id` is `null` if you didn’t send one.

The exchange fails with `400` if the code has expired, was already used, was issued for a different `redirect_uri`, or belongs to a different workspace than your API key. A missing or invalid API key returns `401`. Full reference: [Exchange a code](/docs/api-reference/auth/exchange-code).

**Store the `connection_id`** against the user. You’re done: use it for every [Proxy](/docs/guides/proxy/overview), [Unified API](/docs/guides/unified/overview) and [MCP](/docs/guides/mcp/overview) request for that account.

<Tip>
  You also receive a `connection.created` [webhook](/docs/guides/webhooks/events) for every new connection, whether or not you exchange the code. It’s a useful backstop if a user closes the tab before reaching your callback.
</Tip>

## Protect the flow with state

Without `state`, an attacker could trick a signed-in user into completing a flow the attacker started, linking the attacker’s account to the victim’s profile in your app. To prevent that:

1. Before redirecting, generate a random value and store it in the user’s session.
2. Pass it as `state`.
3. In your callback, compare the `state` query parameter with the stored value. Reject the request if they differ.

```typescript theme={null}
// Before redirecting
const state = crypto.randomUUID();
session.bundleupState = state;

// In the callback
if (req.query.state !== session.bundleupState) {
  return res.status(400).send('Invalid state');
}
```

## Reconnecting an account

Each completed flow creates a **new** connection, even for the same user and provider. When a user reconnects (for example, to grant new scopes or after revoking access), store the new connection ID and delete the old connection with `DELETE /v1/connections/{id}`, so you don’t keep unused credentials around.

## Why the flow works this way

* **Your API key never touches the browser.** It’s only used in the server-to-server code exchange.
* **The connection ID never appears in a URL.** A leaked callback URL (browser history, server logs, a `Referer` header) exposes only a code that is short-lived, single-use, bound to your redirect URI, and useless without your API key.
* **Redirects only go to URIs you registered**, so the flow can’t be used to send users somewhere else.

## Next steps

* [Authenticating API requests](/docs/guides/auth/authenticating-requests): use the connection ID.
* [Connection lifecycle](/docs/guides/auth/connection-lifecycle): what happens to a connection after it’s created.


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