Developer Docs
Sandbox · API v2PlaygroundGet 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
Related
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
Related
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
Related
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
Related
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.
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".
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
Related
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
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
Was this page helpful?
Thanks, noted.Feedback goes to the docs team by email.