Български

Start here — your first call in 5 minutes

Three steps, no reading required: a key → one request → one webhook. Everything below was executed against production; the responses are real (ids, emails and phones masked).

Step 1 — get a key from the merchant

The merchant opens the velik.ai admin → Settings → 🔌 Integrations → API keys → New key, picks a preset (📦 Fulfilment for a warehouse or WMS, 👁 Read only for reporting, ⚙️ Custom for anything else) and hands you the vk_… key once. The key is the merchant's account: one key = one account, Pro plan and above.

Keep it like a password. Never put it in a browser or a mobile app — /api/v1 has no CORS on purpose.

Step 2 — your first request

curl -sS "https://velik.ai/api/v1/orders?status=new&limit=5" \
  -H "Authorization: Bearer $VELIK_API_KEY"
{
  "data": [
    {
      "id": "00000000-0000-4000-8000-000000000001",
      "number": 16,
      "order_number": 1546,
      "status": "new",
      "payment_status": "pending",
      "payment_method": "cod",
      "funnel_step": "original",
      "parent_order_id": null,
      "total": 29,
      "delivery_price": null,
      "discount_amount": null,
      "coupon_code": null,
      "refund_amount_cents": null,
      "refunded_at": null,
      "customer": {
        "name": "Тест V. C. V.",
        "email": "bo…@convertbuilder.com",
        "phone": "+359 8•• ••• •12",
        "vat": null
      },
      "shipping": {
        "address": "ул. Те ••• (masked)",
        "city": "София",
        "zip": "1000",
        "method": null,
        "courier_slug": "econt",
        "courier_delivery_mode": "home",
        "courier_office_id": null
      },
      "courier": null,
      "tracking_number": null,
      "tracking_url": null,
      "line_items": [
        {
          "qty": 1,
          "sku": null,
          "kind": "main",
          "name": "Хранителна добавка за сън",
          "product_id": "00000000-0000-4000-8000-000000000002",
          "variant_id": null,
          "total_cents": 2900,
          "delivery_type": "physical",
          "unit_price_cents": 2900
        }
      ],
      "product_name": "Хранителна добавка за сън",
      "product_sku": null,
      "quantity": 1,
      "unit_price": 29,
      "notes": null,
      "admin_notes": null,
      "return_note": null,
      "page_id": "00000000-0000-4000-8000-000000000003",
      "invoice_number": null,
      "shipment": null,
      "created_at": "2026-09-04T14:10:54.525378+00:00",
      "updated_at": "2026-09-05T14:34:46.691317+00:00"
    },
    {
      "id": "00000000-0000-4000-8000-000000000004",
      "number": 15,
      "order_number": 1544,
      "status": "new",
      "payment_status": "pending",
      "payment_method": "cod",
      "funnel_step": "original",
      "parent_order_id": null,
      "total": 41,
      "delivery_price": null,
      "discount_amount": null,
      "coupon_code": null,
      "refund_amount_cents": null,
      "refunded_at": null,
      "customer": {
        "name": "Тест V. C.",
        "email": "bo…@convertbuilder.com",
        "phone": "+359 8•• ••• •12",
        "vat": null
      },
      "shipping": {
        "address": "ул. Те ••• (masked)",
        "city": "София",
        "zip": "1000",
        "method": null,
        "courier_slug": "econt",
        "courier_delivery_mode": "home",
        "courier_office_id": null
      },
      "courier": null,
      "tracking_number": null,
      "tracking_url": null,
      "line_items": [
        {
          "qty": 1,
          "sku": null,
          "kind": "main",
          "name": "Хранителна добавка за сън",
          "product_id": "00000000-0000-4000-8000-000000000002",
          "variant_id": null,
          "total_cents": 2900,
          "delivery_type": "physical",
          "unit_price_cents": 2900
        }
      ],
      "product_name": "Хранителна добавка за сън",
      "product_sku": null,
      "quantity": 1,
      "unit_price": 29,
      "notes": "+ Downsell: Serenity Balance Woman — €12 вместо €39 — €12.00",
      "admin_notes": null,
      "return_note": null,
      "page_id": "00000000-0000-4000-8000-000000000003",
      "invoice_number": null,
      "shipment": {
        "id": "00000000-0000-4000-8000-000000000005",
        "courier_slug": "econt",
        "tracking_number": "1051•••91",
        "status": "cancelled",
        "last_status_label": "Анулирана",
        "last_status_at": "2026-09-05T15:12:02.308+00:00"
      },
      "created_at": "2026-09-04T12:22:17.798464+00:00",
      "updated_at": "2026-09-05T15:12:03.084937+00:00"
    }
  ],
  "has_more": true,
  "next_cursor": "eyJjIjoiMjAyNi0wOS0wNFQxMjoyMjoxNy43OTg0NjQrMDA6MDAiLCJpIjoiMjFlOTI4OTItMGQ0NC00OTE4LWJiNzktMDQ0ZDAxZGYwYzE3In0"
}

Every list is paginated the same way (limit ≤ 200, opaque cursor, has_more), every write accepts X-Velik-Dry-Run: 1 to validate without writing, and every error is { "error": { "code", "message" } } — see Errors and limits.

Step 3 — let velik.ai call you

Subscribe to the events you need (the secret is returned once — it signs every delivery):

curl -sS -X POST "https://velik.ai/api/v1/webhooks" \
  -H "Authorization: Bearer $VELIK_API_KEY" -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/velik-webhook", "events": ["order.created", "order.status_changed", "shipment.status_changed"] }'
{
  "data": {
    "id": "00000000-0000-4000-8000-000000000035",
    "url": "https://velik.ai/api/webhooks/sink",
    "description": "Fulfilment WMS (probe)",
    "events": [
      "order.created",
      "order.updated",
      "order.status_changed",
      "shipment.created",
      "shipment.status_changed"
    ],
    "active": true,
    "secret_prefix": "whsec_efcd8722…",
    "last_triggered_at": null,
    "last_status_code": null,
    "dead_notified_at": null,
    "created_at": "2026-09-05T16:06:29.476938+00:00",
    "updated_at": "2026-09-05T16:06:29.476938+00:00"
  },
  "secret": "<64 hex characters — shown once, store it>"
}

Then POST /api/v1/webhooks/{id}/test sends a test envelope and returns the first delivery attempt with the HTTP code your endpoint answered. Verify X-Velik-Signature with the reference code in Node, Python or PHP; the full contract is in Webhooks.

Where to go next

I want to…read
connect a fulfilment centre / WMS end to end (pull orders → accept → ship → stock)Fulfilment quickstart
know every endpoint, field and statusAPI reference · openapi.v1.yaml
try it in Postman / Insomnia / BrunoPostman collection (generated from the spec); Insomnia and Bruno import openapi.v1.yaml directly
give an AI agent (Claude Desktop, Claude Code) the same accessMCP server
understand errors, rate limits, pagination, dry runOverview §5–§8
know what changed and what we promise not to changeVersioning · Changelog
explain this to the business owner (Bulgarian, no code)За собственика на бизнеса

Copy-paste examples: curl.sh · node.mjs · python.py. Questions: support@velik.ai — see Support.

velik.ai Platform API

Base URL: https://velik.ai · REST: /api/v1/… · MCP: POST /api/mcp · Spec: openapi.v1.yaml (rendered) · Български: README.bg.md

The Platform API gives integrators — fulfilment centres, warehouse systems, ERP/BI tools and AI agents — programmatic access to a merchant's orders, shipments, inventory, products, customers and webhooks. It is the same data and the same operations the merchant sees in the velik.ai admin, exposed through two transports that share one contract:

transportforauth
REST https://velik.ai/api/v1/…your backend, WMS, ERPAuthorization: Bearer vk_…
MCP POST https://velik.ai/api/mcp (JSON-RPC 2.0, Streamable HTTP, no sessions)AI agents (Claude Desktop, Claude Code, any MCP client)the same key
Webhooks (we POST to you)push instead of pollingsigned with a per-subscription secret

Guides: Fulfilment quickstart · Webhooks · MCP · Changelog · Examples (curl, Node, Python, signature verification in Node/Python/PHP).

Every example in these documents was executed against production; the responses shown are the real ones with ids, emails and phones masked.


1. Getting a key

The merchant issues the key (you cannot create keys for another account):

  1. velik.ai admin → Settings → 🔌 Integrations → API keys → New key.
  2. Pick a preset (see §2) or choose scopes one by one, give it a name (e.g. "Acme Fulfilment").
  3. The full key vk_… is shown once. The merchant hands it to you over a secure channel. The list later shows only the prefix (vk_ab12cd34), the scopes and last used.

Rules:

  • One key = one account. The key acts as the account owner for the resources its scopes cover. Team-member permissions do not apply to keys. Nothing a key can do crosses into another account.
  • Up to 10 active keys per account. Revoking is immediate (401 unauthorized from then on).
  • Scopes are fixed at issue time. To change them, the merchant issues a new key and revokes the old one.
  • Keys are for server-to-server use. There is no CORS on /api/v1 — never ship a key inside a browser or a mobile app.

2. Scopes and presets

Scopes are explicit, resource:action. :write does not imply :read — a key with orders:write only can update an order it already knows the id of, but cannot list orders. Ask the merchant for both when you need both.

scopegrantsRESTMCP tools
orders:readlist / read orders (with shipment summary)GET /api/v1/orders, GET /api/v1/orders/{id}list_orders, get_order
orders:writestatus, tracking number/url, courier, internal notesPATCH /api/v1/orders/{id}update_order
shipments:readshipments (waybills), status events, label PDFGET /api/v1/orders/{id}/shipment, GET /api/v1/shipments/{id}, GET /api/v1/shipments/{id}/labelget_shipment
shipments:writecreate / cancel waybills through the merchant's couriersPOST /api/v1/shipments, POST /api/v1/shipments/{id}/cancelcreate_shipment, cancel_shipment
inventory:readstock levels, thresholds, the stock ledgerGET /api/v1/inventory/stock, GET /api/v1/inventory/movementsget_inventory_stock, list_stock_movements
inventory:writedeliveries and correctionsPOST /api/v1/inventory/movementsrecord_stock_movement
products:readcatalog with variants, prices, SKUs, dimensionsGET /api/v1/products, GET /api/v1/products/{id}list_products, get_product
products:writecreate products; change price, SKU, name, description, weight / dimensions, active, thresholds — never stock quantity, cost, images or filesPOST /api/v1/products, PATCH /api/v1/products/{id}create_product, update_product
customers:readcustomers (aggregated from orders), saved segmentsGET /api/v1/customers, GET /api/v1/segmentslist_customers, list_segments
customers:writesegments (create / rename / filters / delete), segment members by email or phone, customer notes — never emails, phones, consents or deletionPOST /api/v1/segments, PATCH/DELETE /api/v1/segments/{id}, POST/DELETE /api/v1/segments/{id}/members, POST /api/v1/customers/{key}/notescreate_segment, update_segment, delete_segment, add_segment_members, remove_segment_members, add_customer_note
webhooks:readsubscriptions and their deliveriesGET /api/v1/webhooks, GET /api/v1/webhooks/{id}/deliverieslist_webhooks, list_webhook_deliveries
webhooks:writecreate / update / delete / test subscriptions, retry deliveriesPOST /api/v1/webhooks, PATCH/DELETE /api/v1/webhooks/{id}, POST /api/v1/webhooks/{id}/test, POST /api/v1/webhook-deliveries/{id}/retrycreate_webhook, update_webhook, delete_webhook, test_webhook
submit_brief, get_status, get_factsthe bridge to МАШИНАТА (MCP only, separate contract — see mcp.md §5)—submit_brief, get_status, get_facts

Presets the merchant can pick when issuing:

presetscopestypical user
📦 Fulfilmentorders:read, orders:write, shipments:read, shipments:write, inventory:read, inventory:write, products:read, webhooks:read, webhooks:writefulfilment centre / WMS
👁 Read onlyorders:read, shipments:read, inventory:read, products:read, customers:read, webhooks:readBI, accounting, CRM sync
🔗 МАШИНАТАsubmit_brief, get_status, get_facts, products:readthe MASHINATA bridge (the catalog is read-only, so a brief can point at a product by id)
⚙️ Customany combinationeveryone else

There is no default preset — the merchant chooses explicitly. Fulfilment and Read-only keys do not include the bridge scopes and do not see the bridge tools. products:write and customers:write are in no preset — the merchant grants them one by one under Custom (an ERP/CRM sync is an explicit decision).

3. Plans

The Platform API is available on the Pro plan and above. A key of a Free account authenticates but every call returns 403 plan_required. If the plan cannot be verified the API fails closed with 403 plan_lookup_failed (retry later). velik.ai staff accounts (superadmin role) bypass only the plan check — never tenancy: their keys still see only their own account.

4. Authentication

GET /api/v1/orders?status=new HTTP/1.1
Host: velik.ai
Authorization: Bearer vk_…
resultstatuserror.code
missing / malformed / revoked key, deactivated account401unauthorized (+ WWW-Authenticate: Bearer realm="velik.ai API")
key lacks the scope of this route403scope_missing (+ error.scope)
Free plan403plan_required
plan lookup failed403plan_lookup_failed
over the rate limit429rate_limited (+ Retry-After)

Recorded: a Fulfilment key calling a customers:read route:

{
  "error": {
    "code": "scope_missing",
    "message": "This key does not have the customers:read scope.",
    "scope": "customers:read"
  }
}

5. Errors

Every error is JSON with a stable snake_case code. Key your handling on error.code; error.message is English prose and may change.

{ "error": { "code": "validation_failed", "message": "id must be a UUID.", "details": { … } } }
codestatuswhen
unauthorized401see §4
scope_missing · plan_required · plan_lookup_failed403see §4
method_not_allowed405wrong HTTP method (Allow header lists the right ones; OPTIONS → 204)
validation_failed400bad input: unknown field, wrong type/enum, non-UUID id (path ids and the page_id / product_id / order_id filters), non-ISO 8601 date (from, to, since), bad cursor; details.allowed lists the accepted fields where relevant
not_found404no such row in this account — including rows that belong to another account (never 403)
conflict409state conflict: live shipment exists, shipment already cancelled/terminal, webhook limit (20), delivery already delivered, webhook inactive, members on a computed segment
courier_error502the courier refused or did not answer (details.reason, details.detail)
migration_pending503feature not enabled on this deployment yet
rate_limited429over 300 req/min for this key
internal_error500our bug — retry, and tell us the X-Deploy-SHA

Two rules worth repeating:

  • Ids are UUIDs. Anything else is rejected before touching the database: GET /api/v1/orders/1546 → 400 validation_failed "id must be a UUID." Order numbers (number, order_number) are not ids.
  • Other accounts' rows are 404, never 403. The API does not reveal whether an id exists elsewhere.
{
  "error": {
    "code": "validation_failed",
    "message": "Unknown field(s): total. Allowed: status, tracking_number, tracking_url, courier, admin_notes, notify_customer.",
    "details": {
      "allowed": [
        "status",
        "tracking_number",
        "tracking_url",
        "courier",
        "admin_notes",
        "notify_customer"
      ]
    }
  }
}

