# Export Scores (/guides/export-scores)



<span className="eyebrow">
  Guides
</span>

The export endpoints let you pull your organization's scores in bulk as a CSV — every PDP, or every image, that Vizit has scored for you. Unlike the per-product ingestion endpoints, an export is a single asynchronous job: you `POST` to queue it, poll until it's `COMPLETED`, then download the file. This guide walks the full loop, the filters you can apply, retention and concurrency behavior, and the error catalog.

## Prerequisites [#prerequisites]

Before any of the requests below will succeed, make sure you have:

* **Client credentials** provisioned by Vizit for your organization and target environment (production, or `dev1`–`dev5`).
* **A Bearer token** obtained via the OAuth 2.0 client-credentials flow described in [Authentication](/standards/authentication). Cache it until expiry — tokens are valid for 24 hours.
* **A request ID strategy.** Send `X-Request-Id` on each request to correlate your logs with Vizit's. If you omit it, Vizit generates one and echoes it on the response.

All examples below assume `$BASE_URL` is the environment-appropriate hostname (e.g., `https://ext.vizit.com`) and `$TOKEN` is your current access token.

## The export lifecycle [#the-export-lifecycle]

An export moves through four states:

| `status`     | Meaning                                             | `download_url` |
| ------------ | --------------------------------------------------- | -------------- |
| `PENDING`    | Accepted and queued.                                | `null`         |
| `PROCESSING` | Vizit is building the CSV.                          | `null`         |
| `COMPLETED`  | The artifact is ready (within its 7-day retention). | populated      |
| `ERROR`      | The job failed terminally; see `error_code`.        | `null`         |

The flow is always **`POST` → poll `GET` → `GET` download**. Exports complete asynchronously, so clients poll the status URL until `status` reaches `COMPLETED` (or `ERROR`).

## 1. Create the export [#1-create-the-export]

```bash
curl -X POST "$BASE_URL/v1/exports" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "subject": "pdps",
    "filters": {
      "retailer_region": "amazon_us",
      "last_refreshed_after": "2026-01-01T00:00:00Z"
    }
  }'
```

Request body:

| Field                          | Type     | Required | Notes                                                                                                          |
| ------------------------------ | -------- | -------- | -------------------------------------------------------------------------------------------------------------- |
| `subject`                      | `string` | Yes      | `"pdps"` or `"images"`. Any other value returns `400 INVALID_SUBJECT`.                                         |
| `filters`                      | `object` | No       | All keys optional. Unknown keys return `400 INVALID_FILTER`.                                                   |
| `filters.portfolio_id`         | `uuid`   | No       | Restrict to one portfolio. A portfolio outside your org returns `404 PORTFOLIO_NOT_FOUND`.                     |
| `filters.retailer_region`      | `string` | No       | e.g. `amazon_us`. Unsupported values return `400 INVALID_RETAILER_REGION`.                                     |
| `filters.last_refreshed_after` | `string` | No       | RFC 3339 / ISO 8601 timestamp; includes rows refreshed since. Malformed values return `400 INVALID_TIMESTAMP`. |

Successful response (`202 Accepted`):

```json
{
  "export_id": "496c1503-2be9-4908-9c46-ca97612a62ea",
  "subject": "pdps",
  "status": "PENDING",
  "status_url": "/v1/exports/496c1503-2be9-4908-9c46-ca97612a62ea",
  "requested_at": "2026-06-03T19:41:10Z"
}
```

`status_url` is the path you poll next.

<Callout type="info" title="One export at a time per organization">
  An organization may have only one export `PENDING` or `PROCESSING` at a time. Submitting a second while one is in flight returns `409 EXPORT_CONCURRENCY_LIMIT`. Wait for the active export to finish (poll its status URL), then submit the next.
</Callout>

## 2. Poll for completion [#2-poll-for-completion]

```bash
curl "$BASE_URL/v1/exports/496c1503-2be9-4908-9c46-ca97612a62ea" \
  -H "Authorization: Bearer $TOKEN"
```

While the job runs, `status` is `PENDING` then `PROCESSING` and `download_url` is `null`. Once it lands:

```json
{
  "export_id": "496c1503-2be9-4908-9c46-ca97612a62ea",
  "subject": "pdps",
  "status": "COMPLETED",
  "requested_at": "2026-06-03T19:41:10Z",
  "completed_at": "2026-06-03T19:41:11Z",
  "row_count": 2291,
  "byte_size": 412204,
  "download_url": "/v1/exports/496c1503-2be9-4908-9c46-ca97612a62ea/download",
  "download_url_expires_at": "2026-06-10T19:41:11Z",
  "expires_at": "2026-06-10T19:41:11Z",
  "error_code": null
}
```

A polling interval of **5–10 seconds** is plenty — exports complete quickly relative to the scoring pipeline.

## 3. Download the artifact [#3-download-the-artifact]

```bash
curl "$BASE_URL/v1/exports/496c1503-2be9-4908-9c46-ca97612a62ea/download" \
  -H "Authorization: Bearer $TOKEN" \
  -o export.csv
```

