Skip to content

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

WayRequestAnswer
Follow the link (recommended)The link with ?linkType=describedby307 to the public JSON
Ask for the linksetThe link with ?linkType=all, or with Accept: application/json and no linkType200 with application/linkset+json; the describedby entry is the JSON
Look it up by GTIN and serial numbergetPublicPassportByProductId200 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
curl -sS -i -H "Accept: application/json" \
  "https://batterypass.example.com/01/04012345000023/21/SV2027-000184?linkType=describedby"
307
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: Accept

HTTP 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:

200
{
  "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
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

AnswerMeaning
404 from the resolverNo passport for this GTIN and serial number: a Problem not_found for JSON clients, a short page for browsers.
200 without documentThe passport exists but is not public right now; status says why.
400 validation_errorgetPublicPassportByProductId with a value that is not {gtin}:{serial} with a valid GTIN.
429 rate_limitedMore 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.

On this page