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

# Bulk Catalog Export

> Export Snappy's full catalog asynchronously and maintain a local copy. Queue a job, poll for completion, download the result.

The Bulk Catalog Export pattern pulls Snappy's catalog into your own data store asynchronously. You queue a job, poll until it completes, then download a compressed JSON file. This is the right pattern when you need a local copy of the catalog — for blending with other data sources, powering a search index, or serving from your own infrastructure.

## When to use

Use bulk export when:

* You already have local catalog infrastructure (search index, database, product schema)
* You want to blend Snappy products with other sources
* Your rendering layer cannot tolerate per-request API latency

Use [Real-Time Catalog Access](/patterns/real-time-catalog-access) instead for simpler integrations that don't need a local copy.

## The three-phase lifecycle

Every export goes through **Queue → Poll → Download**:

1. **Queue** — POST to an export endpoint to start a job. You get back a job ID.
2. **Poll** — GET the job status until `status` is `completed` (or `failed`).
3. **Download** — fetch the file URL from the completed job response.

## Two export paths

### Filtered products export

Export products from the full catalog with optional filters.

```text theme={null}
POST /v3/products/exports
```

```json theme={null}
{
  "filter": {
    "collectionId": "col_abc123",
    "location": "US"
  }
}
```

### Collection-scoped export

Export all products in a specific collection.

```text theme={null}
POST /v3/collections/exports
```

```json theme={null}
{
  "collectionId": "col_abc123",
  "location": "US"
}
```

<Warning>
  Only **1 concurrent export job** is allowed at a time, shared across all export types. Creating another export while a job is active returns `409 Conflict`. Poll for completion before starting a new job.
</Warning>

## Poll and download

Both export types share the same poll endpoint:

```text theme={null}
GET /v3/exports/{exportId}
```

<CodeGroup>
  ```javascript JavaScript theme={null}
  async function waitForExport(exportId, { pollIntervalMs = 5000, timeoutMs = 300000 } = {}) {
    const start = Date.now();
    while (Date.now() - start < timeoutMs) {
      const res = await fetch(`${SNAPPY}/v3/exports/${exportId}`, { headers });
      const job = await res.json();
      if (job.status === "completed") return job;
      if (job.status === "failed") throw new Error(`Export failed: ${job.error}`);
      await new Promise(r => setTimeout(r, pollIntervalMs));
    }
    throw new Error("Export timed out");
  }

  async function downloadExport(job) {
    const res = await fetch(job.fileUrl); // pre-signed URL, no auth needed
    return res.json(); // { data: [Product, ...] }
  }

  // Full flow:
  async function runExport(collectionId, location = "US") {
    const startRes = await fetch(`${SNAPPY}/v3/products/exports`, {
      method: "POST",
      headers,
      body: JSON.stringify({ filter: { collectionId, location } }),
    });
    const { id: exportId } = await startRes.json();
    const job = await waitForExport(exportId);
    return downloadExport(job);
  }
  ```

  ```python Python theme={null}
  import time, requests

  def wait_for_export(export_id, poll_interval=5, timeout=300):
      start = time.time()
      while time.time() - start < timeout:
          res = requests.get(f"{SNAPPY}/v3/exports/{export_id}", headers=headers)
          job = res.json()
          if job["status"] == "completed":
              return job
          if job["status"] == "failed":
              raise RuntimeError(f"Export failed: {job.get('error')}")
          time.sleep(poll_interval)
      raise TimeoutError("Export timed out")

  def download_export(job):
      res = requests.get(job["fileUrl"])  # pre-signed URL, no auth needed
      return res.json()

  def run_export(collection_id, location="US"):
      start_res = requests.post(
          f"{SNAPPY}/v3/products/exports",
          headers=headers,
          json={"filter": {"collectionId": collection_id, "location": location}},
      )
      export_id = start_res.json()["id"]
      job = wait_for_export(export_id)
      return download_export(job)
  ```
</CodeGroup>

## Keeping your local copy fresh

After the initial bulk export, use the `stock-availability-updates` webhook to receive incremental updates instead of re-exporting the full catalog on a timer.

```text theme={null}
stock-availability-updates
```

Subscribe to this event in your [Webhook](/pages/overview-and-setup) configuration. Each event carries the products whose availability or pricing changed. Update only those records in your local store.

<Tip>
  Re-run a full bulk export periodically (daily or weekly) as a safety net, and rely on webhooks for real-time freshness in between. This covers any webhook delivery failures.
</Tip>

## Common pitfalls

| Pitfall                                             | Fix                                                                             |
| :-------------------------------------------------- | :------------------------------------------------------------------------------ |
| Starting a second export before the first completes | Check for active jobs before queuing; handle `409 Conflict`                     |
| Polling too aggressively                            | Use 5-second intervals minimum; the export rarely completes in under 30 seconds |
| Ignoring the file URL expiry                        | Download the file immediately after the job completes; pre-signed URLs expire   |
| Not subscribing to stock webhooks                   | Your local copy will drift; pair bulk export with `stock-availability-updates`  |

## Related

* [Real-Time Catalog Access](/patterns/real-time-catalog-access) — simpler alternative without local infrastructure
* [Swag Products Access](/patterns/swag-products-access) — swag-specific catalog access
