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/Print jobs & devices

    Print jobs & devices

    Five job endpoints are the entire device integration — issue, claim, heartbeat, inspect, report — plus the device registry the credential binds to. Concept and lifecycle: AutoID print loop; operator walkthrough: Run a print device.

    POST/api/v2/aidc/jobsIssue a print job
    GET/api/v2/aidc/jobsList print jobs
    POST/api/v2/aidc/jobs/claimClaim jobs (pull delivery)
    GET/api/v2/aidc/jobs/{jobId}Inspect a job
    POST/api/v2/aidc/jobs/{jobId}/heartbeatExtend a lease
    POST/api/v2/aidc/jobs/{jobId}/resultReport a job outcome
    KEYaidc:writeIdempotency-Key recommended

    Create a tenant-scoped print job for an ALREADY-ALLOCATED serial and get back a signed (eddsa-jcs-2022) job envelope. Three gates: the serial must hold an active allocation (422 SERIAL_NOT_ALLOCATED), the passport must be readyToPrint — ≥1 anchored validation ∧ published (422 PASSPORT_NOT_READY) — and the serial must not already have a live job (409 JOB_ALREADY_ACTIVE, naming the job in flight). Fail-closed signing: no resolver base or issuer key is a 503, never an unsigned envelope.

    Parameters
    gtinrequiredbody · stringGTIN with a valid check digit (8–14 digits).
    serialrequiredbody · stringAn allocated serial (≤ 20 chars, [A-Za-z0-9-]).
    template_idoptionalbody · stringLabel template id; defaults to the platform template.
    printer_targetoptionalbody · stringPrinter profile hint routed to the claiming device.
    ttl_secondsoptionalbody · numberEnvelope TTL (≤ 604800). Expired envelopes are rejected server-side at result time.
    Request
    POST {{baseUrl}}/api/v2/aidc/jobs
    Authorization: Bearer {{apiKey}}
    Content-Type: application/json
    Idempotency-Key: {{uuid}}
    
    { "gtin": "{{gtin}}", "serial": "{{serial}}" }
    curl -X POST "$BASE_URL/api/v2/aidc/jobs" \
      -H "Authorization: Bearer $NORRUVA_API_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: $(uuidgen)" \
      -d '{
      "gtin": "{{gtin}}",
      "serial": "{{serial}}"
    }'
    const baseUrl = process.env.NORRUVA_BASE_URL;
    const apiKey = process.env.NORRUVA_API_KEY;
    
    const res = await fetch(`${baseUrl}/api/v2/aidc/jobs`, {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${apiKey}`,
        "Content-Type": `application/json`,
        "Idempotency-Key": `${crypto.randomUUID()}`
      },
      body: JSON.stringify({
        "gtin": "{{gtin}}",
        "serial": "{{serial}}"
      })
    });
    
    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/aidc/jobs",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": f"application/json",
            "Idempotency-Key": f"{uuid.uuid4()}"
        },
        json={
          "gtin": "{{gtin}}",
          "serial": "{{serial}}"
        },
    )
    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/aidc/jobs", baseURL)
    
    	payload := []byte(`{
      "gtin": "{{gtin}}",
      "serial": "{{serial}}"
    }`)
    	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 (200 on an idempotent replay). The envelope is the contract artifact a device verifies BEFORE printing.

    JSON
    {
      "job_id": "JOB-20260729-000001",
      "dpp_id": "01/09506000134352/21/A1B2C3",
      "expected_payload": "https://id.norruva.com/01/09506000134352/21/A1B2C3",
      "payload_hash": "9f2c…",
      "attempt_id": "…",           // informational pre-issuance — the DELIVERY attempt is minted at claim
      "expires_at": "2026-07-30T10:00:00.000Z",
      "signature": { "cryptosuite": "eddsa-jcs-2022", "proofValue": "…" }
    }
    Errors you can branch on
    400 VALIDATION_ERROR401409422503
    Related
    AutoID print loopRun a print device
    KEYaidc:read

    Tenant-scoped job summaries, newest first, keyset-paginated (next_cursor). Listings are summaries, never signed envelopes — signing is reserved for a delivery.

    Parameters
    statusoptionalquery · stringOne of the FSM states (QUEUED … SEALED, FAILED, DEAD_LETTER, CANCELLED); unknown → 400.
    limitoptionalquery · number1–100, default 20.
    cursoroptionalquery · stringOpaque next_cursor from a prior page.
    Request
    GET {{baseUrl}}/api/v2/aidc/jobs
    Authorization: Bearer {{apiKey}}
    curl -X GET "$BASE_URL/api/v2/aidc/jobs" \
      -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/aidc/jobs`, {
      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/aidc/jobs",
        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/aidc/jobs", 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}}
    }
    Response
    JSON
    { "count": 1, "jobs": [ { "job_ref": "JOB-20260729-000001", "status": "QUEUED", "gtin": "…", "serial": "…", "expected_payload": "…", "attempt_count": 0, "max_attempts": 3 } ], "next_cursor": null }
    Errors you can branch on
    400 VALIDATION_ERROR401
    Related
    AutoID print loop
    KEYaidc:writedevice-bound key — an unenrolled credential gets 403 DEVICE_NOT_ENROLLED

    THE delivery mechanism (a webhook is only a nudge). The key must be bound to an enrolled device. Atomically hands out QUEUED work plus expired-lease and retryable FAILED jobs under their attempt budget (FOR UPDATE SKIP LOCKED — two devices never receive the same job). Each claim mints a fresh, PERSISTED attempt_id + lease, returned inside a signed envelope. An empty claim is a 200, not an error.

    Parameters
    limitoptionalbody · number1–25 jobs per claim, default 1.
    lease_secondsoptionalbody · number30–3600, default 120. Heartbeat to extend.
    envelope_ttl_secondsoptionalbody · numberEnvelope validity for this attempt.
    Request
    POST {{baseUrl}}/api/v2/aidc/jobs/claim
    Authorization: Bearer {{apiKey}}
    Content-Type: application/json
    
    { "limit": 1, "lease_seconds": 120 }
    curl -X POST "$BASE_URL/api/v2/aidc/jobs/claim" \
      -H "Authorization: Bearer $NORRUVA_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
      "limit": 1,
      "lease_seconds": 120
    }'
    const baseUrl = process.env.NORRUVA_BASE_URL;
    const apiKey = process.env.NORRUVA_API_KEY;
    
    const res = await fetch(`${baseUrl}/api/v2/aidc/jobs/claim`, {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${apiKey}`,
        "Content-Type": `application/json`
      },
      body: JSON.stringify({
        "limit": 1,
        "lease_seconds": 120
      })
    });
    
    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/aidc/jobs/claim",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": f"application/json"
        },
        json={
          "limit": 1,
          "lease_seconds": 120
        },
    )
    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/aidc/jobs/claim", baseURL)
    
    	payload := []byte(`{
      "limit": 1,
      "lease_seconds": 120
    }`)
    	req, _ := http.NewRequest("POST", url, bytes.NewBuffer(payload))
    	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}}
    }
    Response
    JSON
    { "count": 1, "jobs": [ { "job_id": "JOB-20260729-000001", "attempt_id": "…", "lease_expires_at": "…", "expected_payload": "…", "payload_hash": "…", "signature": { "cryptosuite": "eddsa-jcs-2022" } } ] }
    Errors you can branch on
    401403503
    Related
    Run a print device
    KEYaidc:read

    Either side inspects a job by its job_ref. Before any claim: an unsigned summary (a read is not a delivery). Once claimed: the signed envelope for the PERSISTED current attempt, plus the transition history.

    Request
    GET {{baseUrl}}/api/v2/aidc/jobs/{jobId}
    Authorization: Bearer {{apiKey}}
    curl -X GET "$BASE_URL/api/v2/aidc/jobs/{jobId}" \
      -H "Authorization: Bearer $NORRUVA_API_KEY"
    const baseUrl = process.env.NORRUVA_BASE_URL;
    const apiKey = process.env.NORRUVA_API_KEY;
    const jobId = "…";
    
    const res = await fetch(`${baseUrl}/api/v2/aidc/jobs/${jobId}`, {
      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"]
    job_id = "…"
    
    resp = requests.get(
        f"{base_url}/api/v2/aidc/jobs/{job_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")
    	jobId := "…"
    	url := fmt.Sprintf("%s/api/v2/aidc/jobs/%s", baseURL, jobId)
    
    	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
    401404
    Related
    AutoID print loop
    KEYaidc:writedevice-bound key

    The claiming device extends its lease mid-run so a long job is not reclaimed. Must name the attempt it holds — a superseded attempt_id is a 409 STALE_ATTEMPT, a lease no longer held is 409 LEASE_NOT_HELD.

    Request
    POST {{baseUrl}}/api/v2/aidc/jobs/{jobId}/heartbeat
    Authorization: Bearer {{apiKey}}
    Content-Type: application/json
    
    { "attempt_id": "{{attemptId}}", "lease_seconds": 120 }
    curl -X POST "$BASE_URL/api/v2/aidc/jobs/{jobId}/heartbeat" \
      -H "Authorization: Bearer $NORRUVA_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
      "attempt_id": "{{attemptId}}",
      "lease_seconds": 120
    }'
    const baseUrl = process.env.NORRUVA_BASE_URL;
    const apiKey = process.env.NORRUVA_API_KEY;
    const jobId = "…";
    
    const res = await fetch(`${baseUrl}/api/v2/aidc/jobs/${jobId}/heartbeat`, {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${apiKey}`,
        "Content-Type": `application/json`
      },
      body: JSON.stringify({
        "attempt_id": "{{attemptId}}",
        "lease_seconds": 120
      })
    });
    
    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"]
    job_id = "…"
    
    resp = requests.post(
        f"{base_url}/api/v2/aidc/jobs/{job_id}/heartbeat",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": f"application/json"
        },
        json={
          "attempt_id": "{{attemptId}}",
          "lease_seconds": 120
        },
    )
    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")
    	jobId := "…"
    	url := fmt.Sprintf("%s/api/v2/aidc/jobs/%s/heartbeat", baseURL, jobId)
    
    	payload := []byte(`{
      "attempt_id": "{{attemptId}}",
      "lease_seconds": 120
    }`)
    	req, _ := http.NewRequest("POST", url, bytes.NewBuffer(payload))
    	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
    401403404409
    Related
    Run a print device
    KEYaidc:writeIdempotency-Key recommended

    A device (or operator) reports a transition. The actor is DERIVED FROM AUTHENTICATION (D20) — a device-bound key reports as that device and MUST name its attempt_id; evidence-gated states refuse without their artifact (422 MISSING_EVIDENCE). Terminal outcomes propagate to the carrier spine IN THE SAME TRANSACTION: SEALED → metadata.print=printed (+ EPCIS commissioning event), DEAD_LETTER → failed → the re-print file. Fires the aggregate print.confirmed / print.failed webhooks.

    Parameters
    statusrequiredbody · stringTarget FSM state (e.g. RENDERED, SPOOL_SUBMITTED, PRINTED_ATTESTED, SCAN_VERIFIED, SEALED, FAILED).
    attempt_idoptionalbody · stringREQUIRED for device actors; a superseded attempt is a 409.
    evidenceoptionalbody · objectThe state's evidence artifact — render_manifest, spool_receipt, scan_result, or evidence_bundle.
    noteoptionalbody · stringOperator note (≤ 500 chars).
    error_messageoptionalbody · stringFailure reason on FAILED.
    Request
    POST {{baseUrl}}/api/v2/aidc/jobs/{jobId}/result
    Authorization: Bearer {{apiKey}}
    Content-Type: application/json
    Idempotency-Key: {{uuid}}
    
    { "status": "SEALED", "attempt_id": "{{attemptId}}", "evidence": { "bundle": "…" } }
    curl -X POST "$BASE_URL/api/v2/aidc/jobs/{jobId}/result" \
      -H "Authorization: Bearer $NORRUVA_API_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: $(uuidgen)" \
      -d '{
      "status": "SEALED",
      "attempt_id": "{{attemptId}}",
      "evidence": {
        "bundle": "…"
      }
    }'
    const baseUrl = process.env.NORRUVA_BASE_URL;
    const apiKey = process.env.NORRUVA_API_KEY;
    const jobId = "…";
    
    const res = await fetch(`${baseUrl}/api/v2/aidc/jobs/${jobId}/result`, {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${apiKey}`,
        "Content-Type": `application/json`,
        "Idempotency-Key": `${crypto.randomUUID()}`
      },
      body: JSON.stringify({
        "status": "SEALED",
        "attempt_id": "{{attemptId}}",
        "evidence": {
          "bundle": "…"
        }
      })
    });
    
    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"]
    job_id = "…"
    
    resp = requests.post(
        f"{base_url}/api/v2/aidc/jobs/{job_id}/result",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": f"application/json",
            "Idempotency-Key": f"{uuid.uuid4()}"
        },
        json={
          "status": "SEALED",
          "attempt_id": "{{attemptId}}",
          "evidence": {
            "bundle": "…"
          }
        },
    )
    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")
    	jobId := "…"
    	url := fmt.Sprintf("%s/api/v2/aidc/jobs/%s/result", baseURL, jobId)
    
    	payload := []byte(`{
      "status": "SEALED",
      "attempt_id": "{{attemptId}}",
      "evidence": {
        "bundle": "…"
      }
    }`)
    	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

    Send Idempotency-Key — the key is claimed INSIDE the transition transaction, so a retry after a timeout replays instead of double-applying.

    JSON
    { "job": { "job_ref": "JOB-20260729-000001", "status": "SEALED" }, "history": [ … ], "replayed": false }
    Errors you can branch on
    400 VALIDATION_ERROR401403404409410422
    Emits events
    print.confirmedprint.failed
    Related
    AutoID print loopRun a print device

    The reaper (/api/cron/print-job-reaper, CRON_SECRET) parks retry-exhausted jobs in DEAD_LETTER and propagates the failure to the re-print file in the same transaction — nothing is ever silently stranded.

    Was this page helpful?
    Thanks — noted.Feedback goes to the docs team by email.
    ← PreviousWebhooksNext →Import / bulk
    On this page
    POST /aidc/jobsGET /aidc/jobsPOST /aidc/jobs/claimGET /aidc/jobs/{jobId}POST /aidc/jobs/{jobId}/heartbeatPOST /aidc/jobs/{jobId}/resultGET /devicesPOST /devicesGET /devices/{id}PATCH /devices/{id}DELETE /devices/{id}POST /devices/{id}/status
    Norruva DPP API · sandbox developer docsGenerated 2026-07-29 · PRD-aligned (TSC roadmap rev 2) · statuses reflect E2E-verified sandbox behaviour — not marketing