# Create product with vouchers

Create one Store product in the community that owns your API key, and load its voucher codes as stock in the same call.

- Endpoint: `POST https://api.returning.ai/v1/products`
- 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-product-with-vouchers

## When to use this

- You add a reward to your Store, such as a trading credit, and have the voucher codes ready.
- You set up a new Store from your own catalogue, one product at a time.
- You want the product's category, price and access rules set by name, without looking up IDs.

**Instead:** Use [Update product and append vouchers](https://docs.returning.ai/api-reference/store-products/update-product-and-append-vouchers.md) instead to add stock or change a product that already exists, or [Create products in bulk](https://docs.returning.ai/api-reference/store-products/create-products-in-bulk.md) to add up to 100 at once.

## 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 product is saved first, then its voucher codes are added as stock. Unless `isArchived` is `true`, the product is live as soon as the response returns: it appears in List products and in your Store, unless its category is archived or access rules hide it from a trader.

Descriptions keep only basic formatting tags. Voucher codes are trimmed, and a code only has to be unique within its product. With an empty `productPermission`, `isProductAccessEnabled` is saved as `false`.

## Request

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

Every field is required except `expiringVoucherHighlight`; send `null` where a field allows it. Unknown fields are ignored. Call List Store categories first and copy the category name exactly. Put the codes in `vouchers` with one `voucherExpireDate` for all of them; each code is one unit of stock.

| 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, from List Store categories. `null` for no category. (Existing category name or `null`) |
| `vouchers` | `string[]` | Yes | Voucher codes to load as stock, one unit each. Send `[]` to add none. Codes are trimmed and must be unique within the request. (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 | `false` to show the product. `true` keeps it out of List products and the Store. |
| `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 leave the highlight off; 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. Send `[]` for none. 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 POST \
  --url https://api.returning.ai/v1/products \
  --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,
    "expiringVoucherHighlight": {
      "isEnabled": false,
      "duration": 7,
      "timeframe": "days"
    },
    "isProductAccessEnabled": false,
    "productPermission": [],
    "purchaseStatusOverrideName": null
  }'
```

## Response

A `201` returns the saved product in `data`. Save `data._id`: every other product call uses it. `stocks` counts the codes you sent, less any that 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_CREATED`) |
| `message` | `string` | always | Human-readable summary. Do not branch on it. |
| `data` | `object` | always | The saved product. |
| `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,
    "code": "PRODUCT_CREATED"
  },
  "message": "Create 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": 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": 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-26T08:30: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, such as `productPermission.0.type`; 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: an unknown category, role, tag, username or status, an empty or repeated voucher code, or codes without `voucherExpireDate`. `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. |

### Retry with backoff

| Status | Code | What to do |
| --- | --- | --- |
| 500 | `INTERNAL_ERROR` | The outcome is unclear, and the product may exist without its vouchers. Search List products by name before you create it again. |
| 502 | `STORE_DEPENDENCY_UNAVAILABLE` | The Store was unreachable. Search List products by name, then retry with backoff if the product 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 product with vouchers does not accept an `Idempotency-Key`, and repeating it creates a second product with its own copy of the codes. After a timeout or a `5xx`, search List products (`search=name:<name>`) before you create again. If the product exists without its stock, add the codes with Update product and append vouchers. 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 with its new `_id` to confirm the price, category and `stocks`.
- [Need a category name? List Store categories](https://docs.returning.ai/api-reference/store-categories/list-store-categories.md): `GET /v1/categories`.
