> ## Documentation Index
> Fetch the complete documentation index at: https://docs.closient.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Build GS1 Digital Link

> Given a GTIN and optional AIs (batch/lot, serial, expiry, etc.), construct a valid canonical GS1 Digital Link URI suitable for resolver hosting, QR encoding, or sharing with downstream systems.

The GS1 Digital Link standard (v1.4 / ISO/IEC 18975) encodes
Application Identifiers in URL paths instead of barcode element
strings, so the *same identifier* can drive a barcode scan, a deep
link, and a web resolver. This skill teaches an agent how to compose a
correct Digital Link URI from structured input — the inverse of
`decode-gs1-ai`.

## When to use

* A consumer-facing surface needs a QR code or URL for a product:
  packaging, shelf-talker, email receipt, branded resolver page.
* An ERP / PIM / TMS system has structured product+lot+serial data and
  needs to emit GS1 Digital Link strings to label printers or partner
  feeds.
* An agent received a raw GTIN and wants to construct the canonical
  Closient resolver URL to hand back to a user.

For the inverse — taking a Digital Link URI and parsing it — see
`decode-gs1-ai`.

## Anatomy of a Digital Link

```
https://id.gs1.org/01/09506000134352/10/ABC123/21/SN9999?linkType=gs1:pip
└──────┬──────┘ └──┬──────────────┘ └──┬─────┘ └──┬─────┘ └──────┬──────┘
   host       primary AI + value   qualifier   qualifier      link type
```

| Component          | Rules                                                                                                                                                                                                                                             |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Host               | Any HTTPS host that supports the resolver protocol. `id.gs1.org` is the canonical GS1-managed host; you may use your own domain (e.g. `id.closient.com`, `acme.gtin.one`).                                                                        |
| Primary identifier | Exactly one of `/01/<gtin>`, `/00/<sscc>`, `/253/<gdti>`, `/255/<gcn>`, `/401/<ginc>`, `/402/<gsin>`, `/414/<gln>`, `/417/<gln>`, `/8003/<grai>`, `/8004/<giai>`, `/8006/<gctin>`, `/8010/<cpid>`, `/8013/<gmn>`, `/8017/<gsrn>`, `/8018/<gsrn>`. |
| Qualifiers         | Subsequent `/{AI}/{value}` segments that narrow the primary (e.g. `/10/<lot>`, `/17/<expiry>`, `/21/<serial>`).                                                                                                                                   |
| Data attributes    | Long-tail AIs go in the query string: `?15=271231` for AI 15 best-before.                                                                                                                                                                         |
| `linkType=`        | Tells the resolver *what kind of destination* to serve (PIP, product page, instructions, recall, etc.).                                                                                                                                           |

## Step 1 — Validate inputs

Before composing anything, normalize:

```python theme={null}
from apps.core.types.gtin import GTIN

gtin = str(GTIN.from_raw(raw_gtin))   # validates Mod-10 + pads to 14
assert lot is None or len(lot) <= 20
assert serial is None or len(serial) <= 20
# Expiry must be YYMMDD; convert ISO dates first.
```

Reject silently-malformed GTINs at this step rather than building a URI
that fails resolution downstream.

## Step 2 — Compose the primary AI

GTIN is the common case:

```python theme={null}
host = "https://id.gs1.org"
path = f"/01/{gtin}"      # gtin is 14 digits, zero-padded
url  = host + path
```

For other primary identifiers:

| Primary identifier      | Path            |
| ----------------------- | --------------- |
| GTIN-14                 | `/01/<gtin14>`  |
| SSCC                    | `/00/<sscc18>`  |
| GRAI (returnable asset) | `/8003/<grai>`  |
| GIAI (individual asset) | `/8004/<giai>`  |
| GLN (location)          | `/414/<gln>`    |
| GSIN (shipment)         | `/402/<gsin17>` |
| GINC (consignment)      | `/401/<ginc>`   |

## Step 3 — Append qualifier AIs (canonical order)

