# Welcome to EUlabel (https://eulabel.eu/docs/)
EUlabel is the infrastructure layer that connects physical products to their digital identity. Through simple API calls, you can create compliant Digital Product Passports, generate GS1 Digital Link QR codes, and serve structured product data to consumers, regulators, recyclers, and retailers.
What you can do [#what-you-can-do]
* **Create products** with globally unique GS1 Digital Link identifiers
* **Publish Digital Product Passports** with ingredients, nutrition, allergens, sustainability data
* **Generate QR codes** that resolve intelligently based on who scans them
* **Track scan analytics** by geography, device, and audience type
* **Sync product data** from your PIM or CMS via webhooks
Quick links [#quick-links]
| Resource | Description |
| --------------------------------- | ----------------------------------------------------------------------- |
| [Quickstart](https://eulabel.eu/docs/quickstart) | Go from zero to a working passport in 5 minutes |
| [API Reference](https://eulabel.eu/docs/api-reference) | Every endpoint with examples |
| [Authentication](https://eulabel.eu/docs/authentication) | API keys, scopes, and permissions |
| [SDK](https://eulabel.eu/docs/sdk) | TypeScript and Python client libraries |
| [Concepts](https://eulabel.eu/docs/concepts) | Digital Product Passports, GS1 Digital Link, and how the resolver works |
Base URL [#base-url]
All API requests use the following base URL:
```
https://api.eulabel.eu/v1
```
Authentication [#authentication]
Include your API key in the `Authorization` header:
```bash
curl https://api.eulabel.eu/v1/products \
-H "Authorization: Bearer sk_live_..."
```
# Analytics (https://eulabel.eu/docs/api-reference/analytics)
Retrieve scan analytics for products.
Base URL: `https://api.eulabel.eu/v1`
## GET /analytics/product/{productId}
Returns aggregated scan data for a product, including geographic distribution,
device types, and top referrers.
Every QR code scan generates a structured event at the resolver layer, before
the redirect. This means analytics are recorded even if the passport page fails
to load. Location data is derived from IP geolocation — no personally identifiable
information is stored.
**Permission:** `analytics:read`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `productId` | path | string | Yes | Unique product identifier. |
| `start` | query | string | No | Start of analytics window (defaults to 90 days ago). |
| `end` | query | string | No | End of analytics window (defaults to today). |
| `granularity` | query | string | No | Time bucket granularity for `scansByDay`. |
### Response 200
Analytics data
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `productId` | string | No | |
| `period` | object | No | |
| `totalScans` | integer | No | |
| `uniqueCountries` | integer | No | |
| `locations` | object | No | Scan count by ISO 3166-1 alpha-2 country code. |
| `topReferrers` | array | No | |
| `devices` | object | No | |
| `scansByDay` | array | No | |
### Response 401
Missing or invalid authentication
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `error` | object | No | |
```json
{
"error": {
"type": "authentication_error",
"message": "Invalid API key",
"code": "authentication_error"
}
}
```
### Response 404
Resource not found
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `error` | object | No | |
```json
{
"error": {
"type": "not_found",
"message": "Resource not found",
"code": "not_found"
}
}
```
# API Reference (https://eulabel.eu/docs/api-reference)
The EUlabel API provides endpoints for managing products, digital passports, suppliers, and scan analytics. All endpoints follow a consistent design inspired by Stripe: simple, predictable, and well-documented.
Base URL [#base-url]
```
https://api.eulabel.eu/v1
```
Authentication [#authentication]
All requests require an API key in the `Authorization` header:
```bash
curl https://api.eulabel.eu/v1/products \
-H "Authorization: Bearer sk_live_..."
```
See [Authentication](https://eulabel.eu/docs/authentication) for details on API keys and scopes.
Request format [#request-format]
* Request bodies use JSON (`Content-Type: application/json`)
* All timestamps are ISO 8601 in UTC
* Identifiers are UUIDs
Error format [#error-format]
All errors return a consistent structure:
```json
{
"error": {
"type": "validation_error",
"message": "gtin is required",
"code": "validation_error",
"param": "gtin"
}
}
```
Status codes [#status-codes]
| Code | Meaning |
| ----- | ------------------------------------------ |
| `200` | Success |
| `201` | Resource created |
| `400` | Malformed request (invalid JSON) |
| `401` | Missing or invalid authentication |
| `403` | Insufficient permissions |
| `404` | Resource not found |
| `422` | Validation error (check the `param` field) |
| `429` | Rate limit exceeded |
| `500` | Internal server error |
Endpoints [#endpoints]
| Endpoint | Description |
| ------------------------------------- | ------------------------------------- |
| [Products](https://eulabel.eu/docs/api-reference/products) | Create and list products |
| [Passports](https://eulabel.eu/docs/api-reference/passports) | Attach and retrieve digital passports |
| [QR Codes](https://eulabel.eu/docs/api-reference/qr-codes) | Generate GS1 Digital Link QR codes |
| [Suppliers](https://eulabel.eu/docs/api-reference/suppliers) | Manage economic operators |
| [Analytics](https://eulabel.eu/docs/api-reference/analytics) | Retrieve scan analytics |
# Passports (https://eulabel.eu/docs/api-reference/passports)
Attach and retrieve Digital Product Passports.
Base URL: `https://api.eulabel.eu/v1`
## POST /passports
Attaches passport data to an existing product and publishes it.
A product can have one active passport at a time — creating a new
passport supersedes the previous version.
Identify the product by **either** `productId` or `gtin` (but not both).
Using the GTIN is convenient when integrating with systems that track
products by barcode rather than internal IDs. The product must already
exist — this endpoint does not create products.
**Permission:** `passports:write`
### Request Body
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `productId` | string | No | Product UUID. Required if `gtin` is not provided. |
| `gtin` | string | No | GTIN of an existing product. Required if `productId` is not provided. |
| `data` | object | Yes | |
**Example:**
```json
{
"productId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"data": {
"productType": "wine",
"ingredients": [
"Grapes",
"Sulphur Dioxide"
],
"nutrition": {
"energyKj": 300,
"energyKcal": 72,
"fatG": 0,
"saturatedFatG": 0,
"carbohydratesG": 0.5,
"sugarsG": 0.2,
"proteinG": 0.1,
"saltG": 0,
"alcoholG": 12
},
"allergens": {
"containsSulphites": true,
"containsEgg": false,
"containsFish": false,
"containsMilk": false
},
"origin": {
"country": "PT",
"region": "Douro"
},
"producers": [
{
"name": "Quinta do Crasto",
"role": "producer",
"country": "PT"
}
]
}
}
```
### Response 201
Passport created
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `passportId` | string | No | |
| `productId` | string | No | |
| `version` | integer | No | |
| `status` | string | No | |
| `createdAt` | string | No | |
```json
{
"passportId": "e5f6g7h8-i9j0-1234-klmn-opqrstuvwxyz",
"productId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"version": 1,
"status": "published",
"createdAt": "2026-03-14T20:00:00.000Z"
}
```
### Response 401
Missing or invalid authentication
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `error` | object | No | |
```json
{
"error": {
"type": "authentication_error",
"message": "Invalid API key",
"code": "authentication_error"
}
}
```
### Response 404
Product not found
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `error` | object | No | |
```json
{
"error": {
"type": "not_found",
"message": "No product found for this GTIN",
"code": "not_found"
}
}
```
### Response 422
Validation error
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `error` | object | No | |
```json
{
"error": {
"type": "validation_error",
"message": "gtin is required",
"code": "validation_error",
"param": "gtin"
}
}
```
## GET /products/{productId}/passport
Returns the full structured passport data for a product.
**Permission:** `products:read`
Request `Accept: application/ld+json` to receive the passport in JSON-LD
format with schema.org and GS1 Web Vocabulary terms.
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `productId` | path | string | Yes | Unique product identifier. |
### Response 200
Passport data
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `productId` | string | No | |
| `passportId` | string | No | |
| `name` | string | No | |
| `category` | string | No | |
| `gtin` | string | No | |
| `data` | object | No | |
| `qrCodeUrl` | string | No | |
| `status` | string | No | |
| `version` | integer | No | |
| `lastUpdated` | string | No | |
### Response 401
Missing or invalid authentication
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `error` | object | No | |
```json
{
"error": {
"type": "authentication_error",
"message": "Invalid API key",
"code": "authentication_error"
}
}
```
### Response 404
Resource not found
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `error` | object | No | |
```json
{
"error": {
"type": "not_found",
"message": "Resource not found",
"code": "not_found"
}
}
```
# Products (https://eulabel.eu/docs/api-reference/products)
Create, update, list, and retrieve products.
Base URL: `https://api.eulabel.eu/v1`
## POST /products
Creates a new product with a unique digital identity and a GS1 Digital Link QR code URL.
**Permission:** `products:write`
### Request Body
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | Yes | Product display name. |
| `category` | string | Yes | Product category. |
| `brand` | string | No | Brand name. |
| `gtin` | string | Yes | Valid GTIN-8, GTIN-13, or GTIN-14. |
| `sku` | string | No | Internal SKU reference. |
**Example:**
```json
{
"name": "Quinta do Crasto Douro Red 2021",
"category": "wine",
"brand": "Quinta do Crasto",
"gtin": "5601234567890"
}
```
### Response 201
Product created
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `productId` | string | No | Unique product identifier. |
| `qrCodeUrl` | string | No | GS1 Digital Link QR code URL. |
| `createdAt` | string | No | |
```json
{
"productId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"qrCodeUrl": "https://eulabel.eu/01/05601234567890",
"createdAt": "2026-03-14T20:00:00.000Z"
}
```
### Response 401
Missing or invalid authentication
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `error` | object | No | |
```json
{
"error": {
"type": "authentication_error",
"message": "Invalid API key",
"code": "authentication_error"
}
}
```
### Response 409
Duplicate GTIN
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `error` | object | No | |
```json
{
"error": {
"type": "duplicate_error",
"message": "A product with this GTIN already exists",
"code": "duplicate_error",
"param": "gtin"
}
}
```
### Response 422
Validation error
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `error` | object | No | |
```json
{
"error": {
"type": "validation_error",
"message": "gtin is required",
"code": "validation_error",
"param": "gtin"
}
}
```
## GET /products
Returns all products in your organization.
**Permission:** `products:read`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | integer | No | Maximum number of products to return. |
| `starting_after` | query | string | No | Cursor for pagination. Pass the `productId` of the last item from the previous page. |
### Response 200
List of products
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | array | No | |
| `hasMore` | boolean | No | |
### Response 401
Missing or invalid authentication
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `error` | object | No | |
```json
{
"error": {
"type": "authentication_error",
"message": "Invalid API key",
"code": "authentication_error"
}
}
```
## GET /products/{productId}
Retrieves a single product by ID.
**Permission:** `products:read`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `productId` | path | string | Yes | Unique product identifier. |
### Response 200
Product details
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `internalId` | string | No | |
| `tenantId` | string | No | |
| `gtin` | string | No | Normalized GTIN-14. |
| `name` | string | No | |
| `category` | string | No | |
| `brand` | string | No | |
| `sku` | string | No | |
| `links` | array | No | |
| `createdAt` | string | No | |
| `updatedAt` | string | No | |
### Response 401
Missing or invalid authentication
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `error` | object | No | |
```json
{
"error": {
"type": "authentication_error",
"message": "Invalid API key",
"code": "authentication_error"
}
}
```
### Response 404
Resource not found
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `error` | object | No | |
```json
{
"error": {
"type": "not_found",
"message": "Resource not found",
"code": "not_found"
}
}
```
## PATCH /products/{productId}
Updates an existing product. Only the provided fields are changed; omitted
fields remain unchanged. The GTIN cannot be modified.
**Permission:** `products:write`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `productId` | path | string | Yes | Unique product identifier. |
### Request Body
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | No | Product display name. |
| `category` | string | No | Product category. |
| `brand` | string | No | Brand name. |
| `sku` | string | No | Internal SKU reference. Set to `null` to clear. |
**Example:**
```json
{
"name": "Quinta do Crasto Douro Red 2022",
"brand": "Quinta do Crasto"
}
```
### Response 200
Product updated
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `product` | object | No | |
### Response 401
Missing or invalid authentication
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `error` | object | No | |
```json
{
"error": {
"type": "authentication_error",
"message": "Invalid API key",
"code": "authentication_error"
}
}
```
### Response 404
Resource not found
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `error` | object | No | |
```json
{
"error": {
"type": "not_found",
"message": "Resource not found",
"code": "not_found"
}
}
```
### Response 422
Validation error
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `error` | object | No | |
```json
{
"error": {
"type": "validation_error",
"message": "gtin is required",
"code": "validation_error",
"param": "gtin"
}
}
```
# QR Codes (https://eulabel.eu/docs/api-reference/qr-codes)
Generate GS1 Digital Link QR codes.
Base URL: `https://api.eulabel.eu/v1`
## GET /products/{productId}/qr
Returns an SVG image of the GS1 Digital Link QR code for the product.
The QR code encodes a GS1 Digital Link URI (e.g., `https://eulabel.eu/01/05601234567890`)
that resolves through the EUlabel resolver.
When scanned, the resolver identifies the product via its GTIN, determines the
audience context (consumer, regulator, recycler, retailer), and redirects to the
appropriate passport view. The same QR code serves different data to different
audiences.
**Permission:** `products:read`
### Parameters
| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `productId` | path | string | Yes | Unique product identifier. |
### Response 200
QR code SVG image
### Response 401
Missing or invalid authentication
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `error` | object | No | |
```json
{
"error": {
"type": "authentication_error",
"message": "Invalid API key",
"code": "authentication_error"
}
}
```
### Response 404
Resource not found
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `error` | object | No | |
```json
{
"error": {
"type": "not_found",
"message": "Resource not found",
"code": "not_found"
}
}
```
# Suppliers (https://eulabel.eu/docs/api-reference/suppliers)
Register and manage economic operators.
Base URL: `https://api.eulabel.eu/v1`
## POST /suppliers
Registers a supplier record. Suppliers are linked to products to track
supply chain contributors and meet ESPR economic operator disclosure
requirements.
**Permission:** `suppliers:write`
### Request Body
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | Yes | Operator company name. |
| `country` | string | Yes | ISO 3166-1 alpha-2 country code. |
| `role` | string | No | |
| `gln` | string | No | GS1 Global Location Number. |
| `vatNumber` | string | No | VAT registration number. |
**Example:**
```json
{
"name": "Quinta do Crasto, S.A.",
"country": "PT",
"role": "producer",
"vatNumber": "PT500123456"
}
```
### Response 201
Supplier created
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `supplierId` | string | No | |
| `name` | string | No | |
| `createdAt` | string | No | |
```json
{
"supplierId": "sup_sogrape001",
"name": "Quinta do Crasto, S.A.",
"createdAt": "2026-03-14T20:00:00.000Z"
}
```
### Response 401
Missing or invalid authentication
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `error` | object | No | |
```json
{
"error": {
"type": "authentication_error",
"message": "Invalid API key",
"code": "authentication_error"
}
}
```
### Response 422
Validation error
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `error` | object | No | |
```json
{
"error": {
"type": "validation_error",
"message": "gtin is required",
"code": "validation_error",
"param": "gtin"
}
}
```
# API Keys (https://eulabel.eu/docs/authentication/api-keys)
API keys provide programmatic access to the EUlabel API. Each key is scoped to an organization and can be restricted to specific permissions.
Creating an API key [#creating-an-api-key]
Create keys through the Dashboard or via the API (requires an active session):
```bash
curl -X POST https://api.eulabel.eu/v1/auth/api-keys \
-H "Cookie: eulabel_session=..." \
-H "Content-Type: application/json" \
-d '{
"name": "CI Pipeline Key",
"scopes": ["products:read", "products:write", "passports:write"]
}'
```
The API key is returned in the response and **displayed only once**. Store it securely.
Listing API keys [#listing-api-keys]
```bash
curl https://api.eulabel.eu/v1/auth/api-keys \
-H "Cookie: eulabel_session=..."
```
Returns all active keys for your organization (keys are masked -- only the prefix is visible).
Revoking an API key [#revoking-an-api-key]
```bash
curl -X DELETE https://api.eulabel.eu/v1/auth/api-keys/KEY_ID \
-H "Cookie: eulabel_session=..."
```
Revocation takes effect within seconds. Revoked keys cannot be restored.
Key rotation [#key-rotation]
To rotate a key with zero downtime:
1. Create a new API key with the same scopes
2. Update your application to use the new key
3. Verify the new key works correctly
4. Revoke the old key
The platform supports multiple active keys simultaneously, so there is no gap in access during rotation.
Best practices [#best-practices]
* Use descriptive names for keys (e.g., "Production PIM Sync", "CI Pipeline")
* Assign the minimum required scopes
* Rotate keys periodically
* Monitor key usage in the Dashboard for unusual patterns
# Authentication (https://eulabel.eu/docs/authentication)
The EUlabel API supports two authentication methods depending on your use case.
API Key (Bearer Token) [#api-key-bearer-token]
For machine-to-machine integrations, scripts, and CI pipelines. Include your API key in the `Authorization` header:
```bash
curl https://api.eulabel.eu/v1/products \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
API keys are scoped to a specific organization and can be restricted to specific permissions.
| Property | Description |
| --------- | ------------------------------------------------------------- |
| Format | `sk_live_` prefix (production) or `sk_test_` prefix (sandbox) |
| Scope | Per organization, per environment |
| Use cases | PIM webhook delivery, bulk data sync, CI/CD pipelines |
Session Cookie [#session-cookie]
For browser-based access through the EUlabel Dashboard:
| Property | Description |
| --------- | --------------------------------------- |
| Provider | WorkOS (SAML, OIDC, Google, Microsoft) |
| Session | Server-side with refresh token rotation |
| Use cases | Dashboard access, API key management |
Permissions (Scopes) [#permissions-scopes]
Every API key is assigned one or more permission scopes that control what resources it can access.
| Scope | Grants |
| ----------------- | ------------------------------------------ |
| `products:read` | List and get products, passports, QR codes |
| `products:write` | Create products |
| `passports:read` | Read passport data |
| `passports:write` | Create and publish passports |
| `suppliers:read` | List suppliers |
| `suppliers:write` | Create suppliers |
| `analytics:read` | View scan analytics |
| `api_keys:manage` | Manage API keys (session only) |
Error responses [#error-responses]
| Status | Meaning |
| ------ | ------------------------------------------------------------ |
| 401 | Missing or invalid API key |
| 403 | Valid API key but insufficient permissions for this endpoint |
# Roles and Permissions (https://eulabel.eu/docs/authentication/roles-and-permissions)
EUlabel uses role-based access control to manage what different users can see and do within an organization.
Roles [#roles]
| Role | Permissions |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------ |
| **Brand Manager** | Create and edit passports for products in their portfolio. View scan analytics. Cannot access other brands' data. |
| **Compliance Officer** | View regulatory datasets and compliance validation results. Cannot edit product descriptions or marketing content. |
| **Retail Partner** | Read-only access to product information for products they sell. Cannot see cost data or supplier details. |
| **Supplier** | Submit ingredient data and upload certifications for assigned products. Cannot view other suppliers' submissions. |
| **Platform Admin** | Full access to platform configuration, tenant management, and system monitoring. |
Permission model [#permission-model]
Permissions are evaluated through a chain:
```
User -> Organization (tenant) -> Role -> Permission set
```
A user can belong to multiple organizations with different roles in each. For example, a brand manager at one company may also be a read-only viewer at another.
Data visibility [#data-visibility]
Product data has different access levels aligned with ESPR requirements:
| Data Class | Who Can Access | Examples |
| ---------------- | ----------------------------------------- | --------------------------------------------------------------------- |
| **Public** | Anyone (QR scan, unauthenticated) | Product descriptions, ingredients, nutrition, conformity certificates |
| **Restricted** | Authenticated users with appropriate role | Supply chain details, batch-level lab data, manufacturing information |
| **Confidential** | Organization admins only | Pricing, supplier agreements, internal notes |
Public data is served to anyone scanning the QR code. Restricted and confidential data requires authentication and the appropriate role.
Enterprise SSO [#enterprise-sso]
Enterprise customers can connect their corporate identity provider (Okta, Azure AD, Google Workspace) for single sign-on. SSO is configured per organization by the customer's IT admin.
# Changelog (https://eulabel.eu/docs/changelog)
March 2026 [#march-2026]
API v1 Launch [#api-v1-launch]
* **Products API**: Create and manage products with GS1 Digital Link identifiers
* **Passports API**: Attach and retrieve Digital Product Passports with full wine e-label support
* **QR Code generation**: SVG QR codes encoding GS1 Digital Link URIs
* **Suppliers API**: Register and manage economic operators
* **Analytics API**: Scan analytics with geographic, device, and referrer breakdown
* **Webhooks**: Inbound (PIM sync) and outbound (event notifications) webhook support
* **TypeScript SDK**: `@eulabel/sdk` on npm
* **Python SDK**: `eulabel` on PyPI
Supported product categories [#supported-product-categories]
* Wine (EU Regulation 2021/2117 e-label compliance)
Coming soon [#coming-soon]
* Batteries and textiles DPP support
* Additional PIM connectors (Salsify, Akeneo, Contentful)
* Go SDK
* OpenAPI playground in documentation
# Data Formats (https://eulabel.eu/docs/data-formats)
EUlabel outputs passport data in formats aligned with GS1 standards, EU regulatory requirements, and web interoperability best practices.
Format overview [#format-overview]
| Format | Standard | Purpose |
| ------- | ------------------------------------------- | ------------------------------------------------------- |
| JSON-LD | W3C JSON-LD, schema.org, GS1 Web Vocabulary | Primary structured data for passport content |
| Linkset | RFC 9264 | Machine-discoverable link collections from the resolver |
| OpenAPI | OpenAPI 3.1 | REST API documentation and SDK generation |
JSON-LD: Primary data format [#json-ld-primary-data-format]
Product passport data is serialized as JSON-LD using schema.org as the semantic anchor, extended with GS1 Web Vocabulary terms where schema.org lacks coverage.
JSON-LD ensures that passport data is:
* **Machine-readable** by search engines, AI agents, and regulatory systems
* **Interoperable** across platforms without proprietary data mappings
* **Discoverable** when embedded in HTML pages
Example: Wine passport as JSON-LD [#example-wine-passport-as-json-ld]
```json
{
"@context": ["https://schema.org", "https://www.gs1.org/voc/"],
"@type": "Product",
"name": "Quinta dos Carvalhais Alfrocheiro 2019",
"gtin": "05601012012200",
"brand": {
"@type": "Brand",
"name": "Quinta dos Carvalhais"
},
"countryOfOrigin": {
"@type": "Country",
"name": "Portugal"
},
"hasIngredientList": [
{
"@type": "gs1:FoodBeverageTobaccoIngredientDetails",
"ingredientName": "Grapes (Alfrocheiro)",
"ingredientContentPercentage": 99.986
},
{
"@type": "gs1:FoodBeverageTobaccoIngredientDetails",
"ingredientName": "Sulphites",
"ingredientContentPercentage": 0.014
}
],
"hasAllergen": [
{
"@type": "gs1:AllergenDetails",
"allergenType": "https://gs1.org/voc/AllergenTypeCode-AS",
"allergenLevelOfContainment": "CONTAINS"
}
],
"nutritionInformation": {
"@type": "NutritionInformation",
"servingSize": "100 mL",
"calories": "84 kcal",
"fatContent": "0 g",
"carbohydrateContent": "1 g",
"sugarContent": "0.1 g",
"proteinContent": "0 g",
"sodiumContent": "0 g"
}
}
```
Content negotiation [#content-negotiation]
Request `Accept: application/ld+json` from the passport endpoint to receive JSON-LD instead of the default JSON response:
```bash
curl https://api.eulabel.eu/v1/products/a1b2c3d4-.../passport \
-H "Authorization: Bearer sk_live_..." \
-H "Accept: application/ld+json"
```
Embedding in HTML [#embedding-in-html]
Structured data is embedded in passport web pages using a `
```
This satisfies two requirements simultaneously: a human-readable passport page for consumers and a machine-readable dataset for regulators and search engines.
# Linkset (RFC 9264) (https://eulabel.eu/docs/data-formats/linkset)
The EUlabel resolver returns machine-discoverable link collections when a client requests `?linkType=linkset` or sends `Accept: application/linkset+json`.
What is a linkset? [#what-is-a-linkset]
A linkset is a structured collection of links associated with a product, defined by [RFC 9264](https://www.rfc-editor.org/rfc/rfc9264). Each link points to a different resource about the product (ingredient info, allergen data, nutrition, product page) with metadata describing the link type, language, and media type.
Example linkset response [#example-linkset-response]
```json
{
"linkset": [
{
"anchor": "https://eulabel.eu/01/05601012012200",
"https://gs1.org/voc/pip": [
{
"href": "https://eulabel.eu/passport/prd_01F8ABC123",
"title": "Product Information Page",
"type": "text/html",
"hreflang": ["en", "pt"]
}
],
"https://gs1.org/voc/allergenInfo": [
{
"href": "https://api.eulabel.eu/v1/products/prd_01F8ABC123/passport?section=allergens",
"title": "Allergen Information",
"type": "application/json"
}
],
"https://gs1.org/voc/nutritionalInfo": [
{
"href": "https://api.eulabel.eu/v1/products/prd_01F8ABC123/passport?section=nutrition",
"title": "Nutrition Declaration",
"type": "application/json"
}
],
"https://gs1.org/voc/defaultLink": [
{
"href": "https://eulabel.eu/passport/prd_01F8ABC123",
"title": "Digital Product Passport",
"type": "text/html"
}
]
}
]
}
```
Link attributes [#link-attributes]
| Attribute | Required | Description |
| ---------- | ----------- | ----------------------------------------------------- |
| `href` | Yes | Target URL of the resource |
| `title` | Yes | Human-readable title |
| `type` | Recommended | Media type (e.g., `text/html`, `application/ld+json`) |
| `hreflang` | Recommended | BCP 47 language tags |
Requesting a linkset [#requesting-a-linkset]
```bash
# Via query parameter
curl "https://eulabel.eu/01/05601012012200?linkType=linkset"
# Via Accept header
curl https://eulabel.eu/01/05601012012200 \
-H "Accept: application/linkset+json"
```
The default link (`gs1:defaultLink`) is returned when no specific `linkType` is requested. Every product must have exactly one default link.
# Digital Product Passports (https://eulabel.eu/docs/concepts/digital-product-passports)
The European Union is introducing mandatory digital records for physical products. The primary regulation driving this is the **Ecodesign for Sustainable Products Regulation (ESPR)**, which entered into force in July 2024.
What is a DPP? [#what-is-a-dpp]
A Digital Product Passport is a structured, machine-readable dataset about a product's entire lifecycle -- materials, supply chain data, environmental impact, repair instructions, and recyclability. It is accessible via a QR code or other data carrier on the product.
Three concepts that get confused [#three-concepts-that-get-confused]
| Property | QR Code Label | E-Label | Digital Product Passport |
| ------------ | -------------------- | ------------------------- | ---------------------------------------------- |
| Data format | Unstructured webpage | Semi-structured page | Machine-readable (JSON-LD) |
| Legal status | Voluntary | Permitted in some sectors | Mandatory under ESPR |
| Audience | Consumers | Consumers | Consumers + regulators + recyclers + retailers |
| Scope | Single product page | Ingredient/nutrition | Full lifecycle dataset |
| Identifier | Any URL | Any URL | Standardized (GTIN via GS1) |
Regulatory timeline [#regulatory-timeline]
| Year | Milestone |
| ---- | ---------------------------------------------- |
| 2023 | Wine e-labels become mandatory |
| 2024 | ESPR framework enters into force |
| 2025 | Delegated acts and technical standards defined |
| 2026 | EU DPP registry goes live |
| 2027 | Batteries and textiles DPPs mandatory |
| 2030 | Full ESPR coverage across all sectors |
Where EUlabel fits [#where-eulabel-fits]
EUlabel is infrastructure that sits between enterprise product data systems (ERPs, PIMs, CMSs) and the digital experiences required by regulation.
```
Enterprise systems (SAP, Salsify, DatoCMS)
|
Integration layer
|
EUlabel platform
/ \
Resolver Passport API
| |
QR scan Developer access
| |
Passport page Structured data
```
Core capabilities:
* **Product identity management** with globally unique GS1 Digital Link identifiers
* **Passport data hosting** for ingredients, materials, sustainability, compliance
* **GS1-conformant resolver** routing QR scans to the correct destination
* **Compliance automation** formatting data for specific regulations
* **APIs and SDKs** for programmatic access
* **Scan analytics** tracking product engagement
# GS1 Digital Link (https://eulabel.eu/docs/concepts/gs1-digital-link)
GS1 Digital Link is a standardized method for encoding GS1 identifiers (GTINs) into web-resolvable URIs, conformant with ISO/IEC 18975. It achieves two goals simultaneously:
1. **Offline identification** -- identifiers can be extracted from the barcode without internet access
2. **Online information access** -- the URI is a functional web address connecting to digital content
URI structure [#uri-structure]
```
https://{domain}/01/{GTIN}/10/{batch}/21/{serial}
```
| Component | Example | Purpose |
| ------------------------- | -------------------- | -------------------------------------------- |
| **Domain** | `eulabel.eu` | Resolver domain (not part of the identifier) |
| **Primary key (AI 01)** | `/01/05601234567890` | GTIN -- the product identifier |
| **Key qualifier (AI 10)** | `/10/ABC123` | Batch/lot number |
| **Key qualifier (AI 21)** | `/21/1001` | Serial number |
| **Query string** | `?linkType=gs1:pip` | Request specific link type |
Domain independence [#domain-independence]
The domain is **not** part of the product identifier:
```
https://eulabel.eu/01/05601234567890
https://brand.example/01/05601234567890
```
Both URIs identify the **same product**. Identifiers persist independently of any domain, which is essential for ESPR persistence requirements.
Identification granularity [#identification-granularity]
Different levels of identification serve different use cases:
| Level | GS1 Encoding | Use Case |
| --------- | -------------------- | ------------------------------------------ |
| **Model** | GTIN only | Product type identification |
| **Batch** | GTIN + lot number | Recall readiness, batch-specific data |
| **Item** | GTIN + serial number | Repairs, warranty, individual traceability |
Examples [#examples]
```
Model level: https://eulabel.eu/01/09506000164908
Batch level: https://eulabel.eu/01/09506000164908/10/LOT2025A
Item level: https://eulabel.eu/01/09506000164908/21/SN00012345
```
Each level inherits data from above -- everything at the GTIN level applies to all batches and items under it.
Key properties [#key-properties]
* **Globally unique** -- no two products share an identifier
* **Persistent** -- identifiers never change throughout the product lifecycle
* **Web-resolvable** -- scan a QR code with any smartphone camera, no app needed
* **Open standards** -- no vendor lock-in, portable across systems
* **Backward compatible** -- same identifiers work in legacy barcode systems
Data carriers [#data-carriers]
| Carrier | Smartphone Support | DPP Suitability |
| --------------- | ------------------ | ----------------------------------- |
| **QR Code** | Ubiquitous | Primary choice (cited by ESPR) |
| **Data Matrix** | Uneven | Good for small/cylindrical products |
| **NFC** | Widely implemented | Assessment pending |
QR Code with GS1 Digital Link URI syntax is the recommended data carrier for DPP compliance.
# Concepts (https://eulabel.eu/docs/concepts)
EUlabel sits at the intersection of product identity, regulatory compliance, and developer infrastructure. Understanding these four concepts will give you the mental model for how everything fits together.
Digital Product Passports [#digital-product-passports]
The EU now requires machine-readable datasets for physical products under the [Ecodesign for Sustainable Products Regulation (ESPR)](https://eulabel.eu/docs/concepts/digital-product-passports). A Digital Product Passport carries a product's full lifecycle data — materials, ingredients, environmental impact, recyclability — accessible via a QR code on the product itself.
EUlabel provides the infrastructure to create, host, and serve these passports through simple API calls.
[Read more about DPPs](https://eulabel.eu/docs/concepts/digital-product-passports)
GS1 Digital Link [#gs1-digital-link]
[GS1 Digital Link](https://eulabel.eu/docs/concepts/gs1-digital-link) is the standard for encoding product identifiers (GTINs) into web-resolvable URIs. A single URI like `https://eulabel.eu/01/05601234567890` works simultaneously as a barcode identifier and a web address.
This dual nature — offline identification plus online information access — is what makes QR codes on physical products work without requiring a dedicated app.
[Read more about GS1 Digital Link](https://eulabel.eu/docs/concepts/gs1-digital-link)
The Resolver [#the-resolver]
The [resolver](https://eulabel.eu/docs/concepts/resolver) is the routing engine behind the QR code. When a URI is scanned, the resolver identifies the product, determines who is scanning (consumer, regulator, recycler, retailer), and redirects to the appropriate data view.
One QR code, multiple audiences — the same scan serves ingredients to a consumer and JSON-LD compliance data to a regulator.
[Read more about the resolver](https://eulabel.eu/docs/concepts/resolver)
Scan Analytics [#scan-analytics]
Every QR code scan generates a [structured event](https://eulabel.eu/docs/concepts/scan-analytics) before the redirect happens. This means analytics are captured at the infrastructure layer — even if the passport page fails to load.
Track geographic distribution, device types, referrers, and engagement over time.
[Read more about scan analytics](https://eulabel.eu/docs/concepts/scan-analytics)
How they connect [#how-they-connect]
```
Physical product with QR code
|
GS1 Digital Link URI (product identity)
|
Resolver (routing engine)
/ | \
Consumer Regulator Recycler ← audience detection
| | |
Passport JSON-LD Materials ← Digital Product Passport data
|
Scan Analytics (captured before redirect)
```
# How the Resolver Works (https://eulabel.eu/docs/concepts/resolver)
The resolver is the routing engine behind the QR code. When a GS1 Digital Link URI is scanned, the resolver identifies the product, determines the appropriate destination, and redirects the request.
One QR code, multiple audiences [#one-qr-code-multiple-audiences]
The same QR code serves different data to different audiences:
| Audience | Destination | Data Served |
| ------------- | ------------------ | ------------------------------------------------ |
| **Consumer** | Passport page | Ingredients, nutrition, allergens, product story |
| **Regulator** | Compliance dataset | Machine-readable JSON-LD conformity data |
| **Retailer** | Logistics view | Batch info, supply chain data |
| **Recycler** | Material data | Packaging composition, disposal instructions |
The resolver selects the destination based on the `linkType` query parameter, `Accept` header, or default link configuration.
Request flow [#request-flow]
```
1. Consumer scans QR code
|
2. URI hits the resolver: eulabel.eu/01/05601234567890
|
3. GTIN extracted and validated
|
4. Product looked up in database
|
5. Scan event recorded (non-blocking)
|
6. Destination link determined
|
7. HTTP 307 redirect to passport page
```
Requesting specific link types [#requesting-specific-link-types]
Append `?linkType=` to request a specific type of information:
```bash
# Product information page
https://eulabel.eu/01/05601234567890?linkType=gs1:pip
# Allergen information
https://eulabel.eu/01/05601234567890?linkType=gs1:allergenInfo
# Nutrition information
https://eulabel.eu/01/05601234567890?linkType=gs1:nutritionalInfo
# Full linkset (all available links)
https://eulabel.eu/01/05601234567890?linkType=linkset
```
GTIN hierarchy fallback [#gtin-hierarchy-fallback]
The resolver supports three levels of identification and falls back up the hierarchy when more specific data is not available:
```
1. Serial level: GTIN + lot + serial -> item-specific links (repair, warranty)
|
| (not found)
v
2. Batch level: GTIN + lot -> batch-specific links (recall, lab results)
|
| (not found)
v
3. GTIN level: GTIN only -> general product links (ingredients, passport)
```
This means general product information is registered once at the GTIN level, while batch-specific recall notices or item-specific repair records can be added without duplicating data.
HTTP behavior [#http-behavior]
| Response | Condition |
| -------------------------- | ----------------------------------------------------- |
| **307 Temporary Redirect** | Successful resolution -- redirect to target URL |
| **400 Bad Request** | Malformed or invalid GS1 Digital Link URI |
| **404 Not Found** | Valid URI but no registered links for this identifier |
The resolver uses HTTP 307 (not 301) because target URLs may change over time as product data updates. 307 prevents browsers from caching the redirect permanently.
Default link [#default-link]
Every product must have exactly one default link. When no specific `linkType` is requested, the resolver redirects to the default link -- typically the consumer-facing passport page.
# Scan Analytics (https://eulabel.eu/docs/concepts/scan-analytics)
Every QR code scan generates a structured event that feeds into the analytics pipeline, giving you visibility into how products are being engaged with.
Scan event structure [#scan-event-structure]
When a consumer scans a QR code, the resolver captures a scan event before redirecting:
```json
{
"eventId": "evt_01H8ABC123XYZ",
"eventType": "scan",
"productId": "prd_01F8ABC123",
"timestamp": "2026-03-12T14:23:01Z",
"location": {
"country": "PT",
"region": "Lisboa",
"city": "Lisboa"
},
"referrer": "https://winebar.pt/menu",
"device": {
"type": "mobile",
"os": "iOS",
"browser": "Safari"
}
}
```
Privacy [#privacy]
* Location data is derived from IP geolocation, not device GPS
* No personally identifiable information is stored
* Events are captured at the resolver layer before the redirect, so analytics are recorded even if the passport page fails to load
What you can track [#what-you-can-track]
| Metric | Description |
| --------------------------- | ---------------------------------------------------------------------- |
| **Total scans** | Total number of QR code scans per product |
| **Geographic distribution** | Scans broken down by country, region, and city |
| **Device types** | Mobile, tablet, and desktop breakdown |
| **Top referrers** | Where scans are coming from (wine bar menus, e-commerce sites, direct) |
| **Time series** | Scans over time at day, week, or month granularity |
Accessing analytics [#accessing-analytics]
Use the [Analytics API endpoint](https://eulabel.eu/docs/api-reference/analytics) or the EUlabel Dashboard to view scan data.
```bash
curl "https://api.eulabel.eu/v1/analytics/product/a1b2c3d4-...?start=2026-01-01" \
-H "Authorization: Bearer sk_live_..."
```
Event types [#event-types]
The platform tracks events beyond QR scans:
| Event | Trigger | Description |
| ------------------- | -------------------------- | ------------------------- |
| `scan` | QR code scanned | Primary engagement metric |
| `api_request` | Passport retrieved via API | B2B integration usage |
| `passport_created` | New passport published | Product lifecycle event |
| `passport_updated` | Passport modified | Content change tracking |
| `webhook_delivered` | Webhook sent to subscriber | Integration monitoring |
# Quickstart (https://eulabel.eu/docs/quickstart)
This guide walks you through creating a product, attaching passport data, and retrieving it -- all with simple API calls.
Prerequisites [#prerequisites]
* An EUlabel account with an API key (format: `sk_live_...`)
* `curl` or any HTTP client
Step 1: Create a product [#step-1-create-a-product]
Every product needs a name, category, brand, and a valid GTIN (Global Trade Item Number).
```bash
curl -X POST https://api.eulabel.eu/v1/products \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Quinta do Crasto Douro Red 2021",
"category": "wine",
"brand": "Quinta do Crasto",
"gtin": "5601234567890"
}'
```
Response:
```json
{
"productId": "a1b2c3d4-...",
"qrCodeUrl": "https://eulabel.eu/01/05601234567890",
"createdAt": "2026-03-14T20:00:00.000Z"
}
```
Save the `productId` -- you'll need it for the next steps.
Step 2: Attach a Digital Product Passport [#step-2-attach-a-digital-product-passport]
Attach wine e-label data (ingredients, nutrition, allergens, origin) to your product:
```bash
curl -X POST https://api.eulabel.eu/v1/passports \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"productId": "a1b2c3d4-...",
"data": {
"productType": "wine",
"colour": "red",
"vintage": 2021,
"ingredients": ["Grapes (Touriga Nacional, Touriga Franca)", "Sulphur Dioxide"],
"nutrition": {
"energyKj": 351,
"energyKcal": 84,
"fatG": 0,
"saturatedFatG": 0,
"carbohydratesG": 0.6,
"sugarsG": 0.3,
"proteinG": 0.1,
"saltG": 0,
"alcoholG": 13.5
},
"allergens": {
"containsSulphites": true,
"containsEgg": false,
"containsFish": false,
"containsMilk": false
},
"origin": {
"country": "PT",
"region": "Douro",
"designation": "Douro DOC"
},
"producers": [
{ "name": "Quinta do Crasto", "role": "producer", "country": "PT" }
]
}
}'
```
Response:
```json
{
"passportId": "e5f6g7h8-...",
"productId": "a1b2c3d4-...",
"version": 1,
"status": "published",
"createdAt": "2026-03-14T20:00:00.000Z"
}
```
Step 3: Retrieve the passport [#step-3-retrieve-the-passport]
```bash
curl https://api.eulabel.eu/v1/products/a1b2c3d4-.../passport \
-H "Authorization: Bearer sk_live_YOUR_API_KEY"
```
The response contains the full structured passport data for your product.
Step 4: Download the QR code [#step-4-download-the-qr-code]
```bash
curl https://api.eulabel.eu/v1/products/a1b2c3d4-.../qr \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-o label-qr.svg
```
This generates an SVG QR code encoding a GS1 Digital Link URI. When scanned, the resolver routes users to the appropriate passport view.
What happens when the QR code is scanned [#what-happens-when-the-qr-code-is-scanned]
```
Consumer scans QR code on wine bottle
|
eulabel.eu/01/05601234567890
|
Resolver detects audience context
|
Passport page displayed with ingredients, nutrition, allergens
```
The resolver serves different data to different audiences -- consumers see the product story, regulators get structured compliance data, and recyclers see material composition.
Next steps [#next-steps]
* [Authentication](https://eulabel.eu/docs/authentication) -- Learn about API keys, scopes, and permissions
* [API Reference](https://eulabel.eu/docs/api-reference) -- Full endpoint documentation
* [SDK](https://eulabel.eu/docs/sdk) -- Use the TypeScript or Python SDK instead of raw HTTP
# Sandbox Environment (https://eulabel.eu/docs/quickstart/sandbox)
The sandbox environment lets you experiment with the API without affecting production data.
Sandbox endpoint [#sandbox-endpoint]
```
https://sandbox.api.eulabel.eu/v1
```
Use sandbox API keys (prefixed with `sk_test_`) to authenticate.
Pre-populated test data [#pre-populated-test-data]
The sandbox includes sample products ready to use:
| Product | GTIN | Category |
| -------------------- | ------------- | -------- |
| Mateus Rosé | 5601012011050 | wine |
| Sandeman Porto Tawny | 5601012012200 | wine |
| Gazela Vinho Verde | 5601012013900 | wine |
Sandbox vs Production [#sandbox-vs-production]
| Feature | Sandbox | Production |
| ---------------- | ------------------ | --------------- |
| API key prefix | `sk_test_` | `sk_live_` |
| Data persistence | Reset periodically | Permanent |
| Rate limits | Relaxed | Standard |
| QR codes | Not scannable | Live resolution |
| Webhooks | Test deliveries | Real deliveries |
Getting a sandbox API key [#getting-a-sandbox-api-key]
1. Log in to the [EUlabel Dashboard](https://app.eulabel.eu)
2. Navigate to **Settings > API Keys**
3. Click **Create Key** and select the **Sandbox** environment
4. Copy the key -- it is only shown once
Testing with the sandbox [#testing-with-the-sandbox]
```bash
export API_KEY="sk_test_YOUR_SANDBOX_KEY"
# List pre-populated products
curl https://sandbox.api.eulabel.eu/v1/products \
-H "Authorization: Bearer $API_KEY"
# Create a test product
curl -X POST https://sandbox.api.eulabel.eu/v1/products \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Test Wine","category":"wine","brand":"TestBrand","gtin":"4006381333931"}'
```
# SDK Overview (https://eulabel.eu/docs/sdk)
The EUlabel SDK provides typed, ergonomic client libraries that handle authentication, request formatting, retries, and error handling so you interact with a clean interface rather than raw HTTP.
Available SDKs [#available-sdks]
| Language | Package | Install |
| ----------------------- | -------------- | -------------------------- |
| TypeScript / JavaScript | `@eulabel/sdk` | `npm install @eulabel/sdk` |
| Python | `eulabel` | `pip install eulabel` |
What the SDK handles [#what-the-sdk-handles]
| Concern | Without SDK | With SDK |
| -------------- | ------------------------------------------ | ----------------------------------------------------- |
| Authentication | Manual token management, header formatting | Automatic, set once |
| API requests | Raw HTTP calls, URL construction | Typed methods with parameter validation |
| Error handling | Parse error responses, implement retries | Built-in retry with exponential backoff, typed errors |
| Versioning | Track API version changes manually | SDK version pinned to API version |
Quick example (TypeScript) [#quick-example-typescript]
```typescript
import { EUlabel } from '@eulabel/sdk';
const client = new EUlabel({ apiKey: 'sk_live_...' });
const product = await client.products.create({
name: 'Quinta da Bacalhoa Reserva 2022',
category: 'wine',
brand: 'Bacalhoa',
gtin: '5601234567890',
});
const passport = await client.passports.create({
productId: product.productId,
data: {
productType: 'wine',
ingredients: ['Grapes', 'Sulphites'],
nutrition: { energyKj: 351, energyKcal: 84, fatG: 0, saturatedFatG: 0, carbohydratesG: 1, sugarsG: 0.1, proteinG: 0, saltG: 0, alcoholG: 11.1 },
allergens: { containsSulphites: true, containsEgg: false, containsFish: false, containsMilk: false },
origin: { country: 'PT', region: 'Setubal' },
producers: [{ name: 'Bacalhoa', role: 'producer', country: 'PT' }],
},
});
```
Helper functions [#helper-functions]
| Helper | Purpose |
| ---------------------------------------------- | ---------------------------------------- |
| `client.products.getPassport(id)` | Retrieve a product's passport |
| `client.analytics.getProduct(id, opts)` | Get scan analytics with date filters |
| `client.webhooks.verify(payload, sig, secret)` | Verify an inbound webhook signature |
| `client.passports.validate(data)` | Validate passport data before submission |
# Python SDK (https://eulabel.eu/docs/sdk/python)
Installation [#installation]
```bash
pip install eulabel
```
Initialization [#initialization]
```python
from eulabel import EUlabel
client = EUlabel(api_key="sk_live_...")
```
Products [#products]
Create a product [#create-a-product]
```python
product = client.products.create(
name="Quinta do Crasto Douro Red 2021",
category="wine",
brand="Quinta do Crasto",
gtin="5601234567890",
)
print(product.product_id)
print(product.qr_code_url)
```
List products [#list-products]
```python
result = client.products.list()
for product in result.products:
print(product.name, product.gtin)
```
Passports [#passports]
Create a passport [#create-a-passport]
```python
passport = client.passports.create(
product_id="a1b2c3d4-...",
data={
"product_type": "wine",
"colour": "red",
"vintage": 2021,
"ingredients": ["Grapes (Touriga Nacional)", "Sulphur Dioxide"],
"nutrition": {
"energy_kj": 351,
"energy_kcal": 84,
"fat_g": 0,
"saturated_fat_g": 0,
"carbohydrates_g": 0.6,
"sugars_g": 0.3,
"protein_g": 0.1,
"salt_g": 0,
"alcohol_g": 13.5,
},
"allergens": {
"contains_sulphites": True,
"contains_egg": False,
"contains_fish": False,
"contains_milk": False,
},
"origin": {"country": "PT", "region": "Douro", "designation": "Douro DOC"},
"producers": [{"name": "Quinta do Crasto", "role": "producer", "country": "PT"}],
},
)
```
Retrieve a passport [#retrieve-a-passport]
```python
passport = client.products.get_passport("a1b2c3d4-...")
```
Analytics [#analytics]
```python
analytics = client.analytics.get_product(
"a1b2c3d4-...",
start="2026-01-01",
end="2026-03-14",
)
print(f"Total scans: {analytics.total_scans}")
print(f"Countries: {analytics.unique_countries}")
```
Error handling [#error-handling]
```python
from eulabel import EUlabel, EUlabelError
try:
client.products.create(name="Test", category="wine", brand="Test", gtin="invalid")
except EUlabelError as e:
print(e.type) # "validation_error"
print(e.message) # "Invalid GTIN check digit"
print(e.param) # "gtin"
```
# TypeScript SDK (https://eulabel.eu/docs/sdk/typescript)
Installation [#installation]
```bash
npm install @eulabel/sdk
```
```bash
pnpm add @eulabel/sdk
```
```bash
yarn add @eulabel/sdk
```
Initialization [#initialization]
```typescript
import { EUlabel } from '@eulabel/sdk';
const client = new EUlabel({
apiKey: process.env.EULABEL_API_KEY,
});
```
Products [#products]
Create a product [#create-a-product]
```typescript
const product = await client.products.create({
name: 'Quinta do Crasto Douro Red 2021',
category: 'wine',
brand: 'Quinta do Crasto',
gtin: '5601234567890',
});
console.log(product.productId);
console.log(product.qrCodeUrl);
```
List products [#list-products]
```typescript
const { products } = await client.products.list();
for (const product of products) {
console.log(product.name, product.gtin);
}
```
Get a product [#get-a-product]
```typescript
const product = await client.products.get('a1b2c3d4-...');
```
Passports [#passports]
Create a passport [#create-a-passport]
```typescript
const passport = await client.passports.create({
productId: 'a1b2c3d4-...',
data: {
productType: 'wine',
colour: 'red',
vintage: 2021,
ingredients: ['Grapes (Touriga Nacional)', 'Sulphur Dioxide'],
nutrition: {
energyKj: 351,
energyKcal: 84,
fatG: 0,
saturatedFatG: 0,
carbohydratesG: 0.6,
sugarsG: 0.3,
proteinG: 0.1,
saltG: 0,
alcoholG: 13.5,
},
allergens: {
containsSulphites: true,
containsEgg: false,
containsFish: false,
containsMilk: false,
},
origin: { country: 'PT', region: 'Douro', designation: 'Douro DOC' },
producers: [{ name: 'Quinta do Crasto', role: 'producer', country: 'PT' }],
},
});
```
Retrieve a passport [#retrieve-a-passport]
```typescript
const passport = await client.products.getPassport('a1b2c3d4-...');
```
Analytics [#analytics]
```typescript
const analytics = await client.analytics.getProduct('a1b2c3d4-...', {
start: '2026-01-01',
end: '2026-03-14',
});
console.log(`Total scans: ${analytics.totalScans}`);
console.log(`Countries: ${analytics.uniqueCountries}`);
```
Error handling [#error-handling]
```typescript
import { EUlabel, EUlabelError } from '@eulabel/sdk';
try {
await client.products.create({ name: 'Test', category: 'wine', brand: 'Test', gtin: 'invalid' });
} catch (error) {
if (error instanceof EUlabelError) {
console.error(error.type); // "validation_error"
console.error(error.message); // "Invalid GTIN check digit"
console.error(error.param); // "gtin"
}
}
```
Webhook verification [#webhook-verification]
```typescript
import { EUlabel } from '@eulabel/sdk';
const isValid = client.webhooks.verify(
requestBody,
request.headers['x-eulabel-signature'],
process.env.WEBHOOK_SECRET,
);
```
# Inbound Webhooks (https://eulabel.eu/docs/webhooks/inbound)
When product data changes in your PIM (e.g., DatoCMS, Salsify, Akeneo), a webhook triggers EUlabel to regenerate the passport automatically.
Flow [#flow]
1. A product record is created or updated in your PIM
2. The PIM sends a webhook POST to `https://api.eulabel.eu/v1/webhooks/ingest`
3. EUlabel validates the webhook signature
4. The platform maps PIM fields to passport fields
5. The passport is regenerated (or created if new)
6. The QR code URL continues to resolve to the updated passport
Example payload (DatoCMS) [#example-payload-datocms]
```json
POST https://api.eulabel.eu/v1/webhooks/ingest
Content-Type: application/json
X-Webhook-Signature: sha256=abc123...
{
"event_type": "item.update",
"entity_type": "item",
"entity": {
"id": "Kq7rxxPPS32ErMA8nxxMTA",
"type": "product",
"attributes": {
"product_name": "Quinta dos Carvalhais Alfrocheiro 2019",
"barcode": [
{
"barcode_number": "5601012012200",
"capacity_value": "750 mL",
"vintage_year": 2019
}
],
"list_of_ingredient": [
{ "ingredient_name": "Grapes", "quantity": "99.986%" },
{ "ingredient_name": "Sulphites", "quantity": "0.014%" }
],
"contains_sulfites": true,
"energy_value_kj": "351 kJ/100mL",
"alcohol_content_by_volume": "13.5%vol.",
"product_country": "PT",
"product_region": "Dao"
}
}
}
```
Signature verification [#signature-verification]
Every inbound webhook includes a signature header:
```
X-Webhook-Signature: sha256=
```
Verify it by computing the HMAC of the raw request body with your shared secret and comparing to the provided signature. Requests with invalid signatures are rejected with HTTP 401.
Verification example (TypeScript) [#verification-example-typescript]
```typescript
import { EUlabel } from "@eulabel/sdk";
const client = new EUlabel({ apiKey: "sk_live_..." });
const isValid = client.webhooks.verify(
requestBody,
request.headers["x-webhook-signature"],
process.env.WEBHOOK_SECRET,
);
```
Field mapping [#field-mapping]
EUlabel maps PIM-specific fields to the passport schema. Mapping configurations are stored per tenant, allowing each organization to define their own field correspondence.
| PIM Field | EUlabel Passport Field |
| ----------------------------------------- | ---------------------- |
| `product_name` | `name` |
| `barcode[].barcode_number` | `gtin` |
| `list_of_ingredient[].ingredient_name` | `ingredients[]` |
| `contains_sulfites`, `contains_egg`, etc. | `allergens` |
| `energy_value_kj`, `fat`, `sugars`, etc. | `nutrition` |
| `product_country` + `product_region` | `origin` |
| `producer[].name` | `supplier` |
Supported platforms [#supported-platforms]
| Platform | Integration | Status |
| ---------- | ------------------------------- | ----------- |
| DatoCMS | Native webhooks + GraphQL | Available |
| Salsify | Webhooks + REST API | Coming soon |
| Akeneo | Event API + REST | Coming soon |
| Contentful | Webhooks + Content Delivery API | Coming soon |
| Custom PIM | Generic webhook endpoint | Available |
For platforms without native webhook support, call the EUlabel API directly using the [SDK](https://eulabel.eu/docs/sdk).
# Webhooks (https://eulabel.eu/docs/webhooks)
EUlabel supports two directions of webhook communication:
* **Inbound webhooks**: Your PIM or CMS sends product updates to EUlabel, which automatically regenerates passports
* **Outbound webhooks**: EUlabel sends event notifications to your systems when passports change or scan thresholds are reached
Event types [#event-types]
| Event | Direction | Description |
| ----------------------------- | --------- | ----------------------------------------- |
| `item.update` / `item.create` | Inbound | PIM sends product data updates to EUlabel |
| `passport.created` | Outbound | A new passport was published |
| `passport.updated` | Outbound | An existing passport was modified |
| `passport.deleted` | Outbound | A passport was removed |
| `scan.threshold` | Outbound | Scan count crossed a configured threshold |
Signature verification [#signature-verification]
All webhooks (inbound and outbound) include a cryptographic signature for verification:
```
X-Webhook-Signature: sha256=
```
Always verify signatures before processing webhook payloads. See [Inbound webhooks](https://eulabel.eu/docs/webhooks/inbound) and [Outbound webhooks](https://eulabel.eu/docs/webhooks/outbound) for implementation details.
# Outbound Webhooks (https://eulabel.eu/docs/webhooks/outbound)
EUlabel sends outbound webhooks to notify your systems when passport lifecycle events occur.
Subscribable events [#subscribable-events]
| Event | Trigger |
| ------------------ | ----------------------------------------- |
| `passport.created` | A new passport is published |
| `passport.updated` | An existing passport is modified |
| `passport.deleted` | A passport is removed |
| `scan.threshold` | Scan count crosses a configured threshold |
Example payload [#example-payload]
```json
POST https://your-app.example.com/webhooks/eulabel
Content-Type: application/json
X-EUlabel-Signature: sha256=def456...
{
"event": "passport.updated",
"productId": "prd_01F8ABC123",
"passportId": "pp_01F8XYZ456",
"timestamp": "2026-03-12T17:38:00Z",
"changes": ["ingredients", "nutrition"],
"passportUrl": "https://eulabel.eu/passport/prd_01F8ABC123"
}
```
Signature verification [#signature-verification]
Outbound webhooks include a signature header for verification:
```
X-EUlabel-Signature: sha256=
```
Verification example [#verification-example]
```typescript
import { EUlabelWebhooks } from "@eulabel/webhooks";
const webhooks = new EUlabelWebhooks({ secret: "whsec_..." });
app.post("/webhooks/eulabel", async (req, res) => {
const event = webhooks.verify(req.body, req.headers["x-eulabel-signature"]);
switch (event.type) {
case "passport.updated":
await handlePassportUpdate(event);
break;
case "scan.threshold":
await notifyTeam(event);
break;
}
res.status(200).send("OK");
});
```
Retry policy [#retry-policy]
Failed deliveries are retried with exponential backoff:
| Attempt | Delay |
| ------- | ---------- |
| 1 | Immediate |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 15 minutes |
| 5 | 1 hour |
| 6 | 4 hours |
After 6 failed attempts, the webhook is marked as failed and the organization admin is notified via email.
Managing subscriptions [#managing-subscriptions]
Configure webhook subscriptions in the [EUlabel Dashboard](https://app.eulabel.eu) under **Settings > Webhooks**. Provide:
* **Endpoint URL**: The URL where EUlabel sends events
* **Events**: Which event types to subscribe to
* **Secret**: A shared secret for signature verification