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

# Uploads

> Endpoints for creating, listing, and downloading phone-number uploads.

The Uploads API is the heart of OptimalDial. You submit a list of phone numbers — as CSV or JSON — and receive a typed `Upload` resource that you can poll for status or pull processed results from.

## The Upload object

Every endpoint in this section returns or includes an `Upload`. Most fields are populated incrementally as the upload moves through its lifecycle.

| Field                          | Type                            | Notes                                                                                                                                               |
| ------------------------------ | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                           | string (UUID)                   | Stable identifier.                                                                                                                                  |
| `user_id`                      | string (UUID)                   | The user who owns the API key that created this upload (or the web user who uploaded).                                                              |
| `organization_id`              | string (UUID)                   | The organization the API key is scoped to.                                                                                                          |
| `original_filename`            | string                          | The filename you submitted, or one we synthesized for JSON-mode uploads (`api_upload_<hex>.csv`).                                                   |
| `storage_path`                 | string                          | Internal storage key. Use the [download endpoints](#download-endpoints) — never construct URLs from this directly.                                  |
| `file_size_bytes`              | integer \| null                 | Size of the original file.                                                                                                                          |
| `status`                       | enum                            | See [statuses](#upload-statuses) below.                                                                                                             |
| `valid_row_count`              | integer \| null                 | Contact rows that will be processed.                                                                                                                |
| `valid_number_count`           | integer \| null                 | Phone numbers that passed validation and were charged. Equals `valid_row_count` unless you mapped [several phone columns](#multiple-phone-columns). |
| `invalid_row_count`            | integer \| null                 | Numbers we couldn't parse or that aren't US/Canadian.                                                                                               |
| `credits_required`             | integer \| null                 | Credits for this upload: `valid_number_count` for `standard`, or `ceil(valid_number_count × 1.5)` for `max`.                                        |
| `credits_charged`              | integer \| null                 | What was actually deducted (will equal `credits_required` once the upload reaches `ready_for_processing`).                                          |
| `credits_refunded`             | integer \| null                 | Set when OptimalDial refunds an upload — e.g. we couldn't process it.                                                                               |
| `source`                       | `"web"` \| `"api"`              | Which surface created this upload. API-created uploads always have `"api"`.                                                                         |
| `api_key_id`                   | string (UUID) \| null           | Set when `source == "api"`.                                                                                                                         |
| `skip_mobile_lookup`           | boolean \| null                 | Mirrors the request flag.                                                                                                                           |
| `validation_tier`              | `"standard"` \| `"max"` \| null | Validation tier used for this upload.                                                                                                               |
| `identity_enabled`             | boolean \| null                 | Whether [OptimalDial Identity](#optimaldial-identity) was requested.                                                                                |
| `result_summary`               | object \| null                  | Distribution of the results once complete — see below.                                                                                              |
| `export_count`                 | integer                         | How many times results have been downloaded, across the app and this API.                                                                           |
| `last_exported_at`             | string (ISO 8601) \| null       | When results were last downloaded.                                                                                                                  |
| `processed_available`          | boolean                         | `true` once the processed file can be downloaded.                                                                                                   |
| `processed_filtered_available` | boolean                         | `true` once the legacy filtered download will succeed.                                                                                              |
| `error_message`                | string \| null                  | Populated when `status == "failed"`.                                                                                                                |
| `created_at`                   | string (ISO 8601)               | UTC timestamp.                                                                                                                                      |
| `updated_at`                   | string (ISO 8601)               | UTC timestamp of the last status change.                                                                                                            |

### result\_summary

Populated once the upload is `completed`. Lets you see the shape of a list
without downloading it.

```json theme={null}
{
  "format": "identity",
  "total_rows": 2374,
  "status_counts": {
    "1 - Likely Right-Party Answer": 303,
    "5 - Likely Right-Party Voicemail": 891,
    "8 - Likely Wrong Number": 463
  },
  "identity_counts": { "Very High": 344, "High": 846, "Low": 463 },
  "has_answer_intent_column": true,
  "has_identity_column": true
}
```

`format` is `"identity"` when the list was processed with name columns mapped and
`"answer_intent"` otherwise. It tells you which vocabulary `status_counts` keys —
and which values `statuses` accepts on the download endpoint.

### Upload statuses

| Status                 | Meaning                                                                               |
| ---------------------- | ------------------------------------------------------------------------------------- |
| `ready_for_processing` | The upload was accepted and credits were charged. Returned by `POST /api/v1/uploads`. |
| `processing`           | We're working on it.                                                                  |
| `completed`            | Processing finished. Use the [download endpoints](#download-endpoints).               |
| `failed`               | Processing failed; check `error_message`.                                             |
| `cancelled`            | Legacy. Retained so historical uploads still parse; no new upload enters this status. |

The `pending_mapping`, `validating`, `validation_failed`, and `awaiting_confirmation` statuses are only reachable from the in-app upload flow; API-created uploads jump straight to `ready_for_processing`.

***

## OptimalDial Identity

<Note>
  **OptimalDial Identity is in public beta.** It is available to every account and
  the output format is stable, but we're still tuning the thresholds against real
  call outcomes. We'd genuinely like your feedback — especially how the identity
  bands compare to what your reps report from the field. Get in touch at
  [support@optimaldial.com](mailto:support@optimaldial.com).
</Note>

Every number gets scored on two independent questions:

* **Answer Intent** — will this number pick up?
* **Identity** — is it actually your contact?

Both matter, because they pull in opposite directions. Roughly **one in five
numbers on a purchased list doesn't belong to the contact named on that row**, and
wrong numbers answer the phone *more* often than right ones — a stranger has no
reason to screen an unknown caller, while your actual prospect screens everything.
Optimizing for connect rate alone therefore selects for wrong numbers.

OptimalDial Identity needs one extra input: the contact's name, mapped at upload
time via `full_name_column` or `first_name_column` + `last_name_column`. Without a
name, identity is unavailable for that list.

### OptimalDial\_Status — the nine tiers

When identity is enabled, `OptimalDial_Status` holds the two axes crossed into one
value. Take the Answer Intent value; if identity is Medium or better, insert
"Right-Party"; if identity is Low, replace it with "Likely Wrong Number".

| Tier                                    | What it means                                                                             |
| --------------------------------------- | ----------------------------------------------------------------------------------------- |
| `1 - Likely Right-Party Answer`         | Likely to answer, and likely to be your contact.                                          |
| `2 - Likely Answer`                     | Likely to answer. We can't say whose number it is.                                        |
| `3 - Likely Right-Party Call Screening` | Likely to reach iOS, Android, or Samsung call screening, and likely to be your contact.   |
| `4 - Likely Call Screening`             | Likely to reach iOS, Android, or Samsung call screening. We can't say whose number it is. |
| `5 - Likely Right-Party Voicemail`      | Likely to go to voicemail, and likely to be your contact.                                 |
| `6 - Likely Voicemail`                  | Likely to go to voicemail. We can't say whose number it is.                               |
| `7 - Non-Mobile`                        | A landline or VoIP number. Carries no identity signal.                                    |
| `8 - Likely Wrong Number`               | Unlikely to be your contact.                                                              |
| `Unknown`                               | No result available for this number.                                                      |

Each description states what the number is likely to do, then who it's likely to
belong to — in that order. Every tier except `7 - Non-Mobile` is a prediction,
which is why they all carry "Likely"; line type is a carrier attribute, so tier 7
states it outright. Ordering is answer-intent-major, and odd tiers are the
identity-backed ones.
The numeric prefix is there so the column sorts correctly as plain text in Sheets,
Excel, and dialer imports.

Treat this as a fixed vocabulary produced upstream — filter or sort on the numeric
prefix rather than re-deriving the tier from the two axes, which will drift.

**Without identity**, `OptimalDial_Status` holds the Answer Intent value instead
(`Likely Answer`, `Likely Call Screening`, `Likely Voicemail`, `Non-Mobile`,
`Unknown`), exactly as it always has.

### The OptimalDial\_Identity column

Opt in via `include_identity` on the download endpoint. Values are `Very High`,
`High`, `Medium`, `Low`, and `Unknown`.

<Warning>
  `Unknown` means we found no identity signal either way — it is **not** the same as
  "safe". Measured against real call dispositions it sits between `Medium` and
  `Low`. Don't treat it as a clean number, but don't filter it out by default
  either: dropping it costs about as many good conversations as it avoids bad ones.
</Warning>

## Validation tiers

Each upload runs at one of two tiers. Pass `validation_tier` on [Create an upload](#create-an-upload); it defaults to `standard`.

| Tier       | Credits per number | Charged                          | Notes                                                                 |
| ---------- | ------------------ | -------------------------------- | --------------------------------------------------------------------- |
| `standard` | 1                  | `valid_number_count`             | Optimized for speed and volume.                                       |
| `max`      | 1.5                | `ceil(valid_number_count × 1.5)` | More thorough; identifies more likely answerers over a longer window. |

Credits are always whole numbers — `max` rounds the total up, so a 1,001-number list at `max` is charged `1502` credits. Check `credits_charged` on the response to see the exact amount deducted.

***

## Multiple phone columns

Most CRM exports carry more than one number per contact — a mobile, a direct
line, a switchboard. Map up to **10** of them in a single upload with
`phone_columns` and every number is validated.

**Billing is per number.** A contact with a number in two mapped columns costs
2 credits at `standard`, or `ceil(2 × 1.5) = 3` at `max` — the same as two
single-number contacts would. The same number listed in more than one column is
de-duplicated and charged once, and so is a number that already appeared on an
earlier contact. `valid_number_count` on the response is what you were charged
for; `valid_row_count` is how many contacts those numbers belong to.

```bash theme={null}
curl -X POST https://api.optimaldial.com/api/v1/uploads \
  -H "Authorization: Bearer $OPTIMALDIAL_API_KEY" \
  -F "file=@contacts.csv" \
  -F "phone_columns=Mobile,Direct" \
  -F "validation_tier=max"
```

In JSON mode it's a real array:

```json theme={null}
{
  "contacts": [
    { "name": "Jane Doe", "mobile": "+15551234567", "direct": "+15559876543" }
  ],
  "phone_columns": ["mobile", "direct"],
  "full_name_column": "name"
}
```

### What comes back

The processed file keeps **one row per contact** — your file's shape is
unchanged. Each mapped column gets its own result columns, prefixed with that
column's header:

```
First Name,Last Name,Mobile,Direct,Mobile_OptimalDial_Status,Direct_OptimalDial_Status
Jane,Doe,5550100,5550199,Likely Answer,Likely Voicemail
John,Smith,5550200,,Likely Voicemail,
```

A blank status cell means that column held no number for that contact. With
[OptimalDial Identity](#optimaldial-identity) enabled, each column also gets its
own `{column}_OptimalDial_Answer_Intent` and `{column}_OptimalDial_Identity`.

Filtering a [processed download](#filtering-the-processed-file) by status keeps a
contact when **any** of their numbers matches, and sorting ranks each contact by
their best number.

<Note>
  Map a single column — with `phone_column`, or a one-entry `phone_columns` — and
  nothing changes: you get the plain `OptimalDial_Status` column exactly as before.
  The prefixed shape only appears when you map more than one.
</Note>

***

## Create an upload

```http theme={null}
POST /api/v1/uploads
```

Submit a list of phone numbers and start processing. There are two content types — choose whichever matches what you have on hand.

* **`multipart/form-data`** — upload a CSV file you already have on disk. Limit: **100 MB**.
* **`application/json`** — submit phone numbers (or contact objects) directly in the request body.

Both modes share these limits:

* Minimum **100 valid** phone numbers per upload. This counts numbers, not
  contacts — 50 contacts with two mapped phone columns each qualifies.
* Maximum **250,000** phone numbers per request.
* Numbers must resolve to a US or Canadian region (E.164-normalized server-side).

Calls are subject to [rate limits](/guides/errors-and-rate-limits#rate-limits) — 60 / minute per API key, 600 / minute per organization.

### CSV mode (multipart/form-data)

| Form field           | Type                    | Required | Description                                                                                                                                |
| -------------------- | ----------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `file`               | file                    | yes      | The CSV file. Must include a header row.                                                                                                   |
| `phone_column`       | string                  | one of   | Name of the column containing phone numbers.                                                                                               |
| `phone_columns`      | string                  | one of   | Comma-separated names of up to 10 phone columns, e.g. `mobile,direct` — see [Multiple phone columns](#multiple-phone-columns).             |
| `skip_mobile_lookup` | boolean                 | no       | Skip the mobile-vs-landline lookup step (defaults to `false`).                                                                             |
| `validation_tier`    | `"standard"` \| `"max"` | no       | Validation tier (defaults to `standard`). `max` is more thorough and costs 1.5 credits/number — see [Validation tiers](#validation-tiers). |
| `filename_override`  | string                  | no       | Use this as `original_filename` instead of the file's own name.                                                                            |
| `full_name_column`   | string                  | no       | Enables [OptimalDial Identity](#optimaldial-identity). Name of a column holding the contact's full name.                                   |
| `first_name_column`  | string                  | no       | Enables OptimalDial Identity when supplied **together with** `last_name_column`.                                                           |
| `last_name_column`   | string                  | no       | Enables OptimalDial Identity when supplied **together with** `first_name_column`.                                                          |

Supply either `phone_column` or `phone_columns`. Passing both is fine as long as
they agree — if `phone_column` isn't among `phone_columns`, the request returns
`400` rather than us guessing which one you meant to be charged for.

Supply either `full_name_column`, or both `first_name_column` and
`last_name_column`; supplying only one half of the pair returns `400`.

<Tabs>
  <Tab title="cURL">
    ```bash theme={null}
    curl -X POST https://api.optimaldial.com/api/v1/uploads \
      -H "Authorization: Bearer $OPTIMALDIAL_API_KEY" \
      -F "file=@./leads.csv" \
      -F "phone_column=phone"
    ```
  </Tab>

  <Tab title="Node.js">
    ```ts theme={null}
    import fs from "node:fs";

    const form = new FormData();
    form.append("file", new Blob([fs.readFileSync("./leads.csv")]), "leads.csv");
    form.append("phone_column", "phone");

    const res = await fetch("https://api.optimaldial.com/api/v1/uploads", {
      method: "POST",
      headers: { Authorization: `Bearer ${process.env.OPTIMALDIAL_API_KEY}` },
      body: form,
    });
    const upload = await res.json();
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    with open("leads.csv", "rb") as f:
        resp = requests.post(
            "https://api.optimaldial.com/api/v1/uploads",
            headers={"Authorization": f"Bearer {os.environ['OPTIMALDIAL_API_KEY']}"},
            files={"file": ("leads.csv", f, "text/csv")},
            data={"phone_column": "phone"},
        )
    upload = resp.json()
    ```
  </Tab>
</Tabs>

### JSON mode (application/json)

Send either a flat `phone_numbers` array **or** a list of `contacts` objects with a `phone_column` field name. You must include exactly one of the two.

| Field                | Type                    | Required                      | Description                                                                                                       |
| -------------------- | ----------------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `phone_numbers`      | string\[]               | one of                        | A flat array of phone numbers. The output CSV has one column called `phone`.                                      |
| `contacts`           | object\[]               | one of                        | A list of arbitrary objects that include a phone field. Other keys are preserved through to the processed file.   |
| `phone_column`       | string                  | required with `contacts`      | The key on each contact object that holds the phone number.                                                       |
| `phone_columns`      | string\[]               | alternative to `phone_column` | Up to 10 keys holding phone numbers — see [Multiple phone columns](#multiple-phone-columns). Requires `contacts`. |
| `filename`           | string                  | no                            | Used as `original_filename`. Defaults to `api_upload_<hex>.csv`.                                                  |
| `skip_mobile_lookup` | boolean                 | no                            | Skip the mobile-vs-landline lookup step.                                                                          |
| `validation_tier`    | `"standard"` \| `"max"` | no                            | Validation tier (defaults to `standard`). See [Validation tiers](#validation-tiers).                              |
| `full_name_column`   | string                  | no                            | Enables [OptimalDial Identity](#optimaldial-identity). The key holding the contact's full name.                   |
| `first_name_column`  | string                  | no                            | Enables OptimalDial Identity together with `last_name_column`.                                                    |
| `last_name_column`   | string                  | no                            | Enables OptimalDial Identity together with `first_name_column`.                                                   |

<Note>
  OptimalDial Identity needs a name to compare against, so it requires `contacts` —
  a flat `phone_numbers` array carries none. Passing a name column with
  `phone_numbers` returns `400`.
</Note>

<Tabs>
  <Tab title="cURL">
    ```bash theme={null}
    curl -X POST https://api.optimaldial.com/api/v1/uploads \
      -H "Authorization: Bearer $OPTIMALDIAL_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "filename": "april-cohort.csv",
        "contacts": [
          {"phone": "+15551234567", "first_name": "Alex", "list": "april"},
          {"phone": "+15551234568", "first_name": "Bea",  "list": "april"}
          /* …at least 100 entries… */
        ],
        "phone_column": "phone"
      }'
    ```
  </Tab>

  <Tab title="Node.js">
    ```ts theme={null}
    const contacts = leads.map(l => ({
      phone: l.phone,
      first_name: l.firstName,
      list: "april",
    }));

    const res = await fetch("https://api.optimaldial.com/api/v1/uploads", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.OPTIMALDIAL_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        filename: "april-cohort.csv",
        contacts,
        phone_column: "phone",
      }),
    });
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    contacts = [
        {"phone": l["phone"], "first_name": l["first_name"], "list": "april"}
        for l in leads
    ]
    resp = requests.post(
        "https://api.optimaldial.com/api/v1/uploads",
        headers={
            "Authorization": f"Bearer {os.environ['OPTIMALDIAL_API_KEY']}",
            "Content-Type": "application/json",
        },
        json={
            "filename": "april-cohort.csv",
            "contacts": contacts,
            "phone_column": "phone",
        },
    )
    ```
  </Tab>
</Tabs>

### Response

`200 OK` returns a fully populated [`Upload`](#the-upload-object) with `status: "ready_for_processing"`.

### Errors

| Status | When                                                                                                                                                                                                                                                         |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `400`  | Missing required field, malformed JSON, both/neither of `phone_numbers` and `contacts` provided, `phone_column` not present in the data, more than 10 `phone_columns`, a repeated or unknown column name, or `phone_column` disagreeing with `phone_columns` |
| `401`  | Missing, invalid, or revoked API key                                                                                                                                                                                                                         |
| `402`  | Insufficient credits to charge for the valid rows                                                                                                                                                                                                            |
| `403`  | Organization has no active subscription                                                                                                                                                                                                                      |
| `413`  | CSV exceeds 100 MB, or `phone_numbers`/`contacts` exceeds 250,000                                                                                                                                                                                            |
| `415`  | `Content-Type` is neither `multipart/form-data` nor `application/json`                                                                                                                                                                                       |
| `422`  | Fewer than 100 valid phone numbers after validation. Body: `{"detail": {"error":"min_contacts_required","min":100,"got":N,"message":...}}`                                                                                                                   |
| `429`  | Rate limit hit ([details](/guides/errors-and-rate-limits#rate-limits))                                                                                                                                                                                       |

***

## List uploads

```http theme={null}
GET /api/v1/uploads
```

Cursor-paginated list of all uploads in the organization, newest first. Returns both web-uploaded and API-uploaded items; filter on `source` client-side if you only care about one.

### Query parameters

| Param    | Type            | Default | Notes                                                                                                  |
| -------- | --------------- | ------- | ------------------------------------------------------------------------------------------------------ |
| `limit`  | integer (1–100) | 50      | Page size.                                                                                             |
| `cursor` | string          | —       | Pass back `next_cursor` from the previous response. Cursors are opaque; don't construct them yourself. |

<Tabs>
  <Tab title="cURL">
    ```bash theme={null}
    curl "https://api.optimaldial.com/api/v1/uploads?limit=20" \
      -H "Authorization: Bearer $OPTIMALDIAL_API_KEY"
    ```
  </Tab>

  <Tab title="Node.js">
    ```ts theme={null}
    async function* allUploads() {
      let cursor: string | null = null;
      do {
        const url = new URL("https://api.optimaldial.com/api/v1/uploads");
        url.searchParams.set("limit", "100");
        if (cursor) url.searchParams.set("cursor", cursor);

        const res = await fetch(url, {
          headers: { Authorization: `Bearer ${process.env.OPTIMALDIAL_API_KEY}` },
        });
        const page = await res.json();
        yield* page.data;
        cursor = page.next_cursor;
      } while (cursor);
    }
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    def all_uploads():
        cursor = None
        while True:
            params = {"limit": 100}
            if cursor:
                params["cursor"] = cursor
            resp = requests.get(
                "https://api.optimaldial.com/api/v1/uploads",
                headers={"Authorization": f"Bearer {os.environ['OPTIMALDIAL_API_KEY']}"},
                params=params,
            )
            page = resp.json()
            yield from page["data"]
            cursor = page.get("next_cursor")
            if not cursor:
                return
    ```
  </Tab>
</Tabs>

### Response

```json theme={null}
{
  "data": [ /* Upload, Upload, … */ ],
  "next_cursor": "2026-04-24T12:34:56.789+00:00|f3c9b8a7-1234-4abc-9def-0123456789ab"
}
```

`next_cursor` is `null` when there are no more pages. Order is `created_at DESC`, then `id DESC` to break ties.

***

## Retrieve an upload

```http theme={null}
GET /api/v1/uploads/{upload_id}
```

Returns the latest snapshot of one upload. Useful as a fallback if you missed a webhook delivery.

<Tabs>
  <Tab title="cURL">
    ```bash theme={null}
    curl "https://api.optimaldial.com/api/v1/uploads/$UPLOAD_ID" \
      -H "Authorization: Bearer $OPTIMALDIAL_API_KEY"
    ```
  </Tab>

  <Tab title="Node.js">
    ```ts theme={null}
    const res = await fetch(
      `https://api.optimaldial.com/api/v1/uploads/${uploadId}`,
      { headers: { Authorization: `Bearer ${process.env.OPTIMALDIAL_API_KEY}` } },
    );
    const upload = await res.json();
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    resp = requests.get(
        f"https://api.optimaldial.com/api/v1/uploads/{upload_id}",
        headers={"Authorization": f"Bearer {os.environ['OPTIMALDIAL_API_KEY']}"},
    )
    upload = resp.json()
    ```
  </Tab>
</Tabs>

### Errors

| Status | When                                                         |
| ------ | ------------------------------------------------------------ |
| `401`  | Auth failure                                                 |
| `404`  | Upload doesn't exist, or belongs to a different organization |

***

## Cancelling an upload

Uploads can't be cancelled. Once you submit a list we take ownership of the work and the credits are committed, so there is no `DELETE` on this resource — `DELETE /api/v1/uploads/{id}` now returns `410 Gone`.

If you submitted something in error, email [support@optimaldial.com](mailto:support@optimaldial.com) with the upload id. We'll refund the credits where the work hasn't been done.

***

## Download endpoints

```http theme={null}
GET /api/v1/uploads/{upload_id}/download/original
GET /api/v1/uploads/{upload_id}/download/processed
GET /api/v1/uploads/{upload_id}/download/processed-filtered
```

These return short-lived signed download URLs — the actual file lives in object storage and is fetched directly from there.

| Path                            | What it returns                                                                    | When it's ready                                   |
| ------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------- |
| `…/download/original`           | The CSV exactly as you submitted it (or the CSV we built from your JSON contacts). | Immediately after `POST /api/v1/uploads` returns. |
| `…/download/processed`          | The processed file, optionally filtered — see below.                               | After `status` becomes `completed`.               |
| `…/download/processed-filtered` | **Legacy.** Only the "likely answer" rows.                                         | After `status` becomes `completed`.               |

### Filtering the processed file

`…/download/processed` accepts optional query parameters. **With none of them the
response is exactly what it has always been** — a URL to the full processed file.
With any of them, we build the slice you asked for and point the URL at that
instead. The response body is the same shape either way.

| Parameter               | Type    | Default | Description                                                                                        |
| ----------------------- | ------- | ------- | -------------------------------------------------------------------------------------------------- |
| `statuses`              | string  | all     | Comma-separated statuses to keep. Accepts tier numbers (`1,3,5`) or full values (`Likely Answer`). |
| `include_answer_intent` | boolean | `false` | Add the `OptimalDial_Answer_Intent` column.                                                        |
| `include_identity`      | boolean | `false` | Add the `OptimalDial_Identity` column.                                                             |
| `sort_by_status`        | boolean | `false` | Order rows best-result-first. Off by default so your row order is preserved unless you ask.        |

```bash theme={null}
# Right-party tiers only, with both signal columns
curl "https://api.optimaldial.com/api/v1/uploads/$UPLOAD_ID/download/processed\
?statuses=1,3,5&include_answer_intent=true&include_identity=true" \
  -H "Authorization: Bearer $OPTIMALDIAL_API_KEY"
```

An unrecognized status returns `400` listing the valid values for that file.

<Warning>
  `…/download/processed-filtered` is **deprecated**. It still works — older uploads
  serve their stored file and newer ones have the equivalent generated on demand —
  but it can only ever return one fixed slice. Prefer
  `…/download/processed?statuses=…`, which returns any slice you want.
</Warning>

All three responses share the same shape:

```json theme={null}
{
  "download_url": "https://storage.example.com/...?signed=...",
  "expires_at": 1714003600.123
}
```

`expires_at` is a Unix timestamp; the signed URL is valid for **1 hour**. Always re-fetch from this endpoint rather than caching the URL — once it expires, the URL returns `403`.

<Tabs>
  <Tab title="cURL">
    ```bash theme={null}
    DL=$(curl -s "https://api.optimaldial.com/api/v1/uploads/$UPLOAD_ID/download/processed" \
           -H "Authorization: Bearer $OPTIMALDIAL_API_KEY")
    URL=$(echo "$DL" | jq -r .download_url)
    curl -L -o processed.csv "$URL"
    ```
  </Tab>

  <Tab title="Node.js">
    ```ts theme={null}
    const meta = await fetch(
      `https://api.optimaldial.com/api/v1/uploads/${uploadId}/download/processed`,
      { headers: { Authorization: `Bearer ${process.env.OPTIMALDIAL_API_KEY}` } },
    ).then(r => r.json());

    const file = await fetch(meta.download_url);
    await fs.promises.writeFile("processed.csv", Buffer.from(await file.arrayBuffer()));
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    meta = requests.get(
        f"https://api.optimaldial.com/api/v1/uploads/{upload_id}/download/processed",
        headers={"Authorization": f"Bearer {os.environ['OPTIMALDIAL_API_KEY']}"},
    ).json()

    with requests.get(meta["download_url"], stream=True) as r:
        r.raise_for_status()
        with open("processed.csv", "wb") as f:
            for chunk in r.iter_content(chunk_size=64 * 1024):
                f.write(chunk)
    ```
  </Tab>
</Tabs>

### Errors

| Status | When                                                                                                                              |
| ------ | --------------------------------------------------------------------------------------------------------------------------------- |
| `401`  | Auth failure                                                                                                                      |
| `404`  | Upload doesn't exist, or the requested file isn't ready yet (e.g. you asked for `processed` while `status` is still `processing`) |
