Skip to main content
A bulk upload is not one operation with one result. Your platform provider answers per row: it takes some records into the list and refuses others, and it can do both inside a single upload call. GetDialed records that answer on each row, keeps the provider’s own words for the rows it refused, and rolls the whole thing up into a batch state that says honestly what happened.

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. unresolved is 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.
Do not confuse unresolved with the non-terminal needs_reconcile hold. needs_reconcile means “a send is still being chased down”; those rows are held and can be released with POST /flows/batches/{batch_id}/requeue. unresolved is where a row lands when that chase ends without an answer — requeue does not apply to it.

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.
Both columns are delivered in the failed-row CSV export, which is the surface built for reading them: the export puts them next to the original record so the offending column is obvious. They are not fields on the GET /flows/jobs JSON — a rejection reason is provider prose about a specific contact, and the export is where that belongs.

Reading outcomes back

After a large import, the default jobs feed is mostly accepted rows. GET /flows/jobs returns every job in the account newest-first, so a freshly finished million-row upload dominates the first page and everything else is pages behind it.Filter rather than page: add status= for the outcome you care about, batch_id= for one run, or record_set_id= for one upload’s own view. Scoping by record_set_id also includes that upload’s staged rows, which the account-wide feed hides by default.

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:
That invariant is the point of the design. Every row is in exactly one bucket at every instant, so a row can never quietly disappear between “sent” and “answered” — if a count looks wrong, the arithmetic says so immediately rather than after a reconciliation.
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:
Zero accepted is failed, not completed_with_errors. A batch where nothing at all was taken into the list is almost never a data-quality problem spread across your records — it is one structural mistake: the wrong list, a bad column mapping, a file whose header did not match. That has to read loudly on a dashboard and in a stream, so it gets the loud status. completed_with_errors keeps its plain meaning: this mostly worked, and here are the rows that did not.
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 read counters.unprocessed a 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 accepted and rejected keep 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.
Cancel is a one-way door for future sends: held work on a cancelled batch cannot be requeued afterwards.

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 as unresolved 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.