Battery Health CheckDevelopers
OpenAPI Samples

This is the API for dealers. One credential scoped to your own dealer group — provision branches, order AVILOO boxes, list your own tests and look up a car by VIN or registration.

Building for a marketplace? A platform that aggregates listings from several unrelated dealer groups has its own API and its own docs. Marketplace API docs →

Battery Health Check Partner API

A REST API + outbound webhook system for dealer groups who want to provision new branches programmatically, retrieve battery test certificates, and embed test results on their own websites. This single document is everything your dev team needs to integrate — quickstart, full endpoint reference, three worked examples, webhooks, and error handling.

⚡ Quickstart — Web developer

Goal: show the battery certificate on every used-EV listing.

The rest of this page is the full reference — but this is all most websites need. It uses a read-only key (scopes read:tests + read:certificates): ask the dealer to create one in their portal under API access and send you the client_id + client_secret. A read-only key can't change data or order boxes, so it's safe in your site's backend.

  1. Swap the key for a 1-hour token (cache it).
  2. For each listing's VIN, fetch the latest test — State of Health %, range, date.
  3. Fetch the certificate link and render it on the page.
# 1. Get a token (cache for ~55 min)
curl -X POST https://api.batteryhealthcheck.co.uk/v1/oauth/token \
  -d grant_type=client_credentials \
  -d client_id=YOUR_CLIENT_ID -d client_secret=YOUR_CLIENT_SECRET

# 2. Latest test for a VIN you already have on the listing
curl "https://api.batteryhealthcheck.co.uk/v1/tests?vin=WVWZZZ1KZAW000000&per_page=1" \
  -H "Authorization: Bearer ACCESS_TOKEN"

# 2b. ...or by number plate, if that's what your feed carries.
#     Prefer the VIN where you have one — see "Look up by VIN or plate".
curl -G "https://api.batteryhealthcheck.co.uk/v1/tests" \
  --data-urlencode "registration=WP72 FKH" --data-urlencode "per_page=1" \
  -H "Authorization: Bearer ACCESS_TOKEN"

# 3. Downloadable certificate (secure, expiring PDF link)
curl https://api.batteryhealthcheck.co.uk/v1/tests/TEST_ID/certificate \
  -H "Authorization: Bearer ACCESS_TOKEN"

Full walkthrough with Python / Node / PHP: Example C — Embedding battery test data on a listing. How to get your key: Getting access.

Before you go live: branch on result_status before you publish any number — not every completed test produces a quotable state of health. Then read Nulls & beta vehicles: some values are legitimately null, and vehicles still in AVILOO validation are flagged vehicle_supported: false.

Audience. A developer at a dealer group or platform integrating BHC into a dealer-management system, customer record system, or public-facing car-listing website — or at a marketplace aggregating listings from several dealer groups (see Marketplace access). Not a developer? Start with the integration overview. Machine-readable spec: openapi.json. Field-by-field reference: data dictionary. You should already have an account manager at BHC and, for a dealer-group credential, a parent company set up for your group.

Base URLs

EnvironmentBase URL
Productionhttps://api.batteryhealthcheck.co.uk/v1
Sandbox and test data. We do not currently provide a public sandbox. For development, the sample response pack provides a representative set of responses covering successful, provisional and other result states. When an integration is ready for validation, we recommend a pilot using real tests with a participating dealer, which allows both parties to validate the complete workflow before production rollout. See also Result status and Null values and models in validation.

Conventions


Getting access

Every request is authenticated with a client_id + client_secret pair (a “key”). There are two ways to get one:

RouteWho / whenWhat you get
Self-serve
(dealer portal)
Any dealer with an owner/admin login. Sign in and open API access in the sidebar. Create a key and choose an access level. Read-only — look up tests + download certificates (read:tests, read:certificates). Hand this to a website developer; it can't change anything or order boxes.
Full integration — adds dealer info + webhooks (read:dealers, manage:webhooks).
Account manager Group HQs / integrators that need to provision branches or order AVILOO boxes over the API (write:dealers). Ask your BHC account manager. A credential with the exact scopes agreed for your integration.
The secret is shown once. When a key is created we display the client_secret a single time and store only a one-way hash — save it in a password manager. Lost it? Revoke the key and create a new one. Keys don't expire; rotate them proactively (see Key rotation).

Give each key the least access it needs. A website that only shows certificates should use a read-only key, so a leaked key can't be used to change your data or order hardware.


Authentication

OAuth 2.0 client-credentials grant. Exchange a long-lived client_id + client_secret pair for a short-lived (1 hour) JWT, then send the JWT as a Bearer token on every other request.

OAuth flow

┌────────────────┐                                  ┌────────────────┐
│                │  1. POST /oauth/token            │                │
│                │  ─────────────────────────────►  │                │
│                │     client_id + client_secret    │                │
│                │     grant_type=client_credentials│                │
│  Partner       │                                  │  BHC API       │
│                │  2. 200 OK                       │                │
│                │  ◄─────────────────────────────  │                │
│                │     { access_token, expires_in } │                │
│                │                                  │                │
│                │  3. GET /dealers                 │                │
│                │  ─────────────────────────────►  │                │
│                │     Authorization: Bearer <jwt>  │                │
│                │                                  │                │
│                │  4. 200 OK + JSON                │                │
│                │  ◄─────────────────────────────  │                │
└────────────────┘                                  └────────────────┘

Machine-to-machine. No user consent screen, no refresh token. Request a fresh token when the current one is about to expire.

Token endpoint

POST /v1/oauth/token

Request

Content-Type: application/x-www-form-urlencoded

ParameterRequiredDescription
grant_typeyesMust be client_credentials
client_idyesYour credential ID
client_secretyesYour credential secret
scopeoptionalSpace-separated list of requested scopes
curl -X POST https://api.batteryhealthcheck.co.uk/v1/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id=part_a1b2c3d4e5f6" \
  -d "client_secret=<your-secret>" \
  -d "scope=read:tests read:certificates write:dealers"
import requests

resp = requests.post(
    "https://api.batteryhealthcheck.co.uk/v1/oauth/token",
    data={
        "grant_type": "client_credentials",
        "client_id": "part_a1b2c3d4e5f6",
        "client_secret": SECRET,
        "scope": "read:tests read:certificates write:dealers",
    },
    timeout=10,
)
token = resp.json()["access_token"]
const params = new URLSearchParams({
    grant_type: "client_credentials",
    client_id: "part_a1b2c3d4e5f6",
    client_secret: process.env.BHC_SECRET,
    scope: "read:tests read:certificates write:dealers",
});

const resp = await fetch(
    "https://api.batteryhealthcheck.co.uk/v1/oauth/token",
    { method: "POST", body: params }
);
const { access_token } = await resp.json();

Response

{
  "access_token": "eyJhbGciOiJIUzI1NiJ9...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "read:tests read:certificates write:dealers"
}

JWT structure

The access_token is a JWT signed with HS256. You don't need to verify the signature yourself — just store and present the token. We verify on our end.

{
  "iss": "bhc-partner-api",
  "aud": "bhc-partner-api",
  "sub": "part_a1b2c3d4e5f6",
  "scope": "read:tests read:certificates write:dealers",
  "parent_company_id": 198,
  "exp": 1748357821,
  "iat": 1748354221,
  "jti": "tok_8a3f4e2b1c5d"
}

Scope catalog

ScopeGrants
read:dealersList + get dealers
write:dealersCreate + update dealers; request additional boxes
read:testsList + get tests; VIN and registration lookup
read:certificatesGet PDF certificate + JPEG preview
manage:webhooksRegister, list, delete, test, and replay webhooks

write:dealers is the only scope that can create dealers or order boxes; it is never granted to self-serve keys (see Getting access). A key is granted a fixed set of scopes at creation; requesting a scope at the token endpoint can only narrow that set, never widen it.

Marketplace access

A marketplace credential is issued to a platform that aggregates listings from several unrelated dealer groups (a classifieds site, for example). It has no parent company of its own and is not tied to a list of them. It is a vehicle lookup: you bring a VIN or a registration from a listing, and we tell you what we know about that car — across every Battery Health Check dealer, because a marketplace cannot know in advance which dealer holds which car.

