Show the passport in your page

Show a brand's passport in your own page and lose nothing: the passport, its proof and its signature in one call, your visitors' scans and NFC reads counted for them, the printed QR that leads to your page, and the claim and the transfer in a hosted window that comes back to you. Permissions passport:read, scans:write and nfc:verify.

On this page

This page is for the partner that shows a brand's passports in its own page, with its own design: a reseller for its client brands, an agency for the brand it serves. When you leave it, you will know how to read a complete passport in one call, have each scan counted for the visitor who made it, verify an NFC chip only once, receive printed QR scans on your page, and have a product claimed without ever asking your visitor for their SealTrust password.

The page hosted by SealTrust stays the default option. Nothing below changes for a brand that has not asked for it.

#What you need

  • An API key that carries the permissions you need: passport:read to read, scans:write to record scans, nfc:verify for NFC chips. You create it in the console, Settings then Developers.
  • A plan that includes API access.
  • For a reseller, a reseller key: it acts for your brand and for each of the client brands of your contract.
  • Your server. All these calls leave from your server, never from your visitor's browser: your key must never reach a page.

#The six endpoints

Every address also exists with the /v1 prefix, which is the recommended form.

Method and pathWhat it doesPermission
GET /partner/passports/{identifier}the passport of a unit, its proof and its signaturepassport:read
GET /partner/passports/01/{gtin}the passport of a model and its proofpassport:read
GET /partner/passports/01/{gtin}/10/{lot}the passport of a batch and its proofpassport:read
POST /partner/scansrecords a scan made by one of your visitorsscans:write
POST /partner/nfc/verifyverifies a chip read for one of your visitorsnfc:verify
GET /partner/nfc/receipts/{receipt}reads the result of a read our page verifiednfc:verify

Each endpoint answers only for the brands your key may serve. A product of another brand gets the same 404 as an identifier that does not exist.

#The passport, its proof and its signature in one call

{identifier} is the serial number printed on the product, its chip fingerprint or its token number.

curl
curl https://api.sealtrust.io/v1/partner/passports/K4QNAFCHDETK \
  -H "Authorization: Bearer st_live_0000000000000000000000000000000000000000000000"
JSON
{
  "level": "unit",
  "serial": "K4QNAFCHDETK",
  "passport": { "data": {}, "data_hash": "…", "gs1_digital_link": "…" },
  "proof": { "data_hash": "…", "anchor": {}, "vc": {} },
  "credential": { "format": "dc+sd-jwt", "issuer": "did:web:…", "sd_jwt_vc": "…" }
}
  • passport is exactly the public answer of GET /passport/{identifier} at the public access tier.
  • proof is exactly the answer of GET /passport/{identifier}/proof.
  • credential is exactly the answer of GET /passport/{identifier}/vc, or null as long as the brand has not issued a signed credential for this passport.

Your page therefore shows what the hosted page shows an anonymous visitor, no more and no less. For a model or a batch, credential is always null, and level is model or lot.

#Counting each scan for the visitor who made it

Your calls leave from your server, so from your address. With nothing else to go on, all your visitors would have your address: two people scanning the same product within the same half hour would count as a single scan, and every scan would be located at your hosting provider.

POST /partner/scans and POST /partner/nfc/verify therefore ask for four headers that name the visitor.

HeaderWhat it contains
X-SealTrust-Visitor-IPyour visitor's address, as your server saw it
X-SealTrust-Visitor-UAtheir browser's user agent, optional
X-SealTrust-Visitor-Timestampthe signing time, in seconds since 1970
X-SealTrust-Visitor-Signaturev1= followed by the signature, in hexadecimal

The signature is an HMAC-SHA256. Its key is the SHA-256 fingerprint of your API key, written in lowercase hexadecimal. The signed message is made of six lines separated by a line feed: v1, the time, the method in capitals, the path called without its part after ?, the visitor's address, and their user agent (an empty line when there is none).

Python
import hashlib, hmac, time, requests

API_KEY = "st_live_0000000000000000000000000000000000000000000000"
path = "/v1/partner/scans"
ip, ua = "203.0.113.77", "Mozilla/5.0"
ts = str(int(time.time()))
key = hashlib.sha256(API_KEY.encode()).hexdigest().encode()
message = "\n".join(["v1", ts, "POST", path, ip, ua]).encode()
signature = hmac.new(key, message, hashlib.sha256).hexdigest()

