# Update product and append vouchers

Replace one Store product's settings with a full body, and add any new voucher codes to its stock.

- Endpoint: `PUT https://api.returning.ai/v1/products/{productID}`
- Section: Store and rewards / Products
- Authentication: `Authorization: Bearer <API_KEY>` (Community API key)
- Permission: `store` (Shown in the dashboard as "Store")
- Retries: Repeats change nothing; re-sent codes get 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-product-and-append-vouchers

## When to use this

- A product is running low and you have new voucher codes to add.
- You change a product's price, image, category or access rules.
- You archive a product so traders no longer see it, or bring it back.

**Instead:** Use [Update products in bulk](https://docs.returning.ai/api-reference/store-products/update-products-in-bulk.md) instead to change a few fields on many products without sending full bodies.

**Full body:** This is not a partial update. Send every field, starting from the values Read product returns.

## 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

The codes in `vouchers` are checked first: if the product already has any of them, nothing changes. Then the product's settings are replaced and the new codes are added as stock. Existing codes are never removed or replaced.

Setting `isArchived` to `true` takes the product out of List products and your Store; Read product still returns it. `productPermission: []` removes every access rule and sets `isProductAccessEnabled` to `false`.

## Request

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `productID` | `string` | Yes | Product ID, from List products or Create product with vouchers. (24-character hex ID) |

### Headers

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Authorization` | `string` | Yes | Community API key with `store`. (`Bearer <API_KEY>`) |
| `Content-Type` | `string` | Yes | Request body format. (`application/json`) |

### Body

This is a full replace, not a patch: send every field except `expiringVoucherHighlight`, which you can omit to keep. Start from Read product: send `category` back as `categoryName` and `purchaseStatusOverride.status` as `purchaseStatusOverrideName`. To change settings without adding stock, send `vouchers: []` and `voucherExpireDate: ""`.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` | Yes | Product name shown in the Store. (1-100 characters) |
| `description` | `string` | Yes | Product description. HTML is allowed; only `p`, `br`, `strong`, `b`, `em`, `i`, `u`, `span` and `a` tags are kept. Send `""` for none. (String, may be empty) |
| `image` | `string` | Yes | Image URL, or `null` for no image. (`https://` URL or `null`) |
| `price` | `number` | Yes | Price in coins. (Number, 0 or more) |
| `categoryName` | `string` | Yes | Exact name of an existing category in your community. `null` removes the category. (Existing category name or `null`) |
| `vouchers` | `string[]` | Yes | Voucher codes to add as stock, one unit each. Existing codes stay; this never removes or replaces codes. A code the product already has returns `409`. Send `[]` to add none. (Array of non-empty strings) |
| `voucherExpireDate` | `string` | Yes | Expiry date for every code in `vouchers`. Required when `vouchers` has codes; send `""` when it is empty. Codes that have already expired are saved but don't count as stock. (ISO 8601 date, or `""`) |
| `isArchived` | `boolean` | Yes | `true` archives the product: it leaves List products and the Store. `false` shows it again. |
| `redemptionInstructions` | `string` | Yes | Redemption instructions for the trader, as text or HTML, or `null`. (String or `null`) |
| `discountPrice` | `number` | Yes | Discounted price in coins, charged between the discount dates while `isDiscountEnabled` is `true`. Send `0` when there is no discount. (0 or more; below `price` when the discount is on) |
| `isDiscountEnabled` | `boolean` | Yes | Switches the discount on or off. (`true` needs both discount dates) |
| `discountStartDate` | `string` | Yes | When the discount starts, or `null`. (ISO 8601 date or `null`; before `discountEndDate`) |
| `discountEndDate` | `string` | Yes | When the discount ends, or `null`. (ISO 8601 date or `null`; after `discountStartDate`) |
| `userInformation` | `object` | Yes | What to ask the trader for when they redeem. Send all four flags. |
| `userInformation.isEnabled` | `boolean` | Yes | `true` to ask for details. |
| `userInformation.shouldCollectName` | `boolean` | Yes | Ask for the trader's name. |
| `userInformation.shouldCollectPhone` | `boolean` | Yes | Ask for the trader's phone number. |
| `userInformation.shouldCollectAddress` | `boolean` | Yes | Ask for the trader's address. |
| `shouldDisplayRemainingQuantity` | `boolean` | Yes | `true` to show traders how many are left. |
| `shouldPrioritiesExpiringVouchers` | `boolean` | Yes | `true` to prioritise the vouchers closest to expiry. |
| `expiringVoucherHighlight` | `object` | No | Highlight for vouchers close to expiry. Omit it to keep the current setting; when you send it, send all three fields. |
| `expiringVoucherHighlight.isEnabled` | `boolean` | Yes | `true` to turn the highlight on. |
| `expiringVoucherHighlight.duration` | `integer` | Yes | How close to expiry, in `timeframe` units. (Whole number, 0 or more) |
| `expiringVoucherHighlight.timeframe` | `string` | Yes | Unit for `duration`, such as `days`. (String) |
| `isProductAccessEnabled` | `boolean` | Yes | `true` to apply the rules in `productPermission`; `false` to follow the category. Saved as `false` when `productPermission` is empty. |
| `productPermission` | `object[]` | Yes | Access rules, one entry per rule. Replaces every existing rule; `[]` removes them all. Names are matched in your community. (Array) |
| `productPermission.isEnabled` | `boolean` | Yes | `true` when the rule is active. |
| `productPermission.type` | `string` | Yes | What the rule matches. (`user`, `role`, `tag` or `role_combination`) |
| `productPermission.permission` | `object` | No | The traders the rule covers, by name. Optional; when you send it, send all five fields. |
| `productPermission.permission.roleNames` | `string[]` | Yes | Exact role names. `[]` for none. (Existing role names) |
| `productPermission.permission.tagNames` | `string[]` | Yes | Exact tag names. `[]` for none. (Existing tag names) |
| `productPermission.permission.usernames` | `string[]` | Yes | Usernames of traders, matched exactly and case-sensitively. A trader created with Create User may not match until they have signed in once; until then the request returns `400` with `Unable to resolve usernames: <names>`. `[]` for none. (Existing usernames; case-sensitive) |
| `productPermission.permission.combination` | `object[]` | Yes | Role or tag groups a trader must hold together. `[]` for none. |
| `productPermission.permission.combination.type` | `string` | Yes | Whether `values` are role or tag names. (`role` or `tag`) |
| `productPermission.permission.combination.values` | `string[]` | Yes | Role or tag names in the group. |
| `productPermission.permission.isSelectAll` | `boolean` | Yes | `true` when the rule covers all traders. Required whenever you send `permission`. |
| `productPermission.purchaseAccess` | `object` | No | Special price for traders the rule covers. Omit it for no special price. |
| `productPermission.purchaseAccess.specialPrice` | `number` | Yes | Special price in coins. (0 or more) |
| `productPermission.purchaseAccess.isSpecialPriceEnabled` | `boolean` | Yes | `true` to charge the special price. |
| `productPermission.purchaseLimit` | `object` | No | Purchase limit for traders the rule covers. Omit it for no limit. |
| `productPermission.purchaseLimit.isPurchaseLimitEnabled` | `boolean` | Yes | `true` to apply the limit. |
| `productPermission.purchaseLimit.quantity` | `number` | Yes | Purchases allowed in each window. (Whole number, 0 or more) |
| `productPermission.purchaseLimit.intervalCount` | `number` | Yes | Length of the window, in `intervalUnit`s. (Whole number, 1 or more) |
| `productPermission.purchaseLimit.intervalUnit` | `string` | Yes | Unit of the window. (`days`, `weeks`, `months` or `years`) |
| `productPermission.accessLevel` | `string` | Yes | What covered traders can do. `null` means full access. (`full-access`, `view-only`, `access-denied` or `null`) |
| `productPermission.isExclusiveTagEnabled` | `boolean` | Yes | `true` to show the exclusive tag. |
| `purchaseStatusOverrideName` | `string` | Yes | Name of the order status new orders for this product start in, or `null` to use the category or community default. (Existing status name or `null`) |

### Example request

```bash
curl --request PUT \
  --url https://api.returning.ai/v1/products/66f000000000000000000101 \
  --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-8R5N-3C1Y"],
    "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,
    "expiringVoucherHighlight": {
      "isEnabled": false,
      "duration": 7,
      "timeframe": "days"
    },
    "isProductAccessEnabled": false,
    "productPermission": [],
    "purchaseStatusOverrideName": null
  }'
```

## Response

A `200` returns the product after the update in `data`. `stocks` now includes the codes you added, unless they have already expired; voucher codes are never returned. Branch on the HTTP status and `meta.code`, never on `message`.

**Note:** The product also includes redemption method, display field and refund settings managed in the dashboard. Build against the fields listed here.

### 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. (`PRODUCT_UPDATED`) |
| `message` | `string` | always | Human-readable summary. Do not branch on it. |
| `data` | `object` | always | The product after the update. |
| `data._id` | `string` | always | Product ID. Use 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 (200)

```json
{
  "meta": {
    "status": "success",
    "statusCode": 200,
    "code": "PRODUCT_UPDATED"
  },
  "message": "Update product 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": 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-26T09:15:00.000Z"
  }
}
```

## Errors

Every error carries its code in `meta.code`, with the reason in `detail`. When the body fails validation, `detail` is an object keyed by field; when a name has no match or a voucher rule fails, `detail` is a sentence. A body that isn't valid JSON returns `400` without a `meta.code`.

### Fix the request

| Status | Code | What to do |
| --- | --- | --- |
| 400 | `VALIDATION_FAILED` | A field is missing or breaks a rule, or a name has no match. This endpoint needs the full body; `detail` lists each problem. 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 |
| --- | --- | --- |
| 409 | `STORE_RESOURCE_CONFLICT` | One or more codes in `vouchers` are already on this product. Nothing was saved; drop those codes and send again. |
| 404 | `STORE_RESOURCE_NOT_FOUND` | No product with this ID in your community. Find the right ID with List products. |

### Retry with backoff

| Status | Code | What to do |
| --- | --- | --- |
| 500 | `INTERNAL_ERROR` | The update may be partly saved, with the new fields but without the new codes. Read the product back, then send the same request again. |
| 502 | `STORE_DEPENDENCY_UNAVAILABLE` | The Store was unreachable. Read the product back, then send the same request 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 repeating the same request is safe: the settings end the same, and codes the product already has return `409` with nothing saved. After a timeout, read the product back; if `stocks` already includes your codes, you are done. 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

- [Read product](https://docs.returning.ai/api-reference/store-products/read-product.md): `GET /v1/products/{productID}`. Read the product back to confirm the new settings and `stocks`.
