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

# Batch-load CDP data from CSV, JSON, or JSONL files

> Use POST /api/v1/cdp/file-ingest for offline bulk loads — migrations, onboarding imports, one-off backfills — with a full parse-plus-validate dry run, a 200-row-per-call cap, and the GDPR right-to-erasure gate applied to every row.

# Batch-load CDP data from CSV, JSON, or JSONL files

`POST /api/v1/cdp/file-ingest` is the non-SDK bulk-load path into the CDP. You post a file's raw text — CSV, a JSON array, or newline-delimited JSON — and each row lands through the same ingestion primitives the live tracking surface uses: the same identity rules, the same deterministic message-id dedupe, and the same GDPR right-to-erasure gate. Rows that name people land on profiles; rows that name events land on the event stream.

This is the connect-or-validate surface a migration script, an onboarding runbook, or a backfill job calls. It is deliberately synchronous and bounded: one request carries up to 200 rows and returns per-row errors and counters, so you always know exactly which rows landed and which need fixing.

## 1. When to use file-ingest vs the signed SDK surface

The two ingest paths exist for different jobs. Pick deliberately.

| Situation | Use | Why |
| - | - | - |
| Live app eventing (track, identify, page, screen, group, alias) | The HMAC-signed surface `/cdp/v1/:ingest_id/*` | Per-request signing, nonce replay protection, and no owner-role key on the wire |
| A one-time export from a CRM, spreadsheet, or legacy CDP | **file-ingest** | Post the file's text; no SDK to deploy, no signature machinery to set up |
| A nightly object-storage (bucket) drop | The scheduled reader — roadmap | File-ingest is the direct-upload path its parser already powers |

A few rules of thumb:

* **Migrations and onboarding.** A migration script that holds the export in memory should call file-ingest once per chunk; it is far simpler than standing up a signer for a one-shot job.
* **Backfills.** Events you replay from a warehouse dump keep their original `timestamp` per row, so the event stream records when they actually happened, not when you uploaded.
* **Live traffic.** Never feed steady app traffic through file-ingest — the 200-row cap and the operator-role requirement make it the wrong tool. Use the signed surface.

For the background, the CDP loop is documented end to end in [first CDP activation](/guides/cdp-first-activation-end-to-end) and the HMAC contract is spelled out in [sign CDP ingest requests](/guides/cdp-ingest-signing).

## 2. The endpoint

Authenticate with your workspace API key (Clerk session also works). The route requires the **owner, admin, or developer** role, and the rate limit is intentionally tight (10 calls per minute) — this is a bulk operator action, not a hot path.

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/cdp/file-ingest" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "format": "csv",
    "content_kind": "events",
    "content": "event,userId,plan,amount\nsignup,u_123,pro,49\npurchase,u_123,pro,49",
    "dry_run": true
  }'
