Skip to content
Mervio Beta

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.

  1. In the shop's admin, turn on Settings → Modules → API and webhooks.
  2. 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.
  3. 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 by handle, an order by number, a customer by e-mail.
  • external_id stores 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 2xx within 10 seconds; do the work afterwards. Deduplicate by the webhook-id header (equal to id). Order is not guaranteed: compare data.updated_at and 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.updated and inventory.updated come at most once a minute per product or variant, with the newest state.
  • Test: POST /v1/webhooks/{id}/test sends a webhook.test event.

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

  1. Create a shop token for the ERP with orders:read, orders:write, catalog:read, catalog:write, webhooks:write (and customers:read if the ERP needs the buyer's contacts).
  2. Register a webhook for order.paid, order.cancelled and order.updated. For bank transfers and cash on delivery also order.created.
  3. On order.paid, create the sales order in the ERP from data (lines have sku, quantity, unit_price; each line has an id). Store the ERP number: PATCH /v1/orders/{id} with {"external_id": "SO-2026-1042"}.
  4. When the parcel leaves, POST /v1/orders/{id}/shipments with {"tracking_number": "LP123456789LT"} (or lines for a partial shipment). The buyer gets the shipping e-mail, as from the admin.
  5. Keep stock in step from the ERP with POST /v1/inventory/bulk (one request per 100 or 1 000 rows), and prices with POST /v1/variants/bulk.
  6. 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.