Skip to main content
    Skip to content
    NorruvaDeveloper Docs
    Sandbox · verified 2026-07-29
    This page
    Whole docs
    Machine formats

    PlaygroundGet API keys
    IntroductionQuickstartAuthenticationErrors & conventionsSandbox & environments
    Entity modelProducts & categoriesPassports & versionsDigital Link & resolutionCompliance & regulationsWebhooks & eventsAutoID print loopImport jobsObservability & audit
    OverviewAuth & API keysProductsCompliance & regulationsPassportsResolver & publicWebhooksPrint jobs & devicesImport / bulkObservabilityBeyond happy path
    Integration playbookWebhook receiver guideRun a print deviceDeviations & gotchas
    EN 18222 API methodsAnnex ZA — ESPR correspondence
    Docs/Get started/Quickstart

    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.

    Quickstart progress
    0 / 0

    0 · Liveness

    GET/api/v2/healthno auth

    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.

    GET/api/v2/schemas/categoriesschema catalog: versions · jurisdiction · effective window · effectiveNow
    GET/api/v2/schemas/categories/{category}?jurisdiction=EU&date=<ISO>the EFFECTIVE schema + field definitions — exactly what creates/imports validate against
    GET/api/v2/products/categoriesthe tenant's own categories with usage counts
    POST/api/v2/compliance/regulations/select

    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.

    HTTP
    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

    HTTP
    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 + readyToPrint

    Compliance 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}}
    }
    ▲
    Deviation to expect. An out-of-scope call to 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

    HTTP
    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 check
    ▲
    Deviation to expect. The resolver currently returns 401 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.

    HTTP
    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 serial

    Full 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.

    Was this page helpful?
    Thanks — noted.Feedback goes to the docs team by email.
    ← PreviousIntroductionNext →Authentication
    On this page
    0 · Liveness1 · Authenticate2 · Discover3 · Create the product4 · Advance the lifecycle5 · Generate the passport6 · Validate compliance7 · Publish and register8 · Resolve like the outside world9 · Print the label (AutoID)
    Norruva DPP API · sandbox developer docsGenerated 2026-07-29 · PRD-aligned (TSC roadmap rev 2) · statuses reflect E2E-verified sandbox behaviour — not marketing