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

# Export failed rows

> Fix and resubmit — list the rows your platform refused, export them as a CSV, correct the offending column, and re-upload the corrected file

A big upload rarely fails outright. It comes back with most rows accepted and a slice refused — a mistyped phone column, a few rows the provider could not parse, some duplicates. This guide is the loop for that slice: **find it, download it, fix it, send it back.**

The file you get is built for exactly that. Each row carries the record **you originally uploaded**, unchanged, with the outcome columns appended — so you correct the bad column in place and re-upload the same file as a new [record set](/guides/bulk-ingestion).

## Step 1 — Look at what was refused

Start with the jobs feed, scoped to your batch and the outcome you care about:

```bash theme={null}
curl "https://api.getdialed.ai/v1/flows/jobs?batch_id=batch_55667788&status=rejected&limit=50" \
  -H "X-API-Key: $API_KEY"
```

`status` accepts any [import outcome](/concepts/import-outcomes#the-four-outcomes) — `rejected` for rows the provider refused, `unresolved` for rows whose fate could not be determined, `unprocessed` for rows a cancellation stopped before they were sent.

This is the quick look: page envelope, newest first, good for a screen. For the whole set — and for the provider's own reason text, which the JSON feed does not carry — request the export.

## Step 2 — Request the export

`POST /flows/jobs/export` takes the same filters as the listing. At least one is required.

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.getdialed.ai/v1/flows/jobs/export \
    -H "X-API-Key: $API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "batch_id": "batch_55667788",
      "status": "rejected"
    }'
  ```

  ```python Python theme={null}
  res = client.post(
      "flows/jobs/export",
      json={"batch_id": "batch_55667788", "status": "rejected"},
  )
  export_id = res.json()["id"]
  ```
</CodeGroup>

The `202` comes back immediately — nothing has been generated yet:

```json theme={null}
{
  "id": "exp_0123456789abcdef",
  "status": "pending",
  "row_count": null,
  "error_reason": null,
  "created_at": "2026-08-12T12:00:00Z",
  "expires_at": "2026-08-19T12:00:00Z",
  "download_url": null,
  "download_url_expires_at": null
}
```

| Filter          | Meaning                                     |
| --------------- | ------------------------------------------- |
| `batch_id`      | Only rows from this batch.                  |
| `status`        | Only rows in this outcome, e.g. `rejected`. |
| `record_set_id` | Only rows staged from this upload.          |

| Status | Meaning                                                                                                 |
| ------ | ------------------------------------------------------------------------------------------------------- |
| `202`  | Accepted — generation has started.                                                                      |
| `409`  | An export is already pending or generating for this account. One at a time.                             |
| `422`  | No filter supplied. An unfiltered export is every job in the account, which is not a report — scope it. |
| `503`  | The export surface is not configured on this deployment. Nothing was recorded.                          |

<Warning>
  **One export at a time per account.** A second request while one is still generating returns `409` — wait for the first to finish (or expire) rather than retrying in a loop. Generation is intentionally serialized: an export reads every matching row, and several at once would be a self-inflicted load spike.
</Warning>

## Step 3 — Poll until it is ready

```bash theme={null}
curl "https://api.getdialed.ai/v1/flows/jobs/exports/exp_0123456789abcdef" \
  -H "X-API-Key: $API_KEY"
