Developer Docs
Sandbox · API v2PlaygroundGet API keys

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).
dpp:readResolve and read passports (the scan chain and every /dpp/* read).
dpp:editGenerate a passport (POST /dpp/generate) and anchor validation results. passport:write does not imply it: dpp:* routes need a literal dpp:* grant.
dpp:create · dpp:update · dpp:deleteCreate (POST /dpps, generation retry), update (content, elements, publish) and delete passports.
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.