Recipe: connect any API with OAuth
Some services do not hand out API keys: you sign in with OAuth 2.0 instead. BakedBrie connects them through your own OAuth app at the service (its client id and client secret). Steps, templates, preview and checks work exactly as in Connect any API, so read that page first. Only the sign-in is different.
How it works:
- The definition's
authis{"type": "oauth2", ...}. The destination's credential is the OAuth app's client secret. BakedBrie sends it only to the service's token and revocation URLs, never to the API. - Authorization code (
grant: "authorization_code"): a person approves once, in a browser. BakedBrie keeps the tokens sealed, never shows them, and adds the access token to API calls to the base URL's host only. - Client credentials (
grant: "client_credentials", machine to machine): no browser step. BakedBrie gets a token with the client id and secret. - An agent can hand over the sign-in link but can never finish a sign-in: that happens only in the browser of the person who connected the destination, or a workspace owner or admin.
- Changing the base URL or
authlater needs the client secret again and a new sign-in, so a sign-in never follows the definition to a new place.
Before you run it
- At the service, create an OAuth app and register this exact redirect URL. It must match character for character:
https://app.bakedbrie.com/settings/destinations/oauth/callback
- Note the app's client id. Put its client secret in an environment variable in your own shell, for example
export LINKEDIN_CLIENT_SECRET=.... Never paste it into the chat. - From the service's API docs, find: the API base URL, the authorize URL, the token URL, the revocation URL (if it has one), the scopes you need, whether it supports PKCE, and whether it wants the client id and secret as HTTP Basic or as form fields.
whoamimust showdestinations,api_workflowandcustom_oauthon.
The prompt
In BakedBrie, make finished cards on the board "{{BOARD_NAME}}" send to {{SERVICE_NAME}} through a custom API destination that signs in with OAuth. Use only the BakedBrie MCP tools. Start with whoami and stop if destinations, api_workflow or custom_oauth is off. First read the docs pages /docs/recipes/connect-any-api, /docs/recipes/connect-any-api-oauth and /docs/concepts (section "Custom API steps and templates") with read_docs.
The service: API base URL {{BASE_URL}}, OAuth app client id {{CLIENT_ID}}, grant {{GRANT}}, authorize URL {{AUTHORIZE_URL}}, token URL {{TOKEN_URL}}, revocation URL {{REVOCATION_URL_OR_NONE}}, scopes {{SCOPES}}. The client secret is in my environment variable {{ENV_VAR}}. A harmless GET that proves the sign-in works: {{CHECK_PATH}}.
What a finished card should become: {{WHAT_DELIVER_DOES}}
1. Find the board with list_boards.
2. Call prepare_secret with kind oauth_client_secret, purpose destination and env_var {{ENV_VAR}}. Run the command it returns in my shell exactly as given, then use the drop_id. Never print the secret.
3. Write the webhook definition with auth {"type": "oauth2", ...} from the recipe. Call manage_destination with action preview and that webhook (a draft), show me each request, and fix every problem it lists. A preview sends nothing.
4. When I agree, create it with manage_destination: action create, name "{{SERVICE_NAME}}", the webhook, credential {"drop_id": "<the drop id>"}.
5. For authorization_code: give me oauth.consent_url from the answer. I open it in the browser where I am signed in to BakedBrie, approve at {{SERVICE_NAME}}, and click Finish connecting. Then call manage_destination get every few seconds until oauth_grant.state is connected, or tell me oauth_grant.error_code if it is failed. For client_credentials: call get until oauth_grant.state is connected.
6. Check the connection: manage_destination action test_call with the destination_id and path {{CHECK_PATH}}. Read it with test_call_id until state is done or failed, and tell me the status.
7. Make it the board's default destination with manage_destination action set_board_default.
8. Read it back with manage_destination action get and tell me, in plain words, what each finished card will send.
The OAuth auth block
| Field | What it is |
|---|---|
type | Always oauth2. |
grant | authorization_code (a person approves in the browser) or client_credentials (machine to machine). |
client_id | The OAuth app's client id. Not a secret: the secret goes in credential. |
authorize_url | Authorization code only: where the person approves. https, no query. |
token_url | Where BakedBrie gets and refreshes tokens. https, no query. |
revocation_url | Optional. Where BakedBrie asks the service to revoke the sign-in when the destination is turned off (RFC 7009). |
scopes | The scopes to ask for, for example ["openid", "profile"]. Default none. At most 30. |
client_auth | How the client id and secret go to the token URL: basic (HTTP Basic, the default) or body (form fields). |
pkce | Authorization code only: S256 (the default) or off for a service that does not support PKCE. |
issuer | Authorization code only, optional. When set, the service must send back this iss with the sign-in (RFC 9207). |
extra_authorize_params | Authorization code only, optional: access_type (offline or online), prompt (consent, select_account, login or none) and include_granted_scopes (true or false). Nothing else. |
header, prefix | Where the access token goes on API calls. Default Authorization and Bearer . |
provider | linkedin, sprout_social or google: the URLs and the grant must then be exactly the ones in the table below. other, or left out: any https URLs. |
A named provider must use exactly these URLs and one of these grants. Use the client_auth and pkce shown too: they are what each service documents.
| Provider | Grants | Authorize URL | Token URL | Revocation URL | client_auth | pkce |
|---|---|---|---|---|---|---|
linkedin | authorization_code | https://www.linkedin.com/oauth/v2/authorization | https://www.linkedin.com/oauth/v2/accessToken | none | body | off |
sprout_social | authorization_code, client_credentials | https://identity.sproutsocial.com/oauth2/84e39c75-d770-45d9-90a9-7b79e3037d2c/v1/authorize | https://identity.sproutsocial.com/oauth2/84e39c75-d770-45d9-90a9-7b79e3037d2c/v1/token | https://identity.sproutsocial.com/oauth2/84e39c75-d770-45d9-90a9-7b79e3037d2c/v1/revoke | body | S256 |
google | authorization_code | https://accounts.google.com/o/oauth2/v2/auth | https://oauth2.googleapis.com/token | https://oauth2.googleapis.com/revoke | body | S256 |
For google, an issuer, when set, must be https://accounts.google.com. Google returns a refresh token only with access_type offline; prompt consent makes it ask again, so a repeat sign-in gets one too: "extra_authorize_params": {"access_type": "offline", "prompt": "consent"}.
After the sign-in
manage_destination get returns oauth_grant (never a token):
oauth_grant.state | Meaning |
|---|---|
exchange_pending | The person approved. BakedBrie is finishing the sign-in with the service. Read again in a few seconds. |
connected | Deliveries send with this sign-in. |
reconnect_needed | The service stopped accepting it. Deliveries fail with OAUTH_RECONNECT_NEEDED until the person signs in again with oauth.consent_url (the web app shows Sign in again). |
failed | The sign-in did not finish. error_code says why. Check the client id, the secret, the scopes and the redirect URL, then sign in again. |
revoking, revoked | Turned off. revocation says whether the service confirmed (confirmed, unconfirmed, or not_supported when there is no revocation URL). |
For an authorization code destination, create, get and complete_oauth also return oauth: consent_url (a BakedBrie link; once opened, the sign-in must finish within 10 minutes), redirect_uri (the URL the OAuth app must list) and next (what to tell the person).
To stop using a sign-in while keeping the destination, the person turns it off in the BakedBrie web app (the destination's settings). An API token cannot disconnect it. manage_destination action disable turns off the whole destination and its sign-in.
Worked example: LinkedIn
This posts each finished card to the LinkedIn feed of the person who signs in. It needs two self-serve LinkedIn products and three scopes.
- At LinkedIn Developers, create an app. On its Products tab, add Share on LinkedIn (scope
w_member_social) and Sign In with LinkedIn using OpenID Connect (scopesopenidandprofile). - On its Auth tab, add the redirect URL
https://app.bakedbrie.com/settings/destinations/oauth/callback, and copy the client id. In your shell:export LINKEDIN_CLIENT_SECRET=...(the app's client secret). prepare_secretwith{"kind": "oauth_client_secret", "purpose": "destination", "env_var": "LINKEDIN_CLIENT_SECRET"}, and run the command it returns.manage_destinationactionpreview, thencreatewith thiswebhookandcredential{"drop_id": "<the drop id>"}:
{
"base_url": "https://api.linkedin.com",
"auth": {
"type": "oauth2",
"grant": "authorization_code",
"provider": "linkedin",
"client_id": "YOUR_LINKEDIN_CLIENT_ID",
"authorize_url": "https://www.linkedin.com/oauth/v2/authorization",
"token_url": "https://www.linkedin.com/oauth/v2/accessToken",
"client_auth": "body",
"pkce": "off",
"scopes": ["openid", "profile", "w_member_social"]
},
"headers": {"Linkedin-Version": "202609", "X-Restli-Protocol-Version": "2.0.0"},
"actions": {
"deliver": {
"steps": [
{"name": "me", "method": "GET", "path": "/v2/userinfo", "save": {"sub": "$.sub"}, "require": ["sub"]},
{
"name": "post",
"path": "/rest/posts",
"body": {
"author": "urn:li:person:{{steps.me.sub}}",
"commentary": "{{result.captions.platform_captions.LINKEDIN}}",
"visibility": "PUBLIC",
"distribution": {"feedDistribution": "MAIN_FEED", "targetEntities": [], "thirdPartyDistributionChannels": []},
"lifecycleState": "PUBLISHED",
"isReshareDisabledByAuthor": false
}
}
]
}
}
}
A preview with the sample card lists TEMPLATE_INVALID for the missing LINKEDIN caption (see below). Preview with the card_id of a finished card whose captions have one to see the real post.
- Give the person
oauth.consent_urlfrom the answer. They open it in the browser where they are signed in to BakedBrie, approve on LinkedIn, and click Finish connecting.getthen showsoauth_grant.stateconnected. test_callwith path/v2/userinfo: status 200, and the snippet shows the person's name. The token is never shown.set_board_default, then move a card to Done.
What each part does, from LinkedIn's docs:
GET /v2/userinfo(Sign In with LinkedIn using OpenID Connect) answers the member'ssub. The post's author isurn:li:person:<sub>.POST /rest/posts(the Posts API) needs theLinkedin-Versionheader (a supported version,YYYYMM;202609is the latest as of September 2026, and each version is supported for at least a year) andX-Restli-Protocol-Version: 2.0.0.PUBLISHEDis the onlylifecycleStateLinkedIn accepts when a post is created, so a post goes live at once; there are no drafts.- LinkedIn reads
commentaryas its "little" text format, where| { } @ [ ] ( ) < > # \ * _ ~are reserved and must be escaped with a backslash. BakedBrie templates have no filter for that, so the example sends the card'sLINKEDINplatform caption: tell the board's agent to write it in thebakedbrie-captionsblock with those characters escaped. A card without one stops before anything is sent.
Limits to know:
- LinkedIn sign-ins last 60 days, and LinkedIn gives refresh tokens only to a limited set of partner apps. After 60 days the destination shows
reconnect_needed(Sign in again in the web app): the person opensoauth.consent_urlagain. - LinkedIn answers a new post with 201 and puts the post id in a response header, which BakedBrie does not read. The receipt shows the step succeeded with status 201 and no post id, so the post step has no
require. - Finding a member's own posts needs a restricted LinkedIn permission, so there is no
reconcile. If a post's answer is lost, the delivery is outcome unknown and is never resent: look at the LinkedIn feed before you post it again by hand. - LinkedIn lists no revocation URL. Turning the destination off deletes BakedBrie's copy of the sign-in; the person can also remove the app's access in their LinkedIn settings.
- Posting as a company page needs the
w_organization_socialpermission, which this example does not cover. - Share on LinkedIn allows 150 requests per member per day.
Sprout Social
The Sprout Social API is part of Sprout's Advanced plan. It works two ways:
- An API token: send it as
Authorization: Bearer <token>, so use the plain Connect any API recipe with header auth. No OAuth needed. - OAuth machine to machine:
client_credentialswith scopeorganization_id. No browser step.
Sprout's publishing API creates drafts only ("is_draft": true is required): a person finishes each post in Sprout. Find your customer id with GET /v1/metadata/client and your profile ids with GET /v1/<customer id>/metadata/customer (a test_call shows them). The API allows 60 requests per minute and 250,000 per month.
{
"base_url": "https://api.sproutsocial.com",
"auth": {
"type": "oauth2",
"grant": "client_credentials",
"provider": "sprout_social",
"client_id": "YOUR_SPROUT_CLIENT_ID",
"token_url": "https://identity.sproutsocial.com/oauth2/84e39c75-d770-45d9-90a9-7b79e3037d2c/v1/token",
"revocation_url": "https://identity.sproutsocial.com/oauth2/84e39c75-d770-45d9-90a9-7b79e3037d2c/v1/revoke",
"client_auth": "body",
"scopes": ["organization_id"]
},
"slots": {"timezone": "America/New_York", "daily_times": ["10:00"], "include_weekends": false},
"actions": {
"deliver": {
"steps": [
{"name": "slot", "kind": "reserve_slot", "save": {"slot": "$.slot"}, "receipt": ["slot"]},
{
"name": "draft",
"path": "/v1/YOUR_CUSTOMER_ID/publishing/posts",
"body": {
"group_id": 55667788,
"customer_profile_ids": [2345],
"is_draft": true,
"text": "{{result.captions.caption}}",
"delivery": {"scheduled_times": ["{{steps.slot.slot}}"], "type": "SCHEDULED"}
},
"save": {"post_id": "$.data[0].internal.publishing.publishing_post_id"},
"receipt": ["post_id"],
"external_id": "post_id"
}
]
}
}
}
Replace YOUR_CUSTOMER_ID, group_id and customer_profile_ids with your own. Check the body against Sprout's publishing docs before the first send.
What to expect
- Deliveries, receipts,
redeliverandpreviewwork as in Connect any API. A preview shows the token header asBearer •••• (from sign-in). - When the access token runs out, BakedBrie refreshes it before the first step (when the service gave a refresh token), never in the middle of a delivery. A 401 in the middle fails that delivery without sending again; the next one refreshes first.
- Every code: Refusals.