```

`status` moves `pending` → `running` → `ready`. While it is generating, `download_url` is `null` — that is a normal poll, not an error. Once it is `ready`:

```json theme={null}
{
  "id": "exp_0123456789abcdef",
  "status": "ready",
  "row_count": 1284,
  "error_reason": null,
  "created_at": "2026-08-12T12:00:00Z",
  "expires_at": "2026-08-19T12:00:00Z",
  "download_url": "https://…",
  "download_url_expires_at": "2026-08-12T12:15:00Z"
}
```

| Status | Meaning                                                                                          |
| ------ | ------------------------------------------------------------------------------------------------ |
| `200`  | The export record, with a `download_url` once it is `ready`.                                     |
| `404`  | No such export for your organization — another tenant's export answers identically, never `403`. |
| `410`  | The file passed its deletion deadline and is gone. Request a new export.                         |

A `failed` export says why in `error_reason`: `row_cap_exceeded` when the filters matched more rows than one export may contain (the cap is 1,000,000 — a whole bulk batch's rejected rows always fit), `source_unavailable` when the underlying records are no longer retained, or `generation_failed`.

<CodeGroup>
  ```python Python theme={null}
  import time

  def wait_for_export(client, export_id, timeout_s=900):
      """Poll an export until it is ready; returns its download URL."""
      deadline = time.time() + timeout_s
      while time.time() < deadline:
          body = client.get(f"flows/jobs/exports/{export_id}").json()
          if body["status"] == "ready":
              return body["download_url"]
          if body["status"] in ("failed", "expired"):
              raise RuntimeError(f"export {export_id}: {body['error_reason']}")
          time.sleep(5)
      raise TimeoutError(f"export {export_id} not ready after {timeout_s}s")
  ```
</CodeGroup>

## Step 4 — Download the file

`download_url` is a **short-lived signed URL** — about fifteen minutes — and it needs no API key of its own. Download it straight away:

```bash theme={null}
curl -o rejected.csv "$DOWNLOAD_URL"
```

<Note>
  **Never store or share the URL.** It is deliberately short-lived and freely re-fetchable: read the export again at any time for a fresh one, for as long as the export lives. Anything that would need a long-lived link should re-read the export instead.
</Note>

Two lifetimes are in play and they are different:

| What               | How long                                                                                            |
| ------------------ | --------------------------------------------------------------------------------------------------- |
| The download URL   | \~15 minutes, re-fetchable as often as you like                                                     |
| The generated file | **7 days** from when the export was requested, then deleted — `expires_at` reports the exact moment |

After the file is deleted, request the export again and it is regenerated from the underlying rows — for as long as those rows are inside your account's retention window.

## Step 5 — Read the file

Every row is the record you uploaded, column for column, followed by the outcome columns:

| Column                       | What it carries                                                                                                          |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `job_id`                     | The row's identifier in GetDialed.                                                                                       |
| `status`                     | Its [import outcome](/concepts/import-outcomes#the-four-outcomes) — `rejected`, `unresolved`, `unprocessed`, `accepted`. |
| `reject_class`               | The normalized reason category — `parse_error`, `duplicate_key`, `missing_key`, and so on.                               |
| `trouble_message`            | Your platform's **own words**, verbatim.                                                                                 |
| `import_identifier`          | The provider-side upload this row's outcome came from.                                                                   |
| `created_at`, `completed_at` | When the row was created and when it settled.                                                                            |

```csv theme={null}
first_name,last_name,phone,_extra_json,job_id,status,reject_class,trouble_message,import_identifier,created_at,completed_at
Alice,Nguyen,555123456,{},job_99aabbcc,rejected,parse_error,"Invalid number: 555123456",imp_7781,2026-08-12T11:58:02Z,2026-08-12T12:01:44Z
Bob,Marsh,+15555550101,{},job_99aabbcd,rejected,duplicate_key,"Record already in list",imp_7781,2026-08-12T11:58:02Z,2026-08-12T12:01:44Z
```

<Note>
  **Two header details worth knowing.** The record columns come from the rows in the export itself, so a column that only appears on later rows — or one whose name collides with an outcome column, such as your own `status` field — is preserved in the `_extra_json` column rather than dropped or duplicated. And group by `reject_class` before reading prose: it is the stable, countable field, while `trouble_message` is written for a person.
</Note>

<Warning>
  **This file is contact data.** Every cell is a record you uploaded, and `trouble_message` frequently repeats the offending phone number inside the provider's own sentence. Treat the download exactly as you treat the source file.
</Warning>

## Step 6 — Fix and re-upload

Correct the offending column in the CSV, drop the outcome columns, and send it back through the normal [bulk ingestion](/guides/bulk-ingestion) path: create a record set, push the corrected rows as chunks, seal, and trigger.

```python theme={null}
import csv

OUTCOME_COLUMNS = {
    "_extra_json", "job_id", "status", "reject_class",
    "trouble_message", "import_identifier", "created_at", "completed_at",
}

def corrected_rows(path):
    """Yield the original record from each exported row, outcome columns stripped."""
    with open(path, newline="") as fh:
        for row in csv.DictReader(fh):
            record = {k: v for k, v in row.items() if k not in OUTCOME_COLUMNS}
            record["phone"] = normalize_phone(record["phone"])  # your fix
            yield record
```

The corrected upload is an ordinary new batch with its own [outcomes](/concepts/import-outcomes) — so the same loop applies again if anything is still refused.

<Note>
  **Re-uploading corrected rows is a fresh send, and that is the point.** Only rows you actually fix should go back. Rows that came back `unresolved` are a different decision: nobody knows whether they reached your platform the first time, so re-uploading them accepts a possible duplicate contact. Decide that deliberately.
</Note>

## Next steps

<CardGroup cols={2}>
  <Card title="Import outcomes" icon="list-check" href="/concepts/import-outcomes">
    What each outcome means, and how the counters roll up into a batch's terminal state.
  </Card>

  <Card title="Bulk ingestion" icon="upload" href="/guides/bulk-ingestion">
    The upload path for the corrected file — manifest, chunks, seal, trigger.
  </Card>
</CardGroup>
