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/Auth & API keys

    Auth & API keys

    Mint and manage credentials, and exchange OAuth client credentials for a tenant-bound token. See Authentication for request bodies.

    POST/api/v2/api-keysMint an API key
    POST/api/v2/api-keys/{id}/rotateRotate an API key
    DELETE/api/v2/api-keys/{id}Revoke an API key
    POST/api/v2/oauth/tokenOAuth client-credentials token
    GET/api/v2/oauth/authorizeOAuth authorization-code entry
    GET/api/v2/me/permissionsEffective permissions
    GET/api/v2/healthLiveness
    ADMINIdempotency-Key recommendedadmin credential required to mint

    Create a scoped key. The raw secret is returned once and never again.

    Parameters
    namerequiredbody · stringHuman-readable key name. Missing name → 400 VALIDATION_ERROR.
    scopesrequiredbody · string[]Scope strings (canonical). permissions is an accepted alias.
    Request
    POST {{baseUrl}}/api/v2/api-keys
    Authorization: Bearer {{apiKey}}
    Content-Type: application/json
    Idempotency-Key: {{uuid}}
    
    {
      "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" \
      -H "Idempotency-Key: $(uuidgen)" \
      -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`,
        "Idempotency-Key": `${crypto.randomUUID()}`
      },
      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",
            "Idempotency-Key": f"{uuid.uuid4()}"
        },
        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")
    	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 — data.key is the secret; data.keyPrefix is what listings show later.

    JSON
    {
      "data": {
        "id": "key_01H…",
        "key": "nrv_live_…",        // the secret — shown ONCE, store it now
        "keyPrefix": "nrv_…",      // what listings show later
        "scopes": ["products:read", "products:create", "products:edit", "webhooks:manage"]
      }
    }
    Errors you can branch on
    400 VALIDATION_ERROR401
    Related
    AuthenticationQuickstart
    ADMINIdempotency-Key recommended

    Mint a replacement secret — the old secret dies immediately. Lands an audit row.

    ▲
    Send Content-Type: application/json even with an empty body — omitting it is a 415.
    Request
    POST {{baseUrl}}/api/v2/api-keys/{id}/rotate
    Authorization: Bearer {{apiKey}}
    Content-Type: application/json
    Idempotency-Key: {{uuid}}
    curl -X POST "$BASE_URL/api/v2/api-keys/{id}/rotate" \
      -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 id = "…";
    
    const res = await fetch(`${baseUrl}/api/v2/api-keys/${id}/rotate`, {
      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"]
    id = "…"
    
    resp = requests.post(
        f"{base_url}/api/v2/api-keys/{id}/rotate",
        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")
    	id := "…"
    	url := fmt.Sprintf("%s/api/v2/api-keys/%s/rotate", baseURL, id)
    
    	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

    Rotation REPLACES the row: data.id is a NEW id, and the id in the path is revoked. DELETE on the old id is therefore a correct 409 ALREADY_REVOKED — revoke data.id instead. Idempotency-Key is optional here and deliberately NOT derived from the request: two intentional rotations by the same caller look identical (empty body, same path, same credential), so a derived key would swallow the second and answer it from cache. Send a key only when you want a retry replayed — and note that the replayed 200 repeats the raw secret, which is otherwise shown exactly once. Rotating an already-revoked id → 409 ALREADY_REVOKED; an unknown id → 404.

    JSON
    {
      "data": {
        "id": "…NEW key id…",       // the replacement — track this one from now on
        "oldKeyId": "…the id you called…",  // now revoked
        "key": "nv_…",              // the secret — shown ONCE
        "keyPrefix": "nv_…"
      }
    }
    Errors you can branch on
    401404409
    Related
    AuthenticationObservability & audit
    ADMIN

    Revoke a key permanently. Revoking an already-revoked key is 409 ALREADY_REVOKED — including a key already revoked as a side effect of rotate.

    Request
    DELETE {{baseUrl}}/api/v2/api-keys/{id}
    Authorization: Bearer {{apiKey}}
    curl -X DELETE "$BASE_URL/api/v2/api-keys/{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/api-keys/${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/api-keys/{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/api-keys/%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}}
    }
    Errors you can branch on
    401409
    Related
    Authentication
    OAUTHIdempotency-Key requiredcredentials in the body, not the header

    Exchange client credentials for a tenant-bound access token (RFC 6749 §4.4). Body credentials only — no Authorization header on this call. No refresh tokens are issued.

    Parameters
    grant_typerequiredbody · stringMust be "client_credentials" — anything else is a typed 400.
    client_idrequiredbody · stringOAuth client id.
    client_secretrequiredbody · stringOAuth client secret. Cross-tenant binding fails closed.
    scopeoptionalbody · stringOptional space-separated subset of the client's scopes.
    Request
    POST {{baseUrl}}/api/v2/oauth/token
    Content-Type: application/json
    Idempotency-Key: {{uuid}}
    
    {
      "grant_type": "client_credentials",
      "client_id": "{{clientId}}",
      "client_secret": "{{clientSecret}}",
      "scope": "products:read products:create"
    }
    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}}
    }
    Response
    JSON
    {
      "access_token": "eyJ…",
      "token_type": "Bearer",
      "expires_in": 3600
      // deliberately NO refresh_token — request a new token on expiry
    }
    Errors you can branch on
    400 VALIDATION_ERROR401
    Related
    AuthenticationDeviations & gotchas
    OAUTH

    Authorization-code flow entry point (response_type=code → redirect with code + state). Requires a valid Bearer credential — this is NOT a public endpoint; a request without one is 401.

    ▲
    Requires a valid Bearer credential — a request without one is 401; it is not a public endpoint. All five query parameters are otherwise required — omitting any returns 400 invalid_request naming the missing parameters. Registration lives in OAUTH_CLIENT_REGISTRY_JSON.
    Parameters
    response_typerequiredquery · stringSet to "code".
    client_idrequiredquery · stringRegistered OAuth client id.
    redirect_urirequiredquery · stringMust MATCH a redirect URI registered for the client, else 400 invalid_request "Redirect URI not registered for this client". The local dev client norruva-dev-client registers http://localhost:3001/oauth/callback.
    scoperequiredquery · stringSpace-separated requested scopes.
    staterequiredquery · stringOpaque CSRF value, round-tripped on the redirect.
    Request
    GET {{baseUrl}}/api/v2/oauth/authorize
    
    curl -X GET "$BASE_URL/api/v2/oauth/authorize"
    const baseUrl = process.env.NORRUVA_BASE_URL;
    
    const res = await fetch(`${baseUrl}/api/v2/oauth/authorize`, {
      method: "GET",
      headers: {
    
      }
    });
    
    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.get(
        f"{base_url}/api/v2/oauth/authorize",
        headers={
    
        },
    )
    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")
    	url := fmt.Sprintf("%s/api/v2/oauth/authorize", baseURL)
    
    	req, _ := http.NewRequest("GET", url, nil)
    
    	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_ERROR401
    Related
    Authentication
    KEY

    The effective permission set for the calling credential — useful for pre-flight scope checks.

    Request
    GET {{baseUrl}}/api/v2/me/permissions
    Authorization: Bearer {{apiKey}}
    curl -X GET "$BASE_URL/api/v2/me/permissions" \
      -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/me/permissions`, {
      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/me/permissions",
        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/me/permissions", 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
    Authentication
    PUBLIC

    Use this first in every smoke test. Machine discovery lives at /api/v2/openapi.json (authenticated) and /api/v2/public/openapi.json.

    Request
    GET {{baseUrl}}/api/v2/health
    
    curl -X GET "$BASE_URL/api/v2/health"
    const baseUrl = process.env.NORRUVA_BASE_URL;
    
    const res = await fetch(`${baseUrl}/api/v2/health`, {
      method: "GET",
      headers: {
    
      }
    });
    
    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.get(
        f"{base_url}/api/v2/health",
        headers={
    
        },
    )
    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")
    	url := fmt.Sprintf("%s/api/v2/health", baseURL)
    
    	req, _ := http.NewRequest("GET", url, nil)
    
    	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
    QuickstartIntegration playbook
    Was this page helpful?
    Thanks — noted.Feedback goes to the docs team by email.
    ← PreviousOverviewNext →Products
    On this page
    POST /api-keysPOST /api-keys/{id}/rotateDELETE /api-keys/{id}POST /oauth/tokenGET /oauth/authorizeGET /me/permissionsGET /health
    Norruva DPP API · sandbox developer docsGenerated 2026-07-29 · PRD-aligned (TSC roadmap rev 2) · statuses reflect E2E-verified sandbox behaviour — not marketing