Customer pricing
Price lists set what each of your customers pays on top of the wholesale price, with volume tiers, contract prices for one customer, prices fixed on single items, and lists in other currencies.
Your wallet is always charged the wholesale price. A price list says what a customer pays you for the same thing, and every purchase made for a customer is recorded with both figures, so you can bill them and see your margin.
Nothing here collects money from your customers: you bill them yourself, from the sales this API records.
What sets a price
The first of these that applies sets what a customer pays for one unit:
- A price fixed on the item itself: one domain, one licence, one number.
- A contract price agreed with that customer, while it is in date.
- The last price they paid for that item, when their list holds renewals.
- A rule on their price list.
- The list's markup.
Every sale says which it was in level: override, contract, list, or none when you
have no list and nothing above applied, so the customer pays wholesale.
Price lists
A list has a markup for everything, and rules for the exceptions.
curl -X POST https://resellerhq.com/api/v1/price-lists \
-H "Authorization: Bearer $RHQ_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Agency",
"markupBps": 2000,
"rounding": "99",
"rules": [
{ "product": "domain", "match": "com", "action": "renew", "kind": "fixed", "value": 1899 },
{ "product": "domain_privacy", "kind": "markup", "value": 0 },
{ "product": "number", "match": "US", "term": "30d", "kind": "add", "value": 300 },
{ "product": "licence", "kind": "markup", "value": 3500 }
]
}'markupBpsis the markup over wholesale for anything no rule covers, in basis points:2000is 20%.roundingrounds worked-out prices up:99to the next amount ending in .99,00to the next whole dollar,nonenot at all.allowBelowCostisfalseunless you set it. While it isfalse, a price that would fall under wholesale is raised to wholesale. This guards every level above, a price fixed on an item included.renewalsisfollowunless you set it. See Renewals.currencyandrateare US dollars unless you set them. See Currency.
Your first list is the default: every customer without a list of their own is priced from it. With no list at all, sales are recorded at the wholesale price.
Rules
| Field | What it does |
|---|---|
product | number, domain, domain_privacy or licence. domain_privacy is WHOIS privacy, sold per year per domain beside the name itself. |
match | Narrows the rule: a domain ending (com), a country code (US) or a licence plan id. Leave it out to cover the whole product. |
term | Narrows a number rule to one rental length, such as 30d. |
action | new for first purchases, renew for renewals and extensions, restore for bringing a domain back out of redemption. Leave it out to cover all three. |
minQuantity | A volume tier, 2 or more. The rule only fits when at least this many are bought together. Leave it out for any quantity. |
kind | markup adds a share of wholesale, add adds a fixed amount, fixed is the price itself. |
value | Basis points for markup; cents of the list's currency for add and fixed. |
The narrowest rule that fits an item sets its price: a rule naming an ending beats one for
all domains, and one that also names renew beats both. Two rules cannot cover exactly the
same items, unless their volume tiers differ.
Volume tiers
Give two rules the same aim and different minQuantity, and the highest tier the quantity
reaches wins:
[
{ "product": "licence", "kind": "fixed", "value": 1000 },
{ "product": "licence", "minQuantity": 10, "kind": "fixed", "value": 900 },
{ "product": "licence", "minQuantity": 50, "kind": "fixed", "value": 800 }
]Nine seats are $10.00 each, ten are $9.00 each, fifty are $8.00 each. The tier sets the price of every unit, not only of those past it.
The quantity is what is bought together:
- Domains: the years in one registration or renewal.
- Numbers: the numbers asked for in one order. A renewal is one number.
- Licences: the seats on the licence. A renewal counts all of them, and a seat added later is priced by the size the licence reaches, so the eleventh seat gets the tier of ten.
Narrowest still comes first: a rule naming a plan beats a tier for all licences, whatever the quantity.
Prices are for one unit: a year of a domain, a year of privacy on one domain, a seat for a month, one rental of a number.
A domain ending whose privacy the registrar includes for nothing has a wholesale privacy cost
of zero, so { "product": "domain_privacy", "kind": "markup", "value": 0 } — the rule in the
list above — passes privacy on at cost: nothing at all on those endings, and your own cost
with no margin on the rest. An add or fixed rule charges your own amount for it anyway,
even where it costs you nothing. Giving privacy away on an ending that does cost you is a
price under wholesale like any other, so it needs allowBelowCost on the list; without it a
fixed of 0 is quietly raised back to what you paid.
markup and add rules follow the wholesale price when it changes. A fixed price does
not, which is why the floor above exists.
Markup is a share of the wholesale price and margin is a share of the selling price, so they are different numbers: 20% markup on $10.00 sells at $12.00, which is a 16.7% margin.
Put a customer on a list
curl -X PATCH https://resellerhq.com/api/v1/customers/cus_01K6XJ4Q8ZT3M9R5V2B7WDHNCA \
-H "Authorization: Bearer $RHQ_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "priceListId": "prl_01K6XJ4Q8ZT3M9R5V2B7WDHNCA" }'Send null to put them back on the default list.
The same route also takes addressLines and taxId: a customer can carry a billing address
and tax number, printed in the Bill to block of their invoices.
DELETE /price-lists/{id} removes a list. Customers on it go back to the default list, the
one whose isDefault is true. The default itself can only be removed once another list has
been made the default with PATCH /price-lists/{id} and { "isDefault": true }, or when it
is the only list left.
Buying for a customer
Every number order, domain registration and licence purchase is a sale to a customer:
customerId is required on each, and a request that leaves it out answers
400 validation_error. Your wallet is charged wholesale as always; the sale records what the
customer pays.
# A number
curl -X POST https://resellerhq.com/api/v1/numbers/orders \
-H "Authorization: Bearer $RHQ_API_KEY" -H "Content-Type: application/json" \
-d '{ "country": "US", "duration": "30d", "customerId": "cus_01K6XJ4Q8ZT3M9R5V2B7WDHNCA" }'
# A domain
curl -X POST https://resellerhq.com/api/v1/domains \
-H "Authorization: Bearer $RHQ_API_KEY" -H "Content-Type: application/json" \
-d '{ "domain": "northwind-telecom.com", "years": 1, "customerId": "cus_01K6XJ4Q8ZT3M9R5V2B7WDHNCA" }'
# Mailbox licences
curl -X POST https://resellerhq.com/api/v1/licences \
-H "Authorization: Bearer $RHQ_API_KEY" -H "Content-Type: application/json" \
-d '{ "plan": "google-workspace-starter", "domain": "northwind-telecom.com", "seats": 5, "customerId": "cus_01K6XJ4Q8ZT3M9R5V2B7WDHNCA" }'Every later renewal, extension and added seat of that item is a sale to the same customer: a
renewal, seat change or cancellation takes no customerId of its own. An unknown
customerId answers 404 not_found, on these routes and on the catalogue routes below,
where it is optional instead.
Contract prices for one customer
A customer can hold rules of their own. They have the same shape as list rules, and each
can carry startsAt and endsAt. A contract rule that fits a purchase and is in date beats
everything on the customer's list, however narrow the list's rule is.
curl -X PUT https://resellerhq.com/api/v1/customers/cus_01K6XJ4Q8ZT3M9R5V2B7WDHNCA/contract-rules \
-H "Authorization: Bearer $RHQ_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"rules": [
{ "product": "domain", "match": "com", "kind": "fixed", "value": 1499 },
{
"product": "licence", "kind": "markup", "value": 1000,
"startsAt": "2026-11-01T00:00:00.000Z", "endsAt": "2027-11-01T00:00:00.000Z"
}
]
}'PUTreplaces the whole set. Send an empty list to clear it.GET /customers/{id}/contract-rulesreads it back.- A rule counts from
startsAtup to, but not including,endsAt. Leave either out for no limit on that side. Outside its dates a rule is ignored and the list prices the purchase. - Among contract rules the same order holds as on a list: narrowest first, then the highest volume tier reached.
- Two rules for the same items and the same tier must not be in date at the same time. This
year's price and next year's may follow one another; two that overlap are refused with
duplicate_rule, since which one a purchase got would be down to their order. addandfixedvalues are in the currency of the customer's list.
A price fixed on one item
A domain, a licence and a number can each carry customerPrice: what this customer pays for
one unit of this item from now on, whatever their contract and list say. It is set through
the item's own PATCH route, and null clears it.
curl -X PATCH https://resellerhq.com/api/v1/domains/dom_01K6XJ4Q8ZT3M9R5V2B7WDHNCA \
-H "Authorization: Bearer $RHQ_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "customerPrice": 1999 }'| Item | Route | The price is for |
|---|---|---|
| Domain | PATCH /domains/{id} | One year of each renewal. |
| Licence | PATCH /licences/{id} | One seat for a month: every renewal, and seats added later. |
| Number | PATCH /numbers/{id} | One renewal. |
The amount is in the currency of the customer's list. It changes nothing in your wallet and
does not rewrite sales already made. A number's customerPrice is charged for a renewal of
any length. Set on an item that was not bought for a customer, it does nothing, since no
sale is recorded for that item.
Renewals
A list's renewals setting says what a renewal costs the customer after the list changes.
follow, the default, prices each renewal from the list as it is on the day.holdcharges what the list last charged for that item, so a customer who registered at $12.00 a year keeps renewing at $12.00 when the list goes up. First purchases still follow the list.
What is held is the unit price of the item's last renewal that the list priced or, when it has not been renewed yet, of its first purchase.
- Only a price the list set is held. A price fixed on the item and a contract price in date both come first while they stand; once the item price is cleared or the contract ends, the renewal goes back to the price the list last charged, not to the one that was fixed.
- Seats added to a licence are first purchases at the list's price on the day. They do not move the price the licence's renewals are held at.
- A number is held per rental length. A month's renewal holds the last month's price; a length the number has never been sold for follows the list.
- A held price is still raised to wholesale if wholesale has passed it and the list does not allow selling under cost.
- An item with nothing to hold, or whose held sale was in another currency, follows the list.
- A held renewal is recorded with
level: "list"andruleId: null.
Domain and licence renewals cost you the wholesale price the item was bought at. A number's renewal is priced from the provider on the day, so numbers are where wholesale can overtake a fixed price. See When a price falls under wholesale.
Currency
A list can charge in a currency other than US dollars. Set currency to its ISO code and
rate to how much of it one US dollar buys, in millionths: 920000 says one dollar buys
0.92 of it. A list in USD always has a rate of 1000000.
curl -X POST https://resellerhq.com/api/v1/price-lists \
-H "Authorization: Bearer $RHQ_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Europe", "markupBps": 2000, "rounding": "99", "currency": "EUR", "rate": 920000 }'- Wholesale is always in US cents. A price is marked up in dollars, converted at the rate, rounding up to the next cent, and then rounded in the list's currency. Wholesale of $10.00 at 20% is $12.00, which is €11.04, which rounds up to €11.99.
addandfixedvalues on the list, contract prices and prices fixed on items are all in the list's currency, as written. Moving a list to another currency does not convert them.- The floor is wholesale in the list's currency, rounded up to the next cent. A price under it is raised to it.
- The rate is yours to keep up to date. Nothing here follows the market. It must be between
10000(0.01) and100000000000(100,000). - A list can charge in USD, EUR, GBP, CAD, AUD, NZD, CHF, SEK, NOK, DKK, PLN, INR, SGD, HKD,
AED, ZAR, BRL or MXN: currencies counted in hundredths, as every amount here is. Any other
code is refused with
invalid_currency.
Each sale stores currency, the rate on the day, price in that currency and priceUsd,
the same price back in US cents. Profit and margin are always worked out from priceUsd, and
GET /sales/summary reports US cents whatever the sales were charged in.
Before you change a list
POST /price-lists/{id}/impact takes the changes you are thinking of making, in the same
shape as an update, and saves nothing.
curl -X POST https://resellerhq.com/api/v1/price-lists/prl_01K6XJ4Q8ZT3M9R5V2B7WDHNCA/impact \
-H "Authorization: Bearer $RHQ_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "markupBps": 3000 }'It answers with:
customers: how many customers are on the list.items: how many active domains, licences and numbers they hold.data: up to 50 of those items, each withcurrentandproposed, the unit price of its next renewal now and after the change. Contract prices, prices fixed on items and held renewals are all taken into account, so an item they cover shows no change.changed: how many of the items indatawould change price.
Send { "isDefault": true } to look at making the list the default: the customers with no
list of their own are counted too, at what they pay today.
When you do update a list and something changes, a price_list.updated
webhook event is sent with:
idandnameof the list.changed: the settings that moved, such as["markupBps", "rules"].customersandcustomerIds: how many customers the list prices and who they are, up to 1,000 ids.items: how many active domains, licences and numbers those customers hold.
Use it to tell your customers. Saving a list with nothing different sends nothing.
A change applies from the next purchase or renewal. There is no effective date and no
notice period yet: to give customers notice, tell them first and make the change on the day
it should start. The event does not carry old and new prices; call impact before you save
if you need them.
When a price falls under wholesale
A fixed price, a contract price or a price on one item can end up under what the item costs
you. What happens is the list's allowBelowCost:
false, the default: the price is raised to wholesale. The sale hasfloored: true.true: the sale is made at the price set, at a loss.
Either way a sale.below_cost webhook event is sent with the saleId,
customerId, product, reference, the level and ruleId that set the price,
unitCost, unitPrice, currency and raised, which is true when the price was raised.
Use it to find the fixed price that needs another look.
Customer prices in the catalogue
The routes that list what you can buy take an optional customerId and then return, beside
your wholesale price, what that customer pays for a first purchase of one unit:
| Route | Wholesale | Added with customerId |
|---|---|---|
GET /domains/search | price, privacyPrice | customerPrice, customerCurrency, customerPrivacyPrice |
POST /domains/check | price, privacyPrice | customerPrice, customerCurrency, customerPrivacyPrice |
GET /licences/plans | price | customerPrice, customerCurrency |
GET /numbers/offers | amount in each of prices | customerPrice, customerCurrency in each |
curl "https://resellerhq.com/api/v1/licences/plans?customerId=cus_01K6XJ4Q8ZT3M9R5V2B7WDHNCA" \
-H "Authorization: Bearer $RHQ_API_KEY"The price is for a quantity of one, from the customer's contract and list. For a volume tier, ask for a quote with the quantity.
Quote before you sell
POST /price-lists/quote works out what a customer pays for items at the wholesale prices
you give it, without buying anything. Take the wholesale price from the product's own route,
such as a domain search or the licence plans.
curl -X POST https://resellerhq.com/api/v1/price-lists/quote \
-H "Authorization: Bearer $RHQ_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"customerId": "cus_01K6XJ4Q8ZT3M9R5V2B7WDHNCA",
"items": [
{ "product": "domain", "match": "com", "action": "new", "cost": 1319 },
{ "product": "licence", "match": "google-workspace-starter", "quantity": 25, "cost": 840 }
]
}'costis always in US cents.quantityis how many are bought together, for volume tiers, and is 1 when left out.- Each answer has
priceand itscurrency,priceUsd, thelevelandruleIdthat set it, andflooredwhen it was raised to wholesale. - With
customerId, the customer's contract prices count. Prices fixed on single items do not, since a quote names no item.
Sales and margin
Each purchase or renewal made with a customerId adds a sale: what it cost you, what the
customer pays and in which currency, and the level, list and rule that set the price. The
price is fixed when the sale is made, so changing a list later never rewrites what was
already sold. The monthly renewal of a mailbox licence adds a sale
too.
GET /saleslists them, newest first.GET /sales/summarygives the totals, your profit and your margin, in US cents.
Both take the same filters, and with none cover the whole account:
| Filter | Narrows to |
|---|---|
customerId | One customer |
product | number, domain, domain_privacy or licence |
priceListId | Sales priced from one list |
level | What set the price: override, contract, list or none |
from, to | Sales made at or after from and before to |
GET /sales/summary?groupBy= splits the totals as well: by product, customer,
priceList, level or month (the calendar month in UTC, as 2026-10). The answer gains
groups, one row for each value with its key and the same totals.
curl "https://resellerhq.com/api/v1/sales/summary?from=2026-10-01T00:00:00.000Z&groupBy=product" \
-H "Authorization: Bearer $RHQ_API_KEY"A sale carries floored, true when its price was raised to wholesale, and for a number its
term. A sale keeps the id of the rule that priced it, not a copy of the rule: editing the
rule later does not change the sale, but what the rule said then is not kept.
Cancelled numbers
A number that is cancelled and refunded in full was not sold, so:
- A sale not yet invoiced is removed.
- A sale on an open invoice takes the invoice with it. The invoice is voided, you get
invoice.voided, and its other sales are free to go on a new one. - A sale on a paid invoice stays, and the invoice reads as it was paid. A credit is recorded
beside it: a sale with
creditOfset to the first one andquantity,cost,priceandpriceUsdunder zero. The totals stop counting the number, the customer'suninvoicedbalance goes down by what they are owed, and the credit comes off their next invoice.
Invoices
An invoice bills one customer for sales you have already made for them. Each line is a copy of a sale at the price it was made at, so an invoice reads the same for good.
ResellerHQ does not take payment from your customers. There is no card form, no payment
link and no payout. You collect the money however you already do, by bank transfer, your own
card processor or cash, and then mark the invoice paid here. paid is your own record that
the money arrived, and nothing checks it.
curl -X POST https://resellerhq.com/api/v1/invoices \
-H "Authorization: Bearer $RHQ_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 2f1c9a52-7d0e-4a8e-9a53-0c1f6f3f2b11" \
-d '{ "customerId": "cus_01K6XJ4Q8ZT3M9R5V2B7WDHNCA", "dueInDays": 14, "taxBps": 2000 }'- With
customerIdalone, the invoice takes every sale of theirs that is not on an invoice yet, oldest first.saleIdsbills only the sales you name;GET /sales?invoiced=falselists the ones still waiting. dueInDaysis 14 when left out.taxBpsadds tax as a share of the subtotal, in basis points, rounded to the nearest minor unit; left out, there is no tax.noteis free text.- Invoices are numbered
INV-0001,INV-0002and so on across your account. A number is never used twice, and a refused request uses none. - An invoice is in one currency. If the customer has sales in two, the request is refused
with
mixed_currencies; sendcurrencyto bill one of them at a time. - An invoice holds at most 500 sales. With more waiting, the oldest 500 go on it and the rest go on the next.
| Error | Why |
|---|---|
nothing_to_invoice | The customer has no sales waiting to be invoiced |
mixed_currencies | The sales are in more than one currency |
sale_unavailable | A sale you named is not this customer's, or is already on an invoice |
invoice_not_open | The invoice is already paid or void |
invalid_paid_at | paidAt is in the future, or more than a year before the invoice was issued |
nothing_to_invoice is also the answer when the sales come to less than nothing, which
credits can do: a credit goes on an invoice with sales that cover it. mixed_currencies is
also the answer when a sale you named is not in the currency you sent.
A sale is on at most one invoice that still stands. Each sale carries invoiceId, which is
null until it is invoiced.
Your billing details
PATCH /account/billing sets how you are identified on invoices, and the defaults a new one
falls back to when you do not say otherwise.
curl -X PATCH https://resellerhq.com/api/v1/account/billing \
-H "Authorization: Bearer $RHQ_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"legalName": "Northwind Telecom Ltd",
"addressLines": ["1 Market Street", "San Francisco, CA 94105"],
"taxId": "12-3456789",
"billingEmail": "billing@northwind.example",
"defaultDueDays": 30,
"defaultTaxBps": 2000,
"invoiceNote": "Thank you for your business"
}'- Send only what changes;
nullclears a field.GET /accountreads it all back underbilling,nullfor anything you have not set andaddressLines: []for none. defaultDueDays,defaultTaxBpsandinvoiceNoteare used on a new invoice only when that invoice's owndueInDays,taxBpsornoteis left out; sending one,0included, always wins over the default. With neither a default nor a value sent,dueInDaysis 14.- Every invoice keeps
issuer: a copy of your name and billing details as they stood the moment it was made. Changing your billing details later never rewrites an invoice already issued, andissuerisnullon one made before you had set any. - Live and sandbox are separate accounts, so each keeps billing details of its own.
Paid and void
| Route | What it does |
|---|---|
GET /invoices | Lists invoices, newest first. Narrow with customerId, status, overdue or q, which looks in the invoice number and the customer's name and email |
GET /invoices/{id} | One invoice with its lines |
POST /invoices/{id}/pay | Marks it paid. paidAt is when the money arrived, and is now when left out |
POST /invoices/{id}/void | Withdraws it and frees its sales to go on another invoice |
POST /invoices/{id}/share | Creates a public link to the invoice, or returns the one it already has |
DELETE /invoices/{id}/share | Revokes the public link; on an invoice with none, this is also a no-op |
An invoice is open until you mark it paid or void. Both are final: a paid invoice
cannot be voided or paid again, and a void one cannot be reopened. To correct an open
invoice, void it and create a new one, which takes the next number.
The due date is a whole day. An open invoice has overdue: true from 24 hours after
dueAt, when that day has ended everywhere, so an invoice made with dueInDays: 0 is due
today and not overdue today. Nothing else happens to an overdue invoice, and no reminder is
sent.
POST /invoices/{id}/share is how you hand an invoice to the customer it bills: it answers
with { token, createdAt }, and the invoice is then readable at
GET /public/invoices/{token} with no API key at all. Pressing the button twice is safe,
since a second call returns the same token rather than minting a new one. The public page
shows that one invoice and nothing else: no id, no customer id, no sale ids, not even the
token itself. DELETE /invoices/{id}/share kills the link at once; sharing again afterwards
hands out a different token, and the old one answers 404 for good.
What a customer owes
GET /customers/{id}/balance answers with one row for each currency the customer has been
sold in:
| Field | What it holds |
|---|---|
uninvoiced | Sold but not on an invoice yet. Before tax, since tax is set on the invoice. Under zero when the customer is owed a credit |
open | On invoices waiting to be paid, overdue ones included |
overdue | The part of open whose due date has passed |
paid | On invoices you marked paid |
Three webhook events follow an invoice: invoice.created, invoice.paid
and invoice.voided. Each carries the invoice's id, number, customerId, currency,
total and status.
Errors and limits
An account holds at most 50 price lists. A list, and a customer's contract, hold at most 200 rules each.
| Error | Status | Why |
|---|---|---|
too_many_price_lists | 409 | The account already has 50 lists |
too_many_rules | 422 | More than 200 rules were sent |
duplicate_rule | 422 | Two rules cover exactly the same items, share an id, or are two contract prices in date at once |
invalid_dates | 422 | A contract price ends before it starts |
invalid_rate | 422 | A list in USD was given a rate other than 1000000, or another currency was given none |
invalid_currency | 422 | The currency is not one a list can charge in |
default_price_list | 409 | The default list cannot be removed while other lists exist |
A rate or a rule outside its allowed range is a 400 validation_error, like any malformed
request.
Not built yet
- A date for a list change to take effect, and a notice period before it.
- A customer price in the dashboard's renew, extend and change-seats dialogs. The sale is recorded at the right price all the same.
- Lists that inherit from the default list. A customer's own list replaces it entirely.
- Rule versions, a percentage uplift at renewal, and a floor that counts fees as well as wholesale.
- Different prices for the length of a domain registration in years beyond volume tiers, and a set-up price for a number apart from its rental.
- Sending an invoice to the customer, reminders, scheduled invoicing, and editing an open invoice.
- Collecting payment from your customers. An invoice is a record; you take the money.