Token caching

Tokens are valid for 1 hour. Cache and reuse them — request a fresh one only when the current token is within 5 minutes of expiry. The token endpoint is rate-limited at 10 req/min/credential.

import time, threading, requests
_cache = {"token": None, "expires_at": 0}
_lock = threading.Lock()

def get_access_token():
    now = time.time()
    if _cache["token"] and now < _cache["expires_at"] - 300:
        return _cache["token"]
    with _lock:
        if _cache["token"] and now < _cache["expires_at"] - 300:
            return _cache["token"]
        body = requests.post(TOKEN_URL, data={...}, timeout=10).json()
        _cache["token"] = body["access_token"]
        _cache["expires_at"] = now + body["expires_in"]
        return _cache["token"]

IP allow-listing

A credential can be locked to a set of source addresses. Once an allow-list is set, a request presenting a perfectly valid token from any other address is rejected with 403 ip_not_allowed — so a leaked client_secret is worthless to anyone who cannot also call from your infrastructure.

This is a second, independent factor on top of OAuth and we recommend it for any server-to-server integration. Ask your account manager to set it, and give us the egress addresses your integration calls from. Two things to keep in mind:

If your security team requires certificate-based client authentication specifically, rather than network-level restriction, raise it with your account manager — it is a scoped piece of work rather than something we run today.

Credential rotation and revocation

Credentials do not expire automatically. We recommend rotating annually as routine practice, and immediately on suspected compromise.

Rotation. Issue a second credential alongside the existing one; both remain valid concurrently. Deploy the new credential, confirm it is in use, then revoke the previous one. This allows rotation without downtime.

Revocation. Revocation takes effect immediately for new token requests. An access token already issued remains valid until it expires, so allow up to one hour for existing tokens to lapse. Where a credential is known to be compromised, contact us so the outstanding tokens can be invalidated.

Who performs it. Credentials created by a dealer administrator are revoked and reissued by that administrator in the BHC portal. Credentials issued by BHC — those carrying provisioning scopes — are rotated by contacting us. A revoked credential returns 403 credential_disabled.


Endpoints

Every endpoint requires a Bearer JWT. All requests and responses follow the conventions in the Conventions section.

Create a dealer

POST /v1/dealers

Provision a new dealer branch under your parent company. Creates the dealer record, places an order for Aviloo box(es), and emails the owner a password-setup link.

Required scope: write:dealers

Request body

FieldTypeDescription
namerequiredstringTrading name of the branch (up to 200 chars)
legal_namestringRegistered company name if different
company_numberstringUK Companies House number
vat_numberstringVAT registration number
primary_contact_namerequiredstringFull name of the dealer owner
primary_contact_emailrequiredstringOwner email — receives password-setup link
primary_contact_phonestringE.164 format preferred
shipping_addressrequiredobjectWhere to ship the Aviloo box(es). Fields: line1, line2, city, postcode, country (ISO-2)
num_boxesintegerNumber of Aviloo units to order. Default 1, max 10.

Example

curl -X POST https://api.batteryhealthcheck.co.uk/v1/dealers \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: stl-mcr-04-create-1" \
  -d '{
    "name": "Stellantis Manchester (Salford Quays)",
    "primary_contact_name": "Jane Smith",
    "primary_contact_email": "jane@stellantis-mcr.example.com",
    "shipping_address": {
      "line1": "12 Salford Quays",
      "city": "Manchester",
      "postcode": "M50 3AG",
      "country": "GB"
    },
    "num_boxes": 1
  }'

Response 201 Created

{
  "data": {
    "id": 247,
    "name": "Stellantis Manchester (Salford Quays)",
    "status": "active",
    "primary_contact": {
      "name": "Jane Smith",
      "email": "jane@stellantis-mcr.example.com",
      "phone": null
    },
    "shipping_address": {
      "line1": "12 Salford Quays",
      "city": "Manchester",
      "postcode": "M50 3AG",
      "country": "GB"
    },
    "units": [
      { "id": 412, "internal_reference": "BHC-UNIT-000412", "status": "ordered" }
    ],
    "activated_at": "2026-05-27T11:47:21Z",
    "created_at": "2026-05-27T11:47:21Z"
  },
  "meta": { "request_id": "req_a9f2e1c4b7d8" }
}

Request additional Aviloo boxes

POST /v1/dealers/{id}/units

Order additional Aviloo unit(s) for an existing dealer that needs more capacity. New units ship to the dealer's existing shipping address unless overridden.

Required scope: write:dealers

Request body

FieldTypeDescription
quantityrequiredintegerHow many additional boxes. 1–10 per request.
shipping_addressobjectOverride delivery address. Defaults to the dealer's existing shipping address.
location_labelstringOptional label per box (e.g. “Service Bay 2”). Useful for multi-bay dealers.

Example

curl -X POST https://api.batteryhealthcheck.co.uk/v1/dealers/247/units \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: stl-mcr-04-box2" \
  -d '{
    "quantity": 1,
    "location_label": "Service Bay 2"
  }'

Response 201 Created

{
  "data": {
    "units": [
      {
        "id": 502,
        "internal_reference": "BHC-UNIT-000502",
        "status": "ordered",
        "serial_number": null,
        "location_label": "Service Bay 2",
        "shipping_address": {
          "line1": "12 Salford Quays",
          "line2": null,
          "city": "Manchester",
          "postcode": "M50 3AG"
        },
        "dispatched_at": null,
        "delivered_at": null,
        "activated_at": null
      }
    ]
  },
  "meta": { "request_id": "req_e1f3g5h7i9k1" }
}
What happens next. The box(es) ship within ~5 working days. When activated by the dealer, the unit's status moves from orderedshippedactive. Re-fetch the dealer with GET /v1/dealers/{id} to see the latest unit state.

List dealers

GET /v1/dealers

List all dealers under your parent company.

Required scope: read:dealers

Query parameters

ParamTypeDescription
pageinteger1-based page number. Default 1.
per_pageinteger1–100. Default 25.
curl -G https://api.batteryhealthcheck.co.uk/v1/dealers \
  -H "Authorization: Bearer $TOKEN" \
  --data-urlencode "page=1" \
  --data-urlencode "per_page=50"

Response 200 OK

{
  "data": [
    {
      "id": 247,
      "name": "Stellantis Manchester (Salford Quays)",
      "status": "active",
      "primary_contact": { "name": "Jane Smith", "email": "jane@stellantis-mcr.example.com", "phone": "+44 161 555 0123" },
      "shipping_address": { "line1": "12 Salford Quays", "city": "Manchester", "postcode": "M50 3AG", "country": "GB" },
      "units": [
        { "id": 412, "internal_reference": "BHC-UNIT-000412", "status": "active" }
      ],
      "activated_at": "2026-05-28T09:14:00Z",
      "created_at": "2026-05-27T11:47:21Z"
    }
  ],
  "meta": {
    "page": 1,
    "per_page": 50,
    "total": 1,
    "has_more": false,
    "request_id": "req_d2e4f6a8b0c1"
  }
}

Get a dealer

GET /v1/dealers/{id}

Full dealer details including units.

Required scope: read:dealers

{
  "data": {
    "id": 247,
    "name": "Stellantis Manchester (Salford Quays)",
    "legal_name": "Stellantis Manchester Ltd",
    "status": "active",
    "billing_mode": "manual",
    "parent_company_id": 198,
    "company_number": "12345678",
    "vat_number": "GB123456789",
    "primary_contact": {
      "name": "Jane Smith",
      "email": "jane@stellantis-mcr.example.com",
      "phone": "+44 161 555 0123"
    },
    "registered_address": {
      "line1": "12 Salford Quays",
      "line2": null,
      "city": "Manchester",
      "postcode": "M50 3AG",
      "country": "GB"
    },
    "shipping_address": {
      "line1": "12 Salford Quays",
      "line2": null,
      "city": "Manchester",
      "postcode": "M50 3AG",
      "country": "GB"
    },
    "units": [
      {
        "id": 412,
        "internal_reference": "BHC-UNIT-000412",
        "status": "active",
        "serial_number": "AV-2026-0412",
        "location_label": "Workshop",
        "shipping_address": { "line1": "12 Salford Quays", "line2": null, "city": "Manchester", "postcode": "M50 3AG" },
        "dispatched_at": "2026-05-28T08:00:00Z",
        "delivered_at": "2026-06-02T10:30:00Z",
        "activated_at": "2026-06-04T10:00:00Z"
      }
    ],
    "activated_at": "2026-05-28T09:14:00Z",
    "created_at": "2026-05-27T11:47:21Z",
    "updated_at": "2026-06-04T10:00:00Z"
  },
  "meta": { "request_id": "req_d2e4f6a8b0c1" }
}

