The outcome lives on the row
Every uploaded record is one job, and the job’s final status is its outcome. The batch carries the rollup — the counts and the terminal state — never a per-record array. That split is why you read outcomes with the jobs feed:GET /flows/jobs?batch_id=<batch_id>&status=rejected is the list of rows the provider refused, and the same filter with any other status answers the same question for that state.
The four outcomes
All four are terminal: the row will not change again, and its retention clock starts at that moment like any other finished record.
These four join the statuses a row passes through on its way there —
staged, queued, and the non-terminal needs_reconcile hold. See the full job status table for the whole lifecycle in one place.unresolved, stated plainly
unresolved means: we do not know what happened to this row. The upload handle was lost, or the provider’s answer never became conclusive, or the result did not arrive inside the window we are willing to wait (roughly six hours). One of those is true, and none of them tells us whether the record reached your list.
Three things follow, and they are all deliberate:
- The row was not retried. Re-sending a record whose fate is unknown risks contacting the same person twice, so nothing in the platform does it automatically.
- The row will not be retried later either.
unresolvedis terminal precisely so that an unknowable row cannot hold a batch open forever. - Re-driving it is yours to decide. Export the unresolved rows and re-upload them as a new record set if you judge a possible duplicate contact acceptable. That is a real risk, not a formality — the whole reason these rows are quarantined is that we cannot rule it out.
Why a row was rejected
A rejected row carries two descriptions of the same refusal, because neither one alone is enough:
The provider’s text is the truth about this row and occasionally describes the rule it thinks was broken imprecisely; the class is what stays stable enough to build on. Keeping both means neither has to do the other’s job.
Classes you can see today:
The class list is extensible on purpose. If your provider starts returning a reason we have not classified, that row arrives as
unknown with its trouble_message intact — an unfamiliar reason is never guessed at and never causes the rest of the import’s results to be dropped.GET /flows/jobs JSON — a rejection reason is provider prose about a specific contact, and the export is where that belongs.
Reading outcomes back
The batch counters
A batch keeps a running count of where its rows stand. The counts are maintained as rows move — never recomputed by scanning your records — so they stay cheap at any volume.
They always add up:
Where you see them. The full breakdown is delivered with the batch’s terminal frame on
GET /flows/batches/{batch_id}/stream — one aggregate event carrying the final status and every counter (see Execution streaming). While a batch is still running, list its rows by status to see what is in each state.Batch terminal states
The counters decide where the batch lands. There is exactly one rule, applied the same way whether the batch finished on its own or you stopped it:completed_with_errors is a terminal state like any other: the batch is finished, its stream closes, and nothing further will be dispatched from it. It is distinct from partially_failed, which describes the batch’s executions rather than its provider import — the two live at different layers and stay separately readable.
What cancelling does to the counts
Cancelling a batch —POST /flows/batches/{batch_id}/cancel, available to any key for your own organization — stops all future sends and nothing else:
- Further sends stop the moment the call returns. Nothing still waiting in the queue is handed to the provider after that point, whatever its size.
- Every row not yet sent settles as
unprocessed. None is left in limbo. On a large batch the counts land a few seconds behind the response — settling a million rows is real work, and it happens in the background rather than holding your request open — so readcounters.unprocesseda moment later, not in the same breath. - Records already handed to the provider are not recalled. Their results are still collected and written back exactly as they would have been, so
acceptedandrejectedkeep filling in after the cancel. - When the dust settles, the counters tell you precisely what reached the provider before you pulled the brake — which is the number you actually need after cancelling a bad upload.
When outcomes arrive
Results are collected by asking the provider, on a schedule that respects your platform’s rate budget. That polling is the authoritative path and it always runs. Where a provider can also push a notice that an import has finished, that push is only a hint that it is worth asking now — it shortens the wait and never carries the outcome itself. A notice that is late, duplicated or never sent changes nothing about the result you get; it only changes how quickly you get it. If an import never becomes conclusive within roughly six hours, its rows settle asunresolved rather than being chased indefinitely.
One event per batch, never one per row
A finished bulk batch emits one aggregate event carrying its terminal status and its counters. It does not emit an event per record — a million-row upload would otherwise be a million frames on a stream a person is watching, which is a denial of service dressed up as observability. Per-row detail is a query (GET /flows/jobs?batch_id=...&status=...) or a file, not a stream.
Next steps
Export failed rows
Fix and resubmit: list the rejected rows, export them, correct the file, re-upload.
Bulk ingestion
The upload path these outcomes come from, end to end.
Batches, jobs, and executions
Every status in the chain, and where outcomes sit in it.
Dispatch pacing
Why a large upload is delivered in slices, and what
needs_reconcile means.