Orders
Send webshop orders to ZERPY and follow their status, shipments, invoices and payment. Creating, cancelling and payment links need orders:write; reading needs orders:read.
POST /orders
Creates a sales order (channel webshop). By default it is confirmed immediately, which reserves the stock.
curl -X POST https://zerpy.app/api/v1/orders \
-H "Authorization: Bearer $ZERPY_API_KEY" \
-H "Content-Type: application/json" \
-d @order.json{
"external_id": "1001",
"prices_include_vat": true,
"confirm": true,
"customer": {
"email": "mari@example.ee",
"name": "Mari Maasikas",
"phone": "+372 5000 0000",
"company_name": "Optional OÜ",
"reg_code": "12345678",
"vat_number": "EE123456789"
},
"billing_address": {
"line1": "Pärnu mnt 10", "city": "Tallinn", "region": "Harju maakond",
"postal_code": "10148", "country": "EE"
},
"shipping_address": {
"name": "Mari Maasikas", "phone": "+372 5000 0000",
"line1": "Omniva parcel machine", "city": "Tallinn", "postal_code": "10111", "country": "EE",
"pickup_point": "Ülemiste Selver"
},
"lines": [
{ "sku": "P1", "quantity": 2, "unit_price": 12.40 },
{ "sku": "P2", "quantity": 1, "unit_price": 6.20, "vat_rate": 24, "description": "Gadget (gift wrap)" },
{
"sku": "SAUNA-1", "quantity": 1, "unit_price": 11532.00,
"options": [{ "group": "Heater", "value": "HUUM Hive" }, "3f42a0c1-…"]
}
],
"shipping": { "description": "Omniva parcel machine", "price": 3.99 },
"payment": { "amount": 11573.99, "method": "online", "reference": "MONTONIO-8f3a" },
"notes": "Please call before delivery"
}Request fields
| Field | Required | Description |
|---|---|---|
external_id | yes | Your order number (max 100 characters). Idempotency key — see below |
currency | Optional 3-letter code; must equal the company currency, else 422 unsupported_currency. API orders are always in the company currency | |
prices_include_vat | Default true: unit_price and shipping.price are gross (what the customer paid). ZERPY converts them to net. false = the prices are net | |
confirm | Default true: the order is confirmed and stock reserved. false = left as a draft for someone to check and confirm in ZERPY | |
customer.email | yes | Customer e-mail |
customer.name | yes | Person's name |
customer.phone | ||
customer.company_name | Set for business customers — becomes the customer name | |
customer.reg_code | Company registry code (used for matching) | |
customer.vat_number | VAT number | |
billing_address | Address object (below) | |
shipping_address | Address object, plus name, phone, pickup_point (parcel machine / pickup point) | |
lines | yes | 1–200 lines |
lines[].sku | yes | Product SKU. Unknown or archived SKUs → 422 unknown_sku |
lines[].quantity | yes | Positive number |
lines[].unit_price | yes | Unit price, ≥ 0 (gross or net per prices_include_vat), including option prices |
lines[].vat_rate | Defaults to the product's VAT rate | |
lines[].description | Line text; defaults to the product name | |
lines[].options | Configurable products — see below | |
shipping | { description, price, vat_rate? } — added as a separate line without a product. VAT defaults to 24 | |
payment | Money already received in the shop — see below | |
notes | Order notes (max 5000 characters) |
Address object: line1, line2, city, region (county / state / province, e.g. WA), postal_code, country (ISO 3166 alpha-2, e.g. EE). All fields are optional.
Idempotency (external_id)
external_id is unique per company. If an order with the same external_id already exists, ZERPY creates nothing and returns the existing order with status 200, header Idempotent-Replayed: true and "warnings": []. This is safe even for two identical requests at the same moment — so always retry POST /orders after a timeout or network error.
Customer matching
- If
customer.reg_codeis given: an existing customer with that registry code. - Otherwise (or if not found): an existing customer with the same e-mail (case-insensitive; the oldest one wins).
- Otherwise a new customer is created: name =
company_nameorname, business customer whencompany_nameis given, payment terms 0 days, billing address =billing_address(elseshipping_address), note "Created from webshop order …".
Existing customers are never overwritten — the order's own addresses go on the order. Archived customers are not matched.
Prices and VAT
- With
prices_include_vat: true(default) ZERPY divides each price by1 + vat_rate/100and stores net prices. Totals may differ from your shop by ±0.01 in rare rounding cases — compare withtotals.grossin the response. - Your shop decides VAT. For a VAT-free sale (an EU business with a VAT number, or delivery outside the EU) send
"vat_rate": 0on every line (and onshipping). If ZERPY then detects an EU supply or export from the customer and delivery country, the order is marked as such and the legal VAT note is printed on the invoice. Otherwise the order is treated as domestic. - ZERPY does not recalculate prices from price lists for API orders — the shop's price is stored.
Configurable products
lines[].options lists the chosen option values (max 20), each either:
- an option value id from
GET /products, or - a name pair
{ "group": "Heater", "value": "HUUM Hive" }— matched case-insensitively against the names in the company language or any translation.
Rules:
- One value per group. A required group that is not answered →
422 options_required(details.groupslists the missing groups). - An unknown value, a value of another product, or two values of one group →
422 invalid_options. unit_pricemust already include the option prices — ZERPY stores the configuration (it goes to production, invoices and documents) but does not add the deltas.
{
"error": {
"code": "options_required",
"message": "SAUNA-1: choose \"Heater\"",
"details": { "sku": "SAUNA-1", "groups": ["Heater"] }
}
}Payment received in the shop
Send payment when the customer has already paid online (card, bank link, Montonio …):
| Field | Description |
|---|---|
amount | Required. Amount received (gross, positive) |
method | online (default), card, bank_transfer, cash, other |
reference | Transaction id; defaults to external_id |
paid_at | YYYY-MM-DD, default today |
It is recorded as a payment on the order. When the invoice is issued later it is marked paid automatically. Omit payment for unpaid orders (e.g. bank transfer after ordering) — see payment link for collecting money online.
Response
201 Created:
{
"data": { "id": "8622…", "number": "SO-00042", "external_id": "1001", "status": "confirmed", "…": "see Order object" },
"warnings": [
{ "code": "insufficient_stock", "sku": "P2", "needed": 5, "available": 2 }
]
}Orders are accepted even when stock is short (backorder). Check warnings: each insufficient_stock warning says how much was needed and how much was available. Warnings only appear when the order is confirmed.
The order ships from the company's default own warehouse. If the company has no warehouse → 409 no_warehouse.
GET /orders/{id}
One order. {id} is the ZERPY id (UUID) or the order number (SO-00042) — the same applies to every /orders/{id}/… path.
curl https://zerpy.app/api/v1/orders/SO-00042 -H "Authorization: Bearer $ZERPY_API_KEY"Returns { "data": Order } or 404 not_found.
GET /orders
Lists the company's orders (all channels — webshop, manual, partner portal), sorted by updated_at, oldest first.
| Query | Description |
|---|---|
external_id | Only the order with this external_id — find your shop order |
updated_since | ISO 8601 timestamp with time zone: only orders changed after it |
page, limit | Page from 1; limit default 50, max 100 |
curl "https://zerpy.app/api/v1/orders?external_id=1001" -H "Authorization: Bearer $ZERPY_API_KEY"Returns { "data": [Order, …], "page", "limit", "total", "has_more" }.
POST /orders/{id}/cancel
Cancels an order and releases its stock reservation. Possible only while the order is a draft or confirmed (nothing shipped) and has no invoice. Otherwise 409 cannot_cancel. Returns { "data": Order } with status: "cancelled".
If the customer had paid, the payment status becomes refund_due — the refund itself is handled in ZERPY / your payment provider.
POST /orders/{id}/payment-link
Creates an online payment link (bank link / card) through the company's Montonio account — your shop needs no Montonio keys. The company must have Montonio connected in ZERPY.
Body (all optional):
| Field | Description |
|---|---|
amount | Default: what the customer should pay next — the rest of the prepayment, else the open balance. Cannot exceed the open balance (400 invalid_amount) |
return_url | http(s) URL where Montonio sends the customer after paying |
locale | Payment page language: et, en, fi, lv, lt, pl, de, ru. Default from the customer's billing country |
curl -X POST https://zerpy.app/api/v1/orders/SO-00042/payment-link \
-H "Authorization: Bearer $ZERPY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "return_url": "https://shop.example.com/thank-you", "locale": "en" }'{
"data": {
"url": "https://…",
"amount": "59.79",
"currency": "EUR",
"expires_at": "2026-11-21T10:00:00.000Z",
"reference": "SO-00042.3fa2c1"
}
}201= a new link;200= an unpaid link for the same amount was reused. Links are valid for 14 days.- Redirect the customer to
url. When they pay, Montonio notifies ZERPY, the payment is recorded on the order (payment.status→paid) and the invoice is issued as paid. - Errors:
409 cannot_pay(Montonio not connected, order cancelled, currency not EUR or PLN, nothing left to pay),400 invalid_amount,422 validation_failed(invalid body),503 provider_unavailable.
Order object
{
"id": "8622…",
"number": "SO-00042",
"external_id": "1001",
"status": "partially_shipped",
"channel": "webshop",
"order_date": "2026-11-07T00:00:00.000Z",
"currency": "EUR",
"customer": { "name": "Mari Maasikas", "email": "mari@example.ee" },
"shipping_address": {
"line1": "Omniva parcel machine", "city": "Tallinn", "postal_code": "10111", "country": "EE",
"name": "Mari Maasikas", "phone": "+372 5000 0000", "pickup_point": "Ülemiste Selver"
},
"lines": [
{
"sku": "P1", "description": "Widget", "options": [],
"quantity": "2", "quantity_shipped": "2",
"unit_price": "10", "discount_pct": "0", "vat_rate": "24", "total_net": "20.00"
},
{
"sku": null, "description": "Omniva parcel machine", "options": [],
"quantity": "1", "quantity_shipped": "0",
"unit_price": "3.2177", "discount_pct": "0", "vat_rate": "24", "total_net": "3.22"
}
],
"totals": { "net": "23.22", "vat": "5.57", "gross": "28.79" },
"shipments": [
{
"number": "SH-00007", "carrier": "omniva", "tracking_number": "CC123EE",
"shipped_at": "2026-11-08T09:12:00.000Z",
"lines": [{ "sku": "P1", "quantity": "2" }]
}
],
"invoices": [
{ "number": "INV-00031", "type": "invoice", "status": "paid", "total": "28.79", "issue_date": "2026-11-08T00:00:00.000Z", "due_date": "2026-11-08T00:00:00.000Z" }
],
"payment": {
"status": "paid", "paid": "28.79", "balance": "0.00",
"prepayment": null, "prepayment_due_date": null, "reference": "80000428"
},
"notes": null,
"created_at": "2026-11-07T10:00:00.000Z",
"updated_at": "2026-11-08T09:12:00.000Z"
}| Field | Description |
|---|---|
status | draft, confirmed (stock reserved), partially_shipped, shipped, completed (shipped and invoiced), cancelled — derived by ZERPY from shipments and invoices |
channel | webshop for API orders; manual, portal or api for orders created elsewhere |
lines[].sku | null for lines without a product (e.g. shipping) |
lines[].options | Chosen configuration: [{ "group": "Heater", "value": "HUUM Hive" }], empty for normal products. Option prices are included in unit_price |
lines[].unit_price | Net unit price (up to 4 decimals) |
lines[].total_net | Net line total after discount |
totals | net, vat, gross |
shipments[] | Each shipment with carrier, tracking number, date and shipped lines |
invoices[] | Issued invoices and credit notes only (drafts are not listed). type = invoice or credit_note (credit notes have negative totals); status = issued or paid |
payment.status | unpaid, awaiting_deposit / deposit_overdue (a prepayment was requested and is not paid yet), partially_paid, paid, overpaid, refund_due (cancelled with money received), none (nothing to pay) |
payment.paid / balance | Received so far / still to pay (total − paid) |
payment.prepayment, prepayment_due_date | Requested prepayment, if any (set in ZERPY; API orders get none automatically) |
payment.reference | Payment reference number for bank transfers on this order |