Update a dealer

PATCH /v1/dealers/{id}

Update contact details and shipping address. Status changes are managed by BHC ops.

Required scope: write:dealers

curl -X PATCH https://api.batteryhealthcheck.co.uk/v1/dealers/247 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "primary_contact_phone": "+44 161 555 9999" }'

List tests for a dealer

GET /v1/tests?dealer_id={id}

List battery tests performed by a specific dealer. Use since for incremental polling. To list tests across all of your dealers, omit dealer_id.

Required scope: read:tests

Dealer-group credentials only. On a marketplace credential this endpoint is a lookup: vin or registration is required and a request without one returns 400 invalid_request. See Look up tests by VIN or registration.

Query parameters

ParamTypeDescription
dealer_idintegerFilter to a specific dealer in your tree
sinceISO 8601Only return tests with tested_at >= since
untilISO 8601Only return tests with tested_at <= until
pageinteger1-based page number. Default 1.
per_pageinteger1–100. Default 25.

Look up tests by VIN or registration

GET /v1/tests?vin={vin}
GET /v1/tests?registration={plate}

Find tests for a specific vehicle across all your dealers. The primary use case is dealer websites and listing platforms: your inventory page has a VIN or a number plate, you want the most recent battery test for that vehicle to embed on the listing.

Required scope: read:tests

This is the entry point for marketplace credentials, which must supply vin or registration on every call.

Query parameters

ParamTypeDescription
vinstringVehicle Identification Number. Full 17-char VIN performs an exact match; a 3–16 char prefix performs a prefix match.
registrationstringVehicle registration mark (number plate). Exact match only — there is no prefix search. Spaces, hyphens and case are ignored on both sides, so WP72FKH, WP72 FKH and wp72-fkh are equivalent.
dealer_idintegerRestrict to one dealer in your tree. Combines with the filters above.
pageinteger1-based page number. Default 1.
per_pageinteger1–100. Default 25. Results are ordered newest-first by tested_at.
Allowed characters. vin: digits and uppercase A–Z excluding I, O, Q. registration: letters, digits, spaces and hyphens, 2–20 characters once spaces and hyphens are stripped. Anything else returns 400 invalid_field.
Search by plate for convenience — but key your data on VIN. Both filters are supported and both are exact. The difference is that a VIN is permanent and a plate is not: registrations transfer between vehicles (cherished/private plates), and a car can be re-plated on import or re-registration. A VIN identifies one physical vehicle for its whole life.

So: use registration when a plate is what you have — a customer-facing lookup box, a stock feed that carries plates but not VINs. Use vin whenever you have one, and store vehicle.vin as the join key in your own database rather than the plate. If you cache results against a plate, you will eventually attach one vehicle's battery report to a different vehicle.

Example — by VIN

curl -G https://api.batteryhealthcheck.co.uk/v1/tests \
  -H "Authorization: Bearer $TOKEN" \
  --data-urlencode "vin=VR3UHZKXZNT123456" \
  --data-urlencode "per_page=1"

Example — by registration

curl -G https://api.batteryhealthcheck.co.uk/v1/tests \
  -H "Authorization: Bearer $TOKEN" \
  --data-urlencode "registration=WP72 FKH" \
  --data-urlencode "per_page=1"

Handling more than one result

Both filters return a paginated array, not a single object — ordered newest-first by tested_at. There are three reasons you may get more than one row, and they need different handling:

WhyWhat you seeWhat to do
The car was retested (the common case) Several rows, same vehicle.vin, different tested_at The first row is the current one. Add per_page=1 and read data[0].
The plate was transferred (registration only) Several rows with different vehicle.vin values These are genuinely different vehicles that have worn the same plate. Do not merge them. Disambiguate on vehicle.vin, or re-query by VIN.
Two dealers tested the same car Several rows, same VIN, different dealer_id Normal in a group. Filter with dealer_id if you want one branch's view.
Always check the VIN on a plate lookup. If registration returns rows whose vehicle.vin values differ, you are looking at more than one vehicle — taking data[0] blindly will publish the wrong car's battery health. A one-line guard:
rows = get("/v1/tests", registration=plate)["data"]
vins = {r["vehicle"]["vin"] for r in rows if r["vehicle"]["vin"]}

if len(vins) > 1:
    # Plate has transferred. Ask for a VIN rather than guessing.
    raise Ambiguous(plate, vins)

An unknown plate or VIN is not an error — it returns 200 with "data": [] and "total": 0. Treat empty as “no test on record”, never as a failure.

Plate coverage is not universal. Battery tests arrive from the AVILOO box identified by VIN only — the plate is added afterwards, either by the dealer entering it in their portal or by our VIN → registration lookup. Most tests carry one, but a test with no plate on record has vehicle.registration: null and cannot be found by registration at all. It is still findable by vin. This is the other reason to prefer VIN where you have one: vin can match every test we hold, registration can only match the plated ones.

Response 200 OK

{
  "data": [
    {
      "id": 9182,
      "internal_reference": "BHC-TEST-009182",
      "dealer_id": 247,
      "unit_id": 412,
      "status": "completed",
      "vehicle": {
        "registration": "AB23 CDE",
        "vin": "VR3UHZKXZNT123456",
        "make": "Peugeot",
        "model": "e-208",
        "year": 2022,
        "mileage_km": 29644
      },
      "battery": {
        "soh_percent": 91.4,
        "capacity_kwh": 45.7,
        "nominal_kwh": 50.0,
        "estimated_range_miles": 195,
        "cell_count": 96,
        "cell_variance": 0.012
      },
      "tested_at": "2026-05-26T14:22:08Z",
      "results_received_at": "2026-05-26T14:25:09Z",
      "result_status": "final",
      "certificate_number": "BHC-CERT-2026-000183",
      "certificate_available": true,
      "preview_available": true,
      "created_at": "2026-05-26T14:22:08Z",
      "updated_at": "2026-05-26T14:25:09Z"
    }
  ],
  "meta": {
    "page": 1,
    "per_page": 1,
    "total": 1,
    "has_more": false,
    "request_id": "req_91c2d4e6f8a0"
  }
}

Empty data array means no tests exist for that vehicle — either it hasn't been tested, or it was tested at a dealer outside your group.

Abbreviated above. Each test object also carries a diagnostics object — the full measurement record, the per-subsystem check results — plus top-level vehicle_supported (the beta-model flag) and warnings. Both are omitted from the example for length; see The diagnostics block and Warnings.

Get a test

GET /v1/tests/{id}

Full JSON for a single test, including all vehicle and battery details.

Required scope: read:tests


The diagnostics block

Every test object — from GET /v1/tests, GET /v1/tests/{id} and the webhook payload alike — carries a diagnostics object alongside vehicle and battery. It is the normalised form of the full AVILOO measurement record: the same data the certificate is generated from, including the per-subsystem check results and the vehicle support flag.

Looking for the “beta / not yet supported” marker? Use the top-level vehicle_supported on the test object. It carries the same meaning as diagnostics.vehicle_supported below, but it survives a null diagnostics block and is reconciled against every signal AVILOO gives us — so it is guaranteed to agree with the BETA badge the dealer sees in their portal. Prefer it; read the nested one only if you are already working inside the diagnostics block. AVILOO’s vehicle coverage is continually expanding, and models still in validation are tested on a beta basis:
  • true — fully supported model. All derived figures are validated for this vehicle.
  • falsebeta / not yet fully supported. The test still runs and a certificate is still issued, but AVILOO marks the certificate accordingly, and derived figures (estimated range, cell count, and occasionally SoH itself) may be missing or outside the expected range. The dealer portal shows a BETA badge on these tests.
  • null — unknown, because no measurement record is stored for this test (see below).
