Bivetica Motors · Developers

Stock API, version 1

Show a dealership's cars on its own website, written in any language (PHP, Python, JavaScript, .NET, Ruby…), and send website enquiries straight into the dealer's Motors inbox. Plain HTTPS and JSON. No plugins.

Keys and authentication

Base address: https://motors.bivetica.com/api/site/v1. Every request sends the dealer's key:

Authorization: Bearer bvm_live_2f6c…
KeyStockEnquiries
bvm_live_…The dealer's real advertised stockDelivered to the dealer's Motors inbox
bvm_test_…The same real advertised stockFully checked (including idempotency) but not delivered; the response has "test": true
bvm_dev_…Older keys. They keep working exactly like live keys.

The dealer creates keys in Motors under Connections → Your website connections and can revoke them there at any time. A key is shown once; Motors stores only a hash of it.

Keep the key on your server. Never put it in browser JavaScript, a mobile app or a public repository. Your server calls the API and renders the page. This API does not send CORS headers on purpose.

Only cars the dealer has advertised are returned: published, reserved and sold. Drafts, preparation and withdrawn cars never appear, and their photos return 404.

List stock

GET /api/site/v1/stock?offset=0&limit=100
{
  "schemaVersion": 1,
  "mode": "live",
  "stockVersion": "9f2c41d0a7b3e5c8",
  "total": 132,
  "vehicles": [ { …vehicle… } ],
  "nextOffset": 100
}

A vehicle has these fields (any may be null when the dealer has not filled it in):

FieldTypeNotes
idstringStable. Use it in your URLs.
versionintegerIncreases every time the dealer changes the car.
statusstringpublished, reserved or sold. Show "Sold" and no enquiry form for sold cars.
priceModestringfixed or poa (price on application).
pricePenceintegerPrice in pence. null for POA and sold cars: never show an old price.
make, model, derivative, year
mileageintegerMiles.
fuel, transmission, bodyType, colour, engineSize, doors, seats, emissionClass
imagesstring[]In the dealer's order. See Photos.
equipmentstring[]
descriptionstringPlain text. Escape it before putting it in HTML.

stockVersion changes whenever any advertised car changes. Motors shows the dealer which version your site last fetched, so fetch the first page on each page view (or cache for at most a minute).

One car

GET /api/site/v1/stock/{id}
{ "schemaVersion": 1, "mode": "live", "vehicle": { …vehicle… } }

Returns 404 when the car is no longer advertised. Your site should then return 404 too (or redirect to your stock list).

Photos

Each entry in images is either a full https:// address you can use directly, or a path such as /api/site/v1/media/{id}/{index}. Paths need your key, so serve them through your own server (see the examples). Sizes up to a few MB; JPEG, PNG or WebP.

Send an enquiry

POST /api/site/v1/enquiries
Content-Type: application/json
Idempotency-Key: 3b6f0a1c-8d7e-4f2a-9c11-5e0d2b7a4f90

{ "vehicleId": "8c1d…", "name": "Sam Buyer", "email": "sam@example.com", "phone": "07700 900000", "message": "Is it available on Saturday?" }
FieldRules
nameRequired, up to 100 characters.
emailRequired, a valid address, up to 254 characters.
messageRequired, up to 3,000 characters.
vehicleIdRequired string. A published or reserved car's id, or "" for a general enquiry (contact page).
phoneOptional, up to 40 characters.

Response 201: {"id":"…","replayed":false} (test keys add "test":true). Sold or withdrawn car: 404.

Pagination

limit is 1–100 (default 100), offset starts at 0. Keep requesting with offset=nextOffset until nextOffset is null. There is no maximum number of cars. Results are ordered by id, so pages are stable while stock is unchanged.

Errors

Errors are JSON: {"error":"A sentence you can show or log."}

StatusMeaning
400Invalid input (the message says which).
401Missing, invalid or revoked key.
404Car or photo not advertised, or unknown route.
409The Idempotency-Key was already used for a different enquiry.
413 / 415Enquiry over 16 KB / not sent as JSON.
429Rate limit reached. Wait a minute and retry.
500Temporary problem on our side. Retry with the same Idempotency-Key.

