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 |
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.
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}}
}201 Created (200 on an idempotent replay). The envelope is the contract artifact a device verifies BEFORE printing.
{
"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": "…" }
}Tenant-scoped job summaries, newest first, keyset-paginated (next_cursor). Listings are summaries, never signed envelopes — signing is reserved for a delivery.
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}}
}{ "count": 1, "jobs": [ { "job_ref": "JOB-20260729-000001", "status": "QUEUED", "gtin": "…", "serial": "…", "expected_payload": "…", "attempt_count": 0, "max_attempts": 3 } ], "next_cursor": null }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.
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}}
}{ "count": 1, "jobs": [ { "job_id": "JOB-20260729-000001", "attempt_id": "…", "lease_expires_at": "…", "expected_payload": "…", "payload_hash": "…", "signature": { "cryptosuite": "eddsa-jcs-2022" } } ] }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.
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}}
}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.
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}}
}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.
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}}
}Send Idempotency-Key — the key is claimed INSIDE the transition transaction, so a retry after a timeout replays instead of double-applying.
{ "job": { "job_ref": "JOB-20260729-000001", "status": "SEALED" }, "history": [ … ], "replayed": false }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.