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/Authentication

    Authentication

    Every /api/v2/* call carries Authorization: Bearer <credential>. There are two credential types — pick one. Both are tenant-bound and scope-enforced through the same path.

    API key

    The dashboard-first path. An admin mints a scoped key; the raw secret is returned once and never again.

    POST {{baseUrl}}/api/v2/api-keys
    Authorization: Bearer {{adminKey}}
    
    { "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" \
      -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}`
      },
      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}"
        },
        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+"")
    
    	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}}
    }
    • scopes (canonical) or permissions (alias) — an array of strings. name is required, else 400 VALIDATION_ERROR.
    • Rotate with POST /api/v2/api-keys/{id}/rotate — the old secret dies immediately. Revoke with DELETE /api/v2/api-keys/{id}. Both land audit rows.
    • x-api-key-shaped headers also pass the edge, but Authorization: Bearer is canonical.

    OAuth 2.0 client credentials

    The machine-to-machine path (RFC 6749 §4.4). Body credentials only — no Authorization header on this call. Idempotency-Key is required.

    POST {{baseUrl}}/api/v2/oauth/token
    Content-Type: application/json          (or application/x-www-form-urlencoded)
    Idempotency-Key: <uuid>
    
    { "grant_type": "client_credentials",
      "client_id": "{{clientId}}",
      "client_secret": "{{clientSecret}}",
      "scope": "products:read products:create" }   // optional subset
    curl -X POST "$BASE_URL/api/v2/oauth/token" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: $(uuidgen)" \
      -d '{
      "grant_type": "client_credentials",
      "client_id": "{{clientId}}",
      "client_secret": "{{clientSecret}}",
      "scope": "products:read products:create"
    }'
    const baseUrl = process.env.NORRUVA_BASE_URL;
    
    const res = await fetch(`${baseUrl}/api/v2/oauth/token`, {
      method: "POST",
      headers: {
        "Content-Type": `application/json`,
        "Idempotency-Key": `${crypto.randomUUID()}`
      },
      body: JSON.stringify({
        "grant_type": "client_credentials",
        "client_id": "{{clientId}}",
        "client_secret": "{{clientSecret}}",
        "scope": "products:read products:create"
      })
    });
    
    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/oauth/token",
        headers={
            "Content-Type": f"application/json",
            "Idempotency-Key": f"{uuid.uuid4()}"
        },
        json={
          "grant_type": "client_credentials",
          "client_id": "{{clientId}}",
          "client_secret": "{{clientSecret}}",
          "scope": "products:read products:create"
        },
    )
    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/oauth/token", baseURL)
    
    	payload := []byte(`{
      "grant_type": "client_credentials",
      "client_id": "{{clientId}}",
      "client_secret": "{{clientSecret}}",
      "scope": "products:read products:create"
    }`)
    	req, _ := http.NewRequest("POST", url, bytes.NewBuffer(payload))
    	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}}
    }

    200 → an access token used exactly like a key. Deliberately no refresh token is issued; request a new one when it expires. Wrong grant_type → typed 400. Cross-tenant binding fails closed.

    Scopes

    ScopeGrants
    products:readRead products, events, categories. (products:view is an accepted legacy alias.)
    products:createCreate products.
    products:editUpdate products; register Digital Link (publish-dpp).
    identifiers:readRead carrier / serial identifier allocations (carriers/export).
    epcis:captureCapture EPCIS 2.0 events and read the EPCIS surface back.
    aidc:readRead print jobs, job history and the device registry (supply_chain:view / production:view satisfy it).
    aidc:writeIssue print jobs, claim / heartbeat / report as a device, manage devices (supply_chain:edit satisfies it). Bind the key to a device via POST /devices to make it a device credential.
    autoid:verifyAuto-ID label verification (reserved — grantable now; the verify-label endpoint ships with the print cycle).
    webhooks:manageFull webhook CRUD, test, rotate-secret, and delivery inspection.
    credential:read · credential:write · credential:verifyVerifiable-credential operations. verify is not implied by read/write. Plural forms (credentials:verify, …) are accepted aliases.
    *:*Admin wildcard — expands to all permissions.

    Every refusal names its reason

    The DX contract is that the error body alone tells you what went wrong. These three must never blur:

    • 401 — bad or missing credential (also role-level permission denials).
    • 403 API_SCOPE_DENIED — a good credential lacking a scope, with details: { required, available } naming the missing scope.
    • 403 SANDBOX_EXPIRED — the sandbox tenant is past expiry (fires only when the tenant is both sandbox and past its expiry date).

    See Errors & conventions for the full typed-error catalogue.

    Was this page helpful?
    Thanks — noted.Feedback goes to the docs team by email.
    ← PreviousQuickstartNext →Errors & conventions
    On this page
    API keyOAuth 2.0 client credentialsScopesEvery refusal names its reason
    Norruva DPP API · sandbox developer docsGenerated 2026-07-29 · PRD-aligned (TSC roadmap rev 2) · statuses reflect E2E-verified sandbox behaviour — not marketing