# Update products in bulk

Change up to 100 Store products in one call, sending only the fields that change for each, from a JSON array or a CSV file.

- Endpoint: `PUT 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: All-or-nothing; re-sent codes return 409
- Guide: hand-written
- Verified: live, 2026-09-27
- Last updated: 26 Sep 2026
- Web page: https://docs.returning.ai/api-reference/store-products/update-products-in-bulk

## When to use this

- You reprice or archive many products at once.
- You restock several products with new voucher codes in one request.
- Your team keeps the catalogue in a spreadsheet and uploads it as CSV.

**Instead:** Use [Create products in bulk](https://docs.returning.ai/api-reference/store-products/create-products-in-bulk.md) instead to add new products; this endpoint only changes existing ones.

## 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 before anything is saved, then all the products and codes are saved together: if any item fails, nothing changes. Fields an item leaves out keep their current values. New codes are added as stock; existing codes are never removed or replaced.

## 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 PUT \
  --url https://api.returning.ai/v1/products/bulk \
  --header 'Authorization: Bearer <API_KEY>' \
  --header 'Content-Type: application/json' \
  --data '[
    {
      "productID": "66f000000000000000000101",
      "price": 450
    }
  ]'
```

## Response

A `200` returns the updated products in `data`, in the order you sent them, and `meta.updated` counts them. `stocks` includes any codes you added, unless they have already expired. 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 `200`, 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_UPDATED`) |
| `meta.updated` | `integer` | always | Number of products updated. |
| `message` | `string` | always | Human-readable summary. Do not branch on it. |
| `data` | `object[]` | always | The updated products, in the order you sent them. |
| `data._id` | `string` | always | Product ID. Use it to read or update the product. |
| `data.name` | `string` | - | Product name shown in the Store. |
| `data.price` | `number` | - | 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 (200)

```json
{
  "meta": {
    "status": "success",
    "statusCode": 200,
    "updated": 1,
    "code": "PRODUCTS_BULK_UPDATED"
  },
  "message": "Update 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": 450,
      "categoryID": "66f000000000000000000201",
      "category": "Trading rewards",
      "stocks": 2,
      "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": 7,
        "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-26T10:00:00.000Z"
    }
  ]
}
```

## Errors

Every error carries its code in `meta.code`, with the reason in `detail` as a sentence. An item that breaks a rule is named by position, such as `Bulk product item 2 validation failed:`, and a CSV error names its row.

### Fix the request

| Status | Code | What to do |
| --- | --- | --- |
| 400 | `VALIDATION_FAILED` | The body isn't a raw array, has over 100 items or a repeated `productID`, or an item breaks a rule or names something with no match. `detail` says which. Nothing was saved. |
| 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. |

### Fix the data

| Status | Code | What to do |
| --- | --- | --- |
| 404 | `STORE_RESOURCE_NOT_FOUND` | One `productID` isn't in your community, so nothing was saved. Correct or remove that item and send the batch again. |
| 409 | `STORE_RESOURCE_CONFLICT` | An item's `vouchers` has codes that product already has. Nothing was saved; drop those codes and send again. |

### Retry with backoff

| Status | Code | What to do |
| --- | --- | --- |
| 500 | `INTERNAL_ERROR` | The batch is saved in full or not at all. Read one of the products back to see which, then send the same batch again if needed. |
| 502 | `STORE_DEPENDENCY_UNAVAILABLE` | The Store was unreachable. Read one of the products back, then send the same batch again with backoff. |
| 401 | `AUTH_API_KEY_VALIDATION_FAILED` | The key could not be checked just now. Retry with backoff; the key itself may be fine. |

**Retries:** There is no `Idempotency-Key`, but the batch is saved in full or not at all, and repeating it is safe: the fields end the same, and codes already added return `409` with nothing saved. After a timeout, read one of the products back to see whether the batch was saved. 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 change.
- [Check one product? Read product](https://docs.returning.ai/api-reference/store-products/read-product.md): `GET /v1/products/{productID}`.
