v1
2026-05-27

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.

Audience. A developer at a dealer group integrating BHC into your dealer-management system, customer record system, or public-facing car-listing website. You should already have an account manager at BHC and a parent company set up for your group.

Base URLs

EnvironmentBase URL
Productionhttps://api.batteryhealthcheck.co.uk/v1
Sandboxhttps://api-sandbox.batteryhealthcheck.co.uk/v1

Conventions


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 lookup
read:certificatesGet PDF certificate + JPEG preview
manage:webhooksRegister, list, delete, and replay webhooks

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"]

mTLS (optional, enterprise tier)

For partners with strict security requirements, we offer mutual TLS as a second authentication factor on top of OAuth. The flow is unchanged — you still present a Bearer token — but the TLS connection must also present a client certificate we've enrolled. Request setup via your account manager.

Credential rotation

Credentials don't auto-expire — rotate proactively. Annually as routine hygiene, or immediately on suspected compromise.

  1. Ask us to issue a new credential alongside the existing one. Both work concurrently.
  2. Deploy the new credential. Verify it works.
  3. Ask us to revoke the old credential. Zero downtime.

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

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

GET /v1/tests?vin={vin}

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

Required scope: read:tests

Query parameters

ParamTypeDescription
vinstringVehicle Identification Number. Full 17-char VIN performs an exact match; a 3–16 char prefix performs a prefix match.
pageinteger1-based page number. Default 1.
per_pageinteger1–100. Default 25. Results are ordered newest-first by tested_at.
VIN allowed characters. Digits and uppercase A–Z excluding I, O, Q. Other characters return 400.

Example

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

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",
      "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.


Get a test

GET /v1/tests/{id}

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

Required scope: read:tests


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 publishable preview (JPEG) — VIN redacted, safe to embed

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}/replay Replay a failed delivery

Required scope: manage:webhooks

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 VIN-lookup endpoint solves that.

Step 1. Lookup 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:

<div class="battery-summary">
  <h3>Battery Health Check</h3>
  <dl>
    <dt>State of Health</dt>
      <dd>{{ test.battery.soh_percent }}%</dd>
    <dt>Estimated range</dt>
      <dd>{{ test.battery.estimated_range_miles }} miles</dd>
    <dt>Tested</dt>
      <dd>{{ test.tested_at | date }}</dd>
  </dl>
  <p class="ref">Certificate: {{ test.certificate_number }}</p>
</div>

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)
        context["battery_section"] = {
            "soh_percent": test["battery"]["soh_percent"],
            "range_miles": test["battery"]["estimated_range_miles"],
            "tested_at": test["tested_at"],
            "certificate_number": test["certificate_number"],
            "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.

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)" }
    },
    "dealer": { ... full dealer object ... }
  }
}

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": "validation_failed",
    "message": "primary_contact_email is required",
    "request_id": "req_a9f2e1c4b7d8",
    "hints": [
      "Provide primary_contact_email as a valid email string"
    ]
  }
}

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. See Retry-After header.
500 / 502 / 503 / 504ServerOur side. Retry; contact support if persistent.

Common error codes

StatusCodeWhen
400missing_required_fieldA required field is absent
400invalid_field_valueBad email, malformed VIN, etc.
401token_expiredRefresh the token, then retry once
403missing_scopeYour credential doesn't have that scope
404not_foundResource doesn't exist or isn't yours
409certificate_unavailableTest is pending/in-progress/failed; no cert exists
409conflicting_idempotency_keySame key, different body. Use a new key.
429rate_limitedHonour Retry-After

Rate limits

LimitValue
Sustained rate60 requests / minute / credential
Burst120 requests / 5 seconds
Token endpoint10 requests / minute (cache tokens!)

Every response includes:

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 47
X-RateLimit-Reset: 1748357844

On 429:

HTTP/1.1 429 Too Many Requests
Retry-After: 23

{
  "error": {
    "code": "rate_limited",
    "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_unavailableYes (later)Wait a few seconds, retry
429YesHonour Retry-After exactly
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.