# 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](https://passport.kabin.sh/docs/en/concepts/digital-link).

## 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`](https://passport.kabin.sh/docs/en/api-reference/public/get-public-passport-by-product-id) | `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

1. **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.

2. **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:

   ```bash title="cURL"
   curl -sS -i -H "Accept: application/json" \
     "https://batterypass.example.com/01/04012345000023/21/SV2027-000184?linkType=describedby"
   ```

   ```http title="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`](https://passport.kabin.sh/docs/en/api-reference/public/get-public-passport).

3. **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](https://passport.kabin.sh/docs/en/guides/verify-signature).

## 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.

```ts title="TypeScript"
// 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);
```

```python title="Python"
# resolve.py: python resolve.py <digital-link>
import re
import sys
from urllib.parse import unquote, urlsplit

import requests

link = sys.argv[1]  # e.g. https://batterypass.example.com/01/04012345000023/21/SV2027-000184

match = re.search(r"/01/(\d{8,14})/21/([^/]+)$", urlsplit(link).path)
if not match:
    sys.exit("not a GS1 Digital Link with GTIN and serial number")
gtin, serial = match.group(1), unquote(match.group(2))  # a "/" in the serial number arrives as %2F

# linkType=describedby: the resolver answers 307 with the public JSON as Location; requests follows it.
res = requests.get(link, params={"linkType": "describedby"}, headers={"Accept": "application/json"}, timeout=10)
body = res.json()
if not res.ok:
    sys.exit(f"{res.status_code} {body['code']}: {body.get('detail')}")

print(gtin, serial, body["passport_id"], body["status"], res.url)
```

```go title="Go"
// resolve: go run . <digital-link>
package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"net/url"
	"os"
	"regexp"
)

var digitalLink = regexp.MustCompile(`/01/(\d{8,14})/21/([^/]+)$`)

func main() {
	link, err := url.Parse(os.Args[1]) // e.g. https://batterypass.example.com/01/04012345000023/21/SV2027-000184
	if err != nil {
		log.Fatal(err)
	}
	m := digitalLink.FindStringSubmatch(link.EscapedPath())
	if m == nil {
		log.Fatal("not a GS1 Digital Link with GTIN and serial number")
	}
	gtin := m[1]
	serial, err := url.PathUnescape(m[2]) // a "/" in the serial number arrives as %2F
	if err != nil {
		log.Fatal(err)
	}

	// linkType=describedby: the resolver answers 307 with the public JSON as Location; the client follows it.
	q := link.Query()
	q.Set("linkType", "describedby")
	link.RawQuery = q.Encode()
	req, err := http.NewRequest(http.MethodGet, link.String(), nil)
	if err != nil {
		log.Fatal(err)
	}
	req.Header.Set("Accept", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer res.Body.Close()
	var body struct {
		PassportID string `json:"passport_id"`
		Status     string `json:"status"`
		Code       string `json:"code"`
		Detail     string `json:"detail"`
	}
	if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
		log.Fatal(err)
	}
	if res.StatusCode != http.StatusOK {
		log.Fatalf("%s %s: %s", res.Status, body.Code, body.Detail)
	}
	fmt.Println(gtin, serial, body.PassportID, body.Status, res.Request.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:

```json title="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`](https://passport.kabin.sh/docs/en/api-reference/public/get-public-passport-by-product-id).
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.

```bash title="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

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