See all articles

Set up Metabase from code with the API

To set up Metabase from code, read the setup token from GET /api/session/properties and create the first admin with POST /api/setup. Then add a database, a native question, a dashboard and a dashboard subscription with the Metabase API, which works on every edition, including the free open-source edition. Serialization, which exports your questions and dashboards as YAML files you can keep in a repo, is part of the Pro and Enterprise plans.

Each endpoint, method and field below was checked against the Metabase API reference and the source code of Metabase v0.63.19 and v0.64.1. Every call works the same way in both versions. The examples use curl and jq, and each step reuses the IDs from the step before, so you can run them in order as one script. Steps 1 to 3 check what already exists, so the script doesn’t fail when it runs a second time. Steps 4 to 7 still add a new copy of each item on every run.

Where is the Metabase API reference?

The Metabase API reference lives at https://www.metabase.com/docs/latest/api, and its OpenAPI 3.1 spec is a plain JSON file at https://www.metabase.com/docs/latest/api.json. Your own Metabase serves the same reference for its exact version at /api/docs/, and the spec at /api/docs/openapi.json. The API isn’t versioned, so Metabase lists breaking changes in the API changelog at https://www.metabase.com/docs/latest/developers-guide/api-changelog.

If you read the source on GitHub, open it at a release tag such as v0.64.1. In that release, as in v0.63.19, POST /api/setup lives in src/metabase/setup_rest/api.clj, cards in src/metabase/queries_rest/api/card.clj, and dashboards in src/metabase/dashboards_rest/api.clj. Databases are in src/metabase/warehouses_rest/api.clj, API keys in src/metabase/api_keys/api.clj, and subscriptions in src/metabase/pulse/api/pulse.clj.

Step 1: read the setup token

Metabase creates a setup token the first time it starts, and that token lets you create the first user. Read it from GET /api/session/properties under the key setup-token, once GET /api/health answers {"status":"ok"}. Current releases, such as v0.63.19 and v0.64.1, don’t read the token from an environment variable, so the API is the place to get it.

export METABASE_URL="http://localhost:3000"

# /api/health returns 503 while Metabase starts, then 200 with {"status":"ok"}.
until curl -sf "$METABASE_URL/api/health" > /dev/null; do sleep 5; done

curl -s "$METABASE_URL/api/session/properties" |
  jq '{token: ."setup-token", users_exist: ."has-user-setup", version: .version.tag}'

SETUP_TOKEN=$(curl -s "$METABASE_URL/api/session/properties" | jq -r '."setup-token"')
USERS_EXIST=$(curl -s "$METABASE_URL/api/session/properties" | jq -r '."has-user-setup"')

Once the first user exists, Metabase clears the token, so setup-token reads null, and has-user-setup turns true. Check that flag first if your script can run twice, because a second POST /api/setup fails with a 400.

Step 2: POST /api/setup with the token, the user and prefs

The body of POST /api/setup has three keys: token, user and prefs. The user object needs email and password, and first_name and last_name are optional. The prefs object needs site_name and takes an optional site_locale such as en.

if [ "$USERS_EXIST" = "true" ]; then
  # Metabase is already set up, so log in as the first admin instead.
  SESSION_ID=$(jq -n --arg password "$METABASE_ADMIN_PASSWORD" \
      '{username: "admin@example.com", password: $password}' |
    curl -s -X POST "$METABASE_URL/api/session" -H "Content-Type: application/json" -d @- |
    jq -r '.id')
else
  SESSION_ID=$(jq -n \
    --arg token "$SETUP_TOKEN" \
    --arg password "$METABASE_ADMIN_PASSWORD" \
    '{
       token: $token,
       user: {first_name: "Ada", last_name: "Admin", email: "admin@example.com", password: $password},
       prefs: {site_name: "Acme analytics"}
     }' |
    curl -s -X POST "$METABASE_URL/api/setup" -H "Content-Type: application/json" -d @- |
    jq -r '.id')
