# Create products in bulk

Create up to 100 Store products in one call, each with its voucher codes, from a JSON array or a CSV file.

- Endpoint: `POST https://api.returning.ai/v1/products/bulk`
- Section: Store and rewards / Products
- Authentication: `Authorization: Bearer <API_KEY>` (Community API key)
- Permission: `store` (Shown in the dashboard as "Store")
- Retries: No Idempotency-Key; check before retrying
- Guide: hand-written
- Verified: live, 2026-09-27
- Last updated: 26 Sep 2026
- Web page: https://docs.returning.ai/api-reference/store-products/create-products-in-bulk

## When to use this

- You load a new Store catalogue, or a new batch of rewards, and have every product's details and codes ready.
- Your team keeps the catalogue in a spreadsheet and uploads it as CSV.
- You want a batch to go live in full or not at all.

**Instead:** Use [Create product with vouchers](https://docs.returning.ai/api-reference/store-products/create-product-with-vouchers.md) instead to add a single product, or [Update products in bulk](https://docs.returning.ai/api-reference/store-products/update-products-in-bulk.md) to change products that already exist.

## Authentication

- Header: `Authorization: Bearer <API_KEY>`
- Permission: `store`

Send a Community API key with the `store` permission. The key decides the community, so never send a community ID. Keep the key on your server.

## Behaviour

Every item is checked, and every category, role, tag, username and status name looked up, before anything is saved. Then all the products and their codes are saved together: if any item fails, nothing is created. Unless an item has `isArchived: true`, its product is live as soon as the response returns.

Each item always creates a new product. Names don't have to be unique, within the batch or against products you already have. Voucher codes are trimmed, and a code only has to be unique within its own item.

## Request

### Headers

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Authorization` | `string` | Yes | Community API key with `store`. (`Bearer <API_KEY>`) |
| `Content-Type` | `string` | Yes | `application/json` for a JSON array, or `multipart/form-data` for a CSV upload. (`application/json` or `multipart/form-data`) |

### Example request

```bash
curl --request POST \
  --url https://api.returning.ai/v1/products/bulk \
  --header 'Authorization: Bearer <API_KEY>' \
  --header 'Content-Type: application/json' \
  --data '[
    {
      "name": "$25 Trading Credit",
      "description": "<p>Redeem 500 coins for a $25 trading credit on your live account.</p>",
      "image": "https://cdn.example.com/store/trading-credit-25.png",
      "price": 500,
      "categoryName": "Trading rewards",
      "vouchers": ["TC25-7Q4M-2B9X"],
      "voucherExpireDate": "2026-12-31T23:59:59.000Z",
      "isArchived": false,
      "redemptionInstructions": "<p>Your credit is added to your trading account after approval.</p>",
      "discountPrice": 0,
      "isDiscountEnabled": false,
      "discountStartDate": null,
      "discountEndDate": null,
      "userInformation": {
        "isEnabled": false,
        "shouldCollectName": false,
        "shouldCollectPhone": false,
        "shouldCollectAddress": false
      },
      "shouldDisplayRemainingQuantity": true,
      "shouldPrioritiesExpiringVouchers": false,
      "isProductAccessEnabled": false,
      "productPermission": [],
      "purchaseStatusOverrideName": null
    },
    {
      "name": "$50 Trading Credit",
      "description": "<p>Redeem 1000 coins for a $50 trading credit on your live account.</p>",
      "image": "https://cdn.example.com/store/trading-credit-50.png",
      "price": 1000,
      "categoryName": "Trading rewards",
      "vouchers": ["TC50-4K8P-6D2W"],
      "voucherExpireDate": "2026-12-31T23:59:59.000Z",
      "isArchived": false,
      "redemptionInstructions": "<p>Your credit is added to your trading account after approval.</p>",
      "discountPrice": 0,
      "isDiscountEnabled": false,
      "discountStartDate": null,
      "discountEndDate": null,
      "userInformation": {
        "isEnabled": false,
        "shouldCollectName": false,
        "shouldCollectPhone": false,
        "shouldCollectAddress": false
      },
      "shouldDisplayRemainingQuantity": true,
      "shouldPrioritiesExpiringVouchers": false,
      "isProductAccessEnabled": false,
      "productPermission": [],
      "purchaseStatusOverrideName": null
    }
  ]'
