Developer Docs
Sandbox · API v2PlaygroundGet API keys

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, works with a read-only 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. The key must carry dpp:edit; a key holding only passport:write gets 403 API_SCOPE_DENIED here.

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