6. Rate limits

transportlimitbucketheaders
REST /api/v1300 requests / minuteper keyX-RateLimit-Limit, X-RateLimit-Remaining, Retry-After (on 429)
MCP /api/mcp120 messages / minute (a JSON-RPC batch of N counts N; max 20 per POST)per keyX-RateLimit-Limit, X-RateLimit-Remaining, Retry-After (on 429)
Webhook test and retry (POST /api/v1/webhooks/{id}/test, POST /api/v1/webhook-deliveries/{id}/retry, the admin buttons, the MCP tools)30 / minute eachper accountRetry-After (on 429)

Back off on 429 for Retry-After seconds. Prefer webhooks (§10) over tight polling.

7. Pagination and polling

List endpoints return a page and an opaque cursor:

{ "data": [ … ], "has_more": true, "next_cursor": "eyJjIjoi…" }
  • limit — default 50, max 200.
  • cursor — pass next_cursor back to get the next page. Cursors are keyset (created_at, id), so new rows inserted while you paginate do not shift the pages. A malformed cursor → 400 validation_failed.
  • Orders support since=<ISO 8601> (updated_at ≥), the cheapest way to poll for changes without webhooks: remember the updated_at of the newest row you processed and ask for everything since. Times are UTC ISO 8601.
  • GET /api/v1/customers is an aggregate and uses limit / offset / total instead.
  • GET /api/v1/segments and GET /api/v1/webhooks return everything (small lists).

8. Dry run (sandbox)

There is no separate sandbox environment. Instead, every write operation accepts

X-Velik-Dry-Run: 1

and then validates everything — ownership, fields, state, courier resolution, stock — but writes nothing and calls no courier. The response carries dry_run: true and describes what would happen. Use it to test your integration against the merchant's real data, and to preview before committing.

{
  "dry_run": true,
  "data": {
    "id": "00000000-0000-4000-8000-000000000001",
    "number": 16,
    "order_number": 1546,
    "status": "new",
    "payment_status": "pending",
    "payment_method": "cod",
    "funnel_step": "original",
    "parent_order_id": null,
    "total": 29,
    "delivery_price": null,
    "discount_amount": null,
    "coupon_code": null,
    "refund_amount_cents": null,
    "refunded_at": null,
    "customer": {
      "name": "Тест V. C. V.",
      "email": "bo…@convertbuilder.com",
      "phone": "+359 8•• ••• •12",
      "vat": null
    },
    "shipping": {
      "address": "ул. Те ••• (masked)",
      "city": "София",
      "zip": "1000",
      "method": null,
      "courier_slug": "econt",
      "courier_delivery_mode": "home",
      "courier_office_id": null
    },
    "courier": null,
    "tracking_number": null,
    "tracking_url": null,
    "line_items": [
      {
        "qty": 1,
        "sku": null,
        "kind": "main",
        "name": "Хранителна добавка за сън",
        "product_id": "00000000-0000-4000-8000-000000000002",
        "variant_id": null,
        "total_cents": 2900,
        "delivery_type": "physical",
        "unit_price_cents": 2900
      }
    ],
    "product_name": "Хранителна добавка за сън",
    "product_sku": null,
    "quantity": 1,
    "unit_price": 29,
    "notes": null,
    "admin_notes": null,
    "return_note": null,
    "page_id": "00000000-0000-4000-8000-000000000003",
    "invoice_number": null,
    "shipment": null,
    "created_at": "2026-09-04T14:10:54.525378+00:00",
    "updated_at": "2026-09-05T14:34:46.691317+00:00"
  },
  "changes": {
    "status": "processing"
  },
  "status_would_change": true,
  "would_notify_customer": false
}

Every response, success or error, carries X-Deploy-SHA — the revision of velik.ai that served it. Quote it when you report a problem.

9. Resources

method & pathscopereturns
GET /api/v1/ordersorders:readpage of Order — filters status, payment, from, to, since, page_id, product_id, courier (none = no tracking number yet), shipment_status, q
GET /api/v1/orders/{id}orders:readOrder
PATCH /api/v1/orders/{id}orders:writestatus, tracking_number, tracking_url, courier, admin_notes, notify_customer → Order + status_changed, email_sent
GET /api/v1/orders/{id}/shipmentshipments:readlatest Shipment of the order or null
POST /api/v1/shipmentsshipments:writecreate a waybill through the merchant's courier → Shipment
GET /api/v1/shipments/{id}shipments:readShipment with status_events
GET /api/v1/shipments/{id}/labelshipments:readthe label as application/pdf
POST /api/v1/shipments/{id}/cancelshipments:writecancel at the courier
GET /api/v1/inventory/stockinventory:readstock per active physical product (+ variants, thresholds)
GET /api/v1/inventory/movementsinventory:readthe stock ledger (sale, release, return, delivery, correction)
POST /api/v1/inventory/movementsinventory:writerecord a delivery or a correction
GET /api/v1/products · GET /api/v1/products/{id}products:readcatalog with variants; ?status=all includes deactivated
POST /api/v1/products · PATCH /api/v1/products/{id}products:writecreate / update catalog fields (price, SKU, name, dimensions, active…) — not stock, cost or media
GET /api/v1/customerscustomers:readcustomers aggregated from orders
GET /api/v1/segmentscustomers:readsaved customer segments
POST /api/v1/segments · PATCH/DELETE /api/v1/segments/{id}customers:writecomputed (filters) or membership segments
POST/DELETE /api/v1/segments/{id}/memberscustomers:writepush / remove customers (email or phone) in a membership segment
POST /api/v1/customers/{key}/notescustomers:writeinternal note on a customer
GET /api/v1/webhooks · POST /api/v1/webhookswebhooks:read / webhooks:writesubscriptions (secret returned once on create)
PATCH /api/v1/webhooks/{id} · DELETE /api/v1/webhooks/{id}webhooks:writeupdate / delete
GET /api/v1/webhooks/{id}/deliveries · POST /api/v1/webhooks/{id}/testwebhooks:read / webhooks:writedeliveries / send a test event
POST /api/v1/webhook-deliveries/{id}/retrywebhooks:writeretry a delivery now

Full parameters, request bodies and response schemas: openapi.v1.yaml / velik.ai/developers/api.

The data model in one screen

  • Order — id (UUID), number (the merchant's per-account order number), status ∈ new · confirmed · processing · shipped · delivered · cancelled · returned · refunded · archived, payment_status, payment_method (cod, bank, card, free…), currency (ISO 4217 — the order's currency, frozen from the page; every amount of the order is in it), money as numbers in that currency (total, delivery_price, discount_amount), customer { name, email, phone, vat }, shipping { address, city, zip, method, courier_slug, courier_delivery_mode, courier_office_id }, courier / tracking_number / tracking_url (yours or ours), line_items[] (the frozen bill, amounts in cents: kind, product_id, variant_id, name, sku, qty, unit_price_cents, total_cents), shipment (summary of the latest waybill or null), admin_notes, page_id, timestamps. Legacy orders have line_items: null and the single product in product_name / quantity / unit_price. An order from a website shopping cart has funnel_step: "cart" and one kind: "item" line per product: the truth is in line_items, while product_name is only a label ("<first product> + още N") and product_sku / quantity / unit_price describe the first line. Attribution, IPs, payment-provider ids and costs are never exposed.
  • Shipment — a waybill created through velik.ai: courier_slug (econt / speedy / boxnow), tracking_number, normalized status ∈ pending · picked_up · in_transit · delivered · cancelled · refused · returned, status_events[] ({ at, code, label } — the raw courier events), delivery_mode (office / home), office_id, cod_amount, shipping_cost, parcel weight/dimensions, sender_origin, shipment_terms.
  • Product — name, sku, price, currency (ISO 4217: EUR, USD, GBP, HUF, CZK, PLN, RON, CHF; prices are in it, major units — HUF and CZK are whole numbers), active, delivery_type (physical / digital / free), images[] (URLs), stock_quantity (null = not tracked), low_stock_threshold, weight_g, dim_cm { l, w, h }, variants[].
  • Stock item — product_id, tracked, stock_quantity, effective_threshold, low, variants[]. Movement — type, signed qty, order_id, note.
  • Customer — an aggregate keyed by email (else phone): orders_count, total_spent, total_spent_by_currency (the same sum per order currency — amounts in different currencies do not add up), first/last order. Segment — name, filters, customer_count; a segment with filters: { inSegment: <its own id> } is a membership segment (filled through the API or imports), any other filters object is computed from orders. Note — customer_key, text, created_at.
  • Webhook — url, events[], active, secret_prefix, last_status_code. Delivery — event, status ∈ pending · delivered · failed · dead, attempt, next_attempt_at, last_status_code.

10. Webhooks in one paragraph

Subscribe to order.created, order.updated, order.status_changed, order.paid, shipment.created, shipment.status_changed, shipment.cancelled, inventory.low_stock. Deliveries are JSON envelopes { id, event, created_at, account_id, api_version, data }, signed with X-Velik-Signature: t=…,v1=… (HMAC-SHA256 over t.body), delivered at least once with retries (1 min → 5 min → 30 min → 2 h → 12 h; 6 attempts), deduplicated by id. Return any 2xx. Details, every payload and verification code in three languages: webhooks.md.

11. Versioning and stability

  • The version is in the path: /api/v1. Webhook envelopes carry api_version (2026-09-01) because there is no path there.
  • v1 changes only additively: new endpoints, new optional fields, new enum values (documented in the CHANGELOG). Fields are never removed, renamed or retyped. Treat unknown fields and unknown enum values as forward-compatible.
  • A breaking change means /api/v2, with v1 kept for at least 12 months in parallel, Deprecation and Sunset headers on v1, a changelog entry and an email to merchants with active keys.
  • The contract is enforced by tests in the velik.ai repository: every route must exist in openapi.v1.yaml, every response must validate against its schema, and a change to the spec requires a changelog entry.

12. Good citizenship

  • Store the key like a password. Rotate by issuing a new key and revoking the old one.
  • Use since or webhooks instead of full re-syncs.
  • Send X-Velik-Dry-Run: 1 when you are unsure.
  • Set a User-Agent that identifies your integration.
  • Support: support@velik.ai — include the X-Deploy-SHA, the path and the error.code.

13. Support and status

  • Where: support@velik.ai. One integration per thread; we answer on business days, production incidents first.
  • What to include: the X-Deploy-SHA from the response, the path and method, the error.code and message, the time (UTC) and — for webhooks — the X-Velik-Delivery / event_id. Never send your key; the vk_ prefix (first 11 characters) is enough to identify it.
  • Platform status: every /api/v1 response carries X-Deploy-SHA (the revision serving you). A 503 migration_pending means a rollout is in progress — retry in a minute. There is no separate status page yet; incidents that affect integrators are announced by email to merchants with active keys.
  • Changes: additive only in v1, always listed in the Changelog with the contract hash. Breaking changes mean /api/v2 with at least 12 months of v1 in parallel (see §11).

Quickstart: connecting a fulfilment centre

