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

# Bulk operations

> Export a store's catalog, orders or customers to JSONL, and import catalog rows from a public URL, asynchronously and with caps.

## Overview

A bulk operation is a long running export or import that your app asks Salesive to
do in the background. The API creates a row, a worker does the work in pages of
200 records, and your app polls the row for progress. Use it instead of paging
through the ordinary list endpoints when a store has thousands of records.

There are two kinds:

| Kind | What it does | Resources |
| - | - | - |
| **Export** | Writes the store's records to a JSONL file in the shop's private storage | `products`, `foods`, `services`, `digital-products`, `orders`, `customers` |
| **Import** | Creates new catalog records from a JSONL file at a public URL | `products`, `foods`, `services`, `digital-products` |

## Authentication

Bulk operations are for **installed apps only**. Send your `app_` access token:

```http theme={null}
Authorization: Bearer app_<your-app-access-token>
```

The installation, and therefore the store, comes from the token. A merchant
session is refused with `403`, because a merchant has no installation to scope
the work to. You never send a shop id.

## Endpoints

All paths are under `https://api.salesive.com/api/v1`.

| Method | Path | What it does |
| - | - | - |
| `POST` | `/app/bulk-operations` | Create and queue one operation |
| `GET` | `/app/bulk-operations` | List this installation's recent operations |
| `GET` | `/app/bulk-operations/:id` | Read one operation, including its report |
| `GET` | `/app/bulk-operations/:id/download` | Get a short lived download URL for a finished export |
| `POST` | `/app/bulk-operations/:id/cancel` | Stop a queued or running operation |

The create, get, download and cancel routes check the scope the operation needs.
The list route needs no extra scope: it only returns operations your own
installation created.

## Scopes

The required scope depends on the kind and the resource, not on the route alone.

| Operation | Resource | Scope |
| - | - | - |
| Export | `products`, `foods`, `services`, `digital-products` | `READ_INVENTORY` |
| Export | `orders` | `READ_ORDERS` |
| Export | `customers` | `READ_CUSTOMERS` |
| Import | any catalog resource | `WRITE_INVENTORY` |

An import cannot target `orders` or `customers`; the create is refused with `400`.
A missing scope is `403` with a message naming the permission.

## Caps

* **One active export and one active import per installation.** A second create
  of the same kind is `409` while one is `queued` or `running`. A finished,
  failed or cancelled operation drops out of the active set.
* **Five operations per rolling hour per installation.** The sixth create in any
  hour is `429`, whatever the earlier operations' statuses were.
* The list returns the newest first. `limit` defaults to 20 and is capped at 50.
  The response is `{ "operations": [...] }`, with each row in the operation shape
  below but without `report`.

The two active slots and the hourly count are enforced when the operation is
created. A partial unique index on the installation and kind closes the race
between two simultaneous creates, so the API cannot start two exports for one
installation even under parallel requests.

## Create an export

```http theme={null}
POST /api/v1/app/bulk-operations
Content-Type: application/json

{
  "kind": "export",
  "resource": "products",
  "filters": { "listed": true }
}
```

`kind` and `resource` are required. `filters` is optional and is whitelisted per
resource. An unknown filter key is refused with `400` rather than ignored, so an
export never returns more than the app asked for. The create answers `201` with
the operation in `data`.

| Resource | Allowed filters |
| - | - |
| `products`, `foods`, `services`, `digital-products` | `listed` (boolean), `category` (category id), `updatedSince` (ISO date) |
| `orders` | `status` (`pending`, `processing`, `paid`, `cancelled`, `refunded`, `completed`), `from` and `to` (ISO dates) |
| `customers` | `active` (boolean), `createdSince` (ISO date) |

An export also refuses `sourceUrl`. That field belongs to an import.

The worker reads the store's records in pages of 200 and writes one JSON object
per line. An export is limited to **20,000 rows** and **10 MB** of JSONL. An
export over either cap fails with a message asking for a narrower filter.

## Download an export

When the export completes, the worker packages the JSONL as a single entry named
`export.jsonl` inside `bulk-export-<id>.zip` and stores the zip in the shop's
private storage. The download route mints a URL to that package:

```http theme={null}
GET /api/v1/app/bulk-operations/:id/download
```

```json theme={null}
{
  "status": 200,
  "success": true,
  "message": "Download URL",
  "data": {
    "url": "https://files.salesive.com/...",
    "expiresIn": 300,
    "fileName": "bulk-export-6720a1b2c3d4e5f6a7b8c9d0.zip",
    "contentType": "application/zip",
    "format": "jsonl-in-zip",
    "size": 48213
  }
}
```

> [!WARNING]
>
> The zip contains exactly one file, `export.jsonl`. It is JSONL, not CSV: each
> line is one complete JSON object, with no header row and no enclosing array.
> The download URL is a credential and expires after **5 minutes**. Do not log
> it or send it to another service.

The route is `409` when the operation is not a completed export, or when the
export has no file. `downloadAvailable` on the operation is `true` only for a
completed export with a stored file.

## Create an import

```http theme={null}
POST /api/v1/app/bulk-operations
Content-Type: application/json

{
  "kind": "import",
  "resource": "products",
  "sourceUrl": "https://cdn.example.com/products.jsonl"
}
```

