Bridge
Connect your users' Gmail, Google Drive, Slack, and Granola accounts under your own OAuth apps and drive sync and search from your platform.
Bridge runs Gmail, Google Drive, Slack, and Granola connections for your users under your own OAuth apps. Your backend calls the API with one organization API key; users consent on Google or Slack and return to your platform.
Bridge is in beta and available on request. Request access and we'll enable it for your organization.
OAuth setup
Register your apps once per organization under Bridge → Apps in the dashboard. Each card's
dialog shows the redirect URI and, for Slack, the manifest to copy. The scopes and events each app
needs are listed under Scopes and events and returned by
List OAuth Clients as required_scopes.
| App | Secrets you enter | Where they come from |
|---|---|---|
| Gmail | Client ID, Client secret, Return URL | A Google Auth Platform OAuth client (Web application) with the Gmail API enabled and the dialog's redirect URI authorized. |
| Google Drive | Client ID, Client secret, Return URL | The same Google client or another one, with the Google Drive API enabled and the Drive redirect URI authorized. |
| Slack | Client ID, Client secret, Signing secret, Return URL | A Slack app created From a manifest on Slack's app creation page with the manifest in the dialog. Credentials are under Basic Information → App Credentials in the Slack app dashboard. |
| Granola | None | Each user pastes a personal Granola API key into your platform. |
Gmail and Google Drive are separate apps: enter one Google client on both cards or a different client on each. A shared client needs both redirect URIs authorized. Set the user type and publish the app on Google Auth Platform's Audience page; an app left in Testing gets refresh tokens that expire after 7 days.
Use an existing app
You can register an OAuth app you already operate instead of creating a new one. Configure it at the provider first, keeping everything the app already has, then enter its credentials under Bridge → Apps. Connections made through another app must reconnect afterwards.
Google (Gmail, Google Drive)
Check the client type
In Google Auth Platform → Clients, open the client. It must be a Web application client.
Add the redirect URI
Under Authorized redirect URIs, add the URI for each connector the client serves:
| Connector | Redirect URI |
|---|---|
| Gmail | https://api.mixedbread.com/v1/integrations/gmail/oauth/callback |
| Google Drive | https://api.mixedbread.com/v1/integrations/google_drive/oauth/callback |
Google can take a few minutes to apply the change.
Enable the API
Enable the Gmail API or the Google Drive API on the client's project.
Add the scope
For an External app, add the scope under Google Auth Platform → Data Access and complete verification if Google requires it:
| Connector | Scope |
|---|---|
| Gmail | https://www.googleapis.com/auth/gmail.readonly |
| Google Drive | https://www.googleapis.com/auth/drive.readonly |
An Internal app needs no scope list. Either way, publish the app on the Audience page: an app left in Testing gets refresh tokens that expire after 7 days.
Slack
Add the redirect URL
In the Slack app dashboard, open the app, then OAuth & Permissions → Redirect URLs, and add:
https://api.mixedbread.com/v1/integrations/slack/oauth/callbackAdd the scopes
On the same page, under Scopes:
| Section | Scopes |
|---|---|
| User Token Scopes | channels:history, channels:read, files:read, groups:history, groups:read, im:history, im:read, mpim:history, mpim:read, users:read |
| Bot Token Scopes | users:read, users:read.email |
Subscribe to events
Under Event Subscriptions, enable events and set the Request URL to:
https://api.mixedbread.com/v1/integrations/slack/eventsSlack verifies the URL when you save it. Then, under Subscribe to events on behalf of
users, add: message.channels, message.groups, message.im, message.mpim,
channel_created, channel_rename, channel_archive, channel_unarchive,
channel_deleted, member_joined_channel, member_left_channel.
Reinstall the app
Slack requires a reinstall after scopes change. Existing Bridge connections must then reconnect.
Return URL
The page on your platform where users land after consent, for example
https://platform.example.com/connected (http://localhost is allowed for development). The
callback appends these query parameters:
| Parameter | Value |
|---|---|
provider | gmail, google_drive, or slack |
status | connected or error |
installation_id | The installation that was created or reconnected, when status=connected. Store it against your user. |
error | Why consent failed, when status=error |
reference | The reference you passed to authorize, unchanged, on both outcomes |
error | Meaning |
|---|---|
access_denied | The user declined consent |
missing_code | The provider returned no authorization code |
workspace_already_connected | Slack workspace bot (dashboard only): another Mixedbread organization installed the workspace's bot. Personal installations are not affected. |
store_in_use | The store already receives data from another installation of the same account |
store_exists | new_store was set and a store named store_name already exists |
new_store_on_refresh | new_store was set on a connect that refreshes an existing installation |
store_change_on_refresh | store_name named a store other than the installation's own on a connect that refreshes it |
reconnect_account_mismatch | A reconnect was authorized with a different account than the installation's |
scope_not_granted | The user unticked a scope on Google's consent screen. Start again and leave every scope ticked. |
invalid_state | The consent link expired or was reused. Start again. |
token_exchange_failed | Google rejected the code. Check the client secret and the redirect URI. |
callback_failed | Anything else |
Scopes and events
Consent asks for exactly these scopes. A Google app requests them at authorization time, so nothing is configured on the client, but the consent screen must list them if it is published with a scope list. A Slack app must declare them on the app itself; the manifest does.
| App | Scopes |
|---|---|
| Gmail | https://www.googleapis.com/auth/gmail.readonly |
| Google Drive | https://www.googleapis.com/auth/drive.readonly |
| Slack, user token | channels:history, channels:read, files:read, groups:history, groups:read, im:history, im:read, mpim:history, mpim:read, users:read |
| Slack, bot token | users:read, users:read.email |
Slack is not polled. After the first sync, new and edited messages reach a user's store only through the Slack Events API, so the app must also deliver events to Mixedbread:
| Setting | Value |
|---|---|
| Request URL | https://api.mixedbread.com/v1/integrations/slack/events |
| User events | message.channels, message.groups, message.im, message.mpim, channel_created, channel_rename, channel_archive, channel_unarchive, channel_deleted, member_joined_channel, member_left_channel |
An app created from the manifest in the dashboard has the scopes and the events already. See Slack's app manifest guide if you need to review or update the configuration. If you create the app by hand or reuse an existing app, open it in the Slack app dashboard, keep the required scopes and redirect URL under OAuth & Permissions, and keep the request URL and events under Event Subscriptions; Slack verifies the request URL when it is saved. Every event is checked against the signing secret you registered, so after rotating the secret in Slack, update the app under Bridge → Apps or events are rejected and stores stop receiving new messages.
API reference
OAuth clients
| Endpoint | Method | Path |
|---|---|---|
| List OAuth Clients | GET | /v1/integrations/oauth-clients |
| Register OAuth Client | PUT | /v1/integrations/oauth-clients/{provider} |
| Delete OAuth Client | DELETE | /v1/integrations/oauth-clients/{provider} |
| List OAuth Client Connections | GET | /v1/integrations/oauth-clients/{provider}/connections |
Connect
| Endpoint | Method | Path |
|---|---|---|
| Authorize Gmail | GET | /v1/integrations/gmail/oauth/authorize |
| Authorize Google Drive | GET | /v1/integrations/google_drive/oauth/authorize |
| Authorize Slack | GET | /v1/integrations/slack/oauth/authorize |
| Connect Granola | POST | /v1/integrations/granola/connect |
| Reconnect Granola | POST | /v1/integrations/granola/installations/{installation_id}/reconnect |
| Reconnect Gmail | GET | /v1/integrations/gmail/installations/{installation_id}/oauth/authorize |
| Reconnect Google Drive | GET | /v1/integrations/google_drive/installations/{installation_id}/oauth/authorize |
| Reconnect Slack | GET | /v1/integrations/slack/installations/{installation_id}/oauth/authorize |
Installations
| Endpoint | Method | Path |
|---|---|---|
| List Installations | GET | /v1/integrations/installations |
| Get Installation | GET | /v1/integrations/installations/{installation_id} |
| Disconnect Installation | DELETE | /v1/integrations/installations/{installation_id} |
| Sync Installation | POST | /v1/integrations/installations/{installation_id}/sync |
| Update Installation Schedule | PUT | /v1/integrations/installations/{installation_id}/schedule |
| Replace Resources | PUT | /v1/integrations/installations/{installation_id}/resources |
| List Failed Resources | GET | /v1/integrations/installations/{installation_id}/resources/failed |
| Refresh Resources | POST | /v1/integrations/installations/{installation_id}/resources/refresh |
Stores
| Endpoint | Method | Path |
|---|---|---|
| Get Store | GET | /v1/stores/{store_identifier} |
| List Store Files | POST | /v1/stores/{store_identifier}/files/list |
| Search Chunks | POST | /v1/stores/search |
Stores and installations
An installation is one connected account feeding one store. Every connect creates an installation;
to refresh an existing one, reconnect it. Every connect endpoint takes store_name
and new_store.
Shared stores
Stores are shared. store_name may name a store that already exists, including one that other data
sources already feed. Connect a user's Gmail and Slack with the same store_name and both ingest
into one store, searched with one store id:
curl -s -o /dev/null -w '%{redirect_url}' \
'https://api.mixedbread.com/v1/integrations/gmail/oauth/authorize?store_name=user-42&reference=user-42' \
-H 'Authorization: Bearer YOUR_API_KEY'
curl -s -o /dev/null -w '%{redirect_url}' \
'https://api.mixedbread.com/v1/integrations/slack/oauth/authorize?store_name=user-42&reference=user-42' \
-H 'Authorization: Bearer YOUR_API_KEY'The one exception: two installations of the same account cannot feed the same store, because every
item would be ingested twice. That connect fails with 409, or error=store_in_use on the return
URL.
Without store_name, the installation ingests into the account's default store, for example
gmail-<email>. Connecting the same account again without store_name numbers the default
(gmail-<email>-2, -3, …) past any store another installation of the account feeds.
Several installations of one account
Each installation has its own store and its own selection, so one account can be split across stores.
A Slack user can, for example, keep public channels in one store and direct and group messages in
another: connect twice with different store_names, then select the public_channels alias on the
first installation and the direct_messages and group_messages aliases on the second (see
Replace Resources). Slack events fan out to
every installation whose selection covers the conversation, each ingesting into its own store.
The same account can also be connected in several Mixedbread organizations; each organization's installation is its own, with its own store and selection. Disconnecting one installation keeps the provider grant while another installation of the account still uses it, in any organization.
New stores
new_store=true ingests into a store created for this connection. When store_name names a store
that already exists, the connect fails with 409, or error=store_exists on the return URL.
Without store_name, the default name is numbered past every store that exists. A reconnect
keeps the installation's store, so it cannot set new_store (422, or
error=new_store_on_refresh on the return URL) or name another store in store_name (422, or
error=store_change_on_refresh).
Reconnect
Reconnecting refreshes one installation's credentials and keeps its store, for example after the
installation reports status: "needs_reauth":
curl -s -o /dev/null -w '%{redirect_url}' \
'https://api.mixedbread.com/v1/integrations/gmail/installations/INSTALLATION_ID/oauth/authorize?reference=user-42' \
-H 'Authorization: Bearer YOUR_API_KEY'Google Drive and Slack have the same route under google_drive and slack. The user must
authorize the account already connected to the installation; consent for any other account returns
error=reconnect_account_mismatch. The installation_id query parameter on
Authorize Gmail and
Authorize Google Drive still reconnects
but is deprecated in favour of this route.
Granola reconnects with a new API key instead of consent; see Reconnect Granola.
Gmail
Get the OAuth URL
curl -s -o /dev/null -w '%{redirect_url}' \
'https://api.mixedbread.com/v1/integrations/gmail/oauth/authorize?store_name=mail-42&reference=user-42' \
-H 'Authorization: Bearer YOUR_API_KEY'Redirect the browser to the printed URL. store_name names the user's store; omit it for
gmail-<email>. It may name a store the user's other data sources already feed; see
Shared stores. reference is any value up to 255 characters, for example your user id; it
comes back unchanged on the return URL, so the return can be matched to the user who started
the flow without depending on a browser session.
Handle the return
The browser arrives at
https://platform.example.com/connected?provider=gmail&status=connected&installation_id=…&reference=user-42.
Store installation_id against your user, then read the store id:
curl https://api.mixedbread.com/v1/integrations/installations/INSTALLATION_ID \
-H 'Authorization: Bearer YOUR_API_KEY'{
"data": {
"installation_id": "2f1c9a8e-6b4d-4e3a-9c1f-7d8e5b6a4c21",
"provider": "gmail",
"status": "active",
"account": { "id": "olivia@example.com", "name": "olivia@example.com", "email": "olivia@example.com", "workspace": null },
"store_id": "5d3e7f9a-1b2c-4d6e-8f0a-9c8b7a6d5e4f",
"store_name": "mail-42",
"sync": { "failed_resource_count": 0, "last_error": null, "last_error_operation": null, "last_error_at": null }
}
}Keep installation_id and store_id per user. Mixedbread keeps no link between your users
and their installations.
Start the sync
curl -X POST https://api.mixedbread.com/v1/integrations/installations/INSTALLATION_ID/sync \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer YOUR_API_KEY' \
-d '{"sync_all": true}'Every message outside spam, trash, and drafts is imported, and new mail is picked up every ten minutes. To sync selected labels instead, list them and replace the selection:
curl https://api.mixedbread.com/v1/integrations/installations/INSTALLATION_ID/resources \
-H 'Authorization: Bearer YOUR_API_KEY'
curl -X PUT https://api.mixedbread.com/v1/integrations/installations/INSTALLATION_ID/resources \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer YOUR_API_KEY' \
-d '{"resource_ids": ["INBOX", "Label_12"]}'Monitor the sync
# installation status and account-level errors
curl https://api.mixedbread.com/v1/integrations/installations/INSTALLATION_ID \
-H 'Authorization: Bearer YOUR_API_KEY'
# per-resource outcome in resources[].sync.last_run
curl https://api.mixedbread.com/v1/integrations/installations/INSTALLATION_ID/resources \
-H 'Authorization: Bearer YOUR_API_KEY'
# resources whose last job failed
curl https://api.mixedbread.com/v1/integrations/installations/INSTALLATION_ID/resources/failed \
-H 'Authorization: Bearer YOUR_API_KEY'
# files ingested so far, in file_counts
curl https://api.mixedbread.com/v1/stores/STORE_ID \
-H 'Authorization: Bearer YOUR_API_KEY'status: "needs_reauth" on the installation means the user must consent again:
reconnect the installation through
/v1/integrations/gmail/installations/INSTALLATION_ID/oauth/authorize, which keeps its store.
last_run.status is
in_progress, completed, failed, or stale; a large mailbox stays in_progress across
slices until the label is done. To list individual files, use
List Store Files.
Search
curl -X POST https://api.mixedbread.com/v1/stores/search \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer YOUR_API_KEY' \
-d '{"query": "renewal terms", "store_identifiers": ["STORE_ID"], "top_k": 10}'Pass only the store ids that belong to the signed-in user. The organization key can search every store.
Google Drive
Same flow as Gmail with /v1/integrations/google_drive/oauth/authorize. Sync the whole Drive:
curl -X POST https://api.mixedbread.com/v1/integrations/installations/INSTALLATION_ID/sync \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer YOUR_API_KEY' \
-d '{"sync_all": true}'Differences:
sync_allruns one job over the whole Drive, Shared with me included, and registers push notifications for changes.queued_resource_idsis empty and the root folders keeplast_run: null; watchfile_countson the store instead. Shared drives are not part of it: select the Shared drives resource to sync those.- Resources are hierarchical: list a folder's children with
?parent_id=FOLDER_ID. Selecting a folder syncs its subtree. - The root listing holds My Drive, Shared with me, and Shared drives when the account is a member of any. The last two are folders like the others: selecting one syncs everything it holds now and later, and selecting a single shared drive under it syncs only that drive. The two do not overlap — a file that lives in a shared drive is synced through that drive, even when it was also shared with the account directly.
google_drive.folder_pathon a store file is the location the user recognises, rooted at the scope the file came from:My Drive/Reports/2026orShared drives/Engineering/Specs.- Files Drive cannot export (unsupported types, empty files) are skipped, not failed.
- Reconnect after
needs_reauththrough/v1/integrations/google_drive/installations/INSTALLATION_ID/oauth/authorizewith the same Google account.
Slack
Same flow with /v1/integrations/slack/oauth/authorize. Sync every conversation the user can read:
curl -X POST https://api.mixedbread.com/v1/integrations/installations/INSTALLATION_ID/sync \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer YOUR_API_KEY' \
-d '{"sync_all": true}'Differences:
- Users connect as themselves (
access_mode=user, the default), each with their own installation and store.account.workspaceholds the workspace name. Only user mode is accepted over the API: a request withaccess_mode=botis rejected with403, because a bot installation joins the whole workspace and every selected channel, which your users cannot see or consent to. Workspace bots are installed from the dashboard only. sync_allselects all four conversation kinds below. Public channels are ingested whether or not the user has joined them, as the user can read them in Slack.- New messages arrive only through Slack events; there is no schedule to set.
- Reconnect through
/v1/integrations/slack/installations/INSTALLATION_ID/oauth/authorizeas the same Slack user.
Conversation kinds
A Slack selection is made of aliases, one per kind of conversation, plus any individual
conversations. An alias covers every conversation of its kind the user can read, including ones that
appear later. The listing's aliases names them:
| Alias | Covers |
|---|---|
public_channels | Every public channel in the workspace, joined or not |
private_channels | Private channels the user is in |
direct_messages | The user's direct messages |
group_messages | Group direct messages |
To sync only private conversations, plus one public channel the user cares about:
curl -X PUT https://api.mixedbread.com/v1/integrations/installations/INSTALLATION_ID/resources \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer YOUR_API_KEY' \
-d '{
"resource_aliases": ["private_channels", "direct_messages", "group_messages"],
"resource_ids": ["C0123ABC"]
}'The request replaces the whole selection: aliases and conversations not listed stop syncing, and
what was already imported stays in the store. The listing reports the result as
selected_resource_aliases, flags each alias with is_selected, and sets sync_all to true
only while all four are selected:
{
"aliases": [
{ "id": "public_channels", "name": "Public channels", "kind": "channel", "is_selected": false },
{ "id": "private_channels", "name": "Private channels", "kind": "private_channel", "is_selected": true },
{ "id": "direct_messages", "name": "Direct messages", "kind": "direct_message", "is_selected": true },
{ "id": "group_messages", "name": "Group messages", "kind": "group_message", "is_selected": true }
],
"selected_resource_aliases": ["private_channels", "direct_messages", "group_messages"],
"selected_resource_ids": ["C0123ABC"],
"sync_all": false
}How conversations enter a selected alias:
- Right after connecting, the conversation catalog is still being built: the listing reports
listing_status: "loading", and a selection saved meanwhile is applied when the walk completes. - Channels the user creates, joins, or unarchives are added from Slack events, their history is imported, and they stay current. A private channel the user leaves stops syncing the same way.
- A public channel the user has not joined is imported in full and kept current from Slack events, like any other selected conversation.
- Slack sends no event when a direct message or group message is opened, so one is added when its first message arrives. That message and the rest of the conversation are imported with it.
- A channel that turns private or public moves to the other kind: it starts syncing if that alias is selected and stops if not. A conversation selected by ID keeps syncing whatever its kind.
- Refresh Resources rebuilds the whole catalog on demand, for example after a long outage of your events endpoint.
Granola
No OAuth. Connect with the user's API key and state who it belongs to:
curl -X POST https://api.mixedbread.com/v1/integrations/granola/connect \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer YOUR_API_KEY' \
-d '{
"api_key": "GRANOLA_API_KEY",
"account_name": "Olivia Chen",
"account_email": "olivia@example.com",
"store_name": "notes-42"
}'The response has the shape of Get Installation:
data.installation_id and data.store_id. An invalid key returns 422. Sync every note:
curl -X POST https://api.mixedbread.com/v1/integrations/installations/INSTALLATION_ID/sync \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer YOUR_API_KEY' \
-d '{"sync_all": true}'Then monitor and search as in steps 4 and 5 of the Gmail flow. Differences:
sync_allimports every note the key can see. Mixedbread registers and manages a Granola webhook for each installation, so generated and edited notes arrive as Granola sends them; your platform configures nothing. Deleted notes, which Granola sends no event for, are caught by polling every ten minutes by default; change the cadence with Update Installation Schedule:off,ten_minutes,hourly,daily, orweekly.- Resources are folders, listed hierarchically like Drive.
- Personal keys expire on Granola's side. The installation then records
sync.last_erroruntil it is reconnected with a new key.
Reconnect Granola
Replace an installation's API key, for example after the user's personal key expired:
curl -X POST https://api.mixedbread.com/v1/integrations/granola/installations/INSTALLATION_ID/reconnect \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer YOUR_API_KEY' \
-d '{"api_key": "NEW_GRANOLA_API_KEY"}'The installation keeps its store, folder selection and sync schedule, and the response has the shape
of Get Installation. Granola cannot tell
whose key it is, so the new key is not checked against the old one: pass the key of the user the
installation belongs to. An invalid key returns 422, and an unknown installation 404.
Connecting the new key through Connect Granola
instead creates a second installation next to the old one.
Disconnect
curl -X DELETE https://api.mixedbread.com/v1/integrations/installations/INSTALLATION_ID \
-H 'Authorization: Bearer YOUR_API_KEY'Stops the sync and revokes the grant at the provider. Google revokes the whole authorization for a client, so a Google grant is kept while another installation of the same account uses the same OAuth client, for example Gmail and Drive on one client. The store and its files remain; delete the store separately if the data should go too.