Keys and authentication
Base address: https://motors.bivetica.com/api/site/v1. Every request sends the dealer's key:
Authorization: Bearer bvm_live_2f6c…
| Key | Stock | Enquiries |
|---|---|---|
bvm_live_… | The dealer's real advertised stock | Delivered to the dealer's Motors inbox |
bvm_test_… | The same real advertised stock | Fully 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):
| Field | Type | Notes |
|---|---|---|
id | string | Stable. Use it in your URLs. |
version | integer | Increases every time the dealer changes the car. |
status | string | published, reserved or sold. Show "Sold" and no enquiry form for sold cars. |
priceMode | string | fixed or poa (price on application). |
pricePence | integer | Price in pence. null for POA and sold cars: never show an old price. |
make, model, derivative, year | ||
mileage | integer | Miles. |
fuel, transmission, bodyType, colour, engineSize, doors, seats, emissionClass | ||
images | string[] | In the dealer's order. See Photos. |
equipment | string[] | |
description | string | Plain 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?" }
| Field | Rules |
|---|---|
name | Required, up to 100 characters. |
email | Required, a valid address, up to 254 characters. |
message | Required, up to 3,000 characters. |
vehicleId | Required string. A published or reserved car's id, or "" for a general enquiry (contact page). |
phone | Optional, 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."}
| Status | Meaning |
|---|---|
| 400 | Invalid input (the message says which). |
| 401 | Missing, invalid or revoked key. |
| 404 | Car or photo not advertised, or unknown route. |
| 409 | The Idempotency-Key was already used for a different enquiry. |
| 413 / 415 | Enquiry over 16 KB / not sent as JSON. |
| 429 | Rate limit reached. Wait a minute and retry. |
| 500 | Temporary 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.