GS1 DL §4.4 fixes the order qualifier AIs must appear in: **batch/lot
(10) before expiry (17) before serial (21)** when all three are present
on a GTIN-anchored URL. The canonical order:

```
/01/{gtin}/22/{cpv}/10/{lot}/21/{serial}
```

Reordering is not a parse error but it *is* a canonicalization error —
two different orderings will produce two different canonical URLs and
some resolvers cache by canonical form.

Closient's resolver normalizes the order on receipt, so an agent
emitting a non-canonical URL will get redirected to the canonical one.
Better to emit the canonical form from the start:

```python theme={null}
def append_qualifier(url: str, ai: str, value: str | None) -> str:
    return f"{url}/{ai}/{value}" if value else url

url = host + f"/01/{gtin}"
url = append_qualifier(url, "10", lot)
url = append_qualifier(url, "17", expiry_yymmdd)
url = append_qualifier(url, "21", serial)
```

## Step 4 — Encode special characters in values

GS1 Digital Link values are URL path segments, so anything outside the
unreserved set (`A-Z a-z 0-9 - . _ ~`) must be percent-encoded:

```python theme={null}
from urllib.parse import quote
safe_serial = quote(serial, safe="")  # encode literally everything else
url += f"/21/{safe_serial}"
```

In practice, lot/serial characters that show up in real life and need
encoding: space (`%20`), `+` (`%2B`), `&` (`%26`), `/` (`%2F`), `?`
(`%3F`), `#` (`%23`). Don't pre-escape with `\\` — that's a different
encoding.

## Step 5 — Attach data-attribute AIs (query string)

AIs that aren't qualifiers go in the query string as `<ai>=<value>`:

```
/01/09506000134352?15=271231&3922=12.50
```

Useful examples:

| AI   | What it means            | Example query param |
| ---- | ------------------------ | ------------------- |
| 15   | Best-before date YYMMDD  | `15=271231`         |
| 16   | Sell-by date YYMMDD      | `16=271231`         |
| 11   | Production date YYMMDD   | `11=270101`         |
| 8005 | Price per UoM            | `8005=125000`       |
| 3922 | Price (currency-implied) | `3922=12.50`        |

Per the spec, query-string AIs should follow data-attribute conventions
— not used as primary IDs, never the basis for resolution decisions.

## Step 6 — (Optional) attach `linkType` to drive resolution intent

A Digital Link URL can point at *one* identifier but *many*
destinations: product info page, recall notice, instructions, video,
loyalty enrollment. The `linkType` query string parameter tells the
resolver which destination the agent (or end user) wants:

```
https://id.gs1.org/01/09506000134352?linkType=gs1:pip
https://id.gs1.org/01/09506000134352?linkType=gs1:recallNotice
https://id.gs1.org/01/09506000134352?linkType=gs1:instructions
```

The IANA-registered link-type vocabulary is the source of truth (see
`https://www.gs1.org/voc/`). Closient's resolver maps these to
hosted-page destinations and the `Resolver` model's
`destination_type` column — see `apps/resolver/gs1_link_types.py` for
the exhaustive list this codebase recognizes.

If you don't set `linkType`, the resolver returns its **default
destination** — typically the brand's PIP or the public Closient
product page.

## Step 7 — Pick the host

| Host                          | Use it when                                                                                               |
| ----------------------------- | --------------------------------------------------------------------------------------------------------- |
| `https://id.gs1.org`          | You want GS1's official global resolver to handle the URL.                                                |
| `https://id.closient.com`     | You're publishing through Closient and want our resolver to serve hosted pages, redirects, and analytics. |
| `https://<brand>.gtin.one`    | Closient-managed branded resolver subdomain for a specific brand.                                         |
| `https://<brand>.example.com` | The brand's own CNAME pointing at Closient.                                                               |

Any host that implements the GS1 DL resolver protocol will serve the
same identifier. The Closient hosted pages live under
`id.closient.com` and `*.gtin.one`; the `Brand.preferred_resolver_host`
column stores which one to emit for that brand's products.

