<!-- llms.txt: https://workos.com/llms.txt -->
<!-- feedback.md: https://workos.com/docs/feedback.md -->

# Multiple connections

## Overview

Multiple connections let the same owner connect several accounts for one
provider: a personal and a work GitHub account, or one API key per project.
"Owner" means the same connection owner as everywhere else in Pipes: a user,
or an organization for [shared connections](https://workos.com/docs/pipes/connection-sharing). Each
owner's connections to a provider are counted and selected separately.

An integration built for multiple connections follows three rules:

1. **Create** with `connection_intent: "add"`.
2. **Read** with `supports_multiple_connections: true`.
3. **Act** on a connection by passing its `connected_account_id`.

Store the `connected_account_id` from each new connection against your own
record of what it is for. It is the only handle for a connection.

Integrations that already use Pipes keep their current behavior until they
adopt these rules. See [existing integrations](#existing-integrations) for how
requests without them behave and how to migrate.

## Availability

Creating an additional connection is unavailable until WorkOS enables it for
your team. Until then, an add creates a connection only when the owner
has none for the provider, and otherwise returns
`404 multiple_connections_unavailable`. Requests without `connection_intent`,
and reauthorization, are unaffected.

## Labels and account identifiers

Two fields describe a connection but never identify it:

- **`account_display_name`** is a label for people, such as "Work GitHub". It
  is mutable and up to 255 characters, and several connections can share one.
  When a write sets a label that another connection for the same owner and
  provider already uses, the response includes a `warnings` entry with the
  code `duplicate_account_display_name`; the write still succeeds.
- **`account_identifier`** describes which provider account a connection
  points at. It never identifies the connection; that is always
  `connected_account_id`. WorkOS does not infer it from a label or credential:
  it holds only what your application supplies.

WorkOS does not expose a developer-declared uniqueness field, and it neither
refuses nor detects duplicate provider accounts. Two deliberate adds can
connect the same provider account twice.

## Adding a connection

Send `connection_intent: "add"` on any creation request. The owner gains
another connection instead of reusing or replacing an existing one.

### OAuth providers

Request an authorization URL with `connection_intent: "add"` and send the user
through it. You can set the label up front with `account_display_name`.

:::code-group

```bash language="curl"
curl --request POST \
  --url "https://api.workos.com/data-integrations/github/authorize" \
  --header "Authorization: Bearer sk_example_123456789" \
  --header "Content-Type: application/json" \
  -d @- <<'BODY'
    {
        "user_id": "user_01EHZNVPK3SFK441A1RGBFSHRT",
        "connection_intent": "add",
        "account_display_name": "Work GitHub"
    }
BODY
```

:::

When the user completes authorization, WorkOS creates the new connection. If
the add fails, the user returns to your `return_to` URL with an `error` query
parameter (or sees an error page when you set none), and a
`pipes.account_connection.add_failed` event reports it.

### API key and client credentials providers

Use the creation endpoints for
[API keys](https://workos.com/docs/reference/pipes/connected-account/create-api-key-connection) and
[client credentials](https://workos.com/docs/reference/pipes/connected-account/create-client-credentials-connection).
They accept only `connection_intent: "add"` and never update an existing
connection.

:::code-group

```bash language="curl"
curl --request POST \
  --url "https://api.workos.com/data-integrations/github/api-key" \
  --header "Authorization: Bearer sk_example_123456789" \
  --header "Content-Type: application/json" \
  --header "Idempotency-Key: 0b6c3f0e-7a51-4c3a-9d8e-2f1b5a6c7d80" \
  -d @- <<'BODY'
    {
        "user_id": "user_01EHZNVPK3SFK441A1RGBFSHRT",
        "secret": "sk-1234567890abcdef",
        "connection_intent": "add",
        "account_display_name": "Staging project"
    }
BODY
```

:::

To rotate a key or credentials later, use the `PUT` endpoints for
[API keys](https://workos.com/docs/reference/pipes/connected-account/upsert-api-key) and client
credentials with the connection's `connected_account_id`. Sending `add` to a
`PUT` endpoint returns `400`.

### Imported tokens

The token import endpoints for
[users](https://workos.com/docs/reference/pipes/connected-account/create) and
[organizations](https://workos.com/docs/reference/pipes/connected-account/create-organization-data-installation)
accept `connection_intent: "add"` in the same way.

### Limits

An add is refused once the owner has 25 connections for the provider, with
`409 connected_account_limit_reached`. Disconnect a connection before adding
another.

## Retrying an add safely

Retrying an add whose response was lost can create a second connection. To make
a synchronous add retry-safe, send an `Idempotency-Key` header on the REST
creation endpoints for API keys, client credentials, and imported tokens, or
the `idempotencyKey` input on the equivalent Widgets API mutations. Reuse the
same key for every retry of one request.

- A matching retry within 24 hours returns the connection the first attempt
  created, unchanged, even if the retry carries different inputs.
- Reusing the key for a different owner, provider, or authentication method,
  or after 24 hours while that connection exists, returns
  `409 idempotency_key_conflict`.
- Once that connection is deleted, the key creates a new connection.
- A REST add and a Widgets API add of the same kind, for the same owner and
  provider, are one operation and share the key.

The key is optional; without it, retries can create duplicates. It is not
lifetime duplicate prevention. OAuth adds take no key: each authorization
completes once, and starting a second authorization creates a second
connection.

Some WorkOS SDKs send an `Idempotency-Key` automatically when they retry a
`POST`. If you write your own retries, generate one key per add and send it on
every attempt.

## Listing connections

Send `supports_multiple_connections=true` when
[listing providers](https://workos.com/docs/reference/pipes/provider/list) to receive every connection
for the owner in each provider's `connected_accounts` array.

:::code-group

```bash language="curl"
curl --request GET \
  --url "https://api.workos.com/user_management/users/user_01EHZNVPK3SFK441A1RGBFSHRT/data_providers?supports_multiple_connections=true" \
  --header "Authorization: Bearer sk_example_123456789"
```

:::

A request that declares `supports_multiple_connections: true` but names no
connection, while the owner has several, returns `409` rather than guessing.
Ask the user which connection to use, then repeat the request with its
`connected_account_id`:

:::code-group

```json language="json"
{
  "code": "account_selection_required",
  "message": "Several connected accounts match this user for 'github'. Name one with a connected account id."
}
```

:::

A malformed `connected_account_id` returns `400`. An id that does not exist
and an id that belongs to another owner or provider both return the same
`404`, so a failed selector never creates, changes, vends, or reveals another
connection.

## Using a specific connection

Pass `connected_account_id` when you
[fetch an access token](https://workos.com/docs/reference/pipes/access-token/get) or
[vend credentials](https://workos.com/docs/reference/pipes/access-token/vend-credentials):

:::code-group

```bash language="curl"
curl --request POST \
  --url "https://api.workos.com/data-integrations/github/token" \
  --header "Authorization: Bearer sk_example_123456789" \
  --header "Content-Type: application/json" \
  -d @- <<'BODY'
    {
        "user_id": "user_01EHZNVPK3SFK441A1RGBFSHRT",
        "connected_account_id": "data_installation_01HX1ZWK1Z5H5Q7Y7X5EJ09MQ1",
        "supports_multiple_connections": true
    }
BODY
```

:::

With [Relay](https://workos.com/docs/pipes/relay), name the connection with the
`X-Relay-Connected-Account` header. Relay removes the header before forwarding
the request.

:::code-group

```bash language="curl"
curl --request GET https://api.workos.com/relay \
  --header "Authorization: Bearer sk_example_123456789" \
  --header "X-Relay-URL: https://api.github.com/user/repos" \
  --header "X-Relay-User: user_01EHZNVPK3SFK441A1RGBFSHRT" \
  --header "X-Relay-Connected-Account: data_installation_01HX1ZWK1Z5H5Q7Y7X5EJ09MQ1"
```

:::

## Updating, reauthorizing, and deleting

- **Rename or update**: pass `connected_account_id` to
  [update a connected account](https://workos.com/docs/reference/pipes/connected-account/update),
  including its `account_display_name`.
- **Reauthorize**: request an authorization URL with
  `connection_intent: "reauthorize"` and the `connected_account_id` to
  reauthorize. The new grant replaces that connection's credentials.
- **Delete**: pass `connected_account_id` to
  [delete a connected account](https://workos.com/docs/reference/pipes/connected-account/delete) to
  remove exactly that connection.

## Webhooks

Subscribe to the `pipes.account_connection.*` events, which cover every
connection: `connected`, `disconnected`, `reauthorization_needed`,
`connection_failed`, and `add_failed`. Each payload's `id` is the connection's
`connected_account_id`, so match events to your records by it.

`pipes.account_connection.add_failed` reports a failed add. It can occur before
any connection exists, so it carries no connected account id. See
[Pipes events](https://workos.com/docs/events#pipes) for payloads.

## In the Pipes widgets

The [Pipes](https://workos.com/docs/widgets/pipes) and [Pipes Admin](https://workos.com/docs/widgets/pipes-admin) widgets
support multiple connections in their sectioned view, which you turn on with
the `enableSharedConnections` prop. This requires `@workos-inc/widgets` 1.20.0
or later. The flat list rendered without that prop shows one connection per
provider.

- **One connection.** The provider row looks as it did before. Its menu adds
  *Rename* and *Connect another account*.
- **Several connections.** The provider row shows how many there are, and each
  connection gets its own row below it, oldest first, with its own status and
  actions. A row shows the connection's name, falling back to its account
  identifier and then the provider name.
- **Adding.** *Add connection* and *Connect another account* appear only when
  the owner can add one, so they stay hidden while multiple connections are
  unavailable and at the connection limit. In Pipes, adding asks for an
  optional name; Pipes Admin doesn't, so admins name a connection with
  *Rename* afterwards. API key and client credentials adds send an `idempotencyKey`, so retrying
  after an ambiguous failure returns the same connection.
- **Acting on a connection.** Reauthorizing, updating a key or credentials, and
  disconnecting act on exactly that row's connection.
- **Renaming.** A name another connection for the same provider already uses
  still saves, with a notice suggesting a distinct one.

The *Shared connections* section that members see still shows one row per
provider, even when an organization shares several connections.

## Errors

| Status | Code                                | Meaning                                                                                                              |
| ------ | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| 400    |                                     | An invalid combination: `add` with `connected_account_id`, `reauthorize` without it, `add` on a `PUT` endpoint, or a malformed selector. |
| 404    | `multiple_connections_unavailable`  | The add would create an additional connection before WorkOS has enabled them for your team.                   |
| 404    |                                     | The `connected_account_id` does not exist or belongs to another owner or provider.                                    |
| 404    | `connected_account_not_found`       | The connection named for reauthorization does not exist for this owner and provider.                                  |
| 409    | `account_selection_required`        | The request declared `supports_multiple_connections`, named no connection, and several match.                        |
| 409    | `connected_account_limit_reached`   | The owner already has 25 connections for this provider.                                                              |
| 409    | `idempotency_key_conflict`          | The `Idempotency-Key` belongs to a different add, or its 24-hour replay window has passed.                           |

An OAuth reauthorization whose connection is deleted before the user returns
sends the user to your `return_to` URL with `error=target_deleted`. No webhook
event is emitted, since the connection no longer exists.

## Existing integrations

Pipes integrations built before multiple connections send no
`connection_intent`, no `supports_multiple_connections`, and often no
`connected_account_id`. Those requests keep working permanently. To keep them
predictable once an owner has several connections, every connection carries a
`connection_role`:

- **`compatibility`**: the connection that requests without these fields use.
  Each owner has at most one per provider.
- **`standard`**: any further connection for the same owner and provider. Only
  requests that name it with `connected_account_id`, or that declare
  `supports_multiple_connections: true`, can see it.

The first connection for an owner becomes the compatibility connection, even
when it is created with `connection_intent: "add"`; later adds create standard
connections. An integration that follows the three rules above never needs to
check the role. It does not mean preferred, primary, or oldest, and it never
changes.

### What requests without the new fields do

- **Reads** without `supports_multiple_connections` return only the
  compatibility connection, and are never refused with
  `account_selection_required`. In a plural provider list, the singular
  `connected_account` field is the compatibility connection.
- **Token fetches, credential vending, and Relay** without a selector use the
  compatibility connection.
- **`PUT` writes** without `connected_account_id` update the compatibility
  connection.
- **Deletes** without `connected_account_id` delete the compatibility
  connection.
- **Creation** without `connection_intent` only ever addresses the
  compatibility connection: it fills an empty slot, and otherwise OAuth and
  `PUT` update the existing compatibility connection while token imports return
  `409`. It never creates a standard connection.

### An empty compatibility slot

Deleting the compatibility connection leaves the slot empty and never promotes
a standard connection into it. The owner's standard connections then stay
invisible to requests without the new fields. While any standard connection
remains, an add creates another standard connection; only a request without
`connection_intent` refills the slot. The 25-connection limit never blocks that
refill.

### Webhooks

Pipes emits two families of connection events:

| Family                                       | Covers                                  | Events                                                                                   |
| -------------------------------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------- |
| Compatibility `pipes.connected_account.*`    | Compatibility connections only          | `connected`, `disconnected`, `reauthorization_needed`, `connection_failed`               |
| All-connections `pipes.account_connection.*` | Compatibility and standard connections  | `connected`, `disconnected`, `reauthorization_needed`, `connection_failed`, `add_failed` |

An endpoint subscribed to both receives two events for each compatibility
connection change: two views of the same change, not two connections.

### Migrating

1. **Store connection ids.** Save `connected_account_id` against your own
   records, and stop assuming one connection per owner and provider.
2. **Read in plural.** Send `supports_multiple_connections: true` and handle
   `409 account_selection_required`.
3. **Name the connection you use.** Pass `connected_account_id` when fetching
   tokens, vending credentials, calling Relay, updating, and deleting.
4. **Add explicitly.** Send `connection_intent: "add"` when the user connects
   another account, with an `Idempotency-Key` on synchronous adds.
5. **Move webhook handlers** to the `pipes.account_connection.*` family, and
   unsubscribe from `pipes.connected_account.*`.

Move every client that reads connections at the same time. A backend job or
older widget that still reads without `supports_multiple_connections` keeps
working on the compatibility connection, which may not be the one a user meant
once they have several.
