Developers
Certificates, passports and on-chain proofs are served by plain REST endpoints, most of them public, because a proof you cannot check yourself is not a proof. Scoped keys, signed webhooks and a typed SDK cover the rest.
https://api.sealtrust.io
The verification surface requires no account and no API key. Fetch a certificate, a passport, or the Merkle proof of a product and check our anchoring yourself on Base L2 (basescan.org). The proof is public and independently verifiable.
# Public endpoints — no API key required
curl https://api.sealtrust.io/certificate/{identifier}
# The passport as JSON-LD (Schema.org / GS1 vocabulary)
curl "https://api.sealtrust.io/passport/{identifier}?format=jsonld"
# The public Merkle proof — verify our anchoring yourself on Base L2
curl https://api.sealtrust.io/verify/merkle/{identifier}Everything below is live and unauthenticated (rate limits apply). An identifier can be the printed serial number, a uid_hash (0x + 64 hex) or a token_id; /certificate, /resolve and /verify/merkle also accept a certificate number.
| Endpoint | What it does |
|---|---|
| GET/sdm/verify-url | Verify an NTAG 424 DNA scan (SDM): decrypts and validates the tag's single-use code. |
| GET/certificate/{identifier} | Public certificate of authenticity (by printed serial, uid_hash, token_id or certificate number). |
| GET/certificate/{identifier}/download | The same certificate as a PDF. |
| GET/passport/{identifier} | Digital Product Passport, filtered by the access profile requested. Add ?format=jsonld for JSON-LD (Schema.org/GS1). |
| GET/passport/{identifier}/vc | The passport as a tier-filtered SD-JWT-VC verifiable credential. |
| GET/passport/{identifier}/vc/verify | Verify the stored SD-JWT-VC against the brand's signing key. |
| GET/brand/{brand_id}/did.json | Brand DID Document (did:web): public signing keys as JsonWebKey2020. |
| GET/p/{serial} | The unique product identifier (EN 18219). This is what the QR code printed on a product carries, and the only identifier a human can read off an object. Redirects to the product page; add ?linkType=dpp for the passport itself. |
| GET/01/{gtin}/21/{serial} | GS1 Digital Link resolver: the same unit as /p/{serial}, under the GS1 carrier form. |
| GET/resolve/{identifier} | Universal resolver: product + certificate + passport + lifecycle events + media in one response. |
| GET/passport/{identifier}/proof | Proof bundle for the passport: SHA-256 data hash, IPFS copy, Base L2 anchor and SD-JWT-VC status. |
| GET/timeline/{identifier} | Combined history: verifications and ownership transfers. Rate-limited to 30 requests per 60 seconds per IP. |
| GET/verify/merkle/{identifier} | Public Merkle anchor proof for an anchored product: recompute it yourself against Base L2. |
| GET/qr/product/{identifier} | QR code (PNG) pointing to the product's verification page. |
Write operations use API keys (prefix st_live_) sent as Authorization: Bearer. The full secret is shown once at creation; only its SHA-256 hash is stored. Each key is brand-scoped, quota-limited per day, rate-limited, and carries explicit scopes:
mint:batchBatch mint via /partner/mint/batchsellout:writeDeclare a sell-out via /partner/selloutwebhooks:readList your webhook subscriptionswebhooks:writeCreate, update and delete webhook subscriptionsmint:singleGrantable, but no endpoint requires it yetproducts:readGrantable, but no endpoint requires it yetproducts:statusGrantable, but no endpoint requires it yettransfers:createGrantable, but no endpoint requires it yetPOST /partner/mint/batch accepts JSON (a list of items) or a CSV upload, enforces brand isolation and quotas, supports an Idempotency-Key header for safe retries, and returns a job you can poll.
curl -X POST https://api.sealtrust.io/partner/mint/batch \
-H "Authorization: Bearer st_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-2027-0042" \
-d '[
{
"product_name": "Sneaker #001",
"brand_id": 1,
"category_id": 3,
"metadata_uri": "https://metadata.sealtrust.io/001.json"
}
]'
# → { "job_id": "abc123...", "status": "queued", "items_count": 1, "brand_id": 1 }
# Poll: GET /partner/mint/batch/status/{job_id} (max 500 items per batch)Subscribe a URL per brand and receive events as JSON POSTs. The subscription also accepts six event names we do not send today; subscribing to one of them returns no error and produces no delivery. Every delivery is signed with your webhook secret: X-Webhook-Signature carries t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of "{timestamp}.{raw body}" over the exact bytes we sent. Verify against the raw body, never a re-serialized copy of it, and check the timestamp against a tolerance you choose. Each delivery also carries X-Webhook-Timestamp and X-Webhook-Id, the SHA-256 of the body, identical on every retry of an event so you can deduplicate:
import hashlib
import hmac
import time
def verify_webhook(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
"""Verify X-Webhook-Signature: t=<unix_seconds>,v1=<hmac_sha256_hex>."""
parts = {}
for item in header.split(","):
key, _, value = item.strip().partition("=")
if value:
parts[key] = value
ts, sig = parts.get("t"), parts.get("v1")
if not ts or not sig or not ts.isdigit():
return False
if abs(time.time() - int(ts)) > tolerance: # tolerance is yours to choose
return False
signed = ts.encode() + b"." + raw_body # the signed message, not the body alone
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)
# FastAPI example
# raw = await request.body() # the exact bytes, never a re-serialised dict
# sig = request.headers["X-Webhook-Signature"]
# assert verify_webhook(raw, sig, WEBHOOK_SECRET)product.mintedA product is registered from the console, one at a time. A batch mint over the Partner API does not send it: poll the job status instead.product.transferredAn ownership transfer carried out by us lands on the chain. A transfer the holder signs from their own wallet does not send it.transfer.acceptedThe recipient of an escrowed transfer accepts it.product.scannedA scan lands on one of your products.product.gray_marketA scan happens outside the area you authorised.clone.alertWe detect a duplicate on a chip identifier.return.requestedA return is requested.return.receivedThe returned product has reached you.return.completedThe return is settled.return.rejectedThe return is refused.return.expiredThe return request expired with no follow-up.warranty.claimedA warranty is claimed.buyback.offeredYou offer to buy a product back.buyback.acceptedThe holder accepts the offer.buyback.declinedThe holder refuses the offer.buyback.completedThe buyback is settled.buyback.expiredThe offer expired with no answer.@sealtrust-io/sdk is a type-safe client with native fetch and zero dependencies (Node.js ≥ 18). POST and PUT requests automatically carry an Idempotency-Key header; you can provide your own to retry safely.
import { SealTrustClient } from "@sealtrust-io/sdk";
const sealtrust = new SealTrustClient({
apiKey: "st_live_...",
baseUrl: "https://api.sealtrust.io", // optional, this is the default
});
// Read a product's history. The printed serial is the identifier you actually
// have: it is what the QR code on the product carries, and the only one a
// human can read off an object. A token id or a 0x uid hash work too.
const history = await sealtrust.verify.timeline("2MH5NK5F37AE");
console.log(history.product_name);
console.log(history.timeline.length); // verifications + ownership transfers
// Mint a batch, then poll the job
const job = await sealtrust.products.mint([
{ product_name: "Sneaker #001", brand_id: 1, category_id: 3, metadata_uri: "https://metadata.sealtrust.io/001.json" },
]);
const status = await sealtrust.products.getBatchStatus(job.job_id);
console.log(status.status); // "queued" | "started" | "finished" | "failed"
// Subscribe to webhooks
await sealtrust.webhooks.create({
url: "https://example.com/webhooks/sealtrust",
events: ["product.minted", "product.transferred"],
secret: "whsec_...",
});Each product identity is addressable through the GS1 Digital Link syntax the ESPR ecosystem converges on: /01/{gtin}/21/{serial} resolves a GTIN + serial to the item's product page, and to its Digital Product Passport under ?linkType=dpp, so the same carrier works for retailers, customs and recyclers without bespoke integrations.
# One GS1 Digital Link per item: GTIN + serial resolves to the product page
curl https://api.sealtrust.io/01/{gtin}/21/{serial}
# Add ?linkType=dpp to land on the passport itself
curl "https://api.sealtrust.io/01/{gtin}/21/{serial}?linkType=dpp"
# The same identity also resolves certificates and events
curl https://api.sealtrust.io/resolve/{identifier}@sealtrust-io/mcp-server exposes the public verification surface as a Model Context Protocol (MCP) server: seven read-only tools that any MCP client (Claude Desktop, Claude Code and others) can call to verify a product, read its Digital Product Passport and check the proofs behind it. It runs locally over stdio, needs no account and no API key, and only ever reaches public, unauthenticated read-only endpoints.
{
"mcpServers": {
"sealtrust": {
"command": "npx",
"args": ["-y", "@sealtrust-io/mcp-server"]
}
}
}# Claude Code
claude mcp add sealtrust -- npx -y @sealtrust-io/mcp-server
# Optional: point it at another environment (default: https://api.sealtrust.io)
claude mcp add sealtrust --env SEALTRUST_API_URL=https://api.sealtrust.io -- npx -y @sealtrust-io/mcp-serververify_productAuthenticity status of a product (authentic, revoked, expired, found without an active certificate, or unknown) plus public product info.get_passportThe published Digital Product Passport, public tier only (JSON or JSON-LD).get_passport_proofProof bundle: SHA-256 data hash, IPFS copy, Base L2 anchor, SD-JWT-VC status, physical verification count.get_certificateThe public certificate of authenticity (status, dates, issuer).resolve_gs1Resolve a GS1 Digital Link to a passport: the item's with a serial, the model's reference passport without one.verify_credentialVerify the passport's SD-JWT-VC against the brand's did:web signing key.get_product_historyThe item's timeline: verifications and ownership transfers, oldest first.Read-only by design: the server can only GET public data. Nothing it does can mint, transfer or modify a product.
A question about your integration? Talk it through with our team.
Talk to us