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
POST /data/record-sets— create the manifest. Declareschunk_sizeand returns the record set’sid.POST /data/record-sets/{record_set_id}/records— once per chunk, until every record has been sent.POST /data/record-sets/{record_set_id}/seal— declareexpected_count. The API checks it against what it actually staged.
record_set_id. All three upload steps require admin access; reading a record set’s status does not.
There is no
GET /data/record-sets list endpoint. A record set is a short-lived upload handle whose id you already hold — read one with GET /data/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.
Two rules for the Python client, and both bite silently. The
base_url must end with a slash and each relative path must start without one — a leading slash makes httpx discard the /v1 base entirely. The relative path must also carry its group segment: data/record-sets, not record-sets. Record sets live in the data group while definitions, batches and jobs live in flows, so a path missing its group returns 404 on an endpoint that plainly exists.201 response is the manifest:
status moves open → sealed → consumed. Only an open record set accepts chunks.
Reading CSV files
Parsing is client-side in this release. Chunk bodies are JSON only — there is notext/csv upload. You read the file, turn each row into an object, and POST batches of those objects.
Given a CSV like this:
{{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-assignedchunk_index, starting at 0, and the chunk’s records in dial order:
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.
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.
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
Passrecord_set_id where you would have passed records:
records and record_set_id are mutually exclusive — sending both is a 422.
What the estimate tells you
The response carries what this upload will cost against your Domain’s provider budget: calls is how many provider calls the rows come to at max_records_per_call, and pct_of_remaining_day is that as a share of today’s remaining budget. It is null when the flow does not dispatch through a rate-limited platform budget.
spills_past_day_cap: true means the work needs more calls than today’s remaining budget — and it is informational, never a refusal. The trigger was accepted; the overflow is delivered at your Domain’s permitted rate and continues into the following day, which is how the platform is meant to absorb a large upload. Note that the verdict also counts work already queued ahead of you, so a modest upload can read as a small percentage and still be flagged as spilling.
If you would rather it did not run, the brake is cancelling the batch — not a blocked trigger. To see the figures before committing, call POST /platforms/five9/domains/{tenancy_id}/estimate with a record_set_id or a record_count; it commits nothing and uses the same estimator. That dry run is also how you price a batch started by a schedule, a webhook or an event, since those paths have no response for an inline estimate to ride on. Quota and progress is the full model.
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 returns200 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.Idempotency-Key header on the trigger, which forces a new batch:
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 ispending while its rows are being bound, and reaches queued once every row has a job and an execution. Watch it with GET /flows/batches/{batch_id}.
Nothing is sent to your platform during ingestion. Staging and sealing write nothing outbound — dispatch begins only after you trigger, and from
queued it is paced against your Domain’s rate budget rather than fired all at once. Uploading is safe to do ahead of time.GET /flows/jobs?batch_id=<batch_id>, exactly as for an inline trigger.
Step 5 — Watch the outcomes
Ingestion is only half the story. As rows are delivered, your platform answers per row, and each row ends at that answer:accepted, rejected, unprocessed (never sent, because you cancelled) or unresolved (the outcome could not be determined). Import outcomes is the full model; the short version is what you need here.
The batch keeps a running count of where its rows stand — total, queued, dispatched, accepted, rejected, unprocessed, unresolved and parked — and they always sum to total, so nothing can quietly vanish mid-import. The full breakdown arrives with the batch’s terminal frame on GET /flows/batches/{batch_id}/stream; while the import is running, list rows by status to see what is in each state:
To turn the refused slice into a corrected re-upload, see Export failed rows.
Stopping a bad upload
POST /flows/batches/{batch_id}/cancel stops all future sends. Any key for your own organization can do it — it is the brake for your own bad upload, not an admin operation.
Cancelling does not rewrite the past: records already handed to your platform are not recalled, and their results are still collected, so the final counts tell you exactly what got through before you stopped it. Everything not yet sent settles as unprocessed, with nothing left in limbo — on a large batch those counts land a few seconds after the response rather than inside it, because settling a million rows is real work and the call returns as soon as the sends have stopped. A batch that has already finished returns 409.
Releasing held work
When a send goes out and no confirmation comes back, those records are held rather than resent — an automatic retry could dial the same person twice. They show up asparked in the counters and as needs_reconcile rows.
POST /flows/batches/{batch_id}/requeue is how a person releases that held work once they have decided it is safe. It never runs automatically, and held work waits indefinitely until someone asks:
requeued: 0 because nothing is left held. Two refusals are both 409:
- The batch was cancelled (or has otherwise settled). Cancel stops all future sends, and a re-drive is unambiguously a future send.
- Held work was sent in the same provider call as another batch’s records — which the platform does deliberately, to use your provider’s budget well. The whole request is refused and the response names the other batches, because releasing it would re-send records outside the batch you asked about. Nothing is requeued; the decision is yours.
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.
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.
Import outcomes
What each row’s answer means, the counters, and where a batch settles.
Export failed rows
Turn the refused slice into a corrected re-upload.
Batches, jobs, and executions
Every status in the chain, including
staged and queued.