```

## Response

A `201` returns the new products in `data`, in the order you sent them, and `meta.created` counts them. Save each `_id`: every other product call uses it. `stocks` counts each product's codes, less any that have already expired; voucher codes are never returned. Branch on the HTTP status and `meta.code`, never on `message`.

**Note:** If names can't be looked up after saving, the response is still `201`, with `category` as `null` and an empty `productPermission`; read the products back to confirm. Each product also includes redemption method, display field and refund settings managed in the dashboard.

### Response fields

| Field | Type | Presence | Description |
| --- | --- | --- | --- |
| `meta` | `object` | always | Status details. |
| `meta.status` | `string` | always | Result of the request. (`success`) |
| `meta.statusCode` | `integer` | always | The HTTP status, repeated. |
| `meta.code` | `string` | always | Machine-readable result code. (`PRODUCTS_BULK_CREATED`) |
| `meta.created` | `integer` | always | Number of products created. |
| `message` | `string` | always | Human-readable summary. Do not branch on it. |
| `data` | `object[]` | always | The new products, in the order you sent them. |
| `data._id` | `string` | always | Product ID. Save it: you need it to read or update the product. |
| `data.name` | `string` | always | Product name shown in the Store. |
| `data.price` | `number` | always | Price in coins. |
| `data.categoryID` | `string` | always | Category ID, or `null` when the product has no category. |
| `data.category` | `string` | always | Category name, or `null` when the product has no category or its category was deleted. |
| `data.stocks` | `integer` | always | Vouchers left to buy: codes that are unsold and not expired. Voucher codes are never returned. |
| `data.isArchived` | `boolean` | - | `true` when the product is archived and left out of List products and the Store. |
| `data.image` | `string` | - | Image URL, or `null` when there is no image. |
| `data.description` | `string` | - | Product description as HTML. Only basic formatting tags are kept. |
| `data.redemptionInstructions` | `string` | - | Redemption instructions for the trader, or `null`. |
| `data.discountPrice` | `number` | - | Discounted price in coins, charged between the discount dates while the discount is on. |
| `data.isDiscountEnabled` | `boolean` | - | `true` when the discount is switched on. |
| `data.discountStartDate` | `string` | - | When the discount starts, ISO 8601 UTC, or `null`. |
| `data.discountEndDate` | `string` | - | When the discount ends, ISO 8601 UTC, or `null`. |
| `data.userInformation` | `object` | - | What the trader is asked for when they redeem. |
| `data.userInformation.isEnabled` | `boolean` | - | `true` when the trader is asked for details. |
| `data.userInformation.shouldCollectName` | `boolean` | - | `true` to ask for their name. |
| `data.userInformation.shouldCollectPhone` | `boolean` | - | `true` to ask for their phone number. |
| `data.userInformation.shouldCollectAddress` | `boolean` | - | `true` to ask for their address. |
| `data.shouldDisplayRemainingQuantity` | `boolean` | - | `true` to show traders how many are left. |
| `data.shouldPrioritiesExpiringVouchers` | `boolean` | - | `true` to prioritise the vouchers closest to expiry. |
| `data.expiringVoucherHighlight` | `object` | - | Highlight for vouchers close to expiry. |
| `data.expiringVoucherHighlight.isEnabled` | `boolean` | - | `true` when the highlight is on. |
| `data.expiringVoucherHighlight.duration` | `integer` | - | How close to expiry, in `timeframe` units. |
| `data.expiringVoucherHighlight.timeframe` | `string` | - | Unit for `duration`, such as `days`. |
| `data.isProductAccessEnabled` | `boolean` | - | `true` when the product's own rules apply; `false` when it follows its category. |
| `data.productPermission` | `object[]` | always | Access rules. An empty array means the product has no rules of its own. |
| `data.productPermission.isEnabled` | `boolean` | - | `true` when the rule is active. |
| `data.productPermission.type` | `string` | - | What the rule matches. (`user`, `role`, `tag` or `role_combination`) |
| `data.productPermission.permission` | `object` | - | The traders the rule covers, by readable name. |
| `data.productPermission.permission.roleNames` | `string[]` | - | Role names. |
| `data.productPermission.permission.tagNames` | `string[]` | - | Tag names. |
| `data.productPermission.permission.usernames` | `string[]` | - | Usernames. |
| `data.productPermission.permission.userIDs` | `string[]` | - | Record IDs of the traders the rule names. Send `usernames` when you write rules. |
| `data.productPermission.permission.combination` | `object[]` | - | Role or tag groups a trader must hold together. |
| `data.productPermission.permission.combination.type` | `string` | - | `role` or `tag`. |
| `data.productPermission.permission.combination.values` | `string[]` | - | Role or tag names in the group. |
| `data.productPermission.permission.isSelectAll` | `boolean` | - | `true` when the rule covers all traders. |
| `data.productPermission.purchaseAccess` | `object` | - | Special price for traders the rule covers. |
| `data.productPermission.purchaseAccess.specialPrice` | `number` | - | Special price in coins. |
| `data.productPermission.purchaseAccess.isSpecialPriceEnabled` | `boolean` | - | `true` when the special price applies. |
| `data.productPermission.purchaseLimit` | `object` | - | How often a covered trader can buy. |
| `data.productPermission.purchaseLimit.isPurchaseLimitEnabled` | `boolean` | - | `true` when the limit applies. |
| `data.productPermission.purchaseLimit.quantity` | `number` | - | Purchases allowed in each window. |
| `data.productPermission.purchaseLimit.intervalCount` | `number` | - | Length of the window, in `intervalUnit`s. |
| `data.productPermission.purchaseLimit.intervalUnit` | `string` | - | Unit of the window, such as `months`. |
| `data.productPermission.accessLevel` | `string` | - | What covered traders can do. (`full-access`, `view-only` or `access-denied`) |
| `data.productPermission.isExclusiveTagEnabled` | `boolean` | - | `true` to show the exclusive tag. |
| `data.purchaseStatusOverride` | `object` | always | The status new orders for this product start in, when set. |
| `data.purchaseStatusOverride.isEnabled` | `boolean` | - | `true` when new orders start in `status`. |
| `data.purchaseStatusOverride.status` | `string` | - | Order status name, or `null` when the category or community default applies. |
| `data.totalOrders` | `integer` | - | Orders placed for this product. |
| `data.communityID` | `string` | - | Your community ID. |
| `data.createdAt` | `string` | - | When the product was created, ISO 8601 UTC. |
| `data.updatedAt` | `string` | - | When the product last changed, ISO 8601 UTC. |

### Example response (201)

```json
{
  "meta": {
    "status": "success",
    "statusCode": 201,
    "created": 2,
    "code": "PRODUCTS_BULK_CREATED"
  },
  "message": "Create products success.",
  "data": [
    {
      "_id": "66f000000000000000000101",
      "communityID": "66f000000000000000000010",
      "name": "$25 Trading Credit",
      "description": "<p>Redeem 500 coins for a $25 trading credit on your live account.</p>",
      "image": "https://cdn.example.com/store/trading-credit-25.png",
      "price": 500,
      "categoryID": "66f000000000000000000201",
      "category": "Trading rewards",
      "stocks": 1,
      "discountPrice": 0,
      "isDiscountEnabled": false,
      "discountStartDate": null,
      "discountEndDate": null,
      "userInformation": {
        "isEnabled": false,
        "shouldCollectName": false,
        "shouldCollectPhone": false,
        "shouldCollectAddress": false
      },
      "shouldPrioritiesExpiringVouchers": false,
      "shouldDisplayRemainingQuantity": true,
      "expiringVoucherHighlight": {
        "isEnabled": false,
        "duration": 0,
        "timeframe": "days"
      },
      "isArchived": false,
      "totalOrders": 0,
      "redemptionInstructions": "<p>Your credit is added to your trading account after approval.</p>",
      "productPermission": [],
      "isProductAccessEnabled": false,
      "purchaseStatusOverride": {
        "isEnabled": false,
        "status": null
      },
      "createdAt": "2026-09-26T08:30:00.000Z",
      "updatedAt": "2026-09-26T08:30:00.000Z"
    },
    {
      "_id": "66f000000000000000000102",
      "communityID": "66f000000000000000000010",
      "name": "$50 Trading Credit",
      "description": "<p>Redeem 1000 coins for a $50 trading credit on your live account.</p>",
      "image": "https://cdn.example.com/store/trading-credit-50.png",
      "price": 1000,
      "categoryID": "66f000000000000000000201",
      "category": "Trading rewards",
      "stocks": 1,
      "discountPrice": 0,
      "isDiscountEnabled": false,
      "discountStartDate": null,
      "discountEndDate": null,
      "userInformation": {
        "isEnabled": false,
        "shouldCollectName": false,
        "shouldCollectPhone": false,
        "shouldCollectAddress": false
      },
      "shouldPrioritiesExpiringVouchers": false,
      "shouldDisplayRemainingQuantity": true,
      "expiringVoucherHighlight": {
        "isEnabled": false,
        "duration": 0,
        "timeframe": "days"
      },
      "isArchived": false,
      "totalOrders": 0,
      "redemptionInstructions": "<p>Your credit is added to your trading account after approval.</p>",
      "productPermission": [],
      "isProductAccessEnabled": false,
      "purchaseStatusOverride": {
        "isEnabled": false,
        "status": null
      },
      "createdAt": "2026-09-26T08:30:00.000Z",
      "updatedAt": "2026-09-26T08:30:00.000Z"
    }
  ]
}
```

## Errors

Every error carries its code in `meta.code`, with the reason in `detail` as a sentence. A rule broken inside an item is named by position, such as `Bulk product item 2 validation failed: price must not be less than 0`, and a CSV error names its row, such as `row 3: Price must be a number`. A name with no match or a voucher problem doesn't say which item, for example `Category not found for name: Trading prizes`. A body that isn't valid JSON returns `400` without a `meta.code`.

### Fix the request

| Status | Code | What to do |
| --- | --- | --- |
| 400 | `VALIDATION_FAILED` | The body isn't a raw array or has over 100 items, an item or CSV row breaks a rule, or a name has no match: an unknown category, role, tag, username or status, an empty or repeated voucher code in one item, or codes without `voucherExpireDate`. `detail` says which. Nothing was created. |
| 401 | `AUTH_API_KEY_REQUIRED` | No key was sent. Send `Authorization: Bearer <API_KEY>`. |
| 401 | `AUTH_API_KEY_INVALID` | The key is unknown, expired or malformed. Use a current Community API key. |
| 403 | `AUTH_PERMISSION_REQUIRED` | The key lacks `store`. Add the permission in Settings > Integration > API Keys. |

### Retry with backoff

| Status | Code | What to do |
| --- | --- | --- |
| 500 | `INTERNAL_ERROR` | The outcome is unclear, but the batch was saved in full or not at all. Search List products for one of its names before you send it again. |
| 502 | `STORE_DEPENDENCY_UNAVAILABLE` | The Store was unreachable. Search List products for one of the batch's names, then retry with backoff if it isn't there. |
| 401 | `AUTH_API_KEY_VALIDATION_FAILED` | The key could not be checked just now. Retry with backoff; the key itself may be fine. |

**Retries:** Create products in bulk does not accept an `Idempotency-Key`, and repeating a batch creates a second copy of every product, with its own copy of the codes. The batch is saved in full or not at all, so after a timeout or a `5xx`, search List products (`search=name:<name>`) for one product from the batch, choosing one that isn't archived. If it's there, the whole batch was created; don't send it again. Over the [rate limit](https://docs.returning.ai/how-it-works.md#rate-limits), requests return `429`; wait for the window to reset, then retry.

## Next step

- [List products](https://docs.returning.ai/api-reference/store-products/list-products.md): `GET /v1/products`. Read the catalogue back with `limit=100` to confirm every new product and its `stocks`.
- [Need to change them later? Update products in bulk](https://docs.returning.ai/api-reference/store-products/update-products-in-bulk.md): `PUT /v1/products/bulk`.
