Hosted page templates
A hosted page is rendered at the scan URL from a template you control, with the values of the specific code scanned. This is the reference for writing one: the variables, a worked example, and the guarantees around it.
When the page renders
Turn a page on per product, or set one as your organisation default for every product without its own setting. By default it answers the plain product scan only β lot and serial rules keep redirecting, so a recall rule still wins β or flip one setting and it renders for every scan of the code.
Templates are Liquid
Templates use Liquid: {{ output }} for values, {% if %} and {% for %} for logic, and the standard Liquid filters. You edit in the dashboard with a live preview against sample scan data, and the easiest start is duplicating the built-in base template, which uses every variable below. Two behaviours to know: every output is HTML-escaped unless you append | raw, and one filter is a rule of the house β when a lot or serial goes into a URL, write {{ lot | url_encode }}. GS1 allows punctuation such as / and + in those values, and unencoded they break the URL they're pasted into.
Variables
Everything a template can reference. Absent values (a scan without a serial, a product without a photo) are simply empty β guard with {% if %} as the example below does.
| Variable | Example | Description |
|---|---|---|
gtin | 09520123456788 | GTIN-14 of the scanned product |
lot | AB123 | Lot/batch from the scan URL (absent if not encoded) |
serial | XYZ789 | Serial from the scan URL (absent if not encoded) |
cpv | V2 | Consumer product variant (absent if not encoded) |
expiry | 2027-03-01 | Expiry date (AI 17) as an ISO date |
attrs | { "17": "270301" } | All raw AIs from the scan, keyed by AI number |
product.name | Sparkling Elderflower | Product name |
product.brand | Nimbus Drinks | Brand name |
product.image | https://β¦/nimbus-elderflower.jpg | Product image URL |
links | [{ type, url, title }] | Typed links for this product, gs1:pip first when set |
scannedUri | https://gtin.codes/01/β¦ | Canonical Digital Link URI of this scan |
A worked example
This template:
<h1>{{ product.name }}</h1>
{% if lot %}<p>Batch {{ lot }} β <a href="https://brand.example/coa/{{ lot | url_encode }}">certificate for this batch</a></p>
{% endif %}<ul>
{% for link in links %}<li><a href="{{ link.url }}">{{ link.title }}</a></li>
{% endfor %}</ul>receiving a scan of a code carrying lot 24/B+6 β legal GS1 punctuation and all β as these variables:
{
"gtin": "09520123456788",
"lot": "24/B+6",
"attrs": {
"10": "24/B+6"
},
"product": {
"name": "Sparkling Elderflower",
"brand": "Nimbus Drinks"
},
"links": [
{
"type": "gs1:pip",
"url": "https://brand.example/elderflower",
"title": "Product information page"
},
{
"type": "gs1:recallStatus",
"url": "https://brand.example/recall",
"title": "Product recall status"
}
],
"scannedUri": "https://gtin.codes/01/09520123456788/10/24%2FB%2B6"
}renders exactly this:
<h1>Sparkling Elderflower</h1>
<p>Batch 24/B+6 β <a href="https://brand.example/coa/24%2FB%2B6">certificate for this batch</a></p>
<ul>
<li><a href="https://brand.example/elderflower">Product information page</a></li>
<li><a href="https://brand.example/recall">Product recall status</a></li>
</ul>Note the certificate link: {{ lot | url_encode }} turned 24/B+6 into 24%2FB%2B6, so the link resolves as one path segment instead of splitting at the slash. Any template that builds URLs from lot or serial needs this.
What the sanitizer removes
Rendered pages are sanitized before they are served, and the same rules run in the editor preview. These elements are removed outright: <script>, <iframe>, <object>, <embed>, <form>, <base>, <animate>, <animatemotion>, <animatetransform>, <set>. Event-handler attributes (anything named onβ¦) are stripped, links are limited to https, http, mailto and tel, and images to https (or inline data:) sources. On top of that, every page ships a Content-Security-Policy with no script source at all, so scripts never execute even if markup slipped through. This is what makes the promise on every page β no cookies, no scripts, no user data collected from shoppers β enforced rather than aspirational.
Limits and failure
A template source can be up to 64 KB, its rendered output up to 256 KB, and a single render performs at most 100,000 template operations β loops far larger than any legitimate page needs are cut off rather than allowed to burn CPU. All three are far above real use; they exist to stop pathological templates, not you. Parse errors surface in the editor before you can publish. If a template still fails at scan time, the scan falls back to the built-in base page: a shopper always gets a working page, never an error.
Caching and privacy
Rendered pages are publicly cached at the edge for up to five minutes, so edits can take that long to reach every shopper. The cached body can include identifiers from the scan β a lot, serial or expiry date β which is information the scan URL itself already carries; we store nothing from it beyond that cache. Nothing about the person scanning appears anywhere: no cookies, no scripts, and scan analytics stay aggregate with no stored IP addresses, as everywhere on 2D Ready.