> ## 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 Notion

> 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 a Notion database with a single Python script.

Retrieve an experiment and its results with the Automation API, transform them into Notion page properties, and upsert the record into a Notion database using a single Python script.

## Goal

This tutorial describes how the [kameleoon\_to\_notion.py](/assets/developer-docs/script/apis/automation-api-rest/tutorials/kameleoon_to_notion.py.zip) script works, step by step. Given a Kameleoon **experiment ID** and a Notion **database ID**, the script retrieves the experiment metadata and statistical results, determines the best-performing variation, maps the data onto a Notion *Experiments* database, and writes the record back to Notion. Re-running the script for the same experiment updates the existing page instead of creating a duplicate.

Steps 1–5 reuse the same request and poll flow as the [Airtable export tutorial](./exporting-experiment-results-to-airtable); only the destination changes.

<Note>
  Notion's API has no native upsert. The script emulates one: it queries the database's data source for a page whose title matches the experiment name, then updates that page if found or creates a new one otherwise. This tutorial targets Notion API version `2025-09-03`, which organizes each database around one or more **data sources**.
</Note>

## 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).

* **A Notion internal integration token.** Create an integration at [notion.so/my-integrations](https://www.notion.so/my-integrations) and copy its token.

* **A Notion database** with an *Experiments* schema and the following properties: `Experiment Name` (title), `Status` (select), `Start date` (date), `End date` (date), `Notes` (rich text), `Actual` (number), `Probability` (select), and `Result` (select).

* **The Notion database ID.** Open the database as a full page (the ID is the 32-character string in its URL, before the `?v=` view parameter).

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

<Warning>
  Share the database with your integration, or every request returns `object_not_found`. Open the database, go to **`•••` → Connections → Add connections**, and select your integration.
</Warning>

<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 NOTION_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..." }
```

The returned `access_token` is sent as a `Bearer` token on every subsequent Automation API request. Access tokens are 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 Notion page, and `mainGoalId` to scope the results request in the next step.

<Note>
  The API returns `mainGoalId` by default, so the script doesn't need an `optionalFields` parameter to read it. The Automation API doesn't publish a fixed enum for the `status` field, and the tokens can evolve. [Step 7](#7-map-the-data-to-notion-properties) matches `status` case-insensitively, so casing differences between accounts don't break 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 instead of the Bayesian success probability. Enable one method or the other, not both. |
| bayesian             | Boolean | Set to `true` to include the Bayesian success probability in the report. The script reads this value to populate the *Probability* property.            |
| conversionType       | String  | `ALL_CONVERSION` or `CONVERTED_VISITS`.                                                                                                                 |

**Example:**

```python theme={null}
def request_results(token, experiment_id, goal_id):
    body = {
        "visitorData": False,
        "sequentialTesting": False,
        "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` used to poll for the result in the next step.

<Note>
  This script requires `bayesian: true` and sets `sequentialTesting: false`. `bayesian` and `sequentialTesting` are alternative methods for computing significance, and this tutorial reports the Bayesian success probability. 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* property. Confirm the value against the same report in the Kameleoon app if your account uses a different default statistical method.
</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 returned by the previous step. |

The response `status` is `WAITING` while the report is being computed, `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. If a variation's `goalsData` doesn't contain the requested goal ID, the script falls back to whichever goal is present; since [step 3](#3-request-the-experiment’s-results) already scopes the request to a single goal with `goalsIds`, this fallback normally has nothing else to select from. The *Result* property mapped later records whether that variation reached a high enough success probability with a positive uplift to count as a genuine win.

**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. Resolve the Notion data source

Since version `2025-09-03`, a Notion database is a container for one or more **data sources**, and page writes and queries target a data source ID rather than the database ID. The two IDs are not interchangeable.

**Endpoint:** Retrieve the database to discover its data sources by sending a GET request to the [Retrieve a database](https://developers.notion.com/reference/retrieve-a-database) endpoint.

```
GET https://api.notion.com/v1/databases/{databaseId}
```

Every Notion request sends the integration token as a `Bearer` token and the `Notion-Version` header.

**Example:**

```python theme={null}
NOTION_VERSION = "2025-09-03"


def notion_headers(token):
    return {
        "Authorization": f"Bearer {token}",
        "Notion-Version": NOTION_VERSION,
        "Content-Type": "application/json",
    }


def get_data_source_id(token, database_id):
    resp = requests.get(
        f"https://api.notion.com/v1/databases/{database_id}",
        headers=notion_headers(token),
    )
    resp.raise_for_status()
    data_sources = resp.json().get("data_sources", [])
    if not data_sources:
        raise RuntimeError("The database has no data sources.")
    return data_sources[0]["id"]
```

**Response (truncated):**

```json theme={null}
{
  "object": "database",
  "id": "255104cd-477e-808c-b279-d39ab803a7d2",
  "data_sources": [
    { "id": "bc1211ca-e3f1-4939-ae34-5260b16f627c", "name": "Experiments" }
  ]
}
```

The script uses the first data source. If your database exposes several, pick the one whose schema matches the *Experiments* properties.

## 7. Map the data to Notion properties

The script transforms the experiment metadata and the best-performing variation's metrics into Notion property values. Each property type has its own JSON shape.

| Notion property | Type      | Source                                           | Transformation                                                                                                                          |
| --------------- | --------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| Experiment Name | Title     | `experiment.name`                                | Direct. Used as the upsert key.                                                                                                         |
| Status          | Select    | `experiment.status`                              | Mapped, case-insensitively: `active → Running`, `draft`/`planned → Implementing`, `stopped`/`diverted → Completed`, `paused → Defunct`. |
| Start date      | Date      | `experiment.dateStarted`                         | Date-time truncated to an ISO date (`YYYY-MM-DD`).                                                                                      |
| End date        | Date      | `experiment.dateEnded`                           | Date-time truncated to an ISO date.                                                                                                     |
| Notes           | Rich text | `experiment.description`                         | Direct.                                                                                                                                 |
| Actual          | Number    | best variation `improvementRate`                 | Direct (measured uplift, %).                                                                                                            |
| Probability     | Select    | best variation Bayesian success probability      | Bucketed: `≥95 → 80% - High`, `≥80 → 50% - Medium`, else `20% - Low`.                                                                   |
| Result          | Select    | Bayesian success probability + `improvementRate` | probability `≥ 95` and uplift > 0 → `Success`; `≥ 95` and uplift \< 0 → `Failure`; otherwise `Inconclusive`.                            |

The script omits empty values so that existing property values are never overwritten with blanks on update. Notion creates any missing *select* options automatically, but the properties themselves must already exist in the data source schema with the correct types.

**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_notion_properties(experiment, best):
    probability = best.get("bayesian_probability")
    improvement = best.get("improvement_rate")
    props = {}

    name = experiment.get("name")
    if name:
        props["Experiment Name"] = {"title": [{"text": {"content": name}}]}

    status = map_status(experiment.get("status"))
    if status:
        props["Status"] = {"select": {"name": status}}

    start = to_iso_date(experiment.get("dateStarted"))
    if start:
        props["Start date"] = {"date": {"start": start}}

    end = to_iso_date(experiment.get("dateEnded"))
    if end:
        props["End date"] = {"date": {"start": end}}

    notes = experiment.get("description")
    if notes:
        props["Notes"] = {"rich_text": [{"text": {"content": notes}}]}

    if improvement is not None:
        props["Actual"] = {"number": improvement}

    bucket = bayesian_to_probability(probability)
    if bucket:
        props["Probability"] = {"select": {"name": bucket}}

    result = derive_result(probability, improvement)
    if result:
        props["Result"] = {"select": {"name": result}}

    return props
```

<Note>
  Notion allows only one title property per data source. The script keys the upsert on the property named `Experiment Name`; if your title property has a different name, rename it here and in the query filter in [step 8](#8-upsert-the-page-into-notion).
</Note>

## 8. Upsert the page into Notion

Notion has no upsert endpoint, so the script queries the data source for a page whose `Experiment Name` matches, then updates it or creates a new one.

**Find:** Query the data source with a title filter using the [Query a data source](https://developers.notion.com/reference/query-a-data-source) endpoint.

```
POST https://api.notion.com/v1/data_sources/{dataSourceId}/query
```

**Create:** Add a page parented by the data source using the [Create a page](https://developers.notion.com/reference/post-page) endpoint.

```
POST https://api.notion.com/v1/pages
```

**Update:** Overwrite the matched page's properties using the [Update page properties](https://developers.notion.com/reference/patch-page) endpoint.

```
PATCH https://api.notion.com/v1/pages/{pageId}
```

**Example:**

```python theme={null}
def find_page(token, data_source_id, name):
    resp = requests.post(
        f"https://api.notion.com/v1/data_sources/{data_source_id}/query",
        headers=notion_headers(token),
        json={
            "filter": {"property": "Experiment Name", "title": {"equals": name}},
            "page_size": 1,
        },
    )
    resp.raise_for_status()
    results = resp.json().get("results", [])
    return results[0]["id"] if results else None


def upsert_page(token, data_source_id, properties):
    title = properties.get("Experiment Name", {}).get("title")
    name = title[0]["text"]["content"] if title else None

    page_id = find_page(token, data_source_id, name) if name else None
    if page_id:
        resp = requests.patch(
            f"https://api.notion.com/v1/pages/{page_id}",
            headers=notion_headers(token),
            json={"properties": properties},
        )
        action = "Updated"
    else:
        resp = requests.post(
            "https://api.notion.com/v1/pages",
            headers=notion_headers(token),
            json={
                "parent": {"type": "data_source_id", "data_source_id": data_source_id},
                "properties": properties,
            },
        )
        action = "Created"
    resp.raise_for_status()
    return resp.json(), action
```

**Response (truncated):**

```json theme={null}
{
  "object": "page",
  "id": "1a2b3c4d-5e6f-7081-9abc-def012345678",
  "properties": {
    "Experiment Name": { "title": [{ "plain_text": "Product Page Redesign" }] },
    "Status": { "select": { "name": "Completed" } },
    "Start date": { "date": { "start": "2025-01-15" } },
    "End date": { "date": { "start": "2025-02-12" } },
    "Actual": { "number": 211.48 },
    "Probability": { "select": { "name": "80% - High" } },
    "Result": { "select": { "name": "Success" } }
  }
}
```

## 9. Run the script

Pass the experiment ID and the Notion database ID as arguments:

```bash theme={null}
python kameleoon_to_notion.py \
  --experiment-id 188308 \
  --database-id 255104cd477e808cb279d39ab803a7d2
```

The script prints each step: authentication, the experiment fetched, the best-performing variation, the resolved data source, the mapped properties, and whether the Notion page was created or updated.

## Customization notes

* **Status mapping** lives in the `STATUS_MAP` constant, keyed on the status tokens the API returns and matched case-insensitively. Adjust the target values if your *Status* options differ from `Running` / `Implementing` / `Completed` / `Defunct`, and confirm the tokens your account returns with a single `GET /experiments/{experimentId}`.
* **Probability** maps from the measured Bayesian success probability, which requires `bayesian: true` on the results request. If your *Probability* property is instead a pre-experiment estimate that you enter manually, remove the `Probability` block from `build_notion_properties`.
* **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.** The title match is exact, so differences in case or whitespace in `Experiment Name` create a new page instead of updating the existing one. Because the find-then-write flow is not atomic, avoid running two exports for the same experiment concurrently.
* **API version.** The script pins `Notion-Version: 2025-09-03`. If you later add a second data source to the database, update `get_data_source_id` to select the correct one by name.
* **Rate limits.** The Automation API allows up to 50 requests per 10 seconds and 1,000 per hour; the Notion API averages about 3 requests per second. If you batch many experiments, cache tokens and add throttling.
