Turn an accepted quote into an order

Take a B2B quote from request to order over the Quotes and Orders APIs — open and price a quote, let the buyer accept all or part of it, place the order from the order draft the acceptance returns, and write the order back to the quote.

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:

Environment
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 ordered status. After the write-back the quote is still accepted (or partially_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 price call. 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 (prefix ANG-) 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

text
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:

A buyer requests a quote
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:

StatusMeaningNext
requestedA buyer asked.in_review, priced, rejected, expired
in_reviewSales owns it.priced, rejected, expired
quotedPriced and waiting for the buyer.accepted, partially_accepted, declined, expired, priced again
partially_acceptedSome lines decided, some open. Not final.accepted, declined, expired, more lines accepted
accepted, declined, rejected, expiredFinal.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.

Open quote positions for a 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.

Price the quote
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.

  • items names the lines to price by their id, each with a quoted_price (0 or more), an optional price_reason (volume, competition, relationship, clearance, cost_change or other) and a lead_time_days.
  • shipping_amount and shipping_tax_rate (0 to 100) are the carriage, net. grand_total is the quoted goods plus carriage, before tax. Beside it the quote stores tax_total (every position's line total at its own tax_rate, plus the carriage at shipping_tax_rate; a position or carriage without a rate carries none) and gross_total, what the buyer pays with tax: grand_total plus tax_total. Both are stored wherever grand_total is (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_until is 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 new valid_until when you mean to.
  • A quote that is requested, in_review or quoted can be priced. Pricing a decided or expired quote is a 422.
  • 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 a 400. 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 a 400). 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 all
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 one line
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.

The order draft comes once, and only in this answer. There is no call to fetch it again, and accepting the same lines again is refused (a 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:

AnswerWhen
400A named position is not on the quote, a decision is neither accepted nor declined, or the named set is empty.
403The call is attributed to a buyer, the tenant accepts only from the person who asked, and someone else is accepting.
404The call is attributed to a buyer and the quote belongs to another organization.
409A named line is already decided, or every line is.
422The 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.

The acceptance answer carries the whole quote, internal notes and attachments included. It is not the buyer's view. A storefront must pick what it shows out of the answer rather than forward it to a browser. For the buyer's own view of a quote, read 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:

order_draft
{
  "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_price is the negotiated price: the quoted price, else the list price, else 0.
  • shipping appears only on the first acceptance, and only when the quote has carriage. A later acceptance has no shipping key at all (not a zero), so the freight lands on one order only.
  • metadata carries the quote's id and number, and lands on the order.
  • Fields the quote does not hold (buyer, billing_address, cart_id, a line's product_id or sku) are left out, not null. A line with no tax_rate on 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.

Place the order
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:

Write back
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_id on the lines.
  • Repeating it is safe only without item_ids. Nothing is left to mark, so the answer is 409 ("no accepted position is waiting for an order"). With item_ids, it re-marks those lines and overwrites their order_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 no order_id, and a later write-back without item_ids will 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 without item_ids, every line still unmarked gets the second order's id, so name the lines with item_ids whenever 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

EventFires whenCarries
quote.requestedA buyer requested a quote, or sales opened one.quote_id, number, origin, currency, list_total, item_count
quote.quotedA pricing round. Once per round.quote_id, number, totals, shipping_amount, valid_until, revision, the buyer's contact details, seller_note
quote.messageA note with visibility: customer.quote_id, number, body, from, the buyer's contact details
quote.decidedA buyer accepted or declined.quote_id, number, status, accepted_items, declined_items, order_id
quote.rejectedThe merchant refused a quote.quote_id, number, reason, the buyer's contact details
quote.expiringA quote is within the warning window of its valid_until.quote_id, number, valid_until, days_left, totals
quote.expiredThe 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

  • 422 placing the order, code number_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).
  • 409 or 422 on accept, on lines you already accepted. The first acceptance has happened, and the draft was in its answer. Use the copy you stored.
  • 422 on accept: "this quote expired on …". valid_until has passed, or the sweep has already moved the quote to expired ("a quote in expired cannot be accepted"). An expired quote is final. Open a new quote for the lines that are still wanted.
  • 422 on accept: "partial acceptance is switched off". The tenant takes a quote whole. Accept with an empty {} body.
  • 403 on accept. The call is attributed to a buyer, the tenant's acceptance_scope is requester, and another contact is accepting.
  • 404 on a quote that exists. The call is attributed to a buyer from another organization.
  • 400 placing the order naming two totals. You sent a grand_total that 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.
  • 401 or 403. The tenant slug or key is wrong, or the key has no access to Quotes. See API keys and Errors.

Where to go next

Was this page helpful?