```

| Field | Required | Meaning |
| - | - | - |
| `format` | yes | `csv`, `json` (top-level array of objects), or `jsonl` (one object per line) |
| `content_kind` | yes | `events` (each row is a `track`-style event) or `profiles` (each row is an `identify`-style upsert) |
| `content` | yes | The raw file text. Bounded by the inline ceiling — 1 MiB (1,048,576 bytes) |
| `dry_run` | no | `true` to parse and validate without writing. Default `false` |

The response returns counters and a per-row error list — see section 4.

### Row mapping

The reader accepts both nested and flat shapes.

**Events.** A row needs an event name in `event` (also accepted: `event_name`, `eventName`). `userId` / `user_id` and `anonymousId` / `anonymous_id` are optional — an event with neither still lands, it just does not link to a contact. Everything that is not a reserved key folds into `properties`, and a `timestamp` column sets the event time.

**Profiles.** A row needs at least one identifier: `userId`, `anonymousId`, or an `email` / `phone` trait. Non-reserved keys fold into `traits`. Ship one of those identifiers on every row — otherwise the row has nothing to bind a contact to, and it comes back in `errors`.

CSV with a header row is the most convenient shape: the first row names the keys, and the RFC-4180 reader handles quoted fields, embedded commas and newlines, and `\r\n` endings.

## 3. Dry-run semantics

`dry_run: true` runs the full pipeline — decode, column-count checks, domain mapping — and returns what *would* ingest, with **zero writes** and no audit entry. Use it to shake a file before you commit rows to the workspace:

1. Post the file with `dry_run: true`.
2. Compare `valid_rows` against your expected count and read every entry in `errors`.
3. Fix the flagged rows, re-dry-run.
4. Post with `dry_run: false` (or omit the flag).

Nothing about the validation differs between the two modes — a row that fails dry-run fails the real run, and a file that validates clean lands clean.

## 4. Bounds: 200 rows per call, 1 MiB per request

Two hard ceilings keep this endpoint synchronous and safe on the request path:

* **Row cap.** At most **200 rows** are read per request. When the file holds more, the response sets `truncated: true` and the remainder is skipped — chunk larger files by the caller and page through the chunks.
* **Byte cap.** `content` is bounded by the inline ceiling — **1 MiB** (1,048,576 bytes) — the same cap the live tracking surface enforces. A request over the cap is rejected at validation with a 400.

Splitting is safe to retry: events you replay carry their own `message_id` if the file has one, and when it does not, the workspace derives a deterministic message id from the row bytes — so re-uploading a chunk dedupes instead of double-writing.

Response counters tell you which outcome every row reached:

```json theme={null}
{
  "data": {
    "format": "csv",
    "content_kind": "events",
    "dry_run": false,
    "max_rows": 200,
    "total_rows": 200,
    "valid_rows": 198,
    "ingested": 196,
    "contacts_created": 3,
    "deduped": 4,
    "tombstoned": 1,
    "truncated": false,
    "errors": [
      { "index": 41, "message": "Missing required event name." },
      { "index": 166, "message": "Row has no identity key." }
    ]
  }
}
```

| Field | Meaning |
| - | - |
| `max_rows` | The per-request row cap (200) |
| `total_rows` | Rows actually read this request |
| `valid_rows` | Rows that decoded and mapped cleanly |
| `ingested` | Rows written (0 on a dry run) |
| `contacts_created` | New profiles created (profiles kind only) |
| `deduped` | Events suppressed because their message id already landed |
| `tombstoned` | Rows declined by the erasure gate (section 5) |
| `truncated` | True when the 200-row cap clipped a larger file |
| `errors` | Per-row validation failures with a 0-based row index |

## 5. GDPR right-to-erasure gate, per row

Every non-dry-run row passes the erasure gate before a write. If the row's `userId` or `anonymousId`, or its `email` / `phone` trait, matches a contact your workspace has erased (right-to-erasure under GDPR Article 17), the row counts under `tombstoned` and nothing is written for it — a re-upload can never resurrect an erased profile.

For profiles, matching an erased identifier declines the upsert entirely. For events, the row is skipped before resolution. Either way the erasure record is authoritative and the counter tells you exactly how many rows the gate declined. Erase and re-export deliberately: a migration file that still contains erased people gets them declined, not resurrected.

## 6. Where the parser goes next

The decode-and-map half of this endpoint is a pure, dependency-free parser: no object-storage SDK, no transport, no schema changes. The scheduled bucket reader for object-storage source drops reuses this same parser once that transport lands — so the CSV, JSON-array, and JSONL shapes you post today are exactly the shapes the scheduled reader accepts when it ships. Parquet stays out of scope for now: it is a binary format that needs a real dependency, and the text formats cover the common export shapes.

## 7. Troubleshooting

| Symptom | Cause | Fix |
| - | - | - |
| `400` at validation | `content` over the 1 MiB inline ceiling, a missing field, or a wrong enum value | Chunk the file per section 4; send one of `csv` / `json` / `jsonl` and `events` / `profiles` |
| `truncated: true` | The file held more rows than the 200-row cap | Split the remaining rows into the next request |
| All rows in `errors` with "Missing required event name" | `content_kind: "events"` but the header lacks `event` / `event_name` / `eventName` | Rename the column to one of those keys |
| Profiles all rejected | No identifier on the row | Ship `userId`, `anonymousId`, `email`, or `phone` on every row |
| A CSV row is in `errors` with a column-count mismatch | A quoted field reader caught a malformed row | Check quoting and embedded newlines in that row |
| `tombstoned` is above zero | Rows matched erased identifiers | Expected — the gate is working; do not try to re-import those people |

## See also

* [Sign CDP ingest requests](/guides/cdp-ingest-signing) — the HMAC contract the SDK surface uses
* [Import or migrate contacts](/guides/import-and-migrate-contacts) — the decision table over every import surface
* [CDP data catalog and identity rules](/guides/cdp-data-catalog-and-identity-rules) — the match shapes file-ingest rows resolve through
* [First CDP activation, end to end](/guides/cdp-first-activation-end-to-end) — the loop file-ingest feeds
