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/Passports

    Passports

    Generate a passport from a product, publish it, register the resolvable Digital Link, and drive its post-publish lifecycle through the command envelope.

    POST/api/v2/dpp/generateGenerate a passport
    GET/api/v2/dpp/status/{requestId}Generation status
    POST/api/v2/dpp/{productId}/publishPublish passport content
    POST/api/v2/products/{id}/publish-dppRegister the Digital Link
    POST/api/v2/passports/{id}/publishBroadcast to external registries
    POST/api/v2/passports/commandsPassport lifecycle command
    GET/api/v2/passports/commandsCommand catalog
    GET/api/v2/passports/{id}/historyVersion history
    GET/api/v2/passports/{id}/validationsValidation chain + readyToPrint
    POST/api/v2/passports/{id}/validationsAnchor a validation result
    GET/api/v2/passports/{id}/as-ofTemporal snapshot (as-of)
    KEYIdempotency-Key recommended

    Run the 6-stage generation pipeline from a ready_for_dpp product. Async — poll GET /api/v2/dpp/status/{requestId}. In sandbox, anchorToBlockchain is always effectively false.

    Parameters
    productIdrequiredbody · stringThe product to generate from (must be ready_for_dpp).
    strictnessoptionalbody · stringe.g. "advisory".
    regionoptionalbody · stringe.g. "EU".
    lifecycleStageoptionalbody · stringe.g. "production".
    dppOptionsoptionalbody · objectrequestId, manufacturerDid, anchorToBlockchain (ignored in sandbox — never anchors).
    Request
    POST {{baseUrl}}/api/v2/dpp/generate
    Authorization: Bearer {{apiKey}}
    Content-Type: application/json
    Idempotency-Key: {{uuid}}
    
    {
      "productId": "{{productId}}",
      "strictness": "advisory",
      "region": "EU",
      "lifecycleStage": "production",
      "dppOptions": {
        "requestId": "dpp-gen-001",
        "manufacturerDid": "did:ebsi:example-gmbh",
        "anchorToBlockchain": false
      }
    }
    curl -X POST "$BASE_URL/api/v2/dpp/generate" \
      -H "Authorization: Bearer $NORRUVA_API_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: $(uuidgen)" \
      -d '{
      "productId": "{{productId}}",
      "strictness": "advisory",
      "region": "EU",
      "lifecycleStage": "production",
      "dppOptions": {
        "requestId": "dpp-gen-001",
        "manufacturerDid": "did:ebsi:example-gmbh",
        "anchorToBlockchain": false
      }
    }'
    const baseUrl = process.env.NORRUVA_BASE_URL;
    const apiKey = process.env.NORRUVA_API_KEY;
    
    const res = await fetch(`${baseUrl}/api/v2/dpp/generate`, {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${apiKey}`,
        "Content-Type": `application/json`,
        "Idempotency-Key": `${crypto.randomUUID()}`
      },
      body: JSON.stringify({
        "productId": "{{productId}}",
        "strictness": "advisory",
        "region": "EU",
        "lifecycleStage": "production",
        "dppOptions": {
          "requestId": "dpp-gen-001",
          "manufacturerDid": "did:ebsi:example-gmbh",
          "anchorToBlockchain": false
        }
      })
    });
    
    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/dpp/generate",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": f"application/json",
            "Idempotency-Key": f"{uuid.uuid4()}"
        },
        json={
          "productId": "{{productId}}",
          "strictness": "advisory",
          "region": "EU",
          "lifecycleStage": "production",
          "dppOptions": {
            "requestId": "dpp-gen-001",
            "manufacturerDid": "did:ebsi:example-gmbh",
            "anchorToBlockchain": False
          }
        },
    )
    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/dpp/generate", baseURL)
    
    	payload := []byte(`{
      "productId": "{{productId}}",
      "strictness": "advisory",
      "region": "EU",
      "lifecycleStage": "production",
      "dppOptions": {
        "requestId": "dpp-gen-001",
        "manufacturerDid": "did:ebsi:example-gmbh",
        "anchorToBlockchain": false
      }
    }`)
    	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

    202 Accepted — poll /api/v2/dpp/status/{requestId}.

    Errors you can branch on
    400 VALIDATION_ERROR404
    Related
    Passports & versionsQuickstart
    KEY

    Async generation status for a dpp/generate request.

    Request
    GET {{baseUrl}}/api/v2/dpp/status/{requestId}
    Authorization: Bearer {{apiKey}}
    curl -X GET "$BASE_URL/api/v2/dpp/status/{requestId}" \
      -H "Authorization: Bearer $NORRUVA_API_KEY"
    const baseUrl = process.env.NORRUVA_BASE_URL;
    const apiKey = process.env.NORRUVA_API_KEY;
    const requestId = "…";
    
    const res = await fetch(`${baseUrl}/api/v2/dpp/status/${requestId}`, {
      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"]
    request_id = "…"
    
    resp = requests.get(
        f"{base_url}/api/v2/dpp/status/{request_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")
    	requestId := "…"
    	url := fmt.Sprintf("%s/api/v2/dpp/status/%s", baseURL, requestId)
    
    	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
    Passports & versions
    KEYIdempotency-Key recommended

    Mints the public passport content and an immutable version; returns {{passportUid}}. Publish ≠ register — the Digital Link registration is the separate publish-dpp call.

    ▲
    amendmentReason has a 10-character minimum (400 VALIDATION_ERROR below it). The product must already be dpp_issued, draft or archived — calling this straight after the ready_for_dpp transition, before POST /dpp/generate, is 422 INVALID_STATE_TRANSITION.
    Request
    POST {{baseUrl}}/api/v2/dpp/{productId}/publish
    Authorization: Bearer {{apiKey}}
    Content-Type: application/json
    Idempotency-Key: {{uuid}}
    
    {
      "amendmentReason": "Initial publication",
      "updates": { "batteryPassport": { "capacity": 85, "cycleLife": 1500 } }
    }
    curl -X POST "$BASE_URL/api/v2/dpp/{productId}/publish" \
      -H "Authorization: Bearer $NORRUVA_API_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: $(uuidgen)" \
      -d '{
      "amendmentReason": "Initial publication",
      "updates": {
        "batteryPassport": {
          "capacity": 85,
          "cycleLife": 1500
        }
      }
    }'
    const baseUrl = process.env.NORRUVA_BASE_URL;
    const apiKey = process.env.NORRUVA_API_KEY;
    const productId = "…";
    
    const res = await fetch(`${baseUrl}/api/v2/dpp/${productId}/publish`, {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${apiKey}`,
        "Content-Type": `application/json`,
        "Idempotency-Key": `${crypto.randomUUID()}`
      },
      body: JSON.stringify({
        "amendmentReason": "Initial publication",
        "updates": {
          "batteryPassport": {
            "capacity": 85,
            "cycleLife": 1500
          }
        }
      })
    });
    
    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"]
    product_id = "…"
    
    resp = requests.post(
        f"{base_url}/api/v2/dpp/{product_id}/publish",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": f"application/json",
            "Idempotency-Key": f"{uuid.uuid4()}"
        },
        json={
          "amendmentReason": "Initial publication",
          "updates": {
            "batteryPassport": {
              "capacity": 85,
              "cycleLife": 1500
            }
          }
        },
    )
    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")
    	productId := "…"
    	url := fmt.Sprintf("%s/api/v2/dpp/%s/publish", baseURL, productId)
    
    	payload := []byte(`{
      "amendmentReason": "Initial publication",
      "updates": {
        "batteryPassport": {
          "capacity": 85,
          "cycleLife": 1500
        }
      }
    }`)
    	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}}
    }
    Errors you can branch on
    403 GUARD_REJECTED400 VALIDATION_ERROR404
    Emits events
    product.published
    Related
    Passports & versionsDigital Link & resolutionQuickstart
    KEYproducts:editIdempotency-Key required

    The deliberate step that makes the item resolvable (dpp_identifiers 0 → 1). Returns the verifiable credential plus a merkleRoot for later verification. passportType is optional and NOT defaulted — omit it and the type is derived from the product (battery-marked → battery, otherwise generic). A battery-marked product returns 503 COMPLIANCE_EVALUATION_UNAVAILABLE while the battery pack is quarantined (ATLAS-F017); non-battery products publish normally.

    ▲
    Deviation: an out-of-scope call returns 403 FORBIDDEN, not 403 API_SCOPE_DENIED — a different auth wrapper. Branch on both.
    Request
    POST {{baseUrl}}/api/v2/products/{id}/publish-dpp
    Authorization: Bearer {{apiKey}}
    Content-Type: application/json
    Idempotency-Key: {{uuid}}
    
    { "publishToWallet": false }
    curl -X POST "$BASE_URL/api/v2/products/{id}/publish-dpp" \
      -H "Authorization: Bearer $NORRUVA_API_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: $(uuidgen)" \
      -d '{
      "publishToWallet": false
    }'
    const baseUrl = process.env.NORRUVA_BASE_URL;
    const apiKey = process.env.NORRUVA_API_KEY;
    const id = "…";
    
    const res = await fetch(`${baseUrl}/api/v2/products/${id}/publish-dpp`, {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${apiKey}`,
        "Content-Type": `application/json`,
        "Idempotency-Key": `${crypto.randomUUID()}`
      },
      body: JSON.stringify({
        "publishToWallet": false
      })
    });
    
    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/products/{id}/publish-dpp",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": f"application/json",
            "Idempotency-Key": f"{uuid.uuid4()}"
        },
        json={
          "publishToWallet": False
        },
    )
    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")
    	id := "…"
    	url := fmt.Sprintf("%s/api/v2/products/%s/publish-dpp", baseURL, id)
    
    	payload := []byte(`{
      "publishToWallet": false
    }`)
    	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}}
    }
    Errors you can branch on
    403 FORBIDDEN409
    Related
    Digital Link & resolutionDeviations & gotchasQuickstart
    KEYpassports:publishmid_market tier and up

    Broadcast an approved/published passport to external registries (Catena-X / GS1 / EBSI). A third, distinct publish operation — optional. In sandbox/local none of the three connectors is configured, so the honest answer there is 503 REGISTRY_UNAVAILABLE, not a success.

    Parameters
    registriesoptionalbody · string[]Subset of CATENA_X | GS1 | EBSI — UPPER CASE; a lower-case value is a 400 VALIDATION_ERROR. Not yet honoured for selection: the router broadcasts to all configured registries.
    passportDataoptionalbody · objectPayload override; omitted, the stored passport is used.
    Request
    POST {{baseUrl}}/api/v2/passports/{id}/publish
    Authorization: Bearer {{apiKey}}
    Content-Type: application/json
    curl -X POST "$BASE_URL/api/v2/passports/{id}/publish" \
      -H "Authorization: Bearer $NORRUVA_API_KEY" \
      -H "Content-Type: application/json"
    const baseUrl = process.env.NORRUVA_BASE_URL;
    const apiKey = process.env.NORRUVA_API_KEY;
    const id = "…";
    
    const res = await fetch(`${baseUrl}/api/v2/passports/${id}/publish`, {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${apiKey}`,
        "Content-Type": `application/json`
      }
    });
    
    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/passports/{id}/publish",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": f"application/json"
        },
    )
    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/passports/%s/publish", baseURL, id)
    
    	req, _ := http.NewRequest("POST", url, nil)
    	req.Header.Set("Authorization", "Bearer "+apiKey+"")
    	req.Header.Set("Content-Type", "application/json")
    
    	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_ERRORPASSPORT_NOT_PUBLISHABLE503 REGISTRY_UNAVAILABLE404
    Related
    Passports & versionsSandbox & environments
    KEYIdempotency-Key required

    All post-publish state changes use one self-documenting envelope. Guards are real: EDIT on a published passport → 403 GUARD_REJECTED.

    ▲
    The command model keeps its OWN lifecycle (draft → submitted → approved → published), advanced ONLY by PASSPORT.* commands. POST /api/v2/dpp/{productId}/publish mints a passport version but does not move it, so a passport reached through the documented journey is still `draft` here and PASSPORT.SUSPEND is a correct 409 INVALID_TRANSITION. Drive SUBMIT → APPROVE → PUBLISH first; PASSPORT.PUBLISH additionally requires payload.disclosureTier (public | partner | internal). ⚠ THE TWO LIFECYCLES SHARE ONE STATE: sending PASSPORT.SUBMIT against a passport already published through the dpp/publish path moves it to `submitted`, and the public resolver reads that same state — GET /api/v2/public/passport/{uid} goes 200 → 404. Do not send SUBMIT to a live passport.
    Parameters
    typerequiredbody · stringDotted wire format: PASSPORT.SUBMIT | APPROVE | REJECT | EDIT | PUBLISH | SUSPEND | REINSTATE | REVOKE | ARCHIVE | RECYCLE …
    tenantIdrequiredbody · uuidYour tenant id.
    idempotencyKeyrequiredbody · uuidReplaying the same key returns the first result.
    actorIdrequiredbody · uuidThe user id executing the command. Envelope metadata only — authorisation is decided from the authenticated credential, never from this field.
    correlationIdrequiredbody · uuidRequest-tracing id.
    requestedAtrequiredbody · ISO 8601When the command was requested.
    causationIdoptionalbody · uuidId of the causing command/event.
    payloadrequiredbody · objectpassportId + command-specific fields. A field at the wrong level → 400 naming the field.
    Request
    POST {{baseUrl}}/api/v2/passports/commands
    Authorization: Bearer {{apiKey}}
    Content-Type: application/json
    Idempotency-Key: {{uuid}}
    
    {
      "type": "PASSPORT.SUSPEND",
      "tenantId": "{{tenantId}}",
      "actorId": "{{userId}}",
      "idempotencyKey": "{{uuid}}",
      "correlationId": "{{uuid}}",
      "requestedAt": "2026-07-25T12:00:00.000Z",
      "payload": { "passportId": "{{passportUid}}" }
    }
    curl -X POST "$BASE_URL/api/v2/passports/commands" \
      -H "Authorization: Bearer $NORRUVA_API_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: $(uuidgen)" \
      -d '{
      "type": "PASSPORT.SUSPEND",
      "tenantId": "{{tenantId}}",
      "actorId": "{{userId}}",
      "idempotencyKey": "{{uuid}}",
      "correlationId": "{{uuid}}",
      "requestedAt": "2026-07-25T12:00:00.000Z",
      "payload": {
        "passportId": "{{passportUid}}"
      }
    }'
    const baseUrl = process.env.NORRUVA_BASE_URL;
    const apiKey = process.env.NORRUVA_API_KEY;
    
    const res = await fetch(`${baseUrl}/api/v2/passports/commands`, {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${apiKey}`,
        "Content-Type": `application/json`,
        "Idempotency-Key": `${crypto.randomUUID()}`
      },
      body: JSON.stringify({
        "type": "PASSPORT.SUSPEND",
        "tenantId": "{{tenantId}}",
        "actorId": "{{userId}}",
        "idempotencyKey": "{{uuid}}",
        "correlationId": "{{uuid}}",
        "requestedAt": "2026-07-25T12:00:00.000Z",
        "payload": {
          "passportId": "{{passportUid}}"
        }
      })
    });
    
    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/passports/commands",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": f"application/json",
            "Idempotency-Key": f"{uuid.uuid4()}"
        },
        json={
          "type": "PASSPORT.SUSPEND",
          "tenantId": "{{tenantId}}",
          "actorId": "{{userId}}",
          "idempotencyKey": "{{uuid}}",
          "correlationId": "{{uuid}}",
          "requestedAt": "2026-07-25T12:00:00.000Z",
          "payload": {
            "passportId": "{{passportUid}}"
          }
        },
    )
    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/passports/commands", baseURL)
    
    	payload := []byte(`{
      "type": "PASSPORT.SUSPEND",
      "tenantId": "{{tenantId}}",
      "actorId": "{{userId}}",
      "idempotencyKey": "{{uuid}}",
      "correlationId": "{{uuid}}",
      "requestedAt": "2026-07-25T12:00:00.000Z",
      "payload": {
        "passportId": "{{passportUid}}"
      }
    }`)
    	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}}
    }
    Errors you can branch on
    403 GUARD_REJECTED400 VALIDATION_ERROR409
    Related
    Passports & versions
    KEY

    The self-documenting catalog of passport lifecycle commands.

    Request
    GET {{baseUrl}}/api/v2/passports/commands
    Authorization: Bearer {{apiKey}}
    curl -X GET "$BASE_URL/api/v2/passports/commands" \
      -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/passports/commands`, {
      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/passports/commands",
        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/passports/commands", 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
    Passports & versions
    KEY

    The immutable version chain: version number, previous_version_id hash-chain link, current flag. Republishing mints v(N+1); v(N) is superseded, never edited.

    Request
    GET {{baseUrl}}/api/v2/passports/{id}/history
    Authorization: Bearer {{apiKey}}
    curl -X GET "$BASE_URL/api/v2/passports/{id}/history" \
      -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/passports/${id}/history`, {
      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"]
    id = "…"
    
    resp = requests.get(
        f"{base_url}/api/v2/passports/{id}/history",
        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/passports/%s/history", baseURL, id)
    
    	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
    Passports & versions
    KEY

    The anchored validation chain plus the derived readyToPrint flag (= validated ∧ published, PRD F6) — the go/no-go signal before printing carriers.

    Request
    GET {{baseUrl}}/api/v2/passports/{id}/validations
    Authorization: Bearer {{apiKey}}
    curl -X GET "$BASE_URL/api/v2/passports/{id}/validations" \
      -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/passports/${id}/validations`, {
      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"]
    id = "…"
    
    resp = requests.get(
        f"{base_url}/api/v2/passports/{id}/validations",
        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/passports/%s/validations", baseURL, id)
    
    	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}}
    }
    Response
    JSON
    {
      "upi": "…",
      "validations": [
        { "eventId": "…", "occurredAt": "…", "digest": "…",
          "signatureAlgorithm": "…", "jrcAnchor": "…" }
      ],
      "readyToPrint": true   // = at least one anchored validation AND currently published
    }
    Related
    Compliance & regulationsAutoID print loop
    KEYdpp:editIdempotency-Key recommendedsupply_chain:edit also accepted

    The write half of readyToPrint: anchor a validation-result Verifiable Credential into the lifecycle log (VALIDATION_RESULT_ANCHORED, JRC Annex 9). Tenant-guarded (a passport you don't own is a 404). The credential is cryptographically verified before anything is written: it needs a single DataIntegrityProof with cryptosuite eddsa-jcs-2022 and proofPurpose assertionMethod, whose verificationMethod DID equals the issuer DID and whose Ed25519 signature checks out — otherwise a typed 422 (VALIDATION_CREDENTIAL_NOT_VERIFIED) BEFORE any write. The issuer DID method must also be EUDIW-compatible — did:web, did:ebsi or did:key (422 EUDIW_INCOMPATIBLE_DID_METHOD); note that only did:key resolves offline today, so did:web and did:ebsi are currently refused rather than waved through.

    Parameters
    vcrequiredbody · objectW3C Verifiable Credential (@context, type, issuer, issuanceDate, credentialSubject).
    signatureAlgorithmoptionalbody · stringDefaults to ECDSA-P-256.
    Request
    POST {{baseUrl}}/api/v2/passports/{id}/validations
    Authorization: Bearer {{apiKey}}
    Content-Type: application/json
    Idempotency-Key: {{uuid}}
    
    {
      "vc": {
        "@context": ["https://www.w3.org/ns/credentials/v2"],
        "type": ["VerifiableCredential", "ValidationResultCredential"],
        "issuer": "did:web:validator.example.com",
        "issuanceDate": "2026-07-29T00:00:00Z",
        "credentialSubject": { "result": "pass" }
      }
    }
    curl -X POST "$BASE_URL/api/v2/passports/{id}/validations" \
      -H "Authorization: Bearer $NORRUVA_API_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: $(uuidgen)" \
      -d '{
      "vc": {
        "@context": [
          "https://www.w3.org/ns/credentials/v2"
        ],
        "type": [
          "VerifiableCredential",
          "ValidationResultCredential"
        ],
        "issuer": "did:web:validator.example.com",
        "issuanceDate": "2026-07-29T00:00:00Z",
        "credentialSubject": {
          "result": "pass"
        }
      }
    }'
    const baseUrl = process.env.NORRUVA_BASE_URL;
    const apiKey = process.env.NORRUVA_API_KEY;
    const id = "…";
    
    const res = await fetch(`${baseUrl}/api/v2/passports/${id}/validations`, {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${apiKey}`,
        "Content-Type": `application/json`,
        "Idempotency-Key": `${crypto.randomUUID()}`
      },
      body: JSON.stringify({
        "vc": {
          "@context": [
            "https://www.w3.org/ns/credentials/v2"
          ],
          "type": [
            "VerifiableCredential",
            "ValidationResultCredential"
          ],
          "issuer": "did:web:validator.example.com",
          "issuanceDate": "2026-07-29T00:00:00Z",
          "credentialSubject": {
            "result": "pass"
          }
        }
      })
    });
    
    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/passports/{id}/validations",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": f"application/json",
            "Idempotency-Key": f"{uuid.uuid4()}"
        },
        json={
          "vc": {
            "@context": [
              "https://www.w3.org/ns/credentials/v2"
            ],
            "type": [
              "VerifiableCredential",
              "ValidationResultCredential"
            ],
            "issuer": "did:web:validator.example.com",
            "issuanceDate": "2026-07-29T00:00:00Z",
            "credentialSubject": {
              "result": "pass"
            }
          }
        },
    )
    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")
    	id := "…"
    	url := fmt.Sprintf("%s/api/v2/passports/%s/validations", baseURL, id)
    
    	payload := []byte(`{
      "vc": {
        "@context": [
          "https://www.w3.org/ns/credentials/v2"
        ],
        "type": [
          "VerifiableCredential",
          "ValidationResultCredential"
        ],
        "issuer": "did:web:validator.example.com",
        "issuanceDate": "2026-07-29T00:00:00Z",
        "credentialSubject": {
          "result": "pass"
        }
      }
    }`)
    	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. The digest is deterministic over the VC — re-anchoring the same VC converges on the same digest.

    JSON
    { "upi": "…", "digest": "9f2c…", "anchoredAt": "2026-07-29T10:00:00.000Z" }
    Errors you can branch on
    400 VALIDATION_ERROR401404422
    Related
    AutoID print loopPrint jobs & devices
    KEY

    The passport projection valid at a given instant (PRD F4). CURRENTLY 404s FOR EVERY INSTANT: publishing writes passport_versions but nothing writes passport_projections, which is the table this route reads, so the projection is empty even straight after a successful publish. Use GET /api/v2/passports/{id}/history for the version chain until the bi-temporal projector decision is ratified.

    ▲
    Blocked on the reserved bi-temporal projector decision (docs/tsc/START-HERE.md §2, D1–D5) — irreversible-if-wrong under the D4 write-zone invariant, so it is not being filled in ahead of ratification.
    Parameters
    daterequiredquery · ISO 8601The instant to project at.
    Request
    GET {{baseUrl}}/api/v2/passports/{id}/as-of
    Authorization: Bearer {{apiKey}}
    curl -X GET "$BASE_URL/api/v2/passports/{id}/as-of" \
      -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/passports/${id}/as-of`, {
      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"]
    id = "…"
    
    resp = requests.get(
        f"{base_url}/api/v2/passports/{id}/as-of",
        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/passports/%s/as-of", baseURL, id)
    
    	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}}
    }
    Errors you can branch on
    404
    Related
    Passports & versionsDeviations & gotchas

    The command envelope

    All post-publish state changes use one self-documenting envelope; GET /api/v2/passports/commands returns the catalog.

    JSON
    POST /api/v2/passports/commands
    { "type": "PASSPORT.SUSPEND",     // dotted wire format: PASSPORT.SUBMIT | PASSPORT.APPROVE |
                                      // PASSPORT.REJECT | PASSPORT.EDIT | PASSPORT.PUBLISH | PASSPORT.SUSPEND |
                                      // PASSPORT.REINSTATE | PASSPORT.REVOKE | PASSPORT.ARCHIVE | PASSPORT.RECYCLE …
      "tenantId": "<uuid>",
      "idempotencyKey": "<uuid>",
      "payload": { "passportId": "<uuid>", /* commandSpecificFields */ } }

    Guards are real: EDIT on a published passport → 403 GUARD_REJECTED. Replaying with the same idempotencyKey returns the first result. A field at the wrong level → 400 naming the field.

    Was this page helpful?
    Thanks — noted.Feedback goes to the docs team by email.
    ← PreviousCompliance & regulationsNext →Resolver & public
    On this page
    The command envelopePOST /dpp/generateGET /dpp/status/{requestId}POST /dpp/{productId}/publishPOST /products/{id}/publish-dppPOST /passports/{id}/publishPOST /passports/commandsGET /passports/commandsGET /passports/{id}/historyGET /passports/{id}/validationsPOST /passports/{id}/validationsGET /passports/{id}/as-of
    Norruva DPP API · sandbox developer docsGenerated 2026-07-29 · PRD-aligned (TSC roadmap rev 2) · statuses reflect E2E-verified sandbox behaviour — not marketing