Quickstart
Follow these steps verbatim against a freshly-provisioned sandbox tenant and you will reach a publicly-resolvable passport without reading any source code. {{baseUrl}} is your sandbox host; {{key}} is a scoped API key.
0 · Liveness
Use this first in every smoke test. Machine-readable discovery lives at /api/v2/openapi.json (authenticated) and /api/v2/public/openapi.json.
1 · Authenticate
Mint a scoped API key (an admin credential is required to mint). The raw secret is shown once.
POST {{baseUrl}}/api/v2/api-keys
Authorization: Bearer {{adminKey}}
Content-Type: application/json
{ "name": "ci-integration-key",
"scopes": ["products:read", "products:create", "products:edit", "webhooks:manage"] }curl -X POST "$BASE_URL/api/v2/api-keys" \
-H "Authorization: Bearer $NORRUVA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "ci-integration-key",
"scopes": [
"products:read",
"products:create",
"products:edit",
"webhooks:manage"
]
}'const baseUrl = process.env.NORRUVA_BASE_URL;
const apiKey = process.env.NORRUVA_API_KEY;
const res = await fetch(`${baseUrl}/api/v2/api-keys`, {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": `application/json`
},
body: JSON.stringify({
"name": "ci-integration-key",
"scopes": [
"products:read",
"products:create",
"products:edit",
"webhooks:manage"
]
})
});
if (!res.ok) {
const err = await res.json(); // typed envelope: { error: { code, message, details? } }
throw new Error(`${res.status} ${err.error?.code}: ${err.error?.message}`);
}
const data = await res.json();import os, uuid, requests
base_url = os.environ["NORRUVA_BASE_URL"]
api_key = os.environ["NORRUVA_API_KEY"]
resp = requests.post(
f"{base_url}/api/v2/api-keys",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": f"application/json"
},
json={
"name": "ci-integration-key",
"scopes": [
"products:read",
"products:create",
"products:edit",
"webhooks:manage"
]
},
)
resp.raise_for_status() # error body is the typed envelope: {"error": {"code", "message", "details"}}
data = resp.json()package main
import (
"bytes"
"fmt"
"net/http"
"os"
)
func main() {
baseURL := os.Getenv("NORRUVA_BASE_URL")
apiKey := os.Getenv("NORRUVA_API_KEY")
url := fmt.Sprintf("%s/api/v2/api-keys", baseURL)
payload := []byte(`{
"name": "ci-integration-key",
"scopes": [
"products:read",
"products:create",
"products:edit",
"webhooks:manage"
]
}`)
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(payload))
req.Header.Set("Authorization", "Bearer "+apiKey+"")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
fmt.Println(resp.Status) // non-2xx bodies use the typed envelope {error:{code,message,details}}
}201 → data.key is the secret (store it now); data.keyPrefix is what listings show later. Prefer machine-to-machine? Use OAuth client-credentials instead.
2 · Discover
Learn which schema each category resolves to (and which version is current), then which regulations apply — before creating anything.
Schema discovery is point-in-time and jurisdiction-aware (the same T101 resolution the create path runs), works with the read-only TSC key, and a miss returns a typed 404 SCHEMA_NOT_FOUND listing availableCategories. The response links straight to the category's CSV import template.
3 · Create the product
POST {{baseUrl}}/api/v2/products
Authorization: Bearer {{key}}
Content-Type: application/json
Idempotency-Key: <uuid>
{ "name": "EcoCell Battery Pack XR-2024",
"description": "High-capacity EV battery with certified recycled content",
"category": "battery",
"gtin": "04012345000016",
"serialNumber": "SN-BAT-2024-000001",
"extensions": { /* category-specific fields */ } }curl -X POST "$BASE_URL/api/v2/products" \
-H "Authorization: Bearer $NORRUVA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"name": "EcoCell Battery Pack XR-2024",
"description": "High-capacity EV battery with certified recycled content",
"category": "battery",
"gtin": "04012345000016",
"serialNumber": "SN-BAT-2024-000001",
"extensions": {}
}'const baseUrl = process.env.NORRUVA_BASE_URL;
const apiKey = process.env.NORRUVA_API_KEY;
const res = await fetch(`${baseUrl}/api/v2/products`, {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": `application/json`,
"Idempotency-Key": `${crypto.randomUUID()}`
},
body: JSON.stringify({
"name": "EcoCell Battery Pack XR-2024",
"description": "High-capacity EV battery with certified recycled content",
"category": "battery",
"gtin": "04012345000016",
"serialNumber": "SN-BAT-2024-000001",
"extensions": {}
})
});
if (!res.ok) {
const err = await res.json(); // typed envelope: { error: { code, message, details? } }
throw new Error(`${res.status} ${err.error?.code}: ${err.error?.message}`);
}
const data = await res.json();import os, uuid, requests
base_url = os.environ["NORRUVA_BASE_URL"]
api_key = os.environ["NORRUVA_API_KEY"]
resp = requests.post(
f"{base_url}/api/v2/products",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": f"application/json",
"Idempotency-Key": f"{uuid.uuid4()}"
},
json={
"name": "EcoCell Battery Pack XR-2024",
"description": "High-capacity EV battery with certified recycled content",
"category": "battery",
"gtin": "04012345000016",
"serialNumber": "SN-BAT-2024-000001",
"extensions": {}
},
)
resp.raise_for_status() # error body is the typed envelope: {"error": {"code", "message", "details"}}
data = resp.json()package main
import (
"bytes"
"fmt"
"net/http"
"os"
)
func main() {
baseURL := os.Getenv("NORRUVA_BASE_URL")
apiKey := os.Getenv("NORRUVA_API_KEY")
url := fmt.Sprintf("%s/api/v2/products", baseURL)
payload := []byte(`{
"name": "EcoCell Battery Pack XR-2024",
"description": "High-capacity EV battery with certified recycled content",
"category": "battery",
"gtin": "04012345000016",
"serialNumber": "SN-BAT-2024-000001",
"extensions": {}
}`)
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(payload))
req.Header.Set("Authorization", "Bearer "+apiKey+"")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "REPLACE-WITH-UUIDv4")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
fmt.Println(resp.Status) // non-2xx bodies use the typed envelope {error:{code,message,details}}
}201 → capture data.id as {{productId}}. Category schemas drive extensions (electronics ≈ 8 fields, battery ≈ 30). See Products & categories for the guardrails. The GTIN is check-digit-validated and normalized to 14 digits at write time. The serial shown is DL/QR-capable; EPC/RFID carrier generation additionally requires a numeric serial inside the SGTIN-96 budget (≤ 238) — see Serial rules.
4 · Advance the lifecycle
Two transitions are required before a passport can be generated. Each accepts an Idempotency-Key; illegal transitions are typed errors, not silent no-ops.
POST {{baseUrl}}/api/v2/products/{{productId}}/lifecycle/transition
{ "stage": "validated", "reason": "Schema validation passed" }
POST {{baseUrl}}/api/v2/products/{{productId}}/lifecycle/transition
{ "stage": "ready_for_dpp", "reason": "Completeness check passed" }5 · Generate the passport
POST {{baseUrl}}/api/v2/dpp/generate
Idempotency-Key: <uuid>
{ "productId": "{{productId}}",
"strictness": "advisory",
"region": "EU",
"lifecycleStage": "production",
"dppOptions": { "requestId": "dpp-gen-001",
"manufacturerDid": "did:ebsi:example-gmbh",
"anchorToBlockchain": false } }curl -X POST "$BASE_URL/api/v2/dpp/generate" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"productId": "{{productId}}",
"strictness": "advisory",
"region": "EU",
"lifecycleStage": "production",
"dppOptions": {
"requestId": "dpp-gen-001",
"manufacturerDid": "did:ebsi:example-gmbh",
"anchorToBlockchain": false
}
}'const baseUrl = process.env.NORRUVA_BASE_URL;
const res = await fetch(`${baseUrl}/api/v2/dpp/generate`, {
method: "POST",
headers: {
"Idempotency-Key": `${crypto.randomUUID()}`
},
body: JSON.stringify({
"productId": "{{productId}}",
"strictness": "advisory",
"region": "EU",
"lifecycleStage": "production",
"dppOptions": {
"requestId": "dpp-gen-001",
"manufacturerDid": "did:ebsi:example-gmbh",
"anchorToBlockchain": false
}
})
});
if (!res.ok) {
const err = await res.json(); // typed envelope: { error: { code, message, details? } }
throw new Error(`${res.status} ${err.error?.code}: ${err.error?.message}`);
}
const data = await res.json();import os, uuid, requests
base_url = os.environ["NORRUVA_BASE_URL"]
resp = requests.post(
f"{base_url}/api/v2/dpp/generate",
headers={
"Idempotency-Key": f"{uuid.uuid4()}"
},
json={
"productId": "{{productId}}",
"strictness": "advisory",
"region": "EU",
"lifecycleStage": "production",
"dppOptions": {
"requestId": "dpp-gen-001",
"manufacturerDid": "did:ebsi:example-gmbh",
"anchorToBlockchain": False
}
},
)
resp.raise_for_status() # error body is the typed envelope: {"error": {"code", "message", "details"}}
data = resp.json()package main
import (
"bytes"
"fmt"
"net/http"
"os"
)
func main() {
baseURL := os.Getenv("NORRUVA_BASE_URL")
url := fmt.Sprintf("%s/api/v2/dpp/generate", baseURL)
payload := []byte(`{
"productId": "{{productId}}",
"strictness": "advisory",
"region": "EU",
"lifecycleStage": "production",
"dppOptions": {
"requestId": "dpp-gen-001",
"manufacturerDid": "did:ebsi:example-gmbh",
"anchorToBlockchain": false
}
}`)
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(payload))
req.Header.Set("Idempotency-Key", "REPLACE-WITH-UUIDv4")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
fmt.Println(resp.Status) // non-2xx bodies use the typed envelope {error:{code,message,details}}
}Runs a 6-stage pipeline; poll GET /api/v2/dpp/status/{requestId}. In sandbox anchorToBlockchain is always effectively false — sandbox never anchors.
6 · Validate compliance
POST {{baseUrl}}/api/v2/compliance/validate { "productId": "{{productId}}" }
GET {{baseUrl}}/api/v2/products/{{productId}}/compliance/espr/full
GET {{baseUrl}}/api/v2/passports/{{passportUid}}/validations → validation chain + readyToPrintCompliance is fail-closed: a product with no compliance data scores 0 / Indeterminate with findings naming what is missing — never FullyCompliant. After publish, readyToPrint on the validations response is the derived go/no-go signal (= validated ∧ published, PRD F6). See Compliance.
7 · Publish and register
7a — publish mints the public passport content and returns {{passportUid}}:
POST {{baseUrl}}/api/v2/dpp/{{productId}}/publish
Idempotency-Key: <uuid>
{ "amendmentReason": "Initial publication",
"updates": { "batteryPassport": { "capacity": 85, "cycleLife": 1500 } } }curl -X POST "$BASE_URL/api/v2/dpp/{{productId}}/publish" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"amendmentReason": "Initial publication",
"updates": {
"batteryPassport": {
"capacity": 85,
"cycleLife": 1500
}
}
}'const baseUrl = process.env.NORRUVA_BASE_URL;
const productId = "…";
const res = await fetch(`${baseUrl}/api/v2/dpp/${productId}/publish`, {
method: "POST",
headers: {
"Idempotency-Key": `${crypto.randomUUID()}`
},
body: JSON.stringify({
"amendmentReason": "Initial publication",
"updates": {
"batteryPassport": {
"capacity": 85,
"cycleLife": 1500
}
}
})
});
if (!res.ok) {
const err = await res.json(); // typed envelope: { error: { code, message, details? } }
throw new Error(`${res.status} ${err.error?.code}: ${err.error?.message}`);
}
const data = await res.json();import os, uuid, requests
base_url = os.environ["NORRUVA_BASE_URL"]
product_id = "…"
resp = requests.post(
f"{base_url}/api/v2/dpp/{product_id}/publish",
headers={
"Idempotency-Key": f"{uuid.uuid4()}"
},
json={
"amendmentReason": "Initial publication",
"updates": {
"batteryPassport": {
"capacity": 85,
"cycleLife": 1500
}
}
},
)
resp.raise_for_status() # error body is the typed envelope: {"error": {"code", "message", "details"}}
data = resp.json()package main
import (
"bytes"
"fmt"
"net/http"
"os"
)
func main() {
baseURL := os.Getenv("NORRUVA_BASE_URL")
productId := "…"
url := fmt.Sprintf("%s/api/v2/dpp/%s/publish", baseURL, productId)
payload := []byte(`{
"amendmentReason": "Initial publication",
"updates": {
"batteryPassport": {
"capacity": 85,
"cycleLife": 1500
}
}
}`)
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(payload))
req.Header.Set("Idempotency-Key", "REPLACE-WITH-UUIDv4")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
fmt.Println(resp.Status) // non-2xx bodies use the typed envelope {error:{code,message,details}}
}7b — register the Digital Link (this is the deliberate step that makes the item resolvable — Idempotency-Key is required here):
POST {{baseUrl}}/api/v2/products/{{productId}}/publish-dpp
Idempotency-Key: <uuid>
{ "publishToWallet": false }curl -X POST "$BASE_URL/api/v2/products/{{productId}}/publish-dpp" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"publishToWallet": false
}'const baseUrl = process.env.NORRUVA_BASE_URL;
const productId = "…";
const res = await fetch(`${baseUrl}/api/v2/products/${productId}/publish-dpp`, {
method: "POST",
headers: {
"Idempotency-Key": `${crypto.randomUUID()}`
},
body: JSON.stringify({
"publishToWallet": false
})
});
if (!res.ok) {
const err = await res.json(); // typed envelope: { error: { code, message, details? } }
throw new Error(`${res.status} ${err.error?.code}: ${err.error?.message}`);
}
const data = await res.json();import os, uuid, requests
base_url = os.environ["NORRUVA_BASE_URL"]
product_id = "…"
resp = requests.post(
f"{base_url}/api/v2/products/{product_id}/publish-dpp",
headers={
"Idempotency-Key": f"{uuid.uuid4()}"
},
json={
"publishToWallet": False
},
)
resp.raise_for_status() # error body is the typed envelope: {"error": {"code", "message", "details"}}
data = resp.json()package main
import (
"bytes"
"fmt"
"net/http"
"os"
)
func main() {
baseURL := os.Getenv("NORRUVA_BASE_URL")
productId := "…"
url := fmt.Sprintf("%s/api/v2/products/%s/publish-dpp", baseURL, productId)
payload := []byte(`{
"publishToWallet": false
}`)
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(payload))
req.Header.Set("Idempotency-Key", "REPLACE-WITH-UUIDv4")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
fmt.Println(resp.Status) // non-2xx bodies use the typed envelope {error:{code,message,details}}
}publish-dpp returns 403 FORBIDDEN, not 403 API_SCOPE_DENIED — a different auth wrapper. Branch on both. See Deviations.8 · Resolve like the outside world
GET {{baseUrl}}/api/v2/01/{{gtin}}/21/{{serial}} → 302 to /p/{uid} (uid = product id)
GET {{baseUrl}}/api/v2/public/gs1/01/{{gtin}}/21/{{serial}} → machine resolver (public; linkset via Accept)
GET {{baseUrl}}/api/v2/.well-known/gs1resolver → resolver discovery document (public)
GET {{baseUrl}}/api/v2/public/passport/{{passportUid}} → audience-filtered JSON
GET {{baseUrl}}/api/v2/public/passport/{{passportUid}}/verify → signature + anchor check401 when no Authorization header is present. Probes send Authorization: Bearer edge-gate-placeholder. A public scanner shouldn't need this; it is tracked and documented in Deviations.9 · Print the label (AutoID)
Close the physical loop: anchor a validation (the F6 gate's missing half if step 6 left readyToPrint: false), allocate a carrier serial, issue a signed print job, drive it through the evidence-gated lifecycle, and watch the outcome land on the spine.
POST {{baseUrl}}/api/v2/passports/{{passportUid}}/validations { "vc": { … } } → anchored
POST {{baseUrl}}/api/v2/carriers/batch { "productId": "{{productId}}", "count": 1 }
GET {{baseUrl}}/api/v2/carriers/export?productId={{productId}}&format=json → the serial
POST {{baseUrl}}/api/v2/aidc/jobs { "gtin": "{{gtin}}", "serial": "{{serial}}" }
POST {{baseUrl}}/api/v2/aidc/jobs/claim { "limit": 1 } (device-bound key)
POST {{baseUrl}}/api/v2/aidc/jobs/{{jobRef}}/result … RENDERED → SPOOL_SUBMITTED → PRINTED_ATTESTED
→ SCAN_VERIFIED → SEALED (with evidence each)
GET {{baseUrl}}/api/v2/carriers/export?productId={{productId}}&printState=printed → your serialFull lifecycle and guarantees: AutoID print loop · endpoint reference: Print jobs & devices · device walkthrough: Run a print device.
From here: subscribe to Webhooks instead of polling, run bulk imports, and close the loop with observability.