fi

The first user becomes an admin, and the response is a session such as {"id": "..."} that you send in the X-Metabase-Session header. On a second run, has-user-setup is already true, so the script logs in as that admin with POST /api/session instead. The password must pass the password rule, which by default asks for at least six characters and one digit, and Metabase also turns down common passwords. The setup endpoint doesn’t take a database, so you add one in step 4 with POST /api/database, which is the same call the setup screen makes. Metabase drops any key an endpoint doesn’t declare, so a database key sent here is ignored without an error.

Step 3: create a Metabase API key

Scripts and CI jobs should use an API key, because a session lasts 14 days by default and an API key stays valid until you delete or regenerate it. Create the key in the Administrators group so it can do everything an admin can, and store it in your secret manager right away, because Metabase shows the key only once.

# On a later run, set METABASE_API_KEY from your secret manager to skip this step.
if [ -z "${METABASE_API_KEY:-}" ]; then
  # Look for a "Provisioning" key from an earlier run.
  KEY_ID=$(curl -s "$METABASE_URL/api/api-key" -H "X-Metabase-Session: $SESSION_ID" |
    jq '.[] | select(.name == "Provisioning") | .id')

  if [ -n "$KEY_ID" ]; then
    METABASE_API_KEY=$(curl -s -X PUT "$METABASE_URL/api/api-key/$KEY_ID/regenerate" \
      -H "X-Metabase-Session: $SESSION_ID" | jq -r '.unmasked_key')
  else
    ADMIN_GROUP_ID=$(curl -s "$METABASE_URL/api/permissions/group" \
      -H "X-Metabase-Session: $SESSION_ID" |
      jq '.[] | select(.magic_group_type == "admin") | .id')

    METABASE_API_KEY=$(jq -n --argjson group "$ADMIN_GROUP_ID" '{name: "Provisioning", group_id: $group}' |
      curl -s -X POST "$METABASE_URL/api/api-key" \
        -H "X-Metabase-Session: $SESSION_ID" -H "Content-Type: application/json" -d @- |
      jq -r '.unmasked_key')
  fi
fi

POST /api/api-key takes name and group_id, and it returns the key in unmasked_key. Every later call sends the key in the X-API-Key header. A second POST /api/api-key with the same name returns a 400, so on a second run the script finds the key with GET /api/api-key and calls PUT /api/api-key/:id/regenerate instead. That call replaces the old key with a new one and returns it in unmasked_key, so save the new key in your secret manager as well. POST /api/session, which step 2 uses on a second run, takes username and password and returns the same kind of {"id": "..."} session.

Step 4: add a database with POST /api/database

POST /api/database needs name, engine and details, where details holds the connection fields of that engine. For PostgreSQL the engine is postgres and the fields are host, port, dbname, user and password, and you can add "ssl": true to require TLS.

DATABASE_ID=$(jq -n --arg password "$WAREHOUSE_PASSWORD" '{
    name: "Warehouse",
    engine: "postgres",
    details: {host: "warehouse.internal", port: 5432, dbname: "analytics",
              user: "metabase_reader", password: $password}
  }' |
  curl -s -X POST "$METABASE_URL/api/database" \
    -H "X-API-Key: $METABASE_API_KEY" -H "Content-Type: application/json" -d @- |
  jq '.id')

Metabase tests the connection before it saves the database, and a failed test returns a 400 with the driver’s error message. If you don’t want the Sample Database next to yours, start Metabase with MB_LOAD_SAMPLE_CONTENT=false. To find the engine key and the fields of any other database, read the engines map from GET /api/session/properties, for example with jq '.engines | keys'. Each engine lists its connection fields under details-fields.

Step 5: POST /api/card with a native query in dataset_query

In the API, a saved question is a card. POST /api/card needs name, dataset_query, display and visualization_settings, and it also takes type, description and collection_id. A native query card puts the database ID, the type native and the SQL into dataset_query.