requests.post(
    "https://api.sealtrust.io" + path,
    json={"serial": "K4QNAFCHDETK"},
    headers={
        "Authorization": f"Bearer {API_KEY}",
        "X-SealTrust-Visitor-IP": ip,
        "X-SealTrust-Visitor-UA": ua,
        "X-SealTrust-Visitor-Timestamp": ts,
        "X-SealTrust-Visitor-Signature": f"v1={signature}",
    },
    timeout=10,
)
JavaScript
import { createHash, createHmac } from "node:crypto";

const apiKey = "st_live_0000000000000000000000000000000000000000000000";
const path = "/v1/partner/scans";
const ip = "203.0.113.77";
const ua = "Mozilla/5.0";
const ts = String(Math.floor(Date.now() / 1000));
const key = createHash("sha256").update(apiKey).digest("hex");
const signature = createHmac("sha256", key)
  .update(["v1", ts, "POST", path, ip, ua].join("\n"))
  .digest("hex");

await fetch("https://api.sealtrust.io" + path, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${apiKey}`,
    "X-SealTrust-Visitor-IP": ip,
    "X-SealTrust-Visitor-UA": ua,
    "X-SealTrust-Visitor-Timestamp": ts,
    "X-SealTrust-Visitor-Signature": `v1=${signature}`,
  },
  body: JSON.stringify({ serial: "K4QNAFCHDETK" }),
});

A signature is good for one method, one path and five minutes. Sign each call at the moment you send it.

The body of POST /partner/scans names the product by serial or by uid_hash, never both. It also accepts latitude and longitude when the visitor's device provided them. From the address we only keep a city and a country, and we store an anonymised address.

The same visitor coming back to the same product within the half hour counts as a single scan. The answer is then {"ok": true, "deduped": true}.

#A single NFC verification per read

An NFC chip produces, at each read, a single-use address. Verifying it twice makes the second verification answer as a copy of the address, which shows as a possible clone of a genuine product. Two cases arise.

The read opens our page. That is the case of the chips already in circulation: their address leads to SealTrust. When the brand shows its passports at your place, our page verifies the read once, then sends the visitor to your page with level=nfc, serial and receipt. Your server reads the result with that receipt:

curl
curl https://api.sealtrust.io/v1/partner/nfc/receipts/Zr3Kx0vQ8a1bW9c2dE7fG4hJ6kL5mN0p \
  -H "Authorization: Bearer st_live_0000000000000000000000000000000000000000000000"

Never send that read to POST /partner/nfc/verify: it has already been verified. A receipt can be read for ten minutes, as many times as needed.

Your own reader read the chip. Send e, c and, for a sealed chip, t, as the chip wrote them, with the four visitor headers, to POST /partner/nfc/verify. If your call fails on the way, send the same read again with the same key: for ten minutes, the answer gives back the first result with repeat: true, instead of taking it for a copy. If your first call is still running when the retry arrives, the retry waits a few seconds for its result, or answers 503 NFC_READ_IN_PROGRESS with a Retry-After header: send it again after that delay, and it gives back the first result.

Both answers carry the same fields: verified, serial, uid_hash, ctr, brand_code, brand_id, product_name, token_id, declared_stolen, tamper_status, seal_intact, tamper_policy, scan_area and verified_at. brand_code is the brand's public code. brand_id, its internal number, is deprecated: read brand_code. For the passport itself, call GET /partner/passports/{serial} next.

#The printed QR leads to your page

In the console, Settings then Developers, the "Passport shown in a partner's page" block declares your web addresses and the page that shows a passport. A box then sends printed QR scans to that page. A reseller sets it once on its own brand, and the setting holds for all the client brands of its contract, except those that have their own.

The printed links /p/{serial}, /01/{gtin}/21/{serial}, /01/{gtin} and /01/{gtin}/10/{lot} then answer 302 to your page, with these parameters:

ParameterValue
levelunit, model or lot
serialthe serial number, for a unit
gtin and lotfor a model or a batch
langfr or en, the reader's language

Two requests keep our passport: a machine asking for the passport itself (?linkType=dpp), and a withdrawn unit. The scan of a unit is recorded at the moment of the redirect, for the visitor. If your page also records it with POST /partner/scans for the same visitor, it counts only once. Robots that follow a link to show its preview (WhatsApp, Slack, LinkedIn, search engines) are redirected, but do not count as a scan.

A client brand whose plan does not include API access still sees, in this block, that its scans go to its reseller's page. It can keep them on the hosted page: the "Keep QR scans on the hosted page" button does not need API access.

#The claim in a hosted window

Claiming a product needs a SealTrust account. Never ask for your visitor's password: open our claim page, in a new window or by sending the visitor there.

Texte
https://sealtrust.io/en/claim?serial=K4QNAFCHDETK&return=https%3A%2F%2Fyour-site.example%2Fpassport

A visitor who has no account yet creates one from this page. The serial number and return follow them to the confirmation email, then to sign-in: they come back to the claim screen of this product without typing anything again. The "Sign in" and "Sign up" links at the top of the page carry them too as soon as it shows, before it has finished loading: a visitor on a slow mobile network who clicks at once loses nothing.

return is the page to come back to. It must sit on one of the web addresses declared in the console for this brand, or on the verified domain that serves its public pages, otherwise no back button appears. When you have just read the chip, add the read after a #, for instance #e=…&c=…, so the visitor does not have to read it again: the read does not leave their browser.

Our page never claims on its own. It takes the read passed after the # only when return is accepted for this product, then shows a "Claim this product" button: the claim starts on the visitor's click, not before. Without an accepted return, the read is ignored.

This read serves for five minutes after it was verified. After that, or if the visitor takes longer to sign in, they read the chip again on our page. It is the same rule as on the hosted page: a chip read is not replayed beyond a few minutes.

Once the claim is done, the "Back to your-site.example" button brings the visitor back:

  • if you opened the window with window.open, your page receives a message { "type": "passport-claim", "outcome": "claimed", "serial": "K4QNAFCHDETK" } addressed to the exact origin of the return page, and the window closes. So open the window from a page of that same origin;
  • otherwise, the visitor comes back to return, with claim_outcome=claimed added to the address.

outcome takes the values claimed, submitted, already_owned, expired, not_activated, window_closed, code_not_issued, unavailable or failed.

#The transfer in a hosted window

The owner transfers their product the same way, on our transfer page, with the same parameters:

Texte
https://sealtrust.io/en/transfer?serial=K4QNAFCHDETK&return=https%3A%2F%2Fyour-site.example%2Fpassport

The product named by serial is preselected when it belongs to the signed-in visitor. A visitor who is not signed in goes through the sign-in page first, then comes back to this same address, serial and return included. return follows the same rule as for the claim. Two buttons bring the visitor back: "Back to your-site.example" once the transfer is done, and "Back to your-site.example without transferring" before.

  • if you opened the window with window.open, your page receives a message { "type": "passport-transfer", "outcome": "transferred", "serial": "K4QNAFCHDETK" } addressed to the exact origin of the return page, and the window closes;
  • otherwise, the visitor comes back to return, with transfer_outcome=transferred added to the address.

outcome is transferred (transfer done), pending_acceptance (the recipient still has to accept) or cancelled (the visitor came back without transferring). Return and buyback are done from the owner's space on the hosted page.

#Limits

The rate limit is counted per key and per brand, never per address: all the visitors your server represents do not share a single counter. It is described in the overview. These six endpoints do not consume the daily quota of the key.

#Errors

CodeConditionWhat to do
400The visitor headers are missing.Send the four headers described above.
401Key missing or unknown, wrong signature, or time more than five minutes away from ours.Sign each call at the moment you send it, with the exact path called.
403The permission is missing, or the plan does not include API access.Create a key that carries this permission.
403SDM_MAC_MISMATCH: the chip's signature does not verify.The read is not that of a genuine chip.
404Unknown identifier, unpublished passport, expired receipt, or product of a brand your key does not serve. For a chip, the brand is checked before any replay check: a chip of another brand always answers 404, never 409, and its read is not consumed.Check the identifier and the key.
409This chip read was already verified elsewhere.If our page verified it, read its receipt.
422Invalid body, or a visitor address that is not public: a local address, or an address of a private network (10.x, 172.16 to 172.31, 192.168.x, 100.64 to 100.127, IPv6 starting with fc or fd).Send the visitor's public address. Behind a load balancer, read it from the header the load balancer adds, not from the connection address.
429Rate limit reached.Wait for the delay given by Retry-After.
503NFC_READ_IN_PROGRESS: your previous call is still verifying the same read.Send the same read again after the delay given by Retry-After.

#See also

Your answer opens a pre-filled email in your mail app, addressed to contact@sealtrust.io. You read it over before sending it.

Suggest a correctionReport a problem