# Deploying recipes

An automated recipe turns a template into a table in your workspace. This page is the
complete deployment path: what to call, in what order, and what each call needs.

Every request after the token exchange is workspace-scoped and needs a Bearer token. Get one from
[`POST /api/v3/token`](/api/authentication#create-a-token) as described in
[Authorization](/auth), and note that **every path ends in a slash**. Without it the API
answers `301`, and most HTTP clients follow that redirect by turning your `POST` into a `GET`
and dropping the body — you end up parsing an HTML `404` as JSON.

```bash
TOKEN=$(curl -s -X POST 'https://embedded.improvado.io/api/v3/token' \
  -u "$IMPROVADO_USER:$IMPROVADO_PASSWORD" \
  -H 'Content-Type: application/json' \
  -d '{"workspace_id": 23897}' | jq -r .token)
```

## The four calls

Creating a recipe does not configure it. `POST` gives you an empty recipe; the `PUT` that
supplies data sources, connections and accounts is a required step, not an edit. Skipping it
leaves a recipe that builds and fails with a message naming neither cause.

### 1. Pick a template

```bash
curl -s 'https://embedded.improvado.io/api/v3/automated-recipe-templates/' \
  -H "Authorization: Bearer $TOKEN"
```

The detail call additionally returns `output_columns` — the schema of the table the template
produces — so you can validate a template against your warehouse before deploying anything:

```bash
curl -s 'https://embedded.improvado.io/api/v3/automated-recipe-templates/123/' \
  -H "Authorization: Bearer $TOKEN"
```

### 2. Create the recipe

```bash
curl -s -X POST 'https://embedded.improvado.io/api/v3/automated-recipes/' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"template_id": 123, "title": "Cross-Channel"}'
```

`201` returns `recipe_id` and `datatable_id`.

**One recipe per template per workspace.** A second create from the same template returns
`400` with `existing_recipe_ids`:

```json
{
  "detail": "You already have a recipe from this template in this workspace. Use update/build to change account settings, or resetup to update the recipe from the template.",
  "existing_recipe_ids": ["9843192926382447373"]
}
```

Treat that as *already deployed* rather than as a failure: reuse one of the returned recipes.
The list can hold more than one id and its order is not defined — prefer the recipe whose
`state` is not `Failed`, and otherwise the most recently updated. To pull template changes into
one of them instead, see [resetup](#rolling-a-template-fix-into-deployed-recipes) below.

### 3. Configure it — mandatory

Resolve the connection and its accounts **in this workspace** first:

```bash
curl -s 'https://embedded.improvado.io/api/v3/datasources/facebook/connections' \
  -H "Authorization: Bearer $TOKEN"

curl -s 'https://embedded.improvado.io/api/v3/connections/69909/accounts' \
  -H "Authorization: Bearer $TOKEN"
```

Then send the configuration:

```bash
curl -s -X PUT 'https://embedded.improvado.io/api/v3/automated-recipes/12647075758935484548/' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
        "data_sources": ["facebook"],
        "connections": [
          {
            "connection_id": 69909,
            "connection_data_source": "facebook",
            "accounts": [{"id": 3559308, "account_id": "act_750501968745425"}]
          }
        ],
        "sync_from_date": "2026-01-01"
      }'
```

`accounts[]` takes objects, not bare identifiers, and `id` is Improvado's **internal** account
id — passing the platform's account id as a string returns
`400 "Invalid data. Expected a dictionary, but got str."`

The call returns `200` with an empty body. Read the recipe back to confirm what was stored.

### 4. Build and poll

```bash
curl -s -X POST 'https://embedded.improvado.io/api/v3/automated-recipes/12647075758935484548/build/' \
  -H "Authorization: Bearer $TOKEN"

curl -s 'https://embedded.improvado.io/api/v3/automated-recipes/12647075758935484548/' \
  -H "Authorization: Bearer $TOKEN"
```

`state` moves `Extracting Data` → `Building Dependencies` → `Ready to work`, or `Failed`.
The filter on the list endpoint matches `state` **exactly and case-sensitively**: an
unrecognised value returns `200` with an empty page rather than an error, so
`?state=Ready To Work` silently reports no recipes.

A `200` from `/build/` means the build started, not that it will finish. When it fails, the
reason is in the `error` object of the recipe detail, not in any HTTP response.

### What `Ready to work` means

`Ready to work` means the build waited for the extractions of the recipe and was built on the ones
that delivered. An extraction counts as delivered once one of its runs has completed: the first
data is in, while the historical backfill may still be loading.

The build does not wait for extractions that cannot deliver on their own - ones paused because a
connection was deleted or invalidated, an account was disabled, permissions were lost or someone
paused them, and existing extractions a rebuild reuses that have nothing running. It waits for the
others up to a limit, and any still missing then are left out. So when the recipe turns
`Ready to work`, rows of some accounts can be missing from the output, and no row counts are
checked. To confirm coverage, compare the accounts present in the output with the account list
you configured.

A build with nothing to build from fails: when no extraction delivered, or none did for one of the
template's extract templates. Depending on the template, a data source whose extractions all failed
to deliver either fails the recipe or is dropped while the other sources are built.

## When a build fails for missing data

A recipe reads extraction tables, and a build that starts before they exist fails on missing
data. Ask what it is waiting for:

```bash
curl -s 'https://embedded.improvado.io/api/v3/automated-recipes/12647075758935484548/extractions/' \
  -H "Authorization: Bearer $TOKEN"
```

```json
{
  "ready": false,
  "sources": [
    {"data_source": "facebook", "table": "ads_5972_facebook_all_data", "table_exists": false, "extraction_ids": [21996]},
    {"data_source": "google_ads", "table": "ads_6013_google_ads_all_data", "table_exists": true, "extraction_ids": [22014]}
  ]
}
```

`ready` is `true` once every listed table exists — that is the signal that repeating `/build/`
is worth it. The `extraction_ids` are the extractions the last build used - the ones it created
and the existing ones it reused - so they are empty before the first one, and a recipe that
reads no extraction tables answers `ready: false` with an empty `sources`. Each entry is one
table rather than one data source, so a source with several tables appears more than once —
key the list by `table`. Nothing is cached: every call queries the destination, and a
destination that cannot be queried answers `503` rather than reporting `table_exists: false`.

A table exists as soon as the first account's rows land in it, so `ready: true` does not mean that
every account has delivered, and it can be `true` while the recipe is still `Extracting Data`. The
recipe `state` is the readiness signal - see what `Ready to work` means above.

## Rolling a template fix into deployed recipes

When the template changes, recipes already deployed from it keep the cells they were created
with. `resetup` recreates them from the current template:

```bash
curl -s -X POST 'https://embedded.improvado.io/api/v3/automated-recipes/12647075758935484548/resetup/' \
  -H "Authorization: Bearer $TOKEN"
```

The configuration you supplied — data sources, connections, accounts, `sync_from_date` — is
preserved, and the build starts on its own, so no separate `/build/` call is needed. As with
`/build/`, `200` means started, not finished.

## Retiring a recipe

```bash
curl -s -X DELETE 'https://embedded.improvado.io/api/v3/automated-recipes/12647075758935484548/' \
  -H "Authorization: Bearer $TOKEN"
```

`204` and an empty body. This is a hard delete and cannot be undone: the SQL views are dropped,
the recipe's configuration and cells go with it, the data tables it created are cleaned up, and
the intermediate recipes it was built on are deleted with it. The cascade runs downwards, towards
what the recipe reads: a recipe built *on top* of this one is left in place and will break, and an
intermediate that another deployed recipe also reads is deleted all the same.

It frees the template slot — the one-recipe-per-template-per-workspace rule is checked on create,
so after a delete the same template can be deployed again. That is the way out of a failed
deployment, and the way to shrink an account set. `204` only means the deletion was attempted:
recipes are deleted one by one and a per-recipe failure is logged rather than returned, so confirm
with `GET /api/v3/automated-recipes/?template_id=` before deploying the template again.

## `PUT` is append-only

The payload is the **complete** configuration, not a delta: send every data source, connection
and account the recipe should have, including the ones it already has. Removing any of them
returns `400` and saves nothing:

```json
{"connections": ["Cannot remove accounts [3559308] from connection (connection_id=69909, data_source=\"facebook\"). You can only add new accounts."]}
```

There is no way to drop an account from a live recipe. If the set of accounts has to shrink,
[delete the recipe](#retiring-a-recipe) and deploy it again.

## Deploying one template across many workspaces

A custom template is visible to every workspace in your agency, so the template id is the same
everywhere. **The identifiers inside a workspace are not.** The same connector and the same ad
account carry different internal ids in each workspace:

| | workspace 23897 | workspace 23898 |
|---|---|---|
| Facebook `connection_id` | 69909 | 69933 |
| TikTok Ads `connection_id` | 69910 | 69935 |
| `act_750501968745425` → account `id` | 3559308 | 3562350 |
| `7127282116047536129` → account `id` | 3559367 | 3562412 |

Only the external `account_id` is stable. For each workspace: get a token, list the
connections, list that connection's accounts, join your own account list on the external
`account_id`, and use the internal `id` you got back — never an id resolved in another
workspace.

## The output table name

The name is derived from the **template's** title. The `title` you pass when creating a recipe
names the recipe in the UI and has no effect on the table: two deployments of the same template
with different titles produce the same table name.

Uniqueness is scoped to (agency, workspace), so the same table name repeating across your
workspaces is expected — do not try to make table names unique across your fleet. To learn the
physical name, take `datatable_id` from the recipe and read `sql_name` from
[`GET /api/v3/data-tables/{data_table_id}`](/api/data-tables#get-a-data-table).
