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 |
Create a scoped key. The raw secret is returned once and never again.
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}}
}201 Created — data.key is the secret; data.keyPrefix is what listings show later.
{
"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"]
}
}Mint a replacement secret — the old secret dies immediately. Lands an audit row.
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}}
}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.
{
"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_…"
}
}Revoke a key permanently. Revoking an already-revoked key is 409 ALREADY_REVOKED — including a key already revoked as a side effect of rotate.
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}}
}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.
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}}
}{
"access_token": "eyJ…",
"token_type": "Bearer",
"expires_in": 3600
// deliberately NO refresh_token — request a new token on expiry
}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.
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}}
}The effective permission set for the calling credential — useful for pre-flight scope checks.
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}}
}Use this first in every smoke test. Machine discovery lives at /api/v2/openapi.json (authenticated) and /api/v2/public/openapi.json.
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}}
}