---
name: rippit-bulk-ingest-api
description: Walk a user through sending their own data into Rippit with the Rippit bulk ingest API. Use when a user wants to upload custom conversations (chats, tickets, emails, calls, bot transcripts from a system Rippit has no integration for) or a flat table (accounts, orders, usage, custom attributes) into Rippit, set up an API token, run a test upload, run the full upload, or put the upload on a schedule.
compatibility: Needs a Rippit workspace with API ingest enabled and an assistant that can run code and make HTTPS requests on the user's machine. In a chat-only client, write the script and have the user run it.
metadata:
  author: rippit
  version: "1.0"
---

# Rippit Bulk Ingest API

Get the user's data into Rippit in four steps: token, check, test upload, full upload, with an optional sample-data dry run after the check. Do them in order and confirm each one before moving on.

The full reference lives in two guides. Fetch the one you need before writing any code; it has every field, limit and error:

- Conversations: https://integrations.maestroqa.com/api/v2/guides/bulk_conversations.md
- Flat tables (datasets): https://integrations.maestroqa.com/api/v2/guides/bulk_datasets.md
- OpenAPI schema: https://integrations.maestroqa.com/api/v2/openapi.json

Base URL: `https://integrations.maestroqa.com/api/v2/bulk_ingest`. Every request sends the token in an `apitoken` header.

## 0. Conversations or a table?

Ask what they want to send, then pick one:

- **Conversations**: ordered messages, usually between a customer and an agent (a rep or a bot). One-sided logs such as AI agent traces work too, with every message as `agent`. One JSON Lines row per conversation, with its messages as `events`. Uses conversation sources.
- **Flat table**: one row per record, such as accounts or orders. Uses datasets. Parquet is preferred; JSON Lines works for hand-built rows.

If they have both, do conversations first. A table joins to conversations on a `conversation_id` column.

Then ask where the data lives (an API, a database or warehouse, a CSV or Excel export) and how to read it. Ask for any credentials for that source only as environment variables.

## 1. Get the API token

The token lives in a file only the user can read, `~/.rippit_token`. The user pastes it there themselves, so it never goes through the chat and nobody has to restart their session.

Tell the user, word for word:

1. In Rippit, open **Tables**, click **Add table**, then **Add API Table**. (Or ask Rippit in chat to "connect via API".) You need to be a workspace admin.
2. Click **Create API token**, or **Copy** next to an existing one.
3. I'll open a blank file on your computer. Paste the token **into that window** (not into this chat), save, and close it.

Then create and open the file yourself; the user never types a command:

```sh
touch ~/.rippit_token && chmod 600 ~/.rippit_token && open -e ~/.rippit_token
```

On Windows use `notepad %USERPROFILE%\.rippit_token`; on Linux `xdg-open ~/.rippit_token`. If you can't open a window (a remote machine), tell the user to open the file in any editor themselves.

When they say it's saved, check it without showing it: `wc -c < ~/.rippit_token` should be about the token's length, and `file ~/.rippit_token` should say plain text (if it says RTF, ask them to choose **Format → Make Plain Text** in TextEdit and save again).

**Never ask for the token in chat, never print or `cat` the file on its own, and never write the token into a script.** Only use it inline by prefixing commands with `RIPPIT_API_TOKEN=$(tr -d '[:space:]' < ~/.rippit_token)`, which also strips the newline an editor adds. If the user pastes the token into the conversation anyway, don't repeat it back, and suggest they create a new token afterwards.

Every script reads it with `os.environ["RIPPIT_API_TOKEN"]`, and every run is started with that prefix, including scheduled ones.

## 2. Check the token

Before building anything, confirm the token works:

```sh
curl -s -w '\n%{http_code}\n' -H "apitoken: $(tr -d '[:space:]' < ~/.rippit_token)" \
  https://integrations.maestroqa.com/api/v2/bulk_ingest/conversation_sources
```

- `200`: good. It lists existing sources with a `table_url` each, which tells you whether the one they want already exists and where it is.
- `401`: the token is missing, wrong or expired. Check `~/.rippit_token` was saved with just the token in it, or create a new token.
- `403`: API ingest isn't enabled for the workspace. Stop here: they need to ask their Rippit contact to turn it on.

Use `/datasets` instead of `/conversation_sources` for a table.

## 2b. Optional: try it with sample data

Before touching their real data, offer a dry run: "Want to see it work end to end with sample data first?" If yes:

