Skip to content

Create orders

View as Markdown

You can place orders in two ways. The embedded interface 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 on your New order page. See Beauty Router mark.

A New order page makes these calls:

  1. Get offers for the products on the page.
  2. Get a quote with all the money and all the problems.
  3. List the payment cards.
  4. Place the orders.

Your page can also save a draft 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. A quote with a deliveryAddress needs canOrder or canManageCompany.

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.

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

Terminal window
curl https://api.beautyrouter.com/v1/products/offers \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "productIds": ["prod-4Fz9QbX2kL"] }'
{
"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.
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.
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.

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.

Terminal window
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 }
]
}'
{
"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.

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.

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.

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

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. Optional, default false.

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

Terminal window
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 as any other order. Your server also receives an order.created webhook for each one.

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

The example shows only some of the fields.

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.

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.

Beauty Router checks every group before it creates anything. When any group has a problem, 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.

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.

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.

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.

Terminal window
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.

GET /v1/orders lists drafts with the other orders, status=draft returns only drafts, and GET /v1/orders/{orderId} returns one. See List orders.

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.

Terminal window
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 and a quote before you place it. Beauty Router compares against current prices when the draft is placed.

Saving a draft checks only these 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 /v1/orders/{orderId} deletes a draft. See Delete a draft.

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)