Contents
Webshop API – developer guide
The BrewOS webshop API connects the BrewOS account holder's own webshop to BrewOS. The account holder may be, for example, a brewery or an importer. Through the API, the webshop can:
- fetch the account's products, prices and available stock
- log in a holder of an on-premises, retail or wholesale licence
- pass the buyer's order on to BrewOS
- pass on a consumer's order, which is reported under the account holder's own retail licence
Payment for a licence holder's order does not go through the webshop. The order is created in BrewOS as a shipment, the account holder picks and delivers it, and the buyer receives an invoice from BrewOS as before. Payment for a consumer order is handled in the webshop, and BrewOS does not invoice the consumer.
The API can be used in three ways:
- A webshop for alcohol licence holders (on-premises, retail, wholesale): products, prices, stock and orders.
- A consumer-facing website, for example a product showcase or
availability information on the account holder's website. Fetching products and stock
(
GET /products,GET /stock) does not require a buyer login, see Consumer-facing website. - A consumer webshop: a consumer's order for pickup or home delivery
(
POST /consumer-orders), if the account holder has enabled consumer sales. See Consumer orders.
POST /orders requires a buyer who has logged in with their outlet's alcohol
licence number, and BrewOS checks the licence with every order.
A consumer order is placed on its own path, and it is reported under the account holder's
retail licence.
This page is intended for the developer of the webshop. The API key and the buyers' webshop passwords are created by the account holder in their own BrewOS account.
Quick start
- Ask the account holder for an API key. The account owner creates it in BrewOS under
Settings → Webshops. The key has the form
bws_…and is shown only once. Test the connection:
curl -H "Authorization: Bearer bws_1_…" https://brewos.fi/rest/webshop/v1/webshopFetch the products once a day (
GET /products) and, if needed, the stock more often (GET /stock).- Log the buyer in with the licence number and password
(
POST /buyers/authenticate) and keep the returnedbuyer_tokenin the buyer's session. - Send the order (
POST /orders) with the buyer token and a uniqueIdempotency-Keyheader. - Follow the orders (
GET /orders?updated_since=…) if you want to show the buyer the status of the order. - Show the buyer their earlier orders (
GET /buyer/orders) if the webshop has an order history page.
Endpoints
All paths are under https://brewos.fi/rest/webshop/v1. Every call requires the API key
(Authorization: Bearer …). Buyer calls also require the X-Buyer-Token header,
and sending an order the Idempotency-Key header.
| Method | Path | Headers | Purpose |
|---|---|---|---|
GET |
/webshop |
key | Connection test: webshop, account, consumer sales settings and server time |
GET |
/products |
key | Published products with prices and free stock |
GET |
/stock |
key | Product identifiers and free stock |
POST |
/buyers/authenticate |
key | Buyer login with licence number and password |
GET |
/buyer |
key + X-Buyer-Token |
The logged-in buyer's details, licence and contract prices |
GET |
/buyer/orders |
key + X-Buyer-Token |
The buyer's earlier orders: value and delivery and billing status |
POST |
/orders |
key + X-Buyer-Token + Idempotency-Key |
Send a licence holder's order |
GET |
/orders/{order_id} |
key | One of this webshop's orders with its lines |
GET |
/orders?updated_since=… |
key | The webshop's changed orders, oldest first |
POST |
/consumer-orders |
key + Idempotency-Key |
Send a consumer order |
GET |
/consumer-orders/{order_id} |
key | One consumer order with contact details |
POST |
/consumer-orders/{order_id}/payment |
key | Mark a consumer order paid |
POST |
/consumer-orders/{order_id}/cancel |
key | Cancel a consumer order |
Basics
Address and format
All paths are under the address https://brewos.fi/rest/webshop/v1. Requests and responses are
JSON (UTF-8). Send the header Content-Type: application/json with POST requests.
| Item | Format |
|---|---|
| Amounts of money | integers in cents, e.g. 239 = €2.39 |
| VAT | as a percentage, e.g. 25.5 |
| Volume | in millilitres (container_size_ml) |
| Quantities | in whole sales units (pcs). A multipack, e.g. 6 × 330 ml, is one unit. |
| Alcohol content | as a percentage, e.g. 5.5 |
| Timestamps | ISO 8601 with time zone, e.g. 2026-10-12T14:05:12+03:00 (Finnish time) |
| Dates | YYYY-MM-DD |
Identifying products
Products are identified by the same codes the account holder uses to report to Valvira: the EAN code (with check digit, 13 characters) or the Valvira reporting code.
- In the product listing, every product has
ean(the first EAN),eans(all EAN codes) andvalvira_code. Either may benull, but a published product always has at least one code that identifies it. - An order line gives either
eanorvalvira_code. Both are looked up in the same set of codes, so the choice of field does not matter as long as the code is correct. - Every product record also contains the BrewOS internal product number
product_id. It is permanent and unique within the account, so it can be used, for example, to match products to the account's other BrewOS reports. On an order line, however, the product is always named by its code. - Store the product's code in the webshop and use it in the order. The product name is not unique, as there are products with the same name in different packages.
Errors
Errors are returned in RFC 7807 format
(Content-Type: application/problem+json). Your program should branch on
the code field. title is an explanation in Finnish that can be shown
to the user. Line-specific errors are in the list errors:
{
"type": "about:blank",
"status": 409,
"code": "insufficient_stock",
"title": "Saldo ei riitä kaikille riveille",
"errors": [
{ "line": 1, "code": "insufficient_stock", "title": "Saldo ei riitä", "product_id": 1,
"ean": "6430055482348", "valvira_code": null, "requested": 19, "available": 18 }
]
}
(Here the titles say "Not enough stock for all lines" and "Not enough stock".)
All error codes are listed under Error codes.
Authentication
API key
Every request is identified by the webshop's API key:
Authorization: Bearer bws_1_aB3dE…
- The key is a secret. Use it only on the webshop's server, never in code run in the browser or in a mobile app. The API does not send CORS headers.
- The account holder can revoke the key or disable the webshop at any time.
The API then responds
401 invalid_api_keyor403 webshop_disabled. - Rotating the key without downtime: a webshop can have two valid keys at a time. Take the new key into use and then ask the account holder to revoke the old one.
Buyer login
The buyer is always an outlet of an alcohol licence holder. The buyer logs in to the webshop with their outlet's alcohol licence number and a password that the account holder has created in BrewOS and given to the buyer. The webshop passes the credentials on to BrewOS:
curl -X POST https://brewos.fi/rest/webshop/v1/buyers/authenticate \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"license_number": "01063493", "password": "k7mq-x3fa-9pzr-c2hd", "client_ip": "203.0.113.7"}'
The response to a successful login contains a buyer_token, which is valid for 12 hours (expires_at).
- Send the token in the
X-Buyer-Tokenheader in the calls that concern the buyer (GET /buyer,GET /buyer/orders,POST /orders). - Do not store the buyer's password. Keep only the token in the buyer's session. When
the token expires or the API responds
401 invalid_buyer_token, ask the buyer to log in again. - The password has the form
xxxx-xxxx-xxxx-xxxx. Letter case, hyphens and spaces do not matter. - The webshop password works in all webshops of the same account, but the token only in the webshop whose key it was created with.
client_ip(optional) is the buyer's IP address. It is recorded with failed attempts.
Error situations:
- A wrong licence number or password always returns the same response
401 invalid_credentials. The API does not say which one was wrong. - After five failed attempts, the licence number is locked for 15 minutes:
429 too_many_attempts. - When the credentials are correct, the licence and the customer relationship are checked immediately:
403 license_not_in_force,403 license_type_not_acceptedor403 buyer_blocked. Show the buyer thetitleand, if necessary, direct them to contact the seller (the account holder).
Products and stock
GET /products
Returns the published products with their prices and available stock. Fetch the list, for example, once a day.
| Parameter | Default | Maximum |
|---|---|---|
page |
1 | |
per_page |
200 | 500 |
curl -H "Authorization: Bearer $API_KEY" "https://brewos.fi/rest/webshop/v1/products?page=1&per_page=200"
{
"items": [
{
"product_id": 26,
"ean": "6430055480344",
"eans": ["6430055480344"],
"valvira_code": null,
"name": "Avenger",
"description": "Täyteläinen IPA …",
"product_group": "Oluet",
"product_groups": [
{ "id": 12, "name": "Oluet", "dimension": "Tuoteryhmä", "level": "product" }
],
"container_type": "pullo",
"container_size_ml": 330,
"pack_units": 1,
"alcohol_percent": 7.2,
"list_price": { "excl_vat_cents": 250, "incl_vat_cents": 314, "vat_percent": 25.5 },
"group_prices": [
{ "customer_group_id": 3, "customer_group_name": "Anniskelu",
"excl_vat_cents": 230, "incl_vat_cents": 289, "vat_percent": 25.5 }
],
"retail_license_allowed": true,
"deposit_incl_vat_cents": null,
"best_before_months": 12,
"image_url": "https://brewos.fi/tiedosto/…/avenger.png",
"available_quantity": 1969,
"updated_at": "2025-10-20T09:48:49+03:00"
}
],
"page": 1,
"per_page": 200,
"total": 94,
"stock_calculated_at": "2026-10-12T06:00:01+03:00"
}
| Field | Description |
|---|---|
product_id |
The BrewOS internal product number (permanent). |
product_groups |
The product's product groups: id, name, dimension (dimension, e.g. "Type" or "Flavour") and level (level): product_base = a drink group that applies to every package of the drink, product = a package group. A product can have several groups. |
product_group |
The name of the product's first group in dimension order, or null. Kept for compatibility; use product_groups. |
pack_units |
The number of bottles or cans in a multipack. 1 for an ordinary product. |
list_price |
The general price, or null if the product has no general price. |
group_prices |
Customer group prices. The buyer's group is known from the login. |
retail_license_allowed |
Whether the product may be sold under a retail licence, see Licences. |
deposit_incl_vat_cents |
Deposit per sales unit (incl. VAT), or null. The account holder decides whether the deposit is added to the order. |
available_quantity |
Available stock: what is in stock minus orders already received and awaiting picking. |
image_url |
Product image (public address) or null. |
The account holder decides which product groups are published to the webshop. A product is published if it or its drink has any of the selected groups. Products that have no identifying code are left out of the list.
GET /stock
A lightweight stock list: just the codes and the available stock. Suitable for more frequent updates. 500 rows per page by default, at most 1000.
{
"items": [
{ "product_id": 26, "ean": "6430055480344", "eans": ["6430055480344"], "valvira_code": null, "available_quantity": 1969 }
],
"page": 1, "per_page": 500, "total": 94,
"stock_calculated_at": "2026-10-12T12:00:00+03:00"
}
Stock may change after the list is fetched. The decisive check is made when the order is sent, and the
409 insufficient_stockresponse gives the current available quantity for every line.
Consumer-facing website
The product listing and stock can also be used on a website open to consumers, for example for a product showcase or for showing availability. A consumer can order only if the account holder has enabled consumer sales, see Consumer orders.
- Fetch the data on the server. The API key must not end up in the browser. Fetch the products and stock to the website's server, cache them and display them from there. The API allows 120 calls per minute, so do not call it on every page load.
- The prices are licence holders' prices.
list_price,group_pricesand contract prices are the account's selling prices for holders of on-premises, retail and wholesale licences, not consumer prices. The webshop sets the consumer prices. - Availability is the account's own stock.
available_quantitygives the account holder's available stock, not availability in shops, restaurants or Alko. - Product images (
image_url) are public addresses and can be used on the website as they are. - Alcohol marketing. Content visible to consumers must comply with the marketing provisions of the Finnish Alcohol Act. The website's operator is responsible.
Buyer and prices
Login response and GET /buyer
POST /buyers/authenticate returns the buyer's details. GET /buyer returns
the same details, checked again (header X-Buyer-Token). Call it,
for example, at checkout before sending the order.
{
"buyer_token": "3647baa5…",
"expires_at": "2026-10-13T04:33:02+03:00",
"buyer": {
"customer_name": "Walhalli Oy",
"business_id": "2954128-1",
"outlet_name": "Valkeakosken Keilahalli",
"license_number": "01063493",
"license_type": "anniskelu",
"license_status": "active",
"delivery_address": { "street": "Keilahallintie 1", "street2": null, "postal_code": "37600", "town": "Valkeakoski" },
"customer_group_id": 3,
"discount_percent": 0
},
"contract_prices": [
{ "product_id": 26, "ean": "6430055480344", "valvira_code": null,
"excl_vat_cents": 230, "incl_vat_cents": 289, "vat_percent": 25.5, "valid_until": null }
],
"warnings": []
}
The response of GET /buyer does not contain the fields buyer_token and expires_at.
How the price is determined
BrewOS prices the order. The webshop can show the buyer the same price with this rule:
price = contract_prices[code] if the buyer has a contract price
| group_prices[customer_group_id] otherwise, if the buyer's group has a price
| list_price otherwise
line_total = price.excl_vat_cents × quantity
if discount_percent > 0: line_total = round( line_total × (1 − discount_percent / 100) )
VAT = round( line_total × vat_percent / 100 )
The order response always contains the final prices calculated by BrewOS. If they differ from those shown by the webshop (e.g. the account holder changed a price in between), the BrewOS price applies, and the invoice is made accordingly.
Orders
POST /orders
Sends the buyer's order. Headers:
| Header | |
|---|---|
Authorization: Bearer … |
API key |
X-Buyer-Token: … |
the logged-in buyer's token |
Idempotency-Key: … |
required, a unique identifier for this submission (1–100 characters A–Z a–z 0–9 _ - : .), e.g. the webshop's order number or a UUID |
curl -X POST https://brewos.fi/rest/webshop/v1/orders \
-H "Authorization: Bearer $API_KEY" \
-H "X-Buyer-Token: $BUYER_TOKEN" \
-H "Idempotency-Key: WS-100234" \
-H "Content-Type: application/json" \
-d '{
"external_order_id": "WS-100234",
"customer_reference": "Purchase order 55",
"requested_delivery_date": "2026-10-16",
"message": "Deliver to the back door",
"lines": [
{ "ean": "6430055480344", "quantity": 24 },
{ "valvira_code": "000157181607", "quantity": 6 }
]
}'
| Field | Required | Description |
|---|---|---|
external_order_id |
yes | The webshop's order number, at most 64 characters. Unique within the webshop. |
customer_reference |
The buyer's own reference (e.g. purchase order number), at most 250 characters. Shown on the invoice. | |
requested_delivery_date |
Requested delivery date YYYY-MM-DD, not in the past. The account holder sees the request, but it is not binding. |
|
message |
The buyer's message to the account holder, at most 2000 characters. | |
lines |
yes | 1–200 lines. Every line has either ean or valvira_code and quantity (integer 1–100,000). Lines for the same product are combined. |
The order is accepted in full or not at all. BrewOS checks in one go that:
- the buyer's webshop password and customer relationship are valid, and the customer is not under a sales ban
- the alcohol licence is in force according to Valvira's licence register and of the right type
- every code matches one published product, the buyer's licence allows the product and the product has a price
- the available stock is sufficient for every line.
If everything is in order, the response is 201 Created, and the order is shown to the account holder
in BrewOS as a shipment awaiting picking. The stock is reserved immediately.
{
"order_id": 42849,
"external_order_id": "WS-100234",
"status": "received",
"created_at": "2026-10-12T16:33:25+03:00",
"updated_at": "2026-10-12T16:33:25+03:00",
"delivered_at": null,
"customer_reference": "Purchase order 55",
"requested_delivery_date": "2026-10-16",
"invoiced": false,
"invoice_number": null,
"license_check": "verified",
"lines": [
{
"product_id": 26, "ean": "6430055480344", "valvira_code": null, "name": "Avenger", "container_size_ml": 330,
"quantity": 24, "unit_price_excl_vat_cents": 230, "discount_percent": 0,
"line_excl_vat_cents": 5520, "vat_percent": 25.5, "line_vat_cents": 1408,
"line_incl_vat_cents": 6928, "deposit_incl_vat_cents": 0
}
],
"totals": { "excl_vat_cents": 5520, "vat_cents": 1408, "incl_vat_cents": 6928, "deposit_incl_vat_cents": 0 },
"warnings": []
}
order_idis the BrewOS shipment number. The account holder identifies the order by it.- The deposit amount is given separately on the line and in the totals (incl. VAT). On the invoice it appears as a line of its own.
warningsmay containlicense_register_stale. In that case the licence was checked against Valvira's register, which had not been updated for more than 72 hours. The order has been received, and the account holder checks the licence before delivery. Nothing needs to be shown to the buyer.- BrewOS's demo account has demo buyers whose licence numbers are made up (they start with
DEMO-) and are not in Valvira's register. They let you test signing in and ordering end to end:DEMO-0001on-premise licence,DEMO-0002retail licence (only products a retail licence may buy),DEMO-0003on-premise licence that has ended (sign-in returns403 license_not_in_force) andDEMO-0004on-premise licence with special prices (contract_pricesanddiscount_percent). The account holder gives you the passwords. For a demo buyer,warningsin the sign-in and order responses containsdemo_license, and the order'slicense_checkisdemo. In any other account aDEMO-licence number is not accepted. The demo account's orders and their invoices are cancelled every night just after midnight (cancel_reasondemo_reset).
Retries and idempotency
The network connection may drop before the response arrives. In that case, send the order
again with the same Idempotency-Key header and the same body. If
BrewOS had already received the order, it returns the same order with the status code
200 OK and does not create another one.
| Situation | Response |
|---|---|
| Same key, same body | 200 and the original order |
| Same key, different body | 422 idempotency_key_reused |
New key, but external_order_id has already been used |
409 duplicate_order, with the order_id of the order already received in the response |
GET /orders/{order_id}
Returns one order of this webshop in the same format as when the order was
created, but without the warnings field. For orders of other webshops
the response is 404 order_not_found.
status |
Meaning |
|---|---|
received |
Received, awaiting picking |
ready_for_delivery |
Picked, awaiting delivery |
delivered |
Delivered: handed over to the carrier or to the customer (delivered_at) |
cancelled |
The account holder cancelled the order |
delivered does not mean the order has already arrived: the account holder
marks the shipment delivered when it is handed over to the carrier or to the
customer. delivered_at is the time of the handover.
invoiced and invoice_number tell whether the order has been invoiced. The account holder may
change quantities during picking, so lines and totals always show the current situation.
GET /orders?updated_since=…
Returns the orders whose status or content has changed after the given moment,
oldest first. Parameters: updated_since (ISO 8601, optional) and
limit (default 100, at most 500).
{ "items": [ { "order_id": 42849, "status": "ready_for_delivery", "…": "…" } ],
"next_updated_since": "2026-10-13T08:15:00+03:00" }
Continue by giving next_updated_since as the updated_since value in the next call.
The boundary is inclusive, so the last order of the previous page may come again.
Handle the orders by their order_id.
GET /buyer/orders
Returns the earlier orders of the logged-in buyer (header X-Buyer-Token),
newest first. It is meant for the webshop's order history page and carries a
summary only: identifier, creation time, value, and delivery and billing status.
Parameters: page (default 1) and per_page (default 50, at most 200).
curl https://brewos.fi/rest/webshop/v1/buyer/orders?per_page=20 \
-H "Authorization: Bearer $API_KEY" \
-H "X-Buyer-Token: $BUYER_TOKEN"
{
"items": [
{
"order_id": 42849,
"external_order_id": "WS-100234",
"channel": "webshop",
"created_at": "2026-10-12T16:33:25+03:00",
"customer_reference": "Ostotilaus 55",
"status": "delivered",
"delivered_at": "2026-10-14T09:12:00+03:00",
"totals": { "excl_vat_cents": 5520, "vat_cents": 1408, "incl_vat_cents": 6928, "deposit_incl_vat_cents": 0 },
"billing": {
"status": "invoiced",
"invoice_number": 10812,
"invoice_date": "2026-10-14",
"due_date": "2026-10-28",
"invoice_total_incl_vat_cents": 6928,
"open_cents": 6928
}
}
],
"page": 1,
"per_page": 20,
"total": 37
}
The list contains all of the customer's orders for this licence number:
also those placed through the account's other webshops and those the account
holder entered in BrewOS themselves (e.g. telephone orders, channel: "brewos",
external_order_id: null). An order belongs to a licence number through its
outlet. Orders without an outlet are shown only when the customer has no other
licence numbers, so the outlets of a chain do not see each other's orders.
Unfinished drafts are not shown.
statusanddelivered_atare the same as in the order (see GET /orders/{order_id}).totalsis the order's value now, calculated like the order'stotals.billing.invoice_total_incl_vat_centsis the invoice total. It may also include deposit rows and other rows added to the invoice.billing.open_centsis the unpaid amount, ornullwhen it is not known (the invoice was not made in BrewOS, or payments have not yet been read from accounting).
billing.status |
Meaning |
|---|---|
not_invoiced |
Not invoiced yet |
invoiced |
Invoiced, not paid, not overdue |
partly_paid |
Partly paid, not overdue |
overdue |
The due date has passed and the invoice is unpaid |
paid |
Paid |
credited |
Credited or voided |
An expired licence or a sales ban does not hide the history: the buyer still
sees what has been delivered and what is unpaid. The list contains no order lines.
Get the lines of the webshop's own orders with GET /orders/{order_id}.
Consumer orders
When the account holder has enabled consumer sales for the webshop (Settings → Webshops → the webshop → Consumer sales), the webshop can send a consumer's order to BrewOS. The consumer does not log in: the call is made with the API key alone.
- The order becomes a shipment in BrewOS, which is reported to Valvira under the account holder's retail licence. The account holder sets the licence in the webshop's settings.
- The webshop's price applies. The line carries the unit price the consumer paid, including VAT. BrewOS calculates the price excluding VAT using the product's current VAT class.
- Payment is handled in the webshop. BrewOS does not invoice the consumer, but it keeps track of whether the order has been paid.
- An order can be paid (
paid) or unpaid (unpaid), and either one reserves the stock immediately. An unpaid order is cancelled automatically if it is not paid within the time set by the account holder (default 60 min) and has not yet been picked.reservation_expires_atgives the moment. - A retail licence allows the sale of fermented beverages at most
8.0 % and others at most 5.5 %. The product listing field
retail_license_allowedtells you whether the product may be sold to a consumer.
GET /webshop tells you whether consumer sales are enabled:
"consumer_sales": {
"enabled": true,
"license_number": "1234567",
"license_status": "active",
"delivery_methods": [ "pickup" ],
"unpaid_hold_minutes": 60
}
Recommended flow
- When the consumer proceeds to pay, send the order with the status
unpaid. BrewOS reserves the stock or reports a shortage (409 insufficient_stock) before the consumer pays. - When the payment succeeds, mark the order as paid:
POST /consumer-orders/{order_id}/payment. - If the payment is interrupted, cancel the order (
POST /consumer-orders/{order_id}/cancel) or let the reservation expire.
An order that has already been paid can also be sent directly with the status paid. In that
case a stock shortage becomes apparent only after the payment, and the consumer has to be refunded.
Payment on pickup works as follows: the order is sent with the status unpaid, the account
holder then sets the reservation time to zero (does not expire) and marks the
order as paid in BrewOS at handover.
POST /consumer-orders
Headers: Authorization: Bearer … and a required Idempotency-Key, as in POST /orders.
curl -X POST https://brewos.fi/rest/webshop/v1/consumer-orders \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: K-10023" \
-H "Content-Type: application/json" \
-d '{
"external_order_id": "K-10023",
"payment_status": "unpaid",
"delivery_method": "pickup",
"requested_delivery_date": "2026-11-05",
"consumer": {
"name": "Matti Meikäläinen",
"email": "matti@example.fi",
"phone": "+358401234567",
"age_confirmed": true
},
"lines": [
{ "ean": "6430055480344", "quantity": 6, "unit_price_incl_vat_cents": 450 }
],
"shipping_incl_vat_cents": 0,
"total_incl_vat_cents": 2700,
"message": "I will pick it up on Friday"
}'
| Field | Required | Description |
|---|---|---|
external_order_id |
yes | The webshop's order number, at most 64 characters. Unique within the webshop, and the same number space as POST /orders. |
payment_status |
yes | paid or unpaid |
paid_at |
when paid |
Payment time, ISO 8601 with time zone |
payment_reference |
The payment provider's identifier, at most 100 characters. For the account holder's information. | |
payment_method |
Payment method as free text, at most 40 characters (e.g. online bank) |
|
delivery_method |
yes | pickup (collection from the retail outlet) or home_delivery. The permitted methods are in the GET /webshop response. |
requested_delivery_date |
Requested pickup or delivery date YYYY-MM-DD |
|
consumer.name |
yes | The consumer's name, at most 200 characters |
consumer.email, consumer.phone |
Contact details for the account holder | |
consumer.street, consumer.postal_code, consumer.city |
for home delivery | Delivery address |
consumer.country |
FI only (default) |
|
consumer.age_confirmed |
yes | Must be true: the webshop has verified that the buyer has stated they are of legal age. Age is still checked at handover. |
lines |
yes | 1–200 lines. Every line has either ean or valvira_code, quantity (1–100,000) and unit_price_incl_vat_cents, i.e. the unit price the consumer paid including VAT and discounts, excluding the deposit (at least 1 cent). Lines for the same product are combined and must have the same price. |
shipping_incl_vat_cents |
Delivery fee, for information | |
total_incl_vat_cents |
yes | The grand total: lines + deposit + delivery fee. BrewOS calculates the total itself, and if it differs by more than one cent per line, the response is 422 total_mismatch. |
The deposit is added to deposit products according to the account holder's setting.
The deposit amount per product is in the product listing field deposit_incl_vat_cents.
Include it in the grand total.
BrewOS checks in one go that:
- consumer sales are enabled and the delivery method is permitted
- the account holder's retail licence is in force according to Valvira's licence register
- every code matches one published product, and the product may be sold under a retail licence
- the grand total matches
- the available stock is sufficient for every line.
The response 201 Created has the same format as for a B2B order, with the following
additional fields:
{
"type": "consumer",
"order_id": 42911,
"external_order_id": "K-10023",
"status": "received",
"payment": { "status": "unpaid", "paid_at": null, "reference": null, "method": null },
"reservation_expires_at": "2026-11-03T18:42:00+02:00",
"cancel_reason": null,
"delivery_method": "pickup",
"seller_license": { "license_number": "1234567", "check": "verified" },
"consumer": { "name": "Matti Meikäläinen", "email": "matti@example.fi", "phone": "+358401234567", "…": "…" },
"lines": [ { "…": "…", "line_excl_vat_cents": 2151, "vat_percent": 25.5, "line_incl_vat_cents": 2700 } ],
"totals": { "excl_vat_cents": 2151, "vat_cents": 549, "incl_vat_cents": 2700, "deposit_incl_vat_cents": 0,
"shipping_incl_vat_cents": 0, "order_total_incl_vat_cents": 2700 },
"warnings": []
}
warnings may contain the following warnings. The order has been received regardless.
license_register_stale: the licence was checked against Valvira's register, which had not been updated for more than 72 hours.price_differs: the line's price differs from BrewOS's price by more than 20 %. Check the webshop's prices.
Idempotency works as in POST /orders.
POST /consumer-orders/{order_id}/payment
Marks an unpaid order as paid. Body:
{ "paid_at": "2026-11-03T17:45:12+02:00", "payment_reference": "…", "payment_method": "…" }
(paid_at is required). A repeat returns the order unchanged (200).
An order that has already been cancelled returns 409 order_cancelled, and the payment must then be refunded to the consumer.
POST /consumer-orders/{order_id}/cancel
Cancels an order that has not yet been picked (status = received) and releases the
stock. Send {} as the body, or an empty body with Content-Length: 0: the server
rejects a POST with no Content-Length header at all (403, an HTML page). An order that has been picked or delivered returns
409 order_not_cancellable. Contact the account holder in that case. A repeat
returns the cancelled order (200).
GET /consumer-orders/{order_id}
Returns the order with the consumer's details. GET /orders and GET /orders/{order_id}
also return consumer orders ("type": "consumer"), but without the consumer's details.
cancel_reason gives the reason for a cancelled order: payment_expired (the reservation expired),
webshop (the webshop cancelled), brewos (the account holder cancelled) or demo_reset (the demo
account's nightly clean-up).
Law and responsibilities
- Pickup from the retail outlet is permitted.
- Home delivery requires a delivery licence under the amendment to the Alcohol Act that entered into force on 3 July 2026. Deliveries can begin at the earliest in January 2027. The account holder enables the method in the settings once the licence number is known.
- Alcoholic beverages may be handed over to the recipient between 9:00 and 21:00, not to a minor and not to a clearly intoxicated person. Some addresses may not be delivered to, for example schools, day-care centres or healthcare units. Checking the delivery address is the responsibility of the webshop and the carrier.
- Personal data. BrewOS stores the consumer's name and contact details for processing
the order and deletes them 12 months after delivery or cancellation.
Do not send other personal data, for example in the
messagefield.
Licences and retail sale
Only outlets whose alcohol licence is in force in Valvira's licence register and is one of the following can order:
license_type |
Licence | Product restriction |
|---|---|---|
anniskelu |
On-premises licence | no restriction |
tukkumyynti |
Wholesale licence | no restriction |
vahittaismyynti |
Retail licence | fermented beverages at most 8.0 %, others at most 5.5 % |
The product listing field retail_license_allowed tells you in advance whether the product may
be sold under a retail licence. Hide the products that are not allowed, or prevent them from being added
to the basket, when the buyer's license_type is vahittaismyynti. Otherwise the order
is rejected with the error product_not_allowed_for_license.
Error codes
| HTTP | code |
When | What to do |
|---|---|---|---|
| 400 | invalid_json |
The body is not a JSON object | Fix the request |
| 400 | invalid_parameter |
Invalid query parameter | Fix the request |
| 400 | idempotency_key_required |
Idempotency-Key is missing or invalid |
Add the header |
| 401 | invalid_api_key |
The API key is missing, wrong or revoked | Check the key with the account holder |
| 401 | invalid_credentials |
Wrong licence number or password | Ask the buyer to try again |
| 401 | invalid_buyer_token |
The buyer token is missing, expired or was invalidated (e.g. new password) | Log the buyer in again |
| 403 | webshop_disabled |
The account holder has disabled the webshop | Contact the account holder |
| 403 | account_unavailable |
The BrewOS account is not in use | Contact the account holder |
| 403 | license_not_in_force |
The licence is not in force or is not found in the register | Show title; the buyer contacts the seller |
| 403 | license_type_not_accepted |
The licence type does not entitle the holder to order (e.g. a manufacturing licence) | Show title |
| 403 | buyer_blocked |
The customer cannot be sold to at the moment | Show title; the buyer contacts the seller |
| 403 | consumer_sales_disabled |
Consumer sales are not enabled in this webshop | Hide the consumer's option to order |
| 403 | seller_license_not_in_force |
The account holder's retail licence is not in force | Contact the account holder |
| 404 | order_not_found |
This webshop has no such order | |
| 404 | not_found |
No such path | |
| 405 | method_not_allowed |
Wrong HTTP method (the Allow header gives the right ones) |
|
| 409 | insufficient_stock |
Not enough stock; errors[].available |
Update the basket according to the available quantities |
| 409 | duplicate_order |
external_order_id has already been received; order_id |
The order is already in BrewOS, do not send it again |
| 409 | order_cancelled |
The consumer order marked as paid has already been cancelled | Refund the payment to the consumer |
| 409 | order_not_cancellable |
The consumer order has already been picked or delivered | Contact the account holder |
| 422 | validation_failed |
Field errors; errors[].field / errors[].line |
Fix the request |
| 422 | invalid_lines |
Line errors, see below | Remove or fix the lines |
| 422 | idempotency_key_reused |
Same key with a different body | Use a new key for a new order |
| 422 | delivery_method_not_available |
The delivery method is not enabled; delivery_methods |
Offer only the permitted methods |
| 422 | total_mismatch |
The grand total does not match the lines; expected_total_incl_vat_cents |
Fix the total calculation |
| 429 | rate_limited |
More than 120 calls per minute | Wait Retry-After seconds |
| 429 | too_many_attempts |
Too many failed logins | Wait 15 minutes |
| 500 | internal_error |
Unexpected error | Try again later with the same Idempotency-Key header |
| 503 | maintenance |
BrewOS is in maintenance mode | Wait Retry-After |
Codes of line errors (invalid_lines):
errors[].code |
Meaning |
|---|---|
unknown_product |
No published product is found with the code |
ambiguous_product |
The code matches several products. The account holder needs to fix the codes. |
product_not_allowed_for_license |
This product may not be bought under a retail licence |
price_missing |
The product has no price for this buyer |
conflicting_line_prices |
Lines for the same product in a consumer order have different prices |
Field errors of a consumer order (validation_failed) may also contain the code
age_not_confirmed (consumer.age_confirmed is not true) and not_domestic
(a country other than FI).
Limits
- At most 120 calls per minute per API key.
- At most 500 rows per page in the product list and at most 1000 in the stock list.
- At most 200 lines per order and at most 100,000 pcs per line.
- The buyer token is valid for 12 hours.
- Five failed logins within 15 minutes lock the licence number for 15 minutes.
- The API is intended for server-to-server use. No CORS headers are sent.
OpenAPI description
A machine-readable description of the whole API (OpenAPI 3.1): /kehittajat/verkkokauppa-api/openapi.yaml. It can be opened, for example, in Swagger Editor or Postman, or used to generate a client library.
Change history
| Date | Version | Change |
|---|---|---|
| 12 Oct 2026 | v1 | First version: products, stock, buyer login and orders. |
| 12 Oct 2026 | v1 | BrewOS internal product_id added to product data (products, stock, contract prices, order lines, stock errors). |
| 12 Oct 2026 | v1 | Use on a consumer-facing website added to the guide. Orders only from alcohol licence holders. |
| 21 Oct 2026 | v1 | Consumer orders (/consumer-orders), the consumer_sales field in the GET /webshop response and the type field of orders. |
| 29 Oct 2026 | v1 | The buyer's order history (GET /buyer/orders): orders, their value and their delivery and billing status. |
The API version is in the path (/v1/). New fields may be added to responses
without a version change, so your program must tolerate unknown fields.
Incompatible changes are made in a new version.