Developers

Getting started

Five minutes from zero to your first webhook. You'll create an API key, fetch your locations, post an order, then verify a webhook signature on your end.

1. Create an API key

Owners and admins create keys in the admin app: Settings → Integrations → API, Webhooks & AI apps → New API key. A key works across the whole business. Tick only the permissions the integration needs (e.g. orders:read, products:write), and optionally limit it to some locations and set an expiry. The full key (mavos_live_…) is shown once on creation — copy it then. Older pos_live_ keys keep working.

Customer details have their own permissions: names need customers:names, and email addresses and phone numbers need customers:contact. Without them those fields come back null.

export MAVOS_KEY=mavos_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

2. Your first request

List the locations the key has access to:

curl https://mavos.com.au/api/v1/locations \
  -H "Authorization: Bearer $MAVOS_KEY"

Every response carries Mavos-Request-Id (quote it to support) and Mavos-Api-Version (currently 2026-10-06), plus X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset so you can pace bursts. Money is integer cents and timestamps are ISO 8601.

Pagination

Lists take ?limit= (1–200, default 50) and ?cursor=. Pass next_cursor back as cursor while has_more is true.

{
  "data": [ { "id": "…", "name": "Main St" } ],
  "next_cursor": "eyJpZCI6…",
  "has_more": true
}

Errors

Errors share one shape. Match on code; it never changes meaning.

{
  "error": {
    "code": "insufficient_scope",
    "message": "Missing required permission: orders:write",
    "request_id": "…",
    "details": { "required": "orders:write" }
  }
}
  • 401 — unauthenticated, invalid_token, revoked_token, expired_token
  • 403 — insufficient_scope, insufficient_role, location_forbidden, module_not_enabled, ai_forbidden, mfa_required, company_not_connected
  • 400 — validation_failed, company_required (an AI app connection covering several businesses needs a Mavos-Company header); 404 — not_found; 429 — rate_limited
  • 409 — conflict, idempotency_conflict, idempotency_in_progress

3. Create an order

Writes require an Idempotency-Key header (1–255 characters) — a UUID is fine. Sending the same key with the same body within 24 hours replays the stored response with Idempotent-Replayed: true, so a network retry never creates a second order. The same key with a different body answers 409 idempotency_conflict. Prices and modifier deltas come from the database row; the API ignores client-supplied money.

curl https://mavos.com.au/api/v1/orders \
  -X POST \
  -H "Authorization: Bearer $MAVOS_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "location_id": "00000000-0000-0000-0000-000000000000",
    "items": [
      { "product_id": "11111111-1111-1111-1111-111111111111", "quantity": 2 }
    ]
  }'

4. Subscribe to webhooks

There are 84 event kinds; GET /api/v1/event-kinds lists them with the permission each needs. Subscribe to exact kinds (order.paid), prefix wildcards (booking.*) or everything (*). pii_level sets how much customer detail deliveries carry: none (default), names or contact. The signing secret (whsec_…) is returned exactly once on create.

curl https://mavos.com.au/api/v1/webhooks \
  -X POST \
  -H "Authorization: Bearer $MAVOS_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "stock-control",
    "url": "https://stock.example.com/mavos-webhook",
    "subscribed_events": ["order.paid", "order.refunded", "product.*"],
    "pii_level": "none"
  }'

Each delivery is a POST with this envelope:

{
  "id": "evt_123",
  "type": "order.paid",
  "api_version": "2026-10-06",
  "created_at": "2026-10-06T01:23:45.000Z",
  "company_id": "…",
  "location_id": "…",
  "resource": { "type": "order", "id": "…" },
  "actor": { "kind": "user", "id": "…", "label": "…" },
  "data": { … }
}

Headers: Mavos-Signature, Mavos-Event-Id, Mavos-Event-Type, Mavos-Delivery-Id, Mavos-Api-Version and User-Agent: Mavos-Webhooks/2. Answer with a 2xx quickly. Delivery is at-least-once, so de-duplicate on Mavos-Event-Id. A failed delivery is retried with exponential backoff, starting at 1 minute and capped at 24 hours, for up to 12 attempts in all. Test on the endpoint in the admin (or POST /api/v1/webhooks/{id}/test) sends one webhook.test event to that endpoint only.

5. Verify the signature

Every delivery carries Mavos-Signature: t=<unix>,v1=<hex>. Each v1 is HMAC-SHA256 of <t>.<raw body> keyed with the signing secret. For 24 hours after you rotate the secret the header carries two v1 values (new and previous secret): accept the request if any of them matches. Compare in constant time, use the raw body exactly as received (not re-serialised JSON), and reject timestamps more than 5 minutes old.

Node.js

import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

export function verifyMavosSignature(rawBody: string, header: string, secret: string): boolean {
  let t = NaN;
  const signatures: Buffer[] = [];
  for (const part of header.split(",")) {
    const [key, value = ""] = part.trim().split("=");
    if (key === "t") t = Number(value);
    else if (key === "v1" && /^[0-9a-f]{64}$/i.test(value)) signatures.push(Buffer.from(value, "hex"));
  }
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;

  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`, "utf8").digest();
  // Accept if ANY v1 matches: there are two during the 24 hours after a rotation.
  return signatures.some((sig) => sig.length === expected.length && timingSafeEqual(sig, expected));
}

Python

import hmac, hashlib, time

def verify_mavos_signature(raw_body: bytes, header: str, secret: str) -> bool:
    t, signatures = None, []
    for part in header.split(","):
        key, _, value = part.strip().partition("=")
        if key == "t" and value.isdigit():
            t = int(value)
        elif key == "v1":
            signatures.append(value)
    if t is None or abs(time.time() - t) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, sig.lower()) for sig in signatures)

What's next

  • Browse the full API in the interactive docs.
  • Connect Claude, ChatGPT or another AI app through the MCP server.
  • Pull historical events via GET /api/v1/events?since=... if a webhook is missed.
  • Send a failed delivery again from the endpoint's delivery list in the admin, or via POST /api/v1/webhooks/{id}/deliveries/{deliveryId}/retry.