Developer Docs
Sandbox · API v2PlaygroundGet API keys

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/jobsCreate 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 the job RESOURCE ({job, links, replayed}). 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). D-04 (X-Norruva-Api-Version 2026-08-29): creation no longer signs an envelope, so a missing issuer key no longer blocks it, and the request field ttl_seconds is gone. The signed envelope is minted at POST /api/v2/aidc/jobs/claim, over the attempt the claim persists.

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.
carrier_profileoptionalbody · stringcp_gs1_dl_qr_default | cp_gs1_dl_qr_verified | cp_gs1_dl_qr_graded. Echoed back on the job resource.
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, replayed:true, on an idempotent replay). No signature and no attempt_id: creation mints neither, so no replay at any layer can produce a fresh signed attempt. The envelope a device verifies BEFORE printing arrives at claim.

JSON
{
  "job": {
    "job_ref": "JOB-20260829-000000042",
    "status": "QUEUED",
    "expected_payload": "https://id.norruva.com/01/09506000134352/21/A1B2C3",
    "payload_hash": "sha256:9f2c…",
    "template_id": "eu-standard-4x6",
    "carrier_profile": "cp_gs1_dl_qr_default",
    "req_key": "v1:print_2d",
    "attempt_count": 0,
    "max_attempts": 3
  },
  "links": { "self": "/api/v2/aidc/jobs/JOB-20260829-000000042", "claim": "/api/v2/aidc/jobs/claim" },
  "replayed": false
}
Errors you can branch on
Related
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
Related
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
Related
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
Related
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
Related
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
Emits events
print.confirmedprint.failed
Related

Retry-exhausted jobs are parked 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.