Goal: pull new orders, accept them, ship them (with your own tracking number or through the merchant's courier), keep stock in sync, and get told when things change — without polling. Every response below is a real one from production (ids/emails masked). Български: quickstart-fulfilment.bg.md.

You need: a key with the 📦 Fulfilment preset, issued by the merchant (velik.ai → Settings → 🔌 Integrations → API keys). One key per merchant you serve.

export VELIK_API_KEY=vk_…            # from the merchant
export BASE=https://velik.ai

Step 1 — pull the new orders

status=new is the work list. Follow next_cursor until has_more is false.

curl -sS -H "Authorization: Bearer $VELIK_API_KEY" "$BASE/api/v1/orders?status=new&limit=2"
{
  "data": [
    {
      "id": "00000000-0000-4000-8000-000000000001",
      "number": 16,
      "order_number": 1546,
      "status": "new",
      "payment_status": "pending",
      "payment_method": "cod",
      "funnel_step": "original",
      "parent_order_id": null,
      "total": 29,
      "delivery_price": null,
      "discount_amount": null,
      "coupon_code": null,
      "refund_amount_cents": null,
      "refunded_at": null,
      "customer": {
        "name": "Тест V. C. V.",
        "email": "bo…@convertbuilder.com",
        "phone": "+359 8•• ••• •12",
        "vat": null
      },
      "shipping": {
        "address": "ул. Те ••• (masked)",
        "city": "София",
        "zip": "1000",
        "method": null,
        "courier_slug": "econt",
        "courier_delivery_mode": "home",
        "courier_office_id": null
      },
      "courier": null,
      "tracking_number": null,
      "tracking_url": null,
      "line_items": [
        {
          "qty": 1,
          "sku": null,
          "kind": "main",
          "name": "Хранителна добавка за сън",
          "product_id": "00000000-0000-4000-8000-000000000002",
          "variant_id": null,
          "total_cents": 2900,
          "delivery_type": "physical",
          "unit_price_cents": 2900
        }
      ],
      "product_name": "Хранителна добавка за сън",
      "product_sku": null,
      "quantity": 1,
      "unit_price": 29,
      "notes": null,
      "admin_notes": null,
      "return_note": null,
      "page_id": "00000000-0000-4000-8000-000000000003",
      "invoice_number": null,
      "shipment": null,
      "created_at": "2026-09-04T14:10:54.525378+00:00",
      "updated_at": "2026-09-05T14:34:46.691317+00:00"
    },
    {
      "id": "00000000-0000-4000-8000-000000000004",
      "number": 15,
      "order_number": 1544,
      "status": "new",
      "payment_status": "pending",
      "payment_method": "cod",
      "funnel_step": "original",
      "parent_order_id": null,
      "total": 41,
      "delivery_price": null,
      "discount_amount": null,
      "coupon_code": null,
      "refund_amount_cents": null,
      "refunded_at": null,
      "customer": {
        "name": "Тест V. C.",
        "email": "bo…@convertbuilder.com",
        "phone": "+359 8•• ••• •12",
        "vat": null
      },
      "shipping": {
        "address": "ул. Те ••• (masked)",
        "city": "София",
        "zip": "1000",
        "method": null,
        "courier_slug": "econt",
        "courier_delivery_mode": "home",
        "courier_office_id": null
      },
      "courier": null,
      "tracking_number": null,
      "tracking_url": null,
      "line_items": [
        {
          "qty": 1,
          "sku": null,
          "kind": "main",
          "name": "Хранителна добавка за сън",
          "product_id": "00000000-0000-4000-8000-000000000002",
          "variant_id": null,
          "total_cents": 2900,
          "delivery_type": "physical",
          "unit_price_cents": 2900
        }
      ],
      "product_name": "Хранителна добавка за сън",
      "product_sku": null,
      "quantity": 1,
      "unit_price": 29,
      "notes": "+ Downsell: Serenity Balance Woman — €12 вместо €39 — €12.00",
      "admin_notes": null,
      "return_note": null,
      "page_id": "00000000-0000-4000-8000-000000000003",
      "invoice_number": null,
      "shipment": {
        "id": "00000000-0000-4000-8000-000000000005",
        "courier_slug": "econt",
        "tracking_number": "1051•••91",
        "status": "cancelled",
        "last_status_label": "Анулирана",
        "last_status_at": "2026-09-05T15:12:02.308+00:00"
      },
      "created_at": "2026-09-04T12:22:17.798464+00:00",
      "updated_at": "2026-09-05T15:12:03.084937+00:00"
    }
  ],
  "has_more": true,
  "next_cursor": "eyJjIjoiMjAyNi0wOS0wNFQxMjoyMjoxNy43OTg0NjQrMDA6MDAiLCJpIjoiMjFlOTI4OTItMGQ0NC00OTE4LWJiNzktMDQ0ZDAxZGYwYzE3In0"
}

What to read from an order:

  • id — the UUID you will use in every later call (number is what the merchant calls it: #16).
  • line_items[] — what to pack: product_id, variant_id, sku, name, qty; amounts are in cents. kind: "main" is the product, bump / gift / component are add-ons and bundle parts.
  • customer and shipping — recipient, phone, address, and the courier/mode/office the customer chose at checkout (shipping.courier_slug, courier_delivery_mode, courier_office_id).
  • payment_method: "cod" + total — the cash-on-delivery amount.

Tip: courier=none narrows the list to orders that have no tracking number yet, and since=<ISO time> returns only orders updated after a moment (see README §7).

Step 2 — accept the order

Move it to processing so the merchant sees it is being handled. Dry run first if you like:

curl -sS -X PATCH -H "Authorization: Bearer $VELIK_API_KEY" -H "Content-Type: application/json" \
  -H "X-Velik-Dry-Run: 1" "$BASE/api/v1/orders/$ORDER_ID" -d '{"status":"processing"}'

Then for real:

curl -sS -X PATCH -H "Authorization: Bearer $VELIK_API_KEY" -H "Content-Type: application/json" \
  "$BASE/api/v1/orders/$ORDER_ID" -d '{"status":"processing"}'
{
  "data": {
    "id": "00000000-0000-4000-8000-000000000001",
    "number": 16,
    "order_number": 1546,
    "status": "processing",
    "payment_status": "pending",
    "payment_method": "cod",
    "funnel_step": "original",
    "parent_order_id": null,
    "total": 29,
    "delivery_price": null,
    "discount_amount": null,
    "coupon_code": null,
    "refund_amount_cents": null,
    "refunded_at": null,
    "customer": {
      "name": "Тест V. C. V.",
      "email": "bo…@convertbuilder.com",
      "phone": "+359 8•• ••• •12",
      "vat": null
    },
    "shipping": {
      "address": "ул. Те ••• (masked)",
      "city": "София",
      "zip": "1000",
      "method": null,
      "courier_slug": "econt",
      "courier_delivery_mode": "home",
      "courier_office_id": null
    },
    "courier": null,
    "tracking_number": null,
    "tracking_url": null,
    "line_items": [
      {
        "qty": 1,
        "sku": null,
        "kind": "main",
        "name": "Хранителна добавка за сън",
        "product_id": "00000000-0000-4000-8000-000000000002",
        "variant_id": null,
        "total_cents": 2900,
        "delivery_type": "physical",
        "unit_price_cents": 2900
      }
    ],
    "product_name": "Хранителна добавка за сън",
    "product_sku": null,
    "quantity": 1,
    "unit_price": 29,
    "notes": null,
    "admin_notes": null,
    "return_note": null,
    "page_id": "00000000-0000-4000-8000-000000000003",
    "invoice_number": null,
    "shipment": null,
    "created_at": "2026-09-04T14:10:54.525378+00:00",
    "updated_at": "2026-09-05T16:06:31.958157+00:00"
  },
  "status_changed": true,
  "email_sent": false
}

status_changed: true — the transition happened (and every webhook subscriber received order.status_changed). Sending the same status again is a harmless no-op:

{
  "data": {
    "id": "00000000-0000-4000-8000-000000000001",
    "number": 16,
    "order_number": 1546,
    "status": "shipped",
    "payment_status": "pending",
    "payment_method": "cod",
    "funnel_step": "original",
    "parent_order_id": null,
    "total": 29,
    "delivery_price": null,
    "discount_amount": null,
    "coupon_code": null,
    "refund_amount_cents": null,
    "refunded_at": null,
    "customer": {
      "name": "Тест V. C. V.",
      "email": "bo…@convertbuilder.com",
      "phone": "+359 8•• ••• •12",
      "vat": null
    },
    "shipping": {
      "address": "ул. Те ••• (masked)",
      "city": "София",
      "zip": "1000",
      "method": null,
      "courier_slug": "econt",
      "courier_delivery_mode": "home",
      "courier_office_id": null
    },
    "courier": "Speedy",
    "tracking_number": "WMS-2026-000123",
    "tracking_url": "https://tracking.example-wms.com/WMS-2026-000123",
    "line_items": [
      {
        "qty": 1,
        "sku": null,
        "kind": "main",
        "name": "Хранителна добавка за сън",
        "product_id": "00000000-0000-4000-8000-000000000002",
        "variant_id": null,
        "total_cents": 2900,
        "delivery_type": "physical",
        "unit_price_cents": 2900
      }
    ],
    "product_name": "Хранителна добавка за сън",
    "product_sku": null,
    "quantity": 1,
    "unit_price": 29,
    "notes": null,
    "admin_notes": null,
    "return_note": null,
    "page_id": "00000000-0000-4000-8000-000000000003",
    "invoice_number": null,
    "shipment": null,
    "created_at": "2026-09-04T14:10:54.525378+00:00",
    "updated_at": "2026-09-05T16:06:32.348452+00:00"
  },
  "status_changed": false,
  "email_sent": false
}

Step 3 — ship

Two models. Pick the one that matches your contract with the couriers.

(a) You have your own courier contract — send us the tracking number

One PATCH: tracking number (+ optional tracking URL and courier name) and status: "shipped" together. Setting shipped triggers the merchant's "your order was shipped" email and SMS/Viber to the customer, with your tracking number inside, exactly as if the merchant had done it in the admin. Pass "notify_customer": false to suppress that (we did below, because this was a test order).

curl -sS -X PATCH -H "Authorization: Bearer $VELIK_API_KEY" -H "Content-Type: application/json" \
  "$BASE/api/v1/orders/$ORDER_ID" -d '{
    "tracking_number": "WMS-2026-000123",
    "tracking_url": "https://tracking.example-wms.com/WMS-2026-000123",
    "courier": "Speedy",
    "status": "shipped",
    "notify_customer": false
  }'
{
  "data": {
    "id": "00000000-0000-4000-8000-000000000001",
    "number": 16,
    "order_number": 1546,
    "status": "shipped",
    "payment_status": "pending",
    "payment_method": "cod",
    "funnel_step": "original",
    "parent_order_id": null,
    "total": 29,
    "delivery_price": null,
    "discount_amount": null,
    "coupon_code": null,
    "refund_amount_cents": null,
    "refunded_at": null,
    "customer": {
      "name": "Тест V. C. V.",
      "email": "bo…@convertbuilder.com",
      "phone": "+359 8•• ••• •12",
      "vat": null
    },
    "shipping": {
      "address": "ул. Те ••• (masked)",
      "city": "София",
      "zip": "1000",
      "method": null,
      "courier_slug": "econt",
      "courier_delivery_mode": "home",
      "courier_office_id": null
    },
    "courier": "Speedy",
    "tracking_number": "WMS-2026-000123",
    "tracking_url": "https://tracking.example-wms.com/WMS-2026-000123",
    "line_items": [
      {
        "qty": 1,
        "sku": null,
        "kind": "main",
        "name": "Хранителна добавка за сън",
        "product_id": "00000000-0000-4000-8000-000000000002",
        "variant_id": null,
        "total_cents": 2900,
        "delivery_type": "physical",
        "unit_price_cents": 2900
      }
    ],
    "product_name": "Хранителна добавка за сън",
    "product_sku": null,
    "quantity": 1,
    "unit_price": 29,
    "notes": null,
    "admin_notes": null,
    "return_note": null,
    "page_id": "00000000-0000-4000-8000-000000000003",
    "invoice_number": null,
    "shipment": null,
    "created_at": "2026-09-04T14:10:54.525378+00:00",
    "updated_at": "2026-09-05T16:06:32.348452+00:00"
  },
  "status_changed": true,
  "email_sent": false
}

email_sent tells you whether the shipped email actually went out (customer has an email, notifications enabled, template not disabled by the merchant).

(b) Ship through the merchant's courier integration

If the merchant has Econt / Speedy / BoxNow connected in velik.ai, one call creates the waybill with their account, using the courier/mode/office the customer picked at checkout (override with courier, delivery_mode, office_id, weight_g, dim_cm). Cash-on-delivery is computed server-side. Always dry-run first — it resolves everything except the courier call:

curl -sS -X POST -H "Authorization: Bearer $VELIK_API_KEY" -H "Content-Type: application/json" \
  -H "X-Velik-Dry-Run: 1" "$BASE/api/v1/shipments" -d "{\"order_id\":\"$ORDER_ID\"}"
{
  "dry_run": true,
  "courier": "econt",
  "delivery_mode": "home",
  "office_id": null,
  "weight_g": 500,
  "cod_amount": 41,
  "content_label": "Хранителна добавка за сън",
  "shipment_terms": {
    "cod_type": "cash",
    "open_before": "none",
    "refusal_payer": "sender"
  }
}

Drop the header to create it. The response is the Shipment (tracking number, normalized status, COD, parcel); the order moves to processing and webhooks fire shipment.created. Then:

  • label: GET /api/v1/shipments/{shipment_id}/label → application/pdf (this is the real Speedy label of a real shipment, 92 866 bytes):

    curl -sS -H "Authorization: Bearer $VELIK_API_KEY" -o label.pdf "$BASE/api/v1/shipments/$SHIPMENT_ID/label"
  • status: GET /api/v1/shipments/{shipment_id} or GET /api/v1/orders/{order_id}/shipment:

    {
      "data": {
        "id": "00000000-0000-4000-8000-000000000008",
        "order_id": "00000000-0000-4000-8000-000000000009",
        "courier_slug": "speedy",
        "tracking_number": "6372•••83",
        "status": "pending",
        "last_status_label": "Получена информация за пратка",
        "last_status_at": "2026-08-28T11:52:05+00:00",
        "status_events": [
          {
            "at": "2026-08-28T14:52:05+0300",
            "code": "148",
            "label": "Получена информация за пратка"
          }
        ],
        "delivery_mode": "office",
        "office_id": "9289",
        "recipient_address": null,
        "cod_amount": 38,
        "shipping_cost": 4.56,
        "parcel_weight_g": 500,
        "parcel_dim_cm": null,
        "sender_origin": {
          "mode": "address"
        },
        "shipment_terms": {
          "cod_type": "cash",
          "open_before": "none",
          "refusal_payer": "sender"
        },
        "created_at": "2026-08-28T11:52:05.636473+00:00",
        "updated_at": "2026-08-28T12:00:12.689+00:00"
      }
    }
  • cancel: POST /api/v1/shipments/{shipment_id}/cancel (dry run shows whether it still can be):

    {
      "dry_run": true,
      "data": {
        "id": "00000000-0000-4000-8000-000000000008",
        "order_id": "00000000-0000-4000-8000-000000000009",
        "courier_slug": "speedy",
        "tracking_number": "6372•••83",
        "status": "pending",
        "last_status_label": "Получена информация за пратка",
        "last_status_at": "2026-08-28T11:52:05+00:00",
        "status_events": [
          {
            "at": "2026-08-28T14:52:05+0300",
            "code": "148",
            "label": "Получена информация за пратка"
          }
        ],
        "delivery_mode": "office",
        "office_id": "9289",
        "recipient_address": null,
        "cod_amount": 38,
        "shipping_cost": 4.56,
        "parcel_weight_g": 500,
        "parcel_dim_cm": null,
        "sender_origin": {
          "mode": "address"
        },
        "shipment_terms": {
          "cod_type": "cash",
          "open_before": "none",
          "refusal_payer": "sender"
        },
        "created_at": "2026-08-28T11:52:05.636473+00:00",
        "updated_at": "2026-08-28T12:00:12.689+00:00"
      },
      "cancellable": true
    }

If the order already has a live waybill you get 409 conflict with details.shipment; if the courier refuses, 502 courier_error with the courier's reason in details.

Step 4 — let velik.ai tell you what changed (webhooks)

Instead of polling, subscribe once per merchant. The secret comes back only in this response:

curl -sS -X POST -H "Authorization: Bearer $VELIK_API_KEY" -H "Content-Type: application/json" \
  "$BASE/api/v1/webhooks" -d '{
    "url": "https://wms.example.com/velik-webhook",
    "events": ["order.created", "order.updated", "order.status_changed", "shipment.created", "shipment.status_changed"],
    "description": "Fulfilment WMS"
  }'
{
  "data": {
    "id": "00000000-0000-4000-8000-000000000035",
    "url": "https://velik.ai/api/webhooks/sink",
    "description": "Fulfilment WMS (probe)",
    "events": [
      "order.created",
      "order.updated",
      "order.status_changed",
      "shipment.created",
      "shipment.status_changed"
    ],
    "active": true,
    "secret_prefix": "whsec_efcd8722…",
    "last_triggered_at": null,
    "last_status_code": null,
    "dead_notified_at": null,
    "created_at": "2026-09-05T16:06:29.476938+00:00",
    "updated_at": "2026-09-05T16:06:29.476938+00:00"
  },
  "secret": "<64 hex characters — shown once, store it>"
}

Send yourself a test event and look at the delivery log:

curl -sS -X POST -H "Authorization: Bearer $VELIK_API_KEY" "$BASE/api/v1/webhooks/$WEBHOOK_ID/test"
curl -sS -H "Authorization: Bearer $VELIK_API_KEY" "$BASE/api/v1/webhooks/$WEBHOOK_ID/deliveries?limit=10"
{
  "data": [
    {
      "id": "00000000-0000-4000-8000-000000000039",
      "webhook_id": "00000000-0000-4000-8000-000000000035",
      "event_id": "00000000-0000-4000-8000-000000000040",
      "event": "order.status_changed",
      "status": "delivered",
      "attempt": 1,
      "next_attempt_at": "2026-09-05T16:06:32.358+00:00",
      "last_status_code": 200,
      "last_error": null,
      "delivered_at": "2026-09-05T16:06:32.4+00:00",
      "created_at": "2026-09-05T16:06:32.387958+00:00"
    },
    {
      "id": "00000000-0000-4000-8000-000000000041",
      "webhook_id": "00000000-0000-4000-8000-000000000035",
      "event_id": "00000000-0000-4000-8000-000000000042",
      "event": "order.updated",
      "status": "delivered",
      "attempt": 1,
      "next_attempt_at": "2026-09-05T16:06:32.292+00:00",
      "last_status_code": 200,
      "last_error": null,
      "delivered_at": "2026-09-05T16:06:32.335+00:00",
      "created_at": "2026-09-05T16:06:32.326294+00:00"
    },
    {
      "id": "00000000-0000-4000-8000-000000000043",
      "webhook_id": "00000000-0000-4000-8000-000000000035",
      "event_id": "00000000-0000-4000-8000-000000000044",
      "event": "order.status_changed",
      "status": "delivered",
      "attempt": 1,
      "next_attempt_at": "2026-09-05T16:06:31.99+00:00",
      "last_status_code": 200,
      "last_error": null,
      "delivered_at": "2026-09-05T16:06:32.055+00:00",
      "created_at": "2026-09-05T16:06:32.020957+00:00"
    },
    {
      "id": "00000000-0000-4000-8000-000000000037",
      "webhook_id": "00000000-0000-4000-8000-000000000035",
      "event_id": "00000000-0000-4000-8000-000000000038",
      "event": "test",
      "status": "delivered",
      "attempt": 1,
      "next_attempt_at": "2026-09-05T16:06:30.849+00:00",
      "last_status_code": 200,
      "last_error": null,
      "delivered_at": "2026-09-05T16:06:30.903+00:00",
      "created_at": "2026-09-05T16:06:30.88537+00:00"
    }
  ]
}

