# Bulk conversation upload

Send conversations from your own systems (a chat tool, a ticket export, a support inbox or a warehouse table) into Rippit so you can search, grade and analyze them.

A conversation is a group of back-and-forth messages between two sides: a customer and a support rep, a user and a bot, a buyer and a sales rep. Each upload line is one conversation and all of its messages.

You don't need to be an engineer. Copy the prompt at the bottom of this page into [Claude Code](https://claude.com/claude-code), answer its questions about where your chats live, and it writes and runs the export script for you.

---

## How it works

1. **Name the conversation source once**, such as `in-app chat`.
2. **Ask for an upload slot** for it. Rippit returns a one-time upload link.
3. **Upload one file** to that link. The file has one conversation per line.
4. **Tell Rippit the upload is finished.** Processing starts right away.
5. **Check the job** until it says `done`. Any rows that failed come back with their line number and the reason.

Uploading the same conversation again updates it instead of creating a duplicate. That makes it safe to run the same export every hour or every day.

### Conversation sources

A **source** is where a set of conversations comes from, such as `in-app chat`, `support email` or `sales calls`. You name it once, then pass that name as `source` on every upload. Each source becomes its own table in Rippit, with one row per conversation and your `searchable` keys as columns.

A source's columns are the `searchable` keys of its first upload. Later uploads should use the same keys: a file whose keys fit another of your sources better than the one you named fails with a message naming the better fit, so a sales-call export can't end up in your support chats by mistake.

To add your own tables alongside, such as accounts or orders, see the [bulk dataset upload](bulk_datasets.md). Join them to conversations on `conversation_id`.

---

## Before you start

**Get an API token.** In Rippit, click your name in the top right → **Other Settings** → **API Keys**. Tokens expire every 90 days by default; you can turn expiration off or deactivate a token manually.

**Authentication.** Send your token in the `apitoken` header on every request:

```python
headers = {'apitoken': 'YOUR_API_TOKEN'}
requests.post(url, headers=headers)
```

**Base URL:** `https://integrations.maestroqa.com/api/v2/bulk_ingest`

Bulk upload has to be switched on for your account. If it isn't, requests return `403` with "Your account does not have API ingest enabled." Ask your Rippit contact to turn it on.

---

## The file format

Upload **JSON Lines** (`.jsonl`): one conversation per line, and each line is a complete JSON object.

```json
{"conversation": {"conversation_id": "chat_8f2a", "started_at": "2026-09-27T14:02:11Z", "ended_at": "2026-09-27T14:09:40Z", "searchable": {"channel": "web_chat", "plan": "pro", "csat": 5}}, "events": [{"event_id": "1", "time": "2026-09-27T14:02:11Z", "author_role": "customer", "author_email": "sam@acme.com", "author_name": "Sam Lee", "body": "My invoice is wrong"}, {"event_id": "2", "time": "2026-09-27T14:03:05Z", "author_role": "agent", "author_id": "agent_42", "author_name": "Alex Kim", "body": "Sorry about that, let me check."}]}
```

### `conversation`

| Field | Required | What it is |
|---|---|---|
| `conversation_id` | yes | Your ID for the conversation. Maximum 96 characters. Reuse the same ID to update a conversation. |
| `started_at` | yes | When the conversation started, as an ISO 8601 timestamp with a timezone, for example `2026-09-27T14:02:11Z`. |
| `ended_at` | no | When it ended or was last updated. It can't be earlier than `started_at`. If you leave it out, the time of the last event is used. |
| `searchable` | no | Your own fields for filtering and reporting, such as channel, plan, tags or CSAT. At most 50 keys. Values can be text, numbers, `true`/`false` or `null`. |

### `events` (the messages)

| Field | Required | What it is |
|---|---|---|
| `event_id` | yes | Unique within this conversation. Message order or position works well (`"1"`, `"2"` and so on). Maximum 96 characters. |
| `time` | yes | When the message was sent, as an ISO 8601 timestamp. |
| `author_role` | no | `"agent"` or `"customer"`. Leave it out when neither fits. |
| `body` | yes | The message text. Maximum 100,000 characters. |
| `author_id` | no | Your ID for the person who wrote the message. |
| `author_email` | no | Their email address. |
| `author_name` | no | Their name, as shown in Rippit. Without it the message shows as "Anonymous". |
| `public` | no | `false` for internal notes. The default is `true`. |
| `attributes` | no | Your own fields on the message. At most 20 keys. |

