Hoppa till innehållet

Matvy Partner API

Connect a till, a kitchen display or any tool to a Matvy venue directly. Free, documented, and owned by the venue.

OpenAPI 3.1: /api/v1/openapi.json

Overview

Matvy runs bookings, online ordering and order-at-the-table for restaurants, cafés and bars. A venue that wants its own till or kitchen system to work with Matvy no longer needs a paid middleman: it makes a key under Settings, Integrations, Matvy API and hands it to you. With that key you can:

  • read the menu and mark dishes sold out or back,
  • read orders as they arrive and move them along (accepted, preparing, ready, collected),
  • read sales per hour for staff scheduling,
  • receive signed webhooks the moment an order or a dish changes.

Every request is HTTPS and JSON, under https://matvy.com/api/v1. Money is always an integer in minor units (öre, cents) beside an ISO 4217 currency. Times are ISO 8601 in UTC. A key belongs to exactly one venue and can never see another venue's data.

Authentication

Send the key as a Bearer token. Keys look like mtv_live_ followed by 43 characters. Matvy stores only a SHA-256 of the key, so a lost key cannot be shown again: the venue revokes it and makes a new one.

Request
curl https://matvy.com/api/v1/venue \
  -H "Authorization: Bearer mtv_live_..."
Response
{
  "id": "b2f8e0d4-1c3a-4f6e-8d2b-5a7c9e1f3b60",
  "name": "Trattoria Sole",
  "slug": "trattoria-sole",
  "currency": "SEK",
  "timezone": "Europe/Stockholm",
  "country": "SE",
  "key": { "name": "Onslip", "scopes": ["orders:read", "orders:write", "menu:read"] }
}

A missing, malformed, unknown or revoked key gets the same 401. Keep keys on your server, never in an app or a browser.

Scopes

ScopeAllows
orders:readGET /orders, GET /orders/{id}, order webhooks. Includes the guest's first name and phone number, so staff can call a guest about a pickup. Nothing else about the guest, and never card data.
orders:writePOST /orders/{id}/status.
menu:readGET /menu, the menu.item.updated webhook.
menu:writePATCH /menu/items/{id}: availability only. Prices and names stay the venue's decision, in Matvy.
sales:readGET /sales/hourly.
bookings:readGET /bookings, GET /availability. Includes the guest's name, phone and email on each booking.
guests:readGET /guests: the venue's guest list. Personal data — give it only to a system the venue has an agreement with.

A good key without the scope an endpoint needs gets 403. GET /venue works with any scope.

Errors

Every error has the same shape. Branch on the HTTP status and on code; the message is for people.