This is what arrived at the endpoint when we ran Step 2 — order.status_changed, signed:

{
  "user-agent": "velik.ai-webhooks/1",
  "content-type": "application/json",
  "x-velik-event": "order.status_changed",
  "x-velik-delivery": "00000000-0000-4000-8000-000000000101",
  "x-velik-event-id": "00000000-0000-4000-8000-000000000102",
  "x-velik-signature": "t=1788615989,v1=<hex hmac-sha256>",
  "x-velik-timestamp": "1788615989"
}
{
  "id": "00000000-0000-4000-8000-000000000102",
  "data": {
    "order": {
      "id": "00000000-0000-4000-8000-000000000103",
      "notes": null,
      "total": 29,
      "number": 16,
      "status": "new",
      "courier": null,
      "page_id": "00000000-0000-4000-8000-000000000104",
      "customer": {
        "vat": null,
        "name": "Тест V. C. V.",
        "email": "bo…@convertbuilder.com",
        "phone": "+359 8•• ••• •12"
      },
      "quantity": 1,
      "shipment": null,
      "shipping": {
        "zip": "1000",
        "city": "София",
        "method": null,
        "address": "ул. Те ••• (masked)",
        "courier_slug": "econt",
        "courier_office_id": null,
        "courier_delivery_mode": "home"
      },
      "created_at": "2026-09-04T14:10:54.525378+00:00",
      "line_items": [
        {
          "qty": 1,
          "sku": null,
          "kind": "main",
          "name": "Хранителна добавка за сън",
          "product_id": "00000000-0000-4000-8000-000000000105",
          "variant_id": null,
          "total_cents": 2900,
          "delivery_type": "physical",
          "unit_price_cents": 2900
        }
      ],
      "unit_price": 29,
      "updated_at": "2026-09-05T13:46:29.166283+00:00",
      "admin_notes": null,
      "coupon_code": null,
      "funnel_step": "original",
      "product_sku": null,
      "refunded_at": null,
      "return_note": null,
      "order_number": 1546,
      "product_name": "Хранителна добавка за сън",
      "tracking_url": null,
      "delivery_price": null,
      "invoice_number": null,
      "payment_method": "cod",
      "payment_status": "pending",
      "discount_amount": null,
      "parent_order_id": null,
      "tracking_number": null,
      "refund_amount_cents": null
    },
    "source": "api",
    "status": "new",
    "previous_status": "shipped"
  },
  "event": "order.status_changed",
  "account_id": "00000000-0000-4000-8000-000000000106",
  "created_at": "2026-09-05T13:46:29.189Z",
  "api_version": "2026-09-01"
}
  • order.created → a new order to pack (same data.order shape).
  • order.status_changed with status: "delivered" / "returned" / "cancelled" and source: "courier" → the courier reported the outcome; velik.ai mirrors it onto the order (and for cancelled / returned puts the stock back). Use it to close the order in your WMS.
  • shipment.status_changed → every normalized courier status change of a waybill created through velik.ai, with the raw courier event.

Verify the signature and deduplicate by id — see webhooks.md (Node / Python / PHP snippets).

Step 5 — keep stock in sync

Read the levels (per product, with variants and the low-stock threshold):

curl -sS -H "Authorization: Bearer $VELIK_API_KEY" "$BASE/api/v1/inventory/stock"
{
  "data": [
    {
      "product_id": "00000000-0000-4000-8000-000000000010",
      "name": "Електрическа четка за зъби",
      "sku": null,
      "tracked": true,
      "stock_quantity": 50,
      "low_stock_threshold": null,
      "effective_threshold": 5,
      "low": false,
      "block_on_zero_stock": false,
      "variants": []
    }
  ],
  "default_threshold": 5
}

Goods received → delivery (positive qty, optional unit_cost updates the merchant's weighted average cost). Stock-take differences → correction (signed qty). Sales, releases and returns are written by velik.ai itself when orders move. Dry run:

curl -sS -X POST -H "Authorization: Bearer $VELIK_API_KEY" -H "Content-Type: application/json" \
  -H "X-Velik-Dry-Run: 1" "$BASE/api/v1/inventory/movements" \
  -d "{\"product_id\":\"$PRODUCT_ID\",\"type\":\"delivery\",\"qty\":3,\"unit_cost\":2.5,\"note\":\"PO-4411\"}"
{
  "dry_run": true,
  "product_id": "00000000-0000-4000-8000-000000000010",
  "variant_id": null,
  "type": "delivery",
  "qty": 3,
  "unit_cost": 2.5,
  "enables_tracking": false
}

For real (we then reversed it with a correction of −3, so the merchant's stock is unchanged):

{
  "ok": true,
  "applied": 1,
  "new_cost": null
}

The ledger shows every movement, including the ones velik.ai wrote for sales:

curl -sS -H "Authorization: Bearer $VELIK_API_KEY" "$BASE/api/v1/inventory/movements?product_id=$PRODUCT_ID&limit=3"
{
  "data": [
    {
      "id": "00000000-0000-4000-8000-000000000011",
      "product_id": "00000000-0000-4000-8000-000000000010",
      "variant_id": null,
      "order_id": null,
      "type": "correction",
      "qty": 47,
      "note": "probe low_stock (връщане)",
      "created_at": "2026-09-05T15:09:39.738393+00:00"
    },
    {
      "id": "00000000-0000-4000-8000-000000000012",
      "product_id": "00000000-0000-4000-8000-000000000010",
      "variant_id": null,
      "order_id": null,
      "type": "correction",
      "qty": -47,
      "note": "probe low_stock (временно)",
      "created_at": "2026-09-05T15:09:37.380231+00:00"
    },
    {
      "id": "00000000-0000-4000-8000-000000000013",
      "product_id": "00000000-0000-4000-8000-000000000010",
      "variant_id": null,
      "order_id": null,
      "type": "correction",
      "qty": -3,
      "note": "Инвентаризация (probe, връщане)",
      "created_at": "2026-09-05T14:34:37.42505+00:00"
    }
  ],
  "has_more": true,
  "next_cursor": "eyJjIjoiMjAyNi0wOS0wNVQxNDozNDozNy40MjUwNSswMDowMCIsImkiOiIwNzBkODM2YS1hODRlLTRiMTMtOWRhNS0xNzg5MDZhNDUyYmUifQ"
}

Subscribe to inventory.low_stock to be told (once a day, per product) when a tracked product falls to its threshold.

Step 6 — the catalog

You will want SKUs, weights and dimensions for packing:

curl -sS -H "Authorization: Bearer $VELIK_API_KEY" "$BASE/api/v1/products/$PRODUCT_ID"
{
  "data": {
    "id": "00000000-0000-4000-8000-000000000010",
    "name": "Електрическа четка за зъби",
    "marketing_name": null,
    "sku": null,
    "price": 69,
    "original_price": null,
    "active": true,
    "delivery_type": "physical",
    "offer_type": "bundles",
    "description": "✓ Премахва до 10 пъти повече плака в сравнение с ръчна четка\n✓ Избелва зъбите видимо още след първата седмица на употреба\n✓ Защитава венците с интелигентен сензор за натиск\n✓ 4 режима на почистване за персонализирана грижа\n✓ Издръжлива батерия – до 4 седмици на едно зареждане",
    "images": [
      "https://hfnlrnkqudnwppvdluvy.supabase.co/storage/v1/object/public/page-images/generated/1782681654586-ypu2ppw3wc.jpeg",
      "https://hfnlrnkqudnwppvdluvy.supabase.co/storage/v1/object/public/page-images/generated/1782681689061-38s58mjnmwm.jpeg",
      "https://hfnlrnkqudnwppvdluvy.supabase.co/storage/v1/object/public/page-images/generated/1782681794489-30mmnr31kb3.jpeg"
    ],
    "image_url": "https://hfnlrnkqudnwppvdluvy.supabase.co/storage/v1/object/public/page-images/generated/1782681654586-ypu2ppw3wc.jpeg",
    "currency": "EUR",
    "stock_quantity": 50,
    "block_on_zero_stock": false,
    "low_stock_threshold": null,
    "weight_g": null,
    "dim_cm": null,
    "shipping_short_name": null,
    "shipping_category": null,
    "catalog_hidden": false,
    "language": "bg",
    "variants": [],
    "created_at": "2026-06-28T21:19:29.41932+00:00",
    "updated_at": "2026-09-05T15:15:22.873699+00:00"
  }
}

Things that will bite you if you skip them

  • Ids are UUIDs; #16 is number, not id. A non-UUID id is 400, a UUID from another merchant is 404 (never 403).
  • :write does not include :read. The Fulfilment preset has both; a custom key may not.
  • 300 requests/minute per key. Poll with since, or better, use webhooks.
  • Webhooks are at-least-once. The same event id can arrive twice — store processed ids.
  • Free plan → 403 plan_required. The merchant needs Pro or above.
  • Use X-Velik-Dry-Run: 1 on every write while you develop — it validates against the merchant's real data and writes nothing.

Ready-made code: examples/curl.sh, examples/node.mjs, examples/python.py.

Webhooks

velik.ai POSTs signed JSON to your HTTPS endpoint when something happens in the merchant's account. At-least-once delivery with retries; deduplicate by the event id. Manage subscriptions in the admin (Settings → 🔌 Integrations → Webhooks), through /api/v1/webhooks* (webhooks:read / webhooks:write) or through the MCP tools.

1. Events

eventfires whendata
order.createda customer completes checkout on any of the merchant's pages{ order }
order.updatedorder fields other than the status change — tracking number/url, courier, notes, address (admin or API){ order, changed: [columns], source }
order.status_changedthe order status changes — by the merchant, an API key, the courier status mirror, a payment or an automation{ order, previous_status, status, source }
order.paidthe order is paid (card via Stripe, or marked paid by the merchant){ order, previous_payment_status, source }
shipment.createda waybill is created through velik.ai (admin, API or bulk){ shipment, order_id, source }
shipment.status_changedthe courier reports a new normalized status for a waybill (polled every 15 min for Speedy/Econt; pushed by BoxNow){ shipment, previous_status, status, event: { at, code, label } | null, source: "courier" }
shipment.cancelleda waybill is cancelled{ shipment, order_id, courier_cancelled, courier_confirmed, source }
inventory.low_stockthe daily check finds a tracked product at or below its threshold (once per product per day){ product: { id, name, sku }, stock_quantity, threshold }
testyou press Test / call POST /api/v1/webhooks/{id}/test — not subscribable, always delivered to that one subscription{ message, webhook_id }

order is the same public Order view as GET /api/v1/orders/{id}; shipment is the Shipment view. source ∈ merchant · api · courier · payment · automation.

Which events a fulfilment centre needs

  • order.created — new work.
  • order.status_changed — processing/shipped from the merchant, and delivered / returned / cancelled with source: "courier" when the courier reports the outcome (velik.ai mirrors courier statuses onto the order and restocks on cancelled/returned).
  • shipment.status_changed — the fine-grained courier trail, if you ship through the merchant's couriers.
  • inventory.low_stock — replenishment.

2. The envelope

Every delivery is one event:

{
  "id": "00000000-0000-4000-8000-000000000102",
  "data": {
    "order": {
      "id": "00000000-0000-4000-8000-000000000103",
      "notes": null,
      "total": 29,
      "number": 16,
      "status": "new",
      "courier": null,
      "page_id": "00000000-0000-4000-8000-000000000104",
      "customer": {
        "vat": null,
        "name": "Тест V. C. V.",
        "email": "bo…@convertbuilder.com",
        "phone": "+359 8•• ••• •12"
      },
      "quantity": 1,
      "shipment": null,
      "shipping": {
        "zip": "1000",
        "city": "София",
        "method": null,
        "address": "ул. Те ••• (masked)",
        "courier_slug": "econt",
        "courier_office_id": null,
        "courier_delivery_mode": "home"
      },
      "created_at": "2026-09-04T14:10:54.525378+00:00",
      "line_items": [
        {
          "qty": 1,
          "sku": null,
          "kind": "main",
          "name": "Хранителна добавка за сън",
          "product_id": "00000000-0000-4000-8000-000000000105",
          "variant_id": null,
          "total_cents": 2900,
          "delivery_type": "physical",
          "unit_price_cents": 2900
        }
      ],
      "unit_price": 29,
      "updated_at": "2026-09-05T13:46:29.166283+00:00",
      "admin_notes": null,
      "coupon_code": null,
      "funnel_step": "original",
      "product_sku": null,
      "refunded_at": null,
      "return_note": null,
      "order_number": 1546,
      "product_name": "Хранителна добавка за сън",
      "tracking_url": null,
      "delivery_price": null,
      "invoice_number": null,
      "payment_method": "cod",
      "payment_status": "pending",
      "discount_amount": null,
      "parent_order_id": null,
      "tracking_number": null,
      "refund_amount_cents": null
    },
    "source": "api",
    "status": "new",
    "previous_status": "shipped"
  },
  "event": "order.status_changed",
  "account_id": "00000000-0000-4000-8000-000000000106",
  "created_at": "2026-09-05T13:46:29.189Z",
  "api_version": "2026-09-01"
}
fieldmeaning
idevent id (UUID). The idempotency key — the same event may be delivered more than once; process each id once.
eventthe event name
created_atwhen the event happened (UTC ISO 8601)
account_idthe merchant account (user id) — useful when one endpoint serves several merchants
api_version2026-09-01 — the payload version (date-based; changes only additively)
dataevent-specific, see §1

3. Headers and signature

{
  "user-agent": "velik.ai-webhooks/1",
  "content-type": "application/json",
  "x-velik-event": "order.status_changed",
  "x-velik-delivery": "00000000-0000-4000-8000-000000000101",
  "x-velik-event-id": "00000000-0000-4000-8000-000000000102",
  "x-velik-signature": "t=1788615989,v1=<hex hmac-sha256>",
  "x-velik-timestamp": "1788615989"
}
headervalue
Content-Typeapplication/json
User-Agentvelik.ai-webhooks/1
X-Velik-Eventthe event name
X-Velik-Event-Idthe envelope id
X-Velik-Deliverythe delivery attempt id (different on every retry; shown in the deliveries list)
X-Velik-TimestampUnix seconds when this attempt was signed
X-Velik-Signaturet=<timestamp>,v1=<hex HMAC-SHA256(secret, "<timestamp>.<raw body>")>

The secret is per subscription, 64 hex characters, returned once — in the response of POST /api/v1/webhooks (or the modal in the admin). Later reads show only secret_prefix.

Verify

  1. Read the raw request body (bytes, before any JSON parsing or re-serialization).
  2. Parse t and v1 from X-Velik-Signature.
  3. Compute HMAC-SHA256(secret, t + "." + rawBody) and compare with v1 in constant time.
  4. Reject if |now − t| > 300 s (replay protection). Retries are re-signed with a fresh t, so a legitimate late retry still verifies.
  5. Only then parse the JSON.

Reference implementations (each one accepted the real signatures above and rejected a tampered body):

// Node.js (excerpt)
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
const ok =
  v1.length === expected.length && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))