If you publish battery data on a public website, this is the field to branch on.

Fields

FieldTypeDescription
vehicle_supportedboolean | nullfalse = beta / not yet fully supported model. Mirrored to the top level of the test object, which is the copy you should read — see the callout above.
overall_battery_statusstring | nullAVILOO’s headline verdict: OK, WARNING, NOT_CONCLUSIVE or SAFETY_ISSUE.
battery_checksobjectPer-subsystem results, each OK / WARNING / NOT_CONCLUSIVE / SAFETY_ISSUE: battery_management_system, battery_sensors, battery_pack_parameters, battery_cell_voltages, vehicle_communication.
sensor_checksobjectSensor-level results: voltage_sensor, current_sensor, temperature_sensors, cell_voltage_sensors.
energy_kwhobjectGross / net / usable energy, both *_nominal_new (when the battery was new) and *_current (measured now). All kWh.
rangeobjecttypical_* and personal_* figures are in miles; wltp_*_km are {from, to} pairs in km. Any of these may be null.
measurementsobjectcell_temperature_c and cell_voltage_v as {min, max, delta, status}; plus pack_voltage_v, average_current_a, mileage_km.
bmsobjectWhat the car’s own battery management system reported: soc_percent and soh_percent, plus the numeric soc_calculation_accuracy / soh_calculation_accuracy figures AVILOO derives for them. The BMS SoH is the car’s self-assessment — useful as a cross-check against AVILOO’s independently measured battery.soh_percent, but it is the latter that appears on the certificate.

Example

{
  "data": {
    "id": 9182,
    "status": "completed",
    "vehicle": { "vin": "VR3UHZKXZNT123456", "make": "Peugeot", "model": "e-208" },
    "battery": { "soh_percent": 91.4, "capacity_kwh": 45.7 },
    "vehicle_supported": true,
    "warnings": [],
    "diagnostics": {
      "vehicle_supported": true,
      "overall_battery_status": "OK",
      "battery_checks": {
        "battery_management_system": "OK",
        "battery_sensors": "OK",
        "battery_pack_parameters": "OK",
        "battery_cell_voltages": "OK",
        "vehicle_communication": "OK"
      },
      "sensor_checks": {
        "voltage_sensor": "OK",
        "current_sensor": "OK",
        "temperature_sensors": "OK",
        "cell_voltage_sensors": "OK"
      },
      "energy_kwh": {
        "gross_nominal_new": 50.0,  "gross_current": 45.7,
        "net_nominal_new": 46.3,    "net_current": 42.1,
        "usable_nominal_new": 45.0, "usable_current": 41.0
      },
      "range": {
        "typical_new_miles": 214.0,   "typical_current_miles": 195.4,
        "personal_new_miles": null,   "personal_current_miles": null,
        "wltp_new_km":     { "from": 340.0, "to": 362.0 },
        "wltp_current_km": { "from": 310.8, "to": 330.9 }
      },
      "measurements": {
        "cell_temperature_c": { "min": 17.0, "max": 17.4, "delta": 0.4, "status": "OK" },
        "cell_voltage_v":     { "min": 4.146, "max": 4.159, "delta": 0.013, "status": "OK" },
        "pack_voltage_v": 398.7,
        "average_current_a": 1.2,
        "mileage_km": 29644
      },
      "bms": {
        "soc_percent": 96.0,
        "soh_percent": 92.1,
        "soc_calculation_accuracy": 1.94,
        "soh_calculation_accuracy": 0.95
      }
    }
  }
}
diagnostics can be null. The block is built from the detailed AVILOO measurement record, which we fetch and store when the test completes. A small number of older tests were ingested before we captured that record and have never been backfilled — for those, diagnostics is null in its entirety. Treat the whole block as optional: test.diagnostics?.vehicle_supported, not test.diagnostics.vehicle_supported.

Warnings

Alongside diagnostics, each test object carries a top-level warnings array — the conditions AVILOO flagged while evaluating the test, as enum strings. Where vehicle_supported tells you the model is still in validation, warnings tells you what was unusual about this particular test.

{
  "data": {
    "id": 9182,
    "battery": { "soh_percent": 102.9 },
    "vehicle_supported": false,
    "warnings": ["SOH_GREATER_THAN_100"],
    "diagnostics": { ... }
  }
}

An empty array means AVILOO raised no warnings. null means we hold no evaluation record for the test and therefore can't say either way — treat it as unknown, not as “clean”.

Warning types

ValueMeaning
SOH_GREATER_THAN_100Battery evaluated as having more than 100% of its specified capacity. Usually seen on models still in validation — check vehicle_supported.
UNCLEAR_MODELVehicle data was ambiguous about the exact model. Verify the model shown on the certificate.
MISSING_SIGNALAt least one required signal could not be read from the vehicle.
IMPLAUSIBLE_SIGNALThe vehicle reported a value outside the plausible range (e.g. voltage > 2000 V).
NOT_ENOUGH_DATAThe vehicle did not deliver enough data for at least one required signal type.
NO_RELAXED_PHASESNo phase without load on the battery could be detected during the test.
NO_GOOD_RELAXED_PHASESA relaxed phase was detected but its quality was too low to use.
BATTERY_TEMP_TOO_LOWBattery was below the recommended operating window during the test.
BATTERY_TEMP_TOO_HIGHBattery was above the recommended operating window during the test.
BATTERY_TEMP_DELTA_TOO_LARGEDiscrepancy in battery temperature across the pack — potential cooling-system defect.
BATTERY_TEMP_CRITICALLY_HIGHTemperature outside the maximum operating window. A safety concern.
BMS_SOC_IMPLAUSIBLEThe car's own state-of-charge reading is implausible — the BMS may need recalibrating.
Treat this list as open-ended. AVILOO adds warning types as their evaluation improves, and we pass them straight through — so you will eventually see values that aren't in the table above. Match on the specific values you care about and fall through to a generic “see certificate for details” for anything unrecognised. Do not map this to a closed enum that throws on an unknown value.

A practical use: warnings let you explain an anomalous reading rather than inferring it. An SoH of 102.9% is far more actionable when it arrives with ["SOH_GREATER_THAN_100"] attached.



Can you publish this number? result_status

Every test object carries a top-level result_status. It answers one question directly: how much weight can this test’s numbers carry? If you publish battery data on a public website, this is the first field to branch on — it is cheaper to read than reasoning across status, soh_percent, vehicle_supported and warnings yourself.

ValueMeaningWhat to do
"final" A usable state of health. The normal case. Publish it.
"provisional" A number exists but is not safe to quote — a state of health above 100%, which means AVILOO’s reference data for the model isn’t final. Do not publish the figure as a headline. Show the certificate image instead, or display it with an explicit caveat.
"inconclusive" AVILOO ran the test and could not determine battery health. There will never be a number for this test — it is finished, not pending. Show nothing, or “battery health not determined”. Do not render a gauge, a zero, or a loading state.
null Not answerable: the test is still in flight, or it is an older record with no evaluation data stored. Treat as unknown. Never assume "final" from null.
Why this field exists. An inconclusive test previously serialised as status: "completed" with soh_percent: null — indistinguishable from a test whose result was still landing. Integrations null-checked, rendered a blank card and waited for a number that was never coming. result_status names the state so you don’t have to infer it.
It is orthogonal to vehicle_supported. vehicle_supported describes the model (still in AVILOO validation); result_status describes this test’s result. A beta model routinely returns a perfectly usable "final" reading, and a fully-supported model can still return "inconclusive". Read both — neither substitutes for the other.

The one branch that covers all four

test = get_test(test_id)