A conversation can have up to 10,000 events.

**Rules for keys in `searchable` and `attributes`:** letters, numbers and underscores only, starting with a letter or underscore, and at most 64 characters. `plan_tier` works. `Plan Tier!` doesn't.

### More examples

These are pretty-printed so they're easier to read. In your file, each conversation goes on **one line**.

**An in-app chat from your own product.** A bot hands off to an agent. It includes an internal note (`public: false`) and per-message `attributes`.

```json
{
  "conversation": {
    "conversation_id": "app_chat_20931",
    "started_at": "2026-09-28T09:15:00Z",
    "ended_at": "2026-09-28T09:31:42Z",
    "searchable": {"channel": "in_app", "app_version": "4.12.0", "plan": "enterprise", "account_id": "acct_771", "resolved": true}
  },
  "events": [
    {"event_id": "1", "time": "2026-09-28T09:15:00Z", "author_role": "customer", "author_id": "user_5521", "author_name": "Dana Ruiz", "body": "Export to CSV keeps timing out on the reports page."},
    {"event_id": "2", "time": "2026-09-28T09:15:04Z", "author_role": "agent", "author_id": "bot", "author_name": "Support Bot", "body": "Thanks! Connecting you with a support specialist.", "attributes": {"is_bot": true}},
    {"event_id": "3", "time": "2026-09-28T09:18:30Z", "author_role": "agent", "author_email": "priya@yourco.com", "author_name": "Priya Shah", "body": "Hi! How many rows is the report you're exporting?"},
    {"event_id": "4", "time": "2026-09-28T09:20:11Z", "author_role": "customer", "author_id": "user_5521", "author_name": "Dana Ruiz", "body": "Around 400k."},
    {"event_id": "5", "time": "2026-09-28T09:22:00Z", "author_role": "agent", "author_email": "priya@yourco.com", "author_name": "Priya Shah", "body": "Known issue over 250k rows, linking ENG-4410.", "public": false},
    {"event_id": "6", "time": "2026-09-28T09:31:42Z", "author_role": "agent", "author_email": "priya@yourco.com", "author_name": "Priya Shah", "body": "For now, filter by month and export each one. A fix ships next week.", "attributes": {"macro": "large_export_workaround"}}
  ]
}
```

**An email ticket exported from a help desk.** Your ticket fields go in `searchable`. `ended_at` is left out, so the last message's time is used.

```json
{
  "conversation": {
    "conversation_id": "ticket_88412",
    "started_at": "2026-09-25T16:40:00-04:00",
    "searchable": {"channel": "email", "priority": "high", "tags": "billing,refund", "csat": 2, "team": "billing_tier2"}
  },
  "events": [
    {"event_id": "msg_1", "time": "2026-09-25T16:40:00-04:00", "author_role": "customer", "author_email": "jordan@client.io", "author_name": "Jordan Park", "body": "I was charged twice for September. Please refund one of them."},
    {"event_id": "msg_2", "time": "2026-09-26T10:05:00-04:00", "author_role": "agent", "author_id": "agent_17", "author_name": "Chris Wu", "body": "I see both charges. I've refunded the duplicate, and it should show up in 5 to 7 days."},
    {"event_id": "msg_3", "time": "2026-09-26T11:30:00-04:00", "author_role": "customer", "author_email": "jordan@client.io", "author_name": "Jordan Park", "body": "5 to 7 days is too long. This is the second time this has happened."}
  ]
}
```

Values in `searchable` must be single values, not lists. Join a list like tags into one string, as in `"billing,refund"`.

---

## The API

### 1. Name the conversation source

```
POST /conversation_sources
```

```json
{"name": "in-app chat", "description": "Chats from the help widget in our app."}
```

The response:

```json
{"name": "in-app chat", "status": "created"}
```

Calling it again with the same `name` returns `status: "exists"`, so your script can always call it first.

To see your sources and their columns:

```
GET /conversation_sources
```

```json
[
  {"name": "in-app chat", "description": "Chats from the help widget in our app.", "columns": ["channel", "plan", "csat"], "created_at": "2026-10-05T16:26:49Z", "table_url": "https://app.rippit.com/workspace/table/TBDNVN0N48I3S21J"},
  {"name": "sales calls", "description": "", "columns": null, "created_at": "2026-10-06T09:12:03Z", "table_url": null}
]
```

`columns` and `table_url` are `null` until the source's first upload. `table_url` opens the table in Rippit.

### 2. Ask for an upload slot