# Python (excerpt)
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
ok = hmac.compare_digest(v1, expected)
// PHP (excerpt)
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
$ok = hash_equals($expected, $v1);

4. Responding, retries, dead letters

  • Respond 2xx within 10 seconds. Do the work asynchronously if it takes longer. Anything else — non-2xx, timeout, connection error — counts as a failed attempt.

  • Backoff after a failure (6 attempts in total, ≈ 15 hours):

    attemptdelay before it
    1immediately
    260 s
    3300 s (5 min)
    41800 s (30 min)
    57200 s (2 h)
    643200 s (12 h)
  • The first attempt is made immediately, in the same request that produced the event. If that process dies before the attempt, the once-a-minute worker picks the delivery up after a 60-second grace period (so an in-flight first attempt is never duplicated by the worker).

  • After the 6th failure the delivery becomes dead and the merchant gets one email (per subscription, until a delivery succeeds again). Later events keep being attempted — a dead delivery does not pause the subscription.

  • 410 Gone from your endpoint means unsubscribe me: the delivery is marked dead and the subscription is set active: false without an email.

  • Order of delivery is not guaranteed (retries reorder). Use created_at in the envelope, and the updated_at inside the resource, to reason about recency.

  • Response bodies are stored truncated to 1000 characters and visible to the merchant in the deliveries list — return something useful on errors.

A failed attempt as the merchant/API sees it (this endpoint answered 405), and a manual retry:

{
  "data": {
    "id": "00000000-0000-4000-8000-000000000045",
    "webhook_id": "00000000-0000-4000-8000-000000000046",
    "event_id": "00000000-0000-4000-8000-000000000040",
    "event": "order.status_changed",
    "status": "pending",
    "attempt": 1,
    "next_attempt_at": "2026-09-05T16:07:39+00:00",
    "last_status_code": 405,
    "last_error": null,
    "delivered_at": null,
    "created_at": "2026-09-05T16:06:32.387958+00:00"
  },
  "ok": false,
  "status": 405
}

5. Idempotency

The same id can arrive twice (we retry until we see a 2xx, and a 2xx can be lost on the way back). Keep a table of processed event ids (or use id as a unique key on the row you write) and answer 2xx immediately for a duplicate. X-Velik-Delivery differs per attempt; X-Velik-Event-Id / id does not.

6. Managing subscriptions

actionRESTMCP
list (+ available events)GET /api/v1/webhookslist_webhooks
create — secret returned oncePOST /api/v1/webhooks { url, events[], description? }create_webhook
updatePATCH /api/v1/webhooks/{id} { url?, events?, description?, active? }update_webhook
delete (deliveries go with it)DELETE /api/v1/webhooks/{id}delete_webhook
send a test event, wait for the first attemptPOST /api/v1/webhooks/{id}/testtest_webhook
recent deliveriesGET /api/v1/webhooks/{id}/deliveries?limit=list_webhook_deliveries
retry a delivery nowPOST /api/v1/webhook-deliveries/{id}/retry—

Test and retry wait for your endpoint (up to 10 s) and are limited to 30 per minute each, per account (429 rate_limited with Retry-After); they count towards the key's 300/min as well.

Limits and validation: https:// only, no credentials in the URL, and the host must be public — loopback, private (10/8, 172.16/12, 192.168/16, 100.64/10), link-local / metadata (169.254/16), localhost, *.local, *.internal and velik.ai itself are rejected with 400 validation_failed (details.reason: scheme, credentials, host_blocked, private_ip, self_target, dns_private, dns_failed). The hostname is resolved and every address is checked, both when you save the URL and before every delivery; redirects are not followed (a 3xx counts as a failed attempt). http://localhost is not accepted: velik.ai's servers can never reach your machine — use a public tunnel or the sink. At least one event, up to 20 subscriptions per account (409 conflict), unknown event → 400 validation_failed with details.allowed. active: true in a PATCH also clears the dead-letter email flag.

{
  "data": {
    "id": "00000000-0000-4000-8000-000000000035",
    "url": "https://velik.ai/api/webhooks/sink",
    "description": "Fulfilment WMS (probe)",
    "events": [
      "order.created",
      "order.updated",
      "order.status_changed",
      "shipment.created",
      "shipment.status_changed"
    ],
    "active": true,
    "secret_prefix": "whsec_efcd8722…",
    "last_triggered_at": null,
    "last_status_code": null,
    "dead_notified_at": null,
    "created_at": "2026-09-05T16:06:29.476938+00:00",
    "updated_at": "2026-09-05T16:06:29.476938+00:00"
  },
  "secret": "<64 hex characters — shown once, store it>"
}
{
  "data": {
    "id": "00000000-0000-4000-8000-000000000037",
    "webhook_id": "00000000-0000-4000-8000-000000000035",
    "event_id": "00000000-0000-4000-8000-000000000038",
    "event": "test",
    "status": "delivered",
    "attempt": 1,
    "next_attempt_at": "2026-09-05T16:06:30.849+00:00",
    "last_status_code": 200,
    "last_error": null,
    "delivered_at": "2026-09-05T16:06:30.903+00:00",
    "created_at": "2026-09-05T16:06:30.88537+00:00"
  },
  "event_id": "00000000-0000-4000-8000-000000000038"
}

7. Debugging without an endpoint: the sink

While you build, point a subscription at velik.ai's own sink and read what we would have sent:

https://velik.ai/api/webhooks/sink?id=<your webhook id>

The sink accepts deliveries only for an existing, active subscription id, stores the headers (including the signature) and the raw body, and answers 200. Create the subscription with any HTTPS URL first, then PATCH url to the sink URL with its own id. The captured deliveries are visible in the merchant's admin. It is rate limited and capped at 64 KB per delivery — a debugging aid, never a production receiver.

8. Payload reference

order.* events carry the full Order (see README §9); shipment.* carry the Shipment.

Website-cart orders (since 2026-10-05): a merchant's website can have a shopping cart, so one order can hold several products. Such an order has funnel_step: "cart" and one line_items[] entry with kind: "item" per product (plus kind: "component" lines at 0 for bonuses). product_name is then only a label ("<first product> + още N") and product_sku / quantity / unit_price describe the first line — pick, pack and invoice from line_items, never from product_name. There is no new event type.

Recorded from production:

order.updated — tracking fields set through the API (changed lists the columns):

{
  "id": "00000000-0000-4000-8000-000000000108",
  "data": {
    "order": {
      "id": "00000000-0000-4000-8000-000000000103",
      "notes": null,
      "total": 29,
      "number": 16,
      "status": "shipped",
      "courier": null,
      "page_id": "00000000-0000-4000-8000-000000000104",
      "customer": {
        "vat": null,
        "name": "Тест V. C. V.",
        "email": "bo…@convertbuilder.com",
        "phone": "+359 8•• ••• •12"
      },
      "quantity": 1,
      "shipment": null,
      "shipping": {
        "zip": "1000",
        "city": "София",
        "method": null,
        "address": "ул. Те ••• (masked)",
        "courier_slug": "econt",
        "courier_office_id": null,
        "courier_delivery_mode": "home"
      },
      "created_at": "2026-09-04T14:10:54.525378+00:00",
      "line_items": [
        {
          "qty": 1,
          "sku": null,
          "kind": "main",
          "name": "Хранителна добавка за сън",
          "product_id": "00000000-0000-4000-8000-000000000105",
          "variant_id": null,
          "total_cents": 2900,
          "delivery_type": "physical",
          "unit_price_cents": 2900
        }
      ],
      "unit_price": 29,
      "updated_at": "2026-09-05T13:46:29.097395+00:00",
      "admin_notes": null,
      "coupon_code": null,
      "funnel_step": "original",
      "product_sku": null,
      "refunded_at": null,
      "return_note": null,
      "order_number": 1546,
      "product_name": "Хранителна добавка за сън",
      "tracking_url": null,
      "delivery_price": null,
      "invoice_number": null,
      "payment_method": "cod",
      "payment_status": "pending",
      "discount_amount": null,
      "parent_order_id": null,
      "tracking_number": null,
      "refund_amount_cents": null
    },
    "source": "api",
    "changed": ["tracking_number", "tracking_url", "courier"]
  },
  "event": "order.updated",
  "account_id": "00000000-0000-4000-8000-000000000106",
  "created_at": "2026-09-05T13:46:29.116Z",
  "api_version": "2026-09-01"
}

test:

{
  "id": "00000000-0000-4000-8000-000000000110",
  "data": {
    "message": "velik.ai webhook test",
    "webhook_id": "00000000-0000-4000-8000-000000000111"
  },
  "event": "test",
  "account_id": "00000000-0000-4000-8000-000000000106",
  "created_at": "2026-09-05T13:46:20.229Z",
  "api_version": "2026-09-01"
}

order.created — a customer completed checkout (the full Order; shipment is null until a waybill exists):

{
  "id": "00000000-0000-4000-8000-000000000109",
  "data": {
    "order": {
      "id": "00000000-0000-4000-8000-000000000110",
      "notes": null,
      "total": 29,
      "number": 17,
      "status": "new",
      "courier": null,
      "page_id": "00000000-0000-4000-8000-000000000111",
      "customer": {
        "vat": null,
        "name": "Тест V. C. V.",
        "email": "bo…@convertbuilder.com",
        "phone": "+359 8•• ••• •12"
      },
      "quantity": 1,
      "shipment": null,
      "shipping": {
        "zip": "1000",
        "city": "София",
        "method": null,
        "address": "ул. Те ••• (masked)",
        "courier_slug": null,
        "courier_office_id": null,
        "courier_delivery_mode": null
      },
      "created_at": "2026-09-05T15:11:02.897407+00:00",
      "line_items": [
        {
          "qty": 1,
          "sku": null,
          "kind": "main",
          "name": "Хранителна добавка за сън",
          "product_id": "00000000-0000-4000-8000-000000000112",
          "variant_id": null,
          "total_cents": 2900,
          "delivery_type": "physical",
          "unit_price_cents": 2900
        }
      ],
      "unit_price": 29,
      "updated_at": null,
      "admin_notes": null,
      "coupon_code": null,
      "funnel_step": null,
      "product_sku": null,
      "refunded_at": null,
      "return_note": null,
      "order_number": 1562,
      "product_name": "Хранителна добавка за сън",
      "tracking_url": null,
      "delivery_price": null,
      "invoice_number": null,
      "payment_method": "cod",
      "payment_status": "pending",
      "discount_amount": null,
      "parent_order_id": null,
      "tracking_number": null,
      "refund_amount_cents": null
    }
  },
  "event": "order.created",
  "account_id": "00000000-0000-4000-8000-000000000105",
  "created_at": "2026-09-05T15:11:05.194Z",
  "api_version": "2026-09-01"
}

shipment.created — a waybill was created through the merchant's courier (here: Econt, to an office):

{
  "id": "00000000-0000-4000-8000-000000000107",
  "data": {
    "source": "merchant",
    "order_id": "00000000-0000-4000-8000-000000000103",
    "shipment": {
      "id": "00000000-0000-4000-8000-000000000104",
      "status": "pending",
      "order_id": "00000000-0000-4000-8000-000000000103",
      "office_id": "1034",
      "cod_amount": 41,
      "created_at": "2026-09-05T15:11:56.976207+00:00",
      "updated_at": "2026-09-05T15:11:56.976207+00:00",
      "courier_slug": "econt",
      "delivery_mode": "office",
      "parcel_dim_cm": null,
      "sender_origin": {
        "mode": "address"
      },
      "shipping_cost": 5.17,
      "status_events": [],
      "last_status_at": null,
      "shipment_terms": {
        "cod_type": "cash",
        "open_before": "none",
        "refusal_payer": "sender"
      },
      "parcel_weight_g": 500,
      "tracking_number": "1051604256491",
      "last_status_label": null,
      "recipient_address": null
    }
  },
  "event": "shipment.created",
  "account_id": "00000000-0000-4000-8000-000000000105",
  "created_at": "2026-09-05T15:11:57.086Z",
  "api_version": "2026-09-01"
}

shipment.cancelled — the waybill above, cancelled at the courier (courier_confirmed: true — Econt acknowledged):

{
  "id": "00000000-0000-4000-8000-000000000102",
  "data": {
    "source": "merchant",
    "order_id": "00000000-0000-4000-8000-000000000103",
    "shipment": {
      "id": "00000000-0000-4000-8000-000000000104",
      "status": "cancelled",
      "order_id": "00000000-0000-4000-8000-000000000103",
      "office_id": "1034",
      "cod_amount": 41,
      "created_at": "2026-09-05T15:11:56.976207+00:00",
      "updated_at": "2026-09-05T15:11:58.465+00:00",
      "courier_slug": "econt",
      "delivery_mode": "office",
      "parcel_dim_cm": null,
      "sender_origin": {
        "mode": "address"
      },
      "shipping_cost": 5.17,
      "status_events": [
        {
          "at": "2026-09-05T15:11:56.000Z",
          "code": "prepared",
          "label": "Очаква предаване към Еконт"
        },
        {
          "at": "2026-09-05T15:12:02.307Z",
          "code": "merchant_cancel",
          "label": "Анулирана от мърчанта (потвърдено от куриера)"
        }
      ],
      "last_status_at": "2026-09-05T15:12:02.308+00:00",
      "shipment_terms": {
        "cod_type": "cash",
        "open_before": "none",
        "refusal_payer": "sender"
      },
      "parcel_weight_g": 500,
      "tracking_number": "1051604256491",
      "last_status_label": "Анулирана",
      "recipient_address": null
    },
    "courier_cancelled": true,
    "courier_confirmed": true
  },
  "event": "shipment.cancelled",
  "account_id": "00000000-0000-4000-8000-000000000105",
  "created_at": "2026-09-05T15:12:02.402Z",
  "api_version": "2026-09-01"
}

inventory.low_stock — the daily check found a tracked product at or below its threshold:

{
  "id": "00000000-0000-4000-8000-000000000102",
  "data": {
    "product": {
      "id": "00000000-0000-4000-8000-000000000103",
      "sku": null,
      "name": "Електрическа четка за зъби"
    },
    "threshold": 5,
    "stock_quantity": 3
  },
  "event": "inventory.low_stock",
  "account_id": "00000000-0000-4000-8000-000000000104",
  "created_at": "2026-09-05T15:09:39.012Z",
  "api_version": "2026-09-01"
}

