Automating Digital Product Passports with Webhooks

A Digital Product Passport is not a static page — every scan is an event you can build on. React to qr.scanned for analytics, re-orders and recall flags, keep passport data fresh from your ERP, and verify every payload with HMAC-SHA256. A developer-grade guide with code.

by QR3 Redaktion

Automating Digital Product Passports with Webhooks

Most teams treat a Digital Product Passport as a page: you create it, print the QR, and forget it. That leaves the most useful signal on the table. Every time someone scans the QR on a product, that is an event — a real person, in a real country, holding a real unit, at a real moment. Wire those events into your stack and the passport stops being a static page and becomes the front end of an automation pipeline. This guide shows how to subscribe to qr.scanned, verify every payload, and turn scans and updates into real workflows with the qr3 SDK.

Why a DPP is an event source, not a static page

A passport's value is not the page a consumer sees once. It is the stream of interactions around it: scans in the field, data changes from your ERP, lifecycle transitions. Each is something your systems can react to in real time.

Webhooks invert the usual polling model. Instead of asking "has anything happened?" on a timer, qr3 calls you the moment it does. The event types the platform emits include qr.scanned, plus qr.created, qr.updated and qr.deleted for lifecycle changes. The one most teams underuse is qr.scanned: it fires when a consumer, technician, or customs officer actually scans a product in the wild.

A qr.scanned payload carries the context you need to act — including the country of the scan and the dpp/code id that identifies which unit was scanned. That is enough to drive analytics, replenishment, and recall logic without a human in the loop.

Subscribing to qr.scanned

Point a webhook endpoint at your service and handle the event. The qr3 SDK ships a verifier so you do not parse raw bodies by hand:

import express from "express";
import { verifyWebhook } from "@qr3/sdk";

const app = express();
const secret = process.env.QR3_WEBHOOK_SECRET!;

// Use the raw body so the signature matches the exact bytes qr3 signed.
app.post("/webhooks/qr3", express.raw({ type: "application/json" }), (req, res) => {
  const event = verifyWebhook(req.body, req.headers["qr3-signature"], secret);

  if (event.type === "qr.scanned") {
    // event payload includes fields like the scan country and the dpp/code id
    handleScan(event);
  }

  res.sendStatus(200);
});

The handler is deliberately thin: verify, branch on event.type, acknowledge fast with a 200. Do the heavy lifting (analytics writes, ERP calls) asynchronously so a slow downstream never blocks the acknowledgement.

Verifying signatures (verifyWebhook, HMAC-SHA256) — do this always

A webhook endpoint is a public URL. Anyone who finds it can POST to it. If you trust the body without checking who sent it, an attacker can forge "scans", trigger bogus re-orders, or fire false recall flags. Always verify the signature before you act on a payload.

qr3 signs every webhook with HMAC-SHA256 over the request body, using your endpoint secret. The signature arrives in the qr3-signature request header. verifyWebhook(body, signature, secret) recomputes the HMAC and compares it; if it does not match, it throws and you reject the request:

import { verifyWebhook } from "@qr3/sdk";

app.post("/webhooks/qr3", express.raw({ type: "application/json" }), (req, res) => {
  try {
    const event = verifyWebhook(req.body, req.headers["qr3-signature"], secret);
    process(event);
    res.sendStatus(200);
  } catch {
    // signature mismatch → not from qr3 (or body was altered in transit)
    res.sendStatus(401);
  }
});

Three rules that keep this honest:

  • Verify against the raw bytes. Re-serializing JSON can reorder keys and change whitespace, which breaks the HMAC. Capture the raw body (above, express.raw).
  • Keep the secret a secret. It lives in your environment, never in client code or a repo.
  • Fail closed. No valid signature → 401, no side effects. Never "process anyway" on a mismatch.

Patterns: analytics / re-order / recall

Once you trust the event, a handful of patterns cover most of what teams want:

function handleScan(event: { type: string; data: { country?: string; dpp_id?: string } }) {
  // 1) Analytics — where and how often are products scanned?
  metrics.increment("dpp.scan", { country: event.data.country });

  // 2) Re-order — a scan can signal consumption or field activity
  if (event.data.country) maybeReplenish(event.data.dpp_id, event.data.country);

  // 3) Recall flag — scans of a flagged unit alert your team
  if (isRecalled(event.data.dpp_id)) alertRecall(event.data.dpp_id, event.data.country);
}
  • Analytics: aggregate scans by country and unit to see real-world engagement — which markets actually scan, and which SKUs see the most post-sale interaction.
  • Re-order / replenishment: a burst of scans in a region can feed demand signals or trigger restock workflows in your ERP.
  • Recall / safety: if a unit is under recall, a scan is a chance to reach whoever is holding it — alert your team, or surface a notice on the passport itself.

None of these need polling or a nightly batch. They happen the instant the product is scanned.

Keeping data current via client.dpp.update

Reacting to scans is half the loop; the other half is keeping the passport itself accurate. Regulations such as ESPR (EU 2024/1781) and the Battery Regulation (EU 2023/1542) expect passport data to reflect reality over the product's life — recalculated carbon footprint, updated repair instructions, reached recycled-content targets.

Drive those updates from the system of record. When a value changes in your ERP, push it to the passport:

import { QR3 } from "@qr3/sdk";

const client = new QR3({ apiKey: process.env.QR3_API_KEY! });

// GTIN and serial are immutable; data fields are updatable.
await client.dpp.update(dppId, {
  battery_data: { carbon_footprint_kg: 58, recycled_content_pct: 16 },
});

Because the QR encodes a stable resolver URL (https://qr3.app/dpp/{gtin}/{serial}, add ?format=jsonld for JSON-LD), you never reprint a label to change data. The identity stays fixed; the content behind it stays current. Pair this with qr.updated and you can fan out a notification whenever a passport changes — closing the loop between your ERP, the passport, and anyone watching downstream.

An events table: event → what to automate

Event Fires when What to automate
qr.scanned A product QR is scanned in the field Analytics by country, replenishment signals, recall alerts
qr.created A new passport is created Index it, sync to PIM/ERP, notify the catalog team
qr.updated Passport data changes Re-cache the public page, fan out change notifications
qr.deleted A passport is removed Tombstone internal records, revoke downstream references

Start with qr.scanned for engagement and field signals; add the lifecycle events as you sync passports into your wider systems.

FAQ

Do I have to verify the signature if my endpoint URL is secret? Yes. A URL is not a secret — it leaks in logs, proxies, and browser history. HMAC verification with verifyWebhook is the only thing that proves a payload actually came from qr3.

What happens if my endpoint is down when an event fires? Acknowledge fast with 200 once you have verified, and do slow work asynchronously so transient downstream issues never stall the response. Keep your own idempotency on dpp/code id so a retried delivery is not double-counted.

Can I update GTIN or serial via client.dpp.update? No — GTIN and serial are immutable; they are the product's stable identity. Only the data fields are updatable. That immutability is exactly what lets the printed QR stay valid forever.

Sources

Start for free and wire your first DPP webhook: app.qr3.app/sign-up