Turn an accepted quote into an order
Accepting a quote does not create an order. The acceptance call answers with an order draft, a body shaped for the order call, and it is your code that places the order and then tells the quote which order took which lines. This page walks that hand-over end to end: a quote is requested and priced, the buyer accepts part of it, you place the order from the draft, you write the order back, and the buyer comes back for the rest.
This is for developers wiring a B2B storefront, a sales tool or an ERP sync to quotes. You need a tenant with the Quotes and Orders apps and an API key with the quotes.* and orders.* scopes (looking up topics in the event catalog has no documented scope yet, so a development key with * covers it; see which scope covers which app). New to the platform? Prerequisites covers Cockpit, your tenant slug, installing an app and a test tenant. Every call goes through the API gateway with the same two headers. Make these calls from your backend and keep the key out of the browser:
export REVENEXX_TENANT="<TENANT_SLUG>"
export REVENEXX_API_KEY="rvxk_..."
The examples use curl and jq, and a value in angle brackets is an id from your own data.
What the Quotes App does and does not do
- It does not place the order. Orders mints the order and its number. Quotes only hands over a draft and records, afterwards, which order took which lines.
- There is no
orderedstatus. After the write-back the quote is stillaccepted(orpartially_accepted). The order id is recorded on the lines. - It does not look prices up. The prices on a quote are what you or your sales staff send to the
pricecall. A tenant whose prices come from an ERP replaces that pricing call. - A line is accepted or declined whole. Partial acceptance chooses lines, not quantities.
- Every quote has a number. A range named
quote(prefixANG-) numbers them.
Number ranges are optional to set up. Quotes and orders each create their standard range the first time they need one. To see and adjust the ranges before the first quote, call POST /v1/quotes/number-ranges/defaults and POST /v1/orders/number-ranges/defaults with an empty {} body. Both are idempotent. Placing an order answers 422 with the code number_range_missing only when the tenant's order range setting names a range that does not exist: create that range, or point the setting at one that does. See Number ranges.
The path from request to order
request or create ─▶ price ─▶ accept (all or some lines) ─▶ order_draft
▲ │ │ │
└── re-price ──┘ accept the rest later POST /orders/place
│
POST /quotes/quotes/{id}/ordered
1. Open a quote
A quote starts one of two ways.
A buyer asks for one, and it starts as requested:
QUOTE=$(curl -s -X POST https://api.revenexx.com/v1/quotes/request \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"currency": "EUR",
"contact_id": "<CONTACT_ID>",
"organization_id": "<ORGANIZATION_ID>",
"buyer_note": "We order this every quarter. What is the price for 500?",
"shipping_address": { "name": "Acme GmbH", "street": "Hauptstr. 1", "zip": "10115", "city": "Berlin", "country": "DE" },
"items": [
{ "sku": "WIDGET-1", "name": "Widget", "quantity": 500, "list_price": 12.50, "tax_rate": 19 },
{ "sku": "GADGET-2", "name": "Gadget", "quantity": 40, "list_price": 89.00, "tax_rate": 19 }
]
}')
QUOTE_ID=$(printf '%s' "$QUOTE" | jq -r .id)
printf '%s' "$QUOTE" | jq '.items[] | {id, sku}'
The last line prints the line ids you price and accept by. GET /v1/quotes/quotes/{id} reads the quote back without its positions; GET /v1/quotes/quotes/{id}/detail returns the positions, the trail and the attachments as well.
Sales opens one, with POST /v1/quotes/quotes and the same body plus owner_id and seller_note, and read the id the same way. It starts as in_review.
<CONTACT_ID> and <ORGANIZATION_ID> are the buyer's ids from Customers. A call your backend makes with only an API key is not attributed to a buyer, so the body says who is asking.
currency and items are required. currency is a three-letter code, and each item needs a name and a quantity above 0. A quote holds at most 500 lines. Each item can carry sku, product_id, unit, list_price, tax_rate and metadata. The answer is the quote with its items, its number and its status.
The statuses, and where they lead:
| Status | Meaning | Next |
|---|---|---|
requested | A buyer asked. | in_review, priced, rejected, expired |
in_review | Sales owns it. | priced, rejected, expired |
quoted | Priced and waiting for the buyer. | accepted, partially_accepted, declined, expired, priced again |
partially_accepted | Some lines decided, some open. Not final. | accepted, declined, expired, more lines accepted |
accepted, declined, rejected, expired | Final. | none |
To claim a requested quote for a person, POST /v1/quotes/quotes/{id}/review with an owner_id. POST /v1/quotes/quotes/{id}/reject with a reason is the merchant refusing it. GET /v1/quotes/quotes?status=quoted,partially_accepted lists the quotes that are waiting on a customer.
Find the open quotes that name a product
GET /v1/quotes/open-items lists every position of an open quote that names one product, across all quotes. Each row is the position with its quote's quote_number and quote_status, newest quote first, then by position. A quote is open while it is requested, in_review, quoted or partially_accepted; pass status with a comma-separated list of states to read other states.
Ask this before you delete a product for good: a quote that is still being negotiated holds the delete, and this call shows which open quotes still name the product.
curl -s "https://api.revenexx.com/v1/quotes/open-items?product_id=<PRODUCT_ID>&limit=50&offset=0" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
| jq '{total: .page.total, hasMore: .page.hasMore, rows: [.items[] | {quote_number, quote_status, name, quantity}]}'
product_id is required and must be a uuid; without it the call answers 400, and so does a status that names a state a quote cannot be in. limit defaults to 50 and is capped at 200: a larger value is clamped, not refused. The answer has items, a filter that echoes the product and the states read as open, and a page with total (every match, ignoring the page window), limit, offset, returned and hasMore. hasMore is true while offset + returned is below total, so read the next page with a higher offset. A call attributed to a buyer reads only that buyer's organisation's quotes.
2. Price it
Pricing sets what each line costs, the carriage and how long the offer holds. It moves the quote to quoted.
curl -s -X POST "https://api.revenexx.com/v1/quotes/quotes/$QUOTE_ID/price" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"items": [
{ "id": "<ITEM_ID_1>", "quoted_price": 11.25, "price_reason": "volume" },
{ "id": "<ITEM_ID_2>", "quoted_price": 80.10, "price_reason": "volume" }
],
"shipping_amount": 25,
"shipping_tax_rate": 19,
"valid_until": "2026-12-31T23:59:59Z",
"seller_note": "Volume price, valid to the end of the year."
}'
<ITEM_ID_1> and <ITEM_ID_2> are the line ids printed in step 1. You can read them again from GET /v1/quotes/quotes/{id}/items.
itemsnames the lines to price by theirid, each with aquoted_price(0 or more), an optionalprice_reason(volume,competition,relationship,clearance,cost_changeorother) and alead_time_days.shipping_amountandshipping_tax_rate(0 to 100) are the carriage, net.grand_totalis the quoted goods plus carriage, before tax. Beside it the quote storestax_total(every position's line total at its owntax_rate, plus the carriage atshipping_tax_rate; a position or carriage without a rate carries none) andgross_total, what the buyer pays with tax:grand_totalplustax_total. Both are stored wherevergrand_totalis (when the quote is created, on a pricing round and when one position is priced), and returned on the quote and on the accept response. You never send them: the quote computes both and ignores any you send.valid_untilis when the offer ends. Left out, it is now plus the tenant's default (30 days). A re-price does not extend an existing date. Send a newvalid_untilwhen you mean to.- A quote that is
requested,in_revieworquotedcan be priced. Pricing a decided or expired quote is a422. sender_country(optional) is the ISO 3166-1 alpha-2 code of the country the goods are sent from. It sets how the address lines on the quote you send print in capitals, and it is never stored. Anything that is not a country's code is a400. Omit it and the market's ship-from country counts, else the market code when it is a country's code. The addresses freeze the first time the quote is sent and later rounds keep those lines, so a valid value on a re-price changes nothing (an invalid one is still a400). The full rule is on Orders.
To attach a drawing, a datasheet or a signed document, POST /v1/quotes/quotes/{id}/attachments with a filename, a file_ref that points to the file in your own storage and a visibility of internal or customer. The app keeps the reference only, not the file.
While the quote is requested, in_review or quoted, PATCH /v1/quotes/quotes/{id}/items/{item_id} changes one line: it sets the line's negotiated price, price reason and lead time and re-totals the line and the quote. It sends nothing to the buyer and writes no event, so send the quote again with POST .../price to tell them. A key you leave out keeps its value, and null clears it.
Pricing a quote the buyer has already seen again writes a new revision by default, and fires quote.quoted once per round. To talk to the buyer in between, POST /v1/quotes/quotes/{id}/events with a body and a visibility: internal (the default, for your team) or customer. Only a customer note fires quote.message.
3. The buyer accepts
Accepting everything still open takes no positions in the body. Send an empty object:
The calls below save each answer to a file of its own ($ACCEPT_FILE, never overwritten), because the order draft in it comes only once. Keep the file.
ACCEPT_FILE="accept-$QUOTE_ID-$(date +%s).json"
curl -s -o "$ACCEPT_FILE" -X POST "https://api.revenexx.com/v1/quotes/quotes/$QUOTE_ID/accept" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" -d '{}'
jq -e .order_draft "$ACCEPT_FILE" > /dev/null || echo "no order draft in the answer: read $ACCEPT_FILE"
Accepting some lines (use this instead of the call above) names them with a decision each (accepted by default, or declined):
ACCEPT_FILE="accept-$QUOTE_ID-$(date +%s).json"
curl -s -o "$ACCEPT_FILE" -X POST "https://api.revenexx.com/v1/quotes/quotes/$QUOTE_ID/accept" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "items": [ { "id": "<ITEM_ID>", "decision": "accepted" } ] }'
jq -e .order_draft "$ACCEPT_FILE" > /dev/null || echo "no order draft in the answer: read $ACCEPT_FILE"
The quote becomes accepted when no line is left open, declined when no line is left open and none was accepted, and partially_accepted while some lines are still open.
409 while the quote is partially_accepted, a 422 once it is accepted). Store the order_draft durably before you place the order. If placing the order fails after the acceptance, the draft you saved is all you have, and the carriage on a first acceptance is not offered a second time.What an acceptance refuses:
| Answer | When |
|---|---|
400 | A named position is not on the quote, a decision is neither accepted nor declined, or the named set is empty. |
403 | The call is attributed to a buyer, the tenant accepts only from the person who asked, and someone else is accepting. |
404 | The call is attributed to a buyer and the quote belongs to another organization. |
409 | A named line is already decided, or every line is. |
422 | The quote is not open to acceptance: it was never priced, it expired, it is already accepted, declined or rejected, or the tenant does not let a quote be taken apart and you named lines. |
A quote past its valid_until cannot be accepted, whether or not the expiry sweep has moved it to expired yet. A merchant can set a grace period (expiry_grace_hours), but the hourly sweep does not honour it: within about an hour of valid_until the quote shows as expired, and accepting it is refused with a 422 ("a quote in expired cannot be accepted") (as of 2026-09). Do not promise a buyer more than that hour. Who may accept is a tenant setting: acceptance_scope is organization (anyone in the buyer's organization, the default) or requester (only the person who asked), and partial acceptance can be switched off so that a quote is taken whole.
GET /v1/quotes/quotes/{id}/detail?audience=customer, which leaves out internal notes and files.4. Place the order from the draft
The answer has an order_draft covering only the lines this call accepted:
{
"currency": "EUR",
"contact_id": "…",
"organization_id": "…",
"shipping_address": { "name": "Acme GmbH", "street": "Hauptstr. 1", "zip": "10115", "city": "Berlin", "country": "DE" },
"shipping": { "method": "quote", "price": 25, "tax_rate": 19 },
"items": [
{ "sku": "WIDGET-1", "name": "Widget", "quantity": 500, "unit_price": 11.25, "tax_rate": 19 }
],
"metadata": { "quote_id": "…", "quote_number": "ANG-000001" }
}
unit_priceis the negotiated price: the quoted price, else the list price, else 0.shippingappears only on the first acceptance, and only when the quote has carriage. A later acceptance has noshippingkey at all (not a zero), so the freight lands on one order only.metadatacarries the quote's id and number, and lands on the order.- Fields the quote does not hold (
buyer,billing_address,cart_id, a line'sproduct_idorsku) are left out, not null. A line with notax_rateon the quote comes through without one, and the order then records a rate of 0.
The draft has the shape POST /v1/orders/place takes, so post it as it is. One exception: when you place the order as a signed-in buyer, the draft's contact_id must be that buyer's. A colleague accepting under the organization scope would be refused with a 400, so drop contact_id from the draft or match it. Add what only you know, such as channel_id or payment (both are optional), and do not send a grand_total: Orders computes it, and a total you send that does not match is a 400.
ORDER_ID=$(jq '.order_draft | . + {customer_order_number: .metadata.quote_number}' "$ACCEPT_FILE" \
| curl -s -X POST https://api.revenexx.com/v1/orders/place \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" -d @- \
| jq -r .id)
if [ -z "$ORDER_ID" ] || [ "$ORDER_ID" = null ]; then echo "order was not placed: do not write back"; fi
The last line of the snippet stops you before step 5 unless Orders answered with a real order id. The draft comes from the file you saved in step 3 ($ACCEPT_FILE, or its name if you are in a new shell), so this step still works in a new shell. Orders does not deduplicate a repeated place: posting the same draft twice makes two orders. If a place call timed out or its answer was lost, look for it before you post the draft again: the snippet above sets the order's customer_order_number to the quote number, so GET /v1/orders?customer_order_number=<QUOTE_NUMBER> finds it. The field is not unique, so after two partial acceptances the same quote number sits on two orders: compare their lines. A filter on metadata does not exist, and Orders ignores an unknown query parameter and answers with the unfiltered list. If the draft has no items, there is nothing to order: that happens when every open line was declined, and you skip this step.
An order holds at most the tenant's max_items_per_order lines (500 by default), so a longer draft is refused with a 400. Look at the status in the answer before you tell the buyer the order is placed. Orders can answer 201 with status: "pending" and no placed_at, when the caller may only request orders or the order is above the tenant's approval threshold (see purchase approvals for how that differs from a purchase request). See Orders.
5. Write the order back to the quote
Orders mints the order id after the draft was handed over, so the quote cannot know it. Tell it, and only once you hold a real order id. A write-back with a wrong id marks the lines as ordered, and repeating it is then refused:
if [ -z "$ORDER_ID" ] || [ "$ORDER_ID" = null ]; then
echo "no order id: do not write back"
else
curl -s -X POST "https://api.revenexx.com/v1/quotes/quotes/$QUOTE_ID/ordered" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d "{ \"order_id\": \"$ORDER_ID\" }"
fi
The answer is { quote_id, order_id, items }, where items is the number of lines marked. By default it marks every line that is accepted and has no order yet. Pass item_ids to name the lines instead, for example when two orders are open on the same quote. It marks the lines you name, accepted or not. Ids that match no line are ignored, and if none of them matches, the answer is a 409.
- It changes no status and sets no number. It only records
order_idon the lines. - Repeating it is safe only without
item_ids. Nothing is left to mark, so the answer is409("no accepted position is waiting for an order"). Withitem_ids, it re-marks those lines and overwrites theirorder_id, without a check. - Skipping it leaves the quote unable to say which order came from it. The order exists and the quote stays
accepted, but the lines carry noorder_id, and a later write-back withoutitem_idswill mark them. Do it right after you place the order, and retry it if it fails. If you skip it for one order and later write back a second order withoutitem_ids, every line still unmarked gets the second order's id, so name the lines withitem_idswhenever two orders can be open.
6. The rest of the quote
partially_accepted is not the end. The buyer can accept more lines later, and that produces a second order draft and a second order (without carriage), which you place and write back in the same way. If you come back in a new shell, set the variables again first: REVENEXX_TENANT and REVENEXX_API_KEY from the top of the page, QUOTE_ID, and the id of the line still open, read from GET /v1/quotes/quotes/$QUOTE_ID/items. The second acceptance is bound by valid_until like the first. Declining the rest with POST /v1/quotes/quotes/{id}/decline ends the whole quote as declined, which is final, even though some lines were accepted and ordered. The quote.decided event then reports no accepted lines and all lines as declined. To end the quote as accepted instead, accept the remaining lines with "decision": "declined".
A quote that nobody accepts expires. An hourly sweep moves a quote that is requested, in_review, quoted or partially_accepted past its valid_until to expired and fires quote.expired. Only a quote with a valid_until can expire, and pricing sets one. You can run the sweep yourself with POST /v1/quotes/expire and an empty {} body. The hourly run and the manual call do not behave alike: only the manual call honours the tenant's auto_expire_enabled and expiry_warning_days settings and sends quote.expiring, and neither one honours the grace period, so running it often closes that window early (as of 2026-09).
7. Listen for what happened
| Event | Fires when | Carries |
|---|---|---|
quote.requested | A buyer requested a quote, or sales opened one. | quote_id, number, origin, currency, list_total, item_count |
quote.quoted | A pricing round. Once per round. | quote_id, number, totals, shipping_amount, valid_until, revision, the buyer's contact details, seller_note |
quote.message | A note with visibility: customer. | quote_id, number, body, from, the buyer's contact details |
quote.decided | A buyer accepted or declined. | quote_id, number, status, accepted_items, declined_items, order_id |
quote.rejected | The merchant refused a quote. | quote_id, number, reason, the buyer's contact details |
quote.expiring | A quote is within the warning window of its valid_until. | quote_id, number, valid_until, days_left, totals |
quote.expired | The sweep expired a quote. | quote_id, number, valid_until |
Subscribe with the exact topic strings your tenant's catalog lists (GET /v1/events/catalog; Event types explains how topics are named).
quote.decided carries an order_id field, and it is always empty. The order does not exist yet when the buyer accepts. Take the order id from the order you place, not from this event.quote.expiring fires only from a call to POST /v1/quotes/expire, not from the hourly sweep (as of 2026-09). If you depend on the warning, call it on your own schedule.Troubleshooting
422placing the order, codenumber_range_missing. The tenant's order range setting names a range that does not exist. Create it, or point the setting at one that does (see the note on number ranges at the top).409or422on accept, on lines you already accepted. The first acceptance has happened, and the draft was in its answer. Use the copy you stored.422on accept: "this quote expired on …".valid_untilhas passed, or the sweep has already moved the quote toexpired("a quote in expired cannot be accepted"). An expired quote is final. Open a new quote for the lines that are still wanted.422on accept: "partial acceptance is switched off". The tenant takes a quote whole. Accept with an empty{}body.403on accept. The call is attributed to a buyer, the tenant'sacceptance_scopeisrequester, and another contact is accepting.404on a quote that exists. The call is attributed to a buyer from another organization.400placing the order naming two totals. You sent agrand_totalthat Orders computed differently. Leave it out.- The order has no freight. The buyer accepted in two steps, and freight is on the first draft only. Check the first
order_draft. - The re-priced quote still expires on the old date. A re-price keeps
valid_until. Send a new one. - The same quote shows two orders. That is correct after two partial acceptances. Each order took its own lines, and the lines carry their own
order_id. 401or403. The tenant slug or key is wrong, or the key has no access to Quotes. See API keys and Errors.
Where to go next
- Ship, cancel and return an order — what happens to the order after you place it: shipment, cancellation, return and refund.
- Orders — the order reference.
- Number ranges — the order and quote ranges.
- Sign a buyer in — how a storefront attributes calls to a buyer.
- Set up purchase approvals — the other way a B2B order waits for someone.
- Set up B2B pricing — contract prices, before a quote is needed.
- Run promotions and voucher codes — reduce a priced cart with an offer or a code.