order.paid — the merchant marked the order as paid in the admin (source: merchant; card payments arrive with source: payment):

{
  "id": "00000000-0000-4000-8000-000000000202",
  "data": {
    "order": {
      "id": "00000000-0000-4000-8000-000000000203",
      "notes": null,
      "total": 29,
      "number": 16,
      "status": "new",
      "courier": null,
      "page_id": "00000000-0000-4000-8000-000000000204",
      "customer": {
        "vat": null,
        "name": "Тест V. C. V.",
        "email": "bo…@convertbuilder.com",
        "phone": "+359 8•• ••• •12"
      },
      "quantity": 1,
      "shipment": null,
      "shipping": {
        "zip": "1000",
        "city": "София",
        "method": null,
        "address": "ул. Те ••• (masked)",
        "courier_slug": "econt",
        "courier_office_id": null,
        "courier_delivery_mode": "home"
      },
      "created_at": "2026-09-04T14:10:54.525378+00:00",
      "line_items": [
        {
          "qty": 1,
          "sku": null,
          "kind": "main",
          "name": "Хранителна добавка за сън",
          "product_id": "00000000-0000-4000-8000-000000000205",
          "variant_id": null,
          "total_cents": 2900,
          "delivery_type": "physical",
          "unit_price_cents": 2900
        }
      ],
      "unit_price": 29,
      "updated_at": "2026-09-05T16:29:32.854232+00:00",
      "admin_notes": null,
      "coupon_code": null,
      "funnel_step": "original",
      "product_sku": null,
      "refunded_at": null,
      "return_note": null,
      "order_number": 1546,
      "product_name": "Хранителна добавка за сън",
      "tracking_url": null,
      "delivery_price": null,
      "invoice_number": null,
      "payment_method": "cod",
      "payment_status": "paid",
      "discount_amount": null,
      "parent_order_id": null,
      "tracking_number": null,
      "refund_amount_cents": null
    },
    "source": "merchant",
    "previous_payment_status": "pending"
  },
  "event": "order.paid",
  "account_id": "00000000-0000-4000-8000-000000000206",
  "created_at": "2026-09-05T16:29:33.015Z",
  "api_version": "2026-09-01"
}

shipment.status_changed follows the data shape in §1; all JSON schemas are in openapi.v1.yaml under the top-level webhooks key (rendered at velik.ai/developers/api).

MCP server

POST https://velik.ai/api/mcp — a Model Context Protocol server over Streamable HTTP without sessions (JSON-RPC 2.0 in, JSON out). The same vk_ API key, the same scopes, the same operations and views as REST /api/v1 — for AI agents instead of code. 120 JSON-RPC messages / minute per key (a batch of N counts as N; a batch may hold at most 20 messages).

1. Connecting

URLhttps://velik.ai/api/mcp
TransportStreamable HTTP, POST only. GET (SSE stream) and DELETE (sessions) answer 405 — there are no sessions; every POST is self-contained. Batches (JSON arrays) are accepted.
AuthAuthorization: Bearer vk_… — 401 otherwise
Protocol versions2025-06-18, 2025-03-26, 2024-11-05
Methodsinitialize, notifications/initialized, ping, tools/list, tools/call
Rate limit120 messages/min per key — a batch of N costs N; X-RateLimit-Limit / X-RateLimit-Remaining on every response, Retry-After on 429
Batchup to 20 messages per POST; more → 400 JSON-RPC -32600 Batch too large (max 20)

Claude Desktop (via mcp-remote)

claude_desktop_config.json:

{
  "mcpServers": {
    "velik": {
      "command": "npx",
      "args": [
        "-y",
        "mcp-remote",
        "https://velik.ai/api/mcp",
        "--header",
        "Authorization: Bearer ${VELIK_API_KEY}"
      ],
      "env": { "VELIK_API_KEY": "vk_…" }
    }
  }
}

(On Windows, mcp-remote splits arguments on spaces — use "--header", "Authorization:${AUTH}" with "AUTH": "Bearer vk_…" in env.)

Claude Code

claude mcp add --transport http velik https://velik.ai/api/mcp --header "Authorization: Bearer vk_…"

Any client / curl

curl -sS -X POST https://velik.ai/api/mcp -H "Authorization: Bearer $VELIK_API_KEY" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"my-agent","version":"1.0"}}}'
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2025-06-18",
    "capabilities": {
      "tools": {
        "listChanged": false
      }
    },
    "serverInfo": {
      "name": "velik.ai",
      "version": "1.0.0"
    },
    "instructions": "velik.ai MCP. Инструментите се показват според обхвата на API ключа (Настройки → API ключове). Мостът към МАШИНАТА: submit_brief (брифът натам), get_status и get_facts (статусът и фактите насам) — одобрението (публикуване / включване / изпращане) е в админа на velik.ai. Платформата (orders/shipments/inventory/products/customers): същите данни и изгледи като REST /api/v1, от план Pro нагоре."
  }
}

2. tools/list — what a key sees

The list is filtered by the key's scopes: a tool is visible only when the key holds its scope. So the same server shows a different toolbox to a Fulfilment key, a Read-only key and a МАШИНАТА key.

curl -sS -X POST https://velik.ai/api/mcp -H "Authorization: Bearer $VELIK_API_KEY" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

Fulfilment preset (17 tools; descriptions truncated here — the live ones are longer):

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "tools": [
      {
        "name": "list_orders",
        "description": "Orders of this account (newest first, keyset pagination). Filters: status (new, confirmed, processing, shipped, delivere…"
      },
      {
        "name": "get_order",
        "description": "One order by id (UUID) with its shipment summary. Not found in this account → error NOT_FOUND."
      },
      {
        "name": "get_shipment",
        "description": "Shipment (waybill) by shipment id or by order_id: courier, tracking number, normalized status, status events, COD, parce…"
      },
      {
        "name": "get_inventory_stock",
        "description": "Stock levels of active physical products (tracked quantity, variants, low-stock thresholds and flags). Pass product_id f…"
      },
      {
        "name": "list_stock_movements",
        "description": "Stock ledger (sale, release, return, delivery, correction), newest first. Filters: product_id, order_id, type, from/to."
      },
      {
        "name": "list_products",
        "description": "Catalog: products with variants, prices, SKU, stock, shipping dimensions. Active only unless status=\"all\". Filters: deli…"
      },
      {
        "name": "get_product",
        "description": "One product by id with its variants (also inactive ones — they still appear in orders)."
      },
      {
        "name": "update_order",
        "description": "Update an order: status (through the same path as the admin — \"shipped\" emails/SMS the customer per merchant settings, \"…"
      },
      {
        "name": "record_stock_movement",
        "description": "Record a manual stock movement: delivery (qty > 0, optional unit_cost updates the weighted average cost) or correction (…"
      },
      {
        "name": "create_shipment",
        "description": "Create a waybill for an order through the merchant's own courier integration (Econt, Speedy, BoxNow). Uses the courier/m…"
      },
      {
        "name": "cancel_shipment",
        "description": "Cancel a waybill at the courier (Speedy/Econt confirm; BoxNow has no API — marked locally). Frees the order for a new wa…"
      },
      {
        "name": "list_webhooks",
        "description": "Webhook subscriptions of the account (url, events, active, last delivery status) and the list of available events."
      },
      {
        "name": "create_webhook",
        "description": "Create a webhook subscription. Returns the signing secret ONCE — store it. Events: order.created, order.updated, order.s…"
      },
      {
        "name": "update_webhook",
        "description": "Update a webhook: url, events, description, active."
      },
      {
        "name": "delete_webhook",
        "description": "Delete a webhook subscription (its deliveries go with it)."
      },
      {
        "name": "test_webhook",
        "description": "Send a `test` event to one webhook and return the first delivery attempt (status code)."
      },
      {
        "name": "list_webhook_deliveries",
        "description": "Recent deliveries of one webhook (event, status, attempt, last status code)."
      }
    ]
  }
}

Read-only preset (11 tools):

{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "tools": [
      "list_orders",
      "get_order",
      "get_shipment",
      "get_inventory_stock",
      "list_stock_movements",
      "list_products",
      "get_product",
      "list_customers",
      "list_webhooks",
      "list_webhook_deliveries",
      "list_segments"
    ]
  }
}

МАШИНАТА preset sees submit_brief, get_status, get_facts, get_monthly_facts (§5) plus the read-only catalog — list_products and get_product (products:read, since 2026-09-16) — and none of the other platform tools. Keys issued before that date with the three bridge scopes keep seeing exactly the three — plus get_monthly_facts, which carries the existing get_facts scope (same permission, no reissue needed; since 2026-09-18).

The platform tools

toolscopeREST equivalent
list_ordersorders:readGET /api/v1/orders — same filters (status, payment, from, to, since, page_id, product_id, courier, shipment_status, q, limit, cursor)
get_orderorders:readGET /api/v1/orders/{id}
get_shipmentshipments:readGET /api/v1/shipments/{id} (by id) or GET /api/v1/orders/{id}/shipment (by order_id)
get_inventory_stockinventory:readGET /api/v1/inventory/stock
list_stock_movementsinventory:readGET /api/v1/inventory/movements
list_productsproducts:readGET /api/v1/products
get_productproducts:readGET /api/v1/products/{id}
list_customerscustomers:readGET /api/v1/customers
list_segmentscustomers:readGET /api/v1/segments
update_orderorders:writePATCH /api/v1/orders/{id} (+ dry_run: true)
record_stock_movementinventory:writePOST /api/v1/inventory/movements (+ dry_run)
create_shipmentshipments:writePOST /api/v1/shipments (+ dry_run)
cancel_shipmentshipments:writePOST /api/v1/shipments/{id}/cancel (+ dry_run)
list_webhookswebhooks:readGET /api/v1/webhooks
create_webhookwebhooks:writePOST /api/v1/webhooks (secret returned once)
update_webhookwebhooks:writePATCH /api/v1/webhooks/{id}
delete_webhookwebhooks:writeDELETE /api/v1/webhooks/{id}
test_webhookwebhooks:writePOST /api/v1/webhooks/{id}/test
list_webhook_deliverieswebhooks:readGET /api/v1/webhooks/{id}/deliveries
create_productproducts:writePOST /api/v1/products (+ dry_run)
update_productproducts:writePATCH /api/v1/products/{id} (+ dry_run)
create_segmentcustomers:writePOST /api/v1/segments (+ dry_run)
update_segmentcustomers:writePATCH /api/v1/segments/{id} (+ dry_run)
delete_segmentcustomers:writeDELETE /api/v1/segments/{id} (+ dry_run)
add_segment_memberscustomers:writePOST /api/v1/segments/{id}/members (+ dry_run)
remove_segment_memberscustomers:writeDELETE /api/v1/segments/{id}/members (+ dry_run)
add_customer_notecustomers:writePOST /api/v1/customers/{key}/notes (+ dry_run)

products:write / customers:write tools appear only for keys that carry those scopes (no preset does — the merchant grants them under Custom). Write tools take dry_run: true as an argument instead of the X-Velik-Dry-Run header. There is no MCP tool for the PDF label (binary) or for retrying a delivery — use REST.

3. tools/call

Arguments mirror the REST parameters; the result carries the same JSON as REST in structuredContent (and stringified in content[0].text).

curl -sS -X POST https://velik.ai/api/mcp -H "Authorization: Bearer $VELIK_API_KEY" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"list_orders","arguments":{"status":"new","limit":1}}}'
{
  "jsonrpc": "2.0",
  "id": 4,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "{\"data\":[{\"id\":\"00000000-0000-4000-8000-000000000001\",\"number\":16,\"order_number\":1546,\"status\":\"new\",\"payment_status\":\"pending\",\"payment_method\":\"cod\",\"funnel_step\":\"original\",\"parent_order_id\":null,\"total\":29,\"delivery_price\":null,\"discount_amount\":null,\"coupon_code\":null,\"refund_amount_cents\":null,\"refunded_at\":null,\"customer\":{\"name\":\"Тест V. C. V.\",\"email\":\"bo…@convertbuilder.com\",\"phone\":\"+359 8•• ••• •12\",\"vat\":null},\"shipping\":{\"address\":\"ул. Те ••• (masked)\",\"city\":\"София\",\"zip\":\"1000\",\"method\":null,\"courier_slug\":\"econt\",\"courier_delivery_mode\":\"home\",\"courier_office_id\":null},\"courier\":null,\"tracking_number\":null,\"tracking_url\":null,\"line_items\":[{\"qty\":1,\"sku\":null,\"kind\":\"main\",\"name\":\"Хранителна добавка за сън\",\"product_id\":\"00000000-0000-4000-8000-000000000002\",\"variant_id\":null,\"total_cents\":2900,\"delivery_type\":\"physical\",\"unit_price_cents\":2900}],\"product_name\":\"Хранителна добавка за сън\",\"product_sku\":null,\"quantity\":1,\"unit_price\":29,\"notes\":null,\"admin_notes\":null,\"return_note\":null,\"page_id\":\"00000000-0000-4000-8000-000000000003\",\"invoice_number\":null,\"shipment\":null,\"created_at\":\"2026-09-04T14:10:54.525378+00:00\",\"updated_at\":\"2026-09-05T14:34:46.691317+00:00\"}],\"has_more\":true,\"next_cursor\":\"eyJjIjoiMjAyNi0wOS0wNFQxNDoxMDo1NC41MjUzNzgrMDA6MDAiLCJpIjoiM2FlYzgyNDYtYzliNy00YzY3LTk5NTQtZTgyMmM4MWIwNjU4In0\"}"
      }
    ],
    "structuredContent": {
      "data": [
        {
          "id": "00000000-0000-4000-8000-000000000001",
          "number": 16,
          "order_number": 1546,
          "status": "new",
          "payment_status": "pending",
          "payment_method": "cod",
          "funnel_step": "original",
          "parent_order_id": null,
          "total": 29,
          "delivery_price": null,
          "discount_amount": null,
          "coupon_code": null,
          "refund_amount_cents": null,
          "refunded_at": null,
          "customer": {
            "name": "Тест V. C. V.",
            "email": "bo…@convertbuilder.com",
            "phone": "+359 8•• ••• •12",
            "vat": null
          },
          "shipping": {
            "address": "ул. Те ••• (masked)",
            "city": "София",
            "zip": "1000",
            "method": null,
            "courier_slug": "econt",
            "courier_delivery_mode": "home",
            "courier_office_id": null
          },
          "courier": null,
          "tracking_number": null,
          "tracking_url": null,
          "line_items": [
            {
              "qty": 1,
              "sku": null,
              "kind": "main",
              "name": "Хранителна добавка за сън",
              "product_id": "00000000-0000-4000-8000-000000000002",
              "variant_id": null,
              "total_cents": 2900,
              "delivery_type": "physical",
              "unit_price_cents": 2900
            }
          ],
          "product_name": "Хранителна добавка за сън",
          "product_sku": null,
          "quantity": 1,
          "unit_price": 29,
          "notes": null,
          "admin_notes": null,
          "return_note": null,
          "page_id": "00000000-0000-4000-8000-000000000003",
          "invoice_number": null,
          "shipment": null,
          "created_at": "2026-09-04T14:10:54.525378+00:00",
          "updated_at": "2026-09-05T14:34:46.691317+00:00"
        }
      ],
      "has_more": true,
      "next_cursor": "eyJjIjoiMjAyNi0wOS0wNFQxNDoxMDo1NC41MjUzNzgrMDA6MDAiLCJpIjoiM2FlYzgyNDYtYzliNy00YzY3LTk5NTQtZTgyMmM4MWIwNjU4In0"
    },
    "isError": false
  }
}

