Developers
REST API and webhooks
Connect your ERP, warehouse or accounting system to a Mervio shop: products, stock per location, orders, shipments and customers, with events within seconds.
Quick start
The Mervio REST API lets your ERP, warehouse or accounting system work with a shop: products, stock per location, orders, shipments, customers, discounts, B2B price lists and webhooks. It is available from the Grow plan; Pro has higher limits and the B2B resources.
- In the shop's admin, turn on Settings → Modules → API and webhooks.
- Open Settings → API and integrations → Tokens → New token. Choose REST API as the purpose, tick only the scopes you need and copy the token (
mrv_…). It is shown once. - Call the API:
curl https://api.mervio.lt/v1/shop \
-H "Authorization: Bearer mrv_…"
The answer names the shop, its languages and currency, the plan's limits and the token's scopes. Every other endpoint is in the API reference (OpenAPI 3.1: https://api.mervio.lt/v1/openapi.json).
Conventions
- JSON in and out, field names in
snake_case, dates in ISO 8601 UTC (2026-10-28T15:57:43Z). - Money is a decimal string with VAT included, e.g.
"19.99", in the shop's currency. - Translated fields are objects by language:
{"lt": "Kava", "en": "Coffee"}. - Products, variants, customers, orders, shipments and webhooks have a 26-character
id. A product can also be found byhandle, an order bynumber, a customer by e-mail. external_idstores your own id (ERP code) on products, variants, orders and customers; filter lists with?external_id=….- Unknown fields may appear in answers at any time: ignore what you do not know.
Tokens and scopes
A token belongs to one shop and acts with the permissions of the team member who created it: it can never do more than they can, and stops working when they leave the team. For an integration that must keep running, the owner can create a shop token (service key) instead: it does not depend on a person and is shown in the audit log as "API key: name".
| Scope | Allows |
|---|---|
catalog:read / catalog:write |
Products, variants, stock, locations, collections, brands; price lists (Pro) |
orders:read / orders:write |
Orders, shipments, refunds, invoices; creating orders |
customers:read / customers:write |
Customers and customer groups (Pro); the buyer's e-mail and phone in orders |
discounts:read / discounts:write |
Coupons and automatic discounts |
webhooks:read / webhooks:write |
Webhook endpoints and deliveries |
Without customers:read, order answers and order webhooks have email and phone set to null: a warehouse that only ships goods does not need to see how to reach the buyer.
- Purpose: a token is for MCP (AI assistants), REST API or both. An API-only token cannot be used by an AI assistant, and the other way round.
- Expiry: 30, 90 or 365 days, or unlimited. Revoked tokens stop at once.
- Rotation: "New one instead of this" creates a new token with the same scopes; the old one keeps working for 24 hours so you can switch over without downtime.
- IP allowlist (Pro): a token can be limited to IP addresses or CIDR ranges.
- Keep tokens secret like passwords. Tokens start with
mrv_, so secret scanners can find them in code.
Paging and syncing
Lists use cursors. Pass limit (1–250, default 50) and, for the next page, the next_cursor of the previous answer:
{ "data": [ … ], "next_cursor": "eyJzIjoiaWQiLCJpZCI6NTB9", "has_more": true }
sort is id (oldest first, default), -id (newest first) or updated_at.
To keep your system in step, poll with updated_since (the list is then sorted by updated_at):
curl "https://api.mervio.lt/v1/products?updated_since=2026-10-28T12:00:00Z&limit=250" \
-H "Authorization: Bearer mrv_…"
Follow next_cursor until has_more is false, then remember the newest updated_at you saw and start from it next time. Webhooks tell you when to sync; the cursor makes sure nothing is missed.
Stock
Stock is kept per location (store or warehouse, GET /v1/locations). Without location_id the default location is meant. Every change is recorded as an inventory movement.
| Endpoint | Does |
|---|---|
POST /v1/inventory/set |
{ "sku": "KAVA-1", "location_id": 3, "on_hand": 12 } – the absolute quantity |
POST /v1/inventory/adjust |
{ "sku": "KAVA-1", "delta": -2, "reason": "manual" } – add or remove units (never below 0) |
POST /v1/inventory/bulk |
up to 100 rows (Pro: 1 000) of { sku or variant_id, location_id, on_hand } |
GET /v1/inventory |
levels with on_hand, reserved (unpaid orders) and available |
A bulk request counts as one request against the rate limit, so 50 000 levels go in a few minutes:
curl -X POST https://api.mervio.lt/v1/inventory/bulk \
-H "Authorization: Bearer mrv_…" -H "Content-Type: application/json" \
-d '{"items": [{"sku": "KAVA-1", "on_hand": 12}, {"sku": "ARB-2", "location_id": 3, "on_hand": 0}]}'
The answer lists the rows saved under updated and the others under failed with a code (not_found, ambiguous_sku when several variants share the SKU, location_not_found). Prices, compare-at prices, cost and weight change the same way with POST /v1/variants/bulk.
Webhooks
Register an https endpoint with POST /v1/webhooks (or in the admin) and choose the events. The answer contains the signing secret whsec_…; store it.
curl -X POST https://api.mervio.lt/v1/webhooks \
-H "Authorization: Bearer mrv_…" -H "Content-Type: application/json" \
-d '{"url": "https://erp.example.com/mervio", "events": ["order.paid", "order.cancelled", "inventory.updated"]}'
Every delivery is a POST with this body:
{
"id": "01k8x3m4n5p6q7r8s9t0v1w2x3",
"type": "order.paid",
"created_at": "2026-10-28T15:57:43Z",
"api_version": "2026-10-28",
"shop_id": "01k8…",
"data": { "id": "01k8…", "number": 1042, "financial_status": "paid", … }
}
data is the resource in the same shape as GET /v1/… returns it (an order, shipment, product, variant with its stock, or customer), so you need no extra request.
- Delivery: at least once. Answer
2xxwithin 10 seconds; do the work afterwards. Deduplicate by thewebhook-idheader (equal toid). Order is not guaranteed: comparedata.updated_atand skip older states. - Retries: after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h (8 attempts in about 2 days). Redirects are not followed.
- Turned off: an endpoint without any successful delivery for 3 days is turned off and the shop owner gets an e-mail (a warning comes after 24 hours). Turn it back on and resend what it missed (
POST /v1/webhook-deliveries/{id}/redeliver, or "Resend failed" in the admin). - Bulk changes:
product.updatedandinventory.updatedcome at most once a minute per product or variant, with the newest state. - Test:
POST /v1/webhooks/{id}/testsends awebhook.testevent.
Verifying signatures
Mervio signs deliveries by the open Standard Webhooks specification. Headers: webhook-id, webhook-timestamp (Unix seconds) and webhook-signature (v1,<base64>, several separated by spaces while a secret is being rotated). The signature is HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{raw body} with the base64-decoded part of the secret after whsec_. Use the raw request body, not re-encoded JSON, and refuse timestamps older than 5 minutes.
PHP:
function mervioWebhookIsValid(string $secret, array $headers, string $body): bool
{
$id = $headers['webhook-id'] ?? '';
$timestamp = $headers['webhook-timestamp'] ?? '';
if (! ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
return false;
}
$key = base64_decode(substr($secret, strlen('whsec_')));
$expected = 'v1,'.base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$body}", $key, true));
foreach (explode(' ', $headers['webhook-signature'] ?? '') as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
$valid = mervioWebhookIsValid(getenv('MERVIO_WEBHOOK_SECRET'), array_change_key_case(getallheaders()), file_get_contents('php://input'));
Node.js (Express):
import crypto from 'node:crypto';
import express from 'express';
function isValid(secret, headers, body) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
if (
!/^\d+$/.test(timestamp) ||
Math.abs(Date.now() / 1000 - Number(timestamp)) > 300
)
return false;
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const expected =
'v1,' +
crypto
.createHmac('sha256', key)
.update(`${id}.${timestamp}.${body}`)
.digest('base64');
return (headers['webhook-signature'] || '')
.split(' ')
.some(
(signature) =>
signature.length === expected.length &&
crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected),
),
);
}
const app = express();
app.post('/mervio', express.raw({ type: 'application/json' }), (req, res) => {
if (
!isValid(
process.env.MERVIO_WEBHOOK_SECRET,
req.headers,
req.body.toString('utf8'),
)
)
return res.sendStatus(401);
res.sendStatus(204);
// handle JSON.parse(req.body) after answering
});
Ready-made Standard Webhooks libraries exist for PHP, Node, Python, Go, Ruby, Java and others.
Events
| Event | Data | When |
|---|---|---|
order.created |
order | An order was placed (also unpaid ones, e.g. bank transfer). |
order.paid |
order | The order's payment was confirmed. |
order.updated |
order | The order changed: status, payment, fulfilment, addresses, lines. |
order.cancelled |
order | The order was cancelled. |
order.refunded |
order + refund |
Money was refunded, in part or in full. |
shipment.created |
shipment | A shipment left, with its tracking number. |
shipment.ready_for_pickup |
shipment | The parcel waits in a parcel locker or store. |
shipment.delivered |
shipment | The parcel was delivered. |
product.created |
product | A product was created. |
product.updated |
product | A product or its variants changed (at most once a minute per product). |
product.archived |
product | A product was archived. |
inventory.updated |
variant | A variant's stock changed at a location (at most once a minute per variant). |
customer.created |
customer | A customer was created. |
customer.updated |
customer | A customer's details changed. |
webhook.test |
{message, endpoint_id} |
Sent with "Send test"; always delivered to that endpoint only. |
Errors and limits
Errors are RFC 9457 problem details (application/problem+json). Branch on code, which never changes:
{
"type": "https://mervio.lt/developers/errors#validation_failed",
"title": "Unprocessable content",
"status": 422,
"detail": "The request has invalid fields.",
"code": "validation_failed",
"errors": {
"variants.0.price": ["The variants.0.price field must be a number."]
},
"request_id": "req_01k8x3…"
}
| Status | code |
Meaning |
|---|---|---|
| 400 | invalid_json |
The body is not a JSON object |
| 401 | unauthorized |
No token, or it is invalid, expired or revoked |
| 403 | missing_scope |
The token lacks a scope (required_scope names it) |
| 403 | plan_required, module_disabled |
The plan has no API or B2B, or the module is off |
| 403 | wrong_token_kind, ip_not_allowed |
An MCP-only token, or a request from an IP not on the token's list |
| 404 | not_found |
No such resource in this shop |
| 409 | edit_conflict |
If-Match does not match: someone changed it since you read it |
| 409 | invalid_state |
Not possible now (e.g. cancelling a shipped order) |
| 409 | external_id_taken, idempotency_in_progress |
|
| 413 / 415 | payload_too_large, unsupported_media_type |
Over 2 MB, or not JSON |
| 422 | validation_failed, idempotency_key_reused, invalid_cursor |
|
| 429 | rate_limited, daily_quota_exceeded |
Wait Retry-After seconds |
Quote request_id (also the X-Request-Id header) when asking for help: we find the request by it, and the shop sees it in its audit log.
| Plan | Requests per token | Requests per shop per day | Bulk rows | Webhook endpoints | Tokens |
|---|---|---|---|---|---|
| Grow | 2/s, bursts of 40 | 50 000 | 100 | 5 | 10 |
| Pro | 10/s, bursts of 200 | 500 000 | 1 000 | 25 | 25 |
Every answer carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset.
Safe retries. Send an Idempotency-Key header (any unique string, e.g. a UUID) with every POST. Repeating the request with the same key within 24 hours returns the first answer (Idempotent-Replayed: true) instead of creating a second order or shipment.
Concurrent edits. GET answers carry an ETag. Send it back as If-Match with PATCH to refuse overwriting a change someone made in between (409 edit_conflict).
Recipe: orders to the ERP and shipments back
- Create a shop token for the ERP with
orders:read,orders:write,catalog:read,catalog:write,webhooks:write(andcustomers:readif the ERP needs the buyer's contacts). - Register a webhook for
order.paid,order.cancelledandorder.updated. For bank transfers and cash on delivery alsoorder.created. - On
order.paid, create the sales order in the ERP fromdata(lines havesku,quantity,unit_price; each line has anid). Store the ERP number:PATCH /v1/orders/{id}with{"external_id": "SO-2026-1042"}. - When the parcel leaves,
POST /v1/orders/{id}/shipmentswith{"tracking_number": "LP123456789LT"}(orlinesfor a partial shipment). The buyer gets the shipping e-mail, as from the admin. - Keep stock in step from the ERP with
POST /v1/inventory/bulk(one request per 100 or 1 000 rows), and prices withPOST /v1/variants/bulk. - Once a day, poll
GET /v1/orders?updated_since=…to catch anything a webhook outage made you miss.
Orders typed in the ERP (phone or B2B orders) go to Mervio with POST /v1/orders: lines by variant_id, the buyer, addresses, shipping and payment_method, like "New order" in the admin. Use "reserve_stock": false if the ERP already took the stock, "notify": false to send no e-mail, and always an Idempotency-Key.
Changelog
- 2026-10-28 – v1. First version: shop, products, variants, stock per location, locations, collections, brands, orders (with creating orders), shipments, refunds, invoices, customers, discounts, customer groups and price lists (Pro), webhooks.
The API version is in the URL (/v1). New fields and endpoints are added without a new version; a change that breaks integrations gets /v2, and /v1 stays at least 12 months after that, announced here and with Deprecation and Sunset headers.