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

  1. 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.
  2. Test the connection:

    curl -H "Authorization: Bearer bws_1_…" https://brewos.fi/rest/webshop/v1/webshop
    
  3. Fetch the products once a day (GET /products) and, if needed, the stock more often (GET /stock).

  4. Log the buyer in with the licence number and password (POST /buyers/authenticate) and keep the returned buyer_token in the buyer's session.
  5. Send the order (POST /orders) with the buyer token and a unique Idempotency-Key header.
  6. Follow the orders (GET /orders?updated_since=…) if you want to show the buyer the status of the order.
  7. 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) and valvira_code. Either may be null, but a published product always has at least one code that identifies it.
  • An order line gives either ean or valvira_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_key or 403 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-Token header 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_accepted or 403 buyer_blocked. Show the buyer the title and, 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_stock response 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_prices and 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_quantity gives 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:

  1. the buyer's webshop password and customer relationship are valid, and the customer is not under a sales ban
  2. the alcohol licence is in force according to Valvira's licence register and of the right type
  3. every code matches one published product, the buyer's licence allows the product and the product has a price
  4. 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_id is 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.
  • warnings may contain license_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-0001 on-premise licence, DEMO-0002 retail licence (only products a retail licence may buy), DEMO-0003 on-premise licence that has ended (sign-in returns 403 license_not_in_force) and DEMO-0004 on-premise licence with special prices (contract_prices and discount_percent). The account holder gives you the passwords. For a demo buyer, warnings in the sign-in and order responses contains demo_license, and the order's license_check is demo. In any other account a DEMO- licence number is not accepted. The demo account's orders and their invoices are cancelled every night just after midnight (cancel_reason demo_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.

  • status and delivered_at are the same as in the order (see GET /orders/{order_id}).
  • totals is the order's value now, calculated like the order's totals.
  • billing.invoice_total_incl_vat_cents is the invoice total. It may also include deposit rows and other rows added to the invoice.
  • billing.open_cents is the unpaid amount, or null when 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_at gives 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_allowed tells 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

  1. 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.
  2. When the payment succeeds, mark the order as paid: POST /consumer-orders/{order_id}/payment.
  3. 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:

  1. consumer sales are enabled and the delivery method is permitted
  2. the account holder's retail licence is in force according to Valvira's licence register
  3. every code matches one published product, and the product may be sold under a retail licence
  4. the grand total matches
  5. 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 message field.

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.

Page last updated 31 Oct 2026 · OpenAPI description