match test.get("result_status"):
    case "final":
        # Safe to publish. Still check vehicle_supported — a beta
        # model's derived figures (range, cell count) may be missing.
        render_soh(test["battery"]["soh_percent"])
    case "provisional":
        # Number exists but isn't quotable. Certificate image only.
        render_certificate_image(test)
    case "inconclusive":
        # Finished, no verdict. Never coming. Don't show a spinner.
        render_note("Battery health could not be determined")
    case _:
        # null — still processing, or an old record. Poll or omit.
        render_nothing()
{
  "data": {
    "id": 9184,
    "status": "completed",
    "battery": { "soh_percent": null },
    "result_status": "inconclusive",
    "vehicle_supported": true,
    "warnings": []
  }
}

Nulls & beta vehicles

The sample response pack includes a representative response for each of the cases below. Samples are illustrative; the OpenAPI specification and the data dictionary define the supported API contract.

We never omit keys — a value we don’t have is serialised as null rather than dropped, so the schema is stable. That means every consumer must null-check before rendering. These are the fields that are null often enough to matter in production:

FieldNull when
battery.estimated_range_milesAVILOO has no validated range model for the vehicle. Common on vehicle_supported: false models.
battery.cell_countNot reported for most vehicles. Do not build a UI that depends on it.
battery.nominal_kwh, battery.capacity_kwh, battery.cell_varianceSourced from the detailed measurement record — null on the same older tests where diagnostics is null.
certificate_numberThe certificate PDF exists but AVILOO has not assigned a printed reference. Check certificate_available (a boolean) to decide whether to offer a download — not certificate_number.
vehicle.registrationThe test was performed against a VIN with no plate recorded — tests arrive from the box VIN-only, and the plate is added afterwards. Such a test cannot be found by ?registration=; it is still findable by vin. You may search by plate, but store vehicle.vin as your join key — plates transfer between vehicles, VINs don't.
diagnosticsSee the callout above.
vehicle_supportedNeither of AVILOO's support signals is stored for the test. Note this is not the same as diagnostics being null — the top-level flag is answerable from either source, so it is populated in cases where the diagnostics block isn't.
warningsNo evaluation record is held for the test. Note the distinction from [], which positively means “no warnings raised”. See Warnings.

Handling a beta vehicle

A worked example. A Honda e tested in July 2026 returned a state of health of 102.9% with a null estimated range, and warnings: ["SOH_GREATER_THAN_100"] — all artefacts of the model still being in AVILOO validation. The test is genuine and the certificate is valid; what you should not do is publish “102.9% battery health, range unknown” on a listing page without a second thought. Branch on the flag:

test = get_test(test_id)
warnings = test.get("warnings") or []

if test.get("vehicle_supported") is False or "SOH_GREATER_THAN_100" in warnings:
    # Beta model, or an implausible reading. The certificate is valid;
    # the derived figures may not be. Show the image, not your own numbers.
    render_certificate_only(test)
elif test["battery"]["soh_percent"] is not None:
    render_full_summary(test)
else:
    render_nothing()

A simple belt-and-braces check that catches the same class of result without reading either field: treat any soh_percent above 100, or any missing estimated_range_miles, as a signal to fall back to the certificate image rather than your own numeric rendering.


Two certificate artifacts — pick the right one:
  • /certificate (PDF)contains the full VIN. For your own records, customer hand-off, internal sales tools. Do NOT publish on a public website.
  • /preview (JPEG)VIN-redacted, single-page summary branded by Aviloo as “Battery Certificate Preview”. Certificate number is also masked. This is the one to embed on a public car listing page.

Get the full certificate (PDF) — contains VIN, for records

GET /v1/tests/{id}/certificate

Returns a 1-hour signed URL for the full multi-page Aviloo PDF certificate.

The PDF prints the full VIN on it. Use it for sales-quality printable copies, customer hand-off, attaching to a vehicle's permanent record, or anywhere only authorised users will see the file. Don't surface this URL on a public web page — use /preview below for that.

Required scope: read:certificates

Response 200 OK — cached

{
  "data": {
    "url": "https://files.batteryhealthcheck.co.uk/certificates/.../X-Amz-Signature=...",
    "expires_at": "2026-05-27T12:47:00Z",
    "content_type": "application/pdf",
    "certificate_number": "BHC-CERT-2026-000183"
  },
  "meta": { "request_id": "req_e2a4f6c8b1d3" }
}

Response 409 Conflict — not cached yet

HTTP/1.1 409 Conflict
Content-Type: application/json

{
  "error": {
    "code": "certificate_not_ready",
    "message": "Certificate is not yet available — retry shortly",
    "request_id": "req_e2a4f6c8b1d3",
    "retry_after_seconds": 30
  }
}

Get the public-facing certificate image (JPEG)

GET /v1/tests/{id}/preview

Returns a 1-hour signed URL for a JPEG image — the Aviloo-branded “Battery Certificate Preview”.

Single-page summary showing State of Health %, range, vehicle make/model, mileage, test date, and the testing dealer. The VIN is not on the image, and the certificate number is masked (e.g. DD783123-96F3-4F0B-****-************). A QR code on the image links back to Aviloo's hosted validation page.

Designed for public display: embed it directly on car listing pages as an <img>. No personally-identifying vehicle data leaks to the public.

Required scope: read:certificates

Response 200 OK

{
  "data": {
    "url": "https://files.batteryhealthcheck.co.uk/previews/.../X-Amz-Signature=...",
    "expires_at": "2026-05-27T12:47:00Z",
    "content_type": "image/jpeg"
  },
  "meta": { "request_id": "req_f3g5h7i9k1m3" }
}
Rule of thumb
  • Embedding on a public car listing page → /preview (JPEG)
  • Customer download / sales record / internal tool → /certificate (PDF)
  • Raw values for your own UI components → GET /tests/{id} JSON

Manage webhook subscriptions

See Webhooks below for event payloads and signing. The endpoints to manage them:

POST /v1/webhooks Register a webhook
GET /v1/webhooks List your webhooks
DELETE /v1/webhooks/{id} Disable a webhook
POST /v1/webhooks/{id}/test Send a test event
POST /v1/webhooks/{id}/replay Replay a failed delivery

Required scope: manage:webhooks

Prove your endpoint the day you register it. Registering returns 201 whether or not anything will ever reach you — a subscription can look perfectly healthy and still never fire. POST /v1/webhooks/{id}/test sends a real, signed delivery immediately, so you can confirm connectivity, TLS and your signature verification before you depend on it.
curl -X POST https://api.batteryhealthcheck.co.uk/v1/webhooks/42/test \
  -H "Authorization: Bearer $TOKEN"

It works even if the endpoint is subscribed to nothing useful yet — which is exactly the case worth catching. Rate limited to 10/minute.

What the test event looks like

It is not a synthetic bhc.test.completed. It has its own event type and carries no vehicle or battery data at all, so it can never be mistaken for a real result and published:

{
  "event": "bhc.webhook.test",
  "data": {
    "message": "This is a test event from Battery Health Check. …",
    "test_event": true,
    "endpoint_id": 42,
    "sent_at": "2026-08-17T13:22:14Z",
    "triggered_by": "partner_api"
  }
}
Handling it. Return 2xx and ignore the body — that alone proves the pipe. bhc.webhook.test is not subscribable: you cannot register for it, and it is never fanned out by a real domain event, so it only ever arrives because you asked for it. If your handler switches on event, let unknown types fall through to a 200 rather than erroring — that is the correct behaviour for every future event type too.
curl -X POST https://api.batteryhealthcheck.co.uk/v1/webhooks \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.stellantis.example.com/bhc/v1",
    "events": ["bhc.test.completed", "bhc.dealer.activated"]
  }'

The response includes a secretstore it immediately. We can rotate it but never re-display it.


Worked Examples

Three end-to-end scenarios covering the most common integrations. Every step is runnable; the responses match what you'd see in production.

A. Adding a new dealer branch

Scenario: Stellantis is opening a new branch in Manchester (Salford Quays). HQ wants to spin up the dealer record from their own dealer-management system instead of emailing BHC ops.

Step 1. Get a token

TOKEN=$(curl -s -X POST https://api.batteryhealthcheck.co.uk/v1/oauth/token \
  -d "grant_type=client_credentials" \
  -d "client_id=part_a1b2c3d4e5f6" \
  -d "client_secret=$BHC_SECRET" \
  -d "scope=write:dealers manage:webhooks" \
  | jq -r '.access_token')

