Developer docs
Sandbox · API v2PlaygroundGet API keys

Webhooks & events

Subscribe to events instead of polling. A Webhook is a tenant-scoped endpoint + subscribed event set + signing secret; a Delivery is a signed, replay-protected, retried attempt to reach it.

Event catalogue

Subscribe only to catalogue events (the dispatcher’s WEBHOOK_EVENT_TYPES, nothing else is ever delivered); an unknown name returns 422 UNKNOWN_EVENT_TYPE. The AutoID print-cycle trio closes the print loop event-driven: carrier.generated ("codes ready, pull carriers/export"), then print.confirmed / print.failed fan out the print layer's POST /carriers/print-status reports (one print.confirmed per report with the serial list; print.failed may be split across several events, see Event data), full walkthrough in AutoID print loop.

product.createdproduct.updatedproduct.deletedproduct.published compliance.evaluatedpassport.anchored scan.recordedimport.completedimport.failed carrier.generatedprint.confirmedprint.failed aidc.job.createdaidc.job.sealedaidc.job.dead_lettered aidc.job.cancelledaidc.job.unclaimablecarrier.pool.low

compliance.verified and compliance.violation_created are retired: an existing subscription naming either receives compliance.evaluated, and a new or updated subscription that names one is refused with 422 RETIRED_EVENT_TYPE.

Envelope

External events use the platform envelope, the WebhookEvent value object. Field names are exactly these; there is no Stripe-style created/livemode/data.object nesting:

JSON
{ "id": "<uuid>", "type": "product.published",
  "timestamp": "2026-07-20T12:00:00.000Z", "apiVersion": "2026-09-25",
  "data": { /* the event type's fields: see Event data */ } }

Event data

Each event type sends a fixed, named set of fields and nothing else: a notification, not a copy of the record. To read more, follow _links.self (or the job link) with your API key, so a webhook never carries more than your key can already read. A field with no value is left out; nothing is sent as an empty list or empty string.

Typedata fields
product.createdproductId, companyId, status, version, gtin, modelNumber, occurredAt, _links.self
product.updatedas product.created, plus changedFields: the names of the top-level fields that changed (never their values; anything under custom data is reported as customData)
product.publishedproductId, companyId, occurredAt, _links.self
compliance.evaluatedevaluationId, productId, companyId, occurredAt, _links.self (the product's compliance resource). No score and no verdict: fetch the findings.
import.completed / import.failedjobId, companyId, occurredAt, _links.self (the job, GET /api/v2/ingest/{jobId}); completed adds successCount, errorCount, durationMs; failed adds processedRows (fetch the job from _links.self for the reason)
carrier.generatedcompanyId, productId, allocated, exportPath, occurredAt
carrier.pool.lowcompanyId, productId, reason (always allocation_shortfall), requested, allocated, shortfall, remainingCapacity, batchLot, occurredAt
print.confirmed / print.failedserials, count, reportedAt, jobRef, occurredAt; failed adds failures[] of { serial, reason } as your print layer reported them, each reason capped at 500 characters, plus reportRef and chunk (index from 0, total). One report with many failures can arrive as several print.failed events, in any order, each with its own event id; serials, count and failures cover that event only. Group them by reportRef: the report is complete when you hold chunk.total of them.
aidc.job.*Fields are snake_case, as in the AIDC API. Every transition sends job_ref, serial, gtin, dpp_id, status, attempt_count, max_attempts, job_href. aidc.job.created adds carrier, template_id, printer_target, expected_payload, created_at. aidc.job.sealed adds evidence_recorded, device_id, updated_at. aidc.job.dead_lettered adds device_id, updated_at. aidc.job.cancelled adds updated_at. aidc.job.unclaimable adds req_key, unclaimable_since, evaluated_at, eligible_device_count, cause. Dead-lettered and cancelled jobs carry no error text; fetch job_href.

product.deleted, passport.anchored and scan.recorded are reserved: accepted as subscription names, not sent today.

Changes

Breaking change on 2026-09-25 (apiVersion 2026-09-25). There is no compatibility mode: a receiver pinned to 2024-12-05 also gets the new shape, and redeliveries of earlier events are sent in the new shape, except the two retired compliance events, which are not redelivered.

What changed: each event now sends only the fields listed in Event data. product.created and product.updated used to carry the whole product record, including custom data and compliance fields; that exposed more than a notification should, so it was removed. compliance.verified and compliance.violation_created are retired and replaced by one compliance.evaluated event.

What to do: if your receiver read product fields from data, fetch the product from _links.self with your API key. If it branched on compliance.verified or compliance.violation_created, handle compliance.evaluated instead. An existing subscription that names either old type receives compliance.evaluated, once per evaluation even if it names both, and the delivered type is always compliance.evaluated. It has no score and no verdict; fetch the findings from _links.self. A new or updated subscription can no longer name the retired types: the API answers 422 RETIRED_EVENT_TYPE, for example: compliance.verified is retired. Subscribe to compliance.evaluated instead.

2026-09-26, additive, apiVersion unchanged. print.failed now always carries reportRef and chunk. A report too large for one event is split across several instead of being refused, so group by reportRef rather than treating each event as a whole report. A print.failed queued before this change and redelivered after it has no reportRef or chunk: treat an event without them as one whole report.

Delivery contract

  • Signatures: X-Norruva-Signature (V1) plus X-Norruva-Timestamp and X-Norruva-Signature-V2 (timestamped HMAC).
  • Replay window: 5 minutes, a stale timestamp is rejected.
  • Dedup: the delivery id is stable across retries; use it for receiver-side idempotency.
  • Retries: 4xx → no retry; 5xx/timeout → backoff retries. Manual redelivery is available.
  • Delivery durability varies by event type: print.confirmed, print.failed, and carrier.generated ride the durable outbox → event bus → dispatch path, with a persisted delivery record and retry cron backing the signature/replay/dedup/retry guarantees above. product.created, product.updated, product.published, import.completed, and import.failed are emitted best-effort (non-outbox), if the emitting process fails between the domain action and dispatch, the webhook is simply not sent, with no queued retry of the emission itself. A durable-emission upgrade for these types is tracked as a follow-up.
One gap to know. There is no local end-to-end receiver harness yet (signing/retry are unit-verified). Secret rotation IS available, POST /webhooks/{id}/rotate-secret. It takes effect at once: every delivery after it is signed with the new secret only. See the receiver guide.
Was this page helpful?
Thanks, noted.Feedback goes to the docs team by email.