SQL="SELECT date_trunc('day', created_at) AS day, count(*) AS orders FROM orders GROUP BY 1 ORDER BY 1"

CARD_ID=$(jq -n --argjson db "$DATABASE_ID" --arg sql "$SQL" '{
    name: "Orders per day",
    type: "question",
    display: "line",
    visualization_settings: {},
    dataset_query: {database: $db, type: "native", native: {query: $sql}}
  }' |
  curl -s -X POST "$METABASE_URL/api/card" \
    -H "X-API-Key: $METABASE_API_KEY" -H "Content-Type: application/json" -d @- |
  jq '.id')

The OpenAPI spec describes dataset_query in the newer MBQL 5 format, and Metabase converts this classic shape into MBQL 5 when the request comes in. Since Metabase 57, responses return MBQL 5 too, and the API changelog asks you to treat that object as opaque. To read the classic shape back, call GET /api/card/:id?legacy-mbql=true. A native query in MBQL 5 looks like this: {"lib/type": "mbql/query", "database": 2, "stages": [{"lib/type": "mbql.stage/native", "native": "SELECT 1"}]}.

Step 6: create a dashboard and PUT the dashcards

Create the dashboard with POST /api/dashboard, which needs only a name. Then place cards on it with PUT /api/dashboard/:id and a dashcards list. A dashcard with a negative id is new, so Metabase creates it and gives it a real ID.

DASHBOARD_ID=$(jq -n '{name: "Sales overview", description: "Created from code"}' |
  curl -s -X POST "$METABASE_URL/api/dashboard" \
    -H "X-API-Key: $METABASE_API_KEY" -H "Content-Type: application/json" -d @- |
  jq '.id')

jq -n --argjson card "$CARD_ID" '{
    dashcards: [{id: -1, card_id: $card, row: 0, col: 0, size_x: 12, size_y: 6}]
  }' |
  curl -s -X PUT "$METABASE_URL/api/dashboard/$DASHBOARD_ID" \
    -H "X-API-Key: $METABASE_API_KEY" -H "Content-Type: application/json" -d @- |
  jq '.dashcards[] | {id, card_id, row, col, size_x, size_y}'

Each dashcard needs id, row, col, size_x and size_y, and the dashboard grid is 24 columns wide. The dashcards list is the full set of cards on the dashboard, so send the existing dashcards with their real IDs whenever you add one, or Metabase removes the ones you left out. For tabs, send tabs: [{id: -1, name: "Overview"}] and set dashboard_tab_id: -1 on each dashcard. The older PUT /api/dashboard/:id/cards endpoint is deprecated in favor of this call.

For a single card there is a shorter path. If you send dashboard_id with POST /api/card, Metabase saves the question inside that dashboard and places it on the grid for you, and an optional size object with size_x and size_y sets its size.

Step 7: create a dashboard subscription with the pulse API

Dashboard subscriptions send a dashboard by email or Slack, so set up email or Slack first. For email, set MB_EMAIL_SMTP_HOST, MB_EMAIL_SMTP_PORT, MB_EMAIL_SMTP_SECURITY, MB_EMAIL_SMTP_USERNAME, MB_EMAIL_SMTP_PASSWORD and MB_EMAIL_FROM_ADDRESS on the Metabase server, or send the SMTP settings to PUT /api/email. Set MB_SITE_URL too, because Metabase builds the links in emails from it. When it isn’t set, Metabase saves the address from the first request an admin makes, which in this script is http://localhost:3000.

In Metabase 63 and 64, you create a dashboard subscription with POST /api/pulse, the same endpoint the Metabase app calls. The body needs name, cards and channels, plus the dashboard_id. List each question card of the dashboard in cards. A card reference needs id, include_csv and include_xls, and takes an optional dashboard_card_id, which the script fills in from each dashcard’s id. Set include_csv or include_xls to true to attach the results as files.