Step 2. Create the dealer

curl -X POST https://api.batteryhealthcheck.co.uk/v1/dealers \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: stl-mcr-04-create-1" \
  -d '{
    "name": "Stellantis Manchester (Salford Quays)",
    "primary_contact_name": "Jane Smith",
    "primary_contact_email": "jane@stellantis-mcr.example.com",
    "primary_contact_phone": "+44 161 555 0123",
    "shipping_address": {
      "line1": "12 Salford Quays",
      "city": "Manchester",
      "postcode": "M50 3AG",
      "country": "GB"
    },
    "num_boxes": 1
  }'

Response (truncated):

{
  "data": {
    "id": 247,
    "status": "active",
    "units": [{ "id": 412, "status": "ordered" }],
    "activated_at": "2026-05-27T11:47:21Z"
  },
  "meta": { "request_id": "req_a9f2e1c4b7d8" }
}

Step 3. Register a webhook for test results

Instead of polling, subscribe to bhc.test.completed so your CRM knows the moment a new certificate is ready:

curl -X POST https://api.batteryhealthcheck.co.uk/v1/webhooks \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://crm.stellantis.example.com/bhc/test-completed",
    "events": ["bhc.dealer.activated", "bhc.test.completed", "bhc.test.failed"]
  }'

Step 4. What happens next

  1. The dealer owner gets the password-setup email within minutes.
  2. The Aviloo box ships in ~5 working days.
  3. The new dealer is created in the active state immediately — we fire bhc.dealer.activated on creation so your downstream systems can sync.
  4. When the box arrives, the dealer activates it in the BHC portal. The unit moves orderedshippedactive. Re-fetch the dealer with GET /v1/dealers/{id} to inspect the latest unit state.
  5. The dealer can start running tests. Every completed test fires bhc.test.completed; if a test couldn't complete, we fire bhc.test.failed.
Total elapsed time from API call to fully operational dealer: ~5 working days (limited by Aviloo box shipping). The API call itself completes in ~200ms. Compare with the old flow: email back-and-forth, manual provisioning, multiple touchpoints with BHC ops — typically 2–5 business days before the box ships.

B. Requesting additional Aviloo boxes

Scenario: The Manchester branch is doing more volume than expected. They need a second Aviloo box for their second service bay.

Step 1. Find the dealer ID

List your dealers and pick the one you want (cache the ID alongside your own DMS site code so you don't need this lookup repeatedly):

curl -G https://api.batteryhealthcheck.co.uk/v1/dealers \
  -H "Authorization: Bearer $TOKEN" \
  --data-urlencode "page=1" \
  --data-urlencode "per_page=100"

Step 2. Request the additional box

curl -X POST https://api.batteryhealthcheck.co.uk/v1/dealers/247/units \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: stl-mcr-04-box2" \
  -d '{
    "quantity": 1,
    "location_label": "Service Bay 2"
  }'

Response:

{
  "data": {
    "units": [
      {
        "id": 502,
        "internal_reference": "BHC-UNIT-000502",
        "status": "ordered",
        "location_label": "Service Bay 2",
        "shipping_address": { "line1": "12 Salford Quays", "city": "Manchester", "postcode": "M50 3AG" },
        "dispatched_at": null,
        "delivered_at": null,
        "activated_at": null
      }
    ]
  },
  "meta": { "request_id": "req_e1f3g5h7i9k1" }
}

Step 3. Poll the dealer to track unit state

There is no dedicated unit-activation webhook. Re-fetch the dealer (the response embeds the full units array including each unit's status and activated_at) when you want to inspect lifecycle changes:

curl -H "Authorization: Bearer $TOKEN" \
  https://api.batteryhealthcheck.co.uk/v1/dealers/247

C. Embedding battery test data on a car listing page

Scenario: Your dealer-website team wants to show battery health on every used-EV listing. A customer browsing a 2022 Peugeot e-208 should see the SoH%, estimated range, and a downloadable certificate without having to call the dealer.

You have the VIN on every listing (from your inventory feed). You don't know which dealer ran the test — could be Manchester, could be any of your branches. The lookup endpoint solves that.

Feed carries plates, not VINs? Swap vin= for registration= in Step 1 — everything downstream is identical. Before you do, read the plate-vs-VIN guidance: a plate lookup can return two different vehicles if the registration has been transferred, and tests with no plate on record won't be found at all. For a listing site that renders unattended, the VIN is the safer key.
Use a read-only key. This whole flow needs only read:tests and read:certificates. Create a Read-only (website & certificates) key from the dealer portal (Getting access) and give that to your web team — if it ever leaks, it can't change your data or order hardware.

Step 1. Look up the most recent test by VIN

On your listing page's server-side render (or via your inventory backend):

import requests

def get_battery_data_for_vin(vin):
    token = get_access_token()  # cached, see auth section
    resp = requests.get(
        "https://api.batteryhealthcheck.co.uk/v1/tests",
        headers={"Authorization": f"Bearer {token}"},
        params={"vin": vin, "page": 1, "per_page": 1},
        timeout=5,
    )
    data = resp.json()["data"]
    return data[0] if data else None  # None if no test exists
async function getBatteryDataForVin(vin) {
    const token = await getAccessToken();
    const url = new URL("https://api.batteryhealthcheck.co.uk/v1/tests");
    url.searchParams.set("vin", vin);
    url.searchParams.set("page", "1");
    url.searchParams.set("per_page", "1");
    const resp = await fetch(url, {
        headers: { Authorization: `Bearer ${token}` }
    });
    const { data } = await resp.json();
    return data[0] || null;
}
<?php
function get_battery_data_for_vin($vin) {
    $token = get_access_token();
    $ch = curl_init();
    curl_setopt_array($ch, [
        CURLOPT_URL => "https://api.batteryhealthcheck.co.uk/v1/tests?"
            . http_build_query(["vin" => $vin, "page" => 1, "per_page" => 1]),
        CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"],
        CURLOPT_RETURNTRANSFER => true,
    ]);
    $body = json_decode(curl_exec($ch), true);
    curl_close($ch);
    return $body["data"][0] ?? null;
}

Step 2. Choose how to display it

You now have a test record. Three useful ways to surface it on the listing:

Option A — Show key values as text

Best for clean, brand-consistent listings. Pull the values out of the JSON and write your own HTML — guarding each field, since any of them can legitimately be null (see Nulls & beta vehicles):

<div class="battery-summary">
  <h3>Battery Health Check</h3>
  <dl>
    {% if test.battery.soh_percent %}
    <dt>State of Health</dt>
      <dd>{{ test.battery.soh_percent }}%</dd>
    {% endif %}
    {% if test.battery.estimated_range_miles %}
    <dt>Estimated range</dt>
      <dd>{{ test.battery.estimated_range_miles }} miles</dd>
    {% endif %}
    <dt>Tested</dt>
      <dd>{{ test.tested_at | date }}</dd>
  </dl>
  {% if test.certificate_number %}
  <p class="ref">Certificate: {{ test.certificate_number }}</p>
  {% endif %}
</div>
Two checks before you render your own numbers.

1. test.result_status — only "final" is safe to publish as a headline figure. "provisional" means a number exists but isn't quotable; "inconclusive" means there is no number and never will be; null means not answerable yet. Fall back to Option B (the certificate image) or render nothing. See Can you publish this number?

2. test.vehicle_supported — if false, AVILOO is still validating that model. The certificate is valid and safe to show, but derived figures (estimated range, cell count) may be missing. The test.warnings array tells you what was flagged. Prefer Option B for those vehicles. See The diagnostics block and Warnings.

Option B — Embed the abbreviated certificate (JPEG)

Best when you want the official BHC-branded image on the listing. One additional call:

def get_preview_url(test_id, token):
    resp = requests.get(
        f"https://api.batteryhealthcheck.co.uk/v1/tests/{test_id}/preview",
        headers={"Authorization": f"Bearer {token}"},
        timeout=5,
    )
    return resp.json()["data"]["url"]

Then in the listing template:

<img src="{{ preview_url }}"
     alt="Battery Health Check certificate {{ test.certificate_number }}"
     loading="lazy"
     style="max-width: 400px;">
Don't hot-link the signed URL. It expires in 1 hour. Either fetch + cache the JPEG on your CDN, or refresh the URL each page load. Most CDNs will accept the signed URL for a one-time pull.

Option C — Offer the full PDF as a download

Best for “Download full report” buttons. Generate the signed URL on demand when the user clicks:

<!-- Button calls your backend, which then calls BHC -->
<a href="/api/internal/battery-cert/{{ test.id }}">
  Download full battery certificate (PDF)
</a>

Your backend:

@app.route("/api/internal/battery-cert/<int:test_id>")
def proxy_certificate(test_id):
    token = get_access_token()
    resp = requests.get(
        f"https://api.batteryhealthcheck.co.uk/v1/tests/{test_id}/certificate",
        headers={"Authorization": f"Bearer {token}"},
        timeout=5,
    )
    pdf_url = resp.json()["data"]["url"]
    return redirect(pdf_url, code=302)  # short-lived signed URL

Step 3. Handle the “no test exists” case

Not every car on your forecourt has been tested yet. Decide upfront what your listing shows when get_battery_data_for_vin() returns None:

Putting it together

A typical render-time flow on a car listing page:

def render_listing(vehicle):
    test = get_battery_data_for_vin(vehicle.vin)

    context = {"vehicle": vehicle, "battery_section": None}

    if test:
        token = get_access_token()
        preview_url = get_preview_url(test["id"], token)
        # Only a "final" result is quotable. "provisional" has a number
        # that isn't safe to headline, "inconclusive" has none at all,
        # null means not answerable yet — all three fall back to the
        # certificate image. Beta models also get image-only treatment.
        quotable = (test.get("result_status") == "final"
                    and test.get("vehicle_supported") is not False
                    and "SOH_GREATER_THAN_100" not in (test.get("warnings") or []))
        context["battery_section"] = {
            "soh_percent": test["battery"]["soh_percent"] if quotable else None,
            "range_miles": test["battery"]["estimated_range_miles"] if quotable else None,
            "tested_at": test["tested_at"],
            "certificate_number": test["certificate_number"],  # may be null
            "preview_url": preview_url,
            "full_cert_link": f"/api/internal/battery-cert/{test['id']}",
        }

    return render("listing.html", **context)
Performance tip. Two API calls per listing page-render isn't great if you have thousands of listings. Cache the test JSON by VIN (24-hour TTL is fine — tests don't change after they complete). Cache the JPEG itself on your CDN keyed by certificate_number. With those two caches in place, your listing render is fast and tolerant of brief BHC outages.

