> For the complete documentation index, see [llms.txt](https://docs.violet.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.violet.io/api-reference/catalog/offers/bulk-offer-export.md).

# Bulk Offer Export

Asynchronous, unpaginated access to a merchant's offers.

This is the counterpart to [Get All Merchant Offers](/api-reference/catalog/offers/get-all-merchant-offers.md) (`GET /v1/catalog/offers/merchants/{merchant_id}`) for callers who want the whole catalog. You submit an export, poll it, then download a file. A paginated walk over a catalog large enough to take minutes to read can miss or duplicate offers that change while you are walking it; an export reads a single consistent snapshot instead.

## When to use this

**Use bulk export when:**

* You want a merchant's entire catalog, or a large date-bounded slice of it.
* You are seeding a new integration, rebuilding an index, or running a nightly reconciliation.
* Consistency across the whole read matters. A paginated walk can miss or duplicate offers that change while it runs.

**Use the** [**paginated endpoint**](/api-reference/catalog/offers/get-all-merchant-offers.md) **when:**

* You need results in seconds. An export takes minutes; see [Limits](#limits).
* You want a small, targeted subset: one page, one search, or a handful of offers.
* You are serving a live UI. This is a batch tool.

## Quick start

**1. Submit.**

```bash
curl -X POST \
  'https://api.violet.io/v1/catalog/offers/merchants/10174/exports' \
  -H 'X-Violet-Token: <token>' \
  -H 'X-Violet-App-Secret: <secret>' \
  -H 'X-Violet-App-Id: <app id>' \
  -H 'Content-Type: application/json' \
  -d '{}'
```

`202 Accepted`, with the export. Keep the `id`:

```json
{
  "id": "0ab05c57-502e-4009-abe3-f3207a201ae2",
  "merchant_id": 10174,
  "app_id": 10004,
  "status": "CREATED",
  "date_created": "2026-08-27T04:50:48+0000"
}
```

**2. Poll** until `status` is terminal.

```bash
curl 'https://api.violet.io/v1/catalog/offers/merchants/10174/exports/0ab05c57-502e-4009-abe3-f3207a201ae2' \
  -H 'X-Violet-Token: <token>' \
  -H 'X-Violet-App-Secret: <secret>' \
  -H 'X-Violet-App-Id: <app id>'
```

**3. Download.** A `COMPLETED` export carries a `url`, freshly signed on every read and valid for 30 minutes.

```bash
curl -L -o offers.jsonl.gz '<url from the response>'
gunzip -t offers.jsonl.gz && gzcat offers.jsonl.gz | wc -l   # expect object_count lines
```

## Authentication

The same three headers as every other catalog endpoint:

| Header                | Required |
| --------------------- | -------- |
| `X-Violet-Token`      | yes      |
| `X-Violet-App-Secret` | yes      |
| `X-Violet-App-Id`     | yes      |

Your app must have catalog access to the merchant you are exporting. The merchant must have connected their catalog to your app. Without that connection you get `403 insufficient_permissions`, the same as the paginated offers endpoint. Admin and merchant-user tokens are authorized on identity instead and do not need an app connection.

{% hint style="info" %}
A `403` here means the app/merchant connection is missing, not that your token is bad. Check the connection before regenerating credentials.
{% endhint %}

## Endpoints

All four are under `/v1/catalog/`.

### Submit an export

Body is a [submit parameters](#submit-parameters) object; send `{}` for a full export.

| Status | Meaning                                                                                                                                                                       |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `202`  | Accepted. Body is the export; `id` is your handle for everything else.                                                                                                        |
| `403`  | Your app does not have catalog access to this merchant.                                                                                                                       |
| `409`  | You already have an export in flight for this merchant. **The body is the existing export.** Poll that one instead of retrying.                                               |
| `429`  | A per-app or platform-wide concurrency limit was hit (see [Limits](#limits)). Honour `Retry-After` (60 seconds). A `409` for the same merchant takes precedence over a `429`. |
| `503`  | Export is temporarily unavailable. Retry with backoff.                                                                                                                        |

{% openapi src="/files/bEbkEBPLf0IRBDBqq1jM" path="/catalog/offers/merchants/{merchant\_id}/exports" method="post" expanded="true" %}
[catalog-service.yaml](https://3937396275-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F8lXIp71Ct5qCUhXjko2q%2Fuploads%2Fgit-blob-c6e2fedb8258cc2d1a44f80a88a6690e23686c69%2Fcatalog-service.yaml?alt=media\&token=52220ffd-f972-413a-9745-15951e29ff17)
{% endopenapi %}

### Get an export

This is the endpoint you poll, and the only place `url` is issued.

`404` if the id is unknown, or if it belongs to another app. Both cases return the same response, so an app cannot probe for exports it does not own.

{% openapi src="/files/bEbkEBPLf0IRBDBqq1jM" path="/catalog/offers/merchants/{merchant\_id}/exports/{export\_id}" method="get" expanded="true" %}
[catalog-service.yaml](https://3937396275-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F8lXIp71Ct5qCUhXjko2q%2Fuploads%2Fgit-blob-c6e2fedb8258cc2d1a44f80a88a6690e23686c69%2Fcatalog-service.yaml?alt=media\&token=52220ffd-f972-413a-9745-15951e29ff17)
{% endopenapi %}

### List your exports

Newest first, scoped to your app. Filter with `status` (one [status](#statuses-and-error-codes); invalid values are rejected) and page with `size` (default `25`, capped at 100). **List entries carry no `url`.** Fetch the individual export to get one.

{% openapi src="/files/bEbkEBPLf0IRBDBqq1jM" path="/catalog/offers/merchants/{merchant\_id}/exports" method="get" expanded="true" %}
[catalog-service.yaml](https://3937396275-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F8lXIp71Ct5qCUhXjko2q%2Fuploads%2Fgit-blob-c6e2fedb8258cc2d1a44f80a88a6690e23686c69%2Fcatalog-service.yaml?alt=media\&token=52220ffd-f972-413a-9745-15951e29ff17)
{% endopenapi %}

### Cancel an export

Moves the export to `CANCELING`; it reaches `CANCELED` once the run actually stops. While an export sits in `CANCELING` it still holds the merchant's one in-flight slot, so a new submit for the same merchant returns `409` until it becomes `CANCELED`. Cancelling an already-terminal export is a no-op and returns it unchanged, so a retried cancel is safe.

{% openapi src="/files/bEbkEBPLf0IRBDBqq1jM" path="/catalog/offers/merchants/{merchant\_id}/exports/{export\_id}" method="delete" expanded="true" %}
[catalog-service.yaml](https://3937396275-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F8lXIp71Ct5qCUhXjko2q%2Fuploads%2Fgit-blob-c6e2fedb8258cc2d1a44f80a88a6690e23686c69%2Fcatalog-service.yaml?alt=media\&token=52220ffd-f972-413a-9745-15951e29ff17)
{% endopenapi %}

## Submit parameters

All optional. Sending `{}` exports the merchant's full catalog, including unpublished offers (see [Published and unpublished offers](#published-and-unpublished-offers)). The request body accepts exactly the fields below; any other field is ignored.

| Field                    | Type                   | Default                                     | Notes                                                                                                   |
| ------------------------ | ---------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `date_last_modified:min` | ISO 8601 string        | none                                        | Only offers modified **at or after** this. Mirrors the paginated endpoint's parameter of the same name. |
| `date_last_modified:max` | ISO 8601 string        | export start                                | Only offers modified **at or before** this.                                                             |
| `include`                | comma-separated string | none                                        | Extra data per offer. Values: `metadata`, `collections`, `shipping`, `sku_metadata`.                    |
| `base_currency`          | string (ISO 4217)      | none (offers stay in their stored currency) | Convert offer prices to this currency. Not validated at submit; see below.                              |
| `format`                 | `JSONL_GZ` \| `JSONL`  | `JSONL_GZ`                                  | See [Result file format](#result-file-format).                                                          |
| `published_only`         | boolean                | `false`                                     | Restrict to published offers. **Unpublished offers are included by default** (see below).               |

Both date bounds are inclusive. Any field not in this table is ignored, so sending extra keys is harmless but has no effect.

{% hint style="warning" %}
`base_currency` is not validated at submit. If you pass a code the exchange-rate service does not recognise, the export does not fail; the affected offers come back in their original currency, unconverted. Validate your currency code before relying on the conversion, and check the `currency` on the offers you read back. Omitting `base_currency` leaves every offer in the currency it is stored in (there is no forced default on the export path).
{% endhint %}

### Published and unpublished offers

An export includes **both published and unpublished offers by default**. Set `published_only: true` to restrict it to published ones. The resolved value is stored on `parameters.published_only`, so the response always states which set you asked for.

{% hint style="warning" %}
This differs from the [paginated offers endpoint](/api-reference/catalog/offers/get-all-merchant-offers.md), where unpublished offers are excluded unless you ask for them with `include=unpublished`. On an export, `include=unpublished` has no effect. Publish filtering is controlled solely by `published_only`.

If you are switching an integration from pagination to export and your consumer only wants live offers, set `published_only: true` explicitly rather than relying on the default.
{% endhint %}

Example, everything modified since your last export, with metadata and shipping:

```json
{
  "date_last_modified:min": "2026-08-20T00:00:00+0000",
  "include": "metadata,shipping",
  "base_currency": "USD"
}
```

Dates use the same ISO 8601 format as the rest of the API: `2026-06-15T01:01:01+0000`. All timestamps the API accepts and returns are UTC (the `+0000` offset). A `min` later than `max` fails the export with `INVALID_DATE_RANGE`.

### A note on variant mapping

Variant mapping is **always on** for an export and is not a submit parameter. Exports are consumed as files, so each offer keeps the variant mapping that makes it self-describing. You do not need to request it, and there is no way to turn it off.

## The export object

Fields appear once they are known; a `CREATED` export has almost none of them.

### Identity

| Field                   | Notes                                          |
| ----------------------- | ---------------------------------------------- |
| `id`                    | Opaque UUID. Your handle for every other call. |
| `merchant_id`, `app_id` | Who this export belongs to.                    |
| `status`                | See [Statuses](#statuses-and-error-codes).     |
| `error_code`            | Set on `FAILED`.                               |
| `errors`                | Human-readable detail, when there is any.      |
| `parameters`            | What the export is running with.               |

### Progress

| Field           | Notes                                                                                                                    |
| --------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `total_count`   | Offers this export **will** write. Known before the first byte, so you can show real progress.                           |
| `object_count`  | Lines written so far. Updates while `RUNNING`.                                                                           |
| `offer_count`   | Root offers written. Equal to `object_count` today.                                                                      |
| `skipped_count` | Offers that were in the export at snapshot time but were deleted or became invisible before being written. Not an error. |

On a `COMPLETED` export, `object_count + skipped_count == total_count`. The service refuses to complete an export where that does not hold, so if you see it violated, treat it as a bug and report it.

### Result

| Field                  | Notes                                                                                   |
| ---------------------- | --------------------------------------------------------------------------------------- |
| `url`                  | Pre-signed download URL. **Only on `COMPLETED`**, valid 30 minutes, minted per request. |
| `file_size`            | Size of the result file in bytes.                                                       |
| `checksum`             | Whole-object checksum of the file.                                                      |
| `checksum_algorithm`   | e.g. `CRC32C`.                                                                          |
| `format`               | `JSONL_GZ` or `JSONL`.                                                                  |
| `partial_url`          | Download for a partial result, when a failed export still produced a usable file.       |
| `partial_max_offer_id` | Highest offer id guaranteed present in the partial.                                     |

### Timestamps

| Field                                                                  | Notes                                                                                             |
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `snapshot_at`                                                          | When this export's membership was decided.                                                        |
| `resume_from`                                                          | Pass this as `date_last_modified:min` next time. See [Incremental exports](#incremental-exports). |
| `date_created`, `date_started`, `date_completed`, `date_last_modified` | Lifecycle.                                                                                        |
| `date_expires`                                                         | After this, the file is deleted.                                                                  |

A completed export from a real run:

```json
{
  "id": "0ab05c57-502e-4009-abe3-f3207a201ae2",
  "merchant_id": 10174,
  "app_id": 10004,
  "status": "COMPLETED",
  "errors": [],
  "parameters": { "base_currency": "USD" },
  "total_count": 83146,
  "object_count": 83146,
  "offer_count": 83146,
  "skipped_count": 0,
  "file_size": 48151350,
  "checksum": "d1I7JQ==",
  "checksum_algorithm": "CRC32C",
  "format": "JSONL_GZ",
  "url": "https://...",
  "snapshot_at": "2026-08-27T04:50:48+0000",
  "resume_from": "2026-08-27T04:45:48+0000",
  "date_created": "2026-08-27T04:50:48+0000",
  "date_started": "2026-08-27T04:50:48+0000",
  "date_completed": "2026-08-27T04:52:55+0000",
  "date_expires": "2026-09-03T04:52:55+0000"
}
```

## Result file format

**JSON Lines** (`.jsonl`), gzipped by default. It is **not** a JSON array.

One offer per line, each line a complete JSON object, with no enclosing brackets and no commas between records:

```
{"id":123,"merchant_id":10174,"name":"Example Offer","skus":[...]}
{"id":124,"merchant_id":10174,"name":"Another Offer","skus":[...]}
```

Each line has the same shape as one element of the [paginated offers endpoint](/api-reference/catalog/offers/get-all-merchant-offers.md)'s response, with the same snake\_case field names, so an existing offer parser works unchanged. SKUs and variants stay **nested** inside their offer, unlike Shopify's Bulk Operations, which flattens children into separate lines joined by `__parentId`. You do not need to reassemble anything.

Records are written in **ascending offer-id order**, and that order holds across the whole file, including across the parts of a large multipart export.

Read it as a stream:

```python
import gzip, json

with gzip.open("offers.jsonl.gz", "rt", encoding="utf-8") as f:
    for line in f:
        offer = json.loads(line)
        ...
```

```bash
gzcat offers.jsonl.gz | jq -c 'select(.skus | length > 3) | {id, name}'
```

Why JSONL rather than a JSON array: gzip members concatenate, so a large export can be assembled from parts without ever holding the whole file in memory; a truncated file degrades to "the first N offers" rather than becoming unparseable; and streaming is the default rather than something you opt into with a special parser. See the [FAQ](#why-jsonl-and-not-a-json-array).

`format: "JSONL"` gives you the same file uncompressed. Only ask for it if your consumer genuinely cannot gunzip. Real catalogs run around 580 compressed bytes per offer, so uncompressed transfers are several times larger.

## Verifying your download

`checksum` is a **whole-object CRC32C** over the exact bytes of the file, base64-encoded. You can verify it directly against what you downloaded:

```python
import base64
import google_crc32c          # pip install google-crc32c

checksum = google_crc32c.Checksum()
with open("offers.jsonl.gz", "rb") as f:
    for chunk in iter(lambda: f.read(1 << 20), b""):
        checksum.update(chunk)

print(base64.b64encode(checksum.digest()).decode())   # compare to `checksum`
```

{% hint style="warning" %}
Use a **CRC32C** implementation, not CRC-32. Python's `zlib.crc32` and Java's `java.util.zip.CRC32` are the wrong algorithm and will never match; Java's `java.util.zip.CRC32C` is correct.

Checksum the **compressed** file as downloaded, not the decompressed contents.
{% endhint %}

{% hint style="info" %}
If you ever see a checksum ending in `-` followed by a number (e.g. `d1I7JQ==-2`), that is a composite S3 checksum and cannot be verified this way. It should not happen; please report it.
{% endhint %}

Also worth checking: `gunzip -t` succeeds, and the line count equals `object_count`.

## Incremental exports

Do **not** use `date_completed` or your own clock as the starting point for the next export. Use `resume_from`.

```
export 1: {}                                        -> resume_from = 2026-08-27T04:45:48+0000
export 2: {"date_last_modified:min": "2026-08-27T04:45:48+0000"}
```

`resume_from` sits **earlier** than `snapshot_at` on purpose. Offer ids are allocated when a row is inserted but only become visible when its transaction commits, so an offer can be committed with a timestamp slightly before an export that has already read past that point. This margin guarantees no change falls between two consecutive exports.

{% hint style="warning" %}
The consequence is that consecutive exports overlap, so **deduplicate by offer id**. Treat the export as at-least-once delivery. Upserting by `id` is the simplest correct consumer.
{% endhint %}

## Statuses and error codes

| Status      | Terminal | Meaning                                                      |
| ----------- | -------- | ------------------------------------------------------------ |
| `CREATED`   |          | Accepted, not yet started.                                   |
| `RUNNING`   |          | Working. `object_count` climbs.                              |
| `CANCELING` |          | Cancel requested, not yet stopped.                           |
| `COMPLETED` | ✓        | Done. `url` available.                                       |
| `FAILED`    | ✓        | See `error_code`. May carry `partial_url`.                   |
| `CANCELED`  | ✓        | Cancel took effect.                                          |
| `EXPIRED`   | ✓        | Reserved. Check `date_expires` rather than waiting for this. |

| `error_code`         | Meaning                                               | What to do                                                |
| -------------------- | ----------------------------------------------------- | --------------------------------------------------------- |
| `INVALID_DATE_RANGE` | `min` was after `max`, or a date could not be parsed. | Fix the parameters. Retrying as-is will not help.         |
| `TIMEOUT`            | The export stopped making progress and was reclaimed. | Retry. Consider narrowing with a date range.              |
| `CANCELED`           | You cancelled it.                                     | n/a                                                       |
| `INTERNAL_ERROR`     | Something went wrong on our side.                     | Retry once; if it recurs, report it with the export `id`. |

A `FAILED` export can still have produced a usable file. If `partial_url` is present, the file holds every offer up to `partial_max_offer_id`, complete and valid. There is no way to resume from that point: `partial_max_offer_id` is informational, not an input you can pass back. To get the offers beyond it, run a fresh export (bound it with `date_last_modified` to keep it small) and deduplicate by offer id.

## Limits

| Limit                                   | Value                  |
| --------------------------------------- | ---------------------- |
| Exports in flight per app, per merchant | 1                      |
| Exports in flight per app               | 3                      |
| Exports in flight across the platform   | 8 (shared)             |
| Result retention                        | 7 days from completion |
| Download URL lifetime                   | 30 minutes             |
| List page size                          | 100                    |

**Throughput is throttled.** Exports read the primary database alongside live API traffic, so they run at a paced rate rather than as fast as possible. Expect roughly:

| Catalog size   | Approximate duration |
| -------------- | -------------------- |
| 10,000 offers  | \~1 minute           |
| 83,000 offers  | \~7 minutes          |
| 500,000 offers | \~45 minutes         |

Exports also **share** a platform-wide budget, so an export running alongside others is slower than one running by itself. Treat these as order-of-magnitude figures, not an SLA, and poll rather than predict.

## Recommendations

* **Poll on a sensible interval.** Every 10 to 30 seconds is plenty. `total_count` and `object_count` give you real progress, so show a percentage rather than a spinner. A completion webhook is a planned fast-follow; until it ships, poll.
* **Never cache the `url`.** It expires in 30 minutes and is signed per request. Store the export `id`, and fetch a fresh URL when you are actually ready to download.
* **Download promptly, and check `date_expires`.** After it passes the file is gone. The export will still report `COMPLETED` and still hand you a URL, and that URL will fail, so treat `date_expires` as authoritative and re-export rather than retrying the download.
* **Handle `409` by polling, not retrying.** The body of a `409` is the export already in flight. Submitting again in a loop will just keep getting `409`s; the existing export is doing the work.
* **Deduplicate by offer id.** Required for incremental exports, which overlap by design, and harmless for full ones.
* **Stream, don't slurp.** A 500k-offer export is a few hundred MB compressed and several times that decompressed. Iterate line by line.
* **Ask for only what you need.** Every `include` value costs time and bytes on every offer in the export, so dropping one you do not consume is one of the few ways to control how long it takes.
* **Bound incremental exports on both ends** if you are backfilling. Several date-bounded exports run sequentially are easier to resume than one enormous export, and each gets a `resume_from` you can checkpoint.
* **Verify the checksum on anything you are going to act on destructively.** If you are replacing a catalog rather than merging into one, confirm the file is intact first.
* **Schedule off-peak** where you can. Exports are throttled to protect live traffic, which also means they finish faster when there is less of it.
* **Log the export `id`.** It is what we need to diagnose a problem on our side.

## FAQ

### Why is the result not a JSON array?

Because it is JSON Lines. See the next question.

### Why JSONL and not a JSON array?

Four reasons, and they all come from the file being potentially very large:

* **Concatenation is free.** Gzipped JSONL members join end to end into a valid stream, which is what lets a multi-gigabyte export be assembled from independently produced parts. A JSON array cannot be split or joined without rewriting its punctuation.
* **Truncation degrades gracefully.** A cut-off JSONL file is "the first N offers". A cut-off JSON array is unparseable, and you cannot recover the records that did arrive.
* **Streaming is the default.** Reading line by line needs no special parser and no memory proportional to the file. Streaming a JSON array requires a pull parser and extra effort.
* **Line-oriented tooling works.** `wc -l` counts your records; `split`, `head`, `jq -c` and friends all behave.

Shopify's Bulk Operations API returns JSONL for the same reasons. We differ from them in one respect: we keep SKUs and variants nested inside each offer rather than flattening them into separate lines joined by `__parentId`, so you do not have to reassemble records.

### How is this different from paginating the offers endpoint?

Consistency and cost. An export decides its membership once, at `snapshot_at`, and then writes exactly that set, so an offer changing mid-export cannot be skipped or duplicated. A paginated walk over a catalog that is being modified can do both, and you have no way to detect it. The export also does the work once server-side instead of over hundreds of round trips.

### Can I have more than one export running for a merchant?

No. One per app, per merchant. A second export of the same data would double the database load for no new information, so the submit returns `409` with the export already running. Poll that one.

### Why did my submit return `409` when I do not think I have an export running?

An earlier export is probably still in flight. The `409` body is that export. If it looks stuck, check its `date_last_modified`; exports that stop making progress are reclaimed automatically and released. A `409` also persists briefly after you cancel: a `CANCELING` export keeps the merchant's slot until it finishes stopping and reaches `CANCELED`.

### I lost my export id. Can I recover it?

Yes. [List your exports](#list-your-exports) for the merchant; entries come back newest first and are scoped to your app. Find the one you want and use its `id` to fetch a fresh download URL. Logging the `id` at submit time saves you the lookup.

### Why is my export slower than the numbers in the table?

Most likely one of:

* Other exports are running. The rate is shared platform-wide, so concurrent exports are each slower.
* Your `include` list is long. Every extra dataset is work per offer.
* The catalog is bigger than you think. Check `total_count`.

Exports are throttled to protect live API traffic. Slower than the table is normal; the table is not an SLA.

### Can I make my export run faster?

Not through the API. The pacing is a platform protection, not a per-caller setting. What you *can* do is make the export smaller: drop `include` values you do not use, and bound it with `date_last_modified:min` so you are not re-exporting a catalog you already have.

### My export failed. Did I lose everything?

Not necessarily. Check for `partial_url`. If it is present, the file contains every offer up to `partial_max_offer_id`, complete and valid. To get the rest, run a new export and deduplicate by offer id. `partial_max_offer_id` tells you how far the partial reached, but you cannot pass it back to resume from there, so bound the new export with `date_last_modified` to keep it small.

### The download URL stopped working.

Either it aged out (URLs last 30 minutes) or the file expired. Fetch the export again for a fresh URL. If `date_expires` has passed, the file is gone and you need a new export.

### What does `skipped_count` mean? Is it an error?

No. It counts offers that were part of the export when it started but were deleted, or became invisible to your app, before they were written. That is a normal consequence of exporting a live catalog. `object_count + skipped_count` always equals `total_count` on a completed export.

### What if the merchant has no offers, or nothing matches my filters?

The export still completes. `total_count` is `0`, the `COMPLETED` export carries a normal `url`, and the file is a valid empty result: zero lines, or an empty gzip stream when the format is `JSONL_GZ`. You will not get a `404` on download. A completed export with no data is meant to be distinguishable from a lost file.

### Why do consecutive incremental exports return the same offers twice?

By design. `resume_from` is set slightly earlier than the snapshot so that no change can slip between two exports. The overlap is what prevents a gap, so deduplicate by offer id.

### Can I export a specific list of offers, or search results?

No. An export covers a merchant's catalog, optionally narrowed by modification date and publish state. For anything more selective, use the [paginated offers endpoint](/api-reference/catalog/offers/get-all-merchant-offers.md).

### Is there a webhook when an export completes?

Not yet. A completion webhook is a planned fast-follow. For now, poll the export until it reaches a terminal status.

### Can I export more than one merchant at once?

Yes. The one-in-flight limit is per merchant. You can run up to 3 exports concurrently per app, across different merchants.

### Does an export include unpublished offers?

**Yes, by default.** Set `published_only: true` to exclude them.

This is the opposite default from the [paginated offers endpoint](/api-reference/catalog/offers/get-all-merchant-offers.md), where unpublished offers are left out unless you pass `include=unpublished`. On an export that `include` value does nothing; `published_only` is the only control. If you are switching an integration from pagination to export, set `published_only: true` to keep the behaviour you had.

### Which timestamp should I store for the next incremental export?

`resume_from`, and nothing else. Not `date_completed`, not `snapshot_at`, and not your own clock. Each of those can leave a gap in which a change is lost permanently.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.violet.io/api-reference/catalog/offers/bulk-offer-export.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