{ "error": { "code": "conflict", "message": "The order is ready and cannot move to preparing." } }
  • 400 invalid_request the body or query is malformed
  • 401 unauthorized missing, invalid or revoked key
  • 403 forbidden the key lacks the scope
  • 404 not_found no such thing in this venue (another venue's id looks exactly the same)
  • 409 conflict the order cannot make that transition from its current status
  • 429 rate_limited slow down, see Retry-After

Rate limits

120 requests a minute per key. Over that, the answer is 429 with a Retry-After header in seconds. Every request with the key counts, including ones refused for a missing scope. Polling GET /orders every 10 to 30 seconds is fine; webhooks are better. Separately, more than 60 failed authentications a minute from one address are answered with 429.

GET/venue

Scope: any

The key's venue: name, currency, time zone and country, and the key's own name and scopes. The example is under Authentication.

GET/orders

Scope: orders:read

Orders, oldest change first (sorted by updatedAt, then id). Query parameters:

  • status one or more, comma-separated: pending_payment, received, accepted, preparing, ready, collected, cancelled, refunded. Without it, orders still waiting for online payment are left out: they are not work yet.
  • updated_since an ISO 8601 time; only orders changed at or after it.
  • limit 1 to 100, default 50.
  • cursor the nextCursor of the previous page.

To keep in sync: call with updated_since, follow nextCursor until it is null, and remember the last order's updatedAt for next time.

Request
curl "https://matvy.com/api/v1/orders?status=received,accepted&updated_since=2026-10-18T16:00:00Z&limit=20" \
  -H "Authorization: Bearer mtv_live_..."
Response
{ "data": [ /* orders, as below */ ], "hasMore": true, "nextCursor": "MjAyNi0xMC0xOFQxNjo0Mjow..." }

GET/orders/{id}

Scope: orders:read

One order. code is the six characters guest and kitchen say out loud. dueAt null means as soon as possible. vat splits the order by rate, delivery fee included, and adds up to totals.vatMinor. Lines keep the names and prices they had when ordered.

Response
{
  "id": "6f1c2a9e-3b7d-4e51-9a0c-2d8e4f6b1a37",
  "code": "3F2A9C",
  "status": "received",
  "source": "online",
  "fulfilment": "collection",
  "table": null,
  "deliveryAddress": null,
  "dueAt": null,
  "acceptedAt": null,
  "prepMinutes": null,
  "readyEstimateAt": null,
  "guest": { "firstName": "Linnea", "phone": "+46701234567" },
  "locale": "sv",
  "lines": [
    {
      "id": "0b9e…",
      "menuItemId": "c41d…",
      "name": "Pizza Diavola",
      "quantity": 2,
      "unitPriceMinor": 14500,
      "totalMinor": 30500,
      "discountMinor": 0,
      "vatRateBasisPoints": 600,
      "note": "Extra crispy",
      "station": "Pizza",
      "options": [
        { "id": "77aa…", "choiceId": "9f02…", "name": "Burrata", "priceDeltaMinor": 1500 }
      ]
    }
  ],
  "payment": { "status": "paid", "method": "online", "paidAt": "2026-10-18T16:42:07.120Z", "refundedMinor": 0 },
  "totals": {
    "currency": "SEK",
    "subtotalMinor": 30500,
    "discountMinor": 0,
    "deliveryFeeMinor": 0,
    "vatMinor": 1726,
    "totalMinor": 30500,
    "promotionName": null
  },
  "vat": [{ "rateBasisPoints": 600, "grossMinor": 30500, "vatMinor": 1726, "netMinor": 28774 }],
  "cancelledAt": null,
  "cancelReason": null,
  "createdAt": "2026-10-18T16:41:30.004Z",
  "updatedAt": "2026-10-18T16:42:07.131Z"
}

POST/orders/{id}/status

Scope: orders:write

Move an order one step: received to accepted to preparing to ready to collected. It is the same step the kitchen screen takes, so the guest's live page, the alarms on staff phones and the venue's audit log behave exactly as if a cook had pressed the button. Skipping a step or going back is 409.

prepMinutes (0 to 240, only when accepting) is the time the guest is promised. Left out, it is the venue's own default. Cancelling is not offered: it gives a guest their money back, and that stays a decision a person makes in Matvy. Answers with the order.

Request
curl -X POST https://matvy.com/api/v1/orders/6f1c2a9e-.../status \
  -H "Authorization: Bearer mtv_live_..." \
  -H "Content-Type: application/json" \
  -d '{"status": "accepted", "prepMinutes": 20}'

GET/sales/hourly

Scope: sales:read

Sales per hour on the venue's clock, for staff-scheduling tools. from and to are dates (YYYY-MM-DD) in the venue's time zone, at most 62 days. Without them: the last seven whole days. An order counts in the hour it was paid, less anything refunded; covers are the guests of bookings starting in that hour.

Response (shortened)
{
  "venue": "Trattoria Sole", "timezone": "Europe/Stockholm", "currency": "SEK",
  "from": "2026-10-11", "to": "2026-10-17",
  "hours": [
    { "date": "2026-10-11", "hour": 18, "start": "2026-10-11T18:00:00+02:00", "orders": 14,
      "grossInclVatMinor": 487300, "netExVatMinor": 437946, "grossInclVat": 4873, "netExVat": 4379.46, "covers": 31 }
  ]
}

GET/bookings

Scope: bookings:read

The venue's bookings on business days from to to (YYYY-MM-DD; to defaults to from), at most 31 days per call. A business day ends when the venue's own day ends, so a booking at 00:30 after a Friday service belongs to Friday — the same as the venue's reports.

Response (shortened)
{
  "from": "2026-10-24", "to": "2026-10-24",
  "bookings": [
    { "id": "…", "startsAt": "2026-10-24T17:00:00.000Z", "durationMinutes": 120, "partySize": 4,
      "status": "confirmed", "source": "online", "table": "12",
      "guest": { "id": "…", "name": "Sara Ek", "phone": "+46701234567", "email": "sara@example.com" },
      "deposit": null, "extrasMinor": 0 }
  ]
}

GET/availability

Scope: bookings:read

The times a party of partySize can book on date, from the same engine as the booking page. For display only: a time is checked again, inside one locked transaction, when a booking is actually made.

GET/guests

Scope: guests:read

The guest list, oldest change first, 200 per page. Pass updated_since (ISO date-time) to sync changes only, and the returned nextCursor as cursor until it is null. Each guest: name, phone, email, tags, whether they agreed to marketing, and birthday month and day if given.

Webhooks

The venue adds up to 3 HTTPS addresses per key in its settings and picks the events. Each address gets its own signing secret (whsec_…), shown once. Events:

  • order.created an order became real: paid online, taken by phone to pay at the counter, or placed at a table. Needs orders:read.
  • order.updated its status, promised time or refund changed. Needs orders:read.
  • menu.item.updated a dish was marked sold out or back. Needs menu:read.
Body
{
  "id": "e3b0c442-98fc-4c14-9afb-f4c8996fb924",
  "type": "order.created",
  "apiVersion": "1",
  "createdAt": "2026-10-18T16:42:07.140Z",
  "venueId": "b2f8e0d4-1c3a-4f6e-8d2b-5a7c9e1f3b60",
  "data": { "order": { /* the same order object as GET /orders/{id} */ } }
}

Headers: Matvy-Signature, Matvy-Event-Id, Matvy-Event-Type, Matvy-Delivery-Id. Answer with any 2xx within 10 seconds; do the work afterwards. Redirects are not followed.

A failed delivery is retried after 1, 5, 15, 30, 60, 120, 240 and 480 minutes, nine attempts in all. Delivery is at least once and not in order: de-duplicate on Matvy-Event-Id and compare updatedAt, or fetch the order fresh. After 20 failed attempts in a row the address is switched off and the venue sees it in settings, with a button to switch it back on.

Addresses must be HTTPS on the standard port (443) and on a public host; any other port is refused. Matvy refuses private, loopback and link-local addresses when the URL is saved and again at every send. The whole delivery, answer included, has to finish within 10 seconds. Switching an address back on is limited to five times an hour per venue.

Verifying signatures

Matvy-Signature: t=1792341727,v1=5257a869…. v1 is the hex HMAC-SHA256 of ${t}.${raw body} under the endpoint's secret. Compute it over the raw bytes you received, compare in constant time, and refuse a t more than five minutes from now, which stops a captured request from being replayed.

Node.js (Express)
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.MATVY_WEBHOOK_SECRET; // whsec_...

// Read the body as raw bytes: the signature covers them exactly.
app.post("/matvy", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("Matvy-Signature") ?? "";
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  const body = req.body.toString("utf8");
  const expected = crypto.createHmac("sha256", SECRET).update(`${t}.${body}`).digest("hex");

  const fresh = Math.abs(Date.now() / 1000 - t) <= 300;
  // Check the shape first: Buffer.from(x, "hex") silently drops bad characters,
  // and timingSafeEqual throws when the lengths differ.
  const ok =
    fresh &&
    typeof parts.v1 === "string" &&
    /^[0-9a-f]{64}$/.test(parts.v1) &&
    crypto.timingSafeEqual(Buffer.from(parts.v1, "hex"), Buffer.from(expected, "hex"));
  if (!ok) return res.status(400).send("bad signature");

  const event = JSON.parse(body);
  // De-duplicate on event.id (also in the Matvy-Event-Id header).
  console.log(event.type, event.data.order?.code);
  res.sendStatus(200);
});
Python (Flask)
import hashlib, hmac, json, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["MATVY_WEBHOOK_SECRET"].encode()  # whsec_...