- Generate the sample yourself; don't download anything. Conversations: 100 realistic, varied support chats (billing, bugs, how-to questions; 3 to 8 messages each, some with a bot handoff, one internal note with `public: false`), with a few `searchable` fields such as `channel`, `plan` and `csat`. Table: 1,000 rows of fake accounts (`account_id`, `plan`, `seats`, `mrr`, `is_active`, `updated_at`). Use obviously fake names and `example.com` emails.
- Upload it with the same code as step 3, to a source or dataset named `sample conversations` or `sample_accounts`, so it never mixes with real data.
- Show the job result and link the sample table with its `table_url` (step 3), then point out the format they just saw is what their own data maps to.

The sample lands as a real table in their workspace; tell them they can delete it from **Tables** when they're done.

## 3. Test upload

Write one script that reads from their source, maps rows to Rippit's format, and uploads. Run it on a small sample first: **10 conversations, or 1,000 table rows.**

Mapping rules that trip people up (the guides have the rest):

- Timestamps are ISO 8601 with a timezone, such as `2026-09-27T14:02:11Z`.
- Conversation `author_role` is optional; when set it is exactly `"agent"` or `"customer"`. Bots are `"agent"`.
- Keys in `searchable` and `attributes`, and table column names, use letters, numbers and underscores only, starting with a letter or underscore.
- `searchable` values are single values. Join lists into one string, such as `"billing,refund"`.
- Store IDs as text, even when they look like numbers.
- Reusing a `conversation_id` or a primary key updates that record, so re-runs are safe.

Every upload follows the same flow, for conversations and tables alike:

```python
# pip install requests
import os, time, requests

API = "https://integrations.maestroqa.com/api/v2/bulk_ingest"
HDRS = {"apitoken": os.environ["RIPPIT_API_TOKEN"]}


def call(method, path, **kw):
    while True:
        r = requests.request(method, f"{API}{path}", headers=HDRS, **kw)
        if r.status_code != 429:
            r.raise_for_status()
            return r.json()
        time.sleep(int(r.headers.get("Retry-After", 60)))


def send(slot_path, params, fname, data):
    """slot_path: /conversation_blocks/bulk or /datasets/{id}/bulk. data: file bytes, 128 MB max."""
    up = call("POST", slot_path, params=params)
    # The upload link is pre-signed: no Rippit credentials on this request.
    requests.post(up["upload_url"], data=up["upload_fields"], files={"file": (fname, data)}).raise_for_status()
    job = call("POST", f"/jobs/{up['job_id']}/start")
    while job["status"] not in ("done", "failed"):
        time.sleep(10)
        job = call("GET", f"/jobs/{up['job_id']}")
    return job
```

Conversations: `call("POST", "/conversation_sources", json={"name": source})` first (it is safe to repeat), then `send("/conversation_blocks/bulk", {"source": source}, "chats.jsonl", data)`.

Tables: `call("POST", "/datasets", json={...})` returns `dataset_id`, then `send(f"/datasets/{ds}/bulk", {"format": "parquet"}, "rows.parquet", data)`. The datasets guide has a ready `upload()` that builds the dataset from a PyArrow table; use it.

Ask the user what to name the source or dataset, such as `in-app chat` or `accounts`.

After the test run, show the user the job result: `rows_received`, `rows_written`, and every entry in `rejected` (its `row` is the 0-based line in the file). Fix the mapping until `rejected` is empty, then send them to the new table to check a few records look right. The finished job's `result.table_url` opens it: always give it as a clickable link, `[Open <name> in Rippit](<table_url>)`. `GET /conversation_sources` and `GET /datasets` return the same `table_url` per item (a source's is `null` until its first upload). If it's missing, tell them to open **Tables** in Rippit and look for the name. **Don't run the full upload until they confirm.**

## 4. Full upload

Remove the sample limit and run it all:

- Split files to stay under 128 MB each.
- Uploads are limited to 10 per minute and 100 per day, so send fewer, bigger files rather than many small ones.
- Print a summary per file: rows written and rejected, with each rejected row's error.

Then offer to schedule it. Have each run only select records changed since the last run (for example by `updated_at`), and start the job's command with `RIPPIT_API_TOKEN=$(tr -d '[:space:]' < ~/.rippit_token)`. Show a cron line for macOS or Linux, or a Task Scheduler entry for Windows.
