# Create orders

You can place orders in two ways. The [embedded interface](/embedded-marketplace/#checkout) has a ready checkout. The
API lets you build your own New order page and create orders from it. This page covers the API, so you can skip it
completely if you use the embedded interface. Read it if you are curious. A supplier is a brand or distributor.

Show "Powered by Beauty Router" and a link to [Beauty Router's terms](https://beautyrouter.com/terms/) on your New order page.
See [Beauty Router mark](/embedded-marketplace/#beauty-router-mark).

A New order page makes these calls:

1. [Get offers](#get-offers) for the products on the page.
2. [Get a quote](#get-a-quote) with all the money and all the problems.
3. [List the payment cards](#choose-a-payment-card).
4. [Place the orders](#place-the-orders).

Your page can also [save a draft](#drafts) and place it later.

Every endpoint on this page takes the user token, so your page can call them directly. Reads work for any logged-in
user of the company. Placing orders and saving drafts need [`canOrder`](/authentication/#users-who-cannot-order). A quote with a
`deliveryAddress` needs `canOrder` or
[`canManageCompany`](/authentication/#users-who-cannot-change-company-settings).

Wholesale prices reach the API only for companies that are `prequalified` or `verified`. For any other company, the
offers, quote, and order calls fail with `company_not_verified`. See
[Verification statuses](/companies/#verification-statuses).

## Get offers

`POST /v1/products/offers` returns what a New order page shows about each product:

| On your page                  | Field                                                                       |
| ----------------------------- | --------------------------------------------------------------------------- |
| Price                         | `unitPrice` on the offer                                                    |
| Quantity discounts            | `discountLevels` on the offer                                               |
| Order discounts               | `orderDiscountLevels` on the supplier                                       |
| Stock                         | `available`, `status`, and `plannedAvailability` on the offer               |
| Maximum per order             | `maxQuantityPerOrder` on the supplier                                       |
| Unavailable suppliers and why | `status` and `canShipToAddress` on the supplier                             |
| Shipping terms                | `shippingType` and `shippingFee` on the supplier                            |
| Free shipping                 | `freeShippingThreshold` on the supplier                                     |
| Rating                        | `rating` on the supplier                                                    |
| Recommended supplier          | `recommended` on the offer                                                  |

It is the one batch endpoint in the API, made for the rows of a New order page. Load rows as they appear: call it for the
visible rows and the selected rows, and split a longer list into chunks of 500. It has a limit per user token, and a
request over it fails with `rate_limit_exceeded`.

| Field             | Description                                                                                                     |
| ----------------- | --------------------------------------------------------------------------------------------------------------- |
| `productIds`      | The products, up to 500. Required.                                                                              |
| `supplierId`      | Return only the offers of this supplier. Optional.                                                              |
| `deliveryAddress` | The delivery address, with the same fields as the [company's](/companies/#update-the-company). Optional.        |

Without `deliveryAddress`, Beauty Router uses the company's delivery address. It skips suppliers that cannot ship to the
address. When you name a `supplierId`, that supplier comes back with `canShipToAddress: false` instead of being skipped,
so you can tell the user why.

A supplier that is `archived_by_supplier` or `blocked` still comes back, flagged by `status`, so your page can show why.
It is never `recommended`.

```bash
curl https://api.beautyrouter.com/v1/products/offers \
  -H "Authorization: Bearer $USER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "productIds": ["prod-4Fz9QbX2kL"] }'
```

```json
{
  "products": [
    {
      "productId": "prod-4Fz9QbX2kL",
      "hazardousMaterials": false,
      "type": "professional",
      "offers": [
        {
          "supplierId": "sup-1Ry6tUi3Op",
          "unitPrice": 24.50,
          "available": 120,
          "status": "published",
          "plannedAvailability": null,
          "discountLevels": [
            {
              "minQuantity": 12,
              "discountPercent": 10,
              "discountAmount": 0.00,
              "name": "Salon Stock-Up",
              "endsAt": "2026-12-31"
            }
          ],
          "recommended": true
        },
        {
          "supplierId": "sup-8Wq2eRt5Yu",
          "unitPrice": 25.25,
          "available": 0,
          "status": "unavailable",
          "plannedAvailability": "2026-11-12",
          "discountLevels": [],
          "recommended": false
        }
      ]
    }
  ],
  "suppliers": [
    {
      "id": "sup-1Ry6tUi3Op",
      "name": "Amberlin",
      "logo": "https://images.beautyrouter.com/sup-1Ry6tUi3Op/logo.png",
      "rating": 94,
      "status": "active",
      "maxQuantityPerOrder": 48,
      "freeShippingThreshold": 150.00,
      "shippingFee": 12.00,
      "shippingType": "flat",
      "orderDiscountLevels": [
        {
          "minAmount": 500.00,
          "discountPercent": 5,
          "discountAmount": 0.00,
          "name": "Holiday Order Bonus",
          "endsAt": "2026-12-31"
        }
      ],
      "canShipToAddress": true
    },
    {
      "id": "sup-8Wq2eRt5Yu",
      "name": "Dunmore Beauty Supply",
      "logo": "https://images.beautyrouter.com/sup-8Wq2eRt5Yu/logo.png",
      "rating": null,
      "status": "active",
      "maxQuantityPerOrder": 100,
      "freeShippingThreshold": null,
      "shippingFee": null,
      "shippingType": "carrier",
      "orderDiscountLevels": [],
      "canShipToAddress": true
    }
  ],
  "currency": "usd"
}
```

Each product's `offers` are sorted best first: in stock first, then suppliers the company is connected to, then by
rating. The first offer has `recommended: true`, so your page can pick a supplier for each row on its own.

| Product field        | Description                                                     |
| -------------------- | --------------------------------------------------------------- |
| `productId`          | The product ID.                                                 |
| `hazardousMaterials` | Whether the product is hazardous, which limits where it can go. |
| `type`               | `professional` or `retail`, as in the [catalog](/catalog/).     |
| `offers`             | The offers, best first.                                         |

| Offer field           | Description                                                                                           |
| --------------------- | ----------------------------------------------------------------------------------------------------- |
| `supplierId`          | The supplier.                                                                                         |
| `unitPrice`           | The wholesale price of one unit, before discounts.                                                    |
| `available`           | How many units can be ordered now.                                                                    |
| `status`              | `published`, `unavailable` for now, or `discontinued`.                                                |
| `plannedAvailability` | The date the product is back in stock, or `null`.                                                     |
| `discountLevels`      | Quantity discounts. See the level fields below.                                                       |
| `recommended`         | `true` on the first offer.                                                                            |

| Supplier field          | Description                                                                                        |
| ----------------------- | -------------------------------------------------------------------------------------------------- |
| `id`                    | The supplier ID.                                                                                   |
| `name`                  | The supplier name.                                                                                 |
| `logo`                  | The URL of the supplier's logo, or `null`.                                                         |
| `rating`                | The rating from 0 to 100, or `null`.                                                               |
| `status`                | See [Supplier statuses](/suppliers/#supplier-statuses).                                            |
| `maxQuantityPerOrder`   | The most units of one product in one order.                                                        |
| `freeShippingThreshold` | Orders at or above this amount ship free. `null` when the supplier has no free shipping.           |
| `shippingFee`           | The flat shipping charge. `null` when `shippingType` is `carrier`.                                 |
| `shippingType`          | `flat` for a flat amount, or `carrier` for a carrier quote.                                        |
| `orderDiscountLevels`   | Discounts on the whole order. See the level fields below.                                          |
| `canShipToAddress`      | Whether the supplier ships to the address. It can be `false` only for a supplier you named.        |

| Level field       | Description                                                                 |
| ----------------- | --------------------------------------------------------------------------- |
| `minQuantity`     | The quantity from which the level applies. Only in `discountLevels`.        |
| `minAmount`       | The order amount from which the level applies. Only in `orderDiscountLevels`. |
| `discountPercent` | The discount in percent, or `0` when `discountAmount` applies.              |
| `discountAmount`  | The discount per unit in dollars, or `0` when `discountPercent` applies.    |
| `name`            | The name of the discount, or `null`.                                        |
| `endsAt`          | The date the discount ends, or `null`.                                      |

## Get a quote

`POST /v1/orders/quote` is a dry run of order creation. It returns all the money and all the problems, and it creates no
order.

Call it on page load, when the address or a supplier changes, and before placing the orders. When the user edits a
quantity, estimate the line totals from the offers and keep the last tax. Quote has a limit per user token, and a request
over it fails with `rate_limit_exceeded`.

| Field             | Description                                                                                       |
| ----------------- | ------------------------------------------------------------------------------------------------- |
| `lines`           | The lines: `productId`, `quantity`, and optional `supplierId`. Required.                          |
| `deliveryAddress` | The delivery address. Optional. Without it, Beauty Router uses the company's.                     |
| `paymentCardId`   | The card that will pay, so card problems judge that card. Optional. Without it, the default card. |

A line with no `supplierId` goes to the first offer, as in [Get offers](#get-offers).

```bash
curl https://api.beautyrouter.com/v1/orders/quote \
  -H "Authorization: Bearer $USER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "lines": [
      { "productId": "prod-4Fz9QbX2kL", "quantity": 6 },
      { "productId": "prod-6Gh2jKl8Zx", "quantity": 4 },
      { "productId": "prod-2Df7gHj4Kl", "supplierId": "sup-8Wq2eRt5Yu", "quantity": 12 }
    ]
  }'
```

```json
{
  "groups": [
    {
      "supplierId": "sup-1Ry6tUi3Op",
      "lines": [
        { "productId": "prod-4Fz9QbX2kL", "quantity": 6, "unitPrice": 24.50, "discount": 0.00, "total": 147.00 },
        { "productId": "prod-6Gh2jKl8Zx", "quantity": 4, "unitPrice": 26.50, "discount": 0.00, "total": 106.00 }
      ],
      "productsTotal": 253.00,
      "orderDiscount": 0.00,
      "shipping": { "amount": 0.00, "kind": "free" },
      "freeShippingRemaining": null,
      "salesTax": 22.01,
      "taxExemption": { "certificateId": null, "notAppliedReason": "no_certificate" },
      "total": 275.01,
      "chargeTiming": "now",
      "problems": []
    },
    {
      "supplierId": "sup-8Wq2eRt5Yu",
      "lines": [
        { "productId": "prod-2Df7gHj4Kl", "quantity": 12, "unitPrice": 9.75, "discount": 11.70, "total": 105.30 }
      ],
      "productsTotal": 105.30,
      "orderDiscount": 0.00,
      "shipping": { "amount": 14.35, "kind": "carrier" },
      "freeShippingRemaining": null,
      "salesTax": 9.16,
      "taxExemption": { "certificateId": null, "notAppliedReason": "no_certificate" },
      "total": 128.81,
      "chargeTiming": "now",
      "problems": [
        {
          "code": "insufficient_stock",
          "message": "Only 8 units are available.",
          "productId": "prod-2Df7gHj4Kl",
          "available": 8
        }
      ]
    }
  ],
  "unresolvedLines": [],
  "companyProblems": [],
  "total": 403.82,
  "currency": "usd"
}
```

There is one group per supplier, and each group becomes one order.

| Group field             | Description                                                                                      |
| ----------------------- | ------------------------------------------------------------------------------------------------ |
| `supplierId`            | The supplier.                                                                                    |
| `lines`                 | The lines: `productId`, `quantity`, `unitPrice`, `discount` on the whole line, and `total`.      |
| `productsTotal`         | The total of the lines.                                                                          |
| `orderDiscount`         | The discount on the whole order.                                                                 |
| `shipping`              | The shipping charge: `amount`, or `null`, and `kind`, which is `free`, `flat`, or `carrier`.     |
| `freeShippingRemaining` | How much more to order for free shipping, or `null` when it does not apply.                      |
| `salesTax`              | The sales tax, or `null`.                                                                        |
| `taxExemption`          | `certificateId` of the certificate used, or `null`, and `notAppliedReason`. See below.           |
| `total`                 | The amount the salon pays for this group, or `null`. Send it back as `expectedTotal`.            |
| `chargeTiming`          | `now`, or `after_verification` when the company is `prequalified`.                               |
| `problems`              | What stops this group. See [Problems](#problems).                                                |

`notAppliedReason` is `null` when a certificate applies. Otherwise it is `no_certificate`, `other_state` when the
company's certificates are for other states, or `not_reviewed` when Beauty Router has not reviewed the certificate yet.

`unresolvedLines` lists the lines no supplier can take, each with `productId`, `quantity`, and `problems`.
`companyProblems` lists what stops every group. `total` is the sum of all the groups, or `null` when a group's total is.

An amount Beauty Router cannot work out is `null`, never `0`, so do not show it as free. Do not place a group with problems: fix them first, or leave the group out.

### Problems

A problem has a `code` and a `message`. Some also have `productId` or `supplierId`. Show the message, or use the code to
build your own. Fix each problem, or remove the group, before you place the orders.

| Code                            | Meaning                                                                    |
| ------------------------------- | -------------------------------------------------------------------------- |
| `buying_suspended`              | Beauty Router suspended the company's buying.                              |
| `supplier_unavailable`          | The supplier cannot take orders right now.                                 |
| `address_state_without_license` | The delivery address is in a state where the company has no license.       |
| `address_not_deliverable`       | A carrier cannot deliver to the address, or collect from the supplier.     |
| `ship_to_restricted`            | The supplier does not ship to the delivery address.                        |
| `hazmat_destination`            | A hazardous product cannot go to the delivery address.                     |
| `connection_required`           | The supplier must connect with the company before it can order.            |
| `connection_blocked`            | The supplier does not take orders from this company.                       |
| `insufficient_stock`            | There is not enough stock. `available` says how many units can be ordered. |
| `product_unavailable`           | The product is unavailable or discontinued.                                |
| `shipping_unavailable`          | Shipping cannot be calculated for this order.                              |
| `tax_unavailable`               | The sales tax cannot be calculated for the delivery address.               |
| `delivery_address_required`     | The company has no delivery address.                                       |
| `delivery_phone_required`       | Neither the delivery address nor the company has a phone number.           |
| `payment_card_required`         | The company has no payment card.                                           |
| `payment_card_unusable`         | The payment card expired or cannot be used.                                |
| `quantity_limit_exceeded`       | A line is over the supplier's `maxQuantityPerOrder`.                       |
| `order_limit_exceeded`          | The order is over the maximum amount for one order.                        |
| `order_below_minimum`           | The order is below the minimum order amount.                               |

## Choose a payment card

`GET /v1/company/payment-cards` lists the company's cards, so your page can show which one pays. See
[Payment card](/companies/#payment-card). When the company has no usable card, open `showPaymentCards(element)` so the
user can add one.

## Place the orders

`POST /v1/orders/batch` creates one order per group. It takes a required `Idempotency-Key` header.

| Field             | Description                                                                                   |
| ----------------- | --------------------------------------------------------------------------------------------- |
| `orders`          | The orders: `supplierId`, `lines`, `expectedTotal`, and optional `draftOrderId`.              |
| `deliveryAddress` | The delivery address. Optional. Without it, Beauty Router uses the company's.                 |
| `paymentCardId`   | The card that pays, such as `card-6Mn3bVc8Xz`. Optional. Without it, the default card pays.   |
| `saveAsDraft`     | `true` saves the whole request as [drafts](#drafts). Optional, default `false`.               |

Each line has `productId` and `quantity`. `expectedTotal` is the group's `total` from the quote.

```bash
curl https://api.beautyrouter.com/v1/orders/batch \
  -H "Authorization: Bearer $USER_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 4f0c2b7e-9a1d-4c36-b8e5-2d7a6f1e3c90" \
  -d '{
    "orders": [
      {
        "supplierId": "sup-1Ry6tUi3Op",
        "lines": [
          { "productId": "prod-4Fz9QbX2kL", "quantity": 6 },
          { "productId": "prod-6Gh2jKl8Zx", "quantity": 4 }
        ],
        "expectedTotal": 275.01
      }
    ],
    "paymentCardId": "card-6Mn3bVc8Xz"
  }'
```

The response is `201`, with the new orders in `data`. Each one has the same [fields](/orders/#order-fields) as any other
order. Your server also receives an `order.created` [webhook](/webhooks/) for each one.

```json
{
  "data": [
    {
      "id": "order-8Jd3kFs6Gh",
      "number": "4821-7730",
      "status": "placed",
      "total": 275.01,
      "currency": "usd"
    }
  ]
}
```

The example shows only some of the fields.

### Idempotency key

Send a new `Idempotency-Key` for each order the user places, such as a UUID. When a request times out, send it again
with the same key and the same body. Beauty Router returns the first result and does not create the orders twice. A refused request creates nothing and stores no key, so you can repeat it with the same key.

A key belongs to the company and lasts 24 hours. The same key with a different body fails with `409`
`idempotency_key_reused`. While an earlier request with that key is still finishing, a repeat fails with `409`
`idempotency_key_in_use`, so try again shortly.

### When the total changed

Beauty Router recalculates each group, shipping included. When a group differs from `expectedTotal` by $0.10 or more, the
request fails with `409` `order_total_changed`, and nothing is created. The error has a fresh quote in `quote`. Show the
new amounts to the user, then send the request again with the new totals.

A quote is not an object and holds no price. Prices can change until the orders are placed, and `expectedTotal` is how
you find out.

### When a group has problems

Beauty Router checks every group before it creates anything. When any group has a [problem](#problems), or
`companyProblems` lists one, the request
fails with `409` `order_has_problems`, and nothing is created. The error has a fresh quote in `quote`, which lists the
problems per group.

### Payment

The orders are created together, and Beauty Router charges the card at once. The response has each order after its charge
attempt, with its final status: `placed`, `payment_failed`, or `pending_verification` for a `prequalified` company. A failed
charge never fails the request: that order comes back as `payment_failed`, and the other orders stand.

The charge runs while the user is not present, so a card that needs 3-D Secure fails. When an order is `payment_failed`,
open `showOrder(element, orderId)`, where the user pays with 3-D Secure or chooses another card.

### After the order

Shipping is locked when the order is created. Until the order is `placed`, other amounts can still change, so keep the
order up to date from `order.updated`. See [Order fields](/orders/#order-fields).

## Drafts

A draft saves an order for later. Send `saveAsDraft: true` to `POST /v1/orders/batch`. There is one draft per supplier,
like orders. `Idempotency-Key` stays required, and `expectedTotal` is not needed.

```bash
curl https://api.beautyrouter.com/v1/orders/batch \
  -H "Authorization: Bearer $USER_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7b3e1d94-2c6a-4f08-a1d7-5e9c0b8f4a26" \
  -d '{
    "saveAsDraft": true,
    "orders": [
      {
        "supplierId": "sup-1Ry6tUi3Op",
        "lines": [{ "productId": "prod-4Fz9QbX2kL", "quantity": 6 }]
      }
    ]
  }'
```

The response is `201`, with the drafts in `data`. Each one has the status `draft`.

A draft is not an order yet:

- It holds no stock.
- The supplier never sees it.
- It sends no webhook, no email, and no notification. `order.created` is sent when the draft is placed.

The company needs the same status as for placing an order, `prequalified` or `verified`. Otherwise the request fails with
`company_not_verified`.

A draft can have no address and no card. With no address, `salesTax`, `shipping`, and `total` are `null`, never `0`.

### Find your drafts

`GET /v1/orders` lists drafts with the other orders, `status=draft` returns only drafts, and `GET /v1/orders/{orderId}`
returns one. See [List orders](/orders/#list-orders).

### Update or place a draft

Send the draft's ID as `draftOrderId` on the order. With `saveAsDraft: true`, the request updates the draft. With
`saveAsDraft: false`, it places the draft, and the order needs `expectedTotal` as usual. Either way the ID and the number
stay, and the lines are replaced. The supplier cannot change.

```bash
curl https://api.beautyrouter.com/v1/orders/batch \
  -H "Authorization: Bearer $USER_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: c81f6a30-94de-4b57-8e12-3a7d05f6b9c4" \
  -d '{
    "orders": [
      {
        "draftOrderId": "order-2Xc9vBn4Mq",
        "supplierId": "sup-1Ry6tUi3Op",
        "lines": [{ "productId": "prod-4Fz9QbX2kL", "quantity": 6 }],
        "expectedTotal": 171.79
      }
    ],
    "paymentCardId": "card-6Mn3bVc8Xz"
  }'
```

Values are not merged. An address, a phone number, or a card you leave out falls back to the company's default, not to
the draft's value.

Saved amounts are not refreshed. On a reopened draft, [get offers](#get-offers) and [a quote](#get-a-quote) before you
place it. Beauty Router compares against current prices when the draft is placed.

### Problems on a draft

Saving a draft checks only these [problems](#problems):

- `product_unavailable` and `order_below_minimum`.
- `hazmat_destination`, `shipping_unavailable`, and `tax_unavailable`, when the address is known.
- `supplier_unavailable`, when the supplier is not active or has no drop-off point.
- `address_not_deliverable`, for the supplier's drop-off point.

Every other code is checked only when the draft is placed, such as `buying_suspended`, `insufficient_stock`, and the
address and card codes. A company whose buying is suspended can still save a draft.

### Delete a draft

`DELETE /v1/orders/{orderId}` deletes a draft. See [Delete a draft](/orders/#delete-a-draft).

## What stays embedded

Some steps stay in Beauty Router's interface, because they handle card details or need the user present:

| Step                                 | Function                                       |
| ------------------------------------ | ---------------------------------------------- |
| Entering a payment card              | `showVerification(element)`, `showPaymentCards(element)` |
| 3-D Secure                           | `showOrder(element, orderId)`                  |
| Paying again after a failed charge   | `showOrder(element, orderId)`                  |
| Choosing another card on a failed order | `showOrder(element, orderId)`               |