A dry-run write:

{
  "jsonrpc": "2.0",
  "id": 7,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "{\"dry_run\":true,\"data\":{\"id\":\"00000000-0000-4000-8000-000000000001\",\"number\":16,\"order_number\":1546,\"status\":\"new\",\"payment_status\":\"pending\",\"payment_method\":\"cod\",\"funnel_step\":\"original\",\"parent_order_id\":null,\"total\":29,\"delivery_price\":null,\"discount_amount\":null,\"coupon_code\":null,\"refund_amount_cents\":null,\"refunded_at\":null,\"customer\":{\"name\":\"Тест V. C. V.\",\"email\":\"bo…@convertbuilder.com\",\"phone\":\"+359 8•• ••• •12\",\"vat\":null},\"shipping\":{\"address\":\"ул. Те ••• (masked)\",\"city\":\"София\",\"zip\":\"1000\",\"method\":null,\"courier_slug\":\"econt\",\"courier_delivery_mode\":\"home\",\"courier_office_id\":null},\"courier\":null,\"tracking_number\":null,\"tracking_url\":null,\"line_items\":[{\"qty\":1,\"sku\":null,\"kind\":\"main\",\"name\":\"Хранителна добавка за сън\",\"product_id\":\"00000000-0000-4000-8000-000000000002\",\"variant_id\":null,\"total_cents\":2900,\"delivery_type\":\"physical\",\"unit_price_cents\":2900}],\"product_name\":\"Хранителна добавка за сън\",\"product_sku\":null,\"quantity\":1,\"unit_price\":29,\"notes\":null,\"admin_notes\":null,\"return_note\":null,\"page_id\":\"00000000-0000-4000-8000-000000000003\",\"invoice_number\":null,\"shipment\":null,\"created_at\":\"2026-09-04T14:10:54.525378+00:00\",\"updated_at\":\"2026-09-05T14:34:46.691317+00:00\"},\"changes\":{\"status\":\"processing\"},\"status_would_change\":true,\"would_notify_customer\":false}"
      }
    ],
    "structuredContent": {
      "dry_run": true,
      "data": {
        "id": "00000000-0000-4000-8000-000000000001",
        "number": 16,
        "order_number": 1546,
        "status": "new",
        "payment_status": "pending",
        "payment_method": "cod",
        "funnel_step": "original",
        "parent_order_id": null,
        "total": 29,
        "delivery_price": null,
        "discount_amount": null,
        "coupon_code": null,
        "refund_amount_cents": null,
        "refunded_at": null,
        "customer": {
          "name": "Тест V. C. V.",
          "email": "bo…@convertbuilder.com",
          "phone": "+359 8•• ••• •12",
          "vat": null
        },
        "shipping": {
          "address": "ул. Те ••• (masked)",
          "city": "София",
          "zip": "1000",
          "method": null,
          "courier_slug": "econt",
          "courier_delivery_mode": "home",
          "courier_office_id": null
        },
        "courier": null,
        "tracking_number": null,
        "tracking_url": null,
        "line_items": [
          {
            "qty": 1,
            "sku": null,
            "kind": "main",
            "name": "Хранителна добавка за сън",
            "product_id": "00000000-0000-4000-8000-000000000002",
            "variant_id": null,
            "total_cents": 2900,
            "delivery_type": "physical",
            "unit_price_cents": 2900
          }
        ],
        "product_name": "Хранителна добавка за сън",
        "product_sku": null,
        "quantity": 1,
        "unit_price": 29,
        "notes": null,
        "admin_notes": null,
        "return_note": null,
        "page_id": "00000000-0000-4000-8000-000000000003",
        "invoice_number": null,
        "shipment": null,
        "created_at": "2026-09-04T14:10:54.525378+00:00",
        "updated_at": "2026-09-05T14:34:46.691317+00:00"
      },
      "changes": {
        "status": "processing"
      },
      "status_would_change": true,
      "would_notify_customer": false
    },
    "isError": false
  }
}

4. Errors

Protocol errors are JSON-RPC error objects (-32700 parse, -32600 invalid request, -32601 unknown method, -32602 unknown tool / bad params, -32603 internal). Domain refusals are tool results with isError: true and structuredContent: { error: <CODE>, reason }:

errormeaning
SCOPEthe key lacks the tool's scope (the tool would not be in tools/list either)
PLANFree plan / plan could not be verified
VALIDATION_FAILED, NOT_FOUND, CONFLICT, COURIER_ERROR, …the REST error.code, upper-cased; reason is the message

A Fulfilment key calling a bridge tool, and a lookup of an id that is not in the account:

{
  "jsonrpc": "2.0",
  "id": 5,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "Ключът няма обхват submit_brief"
      }
    ],
    "structuredContent": {
      "error": "SCOPE",
      "reason": "Ключът няма обхват submit_brief"
    },
    "isError": true
  }
}
{
  "jsonrpc": "2.0",
  "id": 6,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "Order not found."
      }
    ],
    "structuredContent": {
      "error": "NOT_FOUND",
      "reason": "Order not found."
    },
    "isError": true
  }
}

5. The bridge to МАШИНАТА

The same server also hosts the bridge — three tools with their own contract, used by МАШИНАТА to hand marketing briefs to velik.ai's AI and read back status and facts:

toolscopewhat it does
submit_briefsubmit_briefsubmits a brief (kind: PAGE, AUTOMATION, CAMPAIGN, SEGMENT); returns brief_id + status; idempotent per (business_id, operation, step, run). business_id is optional — it defaults to the key's account. The bridge stops at a draft: publishing / enabling / sending happens in the velik.ai admin.
get_statusget_statusRECEIVED → IN_WORK → AWAITING_APPROVAL → APPROVED → LIVE, or REJECTED / FAILED (with reason, fail_code); admin_url, and on LIVE the asset_id / public_url.
get_factsget_factscounts with provenance for an asset in a from/to window: page_visits, purchases, registrations, attendees, segment_size. purchases = paid orders on the page (payment_status paid / partially_refunded, or cash-on-delivery with status delivered); lead-form rows, free products, upsell child orders, subscription renewals and imported contacts are not purchases (since 2026-09-17).
get_monthly_factsget_factsingredients for the whole business, by month (since 2026-09-18). Input: month (YYYY-MM) and optional names; the business is the key's account. Returns counts and money for orders_paid, customers_new, revenue_cents, returns_count, customers_active_12m, visits_site, sessions_site, checkouts_started, cohort_size, cohort_returned_90d, orders_by_source, customers_new_by_source_3m, cogs_cents, shipping_cents. Ingredients only — CAC, AOV and conversion rates are МАШИНАТА's to compute. Every money value is in euro cents and says so in its name (_cents); currency is stated explicitly as EUR. Each fact carries basis: money is counted by paid_at (an order placed 29 Aug and delivered 3 Sep is September revenue), traffic by created_at. The response also carries paid_at_sources (where the date came from — created_at there means no payment date exists), unavailable (a stated reason instead of a misleading zero) and unattributed (rows with no owner, excluded from every number above). customers_new_by_source_3m (since 2026-09-19) is the only fact with a window of its own — three calendar months — and carries it in its own window; it attributes per customer (the channel of their first paid order), unlike orders_by_source which attributes per order, and it returns both denominators (value = known channel, customers_new_total = all new). visits_site is unique visitors and does not sum across months; sessions_site (since 2026-09-19) is sessions, derived from a 30-minute inactivity gap, and does sum — sessions run systematically ~11 % above unique visitors, so a threshold built on one and applied to the other is wrong by construction. The response also carries definitions — for each requested name: counts (what it counts), includes, excludes, caveats (since 2026-09-19). The definition is part of the contract, not prose: a consumer should only import an ingredient whose definition matches its own. Definition texts are generated from the code constants they describe, so they cannot drift from behaviour. definitions_hash is a SHA-256 fingerprint of the whole dictionary (not of the requested names) — if it changes, a definition changed and the mapping must be re-checked. get_facts is unchanged — it stays per-asset.

These tools are visible only to keys issued with the МАШИНАТА preset (or a custom key holding those scopes); Fulfilment and Read-only keys neither see nor can call them (SCOPE). The bridge's request/response schemas are a separate, frozen contract shared with МАШИНАТА partners — the tool inputSchemas returned by tools/list are authoritative.