Webhooks

Subscribe to events and we'll push them to your endpoint within seconds. Signed using the Standard Webhooks scheme (HMAC-SHA256, base64-encoded). Failed deliveries are retried with exponential backoff, then auto-disabled if a single endpoint accumulates 50 consecutive failures.

Event catalog (v1)

EventFires whenTypical use
bhc.dealer.activated A dealer is provisioned through the partner API (partner-managed dealers are created in the active state) Update your CRM, trigger your own welcome flow
bhc.test.completed A test result arrives, certificate is rendered, status moves to completed Pull the certificate, attach to the vehicle record, notify the customer
bhc.test.failed A test was started but couldn't complete (interrupted, cancelled, hardware fault) Refund the customer if you charged up-front; schedule a retest
No box-activation event today. v1 does not emit a separate event when a physical Aviloo box is activated — only the dealer-level bhc.dealer.activated event exists. To track individual unit lifecycle changes, re-fetch the dealer with GET /v1/dealers/{id} and inspect each unit's status and activated_at.
Marketplace credentials (see Marketplace access) can subscribe to bhc.test.completed and bhc.test.failed only, and their deliveries carry a slim data.dealer object of {id, name} in place of the full dealer record shown below. Everything else in the envelope — signing, retries, replay, the data.test object — is identical.

Event envelope

{
  "event": "bhc.test.completed",
  "data": {
    "test": {
      "id": 9182,
      "internal_reference": "BHC-TEST-009182",
      "dealer_id": 247,
      "unit_id": 412,
      "status": "completed",
      "vehicle": {
        "registration": "AB23 CDE",
        "vin": "VR3UHZKXZNT123456",
        "make": "Peugeot",
        "model": "e-208",
        "year": 2022,
        "mileage_km": 29644
      },
      "battery": {
        "soh_percent": 91.4,
        "capacity_kwh": 45.7,
        "nominal_kwh": 50.0,
        "estimated_range_miles": 195,
        "cell_count": 96,
        "cell_variance": 0.012
      },
      "tested_at": "2026-05-26T14:22:08Z",
      "results_received_at": "2026-05-26T14:25:09Z",
      "certificate_number": "BHC-CERT-2026-000183",
      "certificate_available": true,
      "preview_available": true,
      "dealer": { "id": 247, "name": "Stellantis Manchester (Salford Quays)" },
      "result_status": "final",
      "vehicle_supported": true,
      "warnings": [],
      "diagnostics": { ... full measurement record ... }
    },
    "dealer": { ... full dealer object ... }
  }
}

The test object in the payload is byte-for-byte the same shape returned by GET /v1/tests/{id}, diagnostics and warnings included — so a beta vehicle (vehicle_supported: false) or a flagged reading can be detected at delivery time, without a follow-up call.

The envelope has exactly two top-level keys: event (the event type string) and data (the payload). The unique event identifier for idempotency is delivered in the webhook-id HTTP header — not as a field inside the JSON body. Persist that header value when you record the event, and reject re-deliveries with the same webhook-id.

Signature scheme (Standard Webhooks)

We implement the Standard Webhooks spec. If your stack already has a Stripe/AVILOO/Svix-style verifier, you can reuse it — the on-the-wire format is identical, only the secret needs to change.

Headers we send

HeaderDescription
webhook-idUnique identifier for this delivery (URL-safe base64, ~22 chars). Use this for idempotency.
webhook-timestampUnix epoch seconds (string).
webhook-signatureA space-separated list of versioned signatures. Format: v1,<base64-hmac> — multiple values may appear during secret rotation.
content-typeapplication/json
User-AgentTonicDesk-BHC-PartnerWebhook/1.0

How we compute the signature

The secret you receive at registration is base64-encoded behind a whsec_ prefix (e.g. whsec_8f9a3b2c...). Decode the base64 portion to recover the raw 32-byte key, then:

  1. Build the signed message: <webhook-id> + "." + <webhook-timestamp> + "." + <raw body bytes>.
  2. Compute HMAC-SHA256(decoded_secret, signed_message).
  3. Base64-encode the digest (standard alphabet, with padding).
  4. Send the header value as v1,<base64>. During secret rotation we send two signatures separated by a space; accept the delivery if any one of them verifies.

Verifying signatures

import base64, hmac, hashlib, time
from flask import request, abort

SECRET = "whsec_8f9a3b2c..."   # exact value from /v1/webhooks creation
TOLERANCE = 300                # 5 min replay window

def _key_bytes(secret):
    if not secret.startswith("whsec_"):
        raise ValueError("secret must start with whsec_")
    return base64.b64decode(secret[len("whsec_"):])

@app.route("/webhooks/bhc", methods=["POST"])
def bhc_webhook():
    msg_id   = request.headers.get("webhook-id", "")
    ts_str   = request.headers.get("webhook-timestamp", "0")
    sig_hdr  = request.headers.get("webhook-signature", "")
    body     = request.get_data()  # RAW bytes — never re-serialise

    try:
        ts = int(ts_str)
    except ValueError:
        abort(400, "bad timestamp")
    if abs(time.time() - ts) > TOLERANCE:
        abort(400, "stale timestamp")

    signed = f"{msg_id}.{ts}.".encode() + body
    expected = "v1," + base64.b64encode(
        hmac.new(_key_bytes(SECRET), signed, hashlib.sha256).digest()
    ).decode()

    # Header may contain multiple space-separated signatures during rotation
    if not any(hmac.compare_digest(expected, part)
               for part in sig_hdr.split(" ")):
        abort(400, "invalid signature")

    event = request.get_json()
    handle_event(msg_id, event)  # idempotent on webhook-id
    return "", 200