The API fetches `sourceUrl` **once, at creation**, through the same SSRF guard as
every other remote file. Every redirect hop is resolved and checked before it is
requested, redirects are followed manually (at most 3), credentials in the URL
are refused, and the body is abandoned as soon as it passes the cap. The fetched
bytes are stored and the worker never fetches the URL again.

| Rule | Value |
| - | - |
| URL length | At most 2,048 characters |
| Schemes | `http` and `https` |
| Host | Public. Private, loopback, link local and carrier grade NAT addresses are refused, as is a host that resolves to one |
| File size | At most **10 MB** |
| Fetch timeout | 15 seconds |
| Encoding | Valid UTF-8 with no NUL byte. A leading BOM is removed |
| Rows | At most **5,000** non-empty lines. Blank lines are ignored |

An import does not take `filters`, and `sourceUrl` is required. The file must be
JSONL: one JSON object per line.

Import is **create-only**. A row that contains `id` or `_id` is refused, so an
import can never overwrite an existing record. Unknown fields are refused too, so
a typo does not silently create a record without the field you meant.

| Resource | Row fields |
| - | - |
| `products` | `name`, `description`, `price`, `promoPrice`, `images`, `category`, `listed`, `featured`, `weight`, `quantity`, `barcode`, `lowStockAlert`, `variants` |
| `foods`, `services` | `name`, `description`, `price`, `promoPrice`, `images`, `category`, `listed`, `available`, `addons` |
| `digital-products` | `name`, `description`, `author`, `images`, `category`, `access`, `pricing`, `allowDownload`, `inPass`, `listed`, `featured`, `preview` |

`name` is required on every row. A product also needs a `description` and a
`category`. A product, food or service needs a `price`. Every row is validated
before it is written. A bad row is reported and skipped; the rows around it still
import. A digital product cannot be imported with `listed: true`, because a
listed title needs its file uploaded first.

## Status and report

An operation moves through these statuses:

| Status | Meaning |
| - | - |
| `queued` | Created and waiting for a worker |
| `running` | Being processed |
| `completed` | Finished. An export has a file; an import applied at least one row |
| `failed` | Finished with no usable result |
| `cancelled` | Stopped by the app |

The operation shape:

```json theme={null}
{
  "id": "6720a1b2c3d4e5f6a7b8c9d0",
  "kind": "import",
  "resource": "products",
  "status": "completed",
  "filters": {},
  "total": 250,
  "processed": 247,
  "error": "3 of 250 rows failed.",
  "report": [
    { "line": 12, "message": "price is required and must be 0 or more." }
  ],
  "fileSize": 0,
  "downloadAvailable": false,
  "createdAt": "2026-11-07T09:00:00.000Z",
  "startedAt": "2026-11-07T09:00:04.000Z",
  "finishedAt": "2026-11-07T09:01:12.000Z"
}
```

* `total` is the rows expected: the row count for an export, the data line count
  for an import.
* `processed` is rows written for an export, or rows applied successfully for an
  import.
* `report` holds up to 100 per row import errors, each with the line number
  starting at 1 and a short message. It is returned by the single get only; the
  list leaves it out to keep the list small.
* An import with rows but none applied ends `failed`. An import with some rows
  applied ends `completed` and carries the failure count in `error`.

## Cancel

```http theme={null}
POST /api/v1/app/bulk-operations/:id/cancel
```

Only a `queued` or `running` operation can be cancelled. Anything else is `409`.
A cancel that lands while the worker is between pages, or during the export
upload, wins: the operation ends `cancelled` and does not become `completed`.

## Finished webhook

When an operation reaches `completed`, `failed` or `cancelled`, Salesive sends a
`bulk_operations/finished` webhook to the installation that created it. It is a
notify only event: the body carries the operation id, kind, resource, status,
total, processed and error, never the file or its rows.

The event follows your app's `webhookTopics` subscription like any other topic.
An app with no subscription list receives every topic.

## Errors

| Status | Meaning |
| - | - |
| `400` | The body is invalid: unknown kind or resource, unknown filter, missing `sourceUrl`, a filter on an import, or a URL the guard refuses |
| `403` | The app token is missing a scope the operation needs, or the caller is not an installed app |
| `404` | The operation does not belong to this installation |
| `409` | An operation of this kind is already active, the operation cannot be cancelled in its current status, or the download is not a finished export |
| `413` | The import has more than 5,000 rows, or the fetched file is over 10 MB |
| `415` | The import URL returned binary data or text that is not valid UTF-8 |
| `429` | The installation created five operations in the last hour |
| `502` | The import URL could not be fetched, or the file service could not prepare a download |
| `503` | The operation could not be queued, or the store's storage could not be prepared |

## Next steps

> [card] [Rate limits](https://docs.salesive.com/apps-api/rate-limits.md)
>
> The per installation bucket that ordinary API calls share.

> [card] [Webhooks](https://docs.salesive.com/apps-api/webhooks.md)
>
> Verify the signature on `bulk_operations/finished` and the other topics.

> [card] [List products](https://docs.salesive.com/apps-api/endpoint/products/list-products.md)
>
> The synchronous list endpoint, for small reads.

> [card] [App storage](https://docs.salesive.com/apps-api/app-storage.md)
>
> Keep your app's own state instead of writing it to store records.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.