CARDS=$(curl -s "$METABASE_URL/api/dashboard/$DASHBOARD_ID" -H "X-API-Key: $METABASE_API_KEY" |
  jq '[.dashcards[] | select(.card_id != null and .action_id == null)
       | {id: .card_id, dashboard_card_id: .id, include_csv: false, include_xls: false}]')

jq -n --argjson dash "$DASHBOARD_ID" --argjson cards "$CARDS" '{
    name: "Sales overview",
    dashboard_id: $dash,
    cards: $cards,
    channels: [{
      channel_type: "email",
      enabled: true,
      recipients: [{email: "team@example.com"}],
      schedule_type: "weekly",
      schedule_day: "mon",
      schedule_hour: 8
    }],
    skip_if_empty: false
  }' |
  curl -s -X POST "$METABASE_URL/api/pulse" \
    -H "X-API-Key: $METABASE_API_KEY" -H "Content-Type: application/json" -d @- |
  jq '{id, name, dashboard_id}'

Send enabled: true on each channel, because Metabase expects a true or false value there. The schedule_type is hourly, daily, weekly or monthly, schedule_hour runs from 0 to 23, and a weekly schedule also needs a schedule_day from sun to sat. A monthly schedule takes a schedule_frame of first, mid or last. A recipient is either a Metabase user, such as {"id": 5}, or an email address, and a Slack channel uses channel_type: "slack" with the channel name in details.channel. To list the subscriptions of one dashboard, call GET /api/pulse?dashboard_id=ID.

Recipients see the data that the subscription’s creator can see. Here the creator is the API key in the Administrators group, so they get admin-level results. To show them less, create the subscription with an API key in a group that has only the access they need.

Alerts on a single question have used a different endpoint, /api/notification, since Metabase 54. The 0.52.0 API changelog says that APIs under /api/pulse and /api/alert will be removed in a future version. Setting different filter values for each subscription is part of the Pro and Enterprise plans.

Keep Metabase content in a repo as YAML

The calls above create the parts that belong to one Metabase: the first admin, the database connection, the API key and the subscriptions. To keep questions and dashboards in version control, use serialization, which is part of the Pro and Enterprise plans, both self-hosted and on Metabase Cloud. Serialization exports collections, dashboards, questions, models and metrics as YAML files, and it leaves out users, groups, permissions, alerts, subscriptions and database connection strings.

# Export a .tgz of YAML files (Pro and Enterprise).
curl -s -X POST "$METABASE_URL/api/ee/serialization/export?settings=false&data_model=false" \
  -H "X-API-Key: $METABASE_API_KEY" -o metabase_export.tgz

# Import it into another Metabase.
curl -s -X POST "$TARGET_METABASE_URL/api/ee/serialization/import" \
  -H "X-API-Key: $TARGET_METABASE_API_KEY" -F "file=@metabase_export.tgz" -o -

An import creates or overwrites items by their entity ID, and it never deletes items in the target Metabase. Three more tools fit the same workflow. Remote Sync, on Pro and Enterprise, pushes content from a development Metabase to a Git repo and pulls it into a read-only production Metabase. The config.yml file, on self-hosted Pro and Enterprise, creates users, databases, settings and API keys when Metabase starts. The Metabase CLI, mb from the npm package @metabase/cli, drives this same API from a terminal or a coding agent, and it works with Metabase 58 and later.

Next steps

# Set up Metabase from code with the API

To set up Metabase from code, read the setup token from `GET /api/session/properties` and create the first admin with `POST /api/setup`. Then add a database, a native question, a dashboard and a dashboard subscription with the Metabase API, which works on every edition, including the free open-source edition. Serialization, which exports your questions and dashboards as YAML files you can keep in a repo, is part of the Pro and Enterprise plans.

Each endpoint, method and field below was checked against the Metabase API reference and the source code of Metabase v0.63.19 and v0.64.1. Every call works the same way in both versions. The examples use `curl` and `jq`, and each step reuses the IDs from the step before, so you can run them in order as one script. Steps 1 to 3 check what already exists, so the script doesn't fail when it runs a second time. Steps 4 to 7 still add a new copy of each item on every run.

