> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kameleoon.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Exporting experiment results to Airtable

> Retrieve an experiment and its results with the Automation API, determine the best-performing variation from the Bayesian success probability, and upsert the record into an Airtable table with a single Python script.

## Goal

This tutorial describes how the [kameleoon\_to\_airtable.py](https://storage.googleapis.com/kameleoon-storage-documentation/developers/scripts/kameleoon_to_airtable.py) script works, step by step. Given a Kameleoon **experiment ID**, an Airtable **base ID**, and an Airtable **table ID**, the script retrieves the experiment metadata and statistical results, determines the best-performing variation, maps the data onto the Airtable *Experiments* schema, and writes the record back to Airtable. Re-running the script for the same experiment updates the existing row instead of creating a duplicate.

It follows the previous tutorial on [retrieving experiment results using the Automation API](./retrieving-experiment-results-using-the-automation-api) and reuses the same request and poll flow.

## Requirements

* **Kameleoon API credentials.** The Automation API requires an access token. The script obtains one programmatically from a `client_id` and `client_secret` using the `client_credentials` grant. See [Obtain an access token](/developer-docs/apis/automation-api-rest/get-started/get-started#1-obtain-an-access-token).

* **An Airtable personal access token** with the `data.records:write` scope on the target base.

* **The Airtable base ID and table ID.** Both appear in the table's URL, or in the base's API documentation. The base ID starts with `app`, the table ID with `tbl`.

* **An Airtable table with the *Experiments* schema already built.** The script writes to these fields: `Experiment Name`, `Status`, `Start date`, `End date`, `Notes`, `Actual`, `Probability`, and `Result`. It also expects the manual-entry fields `Assignee`, `Category`, `Prediction`, `Mkt Est`, `Eng Est`, and `Attachments` to exist, even though it never sets them. Airtable rejects a write to a field name that doesn't already exist in the table, and `typecast` only coerces value types for existing fields (it doesn't create missing fields or select options). Create the table with these fields, and matching `Status` select options, before you run the script. See [step 6](#6-map-the-data-to-airtable-fields) for the value each field receives.

* **Python 3.9+** with the `requests` library (`pip install requests`).

<Warning>
  Store all credentials in environment variables. Never hard-code secrets in the script.
</Warning>

```bash theme={null}
export KAMELEOON_CLIENT_ID="..."
export KAMELEOON_CLIENT_SECRET="..."
export AIRTABLE_TOKEN="..."
```

The tutorial uses the example experiment **Product Page Redesign** (ID `188308`), with two variations in addition to the original: *Redesign 1* (ID `828220`) and *Redesign 2* (ID `828221`).

## 1. Authenticate with the Automation API

**Endpoint:** Obtain an access token by sending a POST request to the token endpoint.

```
POST https://api.kameleoon.com/oauth/token
```

| Name           | Type   | Description                        |
| -------------- | ------ | ---------------------------------- |
| grant\_type    | String | Set to `client_credentials`.       |
| client\_id     | String | Your Automation API client ID.     |
| client\_secret | String | Your Automation API client secret. |

**Example:**

```python theme={null}
def kameleoon_token(client_id, client_secret):
    resp = requests.post(
        "https://api.kameleoon.com/oauth/token",
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        data={
            "grant_type": "client_credentials",
            "client_id": client_id,
            "client_secret": client_secret,
        },
    )
    resp.raise_for_status()
    return resp.json()["access_token"]
```

**Response:**

```json theme={null}
{ "access_token": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9..." }
```

Send the returned `access_token` as a `Bearer` token on every subsequent Automation API request. Access tokens remain valid for 2 hours by default.

## 2. Retrieve the experiment

**Endpoint:** Fetch the experiment metadata by sending a GET request to the [Get an experiment](/api-reference/experiment/get-an-experiment) endpoint.

```
GET https://api.kameleoon.com/experiments/{experimentId}
```

| Name         | Type    | Description                                                     |
| ------------ | ------- | --------------------------------------------------------------- |
| experimentId | Integer | Mandatory path parameter. The ID of the experiment to retrieve. |

**Example:**

```python theme={null}
def get_experiment(token, experiment_id):
    resp = requests.get(
        f"https://api.kameleoon.com/experiments/{experiment_id}",
        headers={"Authorization": f"Bearer {token}"},
    )
    resp.raise_for_status()
    return resp.json()
```

**Response (truncated):**

```json theme={null}
{
  "id": 188308,
  "name": "Product Page Redesign",
  "status": "STOPPED",
  "dateStarted": "2025-01-15T09:00:00Z",
  "dateEnded": "2025-02-12T18:00:00Z",
  "description": "Testing two redesigns of the product page.",
  "mainGoalId": 279599,
  "variations": [828220, 828221]
}
```

The script reads `name`, `status`, `dateStarted`, `dateEnded`, and `description` for the Airtable record, and `mainGoalId` to scope the results request in the next step.

<Note>
  The API returns `mainGoalId` by default, so you don't need an `optionalFields` parameter to read it. The Automation API does not publish a fixed enum for the `status` field, but other status-type fields across the API consistently use uppercase tokens (for example, `STOPPED`, `ACTIVE`, `DRAFT`). [Step 6](#6-map-the-data-to-airtable-fields) maps the `status` field on that assumption. Confirm the exact tokens your account returns with a live request before you rely on the mapping.
</Note>

## 3. Request the experiment's results

**Endpoint:** Trigger the generation of the results report by sending a POST request to the [Request experiment's results](/api-reference/experiment/request-experiments-results) endpoint.

```
POST https://api.kameleoon.com/experiments/{experimentId}/results
```

| Name                 | Type    | Description                                                                                                                               |
| -------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| experimentId         | Integer | Mandatory path parameter.                                                                                                                 |
| goalsIds             | Array   | Restricts the report to the given goal IDs. The script passes the experiment's `mainGoalId`.                                              |
| referenceVariationId | String  | The variation used as the reference for comparison. `"0"` uses the original page.                                                         |
| visitorData          | Boolean | `false` for visit-based data, `true` for visitor-based data.                                                                              |
| sequentialTesting    | Boolean | Set to `true` to use sequential testing for confidence intervals.                                                                         |
| bayesian             | Boolean | Set to `true` to include the Bayesian success probability in the report. The script reads this value to populate the *Probability* field. |
| conversionType       | String  | `ALL_CONVERSION` or `CONVERTED_VISITS`.                                                                                                   |

**Example:**

```python theme={null}
def request_results(token, experiment_id, goal_id):
    body = {
        "visitorData": False,
        "sequentialTesting": True,
        "bayesian": True,
        "referenceVariationId": "0",
        "conversionType": "ALL_CONVERSION",
        "goalsIds": [goal_id] if goal_id else None,
    }
    resp = requests.post(
        f"https://api.kameleoon.com/experiments/{experiment_id}/results",
        headers={
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/json",
            "Accept": "*/*",
        },
        json=body,
    )
    resp.raise_for_status()
    return resp.json()["dataCode"]
```

**Response:**

```json theme={null}
{ "dataCode": "14443931880924098266207585267983330260134079899081739889989435434342588016765" }
```

Kameleoon generates the report asynchronously. The endpoint returns a `dataCode`, which [step 4](#4-poll-for-the-results) uses to poll for the result.

<Note>
  This script requires `bayesian: true`. With Bayesian enabled, the report's `reliability` value carries the **Bayesian success probability** (the probability that a variation beats the reference), which the script maps to the *Probability* field. Confirm the value against the same report in the Kameleoon app if your account uses a different default statistical method. The Automation API's spec marks `dateIntervals` as required, but the example above omits it and still returns a valid report. The spec doesn't document what an omitted `dateIntervals` defaults to; this tutorial assumes it covers the full run of the experiment, so confirm that behavior against your own account before relying on it. Pass a `dateIntervals` array to bound the report to a specific period instead.
</Note>

## 4. Poll for the results

**Endpoint:** Retrieve the report by sending GET requests to the [Poll results](/api-reference/data/poll-results) endpoint until it is ready.

```
GET https://api.kameleoon.com/results?dataCode={dataCode}
```

| Name     | Type   | Description                                                                                                 |
| -------- | ------ | ----------------------------------------------------------------------------------------------------------- |
| dataCode | String | Mandatory query parameter. Use the hash that `POST /experiments/{experimentId}/results` returned in step 3. |

The response `status` is `WAITING` while Kameleoon computes the report, `READY` when the data is available, or `ERROR` / `TIMEOUT` on failure. When the status is `ERROR` or `TIMEOUT`, the response includes a top-level `errorDescription`. The script polls on a fixed interval until the status is `READY`.

**Example:**

```python theme={null}
def poll_results(token, data_code, max_attempts=30, delay=2.0):
    for _ in range(max_attempts):
        resp = requests.get(
            "https://api.kameleoon.com/results",
            headers={"Authorization": f"Bearer {token}"},
            params={"dataCode": data_code},
        )
        resp.raise_for_status()
        payload = resp.json()
        status = payload.get("status")
        if status == "READY":
            return payload["data"]
        if status in ("ERROR", "TIMEOUT"):
            raise RuntimeError(payload.get("errorDescription") or status)
        time.sleep(delay)  # status == "WAITING"
    raise TimeoutError("Timed out waiting for results.")
```

**Response (truncated):**

```json theme={null}
{
  "status": "READY",
  "data": {
    "variationData": {
      "_reference": { "breakdownData": { "_reference": { "generalData": {
        "goalsData": { "279599": { "conversionRate": 0.0149 } } } } } },
      "828220": { "breakdownData": { "_reference": { "generalData": {
        "goalsData": { "279599": {
          "reliability": 100.0,
          "improvementRate": 211.48,
          "conversionRate": 0.0466
        } } } } } },
      "828221": { "breakdownData": { "_reference": { "generalData": {
        "goalsData": { "279599": {
          "reliability": 100.0,
          "improvementRate": -43.33,
          "conversionRate": 0.0085
        } } } } } }
    }
  }
}
```

## 5. Select the best-performing variation

The results contain one entry per variation under `variationData`, plus the `_reference` line for the original page. For each variation, the metrics for the requested goal sit under `breakdownData._reference.generalData.goalsData[goalId]`.

The script skips the `_reference` entry, reads each variation's `improvementRate` and `reliability` (the Bayesian success probability), and selects the variation with the highest improvement rate as the best performer. The *Result* field mapped in the next step records whether that variation actually won: whether it reached a high enough success probability with a positive uplift.

**Example:**

```python theme={null}
def pick_best_variation(result_data, goal_id):
    best, best_improvement = {}, float("-inf")
    for variation_id, vdata in result_data["variationData"].items():
        if variation_id == "_reference":
            continue
        general = vdata["breakdownData"]["_reference"]["generalData"]
        goals_data = general.get("goalsData", {})
        if not goals_data:
            continue
        key = str(goal_id) if str(goal_id) in goals_data else next(iter(goals_data))
        metrics = goals_data[key]
        improvement = metrics.get("improvementRate")
        if improvement is not None and improvement > best_improvement:
            best_improvement = improvement
            best = {
                "variation_id": variation_id,
                "improvement_rate": improvement,
                "bayesian_probability": metrics.get("reliability"),
            }
    return best
```

In the example, both variations reach a 100% Bayesian success probability, but *Redesign 1* (`828220`) shows a +211.48% improvement against *Redesign 2*'s -43.33%. *Redesign 1* is therefore the best performer and, with a probability above 95% and a positive uplift, a genuine winner.

## 6. Map the data to Airtable fields

The script transforms the experiment metadata and the best-performing variation's metrics into the Airtable *Experiments* schema.

| Airtable field  | Source                                           | Transformation                                                                                                                          |
| --------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| Experiment Name | `experiment.name`                                | Direct. Used as the upsert key.                                                                                                         |
| Status          | `experiment.status`                              | Mapped, case-insensitively: `ACTIVE → Running`, `DRAFT`/`PLANNED → Implementing`, `STOPPED`/`DIVERTED → Completed`, `PAUSED → Defunct`. |
| Start date      | `experiment.dateStarted`                         | Date-time truncated to an ISO date (`YYYY-MM-DD`).                                                                                      |
| End date        | `experiment.dateEnded`                           | Date-time truncated to an ISO date.                                                                                                     |
| Notes           | `experiment.description`                         | Direct.                                                                                                                                 |
| Actual          | best variation `improvementRate`                 | Direct (measured uplift, %).                                                                                                            |
| Probability     | best variation Bayesian success probability      | Bucketed: `≥95 → 80% - High`, `≥80 → 50% - Medium`, else `20% - Low`.                                                                   |
| Result          | Bayesian success probability + `improvementRate` | probability `≥ 95` and uplift > 0 → `Success`; `≥ 95` and uplift \< 0 → `Failure`; otherwise `Inconclusive`.                            |

The script does not set fields with no Kameleoon source: **Assignee**, **Category**, **Prediction**, **Mkt Est**, **Eng Est**, and **Attachments**. These remain available for manual entry in Airtable. The script also omits empty values, so it never overwrites an existing cell with a blank.

**Example:**

```python theme={null}
STATUS_MAP = {
    "ACTIVE": "Running",
    "DRAFT": "Implementing",
    "PLANNED": "Implementing",
    "PAUSED": "Defunct",
    "STOPPED": "Completed",
    "DIVERTED": "Completed",
}


def map_status(status):
    return STATUS_MAP.get((status or "").upper())


def to_iso_date(value):
    return value[:10] if value else None


def bayesian_to_probability(probability):
    if probability is None:
        return None
    if probability >= 95:
        return "80% - High"
    if probability >= 80:
        return "50% - Medium"
    return "20% - Low"


def derive_result(probability, improvement):
    if probability is None or improvement is None:
        return "Inconclusive"
    if probability >= 95 and improvement > 0:
        return "Success"
    if probability >= 95 and improvement < 0:
        return "Failure"
    return "Inconclusive"


def build_airtable_fields(experiment, best):
    probability = best.get("bayesian_probability")
    improvement = best.get("improvement_rate")
    fields = {
        "Experiment Name": experiment.get("name"),
        "Status": map_status(experiment.get("status")),
        "Start date": to_iso_date(experiment.get("dateStarted")),
        "End date": to_iso_date(experiment.get("dateEnded")),
        "Notes": experiment.get("description"),
        "Actual": improvement,
        "Probability": bayesian_to_probability(probability),
        "Result": derive_result(probability, improvement),
    }
    return {k: v for k, v in fields.items() if v is not None}
```

<Note>
  The Automation API does not publish a fixed enum for the experiment `status` field, and the tokens can evolve. `map_status` matches case-insensitively and returns `None` for an unrecognized status, which drops the *Status* cell rather than writing a wrong value. Confirm the tokens your account returns with a single `GET /experiments/{experimentId}` and extend `STATUS_MAP` if needed.
</Note>

## 7. Upsert the record into Airtable

<Note>
  The Airtable *Update table* endpoint (`PATCH /v0/meta/bases/{baseId}/tables/{tableId}`) only changes a table's name and description; it cannot write data into rows. To populate a record, use the **records** endpoint with the `performUpsert` option.
</Note>

**Endpoint:** Create or update the record by sending a PATCH request to the records endpoint.

```
PATCH https://api.airtable.com/v0/{baseId}/{tableId}
```

| Name                          | Type    | Description                                                                        |
| ----------------------------- | ------- | ---------------------------------------------------------------------------------- |
| performUpsert.fieldsToMergeOn | Array   | Field(s) used to match an existing record. The script merges on `Experiment Name`. |
| records                       | Array   | A list of records (max 10 per request), each with a `fields` object.               |
| typecast                      | Boolean | `true` lets Airtable coerce strings into select options and parse dates.           |

**Example:**

```python theme={null}
def upsert_record(airtable_token, base_id, table_id, fields):
    resp = requests.patch(
        f"https://api.airtable.com/v0/{base_id}/{table_id}",
        headers={
            "Authorization": f"Bearer {airtable_token}",
            "Content-Type": "application/json",
        },
        json={
            "performUpsert": {"fieldsToMergeOn": ["Experiment Name"]},
            "typecast": True,
            "records": [{"fields": fields}],
        },
    )
    resp.raise_for_status()
    return resp.json()
```

**Response (truncated):**

```json theme={null}
{
  "records": [
    {
      "id": "rec0Vh8KLmNoPqRsT",
      "fields": {
        "Experiment Name": "Product Page Redesign",
        "Status": "Completed",
        "Start date": "2025-01-15",
        "End date": "2025-02-12",
        "Actual": 211.48,
        "Probability": "80% - High",
        "Result": "Success"
      }
    }
  ],
  "createdRecords": [],
  "updatedRecords": ["rec0Vh8KLmNoPqRsT"]
}
```

The response reports the outcome per record: a returned ID under `createdRecords` means Airtable created a new row, while an ID under `updatedRecords` means Airtable updated an existing row.

## 8. Run the script

Pass the experiment ID and the Airtable base and table IDs as arguments:

```bash theme={null}
python kameleoon_to_airtable.py \
  --experiment-id 188308 \
  --base-id appXXXXXXXXXXXXXX \
  --table-id tbll9adSjdedH5t3f
```

The script prints each step: authentication, the experiment fetched, the best-performing variation, the mapped fields, and whether it created or updated the Airtable record.

## Full script

The complete script below matches [kameleoon\_to\_airtable.py](https://storage.googleapis.com/kameleoon-storage-documentation/developers/scripts/kameleoon_to_airtable.py) function for function. Copy it directly, or download the file from that link.

```python theme={null}
#!/usr/bin/env python3
"""Export a Kameleoon experiment's results to an Airtable *Experiments* table.

Given a Kameleoon experiment ID, an Airtable base ID, and an Airtable table ID,
this script:

  1. Authenticates with the Automation API (client_credentials grant).
  2. Retrieves the experiment metadata.
  3. Requests the experiment's results (with the Bayesian success probability).
  4. Polls until the report is ready.
  5. Selects the best-performing variation.
  6. Maps the data onto the Airtable *Experiments* schema.
  7. Upserts the record into Airtable, keyed on "Experiment Name".

Credentials are read from environment variables (never hard-code secrets):

    export KAMELEOON_CLIENT_ID="..."
    export KAMELEOON_CLIENT_SECRET="..."
    export AIRTABLE_TOKEN="..."

Usage:

    python kameleoon_to_airtable.py \
        --experiment-id 188308 \
        --base-id appXXXXXXXXXXXXXX \
        --table-id tbll9adSjdedH5t3f
"""

import argparse
import os
import sys
import time

import requests

# Maps the uppercase status tokens returned by the Automation API to the
# options of the Airtable *Status* field. Adjust the target values if your
# *Status* options differ, and confirm the tokens your account returns with a
# single GET /experiments/{experimentId}.
STATUS_MAP = {
    "ACTIVE": "Running",
    "DRAFT": "Implementing",
    "PLANNED": "Implementing",
    "PAUSED": "Defunct",
    "STOPPED": "Completed",
    "DIVERTED": "Completed",
}


# --------------------------------------------------------------------------- #
# 1. Authenticate with the Automation API
# --------------------------------------------------------------------------- #
def kameleoon_token(client_id, client_secret):
    resp = requests.post(
        "https://api.kameleoon.com/oauth/token",
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        data={
            "grant_type": "client_credentials",
            "client_id": client_id,
            "client_secret": client_secret,
        },
    )
    resp.raise_for_status()
    return resp.json()["access_token"]


# --------------------------------------------------------------------------- #
# 2. Retrieve the experiment
# --------------------------------------------------------------------------- #
def get_experiment(token, experiment_id):
    resp = requests.get(
        f"https://api.kameleoon.com/experiments/{experiment_id}",
        headers={"Authorization": f"Bearer {token}"},
    )
    resp.raise_for_status()
    return resp.json()


# --------------------------------------------------------------------------- #
# 3. Request the experiment's results
# --------------------------------------------------------------------------- #
def request_results(token, experiment_id, goal_id):
    body = {
        "visitorData": False,
        "sequentialTesting": True,
        "bayesian": True,
        "referenceVariationId": "0",
        "conversionType": "ALL_CONVERSION",
        "goalsIds": [goal_id] if goal_id else None,
    }
    resp = requests.post(
        f"https://api.kameleoon.com/experiments/{experiment_id}/results",
        headers={
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/json",
            "Accept": "*/*",
        },
        json=body,
    )
    resp.raise_for_status()
    return resp.json()["dataCode"]


# --------------------------------------------------------------------------- #
# 4. Poll for the results
# --------------------------------------------------------------------------- #
def poll_results(token, data_code, max_attempts=30, delay=2.0):
    for _ in range(max_attempts):
        resp = requests.get(
            "https://api.kameleoon.com/results",
            headers={"Authorization": f"Bearer {token}"},
            params={"dataCode": data_code},
        )
        resp.raise_for_status()
        payload = resp.json()
        status = payload.get("status")
        if status == "READY":
            return payload["data"]
        if status in ("ERROR", "TIMEOUT"):
            raise RuntimeError(payload.get("errorDescription") or status)
        time.sleep(delay)  # status == "WAITING"
    raise TimeoutError("Timed out waiting for results.")


# --------------------------------------------------------------------------- #
# 5. Select the best-performing variation
# --------------------------------------------------------------------------- #
def pick_best_variation(result_data, goal_id):
    best, best_improvement = {}, float("-inf")
    for variation_id, vdata in result_data["variationData"].items():
        if variation_id == "_reference":
            continue
        general = vdata["breakdownData"]["_reference"]["generalData"]
        goals_data = general.get("goalsData", {})
        if not goals_data:
            continue
        key = str(goal_id) if str(goal_id) in goals_data else next(iter(goals_data))
        metrics = goals_data[key]
        improvement = metrics.get("improvementRate")
        if improvement is not None and improvement > best_improvement:
            best_improvement = improvement
            best = {
                "variation_id": variation_id,
                "improvement_rate": improvement,
                "bayesian_probability": metrics.get("reliability"),
            }
    return best


# --------------------------------------------------------------------------- #
# 6. Map the data to Airtable fields
# --------------------------------------------------------------------------- #
def map_status(status):
    return STATUS_MAP.get((status or "").upper())


def to_iso_date(value):
    return value[:10] if value else None


def bayesian_to_probability(probability):
    if probability is None:
        return None
    if probability >= 95:
        return "80% - High"
    if probability >= 80:
        return "50% - Medium"
    return "20% - Low"


def derive_result(probability, improvement):
    if probability is None or improvement is None:
        return "Inconclusive"
    if probability >= 95 and improvement > 0:
        return "Success"
    if probability >= 95 and improvement < 0:
        return "Failure"
    return "Inconclusive"


def build_airtable_fields(experiment, best):
    probability = best.get("bayesian_probability")
    improvement = best.get("improvement_rate")
    fields = {
        "Experiment Name": experiment.get("name"),
        "Status": map_status(experiment.get("status")),
        "Start date": to_iso_date(experiment.get("dateStarted")),
        "End date": to_iso_date(experiment.get("dateEnded")),
        "Notes": experiment.get("description"),
        "Actual": improvement,
        "Probability": bayesian_to_probability(probability),
        "Result": derive_result(probability, improvement),
    }
    return {k: v for k, v in fields.items() if v is not None}


# --------------------------------------------------------------------------- #
# 7. Upsert the record into Airtable
# --------------------------------------------------------------------------- #
def upsert_record(airtable_token, base_id, table_id, fields):
    resp = requests.patch(
        f"https://api.airtable.com/v0/{base_id}/{table_id}",
        headers={
            "Authorization": f"Bearer {airtable_token}",
            "Content-Type": "application/json",
        },
        json={
            "performUpsert": {"fieldsToMergeOn": ["Experiment Name"]},
            "typecast": True,
            "records": [{"fields": fields}],
        },
    )
    resp.raise_for_status()
    return resp.json()


# --------------------------------------------------------------------------- #
# 8. Orchestration
# --------------------------------------------------------------------------- #
def run(experiment_id, base_id, table_id):
    client_id = os.environ.get("KAMELEOON_CLIENT_ID")
    client_secret = os.environ.get("KAMELEOON_CLIENT_SECRET")
    airtable_token = os.environ.get("AIRTABLE_TOKEN")

    missing = [
        name
        for name, value in (
            ("KAMELEOON_CLIENT_ID", client_id),
            ("KAMELEOON_CLIENT_SECRET", client_secret),
            ("AIRTABLE_TOKEN", airtable_token),
        )
        if not value
    ]
    if missing:
        raise SystemExit(
            "Missing required environment variable(s): " + ", ".join(missing)
        )

    # 1. Authenticate.
    token = kameleoon_token(client_id, client_secret)
    print("Authenticated with the Automation API.")

    # 2. Retrieve the experiment.
    experiment = get_experiment(token, experiment_id)
    goal_id = experiment.get("mainGoalId")
    print(
        f"Fetched experiment {experiment.get('id')}: "
        f"{experiment.get('name')!r} (status: {experiment.get('status')})."
    )

    # 3-4. Request and poll for the results.
    data_code = request_results(token, experiment_id, goal_id)
    result_data = poll_results(token, data_code)

    # 5. Select the best-performing variation.
    best = pick_best_variation(result_data, goal_id)
    if best:
        print(
            f"Best-performing variation: {best['variation_id']} "
            f"(uplift {best['improvement_rate']}%, "
            f"Bayesian probability {best.get('bayesian_probability')}%)."
        )
    else:
        print("No variation data available for the requested goal.")

    # 6. Map the data to Airtable fields.
    fields = build_airtable_fields(experiment, best)
    print("Mapped Airtable fields:")
    for key, value in fields.items():
        print(f"  {key}: {value}")

    # 7. Upsert the record.
    response = upsert_record(airtable_token, base_id, table_id, fields)
    created = response.get("createdRecords") or []
    record_id = response["records"][0]["id"] if response.get("records") else None
    action = "Created" if record_id in created else "Updated"
    print(f"{action} Airtable record {record_id}.")

    return response


def main():
    parser = argparse.ArgumentParser(
        description="Export a Kameleoon experiment's results to Airtable."
    )
    parser.add_argument(
        "--experiment-id",
        required=True,
        help="ID of the Kameleoon experiment to export.",
    )
    parser.add_argument(
        "--base-id",
        required=True,
        help="Airtable base ID (starts with 'app').",
    )
    parser.add_argument(
        "--table-id",
        required=True,
        help="Airtable table ID (starts with 'tbl') or table name.",
    )
    args = parser.parse_args()

    try:
        run(args.experiment_id, args.base_id, args.table_id)
    except requests.HTTPError as exc:
        detail = ""
        if exc.response is not None:
            detail = f" — {exc.response.status_code}: {exc.response.text}"
        print(f"HTTP error: {exc}{detail}", file=sys.stderr)
        sys.exit(1)
    except (RuntimeError, TimeoutError) as exc:
        print(f"Error: {exc}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()
```

## Customization notes

* **Status mapping** lives in the `STATUS_MAP` constant, keyed on the uppercase status tokens the API returns. Adjust the target values if your *Status* options differ from `Running` / `Implementing` / `Completed` / `Defunct`, and confirm the tokens your account returns with a live `GET /experiments/{experimentId}` request before you rely on the mapping.
* **Probability** comes from the measured Bayesian success probability, which requires `bayesian: true` on the results request. If your *Probability* field is instead a pre-experiment estimate that you enter manually, remove the `Probability` line from `build_airtable_fields`.
* **Goal selection** uses the experiment's `mainGoalId`. To report on a different goal, pass its ID to `request_results` and `pick_best_variation`.
* **Upsert key.** Airtable matches the merge field exactly, so differences in case or whitespace in `Experiment Name` create a new row instead of updating the existing one. Keep experiment names stable, or merge on a dedicated stable identifier field.
* **Actual field format.** The script writes the raw `improvementRate` value (for example, `211.48`) into *Actual*. If *Actual* is an Airtable Percent field, set it to expect a plain number rather than a fraction, or divide the value by 100 in `build_airtable_fields` to match a fraction-based Percent field.
* **Rate limits.** The Automation API allows up to 50 requests per 10 seconds and 1,000 per hour, but Kameleoon recommends staying under 12 calls per minute per account and advises against using the Automation API for high-frequency tracking. If you batch many experiments, cache tokens, throttle requests, and consider the [Data API](/developer-docs/apis/data-api-rest/overview) for high-volume needs.
