Mixedbread

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.

Sequence diagram of the Gmail connect flow between the user's browser, your backend, the Mixedbread API, and Google. 1. Your backend to Mixedbread API: Get the OAuth URL with your organization API key (GET /v1/integrations/gmail/oauth/authorize?store_name=mail-42&reference=user-42), which returns a redirect URL. 2. Your backend to User's browser: Send the browser to that URL. 3. User's browser to Google: The user consents. 4. Google to Mixedbread API: Call the Mixedbread callback (https://api.mixedbread.com/v1/integrations/gmail/oauth/callback). 5. Mixedbread API to User's browser: Redirect to your Return URL. 6. User's browser to Your backend: Arrive at your Return URL (https://platform.example.com/connected?provider=gmail&status=connected&installation_id=…&reference=user-42). On failure (?status=error&error=access_denied&reference=user-42). 7. Your backend: Store installation_id against your user. Mixedbread keeps no link between your users and their installations. 8. Your backend to Mixedbread API: Read the store id (GET /v1/integrations/installations/{installation_id}), which returns store_id and status: "active". 9. Your backend to Mixedbread API: Start the sync (POST /v1/integrations/installations/{installation_id}/sync with {"sync_all": true}). Or select resources by id (PUT /v1/integrations/installations/{installation_id}/resources). 10. Your backend to Mixedbread API: Monitor the sync, then search the user's store (POST /v1/stores/search).

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.

AppSecrets you enterWhere they come from
GmailClient ID, Client secret, Return URLA Google Auth Platform OAuth client (Web application) with the Gmail API enabled and the dialog's redirect URI authorized.
Google DriveClient ID, Client secret, Return URLThe same Google client or another one, with the Google Drive API enabled and the Drive redirect URI authorized.
SlackClient ID, Client secret, Signing secret, Return URLA 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.
GranolaNoneEach 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:

ConnectorRedirect URI
Gmailhttps://api.mixedbread.com/v1/integrations/gmail/oauth/callback
Google Drivehttps://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:

ConnectorScope
Gmailhttps://www.googleapis.com/auth/gmail.readonly
Google Drivehttps://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/callback

Add the scopes

On the same page, under Scopes:

SectionScopes
User Token Scopeschannels:history, channels:read, files:read, groups:history, groups:read, im:history, im:read, mpim:history, mpim:read, users:read
Bot Token Scopesusers: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/events

Slack 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:

ParameterValue
providergmail, google_drive, or slack
statusconnected or error
installation_idThe installation that was created or reconnected, when status=connected. Store it against your user.
errorWhy consent failed, when status=error
referenceThe reference you passed to authorize, unchanged, on both outcomes
errorMeaning
access_deniedThe user declined consent
missing_codeThe provider returned no authorization code
workspace_already_connectedSlack workspace bot (dashboard only): another Mixedbread organization installed the workspace's bot. Personal installations are not affected.
store_in_useThe store already receives data from another installation of the same account
store_existsnew_store was set and a store named store_name already exists
new_store_on_refreshnew_store was set on a connect that refreshes an existing installation
store_change_on_refreshstore_name named a store other than the installation's own on a connect that refreshes it
reconnect_account_mismatchA reconnect was authorized with a different account than the installation's
scope_not_grantedThe user unticked a scope on Google's consent screen. Start again and leave every scope ticked.
invalid_stateThe consent link expired or was reused. Start again.
token_exchange_failedGoogle rejected the code. Check the client secret and the redirect URI.
callback_failedAnything 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.

AppScopes
Gmailhttps://www.googleapis.com/auth/gmail.readonly
Google Drivehttps://www.googleapis.com/auth/drive.readonly
Slack, user tokenchannels:history, channels:read, files:read, groups:history, groups:read, im:history, im:read, mpim:history, mpim:read, users:read
Slack, bot tokenusers: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:

SettingValue
Request URLhttps://api.mixedbread.com/v1/integrations/slack/events
User eventsmessage.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

EndpointMethodPath
List OAuth ClientsGET/v1/integrations/oauth-clients
Register OAuth ClientPUT/v1/integrations/oauth-clients/{provider}
Delete OAuth ClientDELETE/v1/integrations/oauth-clients/{provider}
List OAuth Client ConnectionsGET/v1/integrations/oauth-clients/{provider}/connections

Connect

EndpointMethodPath
Authorize GmailGET/v1/integrations/gmail/oauth/authorize
Authorize Google DriveGET/v1/integrations/google_drive/oauth/authorize
Authorize SlackGET/v1/integrations/slack/oauth/authorize
Connect GranolaPOST/v1/integrations/granola/connect
Reconnect GranolaPOST/v1/integrations/granola/installations/{installation_id}/reconnect
Reconnect GmailGET/v1/integrations/gmail/installations/{installation_id}/oauth/authorize
Reconnect Google DriveGET/v1/integrations/google_drive/installations/{installation_id}/oauth/authorize
Reconnect SlackGET/v1/integrations/slack/installations/{installation_id}/oauth/authorize

Installations

EndpointMethodPath
List InstallationsGET/v1/integrations/installations
Get InstallationGET/v1/integrations/installations/{installation_id}
Disconnect InstallationDELETE/v1/integrations/installations/{installation_id}
Sync InstallationPOST/v1/integrations/installations/{installation_id}/sync
Update Installation SchedulePUT/v1/integrations/installations/{installation_id}/schedule
Replace ResourcesPUT/v1/integrations/installations/{installation_id}/resources
List Failed ResourcesGET/v1/integrations/installations/{installation_id}/resources/failed
Refresh ResourcesPOST/v1/integrations/installations/{installation_id}/resources/refresh

Stores

EndpointMethodPath
Get StoreGET/v1/stores/{store_identifier}
List Store FilesPOST/v1/stores/{store_identifier}/files/list
Search ChunksPOST/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.

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_all runs one job over the whole Drive, Shared with me included, and registers push notifications for changes. queued_resource_ids is empty and the root folders keep last_run: null; watch file_counts on 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_path on a store file is the location the user recognises, rooted at the scope the file came from: My Drive/Reports/2026 or Shared drives/Engineering/Specs.
  • Files Drive cannot export (unsupported types, empty files) are skipped, not failed.
  • Reconnect after needs_reauth through /v1/integrations/google_drive/installations/INSTALLATION_ID/oauth/authorize with 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.workspace holds the workspace name. Only user mode is accepted over the API: a request with access_mode=bot is rejected with 403, 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_all selects 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/authorize as 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:

AliasCovers
public_channelsEvery public channel in the workspace, joined or not
private_channelsPrivate channels the user is in
direct_messagesThe user's direct messages
group_messagesGroup 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_all imports 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, or weekly.
  • Resources are folders, listed hierarchically like Drive.
  • Personal keys expire on Granola's side. The installation then records sync.last_error until 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.