6. Notes for agent builders

  • Read tools are safe to call freely (within 120 messages/min; a batch of N costs N, max 20 per POST). Write tools change real orders and stock and can email/SMS the merchant's customers (update_order with status: "shipped") — prefer dry_run: true first and confirm with the user.
  • Ids are UUIDs; order numbers are not ids. NOT_FOUND also means "not in this account".
  • list_orders / list_stock_movements / list_products return next_cursor — pass it back as cursor.
  • Amounts: total etc. are numbers in the order currency (ISO 4217, since 2026-10-16); line_items[].unit_price_cents are hundredths of the same currency (×100 for every currency, HUF and CZK included). A product carries its own currency; create_product / update_product read prices in it (HUF and CZK are whole numbers).
  • Website-cart orders (funnel_step: "cart") hold several products as line_items[] with kind: "item"; their product_name is only a label ("<first> + още N") — read the lines, never the label.
  • catalog_hidden: true on a product („only in a bundle") hides it from product pickers and from the website catalog / shopping cart (скрит и от каталога на сайта).

Changelog — velik.ai Platform API

The contract is /api/v1 + the MCP server + webhooks. Only additive changes land in v1; each entry lists them. The Contract hash is the SHA-256 of the canonical openapi.v1.yaml (node scripts/contract-hash.js); a test fails when the spec changes without a new entry here.

2026-10-16 — Currencies: currency on products and orders

  • A merchant can now sell a product in a currency other than euro. The supported codes (ISO 4217) are EUR, USD, GBP, HUF, CZK, PLN, RON, CHF. The currency belongs to the product; its pages and orders take it over.
  • Product.currency is now the product's real currency (until today it was always EUR). price, original_price and variants[].price_delta are in that currency, in major units. HUF and CZK are whole numbers (no decimals).
  • Order.currency (new field): the order's currency, taken from the page when the order was placed and never changed. Every amount of the order is in it: total, delivery_price, discount_amount and unit_price in major units; line_items[].*_cents and refund_amount_cents in hundredths (×100 for every currency, HUF and CZK included). Webhook order.* payloads carry it too. Imported history may carry another code (e.g. BGN).
  • Customer.total_spent_by_currency (new field): the customer's spend per order currency, e.g. { "EUR": 58.5, "HUF": 12990 }. total_spent is unchanged and therefore mixes currencies for a customer who bought in more than one.
  • POST /api/v1/products and the MCP create_product accept currency (default: the account currency — EUR unless the merchant chose another). price / original_price are read in that currency; a string amount is read the way a person types it ("12.990" in HUF = 12990, in EUR = 12.99). An unknown code → 400 validation_failed with details.allowed.
  • PATCH /api/v1/products/{id} and update_product accept currency while the product has no published page and no orders; after that → 409 conflict with details.reason: currency_locked. Prices in the same request are read in the new currency.
  • Additive only: no field was removed or renamed. For an account that sells only in euro nothing changes — every currency is EUR.

Contract hash: b1f450de42a18e3cb8cee59efd8d7828aa703827cba3fe7e93306b3b59279bd2

2026-10-05 — Orders from a website shopping cart (funnel_step: cart, kind: item)

  • A merchant's multi-page website can now have a shopping cart: the customer puts several catalog products into one order.
  • Such an order has funnel_step: "cart" and one line_items[] entry with kind: "item" per product (no main line). Bonuses of a product are component lines at 0 with a note.
  • The truth is in line_items. product_name is only a label ("<first product> + още N") and must not be resolved to a product; product_sku / quantity / unit_price describe the first line. Pick, pack and invoice from the lines.
  • LineItem.kind gains the value item; Order.funnel_step is now documented with its real values (original, step<N>_upsell, step<N>_downsell, cart).
  • catalog_hidden („only in a bundle") now also hides the product from the website catalog and cart — documented on Product, ProductWritable and the MCP create_product / update_product tools.
  • Webhooks: no new event type — cart orders arrive as the usual order.* events.
  • Additive only: no field was removed or changed.

Contract hash: dc458713fce1f9d1d6f416a99aa4cf69695516a2f0cff7b6784936d4f2bf9a68

2026-09-29 — Orders: merged orders (merged_into_order_id, merged_from)

  • Merchants can now merge several orders of the same customer (placed within 24 hours, cash on delivery or bank transfer, not yet shipped) into one shipment. One order is kept; the others are absorbed into it.
  • Order.merged_into_order_id — set on an absorbed order. That order is cancelled, its items now live in the kept order, and it is excluded from revenue and order counts. Do not ship it and do not count it.
  • Order.merged_from — on the kept order: the absorbed orders as they were (id, number, total, delivery_price, discount_amount, merged_at). null when nothing was merged.
  • Webhooks: the kept order emits order.updated (changed includes line_items, total, delivery_price, discount_amount, merged_from); each absorbed order emits order.status_changed → cancelled with merged_into_order_id in the order payload. There is no new event type.
  • The new total is never higher than the sum of the separate orders (one delivery; free-delivery threshold; volume price; the better of the existing discounts and an automatic code).
  • Additive only: no field was removed or changed.

Contract hash: a54bd02d3dde91a6562eae53568ccb573b866133ef876d72910c3f320ab623f9

2026-09-19 — get_monthly_facts: customers_new_by_source_3m

  • New ingredient customers_new_by_source_3m: new customers by acquisition channel over three calendar months — the requested month and the two before it.
  • It is the first fact with a window of its own. Everything else in this tool covers one month. It therefore carries its own window in the response rather than inheriting the monthly one — a number that travels with someone else's window invites comparison against the wrong thing.
  • Attribution is per customer, not per order: the channel is the utm_source of that customer's first paid order — the channel that acquired them. orders_by_source answers a different question and attributes per order. One customer with two orders from two channels counts once here and twice there.
  • Both denominators are returned. value is new customers with a known channel; customers_new_total is all new customers in the window; unknown is the difference. The share of the largest channel is the consumer's to compute — and it must say which denominator it used. Measured 2026-09-18: 47 new customers in Jul–Sep, 39 with a known channel, fb 30 / ig 9 — the largest channel is 76.9 % of known but 63.8 % of all, a 13-point spread that comes from the choice of denominator alone.
  • The same LOOKBACK_MONTHS caveat as customers_new applies and is stated in the definition.

Contract hash: b19cc70f56d00919e75f88c693f092ea9d638fb1054593a690cf83317c9b32cc

2026-09-19 — get_monthly_facts: sessions_site alongside visits_site

  • New ingredient sessions_site: sessions that started in the month, derived from a 30-minute inactivity gap between visits of one visitor_id. It sits next to visits_site, it does not replace it.
  • Why it matters: visits_site counts unique visitors, which is a smaller number than sessions for the same traffic. A conversion rate computed on unique visitors and compared against a threshold built on sessions reads systematically higher with nothing in the data having changed. Measured 2026-09-18: 3 392 sessions vs 3 063 unique visitors in September — a ~11 % gap, systematic, not noise.
  • sessions_site sums across months; visits_site does not. Sessions are events, visitors are people.
  • The 30-minute window is the same one the page beacon uses to decide whether to write a row at all — measuring with a different ruler than the one the data was written with would produce a number that is nobody's.
  • Visits with no visitor_id cannot be joined into a session. They are not silently dropped: coverage: { with_visitor, of } states them. This matters historically — in July 2026, 85 % of rows had no visitor_id (vs 3.7 % in September), so July's ratio (2.32) is not comparable and must not be used as a threshold.
  • Both traffic facts come from one read of the rolled row, so their observed_at always agrees and their ratio is always internally consistent.
  • Migration 186 adds sessions and visits_without_visitor to merchant_visits_monthly and backfills the surviving 90 days.

Contract hash: b19cc70f56d00919e75f88c693f092ea9d638fb1054593a690cf83317c9b32cc

2026-09-19 — get_monthly_facts: definitions travel with the numbers

  • Every fact now ships its definition: counts, includes, excludes, caveats. A consumer that only imports ingredients whose definition matches its own previously had nothing to match against — unit and basis say in what and by which field, but not what is in and what is out.
  • Definition texts are generated from the code constants they describe (excluded order statuses, the paid-status list, the phone-digit threshold for customer identity, the look-back window). A hand-written list models the code and goes stale with it, silently; a generated one cannot.
  • definitions_hash is a SHA-256 fingerprint of the whole dictionary, stable across calls and independent of the names filter. A changed hash means a definition changed.
  • Two differences are now stated rather than left to be discovered: revenue_cents is not net of refunds (refund amounts are not recorded at all, so a partially refunded order contributes its full total), and archived is a merchant's judgment call ("hide these from my numbers"), not a technical flag — the platform has no is_test column.
  • revenue_cents excludes shipping; what the customer paid is revenue_cents + shipping_cents.
  • Additive only: no field was removed or changed.

⚠️ The REST Contract hash is unchanged: MCP tools do not live in openapi.v1.yaml. The dictionary has its own fingerprint, returned as definitions_hash — the two are different things and pin different contracts.

Contract hash: b19cc70f56d00919e75f88c693f092ea9d638fb1054593a690cf83317c9b32cc

2026-09-18 — get_monthly_facts: ingredients for the whole business, by month

Contract hash: b19cc70f56d00919e75f88c693f092ea9d638fb1054593a690cf83317c9b32cc

Additive. A new MCP tool; get_facts is unchanged and stays per-asset.

  • get_monthly_facts takes month (YYYY-MM) and optional names; the business is the key's account. It returns the ingredients for a whole business in that month — orders_paid, customers_new, revenue_cents, returns_count, customers_active_12m, visits_site, checkouts_started, cohort_size, cohort_returned_90d, orders_by_source, cogs_cents, shipping_cents. Derived figures (CAC, AOV, conversion rates) are deliberately not returned: they belong to the consumer, so there is only one place that defines them.
  • It carries the existing get_facts scope, not a new one — the same permission ("read facts for this account"). Keys already issued with the МАШИНАТА preset see the tool immediately; no reissue is needed.
  • Money is in euro cents and says so in its name (_cents), and currency is stated explicitly as EUR. Every fact also carries basis, the field it was measured by: money by paid_at, traffic by created_at. An order placed 29 August and delivered 3 September is September revenue — previously such an order was reported in August, before the merchant had the money.
  • The response distinguishes three kinds of silence instead of returning a misleading 0: unavailable lists facts that cannot be computed with the reason (fees are not recorded anywhere; the refunded amount is not recorded, though the count of returns is), paid_at_sources says where each payment date came from (created_at there means no payment date exists for those rows), and unattributed reports rows that have no owner and therefore enter no number above.
  • visits_site counts unique visitors within that month and is explicitly not summable across months; it is served from a nightly rollup, so a month older than the 90-day retention that was never rolled up is reported as unavailable, not as zero.

2026-09-17 — Confirming an order may issue a waybill automatically

Contract hash: bd3588c2d4820e1e1c11165e53d527c3203e214cbb3a15fa459acc1554d93413

Behaviour only; shapes unchanged.

  • When a merchant has enabled automatic waybills for a courier (Settings → 📦 Куриери → „⚡ Автоматична товарителница при „Потвърдена"", i.e. auto_create_on_order in the courier config, also settable via the assistant), setting an order's status to confirmed through PATCH /api/v1/orders/{id} / update_order issues the waybill with that courier and moves the order to processing, exactly as the admin button does (shipment.created + order.status_changed webhooks follow). It is skipped, with the reason written to the order's admin_notes, when the phone, address or office is missing, when another order with the same phone and total exists within 24 h, or when the courier rejects the request. Bulk status changes never issue waybills.

2026-09-17 — tracking_number on PATCH /orders/{id} registers a tracked shipment

Contract hash: bd3588c2d4820e1e1c11165e53d527c3203e214cbb3a15fa459acc1554d93413

Behaviour only; request and response shapes are unchanged.

  • Setting a new tracking_number on an order (via PATCH /api/v1/orders/{id}, the MCP update_order, or the admin) now also creates a shipment record for it (source: manual, no label in velik.ai). If the merchant has that courier connected in Settings → 📦 Куриери, the status poller follows the waybill and moves the order to shipped / delivered / returned automatically — the same path as waybills issued from velik.ai. The courier is taken from courier (Speedy/Спиди, Econt/Еконт, BOX NOW) or from the customer's checkout choice; an unrecognised courier records the number without a shipment. A shipment.created webhook fires with source: "api" / "merchant". Repeating the same number is a no-op; an order that already has an active waybill keeps it.

2026-09-17 — get_facts: purchases counts paid orders only

Contract hash: bd3588c2d4820e1e1c11165e53d527c3203e214cbb3a15fa459acc1554d93413

Bridge only (MCP get_facts); the REST spec is unchanged. A correction, not a new field.

  • purchases used to count every order row on the page outside archived / refunded / cancelled — including lead-form and webinar-registration rows (payment_method: free, pending) and unpaid cash-on-delivery orders still new. It now counts paid orders only: payment_status paid or partially_refunded, or cash-on-delivery with status delivered. Excluded: lead rows and free products (payment_method: free), upsell child orders (parent_order_id, the parent is the purchase), subscription renewals (funnel_step: subscription_recurring; the first order of a subscription counts), imported contacts, and returned orders. Expect lower numbers than before on pages with lead forms or with cash-on-delivery orders not yet delivered.

2026-09-16 — Product search, first photo and currency; МАШИНАТА preset reads the catalog

Contract hash: bd3588c2d4820e1e1c11165e53d527c3203e214cbb3a15fa459acc1554d93413

Additive.

  • GET /api/v1/products and the MCP list_products accept q — a case-insensitive substring search in name / marketing_name (≤ 100 chars). Metacharacters are escaped, not stripped.
  • Product carries two new fields: image_url (the first photo, images[0], or null) and currency (always EUR — the platform sells in euro only). GET /api/v1/products/{id}, POST/PATCH responses and get_product return them too.
  • The МАШИНАТА preset now includes products:read, so a key issued with it also sees list_products and get_product (a brief can point at a product by id). Keys issued earlier with the three bridge scopes are unchanged — scopes live on the key, not on the preset.

2026-09-05 — Webhook test/retry limit, first-attempt grace, share image (security audit)

Contract hash: fa360ce87f03bdc8b07dfebcb8f6286df4fe0c6e231be4f1b43ed04ccdded6b6

  • Webhook test and retry (POST /api/v1/webhooks/{id}/test, POST /api/v1/webhook-deliveries/{id}/retry, the admin buttons and the MCP tools) are limited to 30 per minute each, per account — 429 rate_limited with Retry-After and details.retry_after_sec. x-velik-rate-limits.webhook_test_retry_per_minute documents it.
  • A delivery's first attempt is still immediate; the once-a-minute worker now waits a 60-second grace period before taking over a delivery whose first attempt may be in flight, so the same event is not sent twice by both. Deduplicate by id regardless (at-least-once stays the contract).
  • /developers pages carry an og:image (1200×630) for link previews.

2026-09-05 — Webhook URL policy and MCP batch limits (security audit)

Contract hash: f19bd466eb731ab19f8ff1d225f72b95f103290e3abd2c9434a088b18760a93b

Two tightenings that a correctly built integration never notices:

  • Webhook URLs (POST/PATCH /api/v1/webhooks, create_webhook / update_webhook): https:// only, no credentials, and the host must be public — loopback, private, link-local / metadata addresses, localhost, *.local, *.internal and velik.ai itself are rejected with 400 validation_failed and details.reason (scheme, credentials, host_blocked, private_ip, self_target, dns_private, dns_failed). The hostname is resolved and checked when you save it and before every delivery. Redirects are no longer followed (a 3xx is a failed attempt, last_error: redirect_not_followed). The former http://localhost exception is removed — velik.ai's servers can never reach your machine; use a public tunnel or the sink.
  • MCP rate limit is now 120 JSON-RPC messages / minute per key: a batch of N costs N, and a batch may hold at most 20 messages (more → 400 JSON-RPC -32600 Batch too large (max 20)). Every /api/mcp response carries X-RateLimit-Limit / X-RateLimit-Remaining like REST; 429 keeps its body { error, retryAfterSec } and adds Retry-After. x-velik-rate-limits.mcp_max_batch documents the cap.

2026-09-05 — Stricter input validation (security audit)

Contract hash: 6c5c40cc1243d07d491fc6042e9af57c0677b4906d2632a6045069a43eed8619

Spec: documentation-only changes (no field, path or status added or removed) — the spec now passes the Spectral oas ruleset with 0 warnings: the description properties of Product / Webhook were written as strings instead of schemas, the rate-limit headers of GET /api/v1/orders are declared per header, error.details declares items, and 13 operations gained a description. New: Postman collection generated from the spec; the Redoc reference is loaded with Subresource Integrity.

Inputs that used to reach the database and fail with 500 internal_error are now rejected up front with 400 validation_failed:

  • Filter ids must be UUIDs: page_id, product_id on GET /api/v1/orders; product_id, order_id on GET /api/v1/inventory/movements; product_id on GET /api/v1/inventory/stock (and the same MCP tools).
  • Date filters must be strict ISO 8601 (2026-09-01, 2026-09-01T10:00:00Z, 2026-09-01T10:00:00.123456+00:00): from, to, since, and the created_at inside a cursor. Lenient forms such as Sep 1, 2026 are rejected.
  • delivery_type on GET /api/v1/products must be physical or digital.
  • last_used_at on an API key is now updated at most once per minute (it used to be written on every request).

2026-09-05 — products:write and customers:write

Contract hash: 09080b2b08a017d65f6094ec1333441a20b42ee9ebbd263f9ead4c51557c99af

Additive. Two new scopes (granted only under the Custom preset; the Fulfilment / Read-only / МАШИНАТА presets are unchanged):

  • products:write — POST /api/v1/products, PATCH /api/v1/products/{id}: name, marketing_name, sku, price, original_price, active, description, delivery_type, weight_g, dim_cm, shipping_short_name, shipping_category, low_stock_threshold, block_on_zero_stock, catalog_hidden. Never stock quantity (use inventory movements), cost, images or files. MCP: create_product, update_product.
  • customers:write — POST /api/v1/segments, PATCH /api/v1/segments/{id}, DELETE /api/v1/segments/{id}, POST /api/v1/segments/{id}/members, DELETE /api/v1/segments/{id}/members, POST /api/v1/customers/{key}/notes. Membership segments (no filters) are filled from a CRM by email / phone; computed segments refuse members with 409 conflict. Never emails, phones, consents or customer deletion. MCP: create_segment, update_segment, delete_segment, add_segment_members, remove_segment_members, add_customer_note.
  • New path parameter key (customer key) and schemas ProductCreate, ProductPatch, SegmentCreate, SegmentPatch, SegmentFilters, SegmentMembersBody, CustomerNoteCreate (+ result / dry-run shapes). All write routes honour X-Velik-Dry-Run.

2026-09-05 — v1 initial release

Contract hash: 60255ea16f2504a5d3a5dfa3f193368f6400cd7a65b23561eabaebdd033537b1

REST /api/v1

  • Orders: GET /api/v1/orders (filters status, payment, from, to, since, page_id, product_id, courier, shipment_status, q; keyset pagination), GET /api/v1/orders/{id}, PATCH /api/v1/orders/{id} (status, tracking_number, tracking_url, courier, admin_notes, notify_customer), GET /api/v1/orders/{id}/shipment.
  • Shipments: POST /api/v1/shipments, GET /api/v1/shipments/{id}, GET /api/v1/shipments/{id}/label (PDF), POST /api/v1/shipments/{id}/cancel.
  • Inventory: GET /api/v1/inventory/stock, GET /api/v1/inventory/movements, POST /api/v1/inventory/movements (delivery / correction).
  • Products: GET /api/v1/products, GET /api/v1/products/{id}.
  • Customers: GET /api/v1/customers (offset pagination), GET /api/v1/segments.
  • Webhooks: GET /api/v1/webhooks, POST /api/v1/webhooks, PATCH /api/v1/webhooks/{id}, DELETE /api/v1/webhooks/{id}, GET /api/v1/webhooks/{id}/deliveries, POST /api/v1/webhooks/{id}/test, POST /api/v1/webhook-deliveries/{id}/retry.
  • Cross-cutting: Bearer vk_ keys with explicit scopes (13; presets Fulfilment / Read only / МАШИНАТА / Custom), Pro+ plan gate, 300 req/min per key with X-RateLimit-*, X-Velik-Dry-Run, { error: { code, message, details? } }, X-Deploy-SHA.

MCP POST /api/mcp

  • 19 platform tools mirroring the REST operations, shown per key scope in tools/list, next to the 3 bridge tools (submit_brief, get_status, get_facts). 120 req/min per key.

Webhooks

  • Events order.created, order.updated, order.status_changed, order.paid, shipment.created, shipment.status_changed, shipment.cancelled, inventory.low_stock (+ test).
  • Envelope { id, event, created_at, account_id, api_version: "2026-09-01", data }, X-Velik-Signature: t=…,v1=… (HMAC-SHA256), at-least-once delivery with backoff 1m · 5m · 30m · 2h · 12h (6 attempts), dead + email, 410 Gone unsubscribes.
  • Replaces the pre-2026-09 flat order.created POST (no subscribers existed).

Generated from docs/integrations/ in the velik.ai repository. Contract: /openapi.v1.yaml · API reference · Postman collection · Privacy · Terms