Skip to main content
Past 10,000 records, don’t send an array. Upload a record set — a manifest you push records into in chunks, seal, and then hand to a trigger by id. The records never travel inside the trigger request, so a 1,000,000-row upload is a hundred small POSTs and one trigger call that returns immediately.

When to use it

Inline is simpler and stays the right answer for small loads. Everything on this page is for the case where the array would be too big to send at all.

The three-step flow

  1. POST /record-sets — create the manifest. Declares chunk_size and returns the record set’s id.
  2. POST /record-sets/{record_set_id}/records — once per chunk, until every record has been sent.
  3. POST /record-sets/{record_set_id}/seal — declare expected_count. The API checks it against what it actually staged.
Then trigger the flow with record_set_id. All three upload steps require admin access; reading a record set’s status does not.
There is no GET /record-sets list endpoint. A record set is a short-lived upload handle whose id you already hold — read one with GET /record-sets/{record_set_id} to check progress.

Step 1 — Create the record set

chunk_size is fixed for the whole upload and defaults to the largest chunk the API accepts (10,000). metadata is free-form and comes back on every read.
The 201 response is the manifest:
status moves opensealedconsumed. Only an open record set accepts chunks.

Reading CSV files

Parsing is client-side in this release. Chunk bodies are JSON only — there is no text/csv upload. You read the file, turn each row into an object, and POST batches of those objects. Given a CSV like this:
each row becomes one object, and your flow reads its columns through {{input.first_name}}, {{input.last_name}} and {{input.phone}} — exactly as on the inline path. Combining several files into one upload works the same way: the manifest is the “combine files” primitive. Push each file’s rows as additional chunks into the same record set, keeping a single running chunk index across all of them.

Step 2 — Push the chunks

Each chunk carries a client-assigned chunk_index, starting at 0, and the chunk’s records in dial order:
Chunks may be sent in any order. Re-POSTing the same chunk_index with the same rows is a safe no-op: the response reports replay: true and the record set’s counters do not move. That is what makes an interrupted upload recoverable — on any error you do not know whether the chunk landed, so you simply send it again. A row’s position in the set is chunk_index * chunk_size + offset, where offset is its position within the chunk. Keep chunk_size constant for every chunk except the final one. A short chunk in the middle is allowed — it just leaves a harmless gap in the row numbering, because rows are processed by range and sealing validates the totals.

Pacing and 429

The chunk endpoint has its own rate limit, separate from (and far above) the standard per-principal limit. Even so, honour Retry-After on a 429 and back off — behind multiple API replicas this is the difference between an upload that completes and one that fails intermittently.
Each chunk responds with its own outcome:
received_count is the running total across the whole record set. The value from your last chunk is what you pass as expected_count when you seal.
An oversized-record 422 names the offending row index, never the row itself. No error body ever echoes your record data back.

Step 3 — Seal

expected_count is the total number of records you sent. The API compares it with the number it actually staged and refuses the seal if they disagree — this is how a chunk that was silently lost in transit is caught, before anything is dialled. You don’t need the total up front, only at seal time, which is what lets you stream several files in without counting them first.
A 409 on seal names both numbers — what you expected and what was staged — so a missing chunk is distinguishable from a duplicate seal. Re-sealing with the same expected_count returns the same result, so the call is safe to retry.

Step 4 — Trigger the flow

Pass record_set_id where you would have passed records:
records and record_set_id are mutually exclusive — sending both is a 422.
The definition must use batched delivery. A definition with per-record (direct-delivery) steps refuses a record set with 422, pointing you back at the inline records path (up to 10,000), which works today. There is no silent coercion between the two delivery modes.
A record set is single-use — exactly one trigger consumes it. The two 409s here are distinct and mean different things:

Idempotency

Identical content re-submitted within 7 days is not ingested twice. The second call returns 200 with deduplicated: true and the original batch_id:
A deduplicated submission is a 200, not an error. A client that treats any non-fresh response as a failure will mis-handle it. The batch_id you get back is a usable handle to the original batch — poll it as normal.
Content identity is computed by the server from the sealed records and the definition, so uploading the same rows against a different definition is not a duplicate. To deliberately re-run identical content — a genuine second dial of the same list — supply an Idempotency-Key header on the trigger, which forces a new batch:
The key must be non-blank and at most 255 characters. It applies to the record-set path only and is ignored on the inline records path. After 7 days the window closes and identical content is accepted again without a key.

What happens next

The trigger returns as soon as the batch is minted — the per-record work happens after the response, which is why the call is fast regardless of record count. Every uploaded row becomes one job and one execution. The batch is pending while its rows are being bound, and reaches queued once every row has a job and an execution. Watch it with GET /batches/{batch_id}.
Nothing is sent to your platform during ingestion. A queued batch is fully staged and idle — dispatch to the contact-centre platform arrives in a later release. Uploading is safe to do ahead of time.
Read the individual rows with GET /jobs?batch_id=<batch_id>, exactly as for an inline trigger.

Retention

The second row is the important one for abandoned uploads: a record set you create and never trigger does not sit around holding contact data indefinitely.
Changing an account’s retention affects future batches only. Rows already staged keep the retention that was in force when their batch was created.

Limits

Records per chunk and chunk body size bind independently — at roughly 300 bytes per record the 16 MB body ceiling is reached well above the 10,000-record chunk cap, so the record count is normally the binding limit.

Next steps

Trigger with records

The inline path for loads up to 10,000 records.

Batches, jobs, and executions

Every status in the chain, including staged and queued.