Pricing API
Price made-to-measure blinds at your trade terms, from your own ordering system. Everything here returns both the list price and your net price, so your system can show either.
| Base URL | The host and port we gave you. Every path below is relative to it, so
/dealer/quote means $BASE/dealer/quote. |
| Products | Your contracted range only. Anything outside it is not listed and cannot be priced — see Products. |
| Access | Your API key and your registered IP address. Requests from anywhere else do not reach us — see Authentication. |
| Scope | Quoting only. This API does not place orders, hold stock, or return lead times per job — see What this API does not do. |
Quick start
Three steps to your first price.
- Ask your account manager for an API key. We issue it and send it to you — there is no self-service sign-up.
- Store it on your server. It is a secret — see Authentication.
- Send a configured line to
/dealer/quote.
curl -X POST "$BASE/dealer/quote" \
-H "X-API-Key: $HTW_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"blind_type_code": "P60",
"fabric_code": "PS41G0001",
"dimensions": { "width_mm": "1219.2", "height_mm": "1219.2" },
"options": [],
"quantity": 1
}'
import os, requests
r = requests.post(
f"{os.environ['BASE']}/dealer/quote",
headers={"X-API-Key": os.environ["HTW_API_KEY"]},
json={
"blind_type_code": "P60",
"fabric_code": "PS41G0001",
"dimensions": {"width_mm": "1219.2", "height_mm": "1219.2"},
"options": [],
"quantity": 1,
},
timeout=10,
)
r.raise_for_status()
q = r.json()
print(q["list_unit_price"]["amount"], "list ->", q["unit_price"]["amount"], "yours")
const res = await fetch(`${process.env.BASE}/dealer/quote`, {
method: "POST",
headers: {
"X-API-Key": process.env.HTW_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
blind_type_code: "P60",
fabric_code: "PS41G0001",
dimensions: { width_mm: "1219.2", height_mm: "1219.2" },
options: [],
quantity: 1,
}),
});
if (!res.ok) throw new Error(await res.text());
const q = await res.json();
console.log(q.list_unit_price.amount, "list ->", q.unit_price.amount, "yours");
What comes back
{
"list_unit_price": { "amount": "252.00", "currency": "USD" },
"unit_price": { "amount": "100.80", "currency": "USD" },
"list_line_total": { "amount": "252.00", "currency": "USD" },
"line_total": { "amount": "100.80", "currency": "USD" },
"currency": "USD",
"breakdown": [
{ "label": "Base (pls41g) 48.00\"x48.00\"",
"amount": { "amount": "252.00", "currency": "USD" } }
],
"rate_card_version": "erp-2026.08.04-1546",
"discount": { "account": "XX-000", "multiplier": "0.400",
"percent_off": "60.0", "source": "erp" }
}
Illustrative figures. Your account and your multipliers are your own — call
/dealer/me to see them.
0.1 + 0.2 is not 0.3 in floating point, and these are prices.
StockCutCharge: YES is
added to Simply Cordless lines automatically and appears as its own line in the breakdown.
You do not need to send it. If a line genuinely is not cut, send
{"name": "StockCutCharge", "value": "NO"} and that wins.
How the discount is applied
Each line of the breakdown is discounted and rounded to the cent separately, and
those are then added — the same arithmetic your invoice uses. So an $89 blind with a $15
stock cut charge at ×0.405 comes to 36.05 + 6.08 = 42.13, not
42.12. Rounding is half-up. line_total applies the quantity
inside each component, so use it for the order value rather than multiplying
unit_price yourself.
Authentication
Two ways in, and the choice matters.
| Method | Header | Use it for |
|---|---|---|
| API key | X-API-Key: htw_live_… |
Your server calling ours. This is the one you want. |
| Session token | Authorization: Bearer … |
The trade portal itself. Expires after 14 days. |
Keys are shown once, when issued. We store only a hash, so we cannot recover one for you — if it is lost, we revoke it and issue another.
Your IP address
The key is not the only gate. This API answers only requests from the IP addresses you registered with us — usually the static outbound address of the server that will be calling. From anywhere else the connection is refused before the key is even read, so a leaked key is not usable from a stranger's machine.
- Send us the address (or CIDR range) your calls will come from, before you start.
- Tell us before you move providers, add a second server, or change your firewall — otherwise your integration stops the moment the address changes.
- If your outbound address is dynamic, say so and we will work something else out.
$BASE is
https://api.richview.com. The older http://a.richview.com:8101
address is plain HTTP — your key travels in clear on it — and is being retired; move any
integration still pointing at it. The portal you sign into in a browser is a separate
hostname, https://trade.richview.com, and it does not accept API keys at all:
keys work only from the address you registered, over HTTPS.
Everything needs a credential
Not just pricing. The product list, fabrics, options and the price book all require your
key too — there are no anonymous endpoints here beyond a health check. If you get a
401 from a plain GET, that is why.
Your account is never something you send. It is resolved from your key.
There is no account, discount or price field on any
request, so a bug in your code cannot accidentally price as someone else.
Your discount
Your account, company, and multiplier for every product you are set up for. Useful at start-up to check your discount, or to show it in your own UI.
{
"account": "XX-000",
"company": "YOUR COMPANY LTD",
"discounts": [
{ "blind_type_code": "P60", "multiplier": "0.250", "percent_off": "75.0" },
{ "blind_type_code": "RC", "multiplier": "0.400", "percent_off": "60.0" }
]
}
This lists only the products in your contracted range. If you hold terms with us on something not sold through this API, it will not appear here.
Price a blind
| Field | Type | Notes |
|---|---|---|
blind_type_code | string | e.g. P60. See Products. |
fabric_code | string | Must belong to that product. See Fabrics. |
dimensions.width_mm | string | Millimetres. inches × 25.4 |
dimensions.height_mm | string | Millimetres. |
options | array | {name, value} pairs. See Options. |
quantity | int | 1–100. Default 1. |
multi_blind | int | Shades on one headrail, 1–8. Default 1. See Sizing. |
Price many at once
Send an array of the same line objects, up to 50. Use this for a whole job rather than one request per window.
Results come back in order. A bad line returns an error entry
instead of failing the batch, so four windows with one typo still price the other three.
[
{ "index": 0, "quote": { "unit_price": { "amount": "100.80", … } } },
{ "index": 1, "error": { "field": "fabric_code",
"message": "Fabric 'XYZ' is not available for Economy Cellular Shade.",
"allowed": ["PS31G0001", "PS31G0004", …] } },
{ "index": 2, "quote": { … } }
]
Products
Every product you can order, with its lead time and starting price. Note the starting
price here is list — your net comes from /dealer/quote.
[ { "code": "P60", "name": "Economy Cellular Shade",
"from_price": "116.00", "currency": "USD",
"fabric_count": 56, "lead_time_days": 6 } ]
404 from
every endpoint here, including the price book. To add a product to your range, talk to your
account manager; it is a change at our end, not a parameter you can send.
Fabrics
Paged and searchable — some products carry 400+ fabrics, so fetch what you need rather than the lot.
Each fabric carries a type — the cloth itself, shared by every colour in the
same price group, e.g. 3/4 LF SINGLE — alongside its name, which
is the colour. Group by type and you get the same two-step choice as our
order entry: fabric type first, then colour. A handful of older fabrics have no type
recorded; those come back with type empty.
{ "code": "PS410001", "name": "Cotton 001", "type": "3/4 LF SINGLE",
"light": "light_filtering", "price_group": "pls410" }
| Query | Notes |
|---|---|
q | Search name or code. |
light | light_filtering, room_darkening, blackout, sheer |
offset, limit | Paging. Limit 1–500, default 60. |
GET /products/P60/fabrics?light=blackout&limit=20
Options
The valid option names and values for a product, with a price hint where the option costs extra. Do not hard-code these — they change with the price book.
P60 today
| Option | Values | Adds |
|---|---|---|
ControlType | CORDED, CLUTCH, CORDLESS | — |
MOTORIZED | $540 | |
Stack | BOTTOM_UP | — |
TOP_DOWN_BOTTOM_UP | $80 | |
RemoteChannel | NONE / ST15 | $130 |
TileCutOut | NONE / ONE_SIDE / TWO_SIDES | $63 per side |
MeasureName | INSIDE_MOUNT / OUTSIDE_MOUNT | — |
Optional hardware
Battery packs, solar panels, cords and the P-BOX hub. These are separate line
items, not shade options — per: "order" means one per order (the hub),
per: "shade" means one per blind.
How sizing works
Prices come from a width × drop grid. A size rounds up into the next published band — the same rule your invoices are priced on — so 35"×47" and 36"×48" can cost the same, while 36.5" moves to the next band.
Send millimetres. We match at whole-millimetre precision, so a plain 36" (914.4 mm against a band published at 914 mm) lands in the band you would expect rather than the next one up.
Sizes beyond the largest published band are rejected, never extrapolated.
Check the range in /products/{code}/config before sending.
Multi-headrail
For a 2-on-1 or 3-on-1, send the total headrail width and set
multi_blind. The price is for the whole headrail; the response adds
per_shade_price showing the split.
{ "…": "…", "multi_blind": 2 }
→ "unit_price": { "amount": "370.00" },
"per_shade_price": { "amount": "185.00" }
What this API does not do
Worth knowing before you design around it, so you do not build against something that isn't here.
- It does not place orders. A quote is a price, not a commitment on either side. Keep ordering on your existing channel; when order submission is available we will tell you, and it will be additive — nothing here changes.
- It does not know stock or your delivery date. The
lead_time_dayson a product is the standard published lead time, not a promise for your job. - It does not discount hardware. Accessories come back at list.
- It has no test or sandbox environment. Every call is against live prices. Nothing here charges you or creates a record, so that is safe — but the numbers you see are the real ones.
rate_card_version. If it
differs between the quote you showed a customer and the order you place, re-price first —
that is the field to watch, not a date.
Errors
Every rejection names the field and, where useful, lists what is allowed — so your system can show a real message rather than "invalid request".
| Status | Meaning | What to do |
|---|---|---|
401 | Missing, invalid, or revoked key | Check the header; create a new key if revoked. |
403 | Signed in but not linked to a trade account | Link in the portal, or contact your account manager. |
404 | Unknown product, or one outside your range | Check /storefront/products — it lists exactly what you may order. |
422 | Bad fabric, size, option, or quantity | Read detail.allowed. |
429 | Too many requests | Back off; honour Retry-After. |
{ "detail": {
"field": "options.ControlType",
"message": "'MOTORIZATION' is not a valid ControlType for Economy Cellular Shade.",
"allowed": ["CLUTCH", "CORDED", "CORDLESS", "MOTORIZED"] } }
No response at all — connection refused, or a timeout with no HTTP status — usually means the request came from an address we do not have registered. Check your server's outbound IP against what you told us; it is not something the API can tell you, because the connection never gets that far.
Rate limits & good manners
- 300 quotes per minute. Use
/dealer/quote/batchfor jobs — one request of 50 lines beats 50 requests. - Cache the catalog, not prices. Products, fabrics and options change rarely; prices you should always fetch.
- Watch
rate_card_version. If it changes between your quote and your order, re-price before committing. - On
429, honourRetry-Afterand back off.
Try it
Runs a real request against this server with your key, so you can see the shape of the response before writing any code.
Prefer a schema? The machine-readable OpenAPI document is at
/openapi.json — fetch it with your key, the same as any other endpoint:
curl -H "X-API-Key: $HTW_API_KEY" "$BASE/openapi.json" > htw-openapi.json
Support
Talk to your account manager for anything commercial — your discount, adding a product to your range, registering another IP address, or a key that needs replacing.
For anything technical, include these three things and we can usually answer in one round trip:
- the key prefix you were using (
htw_live_abc123— the first few characters only, never the whole key); - the request body you sent and the status and body you got back;
- the rate_card_version from the response, if you got one.
Never send us a whole API key, in an email or anywhere else. If you think one has been exposed, say so and we will revoke it — that costs you one integration restart, which is cheaper than the alternative.