```
POST /conversation_blocks/bulk?source=in-app%20chat
```

`source` is the source's `name`, URL-encoded. Uploading to a name you haven't created returns `404`.

The response:

```json
{
  "job_id": "6abc...",
  "upload_url": "https://rippit-bulk-ingest.s3.amazonaws.com/",
  "upload_fields": {"key": "...", "policy": "...", "...": "..."},
  "expires_at": "2026-09-30T15:00:00Z",
  "max_bytes": 134217728
}
```

The upload link expires after one hour.

### 3. Upload the file

Send a multipart form POST to `upload_url`. Include every entry in `upload_fields` exactly as you received it, and attach the file as `file`. Don't send your Rippit credentials on this request.

```python
requests.post(up["upload_url"], data=up["upload_fields"], files={"file": open("chats.jsonl", "rb")})
```

A successful upload returns `204`. The file can be at most 128 MB, the value of `max_bytes`. Split bigger exports across several uploads.

### 4. Mark the upload finished

```
POST /jobs/{job_id}/start
```

This returns the job with `status: "queued"`.

### 5. Check the job

```
GET /jobs/{job_id}
```

Poll this every 10 to 30 seconds until `status` is `done` or `failed`.

```json
{
  "job_id": "6abc...",
  "status": "done",
  "result": {
    "rows_received": 1000,
    "rows_written": 998,
    "rejected": [{"row": 17, "error": "events.0.author_role: Input should be 'agent' or 'customer'"}],
    "source": "in-app chat",
    "table_url": "https://app.rippit.com/workspace/table/TBDNVN0N48I3S21J"
  }
}
```

`row` is the 0-based line number in your file. Fix the rejected lines and upload just those again.

---

## Limits

| Limit | Default |
|---|---|
| File size | 128 MB per upload |
| Uploads | 10 per minute, 100 per hour, 100 per day |

Only uploads that were actually completed count toward the limits. If you hit one you get `429` with a `Retry-After` header. Wait that many seconds and try again.

## Errors

| Code | Meaning | Fix |
|---|---|---|
| `401` | The token is missing, wrong or expired. | Check the `apitoken` header, or create a new token. |
| `403` | Bulk upload isn't enabled for your account. | Ask your Rippit contact to turn it on. |
| `404` | The source or job wasn't found. | Create the source with `POST /conversation_sources` first, or check the `job_id`. You can only see your own account's sources and jobs. |
| `409` on `/start` | The file wasn't uploaded yet, or the job was already started. | Upload the file first, and call `/start` only once. |
| `413` | The file is over 128 MB. | Split it into smaller files. |
| `400 EntityTooLarge` from the upload link | The file is over 128 MB. | Split it into smaller files. |
| `429` | You hit the upload limit. | Wait for the `Retry-After` time. |

---

## Paste this into Claude Code

```
I want to send our support conversations into Rippit using its bulk conversation upload API.
Docs: https://integrations.maestroqa.com/api/v2/guides/bulk_conversations.md
API schema: https://integrations.maestroqa.com/api/v2/openapi.json

Please:
1. Ask me where our conversations live (API, database, CSV export, etc.) and how to access them. Ask for credentials only through environment variables.
2. Write a Python script that:
   - pulls conversations from that source (let me choose a date range, default: last 7 days)
   - maps each one to a Rippit conversation block, one JSON object per line:
     conversation: conversation_id, started_at, ended_at, searchable {useful fields like channel, tags, csat}
     events: event_id (unique within the conversation), time, body, optional author_role ("agent" or "customer"), author_id or author_email, author_name
   - uses ISO 8601 timestamps with timezones, and keeps searchable/attribute keys to letters, numbers and underscores
   - writes a .jsonl file and splits it if it would go over 128 MB
   - names the conversation source first with POST /conversation_sources {"name": ...} (ask me what to call it, e.g. "in-app chat"), and always passes the same name as source
   - for each file: POST /conversation_blocks/bulk?source=<name>, upload it to upload_url with upload_fields as a multipart form, POST /jobs/{job_id}/start, then poll GET /jobs/{job_id} until it's done or failed
   - authenticates with an `apitoken` header set from the RIPPIT_API_TOKEN environment variable
   - backs off on 429 using Retry-After
   - prints how many rows were written and every rejected row with its error
3. Test it with 10 conversations first and show me the job result before running everything.
4. Once it works, show me how to run it on a schedule (cron) so new conversations sync every day.
```