## Where is the Metabase API reference?

The Metabase API reference lives at [https://www.metabase.com/docs/latest/api](/docs/latest/api), and its OpenAPI 3.1 spec is a plain JSON file at [https://www.metabase.com/docs/latest/api.json](/docs/latest/api.json). Your own Metabase serves the same reference for its exact version at `/api/docs/`, and the spec at `/api/docs/openapi.json`. The API isn't versioned, so Metabase lists breaking changes in the API changelog at [https://www.metabase.com/docs/latest/developers-guide/api-changelog](/docs/latest/developers-guide/api-changelog).

If you read the source on GitHub, open it at a release tag such as `v0.64.1`. In that release, as in v0.63.19, `POST /api/setup` lives in `src/metabase/setup_rest/api.clj`, cards in `src/metabase/queries_rest/api/card.clj`, and dashboards in `src/metabase/dashboards_rest/api.clj`. Databases are in `src/metabase/warehouses_rest/api.clj`, API keys in `src/metabase/api_keys/api.clj`, and subscriptions in `src/metabase/pulse/api/pulse.clj`.

## Step 1: read the setup token

Metabase creates a setup token the first time it starts, and that token lets you create the first user. Read it from `GET /api/session/properties` under the key `setup-token`, once `GET /api/health` answers `{"status":"ok"}`. Current releases, such as v0.63.19 and v0.64.1, don't read the token from an environment variable, so the API is the place to get it.

```bash
export METABASE_URL="http://localhost:3000"

# /api/health returns 503 while Metabase starts, then 200 with {"status":"ok"}.
until curl -sf "$METABASE_URL/api/health" > /dev/null; do sleep 5; done

curl -s "$METABASE_URL/api/session/properties" |
  jq '{token: ."setup-token", users_exist: ."has-user-setup", version: .version.tag}'

SETUP_TOKEN=$(curl -s "$METABASE_URL/api/session/properties" | jq -r '."setup-token"')
USERS_EXIST=$(curl -s "$METABASE_URL/api/session/properties" | jq -r '."has-user-setup"')
```

Once the first user exists, Metabase clears the token, so `setup-token` reads `null`, and `has-user-setup` turns `true`. Check that flag first if your script can run twice, because a second `POST /api/setup` fails with a 400.

## Step 2: POST /api/setup with the token, the user and prefs

The body of `POST /api/setup` has three keys: `token`, `user` and `prefs`. The `user` object needs `email` and `password`, and `first_name` and `last_name` are optional. The `prefs` object needs `site_name` and takes an optional `site_locale` such as `en`.

```bash
if [ "$USERS_EXIST" = "true" ]; then
  # Metabase is already set up, so log in as the first admin instead.
  SESSION_ID=$(jq -n --arg password "$METABASE_ADMIN_PASSWORD" \
      '{username: "admin@example.com", password: $password}' |
    curl -s -X POST "$METABASE_URL/api/session" -H "Content-Type: application/json" -d @- |
    jq -r '.id')
else
  SESSION_ID=$(jq -n \
    --arg token "$SETUP_TOKEN" \
    --arg password "$METABASE_ADMIN_PASSWORD" \
    '{
       token: $token,
       user: {first_name: "Ada", last_name: "Admin", email: "admin@example.com", password: $password},
       prefs: {site_name: "Acme analytics"}
     }' |
    curl -s -X POST "$METABASE_URL/api/setup" -H "Content-Type: application/json" -d @- |
    jq -r '.id')
fi
```

The first user becomes an admin, and the response is a session such as `{"id": "..."}` that you send in the `X-Metabase-Session` header. On a second run, `has-user-setup` is already `true`, so the script logs in as that admin with `POST /api/session` instead. The password must pass the password rule, which by default asks for at least six characters and one digit, and Metabase also turns down common passwords. The setup endpoint doesn't take a database, so you add one in step 4 with `POST /api/database`, which is the same call the setup screen makes. Metabase drops any key an endpoint doesn't declare, so a `database` key sent here is ignored without an error.

## Step 3: create a Metabase API key

Scripts and CI jobs should use an API key, because a session lasts 14 days by default and an API key stays valid until you delete or regenerate it. Create the key in the Administrators group so it can do everything an admin can, and store it in your secret manager right away, because Metabase shows the key only once.

```bash
# On a later run, set METABASE_API_KEY from your secret manager to skip this step.
if [ -z "${METABASE_API_KEY:-}" ]; then
  # Look for a "Provisioning" key from an earlier run.
  KEY_ID=$(curl -s "$METABASE_URL/api/api-key" -H "X-Metabase-Session: $SESSION_ID" |
    jq '.[] | select(.name == "Provisioning") | .id')

  if [ -n "$KEY_ID" ]; then
    METABASE_API_KEY=$(curl -s -X PUT "$METABASE_URL/api/api-key/$KEY_ID/regenerate" \
      -H "X-Metabase-Session: $SESSION_ID" | jq -r '.unmasked_key')
  else
    ADMIN_GROUP_ID=$(curl -s "$METABASE_URL/api/permissions/group" \
      -H "X-Metabase-Session: $SESSION_ID" |
      jq '.[] | select(.magic_group_type == "admin") | .id')

    METABASE_API_KEY=$(jq -n --argjson group "$ADMIN_GROUP_ID" '{name: "Provisioning", group_id: $group}' |
      curl -s -X POST "$METABASE_URL/api/api-key" \
        -H "X-Metabase-Session: $SESSION_ID" -H "Content-Type: application/json" -d @- |
      jq -r '.unmasked_key')
  fi
fi
```

`POST /api/api-key` takes `name` and `group_id`, and it returns the key in `unmasked_key`. Every later call sends the key in the `X-API-Key` header. A second `POST /api/api-key` with the same name returns a 400, so on a second run the script finds the key with `GET /api/api-key` and calls `PUT /api/api-key/:id/regenerate` instead. That call replaces the old key with a new one and returns it in `unmasked_key`, so save the new key in your secret manager as well. `POST /api/session`, which step 2 uses on a second run, takes `username` and `password` and returns the same kind of `{"id": "..."}` session.

## Step 4: add a database with POST /api/database

`POST /api/database` needs `name`, `engine` and `details`, where `details` holds the connection fields of that engine. For PostgreSQL the engine is `postgres` and the fields are `host`, `port`, `dbname`, `user` and `password`, and you can add `"ssl": true` to require TLS.

```bash
DATABASE_ID=$(jq -n --arg password "$WAREHOUSE_PASSWORD" '{
    name: "Warehouse",
    engine: "postgres",
    details: {host: "warehouse.internal", port: 5432, dbname: "analytics",
              user: "metabase_reader", password: $password}
  }' |
  curl -s -X POST "$METABASE_URL/api/database" \
    -H "X-API-Key: $METABASE_API_KEY" -H "Content-Type: application/json" -d @- |
  jq '.id')
```

Metabase tests the connection before it saves the database, and a failed test returns a 400 with the driver's error message. If you don't want the Sample Database next to yours, start Metabase with `MB_LOAD_SAMPLE_CONTENT=false`. To find the engine key and the fields of any other database, read the `engines` map from `GET /api/session/properties`, for example with `jq '.engines | keys'`. Each engine lists its connection fields under `details-fields`.

## Step 5: POST /api/card with a native query in dataset_query

In the API, a saved question is a card. `POST /api/card` needs `name`, `dataset_query`, `display` and `visualization_settings`, and it also takes `type`, `description` and `collection_id`. A native query card puts the database ID, the type `native` and the SQL into `dataset_query`.

```bash
SQL="SELECT date_trunc('day', created_at) AS day, count(*) AS orders FROM orders GROUP BY 1 ORDER BY 1"

CARD_ID=$(jq -n --argjson db "$DATABASE_ID" --arg sql "$SQL" '{
    name: "Orders per day",
    type: "question",
    display: "line",
    visualization_settings: {},
    dataset_query: {database: $db, type: "native", native: {query: $sql}}
  }' |
  curl -s -X POST "$METABASE_URL/api/card" \
    -H "X-API-Key: $METABASE_API_KEY" -H "Content-Type: application/json" -d @- |
  jq '.id')
```

The OpenAPI spec describes `dataset_query` in the newer MBQL 5 format, and Metabase converts this classic shape into MBQL 5 when the request comes in. Since Metabase 57, responses return MBQL 5 too, and the API changelog asks you to treat that object as opaque. To read the classic shape back, call `GET /api/card/:id?legacy-mbql=true`. A native query in MBQL 5 looks like this: `{"lib/type": "mbql/query", "database": 2, "stages": [{"lib/type": "mbql.stage/native", "native": "SELECT 1"}]}`.

## Step 6: create a dashboard and PUT the dashcards

Create the dashboard with `POST /api/dashboard`, which needs only a `name`. Then place cards on it with `PUT /api/dashboard/:id` and a `dashcards` list. A dashcard with a negative `id` is new, so Metabase creates it and gives it a real ID.

```bash
DASHBOARD_ID=$(jq -n '{name: "Sales overview", description: "Created from code"}' |
  curl -s -X POST "$METABASE_URL/api/dashboard" \
    -H "X-API-Key: $METABASE_API_KEY" -H "Content-Type: application/json" -d @- |
  jq '.id')

jq -n --argjson card "$CARD_ID" '{
    dashcards: [{id: -1, card_id: $card, row: 0, col: 0, size_x: 12, size_y: 6}]
  }' |
  curl -s -X PUT "$METABASE_URL/api/dashboard/$DASHBOARD_ID" \
    -H "X-API-Key: $METABASE_API_KEY" -H "Content-Type: application/json" -d @- |
  jq '.dashcards[] | {id, card_id, row, col, size_x, size_y}'
```

Each dashcard needs `id`, `row`, `col`, `size_x` and `size_y`, and the dashboard grid is 24 columns wide. The `dashcards` list is the full set of cards on the dashboard, so send the existing dashcards with their real IDs whenever you add one, or Metabase removes the ones you left out. For tabs, send `tabs: [{id: -1, name: "Overview"}]` and set `dashboard_tab_id: -1` on each dashcard. The older `PUT /api/dashboard/:id/cards` endpoint is deprecated in favor of this call.

For a single card there is a shorter path. If you send `dashboard_id` with `POST /api/card`, Metabase saves the question inside that dashboard and places it on the grid for you, and an optional `size` object with `size_x` and `size_y` sets its size.

## Step 7: create a dashboard subscription with the pulse API

Dashboard subscriptions send a dashboard by email or Slack, so set up email or Slack first. For email, set `MB_EMAIL_SMTP_HOST`, `MB_EMAIL_SMTP_PORT`, `MB_EMAIL_SMTP_SECURITY`, `MB_EMAIL_SMTP_USERNAME`, `MB_EMAIL_SMTP_PASSWORD` and `MB_EMAIL_FROM_ADDRESS` on the Metabase server, or send the SMTP settings to `PUT /api/email`. Set `MB_SITE_URL` too, because Metabase builds the links in emails from it. When it isn't set, Metabase saves the address from the first request an admin makes, which in this script is `http://localhost:3000`.

In Metabase 63 and 64, you create a dashboard subscription with `POST /api/pulse`, the same endpoint the Metabase app calls. The body needs `name`, `cards` and `channels`, plus the `dashboard_id`. List each question card of the dashboard in `cards`. A card reference needs `id`, `include_csv` and `include_xls`, and takes an optional `dashboard_card_id`, which the script fills in from each dashcard's `id`. Set `include_csv` or `include_xls` to `true` to attach the results as files.

```bash
CARDS=$(curl -s "$METABASE_URL/api/dashboard/$DASHBOARD_ID" -H "X-API-Key: $METABASE_API_KEY" |
  jq '[.dashcards[] | select(.card_id != null and .action_id == null)
       | {id: .card_id, dashboard_card_id: .id, include_csv: false, include_xls: false}]')

jq -n --argjson dash "$DASHBOARD_ID" --argjson cards "$CARDS" '{
    name: "Sales overview",
    dashboard_id: $dash,
    cards: $cards,
    channels: [{
      channel_type: "email",
      enabled: true,
      recipients: [{email: "team@example.com"}],
      schedule_type: "weekly",
      schedule_day: "mon",
      schedule_hour: 8
    }],
    skip_if_empty: false
  }' |
  curl -s -X POST "$METABASE_URL/api/pulse" \
    -H "X-API-Key: $METABASE_API_KEY" -H "Content-Type: application/json" -d @- |
  jq '{id, name, dashboard_id}'
```

Send `enabled: true` on each channel, because Metabase expects a true or false value there. The `schedule_type` is `hourly`, `daily`, `weekly` or `monthly`, `schedule_hour` runs from 0 to 23, and a weekly schedule also needs a `schedule_day` from `sun` to `sat`. A monthly schedule takes a `schedule_frame` of `first`, `mid` or `last`. A recipient is either a Metabase user, such as `{"id": 5}`, or an email address, and a Slack channel uses `channel_type: "slack"` with the channel name in `details.channel`. To list the subscriptions of one dashboard, call `GET /api/pulse?dashboard_id=ID`.

Recipients see the data that the subscription's creator can see. Here the creator is the API key in the Administrators group, so they get admin-level results. To show them less, create the subscription with an API key in a group that has only the access they need.

Alerts on a single question have used a different endpoint, `/api/notification`, since Metabase 54. The 0.52.0 API changelog says that APIs under `/api/pulse` and `/api/alert` will be removed in a future version. Setting different filter values for each subscription is part of the Pro and Enterprise plans.

## Keep Metabase content in a repo as YAML

The calls above create the parts that belong to one Metabase: the first admin, the database connection, the API key and the subscriptions. To keep questions and dashboards in version control, use serialization, which is part of the Pro and Enterprise plans, both self-hosted and on Metabase Cloud. Serialization exports collections, dashboards, questions, models and metrics as YAML files, and it leaves out users, groups, permissions, alerts, subscriptions and database connection strings.

```bash
# Export a .tgz of YAML files (Pro and Enterprise).
curl -s -X POST "$METABASE_URL/api/ee/serialization/export?settings=false&data_model=false" \
  -H "X-API-Key: $METABASE_API_KEY" -o metabase_export.tgz

# Import it into another Metabase.
curl -s -X POST "$TARGET_METABASE_URL/api/ee/serialization/import" \
  -H "X-API-Key: $TARGET_METABASE_API_KEY" -F "file=@metabase_export.tgz" -o -
```

An import creates or overwrites items by their entity ID, and it never deletes items in the target Metabase. Three more tools fit the same workflow. Remote Sync, on Pro and Enterprise, pushes content from a development Metabase to a Git repo and pulls it into a read-only production Metabase. The `config.yml` file, on self-hosted Pro and Enterprise, creates users, databases, settings and API keys when Metabase starts. The Metabase CLI, `mb` from the npm package `@metabase/cli`, drives this same API from a terminal or a coding agent, and it works with Metabase 58 and later.

## Next steps

- [Metabase API reference](/docs/latest/api)
- [API keys](/docs/latest/people-and-groups/api-keys)
- [Dashboard subscriptions](/docs/latest/dashboards/subscriptions)
- [Serialization](/docs/latest/installation-and-operation/serialization)
- [API changelog](/docs/latest/developers-guide/api-changelog)
- [Running Metabase on Docker](/docs/latest/installation-and-operation/running-metabase-on-docker)