# 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](/docs/recipes/connect-any-api-oauth).

How it works:

- The destination holds the definition: a base URL, how it signs in, and named actions made of steps. The `deliver` action 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.
- `preview` shows each request exactly as it would be sent, with the key masked. Nothing is sent.
- `test_call` checks 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](#unclear-sends)).

Every step field, template and filter is in [Concepts: custom API steps and templates](/docs/concepts#custom-api-steps-and-templates).

## Before you run it

1. Connect [Claude Code](/docs/connect-claude-code) or [Codex](/docs/connect-codex) to BakedBrie. `whoami` must show `destinations` and `api_workflow` on.
2. 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.
3. 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: `Authorization` and `Bearer `);
   - a harmless GET path that proves the key works, for example `/me`;
   - in plain words, what a finished card should become at the service.
4. Fill in the UPPERCASE `{{...}}` values in the prompt. Lowercase ones like `{{card.title}}` are BakedBrie templates: keep them as written.

## The prompt

```text
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](https://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.

1. In your shell: `export HTTPBIN_DEMO_KEY=made-up-key-123`
2. `prepare_secret`:

   ```json
   {"kind": "api_key", "purpose": "destination", "env_var": "HTTPBIN_DEMO_KEY"}
   ```

   Run the `command` it returns, exactly as given. Keep the `drop_id`.
3. `manage_destination` with action `preview` and the draft definition. Nothing is sent:

   ```json
   {
     "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.
4. `manage_destination` with action `create`, `name` "httpbin test", the same `webhook`, and `credential` `{"drop_id": "<the drop id>"}`. Keep the destination `id`.
5. `manage_destination` with action `test_call`, the `destination_id` and `path` `/me`. It answers `{"test_call_id": "...", "state": "pending"}`. A few seconds later, call it again with `test_call_id`: `state` is `done`, `status` is 200, and the `snippet` shows httpbin's echo of the headers with `"Authorization": "Bearer [removed]"`.
6. `manage_destination` with action `set_board_default`, the `board_id` and the `destination_id`.
7. Move one card to Done (`preview_move`, then `move_card`). `list_results` then shows its delivery with a receipt like this:

   ```json
   {"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"}}]}
   ```

8. When you are done, turn it off: `manage_destination` with action `disable` and the `destination_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.

```json
{
  "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`.

```json
{
  "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:

````text
```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.

```json
{
  "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.

```json
{
  "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).

```json
{
  "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.

```json
{
  "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.

```json
{
  "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_destination` action `get` shows 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_results` shows its receipt: each step with its state, HTTP status, error code and the `receipt` fields 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_destination` action `update` with the whole `webhook` and the `expected_revision` from `get`. Changing `base_url` or `auth` needs 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](/docs/refusals).
