Voltpass developer documentation
API & webhooks
The REST API and outbound webhooks are included in the Scale plan. Every request and event is scoped to the shop that owns the key or endpoint.
Authentication
Create a key in the Voltpass admin under API & webhooks. The key is shown once; Voltpass stores only its SHA-256 hash. Send it as a bearer token over HTTPS. Revoked keys, keys of shops that are no longer on Scale and keys of uninstalled shops answer 401 or 403.
curl https://{app-host}/api/v1/units?status=draft \
-H "Authorization: Bearer vp_..."Each key may make 120 requests per minute. Responses carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; a 429 response includes Retry-After.
Endpoints
GET /api/v1/models— List battery models. Query: page, limit (max 100), archived=true to include archived models.GET /api/v1/models/{id}— One model.GET /api/v1/units— List units. Query: model, status, search (serial contains), page, limit.GET /api/v1/units/{id}— One unit with its full measurement series.POST /api/v1/units— Create up to 1,000 draft units under a published model. Same validation as the CSV import.POST /api/v1/units/{id}/status— Change status with the same transition rules as the admin.POST /api/v1/units/{id}/measurements— Append a state-of-health measurement (stored with source "api").
Lists return { data: [...], pagination: { page, limit, total, pages } }; single resources return { data: {...} }. Customer references and access-requester contact details are never returned.
POST /api/v1/units
Content-Type: application/json
{
"modelId": "clx...",
"units": [
{ "serial": "AB-0001", "manufactureDate": "2026-09-30",
"manufacturingPlace": "Shenzhen", "gtin": "09506000134352",
"orderId": "gid://shopify/Order/1", "customerRef": "optional" }
]
}Unit creation is all-or-nothing for invalid rows: any invalid or repeated serial answers 422 with details listing the unit index and reason. Serials that already exist are skipped and listed in skippedExistingSerials, so retrying a request is safe. Plan unit limits apply.
POST /api/v1/units/{id}/status
{ "status": "placed_on_market" }
POST /api/v1/units/{id}/measurements
{ "measuredAt": "2026-10-01T09:00:00Z", "capacityAh": 17.6,
"cycles": 120, "internalResistanceMohm": 38, "stateOfHealthPct": 96,
"note": "Workshop check" }Allowed transitions: draft → placed_on_market → in_service → withdrawn → recycled (placed_on_market may also go straight to withdrawn). Recycling closes the public passport permanently.
Errors use { error: { code, message, details? } } with codes unauthorized (401), plan_required, shop_inactive (403), not_found (404), method_not_allowed (405), unsupported_media_type (415), invalid_request / invalid_units (400, 422) and rate_limited (429).
Webhooks
Configure one HTTPS endpoint per shop. Voltpass sends a JSON POST for unit.created, unit.status_changed, unit.measurement_added, access_request.created and recall.published (a product safety recall notice went live; data.recall carries its id, public code and URL, product name, remedies, model IDs, the affected unit count and up to 1,000 affected unit IDs). A 2xx answer within 10 seconds counts as delivered; anything else is retried with exponential backoff, up to six attempts. Redirects are not followed. Events carry identifiers and status, never customer or requester contact details.
X-Voltpass-Event: unit.status_changed
X-Voltpass-Delivery: evt_6f1c...
X-Voltpass-Signature: t=1791532800,v1=5257a869e7ec...
{ "id": "evt_6f1c...", "event": "unit.status_changed",
"createdAt": "2026-10-09T08:00:00.000Z", "shop": "example.myshopify.com",
"data": { "unit": { "id": "...", "serial": "AB-0001", "passportCode": "...",
"modelId": "...", "status": "in_service", "gtin": null },
"previousStatus": "placed_on_market", "changedBy": "api:ERP" } }Verifying signatures
The signature is HMAC-SHA256 of <t>.<raw request body> with your signing secret, hex-encoded. Compare in constant time and reject timestamps more than five minutes old. Use the delivery ID to ignore duplicates.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, rawBody, header) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`).digest();
const given = Buffer.from(parts.v1 ?? "", "hex");
return given.length === expected.length && timingSafeEqual(expected, given);
}Machine-readable passports (JSON-LD)
Every public passport is available as JSON-LD without a key. Request /p/{code} with Accept: application/ld+json (or application/json, or ?format=jsonld) and you are redirected (303) to /p/{code}.jsonld. The GS1 Digital Link /01/{gtin}/21/{serial} answers JSON-LD directly for the same Accept header. The HTML page advertises the JSON-LD with <link rel="alternate" type="application/ld+json">.
Only public-tier data is included. The product core uses schema.org (Product, Organization, QuantitativeValue with UN/CEFACT unit codes) and gs1: terms. Battery data is grouped under bp: terms named after the BatteryPass Data Model aspects where the mapping is straightforward: generalProductInformation, carbonFootprint, supplyChainDueDiligence, materialComposition, circularity, performance and labels. It is not an official BatteryPass serialisation. Restricted information (detailed composition, dismantling instructions, state of health, test reports) is only available through an approved access request.
curl -L -H "Accept: application/ld+json" https://{app-host}/p/{code}
curl -H "Accept: application/linkset+json" \
https://{app-host}/01/09506000134352/21/AB-0001
# or ?linkType=linksetThe Digital Link also returns a minimal GS1 linkset (RFC 9264, application/linkset+json) with gs1:defaultLink, gs1:pip, gs1:sustainabilityInfo and, when declared, gs1:certificationInfo and gs1:recyclingInfo. Its redirect carries a Link header pointing at the linkset.