const crypto = require("crypto");

const SECRET = "whsec_8f9a3b2c...";
const TOLERANCE = 300;

function keyBytes(secret) {
    if (!secret.startsWith("whsec_")) {
        throw new Error("secret must start with whsec_");
    }
    return Buffer.from(secret.slice("whsec_".length), "base64");
}

app.post("/webhooks/bhc",
    express.raw({ type: "application/json" }),
    (req, res) => {
        const msgId  = req.headers["webhook-id"]        || "";
        const tsStr  = req.headers["webhook-timestamp"] || "0";
        const sigHdr = req.headers["webhook-signature"] || "";
        const body   = req.body;  # Buffer of RAW bytes

        const ts = parseInt(tsStr, 10);
        if (!Number.isFinite(ts) ||
            Math.abs(Date.now()/1000 - ts) > TOLERANCE) {
            return res.status(400).send("stale timestamp");
        }

        const signed = Buffer.concat([
            Buffer.from(`${msgId}.${ts}.`),
            body,
        ]);
        const expected = "v1," + crypto
            .createHmac("sha256", keyBytes(SECRET))
            .update(signed).digest("base64");

        const ok = sigHdr.split(" ").some(part => {
            const a = Buffer.from(expected);
            const b = Buffer.from(part);
            return a.length === b.length && crypto.timingSafeEqual(a, b);
        });
        if (!ok) return res.status(400).send("invalid signature");

        handleEvent(msgId, JSON.parse(body));  # idempotent on webhook-id
        res.status(200).send();
    }
);
<?php
$secret = "whsec_8f9a3b2c...";
$tolerance = 300;

$msgId  = $_SERVER["HTTP_WEBHOOK_ID"]        ?? "";
$tsStr  = $_SERVER["HTTP_WEBHOOK_TIMESTAMP"] ?? "0";
$sigHdr = $_SERVER["HTTP_WEBHOOK_SIGNATURE"] ?? "";
$body   = file_get_contents("php://input");   # RAW bytes

$ts = (int)$tsStr;
if (abs(time() - $ts) > $tolerance) {
    http_response_code(400); exit("stale");
}

if (strpos($secret, "whsec_") !== 0) {
    http_response_code(500); exit("bad secret");
}
$key = base64_decode(substr($secret, strlen("whsec_")));

$signed   = $msgId . "." . $ts . "." . $body;
$expected = "v1," . base64_encode(hash_hmac("sha256", $signed, $key, true));

$ok = false;
foreach (explode(" ", $sigHdr) as $part) {
    if (hash_equals($expected, $part)) { $ok = true; break; }
}
if (!$ok) { http_response_code(400); exit("invalid"); }

handleEvent($msgId, json_decode($body, true));
http_response_code(200);
Use the raw request body, not a re-serialised one. If your framework parses JSON before you grab the bytes, your HMAC won't match. Most frameworks expose a raw-body hook or middleware.

Retry policy

AttemptDelay after previous
1 (initial)
260 seconds
3120 seconds
4 (final)240 seconds

A single delivery is attempted up to 4 times in total (initial + 3 retries). Backoffs are 60s, 120s, 240s, 480s — only the first three are used by the max-attempts cap. After the final failed attempt the delivery stops retrying. An individual endpoint is auto-disabled after 50 consecutive failures across deliveries. Use POST /v1/webhooks/{id}/replay to retry manually, or contact your account manager.

What counts as success vs failure

Your responseTreated as
200–299Success. We stop.
3xxFailure. We don't follow redirects.
4xx / 5xxFailure. We retry.
Timeout (10s)Failure. We retry.
Respond fast, process async. Return 2xx as soon as you've verified the signature and queued the event. Don't do heavy lifting inside the webhook handler — we time out at 10s.

Errors

Stable error codes, standard HTTP semantics, and a request ID on every response. Branch on error.code, not on the message.

Error envelope

{
  "error": {
    "code": "invalid_field",
    "message": "primary_contact_email is required",
    "request_id": "req_a9f2e1c4b7d8",
    "field": "primary_contact_email"
  }
}

HTTP status codes

StatusClassWhen
200 / 201 / 202 / 204SuccessRequest succeeded
400ClientMalformed request, missing parameters
401ClientMissing or invalid Bearer token
403ClientToken valid but lacks required scope
404ClientResource doesn't exist or isn't yours
409ClientResource state prevents the action
422ClientValidation failure
429ClientRate-limit exceeded. Back off and retry.
500 / 502 / 503 / 504ServerOur side. Retry; contact support if persistent.

Common error codes

StatusCodeWhen
Authentication
401missing_authorizationNo Authorization: Bearer header
401invalid_tokenToken signature, issuer or audience invalid
401invalid_audienceToken audience does not match this API
401token_expiredRequest a fresh token, then retry once
401invalid_clientclient_id / client_secret not recognised (token endpoint)
403insufficient_scopeYour credential does not have that scope
403credential_disabledCredential has been revoked
403credential_not_foundCredential no longer exists
403ip_not_allowedSource address is not on the credential’s allow-list
Token endpoint
400invalid_grant_typeOnly client_credentials is supported
400invalid_scopeRequested scope is not granted to this credential
Requests
400invalid_requestMalformed request; a missing / unusable Idempotency-Key; or a marketplace credential calling GET /v1/tests without vin or registration
400 or 422invalid_fieldA field value was rejected. The response carries field naming which one
422validationValidation failure
404not_foundResource does not exist, or is not yours
409certificate_not_readyNo certificate has been rendered for this test yet. Body carries retry_after_seconds
409preview_not_readyNo preview image has been rendered for this test yet. Body carries retry_after_seconds
422invalid_webhook_urlWebhook URL rejected — see Manage webhook subscriptions
422webhook_limit_reachedMaximum number of webhooks per credential reached
Limits and server
429too_many_requestsRate limit exceeded. Limits are per-minute windows — back off at least 60s
500internal_errorOur side. Retry; quote the request_id if it persists
502bad_gatewayOur side
503service_unavailableA downstream dependency is unavailable. Retry with backoff

Rate limits

Limits are applied per endpoint group, per credential.

Endpoint groupLimit
Reads by ID — get test, get dealer, certificate, preview120 / minute
Lists — list tests, list dealers, list & get webhooks60 / minute
Writes — create dealer, update dealer, request units, create & delete webhook30 / minute
Token endpoint (/oauth/token)10 / minute — cache your token
Send a webhook test event10 / minute
Replay a webhook delivery5 / minute
Per-source-IP ceiling. In addition to the per-credential limits above, a ceiling of 300 requests per minute per source IP address applies across all credentials and endpoints. For a platform calling on behalf of many dealers from a small number of hosts, this is the limit reached first. Distribute traffic across egress addresses, or contact us before increasing volume.

On 429:

HTTP/1.1 429 Too Many Requests

{
  "error": {
    "code": "too_many_requests",
    "message": "Rate limit exceeded. Retry in 23 seconds.",
    "request_id": "req_e9d8c7b6a5f4"
  }
}

Request ID correlation

Every response includes request_id. On success responses it lives inside meta.request_id; on error responses it lives inside error.request_id. The same value is mirrored on the X-Request-Id response header. Log it alongside your own trace IDs and quote it in support tickets — we use it to find your specific request in our logs in seconds.

Retry guidance

StatusRetry?How
2xxn/aSuccess
400 / 422NoFix the request
401 + token_expiredYes (once)Refresh token, retry once
403 / 404NoRetrying won't help
409 + certificate_not_ready / preview_not_readyYes (later)The response body carries retry_after_seconds. Wait that long, then retry
429YesBack off at least 60s — limits are per-minute windows
5xx / network errorYesExponential backoff with jitter, e.g. starting at 2s and doubling each attempt. Cap at 5 attempts. Use Idempotency-Key on POSTs so retries are safe.

Getting support

When reporting an issue include: one or more request_ids from failed calls, your client_id (not your secret), timestamps in UTC, and what you expected vs what happened.