@app.post("/matvy")
def matvy():
    try:
        header = request.headers.get("Matvy-Signature", "")
        parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
        t = int(parts.get("t", "0"))
        body = request.get_data(as_text=True)  # the raw body, not re-serialised JSON
        expected = hmac.new(SECRET, f"{t}.{body}".encode(), hashlib.sha256).hexdigest()
        ok = abs(time.time() - t) <= 300 and hmac.compare_digest(expected, parts.get("v1", ""))
        event = json.loads(body) if ok else None
    except (ValueError, TypeError, UnicodeError):
        ok = False
    if not ok:
        abort(400)

    print(event["type"], event["data"].get("order", {}).get("code"))
    return "", 200

Versioning

This is version 1, and the version is in the path. Within v1 we only add: new endpoints, new optional parameters, new fields in answers, new webhook events you have to subscribe to. We never remove or rename a field, change its type or meaning, or make an optional parameter required. Ignore fields you do not know. Anything that would break you becomes /api/v2, and v1 keeps running for at least 12 months after v2 ships, with notice to every venue that has a key.

Questions or a partnership: support@matvy.com. Venues: see what Matvy does.

Vi sparar kakor så att sidan minns ditt språk och ditt land. Det finns ingen besöksstatistik och inga annonsspårare här. Vad vi sparar