AutoID print loop
Norruva creates the identity; the print layer (e.g. TSC) prints and encodes it; the outcome comes back per serial. Four legs, webhooks both ways — print execution stays the printer partner's job, Norruva stays the system of record for what was (and wasn't) printed.
The loop
POST /api/v2/carriers/batch { "productId": "…", "count": 1000 }
→ 201 { allocated, serials[] } → webhook carrier.generated
("codes ready" — eventData carries exportPath; no polling)
GET /api/v2/carriers/export?productId=…
→ the print CSV: gtin, serial, digital_link, sgtin96_epc
(digital_link IS the QR payload; sgtin96_epc feeds the RFID encoder)
…the print layer prints…
POST /api/v2/carriers/print-status
{ "results": [ { "serial": "…", "status": "printed", "deviceId": "PEX-2000-01" },
{ "serial": "…", "status": "failed", "reason": "ribbon jam" } ] }
→ 200 { updated[], unknown[], printed[], failed[], reportedAt, events[] }
→ webhooks print.confirmed / print.failedThe endpoints
| Call | Purpose | Auth |
|---|---|---|
POST /api/v2/carriers/batch | Allocate up to 10k numeric, SGTIN-96-safe serials for a product. Idempotent via Idempotency-Key; fires carrier.generated so subscribers pull instead of poll. | KEY production:create |
GET /api/v2/carriers/export | The print contract (CSV or format=json). Optional printState=printed|failed|pending narrows to the reported outcome — printState=failed is exactly the re-print file; an unknown value refuses with 422. | KEY production:view (identifiers:read satisfies it) |
POST /api/v2/carriers/print-status | Per-serial outcomes from the print layer, ≤1000 per call: printed or failed + optional reason / deviceId. | KEY epcis:capture (the TSC key) or production:create |
Semantics that matter
- Idempotent by construction. Outcomes land on the serialization record (
metadata.print: status, reason, deviceId, reportedAt) and are replaced wholesale — a later re-print legitimately overwrites an earlier failure (last write wins, also within one request). - A retry replays; it does not re-report. Because print controllers rarely send an
Idempotency-Key, the endpoint derives one from the batch itself. Re-posting an identical batch returns the first response and fires no further webhooks, so a timed-out report is safe to retry without duplicatingprint.confirmed/print.failedfor subscribers. Theevents[]counts in a replayed response describe the original call. Change any outcome in the batch and it hashes differently, so genuinely new results always execute. - Unknown serials are reported, never dropped. Anything not found for your tenant comes back in
unknown[]with a200— the known outcomes still commit. - Aggregate events, not per-serial spam. One
print.confirmedand/or oneprint.faileddelivery per request, each carrying the serial list (+ per-serialfailures[]reasons on the failed event) — a 10k-label run is 2 deliveries, not 10k. - Webhook dispatch failures never fail the call. The outcome is already durably recorded;
events[]in the response tells you what was actually notified.
Report outcomes — example
curl -X POST "$BASE/api/v2/carriers/print-status" \
-H "Authorization: Bearer $TSC_KEY" -H "Content-Type: application/json" \
-d '{ "results": [
{ "serial": "100001", "status": "printed", "deviceId": "PEX-2000-01" },
{ "serial": "100002", "status": "failed", "reason": "ribbon jam" } ] }'{ "updated": ["100001", "100002"], "unknown": [],
"printed": ["100001"], "failed": ["100002"],
"reportedAt": "2026-07-22T17:20:00.000Z",
"events": [ { "type": "print.confirmed", "webhooksNotified": 1 },
{ "type": "print.failed", "webhooksNotified": 1 } ] }The re-print file
After outcomes are in, GET /carriers/export?productId=…&printState=failed returns exactly the serials that need another pass — same CSV columns, so the same file feeds the same print pipeline. printState=pending answers "what has the print layer not reported yet?" for reconciliation.
Tier 2 — orchestrated print jobs (AIDC)
The CSV loop above trusts the print layer to manage its own work. The print-job tier (/api/v2/aidc/*, F15) makes each label an orchestrated, evidence-carrying unit instead: Norruva issues a signed job for one allocated serial, an enrolled device claims it under a lease, reports each state transition with its evidence artifact, and the terminal outcome lands on the same metadata.print spine the CSV loop uses — so the export, the re-print file and the webhooks stay one truth whichever tier printed the label.
POST /api/v2/aidc/jobs { "gtin": "…", "serial": "…" } ← serial must be allocated
→ 201 signed envelope (fails 422 unless the passport is readyToPrint)
POST /api/v2/aidc/jobs/claim ← THE device pulls (no inbound port into the plant)
→ signed envelope + attempt_id + lease
POST /api/v2/aidc/jobs/{ref}/heartbeat ← long run? extend the lease
POST /api/v2/aidc/jobs/{ref}/result { "status": "RENDERED", "evidence": {…}, "attempt_id": "…" }
…repeat per state… SPOOL_SUBMITTED → PRINTED_ATTESTED → SCAN_VERIFIED → SEALED
→ SEALED: metadata.print=printed + EPCIS commissioning + print.confirmed
→ DEAD_LETTER (retries exhausted): metadata.print=failed → the re-print fileThe job lifecycle — states are evidence-gated
| State | Meaning | Required evidence | Who may assert it |
|---|---|---|---|
QUEUED | Created from an allocated serial; waiting for a claim. | — | issuer |
CLAIMED | A device holds it under a lease (fresh attempt_id per claim). | — | device (via claim) |
RENDERED | Label content produced. | render_manifest | device / operator |
SPOOL_SUBMITTED | A spooler accepted the job — machine-observable, nothing more. | spool_receipt | device / operator |
PRINTED_ATTESTED | Paper came out. Actor-attested only — a spool submission can never imply it (the honesty constraint). | — | device or human only, never system |
SCAN_VERIFIED | The printed symbol decoded back to the expected payload. | scan_result | device / operator |
SEALED | Terminal success — evidence bundle sealed. Propagates printed to the spine + EPCIS commissioning. | evidence_bundle | device / operator |
FAILED | Retryable up to max_attempts — re-enters the claim pool. | — | any |
DEAD_LETTER | Terminal failure — retry budget exhausted (reaper). Propagates failed → the re-print file. | — | system (reaper) |
CANCELLED | Terminal, before physical work — no print outcome to report. | — | issuer |
The four client guarantees
- Lease. A crashed device's job is reclaimable the moment its lease expires — no human, no reaper on the happy path. Heartbeat to keep a long run.
- Attempt id. Every claim mints and PERSISTS a fresh
attempt_id; a result from a superseded attempt is a409 STALE_ATTEMPT, an expired envelope a410. - Idempotency. Send
Idempotency-Keyon results — the key is claimed inside the transition transaction, so a retry after a timeout replays instead of double-applying. - Atomic outcomes. A terminal transition and the spine write (
metadata.print) commit in ONE transaction — there is no window where the job says printed and the export says pending.
Two creation gates, both typed 422s: SERIAL_NOT_ALLOCATED (allocate via carriers/batch first — a job can never mint a serial) and PASSPORT_NOT_READY (the F6 readyToPrint gate: ≥ 1 anchored validation ∧ published; anchor via POST /passports/{id}/validations). Full endpoint reference: Print jobs & devices; operator walkthrough: Run a print device.
POST /epcis/capture with the same TSC key), and a dedicated verify-label endpoint is not shipped — see Deviations. Subscribe to the trio on one endpoint via Webhooks & events.