## Full example

Inputs:

* GTIN-14: `09506000134352`
* Lot: `ABC123`
* Expiry: `2030-09-01` (= `300901` YYMMDD)
* Serial: `SN9999`
* Best-before: `2027-12-31` (= `271231` YYMMDD, AI 15 → query string)
* Link type: PIP (product information page)
* Host: `https://id.closient.com`

Canonical URL:

```
https://id.closient.com/01/09506000134352/10/ABC123/17/300901/21/SN9999?15=271231&linkType=gs1:pip
```

In Python (Closient internal helper):

```python theme={null}
from apps.resolver.digital_link_builder import build_digital_link

url = build_digital_link(
    host="https://id.closient.com",
    gtin="09506000134352",
    lot="ABC123",
    expiry="2030-09-01",
    serial="SN9999",
    extra_query={"15": "271231"},
    link_type="gs1:pip",
)
```

That helper takes care of the AI ordering, percent-encoding, and date
formatting in one call.

## Common mistakes

* **Wrong qualifier order.** Emitting `/01/<gtin>/21/<serial>/10/<lot>`
  is parseable but not canonical; the resolver redirects to the canonical
  ordering and analytics will conflate the two URLs.
* **Leaving out the qualifier on a serialized item.** A GTIN+serial URL
  (`/01/<gtin>/21/<serial>`) resolves to that *specific item*; a GTIN-only
  URL (`/01/<gtin>`) resolves to the class. Emit the qualifier when you
  have the data — class-level resolution can't drive item-level
  experiences like recalls.
* **Encoding the AI itself.** AI digits are *path literals* and must NOT
  be percent-encoded — only the *value* slot is.
* **Wrong link-type CURIE.** `linkType=gs1:pip` (correct CURIE) versus
  `linkType=pip` (bare token, ambiguous) — always emit the namespaced
  form. Closient's test suite normalizes both, but partner resolvers may
  not.
* **Hardcoding `id.gs1.org` for brand-owned products.** Closient brands
  have their own preferred resolver host; use that so the brand owns the
  surface and gets the analytics.
* **Not URL-encoding non-ASCII or reserved characters in lot/serial.**
  A lot of `LOT-2024/Q1` becomes `LOT-2024%2FQ1`.

## What Closient does with the URL

Once emitted, the URL can be:

1. **Encoded as a QR code** — Closient's QR rendering service
   (`apps/products/services/qr_url.py`) takes any Digital Link URL and
   produces a print-ready QR. See the `Print Digital Link` MCP tool or
   `POST /products/api/v1/qr/render`.
2. **Hosted by Closient's resolver** — if the URL points at
   `id.closient.com` or a `*.gtin.one` brand domain, Closient serves
   the destination (hosted page, redirect, JSON linkset, branded video,
   etc.) based on `linkType` and the brand's resolver configuration.
3. **Verified for serialized items** — call
   `GET /resolver/api/v1/verify/01/{gtin}/21/{serial}` to confirm an
   item is authentic + active.

## Related skills

* `decode-gs1-ai` — inverse: take a Digital Link URL apart into AIs.
* `resolve-gtin` — once you have the URL, fetch product detail.
* `capture-epcis-event` — emit a supply-chain event keyed on the same
  GTIN + serial.
* `closient-api-quickstart` — authenticate before calling
  `/products/api/v1/qr/render` or `/resolver/api/v1/verify/...`.

## Authoritative references

* GS1 Digital Link Standard 1.4 — sections 4 (URI syntax), 5
  (canonicalization), 6 (link types).
* ISO/IEC 18975:2024 — formalization of GS1 Digital Link.
* GS1 link-type vocabulary: `https://www.gs1.org/voc/`.
* Closient internal: `apps/resolver/digital_link_builder.py`,
  `apps/resolver/gs1_link_types.py`, `apps/products/services/qr_url.py`.