The download endpoint streams `text/csv` with a `Content-Disposition: attachment` filename. It uses the **same Bearer token** as every other request — there's no presigned URL and no separate credential to manage.

The CSV always starts with a stable header row, then one row per record:

* **`subject: "pdps"`** — one row per PDP: identifiers (`pdp_id`, `portfolio_id`, `retailer_region`, `item_id`), `name`, `brand`, `category_id`, `category`, the listing scores and penalties, content-mix fields, `score_status`, and `created_at` / `updated_at` / `last_refreshed`.
* **`subject: "images"`** — one row per image: identifiers, `image_type` (`hero` / `carousel`), `image_url`, `position`, `vizit_score`, `raw_score`, plus hero-only columns (`hero_score`, `mobile_*`) that are blank for carousel rows, classification fields, and timestamps.

<Callout type="info" title="Retention: 7 days, then the file is gone">
  The artifact lives for **7 days** from completion (`expires_at` = `completed_at` + 7 days). The `download_url` stays valid for exactly that window — there is **no separate URL-level TTL**; the URL works as long as the artifact does. After expiry, `GET` on the status URL returns `status: COMPLETED` with a `null` `download_url` and `error_code: EXPORT_EXPIRED`, and the download endpoint returns `404 EXPORT_EXPIRED`. Re-submit the export to regenerate it.
</Callout>

## Error cheat sheet [#error-cheat-sheet]

Every non-`2xx` response follows the standard error shape: `error_code`, `detail`, `status_code`, `request_id`. Branch on `error_code`, never on `detail`.

### Validation errors (`400`) [#validation-errors-400]

| `error_code`              | What it means                                                    | Likely caller action                                     |
| ------------------------- | ---------------------------------------------------------------- | -------------------------------------------------------- |
| `INVALID_SUBJECT`         | `subject` isn't `pdps` or `images`.                              | Send a valid subject.                                    |
| `INVALID_FILTER`          | `filters` contains an unknown key.                               | Remove the unrecognized key.                             |
| `INVALID_RETAILER_REGION` | `filters.retailer_region` isn't a supported region.              | Use a documented retailer-region key (e.g. `amazon_us`). |
| `INVALID_TIMESTAMP`       | `filters.last_refreshed_after` isn't a valid RFC 3339 timestamp. | Send an ISO 8601 / RFC 3339 value.                       |

### Resource & state errors (`404` / `409`) [#resource--state-errors-404--409]

| `error_code`               | Status | When                                                          | What to do                                                                                              |
| -------------------------- | ------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `PORTFOLIO_NOT_FOUND`      | `404`  | `filters.portfolio_id` isn't a portfolio in your org.         | Use a portfolio your organization owns. (Cross-org IDs return 404, not 403, so existence isn't leaked.) |
| `EXPORT_CONCURRENCY_LIMIT` | `409`  | An export is already `PENDING`/`PROCESSING` for your org.     | Wait for it to finish, then submit again.                                                               |
| `EXPORT_NOT_FOUND`         | `404`  | The `export_id` doesn't exist in your org.                    | Check the id; cross-org ids are indistinguishable from missing.                                         |
| `EXPORT_NOT_READY`         | `404`  | You called `/download` before the export reached `COMPLETED`. | Poll the status URL until `status` is `COMPLETED`.                                                      |
| `EXPORT_EXPIRED`           | `404`  | The artifact is past its 7-day retention (or was purged).     | Re-submit the export to regenerate it.                                                                  |

### Terminal failure [#terminal-failure]

| `error_code`    | Where                                  | What to do                                                                                         |
| --------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `EXPORT_FAILED` | `status: ERROR` on the status response | The job hit an unrecoverable error. Re-submit; if it persists, contact Vizit with the `export_id`. |

### Auth and rate-limit errors [#auth-and-rate-limit-errors]

| Status                  | Action                                                                                        |
| ----------------------- | --------------------------------------------------------------------------------------------- |
| `401 Unauthorized`      | Request a fresh access token and retry once. See [Authentication](/standards/authentication). |
| `403 Forbidden`         | The token is valid but lacks permission. Contact Vizit.                                       |
| `429 Too Many Requests` | Back off for the `Retry-After` seconds, then resume.                                          |

## Rate limits and retries [#rate-limits-and-retries]

* Per-organization rate limits are enforced at the API gateway; the export concurrency cap (one in-flight job per org) is separate and enforced at submission time.
* Retry `429` and `5xx` responses with exponential backoff (start \~5s, double each retry, cap \~60s).
* Do **not** retry `4xx` responses other than `429` until you've fixed the request — the same input fails the same way.

## What's next [#whats-next]

* The full operation contracts are in the API Reference:
  * [`POST /v1/exports`](/api/exports/createExport)
  * [`GET /v1/exports/{export_id}`](/api/exports/getExport)
  * [`GET /v1/exports/{export_id}/download`](/api/exports/downloadExport)
* For credentials, token acquisition, and security guidance: [Authentication](/standards/authentication).
* For the cross-cutting error response format: [Errors](/standards/errors).
