Recipe: connect any API
A custom API destination sends a finished card to any service with an HTTPS API. You describe the calls as steps, and BakedBrie makes them with your key. Use it when the service has no preset (Slack and WoopSocial have their own recipes). If the service signs in with OAuth instead of a key, read this page first, then Connect any API with OAuth.
How it works:
- The destination holds the definition: a base URL, how it signs in, and named actions made of steps. The
deliveraction runs when a card on the board finishes (or is approved, on a board with reviews). - The key is sent only to the base URL's host, in the header you name. It never appears in the definition, the chat, a receipt or a saved answer.
previewshows each request exactly as it would be sent, with the key masked. Nothing is sent.test_callchecks the connection with one GET you name, sent once.- A step that posts is sent once. If its answer never arrives, BakedBrie does not send it again (see Unclear sends).
Every step field, template and filter is in Concepts: custom API steps and templates.
Before you run it
- Connect Claude Code or Codex to BakedBrie.
whoamimust showdestinationsandapi_workflowon. - Put the service's API key in an environment variable in your own shell, for example
export ACME_API_KEY=.... Never paste a key into the chat. - Have these ready:
- the API's base URL (https), for example
https://api.example.com/v1; - the header the key goes in and the text before it (most services:
AuthorizationandBearer); - a harmless GET path that proves the key works, for example
/me; - in plain words, what a finished card should become at the service.
- the API's base URL (https), for example
- Fill in the UPPERCASE
{{...}}values in the prompt. Lowercase ones like{{card.title}}are BakedBrie templates: keep them as written.
The prompt
In BakedBrie, make finished cards on the board "{{BOARD_NAME}}" send to {{SERVICE_NAME}} through a custom API destination. Use only the BakedBrie MCP tools. Start with whoami and stop if destinations or api_workflow is off. First read the docs pages /docs/recipes/connect-any-api and /docs/concepts (section "Custom API steps and templates") with read_docs.
The API: base URL {{BASE_URL}}. The key is in my environment variable {{ENV_VAR}} and goes in the header {{HEADER}} as "{{PREFIX}}<key>". A harmless GET that proves the key 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 api_key, 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 key.
3. Write the webhook definition from the recipe's patterns. 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. Check the connection: manage_destination action test_call with the destination_id and path {{CHECK_PATH}}. Read it with test_call_id every few seconds until state is done or failed, and tell me the status.
6. Make it the board's default destination with manage_destination action set_board_default.
7. Read it back with manage_destination action get and tell me, in plain words, what each finished card will send.
Worked example: httpbin
httpbin.org is a public test service: https://httpbin.org/anything answers every request with everything it received, including every header. That makes it a safe first try, with a made-up key, because you can see exactly what BakedBrie sent. BakedBrie removes the key from what it keeps: receipts, saved answers and the check's snippet show [removed] in its place.
- In your shell:
export HTTPBIN_DEMO_KEY=made-up-key-123 prepare_secret:
{"kind": "api_key", "purpose": "destination", "env_var": "HTTPBIN_DEMO_KEY"}
Run the command it returns, exactly as given. Keep the drop_id.
manage_destinationwith actionpreviewand the draft definition. Nothing is sent:
{
"action": "preview",
"webhook": {
"base_url": "https://httpbin.org/anything",
"auth": {"header": "Authorization", "prefix": "Bearer "},
"actions": {
"deliver": {
"steps": [
{
"name": "post",
"path": "/posts",
"body": {"title": "{{card.title}}", "text": "{{result.markdown | clip:2000}}", "link": "{{card.web_url}}"},
"save": {"title": "$.json.title", "url": "$.url"},
"receipt": ["title", "url"],
"ok_when": {"path": "$.method", "equals": "POST"}
}
]
}
}
}
}
The answer lists one request: POST https://httpbin.org/anything/posts, the header authorization: Bearer •••• (saved key), and the JSON body filled in from a sample card. problems is empty.
manage_destinationwith actioncreate,name"httpbin test", the samewebhook, andcredential{"drop_id": "<the drop id>"}. Keep the destinationid.manage_destinationwith actiontest_call, thedestination_idandpath/me. It answers{"test_call_id": "...", "state": "pending"}. A few seconds later, call it again withtest_call_id:stateisdone,statusis 200, and thesnippetshows httpbin's echo of the headers with"Authorization": "Bearer [removed]".manage_destinationwith actionset_board_default, theboard_idand thedestination_id.- Move one card to Done (
preview_move, thenmove_card).list_resultsthen shows its delivery with a receipt like this:
{"action": "deliver", "state": "succeeded", "steps": [{"name": "post", "state": "succeeded", "http_status": 200, "error_code": null, "ids": {"title": "Spring launch carousel", "url": "https://httpbin.org/anything/posts"}}]}
- When you are done, turn it off:
manage_destinationwith actiondisableand thedestination_id.
Patterns
Each pattern is a complete definition for a made-up API at https://api.example.com/v1: POST /media takes one file and answers {"id"}, POST /posts takes JSON and answers {"id", "url"}, GET /posts?text=... lists posts. Change the paths and fields to match your service's own API docs, then preview.
Send JSON
One call per finished card. require says the answer must carry an id; external_id puts it on the receipt as the delivery's id.
{
"base_url": "https://api.example.com/v1",
"auth": {"header": "Authorization", "prefix": "Bearer "},
"actions": {
"deliver": {
"steps": [
{
"name": "post",
"path": "/posts",
"body": {"title": "{{card.title}}", "text": "{{result.markdown}}", "link": "{{card.web_url}}"},
"save": {"id": "$.id", "url": "$.url"},
"require": ["id"],
"receipt": ["id", "url"],
"external_id": "id"
}
]
}
}
}
Upload files, then create a post
The first step runs once per file the card produced (for_each: files) and saves each file's id. The second step sends the list of ids with {{steps.upload | optional | map:id}}: map:id takes the id of each saved answer, and optional leaves media_ids out when the card has no files. An upload that runs twice leaves an unused file at the service, not a second post, so it is marked retry_safe.
{
"base_url": "https://api.example.com/v1",
"auth": {"header": "Authorization", "prefix": "Bearer "},
"actions": {
"deliver": {
"steps": [
{
"name": "upload",
"path": "/media",
"body_format": "multipart_file",
"file_field": "file",
"for_each": "files",
"save": {"id": "$.id"},
"require": ["id"],
"receipt": ["id"],
"retry_safe": true
},
{
"name": "post",
"path": "/posts",
"body": {"text": "{{card.title}}: {{result.captions.caption}}", "media_ids": "{{steps.upload | optional | map:id}}"},
"save": {"id": "$.id", "url": "$.url"},
"require": ["id"],
"receipt": ["id", "url"],
"external_id": "id"
}
]
}
}
}
The result's own Markdown is not sent as a file. multipart_file sends each file as one form part named file_field; raw_file sends the file bytes as the whole body.
Captions
The agent that works the card writes captions in a fenced block tagged bakedbrie-captions in its result:
```bakedbrie-captions
{"caption": "Short caption for most places", "hashtags": ["#one", "#two"], "platform_captions": {"X": "Caption for X"}}
```
{{result.captions.caption}} is that caption. Without a block, it is the result's Markdown with fenced blocks removed. {{result.captions.hashtags}} is the list, {{result.captions.hashtags | join:space}} the same list as text, and {{result.captions.platform_captions.X}} one platform's own caption. When the board has a check rule and the result has no block, the check's block is used. A block that is not valid JSON stops the delivery before anything is sent, and preview with that card shows the problem.
{
"base_url": "https://api.example.com/v1",
"auth": {"header": "Authorization", "prefix": "Bearer "},
"actions": {
"deliver": {
"steps": [
{
"name": "post",
"path": "/posts",
"body": {
"text": "{{result.captions.caption}}\n\n{{result.captions.hashtags | join:space}}",
"short_text": "{{result.captions.platform_captions.X | optional}}"
},
"save": {"id": "$.id", "url": "$.url"},
"require": ["id"],
"receipt": ["id", "url"],
"external_id": "id"
}
]
}
}
}
Targets: one call per account
targets lists the accounts. A step with for_each: targets runs once for each, with that target's fields in {{target.<field>}}. A target's variant picks a named variant of the step (its own path or body). Each target gets its own receipt line; one that fails does not stop the others, and the delivery then says failed.
{
"base_url": "https://api.example.com/v1",
"auth": {"header": "Authorization", "prefix": "Bearer "},
"targets": [
{"key": "brand", "account": "acct_brand"},
{"key": "founder", "account": "acct_founder", "variant": "short"}
],
"actions": {
"deliver": {
"steps": [
{
"name": "post",
"path": "/posts",
"for_each": "targets",
"body": {"text": "{{result.captions.caption}}", "account": "{{target.account}}"},
"variants": {"short": {"body": {"text": "{{result.captions.caption | clip:280}}", "account": "{{target.account}}"}}},
"save": {"id": "$.id", "url": "$.url"},
"require": ["id"],
"receipt": ["id", "url"],
"external_id": "id"
}
]
}
}
}
Posting times
slots sets the posting times. A step with kind: reserve_slot gives the card the next open time, at least 30 minutes ahead and never one another card of this destination holds. It sends nothing to the service; a retry keeps the same time. Use it as {{steps.slot.slot}} (ISO 8601, UTC).
{
"base_url": "https://api.example.com/v1",
"auth": {"header": "Authorization", "prefix": "Bearer "},
"slots": {"timezone": "America/New_York", "daily_times": ["09:00", "13:00"], "include_weekends": false},
"actions": {
"deliver": {
"steps": [
{"name": "slot", "kind": "reserve_slot", "save": {"slot": "$.slot"}, "receipt": ["slot"]},
{
"name": "post",
"path": "/posts",
"body": {"text": "{{result.captions.caption}}", "publish_at": "{{steps.slot.slot}}"},
"save": {"id": "$.id", "url": "$.url"},
"require": ["id"],
"receipt": ["id", "url"],
"external_id": "id"
}
]
}
}
}
Unclear sends
A timeout, a lost connection or a 5xx answer after a post was sent means BakedBrie cannot know whether it landed. The step is then outcome unknown, and BakedBrie never sends it again: redeliver does not repeat it either. Leave retry_safe off for any step that creates something (it is off by default for POST, PUT, PATCH and DELETE; a GET is always safe to repeat).
Add reconcile so a redeliver looks for the post instead: it lists posts (up to max_pages pages), and when exactly one matches every match rule it adopts that post's id and the delivery succeeds. No match, or more than one, stays outcome unknown for a person to check.
{
"base_url": "https://api.example.com/v1",
"auth": {"header": "Authorization", "prefix": "Bearer "},
"actions": {
"deliver": {
"steps": [
{
"name": "post",
"path": "/posts",
"body": {"text": "{{result.captions.caption}}"},
"save": {"id": "$.id", "url": "$.url"},
"require": ["id"],
"receipt": ["id", "url"],
"external_id": "id",
"reconcile": {
"path": "/posts?text={{result.captions.caption}}&limit=100",
"items": "$.posts",
"next_cursor": "$.next_cursor",
"cursor_param": "cursor",
"match": [{"path": "$.text", "equals": "{{result.captions.caption}}"}],
"save": {"id": "$.id", "url": "$.url"}
}
}
]
}
}
}
Error mapping
Without mapping, a 401 or 403 is DESTINATION_AUTH_FAILED, a 429 is retried at most twice after the service's Retry-After (up to 5 seconds) and then RATE_LIMITED, another 4xx is HTTP_<status> (for example HTTP_422), and a 5xx is API_UNAVAILABLE.
error names where the service puts its own error code; BakedBrie turns it into an upper-case code with your prefix (text_required becomes EXAMPLE_TEXT_REQUIRED). ok_when is for a service that answers 200 even when it refused: the step succeeds only when the value at path equals equals, and otherwise fails with the mapped code, or error.code when the answer has none (API_NOT_OK when the step has no error). maybe_sent lists the service's codes that mean it may have posted anyway; those are outcome unknown, never resent.
{
"base_url": "https://api.example.com/v1",
"auth": {"header": "Authorization", "prefix": "Bearer "},
"actions": {
"deliver": {
"steps": [
{
"name": "post",
"path": "/posts",
"body": {"text": "{{result.captions.caption}}"},
"ok_when": {"path": "$.ok", "equals": true},
"error": {"path": "$.error", "prefix": "EXAMPLE_", "code": "EXAMPLE_NOT_OK", "maybe_sent": ["internal_error"]},
"save": {"id": "$.post.id"},
"require": ["id"],
"receipt": ["id"],
"external_id": "id"
}
]
}
}
}
The size limit
A whole webhook definition is at most 7000 characters, counted as saved: BakedBrie fills in each field's default (for example "kind": "http" and "body_format": "json"), which adds about 70 characters per step. Other limits: 1 to 12 actions, 1 to 20 steps per action, 20 targets, and 500 characters per path. A definition over the limit is refused with INVALID_INPUT, and preview says so before you create anything. To make one smaller, use short step names and leave out fields that repeat a default.
What to expect
manage_destinationactiongetshows the definition,has_credential: true, and the board it is the default for. The key is never shown.- Each finished card makes one delivery.
list_resultsshows its receipt: each step with its state, HTTP status, error code and thereceiptfields you chose. - A failed delivery can be retried with
redeliver. Steps that already succeeded are not sent again, and an outcome-unknown step is looked for (reconcile), never resent. - Changing the definition later:
manage_destinationactionupdatewith the wholewebhookand theexpected_revisionfromget. Changingbase_urlorauthneeds the key again (credential), so a saved key never follows the definition to a new place. - Other actions in the same definition can run on board events: see
manage_board_hooks. - Every code: Refusals.