Resolve a QR code or GS1 Digital Link
From a scanned GS1 Digital Link to the public passport JSON, through the resolver or by GTIN and serial number.
The QR code on a battery holds a GS1 Digital Link such as
https://batterypass.example.com/01/04012345000023/21/SV2027-000184. A browser that opens it lands on the public
passport page. A scanner app, a recycler's system or a marketplace usually wants the data instead: this guide
gets the public passport JSON from the link. No key is needed. The background is in
GS1 Digital Link and resolver.
Three ways to the JSON
| Way | Request | Answer |
|---|---|---|
| Follow the link (recommended) | The link with ?linkType=describedby | 307 to the public JSON |
| Ask for the linkset | The link with ?linkType=all, or with Accept: application/json and no linkType | 200 with application/linkset+json; the describedby entry is the JSON |
| Look it up by GTIN and serial number | getPublicPassportByProductId | 200 with the public JSON |
Following the link works for any BatteryPass instance and any resolver hostname, because the link itself says where to go. The lookup needs you to know the BatteryPass address that holds the passport.
Step by step
Parse the link
A BatteryPass Digital Link has the path /01/{gtin}/21/{serial}: application identifier 01 with the model's
GTIN (14 digits), 21 with the unit's serial number. Serial numbers consist of letters, digits, ., - and
/; a / travels percent-encoded as %2F, so decode the segment after splitting the path, not before. Keep the
origin of the link as it is: it may be a dedicated resolver hostname rather than the API's address.
Request the JSON
Add linkType=describedby and send Accept: application/json, so an unknown passport comes back as a JSON
Problem rather than an HTML page:
curl -sS -i -H "Accept: application/json" \
"https://batterypass.example.com/01/04012345000023/21/SV2027-000184?linkType=describedby"HTTP/1.1 307 Temporary Redirect
Location: https://batterypass.example.com/api/v1/public/passports/0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10
Link: <https://batterypass.example.com/pass/0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10>; rel="https://gs1.org/voc/pip"; type="text/html", <https://batterypass.example.com/api/v1/public/passports/0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10>; rel="describedby"; type="application/json", <https://batterypass.example.com/01/04012345000023/21/SV2027-000184?linkType=all>; rel="linkset"; type="application/linkset+json"
Cache-Control: public, max-age=300
Vary: AcceptHTTP clients follow the 307 on their own (curl -L, fetch, requests, Go's http.Client). The target is
getPublicPassport.
Read the passport
The JSON has passport_id (urn:dpp:{uuid}), status, links and, while the passport is public, the signed
document with its verification. A passport whose identifiers are locked but that is not published yet
(qr_locked), or one that is suspended, withdrawn or deleted_recycled, still resolves, but its JSON carries
only the status, without document. To be sure the data is genuine, check the signature as shown in
Verify a passport signature offline.
Complete code
Each program takes the scanned link as its argument and prints GTIN, serial number, passport id, status and the URL of the JSON.
// resolve.mts: npx tsx resolve.mts <digital-link>
const link = new URL(process.argv[2]); // e.g. https://batterypass.example.com/01/04012345000023/21/SV2027-000184
const match = /\/01\/(\d{8,14})\/21\/([^/]+)$/.exec(link.pathname);
if (!match) throw new Error('not a GS1 Digital Link with GTIN and serial number');
const gtin = match[1];
const serial = decodeURIComponent(match[2]); // a "/" in the serial number arrives as %2F
// linkType=describedby: the resolver answers 307 with the public JSON as Location; fetch follows it.
link.searchParams.set('linkType', 'describedby');
const res = await fetch(link, { headers: { Accept: 'application/json' } });
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.code}: ${body.detail}`);
console.log(gtin, serial, body.passport_id, body.status, res.url);The linkset
With ?linkType=all, or with Accept: application/json (also application/linkset+json or
application/ld+json) and no linkType, the resolver answers 200 with all links of the passport:
{
"linkset": [
{
"anchor": "https://batterypass.example.com/01/04012345000023/21/SV2027-000184",
"https://gs1.org/voc/pip": [
{ "href": "https://batterypass.example.com/pass/0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10", "type": "text/html", "title": "Battery passport" }
],
"describedby": [
{ "href": "https://batterypass.example.com/api/v1/public/passports/0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10", "type": "application/json", "title": "Public passport (JSON)" }
]
}
]
}Look up by GTIN and serial number
If you know the BatteryPass address and have GTIN and serial number from another source (a label, a delivery
note, an ERP record), use getPublicPassportByProductId.
The path parameter is {gtin}:{serial}. The GTIN may have 8, 12, 13 or 14 digits and must have a valid check
digit; percent-encode the serial number.
curl -sS "$BATTERYPASS_URL/api/v1/public/dpps/by-product-id/04012345000023:SV2027-000184"The answer is the same public JSON as above. A malformed value is 400 validation_error on productId
(invalid_format); an unknown pair is 404 not_found.
Errors and limits
| Answer | Meaning |
|---|---|
404 from the resolver | No passport for this GTIN and serial number: a Problem not_found for JSON clients, a short page for browsers. |
200 without document | The passport exists but is not public right now; status says why. |
400 validation_error | getPublicPassportByProductId with a value that is not {gtin}:{serial} with a valid GTIN. |
429 rate_limited | More than 300 public requests per minute from your address (resolver and public reads together); wait for Retry-After. |
Resolver answers and the current public JSON may be cached for five minutes. If you scan many codes, cache the results on your side as well.