# Export passports

> Download the BatteryPass JSON, the PDF and the QR code of a passport version, and the units of your organisation as a spreadsheet.

All exports are plain `GET` requests with a key that has the scope `read`. They come back as files with a
`Content-Disposition: attachment` header that carries a ready-made file name.

| Export                             | Operation                                                                                                | Content type                   | File name                                 |
| ---------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------ | ----------------------------------------- |
| BatteryPass v2.0 JSON of a version | [`exportPassportJson`](https://passport.kabin.sh/docs/en/api-reference/passports/export-passport-json) | `application/json`             | `{gtin}-{serial}-batterypass-v2.json`     |
| PDF of a version                   | [`exportPassportPdf`](https://passport.kabin.sh/docs/en/api-reference/passports/export-passport-pdf)   | `application/pdf`              | `{gtin}-{serial}-v{versionNo}-{lang}.pdf` |
| QR code of the unit                | [`getPassportQr`](https://passport.kabin.sh/docs/en/api-reference/passports/get-passport-qr)           | `image/png` or `image/svg+xml` | `qr-{gtin}-{serial}.png` or `.svg`        |
| Units as a spreadsheet             | [`exportUnits`](https://passport.kabin.sh/docs/en/api-reference/catalog/export-units)                  | XLSX or `text/csv`             | `units-{YYYYMMDD}.xlsx` or `.csv`         |

A `/` in a serial number becomes `_` in file names.

## Passport exports step by step

1. **Find the version**

   The JSON and the PDF belong to a published version. [`getPassport`](https://passport.kabin.sh/docs/en/api-reference/passports/get-passport)
   returns the current one in `current_version.version_no`;
   [`listPassportVersions`](https://passport.kabin.sh/docs/en/api-reference/passports/list-passport-versions) lists all versions, newest
   first.

   ```bash title="cURL"
   curl -sS "$BATTERYPASS_URL/api/v1/batteries/0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f/passport" \
     -H "Authorization: Bearer $BATTERYPASS_API_KEY" | jq '{status, passport_id, version: .current_version.version_no}'
   ```

   A unit that was never published has no version: the version exports answer `404 not_found`.

2. **Download the files**

   ```bash title="cURL"
   BATTERY_ID=0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f
   # -O -J saves under the name from Content-Disposition
   curl -sS -OJ -H "Authorization: Bearer $BATTERYPASS_API_KEY" \
     "$BATTERYPASS_URL/api/v1/batteries/$BATTERY_ID/passport/versions/3/batterypass-v2"
   curl -sS -OJ -H "Authorization: Bearer $BATTERYPASS_API_KEY" \
     "$BATTERYPASS_URL/api/v1/batteries/$BATTERY_ID/passport/versions/3/pdf?lang=en"
   curl -sS -OJ -H "Authorization: Bearer $BATTERYPASS_API_KEY" \
     "$BATTERYPASS_URL/api/v1/batteries/$BATTERY_ID/passport/qr?format=svg"
   ```

   In code, read the version first and take the file name from `Content-Disposition`:

   ```ts title="TypeScript"
   // exports.mts: npx tsx exports.mts <battery-id>
   import { writeFile } from 'node:fs/promises';
   import { basename } from 'node:path';

   const BASE = `${process.env.BATTERYPASS_URL}/api/v1`;
   const headers = { Authorization: `Bearer ${process.env.BATTERYPASS_API_KEY}` };
   const batteryId = process.argv[2];

   async function download(path: string) {
     const res = await fetch(BASE + path, { headers });
     if (!res.ok) throw new Error(`${path}: ${res.status} ${(await res.json()).code}`);
     // Content-Disposition: attachment; filename=04012345000023-SV2027-000184-v3-en.pdf
     const match = /filename="?([^";]+)"?/.exec(res.headers.get('Content-Disposition') ?? '');
     const file = basename(match?.[1] ?? 'download');
     await writeFile(file, Buffer.from(await res.arrayBuffer()));
     console.log(file, res.headers.get('Content-Type'));
   }

   const passportRes = await fetch(`${BASE}/batteries/${batteryId}/passport`, { headers });
   const passport = await passportRes.json();
   const versionNo = passport.current_version?.version_no;
   if (!versionNo) throw new Error(`passport is ${passport.status}: no published version yet`);

   await download(`/batteries/${batteryId}/passport/versions/${versionNo}/batterypass-v2`);
   await download(`/batteries/${batteryId}/passport/versions/${versionNo}/pdf?lang=en`);
   await download(`/batteries/${batteryId}/passport/qr?format=svg`);
   ```

   ```python title="Python"
   # exports.py: python exports.py <battery-id>
   import os
   import re
   import sys

   import requests

   BASE = os.environ["BATTERYPASS_URL"] + "/api/v1"
   session = requests.Session()
   session.headers["Authorization"] = "Bearer " + os.environ["BATTERYPASS_API_KEY"]
   battery_id = sys.argv[1]


   def download(path, **params):
       res = session.get(BASE + path, params=params, timeout=60)
       res.raise_for_status()
       # Content-Disposition: attachment; filename=04012345000023-SV2027-000184-v3-en.pdf
       match = re.search(r'filename="?([^";]+)"?', res.headers.get("Content-Disposition", ""))
       name = os.path.basename(match.group(1) if match else "download")
       with open(name, "wb") as f:
           f.write(res.content)
       print(name, res.headers["Content-Type"])


   passport = session.get(f"{BASE}/batteries/{battery_id}/passport", timeout=30).json()
   version_no = passport.get("current_version", {}).get("version_no")
   if not version_no:
       sys.exit(f"passport is {passport['status']}: no published version yet")

   download(f"/batteries/{battery_id}/passport/versions/{version_no}/batterypass-v2")
   download(f"/batteries/{battery_id}/passport/versions/{version_no}/pdf", lang="en")
   download(f"/batteries/{battery_id}/passport/qr", format="svg")
   ```

   ```go title="Go"
   // exports: go run . <battery-id>
   package main

   import (
   	"encoding/json"
   	"fmt"
   	"io"
   	"log"
   	"mime"
   	"net/http"
   	"os"
   	"path/filepath"
   )

   var (
   	base = os.Getenv("BATTERYPASS_URL") + "/api/v1"
   	key  = os.Getenv("BATTERYPASS_API_KEY")
   )

   func get(path string) (*http.Response, error) {
   	req, err := http.NewRequest(http.MethodGet, base+path, nil)
   	if err != nil {
   		return nil, err
   	}
   	req.Header.Set("Authorization", "Bearer "+key)
   	res, err := http.DefaultClient.Do(req)
   	if err == nil && res.StatusCode != http.StatusOK {
   		res.Body.Close()
   		return nil, fmt.Errorf("%s: %s", path, res.Status)
   	}
   	return res, err
   }

   func download(path string) error {
   	res, err := get(path)
   	if err != nil {
   		return err
   	}
   	defer res.Body.Close()
   	// Content-Disposition: attachment; filename=04012345000023-SV2027-000184-v3-en.pdf
   	_, params, err := mime.ParseMediaType(res.Header.Get("Content-Disposition"))
   	if err != nil {
   		return err
   	}
   	name := filepath.Base(params["filename"])
   	f, err := os.Create(name)
   	if err != nil {
   		return err
   	}
   	defer f.Close()
   	if _, err := io.Copy(f, res.Body); err != nil {
   		return err
   	}
   	fmt.Println(name, res.Header.Get("Content-Type"))
   	return nil
   }

   func main() {
   	batteryID := os.Args[1]
   	res, err := get("/batteries/" + batteryID + "/passport")
   	if err != nil {
   		log.Fatal(err)
   	}
   	var passport struct {
   		Status         string `json:"status"`
   		CurrentVersion *struct {
   			VersionNo int `json:"version_no"`
   		} `json:"current_version"`
   	}
   	err = json.NewDecoder(res.Body).Decode(&passport)
   	res.Body.Close()
   	if err != nil {
   		log.Fatal(err)
   	}
   	if passport.CurrentVersion == nil {
   		log.Fatalf("passport is %s: no published version yet", passport.Status)
   	}
   	version := fmt.Sprintf("/batteries/%s/passport/versions/%d", batteryID, passport.CurrentVersion.VersionNo)
   	for _, path := range []string{
   		version + "/batterypass-v2",
   		version + "/pdf?lang=en",
   		"/batteries/" + batteryID + "/passport/qr?format=svg",
   	} {
   		if err := download(path); err != nil {
   			log.Fatal(err)
   		}
   	}
   }
   ```

## BatteryPass JSON

`exportPassportJson` returns the export that was built and stored when the version was published, in the
format of the BatteryPass-Ready Data Model v2.0. It contains every access tier, including restricted values, so
treat the file as confidential. Whether it passed the data model's JSON schema is advisory and does not block
publication: the version summary carries `batterypass_v2_valid` and `batterypass_v2_issue_count`, and
[`getPassportVersion`](https://passport.kabin.sh/docs/en/api-reference/passports/get-passport-version) the full report in
`batterypass_v2`.

## PDF

`exportPassportPdf` renders a version with every tier (restricted rows tagged with their tier), the signature and
the QR code. `lang` is `de` (the default) or `en`. Like the JSON, the PDF contains restricted data.

## QR code

`getPassportQr` renders the unit's `gs1_digital_link` (error correction level M, a quiet zone of four modules).

- **format** `string` (query, default: png): `png` or `svg`. SVG scales without loss and suits printing.
- **size** `integer` (query, default: 512): Width and height of the PNG in pixels, 128 to 2048. Ignored for SVG.

The QR code exists once the identifiers are locked, before or at the first publication. Before that, the answer
is `409 conflict`. The link inside it never changes afterwards, so a printed label stays valid for later
versions. To label many units at once, see the recipe [QR labels for a batch](https://passport.kabin.sh/docs/en/recipes/qr-labels-for-a-batch).

## Units as a spreadsheet

`exportUnits` returns the units that match the filters, with the columns of the spreadsheet import: model,
version, the unit's own fields and every unit-level field that has a value. A member can edit the file and
import it again in the portal.

- **format** `string` (query, default: xlsx): `xlsx` or `csv`. CSV is UTF-8 with a byte order mark, comma-separated, with CRLF line ends.
- **battery_model_id** `uuid` (query): Only units of this model.
- **model_version_id** `uuid` (query): Only units of this model version.
- **passport_status** `string` (query): Only units whose passport has this status, e.g. `published`.
- **serial_prefix** `string` (query): Only serial numbers that start with this text.
- **import_id** `uuid` (query): Only units created by this import.

```bash title="cURL"
curl -sS -OJ -H "Authorization: Bearer $BATTERYPASS_API_KEY" \
  "$BATTERYPASS_URL/api/v1/batteries/export?format=csv&passport_status=published&serial_prefix=SV2027-"
```

At most 10 000 units fit in one file. With more, the answer is `400 validation_error` on `filter`
(`out_of_range`): narrow the filters, for example by model version or serial number prefix. In CSV files, a cell
that starts with `=`, `+`, `@`, or with `-` but is not a number, gets a leading `'` so spreadsheet programs do
not run it as a formula; the import removes it again.

## Errors

| Status and `code`      | Typical cause                                                                                      |
| ---------------------- | -------------------------------------------------------------------------------------------------- |
| `400 validation_error` | `size` outside 128 to 2048; an unknown `format` or `lang`; more than 10 000 units in `exportUnits` |
| `401 unauthenticated`  | Key missing, unknown or revoked                                                                    |
| `403 forbidden`        | The key lacks the scope `read`                                                                     |
| `404 not_found`        | Unknown unit, or no version with this number                                                       |
| `409 conflict`         | `getPassportQr` before the identifiers are locked                                                  |
| `429 rate_limited`     | Too many requests; wait for `Retry-After`                                                          |