Rate limits

300 requests per minute per key, and 10 accepted enquiries per minute per key. Above that you get 429.

Idempotency

Every enquiry needs an Idempotency-Key header: 16–100 letters, digits or hyphens, unique per enquiry (a UUID is ideal). Sending the same key with the same body again returns the original enquiry with "replayed":true and creates nothing new, so retries after a timeout are safe. Create the key when you render the form, not when the visitor clicks again.

Examples

curl

curl -H "Authorization: Bearer $MOTORS_KEY" "https://motors.bivetica.com/api/site/v1/stock?limit=20"
curl -H "Authorization: Bearer $MOTORS_KEY" https://motors.bivetica.com/api/site/v1/stock/CAR_ID
curl -X POST -H "Authorization: Bearer $MOTORS_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"vehicleId":"CAR_ID","name":"Sam","email":"sam@example.com","message":"Is it available?"}' \
  https://motors.bivetica.com/api/site/v1/enquiries

JavaScript (Node.js 18+, on your server)

const API = 'https://motors.bivetica.com/api/site/v1', headers = { Authorization: `Bearer ${process.env.MOTORS_KEY}` };
async function allStock() {
  const cars = []; let offset = 0;
  while (offset !== null) {
    const page = await (await fetch(`${API}/stock?limit=100&offset=${offset}`, { headers })).json();
    cars.push(...page.vehicles); offset = page.nextOffset;
  }
  return cars;
}
async function sendEnquiry(form) {
  const r = await fetch(`${API}/enquiries`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID() },
    body: JSON.stringify({ vehicleId: form.vehicleId, name: form.name, email: form.email, message: form.message }) });
  if (r.status !== 201) throw new Error((await r.json()).error);
}

PHP

<?php
function motors(string $path, ?array $body = null): array {
    $ch = curl_init('https://motors.bivetica.com/api/site/v1' . $path);
    $headers = ['Authorization: Bearer ' . getenv('MOTORS_KEY')];
    if ($body !== null) {
        $headers[] = 'Content-Type: application/json';
        $headers[] = 'Idempotency-Key: ' . bin2hex(random_bytes(16));
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    }
    curl_setopt_array($ch, [CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15]);
    $json = json_decode(curl_exec($ch), true);
    return [curl_getinfo($ch, CURLINFO_RESPONSE_CODE), $json];
}
[$status, $page] = motors('/stock?limit=100');
foreach ($page['vehicles'] as $v) {
    $price = $v['status'] === 'sold' ? 'Sold' : ($v['pricePence'] === null ? 'POA' : '£' . number_format($v['pricePence'] / 100));
    echo '<li>' . htmlspecialchars("{$v['year']} {$v['make']} {$v['model']}") . " $price</li>";
}

Python

import json, os, urllib.request, uuid
API, KEY = "https://motors.bivetica.com/api/site/v1", os.environ["MOTORS_KEY"]
def call(path, body=None):
    headers = {"Authorization": "Bearer " + KEY}
    if body is not None: headers |= {"Content-Type": "application/json", "Idempotency-Key": str(uuid.uuid4())}
    req = urllib.request.Request(API + path, json.dumps(body).encode() if body is not None else None, headers)
    with urllib.request.urlopen(req, timeout=15) as r: return json.loads(r.read())
page = call("/stock?limit=100")
for v in page["vehicles"]: print(v["year"], v["make"], v["model"], v["pricePence"])

A complete website in one file

This 50-line Python program (standard library only) is a working dealer site: stock list, car pages with photos, sold handling and an enquiry form. Run it with MOTORS_KEY=bvm_test_… python3 tiny_site.py and open http://localhost:8000. Bivetica tests it against the API on every release.

"""A complete dealer website in one file, using only the Bivetica Motors stock API (v1) and the Python standard library.
Run: MOTORS_KEY=bvm_test_... python3 tiny_site.py   then open http://localhost:8000"""
import html, json, os, uuid, urllib.error, urllib.parse, urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer

