---
title: "Automating Digital Product Passports with Webhooks — qr3.app Blog"
description: "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."
canonical: "https://qr3.app/en/blog/dpp-webhooks-automation/"
lang: "en"
robots: "index,follow,max-image-preview:large,max-snippet:-1"
---

# Automating Digital Product Passports with Webhooks — qr3.app Blog

> 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.

Canonical: <https://qr3.app/en/blog/dpp-webhooks-automation/>

[← Blog](/en/blog/) [Tutorials](/en/blog/category/tutorials/)

May 30, 2026 5 min read [Tutorials](/en/blog/category/tutorials/) [dpp](/en/blog/tag/dpp/)[webhooks](/en/blog/tag/webhooks/)[automation](/en/blog/tag/automation/)[qr-scanned](/en/blog/tag/qr-scanned/)[sdk](/en/blog/tag/sdk/)

# 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](https://qr3-blog-agent.meistern.workers.dev/cdn/blog/dpp-webhooks-automation/hero.png)

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. Verify the signature against the raw body before you parse it:

```
import express from "express";
import { createHmac, timingSafeEqual } from "crypto";

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

// Recompute the HMAC over the exact bytes qr3 signed and compare in constant time.
function verify(raw: Buffer, header: string | undefined): boolean {
  const expected = createHmac("sha256", secret).update(raw).digest("hex");
  const expectedBytes = Buffer.from(`sha256=${expected}`);
  const receivedBytes = Buffer.from(header ?? "");
  return (
    expectedBytes.length === receivedBytes.length &&
    timingSafeEqual(expectedBytes, receivedBytes)
  );
}

// Use the raw body so the signature matches the exact bytes qr3 signed.
app.post("/webhooks/qr3", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.body, req.headers["x-qr3-signature"] as string)) return res.sendStatus(401);
  const event = JSON.parse(req.body.toString("utf8"));

  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 (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 raw request body, using your endpoint secret. The signature arrives in the `X-QR3-Signature` request header, as `sha256=` followed by the hex digest. Recompute the HMAC over the same bytes and compare the two in constant time; if they differ, reject the request:

```
app.post("/webhooks/qr3", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.body, req.headers["x-qr3-signature"] as string)) {
    // signature mismatch → not from qr3 (or body was altered in transit)
    return res.sendStatus(401);
  }
  process(JSON.parse(req.body.toString("utf8")));
  res.sendStatus(200);
});
```

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. Checking the `X-QR3-Signature` HMAC against the raw body 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

- [EU ESPR — Regulation (EU) 2024/1781](https://eur-lex.europa.eu/eli/reg/2024/1781/oj)

- [EU Battery Regulation 2023/1542](https://eur-lex.europa.eu/eli/reg/2023/1542/oj)

- [HMAC — RFC 2104](https://www.rfc-editor.org/rfc/rfc2104)

**Start for free** and wire your first DPP webhook: [app.qr3.app/sign-up](https://app.qr3.app/sign-up)

## Related posts

- [![](https://qr3-blog-agent.meistern.workers.dev/cdn/blog/gs1-resolver-test-suite-dpp-links/hero.png) Tutorials GS1 Resolver Test Suite: Reliably Test DPP Links Before Go-Live The stable GS1 test suite makes Resolver 1.2 testable. Verify redirects, linksets, CORS, languages, and error handling before go-live.](/en/blog/gs1-resolver-test-suite-dpp-links/)
- [![](https://qr3-blog-agent.meistern.workers.dev/cdn/blog/dpp-battery-passport-2027-20260715/hero.png) Industry News BatteryPass-Ready Launches Test Environment for the Digital Battery Passport The BatteryPass-Ready consortium launched a public test environment for the Digital Battery Passport on June 24, 2026 — ahead of the EU mandate taking effect in February 2027.](/en/blog/dpp-battery-passport-2027-20260715/)
- [![](https://qr3-blog-agent.meistern.workers.dev/cdn/blog/sector-20260713/hero.png) Industry News Digital Battery Passport 2027: What Manufacturers Need to Know Now Webinars, open-source tools, and new standards: the EU Battery Passport is gaining momentum. A July 2026 status overview and the open questions ahead of the February 2027 deadline.](/en/blog/sector-20260713/)
- [![](https://qr3-blog-agent.meistern.workers.dev/cdn/blog/sector-20260711/hero.png) Industry News Digital Battery Passport 2026: Test Environments, Standards, and Open Questions Where does industry stand on the Digital Battery Passport? An overview of test environments, CEN/CENELEC standards, open-source tools, and regulatory developments as of summer 2026.](/en/blog/sector-20260711/)

[← All articles](/en/blog/) [Start for free](https://app.qr3.app/sign-up)
