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/API reference/Products

    Products

    Create and manage products and advance them toward passport generation. Update with PATCH (canonical, PRD F1) or PUT (alias) — identical semantics.

    GET/api/v2/productsList products
    POST/api/v2/productsCreate a product
    GET/api/v2/products/{id}Get a product
    PATCH/api/v2/products/{id}Update a product
    DELETE/api/v2/products/{id}Delete a product
    POST/api/v2/products/batchBatch create products
    GET/api/v2/products/{id}/versionsProduct versions
    POST/api/v2/products/{id}/lifecycle/transitionAdvance product lifecycle
    GET/api/v2/products/{id}/eventsProduct domain events
    POST/api/v2/products/{id}/voidVoid a serialized item
    GET/api/v2/products/categoriesTenant categories
    GET/api/v2/schemas/categoriesSchema catalog
    GET/api/v2/schemas/categories/{category}Effective category schema
    GET/api/v2/schemas/categories/{category}/import-templateCategory import template
    KEYproducts:readproducts:view is an accepted legacy alias

    List the tenant's products.

    Request
    GET {{baseUrl}}/api/v2/products
    Authorization: Bearer {{apiKey}}
    curl -X GET "$BASE_URL/api/v2/products" \
      -H "Authorization: Bearer $NORRUVA_API_KEY"
    const baseUrl = process.env.NORRUVA_BASE_URL;
    const apiKey = process.env.NORRUVA_API_KEY;
    
    const res = await fetch(`${baseUrl}/api/v2/products`, {
      method: "GET",
      headers: {
        "Authorization": `Bearer ${apiKey}`
      }
    });
    
    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.get(
        f"{base_url}/api/v2/products",
        headers={
            "Authorization": f"Bearer {api_key}"
        },
    )
    resp.raise_for_status()   # error body is the typed envelope: {"error": {"code", "message", "details"}}
    data = resp.json()
    package main
    
    import (
    	"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)
    
    	req, _ := http.NewRequest("GET", url, nil)
    	req.Header.Set("Authorization", "Bearer "+apiKey+"")
    
    	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}}
    }
    Related
    Products & categories
    KEYproducts:createIdempotency-Key recommended

    Create a tenant-owned commercial item. Creating a product does not make it public and does not register a resolvable identifier.

    ▲
    A second model-granularity product with the same GTIN in the same tenant is a 409 conflict — (company, gtin) is unique.
    Parameters
    namerequiredbody · stringProduct name.
    descriptionrequiredbody · stringMust be ≥ 10 characters, else 400 VALIDATION_ERROR.
    categoryrequiredbody · stringResolves to an effective schema that governs extensions. No effective schema → 422 SCHEMA_NOT_FOUND.
    gtinrequiredbody · stringCheck-digit-validated; a valid GTIN-8/12/13 is normalized to 14 digits at write time.
    serialNumberrequiredbody · string≤ 20 chars (SGTIN-96 bound). Numeric serials must fit ≤ 2^38 for EPC carrier generation.
    extensionsrequiredbody · objectCategory-specific fields (electronics ≈ 8, battery ≈ 30). Missing → 422 EXTENSIONS_REQUIRED.
    Request
    POST {{baseUrl}}/api/v2/products
    Authorization: Bearer {{apiKey}}
    Content-Type: application/json
    Idempotency-Key: {{uuid}}
    
    {
      // electronics, not battery: battery compliance is quarantined (ATLAS-F017),
      // so a battery product cannot complete validate → publish-dpp → resolve today.
      "name": "EcoCell Charger XR-2024",
      "description": "Fast charger with certified recycled content",
      "category": "electronics",
      "gtin": "04012345000016",
      "serialNumber": "SN-ELEC-2024-000001",
      "extensions": {
        "energyEfficiencyClass": "A",
        "annualEnergyConsumptionKwh": 42,
        "repairabilityScore": 8.5,
        "sparePartsAvailabilityYears": 10,
        "hazardousSubstances": [],
        "recycledContentPercentage": 35,
        "productLifetimeYears": 12,
        // enum, NOT a WEEE number: temperature_exchange | screens_monitors | lamps
        //  | large_equipment | small_equipment | small_it_telecom
        "weeeCategory": "small_it_telecom"
      }
    }
    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 Charger XR-2024",
      "description": "Fast charger with certified recycled content",
      "category": "electronics",
      "gtin": "04012345000016",
      "serialNumber": "SN-ELEC-2024-000001",
      "extensions": {
        "energyEfficiencyClass": "A",
        "annualEnergyConsumptionKwh": 42,
        "repairabilityScore": 8.5,
        "sparePartsAvailabilityYears": 10,
        "hazardousSubstances": [],
        "recycledContentPercentage": 35,
        "productLifetimeYears": 12,
        "weeeCategory": "small_it_telecom"
      }
    }'
    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 Charger XR-2024",
        "description": "Fast charger with certified recycled content",
        "category": "electronics",
        "gtin": "04012345000016",
        "serialNumber": "SN-ELEC-2024-000001",
        "extensions": {
          "energyEfficiencyClass": "A",
          "annualEnergyConsumptionKwh": 42,
          "repairabilityScore": 8.5,
          "sparePartsAvailabilityYears": 10,
          "hazardousSubstances": [],
          "recycledContentPercentage": 35,
          "productLifetimeYears": 12,
          "weeeCategory": "small_it_telecom"
        }
      })
    });
    
    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 Charger XR-2024",
          "description": "Fast charger with certified recycled content",
          "category": "electronics",
          "gtin": "04012345000016",
          "serialNumber": "SN-ELEC-2024-000001",
          "extensions": {
            "energyEfficiencyClass": "A",
            "annualEnergyConsumptionKwh": 42,
            "repairabilityScore": 8.5,
            "sparePartsAvailabilityYears": 10,
            "hazardousSubstances": [],
            "recycledContentPercentage": 35,
            "productLifetimeYears": 12,
            "weeeCategory": "small_it_telecom"
          }
        },
    )
    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 Charger XR-2024",
      "description": "Fast charger with certified recycled content",
      "category": "electronics",
      "gtin": "04012345000016",
      "serialNumber": "SN-ELEC-2024-000001",
      "extensions": {
        "energyEfficiencyClass": "A",
        "annualEnergyConsumptionKwh": 42,
        "repairabilityScore": 8.5,
        "sparePartsAvailabilityYears": 10,
        "hazardousSubstances": [],
        "recycledContentPercentage": 35,
        "productLifetimeYears": 12,
        "weeeCategory": "small_it_telecom"
      }
    }`)
    	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}}
    }
    Response

    201 Created — the product is returned UNWRAPPED; capture id (not data.id). GET /api/v2/products does use a data envelope, and the write path renames name → productName.

    JSON
    {
      "id": "…uuid…",              // capture as {{productId}} — NOT under a "data" envelope
      "productName": "EcoCell Battery Pack XR-2024",   // request field is "name"
      "productDescription": "…",   // request field is "description"
      "category": "battery",
      "status": "Draft",
      "gtin": "04012345000016"
    }
    Errors you can branch on
    400 VALIDATION_ERROR422 EXTENSIONS_REQUIRED422 SCHEMA_NOT_FOUND403 API_SCOPE_DENIED409
    Emits events
    product.created
    Related
    Products & categoriesQuickstart
    KEYproducts:read

    Read one product. Cross-tenant reads fail closed with 404 — authz runs before existence.

    Request
    GET {{baseUrl}}/api/v2/products/{id}
    Authorization: Bearer {{apiKey}}
    curl -X GET "$BASE_URL/api/v2/products/{id}" \
      -H "Authorization: Bearer $NORRUVA_API_KEY"
    const baseUrl = process.env.NORRUVA_BASE_URL;
    const apiKey = process.env.NORRUVA_API_KEY;
    const id = "…";
    
    const res = await fetch(`${baseUrl}/api/v2/products/${id}`, {
      method: "GET",
      headers: {
        "Authorization": `Bearer ${apiKey}`
      }
    });
    
    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"]
    id = "…"
    
    resp = requests.get(
        f"{base_url}/api/v2/products/{id}",
        headers={
            "Authorization": f"Bearer {api_key}"
        },
    )
    resp.raise_for_status()   # error body is the typed envelope: {"error": {"code", "message", "details"}}
    data = resp.json()
    package main
    
    import (
    	"fmt"
    	"net/http"
    	"os"
    )
    
    func main() {
    	baseURL := os.Getenv("NORRUVA_BASE_URL")
    	apiKey := os.Getenv("NORRUVA_API_KEY")
    	id := "…"
    	url := fmt.Sprintf("%s/api/v2/products/%s", baseURL, id)
    
    	req, _ := http.NewRequest("GET", url, nil)
    	req.Header.Set("Authorization", "Bearer "+apiKey+"")
    
    	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}}
    }
    Errors you can branch on
    404
    Related
    Products & categories
    KEYproducts:edit

    Update a product. PATCH is canonical (PRD F1); PUT is an accepted alias — both run the same full-update handler.

    Request
    PATCH {{baseUrl}}/api/v2/products/{id}
    Authorization: Bearer {{apiKey}}
    Content-Type: application/json
    curl -X PATCH "$BASE_URL/api/v2/products/{id}" \
      -H "Authorization: Bearer $NORRUVA_API_KEY" \
      -H "Content-Type: application/json"
    const baseUrl = process.env.NORRUVA_BASE_URL;
    const apiKey = process.env.NORRUVA_API_KEY;
    const id = "…";
    
    const res = await fetch(`${baseUrl}/api/v2/products/${id}`, {
      method: "PATCH",
      headers: {
        "Authorization": `Bearer ${apiKey}`,
        "Content-Type": `application/json`
      }
    });
    
    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"]
    id = "…"
    
    resp = requests.patch(
        f"{base_url}/api/v2/products/{id}",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": f"application/json"
        },
    )
    resp.raise_for_status()   # error body is the typed envelope: {"error": {"code", "message", "details"}}
    data = resp.json()
    package main
    
    import (
    	"fmt"
    	"net/http"
    	"os"
    )
    
    func main() {
    	baseURL := os.Getenv("NORRUVA_BASE_URL")
    	apiKey := os.Getenv("NORRUVA_API_KEY")
    	id := "…"
    	url := fmt.Sprintf("%s/api/v2/products/%s", baseURL, id)
    
    	req, _ := http.NewRequest("PATCH", url, nil)
    	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}}
    }
    Errors you can branch on
    400 VALIDATION_ERROR404405
    Emits events
    product.updated
    Related
    Products & categories
    KEYproducts:delete

    Hard-delete a product that has no retained print-loop history. A product whose serials ever entered the AIDC print loop cannot be deleted — what physically printed is retained evidence — and answers 409 PRODUCT_REFERENCED; void it instead (POST /products/{id}/void), which is its terminal state.

    Request
    DELETE {{baseUrl}}/api/v2/products/{id}
    Authorization: Bearer {{apiKey}}
    curl -X DELETE "$BASE_URL/api/v2/products/{id}" \
      -H "Authorization: Bearer $NORRUVA_API_KEY"
    const baseUrl = process.env.NORRUVA_BASE_URL;
    const apiKey = process.env.NORRUVA_API_KEY;
    const id = "…";
    
    const res = await fetch(`${baseUrl}/api/v2/products/${id}`, {
      method: "DELETE",
      headers: {
        "Authorization": `Bearer ${apiKey}`
      }
    });
    
    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"]
    id = "…"
    
    resp = requests.delete(
        f"{base_url}/api/v2/products/{id}",
        headers={
            "Authorization": f"Bearer {api_key}"
        },
    )
    resp.raise_for_status()   # error body is the typed envelope: {"error": {"code", "message", "details"}}
    data = resp.json()
    package main
    
    import (
    	"fmt"
    	"net/http"
    	"os"
    )
    
    func main() {
    	baseURL := os.Getenv("NORRUVA_BASE_URL")
    	apiKey := os.Getenv("NORRUVA_API_KEY")
    	id := "…"
    	url := fmt.Sprintf("%s/api/v2/products/%s", baseURL, id)
    
    	req, _ := http.NewRequest("DELETE", url, nil)
    	req.Header.Set("Authorization", "Bearer "+apiKey+"")
    
    	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}}
    }
    Response

    204 No Content. 409 PRODUCT_REFERENCED when print_jobs still reference one of the product's serials — the product stays addressable and voidable.

    Errors you can branch on
    404409
    Emits events
    product.deleted
    Related
    Products & categories
    KEYproducts:createIdempotency-Key recommended

    Create 1–1000 products in one call — same item contract as single create, per-index error isolation.

    Request
    POST {{baseUrl}}/api/v2/products/batch
    Authorization: Bearer {{apiKey}}
    Content-Type: application/json
    Idempotency-Key: {{uuid}}
    curl -X POST "$BASE_URL/api/v2/products/batch" \
      -H "Authorization: Bearer $NORRUVA_API_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: $(uuidgen)"
    const baseUrl = process.env.NORRUVA_BASE_URL;
    const apiKey = process.env.NORRUVA_API_KEY;
    
    const res = await fetch(`${baseUrl}/api/v2/products/batch`, {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${apiKey}`,
        "Content-Type": `application/json`,
        "Idempotency-Key": `${crypto.randomUUID()}`
      }
    });
    
    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/batch",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": f"application/json",
            "Idempotency-Key": f"{uuid.uuid4()}"
        },
    )
    resp.raise_for_status()   # error body is the typed envelope: {"error": {"code", "message", "details"}}
    data = resp.json()
    package main
    
    import (
    	"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/batch", baseURL)
    
    	req, _ := http.NewRequest("POST", url, nil)
    	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}}
    }
    Response

    200 — per-index isolation: good rows commit even when others fail. Each errors[] item is { index, error } with the failure code prefixed into the error string.

    JSON
    {
      "created": 998,
      "failed": 2,
      "errors": [ { "index": 14, "error": "VALIDATION_ERROR: productDescription: Description must be at least 10 characters." } ]
    }
    Errors you can branch on
    400 VALIDATION_ERROR
    Related
    Import jobs
    KEYproducts:read

    Product version list (PRD F1).

    Request
    GET {{baseUrl}}/api/v2/products/{id}/versions
    Authorization: Bearer {{apiKey}}
    curl -X GET "$BASE_URL/api/v2/products/{id}/versions" \
      -H "Authorization: Bearer $NORRUVA_API_KEY"
    const baseUrl = process.env.NORRUVA_BASE_URL;
    const apiKey = process.env.NORRUVA_API_KEY;
    const id = "…";
    
    const res = await fetch(`${baseUrl}/api/v2/products/${id}/versions`, {
      method: "GET",
      headers: {
        "Authorization": `Bearer ${apiKey}`
      }
    });
    
    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"]
    id = "…"
    
    resp = requests.get(
        f"{base_url}/api/v2/products/{id}/versions",
        headers={
            "Authorization": f"Bearer {api_key}"
        },
    )
    resp.raise_for_status()   # error body is the typed envelope: {"error": {"code", "message", "details"}}
    data = resp.json()
    package main
    
    import (
    	"fmt"
    	"net/http"
    	"os"
    )
    
    func main() {
    	baseURL := os.Getenv("NORRUVA_BASE_URL")
    	apiKey := os.Getenv("NORRUVA_API_KEY")
    	id := "…"
    	url := fmt.Sprintf("%s/api/v2/products/%s/versions", baseURL, id)
    
    	req, _ := http.NewRequest("GET", url, nil)
    	req.Header.Set("Authorization", "Bearer "+apiKey+"")
    
    	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}}
    }
    Related
    Passports & versions
    KEYproducts:editIdempotency-Key recommended

    Move draft → validated → ready_for_dpp. Two transitions are required before passport generation. Illegal transitions are typed errors, not silent no-ops.

    Parameters
    stagerequiredbody · stringTarget stage, e.g. "validated" then "ready_for_dpp".
    reasonoptionalbody · stringRecorded on the transition event.
    Request
    POST {{baseUrl}}/api/v2/products/{id}/lifecycle/transition
    Authorization: Bearer {{apiKey}}
    Content-Type: application/json
    Idempotency-Key: {{uuid}}
    
    {
      "stage": "validated",
      "reason": "Schema validation passed"
    }
    curl -X POST "$BASE_URL/api/v2/products/{id}/lifecycle/transition" \
      -H "Authorization: Bearer $NORRUVA_API_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: $(uuidgen)" \
      -d '{
      "stage": "validated",
      "reason": "Schema validation passed"
    }'
    const baseUrl = process.env.NORRUVA_BASE_URL;
    const apiKey = process.env.NORRUVA_API_KEY;
    const id = "…";
    
    const res = await fetch(`${baseUrl}/api/v2/products/${id}/lifecycle/transition`, {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${apiKey}`,
        "Content-Type": `application/json`,
        "Idempotency-Key": `${crypto.randomUUID()}`
      },
      body: JSON.stringify({
        "stage": "validated",
        "reason": "Schema validation passed"
      })
    });
    
    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"]
    id = "…"
    
    resp = requests.post(
        f"{base_url}/api/v2/products/{id}/lifecycle/transition",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": f"application/json",
            "Idempotency-Key": f"{uuid.uuid4()}"
        },
        json={
          "stage": "validated",
          "reason": "Schema validation passed"
        },
    )
    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")
    	id := "…"
    	url := fmt.Sprintf("%s/api/v2/products/%s/lifecycle/transition", baseURL, id)
    
    	payload := []byte(`{
      "stage": "validated",
      "reason": "Schema validation passed"
    }`)
    	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}}
    }
    Errors you can branch on
    400 VALIDATION_ERROR404
    Emits events
    product.updated
    Related
    QuickstartProducts & categories
    KEYproducts:read

    Domain events for one product — the per-product observability surface.

    Request
    GET {{baseUrl}}/api/v2/products/{id}/events
    Authorization: Bearer {{apiKey}}
    curl -X GET "$BASE_URL/api/v2/products/{id}/events" \
      -H "Authorization: Bearer $NORRUVA_API_KEY"
    const baseUrl = process.env.NORRUVA_BASE_URL;
    const apiKey = process.env.NORRUVA_API_KEY;
    const id = "…";
    
    const res = await fetch(`${baseUrl}/api/v2/products/${id}/events`, {
      method: "GET",
      headers: {
        "Authorization": `Bearer ${apiKey}`
      }
    });
    
    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"]
    id = "…"
    
    resp = requests.get(
        f"{base_url}/api/v2/products/{id}/events",
        headers={
            "Authorization": f"Bearer {api_key}"
        },
    )
    resp.raise_for_status()   # error body is the typed envelope: {"error": {"code", "message", "details"}}
    data = resp.json()
    package main
    
    import (
    	"fmt"
    	"net/http"
    	"os"
    )
    
    func main() {
    	baseURL := os.Getenv("NORRUVA_BASE_URL")
    	apiKey := os.Getenv("NORRUVA_API_KEY")
    	id := "…"
    	url := fmt.Sprintf("%s/api/v2/products/%s/events", baseURL, id)
    
    	req, _ := http.NewRequest("GET", url, nil)
    	req.Header.Set("Authorization", "Bearer "+apiKey+"")
    
    	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}}
    }
    Related
    Observability & audit
    KEYproducts:edit

    Void a serialized item (reason required). A voided serial resolves as 410 SERIAL_VOIDED with a tombstone and zero passport content. Beyond the core journey.

    Parameters
    reasonrequiredbody · stringWhy the item is voided — recorded and audited.
    Request
    POST {{baseUrl}}/api/v2/products/{id}/void
    Authorization: Bearer {{apiKey}}
    Content-Type: application/json
    curl -X POST "$BASE_URL/api/v2/products/{id}/void" \
      -H "Authorization: Bearer $NORRUVA_API_KEY" \
      -H "Content-Type: application/json"
    const baseUrl = process.env.NORRUVA_BASE_URL;
    const apiKey = process.env.NORRUVA_API_KEY;
    const id = "…";
    
    const res = await fetch(`${baseUrl}/api/v2/products/${id}/void`, {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${apiKey}`,
        "Content-Type": `application/json`
      }
    });
    
    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"]
    id = "…"
    
    resp = requests.post(
        f"{base_url}/api/v2/products/{id}/void",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": f"application/json"
        },
    )
    resp.raise_for_status()   # error body is the typed envelope: {"error": {"code", "message", "details"}}
    data = resp.json()
    package main
    
    import (
    	"fmt"
    	"net/http"
    	"os"
    )
    
    func main() {
    	baseURL := os.Getenv("NORRUVA_BASE_URL")
    	apiKey := os.Getenv("NORRUVA_API_KEY")
    	id := "…"
    	url := fmt.Sprintf("%s/api/v2/products/%s/void", baseURL, id)
    
    	req, _ := http.NewRequest("POST", url, nil)
    	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}}
    }
    Errors you can branch on
    409404
    Related
    Digital Link & resolution
    KEYproducts:read

    The tenant's own categories with usage counts.

    Request
    GET {{baseUrl}}/api/v2/products/categories
    Authorization: Bearer {{apiKey}}
    curl -X GET "$BASE_URL/api/v2/products/categories" \
      -H "Authorization: Bearer $NORRUVA_API_KEY"
    const baseUrl = process.env.NORRUVA_BASE_URL;
    const apiKey = process.env.NORRUVA_API_KEY;
    
    const res = await fetch(`${baseUrl}/api/v2/products/categories`, {
      method: "GET",
      headers: {
        "Authorization": `Bearer ${apiKey}`
      }
    });
    
    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.get(
        f"{base_url}/api/v2/products/categories",
        headers={
            "Authorization": f"Bearer {api_key}"
        },
    )
    resp.raise_for_status()   # error body is the typed envelope: {"error": {"code", "message", "details"}}
    data = resp.json()
    package main
    
    import (
    	"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/categories", baseURL)
    
    	req, _ := http.NewRequest("GET", url, nil)
    	req.Header.Set("Authorization", "Bearer "+apiKey+"")
    
    	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}}
    }
    Related
    Products & categories
    KEYproducts:read

    Every category with its schema versions, jurisdiction, effective window, and effectiveNow flag. Works with the read-only TSC key.

    Request
    GET {{baseUrl}}/api/v2/schemas/categories
    Authorization: Bearer {{apiKey}}
    curl -X GET "$BASE_URL/api/v2/schemas/categories" \
      -H "Authorization: Bearer $NORRUVA_API_KEY"
    const baseUrl = process.env.NORRUVA_BASE_URL;
    const apiKey = process.env.NORRUVA_API_KEY;
    
    const res = await fetch(`${baseUrl}/api/v2/schemas/categories`, {
      method: "GET",
      headers: {
        "Authorization": `Bearer ${apiKey}`
      }
    });
    
    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.get(
        f"{base_url}/api/v2/schemas/categories",
        headers={
            "Authorization": f"Bearer {api_key}"
        },
    )
    resp.raise_for_status()   # error body is the typed envelope: {"error": {"code", "message", "details"}}
    data = resp.json()
    package main
    
    import (
    	"fmt"
    	"net/http"
    	"os"
    )
    
    func main() {
    	baseURL := os.Getenv("NORRUVA_BASE_URL")
    	apiKey := os.Getenv("NORRUVA_API_KEY")
    	url := fmt.Sprintf("%s/api/v2/schemas/categories", baseURL)
    
    	req, _ := http.NewRequest("GET", url, nil)
    	req.Header.Set("Authorization", "Bearer "+apiKey+"")
    
    	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}}
    }
    Related
    Products & categoriesQuickstart
    KEYproducts:read

    The schema effective at an instant for a jurisdiction — exactly what creates and imports validate against (the same T101 resolution the create path runs). Links straight to the category's CSV import template.

    ▲
    A miss returns a typed 404 SCHEMA_NOT_FOUND listing availableCategories.
    Parameters
    categoryrequiredpath · stringCategory key, e.g. battery.
    jurisdictionoptionalquery · stringe.g. "EU".
    dateoptionalquery · ISO 8601Point-in-time resolution instant.
    includeFieldsoptionalquery · booleanfalse skips the field definitions.
    Request
    GET {{baseUrl}}/api/v2/schemas/categories/{category}
    Authorization: Bearer {{apiKey}}
    curl -X GET "$BASE_URL/api/v2/schemas/categories/{category}" \
      -H "Authorization: Bearer $NORRUVA_API_KEY"
    const baseUrl = process.env.NORRUVA_BASE_URL;
    const apiKey = process.env.NORRUVA_API_KEY;
    const category = "…";
    
    const res = await fetch(`${baseUrl}/api/v2/schemas/categories/${category}`, {
      method: "GET",
      headers: {
        "Authorization": `Bearer ${apiKey}`
      }
    });
    
    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"]
    category = "…"
    
    resp = requests.get(
        f"{base_url}/api/v2/schemas/categories/{category}",
        headers={
            "Authorization": f"Bearer {api_key}"
        },
    )
    resp.raise_for_status()   # error body is the typed envelope: {"error": {"code", "message", "details"}}
    data = resp.json()
    package main
    
    import (
    	"fmt"
    	"net/http"
    	"os"
    )
    
    func main() {
    	baseURL := os.Getenv("NORRUVA_BASE_URL")
    	apiKey := os.Getenv("NORRUVA_API_KEY")
    	category := "…"
    	url := fmt.Sprintf("%s/api/v2/schemas/categories/%s", baseURL, category)
    
    	req, _ := http.NewRequest("GET", url, nil)
    	req.Header.Set("Authorization", "Bearer "+apiKey+"")
    
    	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}}
    }
    Response
    JSON
    {
      "schemaId": "…", "schemaRegistryId": "…", "version": "…",
      "effectiveFrom": "…", "effectiveUntil": null,
      "fieldSchema": { /* full field definitions */ },
      "_links": { "importTemplate": "/api/v2/schemas/categories/battery/import-template?jurisdiction=EU" }
    }
    Errors you can branch on
    422 SCHEMA_NOT_FOUND
    Related
    Products & categoriesImport jobs
    KEYproducts:read

    text/csv template rendered from that SAME effective schema — the columns round-trip by construction.

    Parameters
    jurisdictionoptionalquery · stringe.g. "EU".
    Request
    GET {{baseUrl}}/api/v2/schemas/categories/{category}/import-template
    Authorization: Bearer {{apiKey}}
    curl -X GET "$BASE_URL/api/v2/schemas/categories/{category}/import-template" \
      -H "Authorization: Bearer $NORRUVA_API_KEY"
    const baseUrl = process.env.NORRUVA_BASE_URL;
    const apiKey = process.env.NORRUVA_API_KEY;
    const category = "…";
    
    const res = await fetch(`${baseUrl}/api/v2/schemas/categories/${category}/import-template`, {
      method: "GET",
      headers: {
        "Authorization": `Bearer ${apiKey}`
      }
    });
    
    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"]
    category = "…"
    
    resp = requests.get(
        f"{base_url}/api/v2/schemas/categories/{category}/import-template",
        headers={
            "Authorization": f"Bearer {api_key}"
        },
    )
    resp.raise_for_status()   # error body is the typed envelope: {"error": {"code", "message", "details"}}
    data = resp.json()
    package main
    
    import (
    	"fmt"
    	"net/http"
    	"os"
    )
    
    func main() {
    	baseURL := os.Getenv("NORRUVA_BASE_URL")
    	apiKey := os.Getenv("NORRUVA_API_KEY")
    	category := "…"
    	url := fmt.Sprintf("%s/api/v2/schemas/categories/%s/import-template", baseURL, category)
    
    	req, _ := http.NewRequest("GET", url, nil)
    	req.Header.Set("Authorization", "Bearer "+apiKey+"")
    
    	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}}
    }
    Related
    Import jobsProducts & categories
    Was this page helpful?
    Thanks — noted.Feedback goes to the docs team by email.
    ← PreviousAuth & API keysNext →Compliance & regulations
    On this page
    GET /productsPOST /productsGET /products/{id}PATCH /products/{id}DELETE /products/{id}POST /products/batchGET /products/{id}/versionsPOST /products/{id}/lifecycle/transitionGET /products/{id}/eventsPOST /products/{id}/voidGET /products/categoriesGET /schemas/categoriesGET /schemas/categories/{category}GET /schemas/categories/{category}/import-template
    Norruva DPP API · sandbox developer docsGenerated 2026-07-29 · PRD-aligned (TSC roadmap rev 2) · statuses reflect E2E-verified sandbox behaviour — not marketing