API = os.environ.get("MOTORS_API", "https://motors.bivetica.com") + "/api/site/v1"
KEY = os.environ["MOTORS_KEY"]  # keep the key on the server, never in a page

def api(path, body=None, idem=None):
    headers = {"Authorization": "Bearer " + KEY}
    if body is not None: headers |= {"Content-Type": "application/json", "Idempotency-Key": idem}
    req = urllib.request.Request(API + path, json.dumps(body).encode() if body is not None else None, headers)
    try:
        with urllib.request.urlopen(req, timeout=15) as r: return r.status, r.read(), r.headers.get("Content-Type")
    except urllib.error.HTTPError as e: return e.code, e.read(), "application/json"

def all_stock():
    cars, offset = [], 0
    while offset is not None:
        page = json.loads(api(f"/stock?limit=100&offset={offset}")[1]); cars += page["vehicles"]; offset = page["nextOffset"]
    return cars

def name(v): return f"{v['year']} {v['make']} {v['model']}"
def price(v): return "Sold" if v["status"] == "sold" else "POA" if v["pricePence"] is None else f"£{v['pricePence'] / 100:,.0f}"
def img(src): return src if src.startswith("https://") else "/photo" + src[len("/api/site/v1/media"):]
def page(title, body): return f"<!doctype html><meta charset=utf-8><title>{html.escape(title)}</title><h1>{html.escape(title)}</h1>{body}".encode()

class Site(BaseHTTPRequestHandler):
    def reply(self, status, body, kind="text/html; charset=utf-8"):
        self.send_response(status); self.send_header("Content-Type", kind); self.end_headers(); self.wfile.write(body)
    def do_GET(self):
        if self.path == "/":
            items = "".join(f'<li><a href="/car/{v["id"]}">{html.escape(name(v))}</a> {price(v)}</li>' for v in all_stock())
            return self.reply(200, page("Our stock", f"<ul>{items}</ul>"))
        if self.path.startswith("/photo/"):
            status, data, kind = api("/media/" + self.path[len("/photo/"):]); return self.reply(status, data, kind)
        if self.path.startswith("/car/"):
            status, data, _ = api("/stock/" + urllib.parse.quote(self.path[5:]))
            if status != 200: return self.reply(404, page("Not found", "<p>This car is no longer advertised.</p>"))
            v = json.loads(data)["vehicle"]
            photos = "".join(f'<img src="{html.escape(img(s))}" width="320">' for s in v["images"])
            form = "" if v["status"] == "sold" else f'<form method="post" action="/enquire"><input type="hidden" name="vehicleId" value="{html.escape(v["id"])}"><input name="name" required placeholder="Name"><input name="email" type="email" required placeholder="Email"><textarea name="message" required></textarea><button>Send enquiry</button></form>'
            return self.reply(200, page(name(v), f"<p>{price(v)} · {v['mileage']} miles</p>{photos}<p>{html.escape(v['description'])}</p>{form}"))
        self.reply(404, page("Not found", ""))
    def do_POST(self):
        form = {k: v[0] for k, v in urllib.parse.parse_qs(self.rfile.read(int(self.headers["Content-Length"])).decode()).items()}
        status, data, _ = api("/enquiries", {k: form.get(k, "") for k in ("vehicleId", "name", "email", "message")}, str(uuid.uuid4()))
        self.reply(200 if status == 201 else 400, page("Thank you" if status == 201 else "Sorry", "" if status == 201 else html.escape(json.loads(data)["error"])))

if __name__ == "__main__":
    HTTPServer(("0.0.0.0", int(os.environ.get("PORT", "8000"))), Site).serve_forever()

Versioning

This is version 1 (/api/site/v1, "schemaVersion": 1). Within v1 we only add fields and endpoints; we never remove or rename one, so ignore fields you don't recognise. A breaking change would get a new /v2 path, announced to dealers at least 6 months before v1 is switched off.

Changes: 2026-10 — live and test keys, one-car endpoint, stockVersion/total/mode, general enquiries and optional phone.