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) orpermissions(alias) — an array of strings.nameis required, else400 VALIDATION_ERROR.- Rotate with
POST /api/v2/api-keys/{id}/rotate— the old secret dies immediately. Revoke withDELETE /api/v2/api-keys/{id}. Both land audit rows. x-api-key-shaped headers also pass the edge, butAuthorization: Beareris 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 subsetcurl -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
| Scope | Grants |
|---|---|
products:read | Read products, events, categories. (products:view is an accepted legacy alias.) |
products:create | Create products. |
products:edit | Update products; register Digital Link (publish-dpp). |
identifiers:read | Read carrier / serial identifier allocations (carriers/export). |
epcis:capture | Capture EPCIS 2.0 events and read the EPCIS surface back. |
aidc:read | Read print jobs, job history and the device registry (supply_chain:view / production:view satisfy it). |
aidc:write | Issue 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:verify | Auto-ID label verification (reserved — grantable now; the verify-label endpoint ships with the print cycle). |
webhooks:manage | Full webhook CRUD, test, rotate-secret, and delivery inspection. |
credential:read · credential:write · credential:verify | Verifiable-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, withdetails: { 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.