For the complete documentation index, see llms.txt. This page is also available as Markdown.

Bulk Offer Export

Asynchronous, unpaginated access to a merchant's offers.

This is the counterpart to Get All Merchant Offers (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 when:

  • You need results in seconds. An export takes minutes; see 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.

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:

2. Poll until status is terminal.

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

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.

A 403 here means the app/merchant connection is missing, not that your token is bad. Check the connection before regenerating credentials.

Endpoints

All four are under /v1/catalog/.

Submit an export

Body is a 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). Honour Retry-After (60 seconds). A 409 for the same merchant takes precedence over a 429.

503

Export is temporarily unavailable. Retry with backoff.

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.

List your exports

Newest first, scoped to your app. Filter with status (one status; 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.

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.

Submit parameters

All optional. Sending {} exports the merchant's full catalog, including unpublished offers (see 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

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.

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.

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

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

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.

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:

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:

Each line has the same shape as one element of the paginated offers endpoint'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:

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.

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:

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.

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.

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.

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 409s; 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 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.

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, 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.

Last updated

Was this helpful?