API docs
Human-readable reference. Machine-readable contract: /openapi.json. Agent summary: /llms.txt.
Quickstart
A purchase is one HTTP negotiation: probe, get a 402, sign a
Hedera transaction client-side, retry, get the file. Signing can't be done in curl — that
step needs a Hedera key, so the one-command version is
node scripts/buy.mjs --query "..." --max-price 300 (--help lists
the flags). What follows is the real trace, endpoint and headers included.
No funded account yet? Add --dry-run to stop at the
402 and print the payment requirements — the whole negotiation, no keys and no
money. That is the fastest way to see the contract before you fund anything.
# 1. Probe: call the paid endpoint with no payment attached
curl -i "http://localhost:3000/api/v1/models/5/download?license=personal"
# HTTP/1.1 402 Payment Required
# x-printwright-environment: demo; network=testnet; funds=test-only
# payment-required: eyJ4NDAyVmVyc2lvbiI6Mi... (base64 of the JSON body below)
# {"x402Version":2,"error":"payment required","resource":{"url":"...","description":"..."},
# "accepts":[
# {"scheme":"exact","network":"hedera:testnet","payTo":"0.0.9584959","amount":"900000",
# "asset":"0.0.429274","extra":{"feePayer":"0.0.7162784"},"maxTimeoutSeconds":180},
# {"scheme":"exact","network":"hedera:testnet","payTo":"0.0.9584959","amount":"1358695652",
# "asset":"0.0.0","extra":{"feePayer":"0.0.7162784"},"maxTimeoutSeconds":180}],
# "demo":{"network":"testnet","funds":"test-only","message":"Demo deployment. ..."}}
# 2. Pick one `accepts` entry (asset 0.0.0 = HBAR tinybars, 0.0.429274 = testnet USDC).
# Sign a Hedera TransferTransaction with transactionId.accountId = extra.feePayer
# (buyer signature only — the fee payer sponsors network fees and submits).
# This step is not curl-able; scripts/buy.mjs does it for you.
# 3. Retry the identical URL with the base64 PaymentPayload in PAYMENT-SIGNATURE:
curl -i "http://localhost:3000/api/v1/models/5/download?license=personal" \
-H "PAYMENT-SIGNATURE: <base64 PaymentPayload>"
# HTTP/1.1 200 OK
# payment-response: eyJ0cmFuc2FjdGlvbiI6Ii4uLiJ9 (base64 settlement receipt)
# { "files": [...], "license": {"cert_id": "pw-000036", "kind": "personal"},
# "receipt": {"url":".../receipts/pw-000036","token":"private signed capability"},
# "certificate": {...}, "transaction_id": "...",
# "transaction_url": "https://hashscan.io/testnet/transaction/..." }
Demo environment signal
This deployment is a Hedera testnet demo: settlements are real Hedera transactions and certificates are genuinely anchored and publicly verifiable, but the value moved has no monetary worth and a license here is not a commercial grant of rights. Humans see a banner. Agents never render HTML, so the same fact is machine-readable in two places:
- An
X-Printwright-Environmentresponse header on every/api/v1response, including errors:demo; network=testnet; funds=test-only. - A
demoobject in the402PaymentRequirements body —network,fundsand a plain-languagemessage— alongsideaccepts. Unknown fields are ignored by x402 clients, so an agent that can already pay is unaffected.
Both disappear together on a mainnet deployment; neither can be left behind.
An agent deciding whether it is authorised to spend should read the header before it reads
accepts.
Durable receipts and optional library
Every real paid delivery includes a private receipt
capability. Its URL plus token opens a no-store receipt and can reissue a model download after
the original 30-day grant expires. Sandbox responses never receive this capability. Treat the
token like the downloadable license it protects; do not log or send it to an AI provider.
A buyer may optionally save the license under an email from that receipt. This creates no buyer account or password. A 30-minute magic link establishes an encrypted, HttpOnly 30-day browser library and immediately redirects to a clean token-free URL. Returning sign-in gives the same response for known and unknown emails. The library lists saved paid receipts and can reissue downloads; the portable PWC-1 certificate remains independent of the email and library.
External designer profiles
Designers can import up to 50 self-owned models from a portable public HTTPS manifest under Designer → Imports. The external-profile v1 JSON Schema fixes the source-profile, per-model provenance, source-license, file URL/hash, and offer fields; the live example includes one eligible original and one deliberately blocked Creative Commons item.
Review fetches metadata only. Files are downloaded only after the signed-in designer checks a separate ownership warranty for each selected proprietary original. Every download is public HTTPS with private/reserved DNS rejection, redirect revalidation, bounded bytes, and a required SHA-256 match. Creative Commons, CC0/public-domain, missing, and unknown source licenses remain blocked with a reason; Printwright never silently replaces their terms. Imports create drafts, preserve source links/license/warranty time, and still require normal mesh checks and the publish warranty.
Batch purchase
Print farms can acquire up to 200 line items and 250 licenses with one x402
negotiation and one settlement, using quantity for repeat units of
one offer. Probe and retry the identical POST /api/v1/batches
body. Every license receives its own serial, file grant, PWC-1 certificate, and verify URL. Items
settle to Printwright's treasury through one payTo, including
mixed-designer batches. Designer shares are recorded per item and paid after delivery;
designer payout never changes the buyer's certificate or delivery result.
curl -i -X POST http://localhost:3000/api/v1/batches \
-H "Content-Type: application/json" \
-d '{"items":[
{"model_id":5,"license":"commercial_unit","quantity":3}
]}'
# 402: one PaymentRequired whose amount is the exact sum
# Retry this POST and identical JSON with PAYMENT-SIGNATURE: <base64 PaymentPayload>
# 200: {"transaction_id":"...","licenses":[{"cert_id":"pw-...","files":[...],"receipt":{...}}, ...]}
Getting the file back
The purchase is the file, so a paid response always carries
files; it is never 200 with an empty list.
Those URLs ride a download grant that expires, so keep the
receipt capability returned alongside them — its token does not
expire and needs no account.
GET {receipt.files_url}?token=… re-lists the files with fresh
download URLs at any time, {receipt.download_url}?token=…&f=N
fetches one part, and {receipt.url}?token=… opens the human receipt
page. Payments are final, so store that token with the certificate id at settlement.
Signed lifecycle webhooks
Webhooks are outbound notifications for licenses already paid through x402;
they cannot create a purchase, license, certificate, or ledger entry. A designer can add a
public HTTPS endpoint under Designer → Webhooks and receives
sale.completed after paid delivery. A batch buyer can include the
optional callback below in the body used for both the 402 probe and signed retry; it receives
certificate.anchored only after each PWC-1 commitment lands on HCS.
Sandbox purchases emit neither event.
{
"items": [{"model_id": 5, "license": "commercial_unit"}],
"webhook": {
"url": "https://farm.example/printwright-events",
"secret": "at-least-32-bytes-known-only-to-the-buyer"
}
}
Delivery uses stable event IDs, JSON request bodies, bounded retries, and
these headers: Webhook-Id,
Webhook-Timestamp, and Webhook-Signature.
Verify the signature against the raw request bytes before parsing them:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyPrintwrightWebhook(rawBody, headers, secret) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const received = Buffer.from(headers["webhook-signature"].replace(/^v1=/, ""), "hex");
const signed = `${id}.${timestamp}.${rawBody}`;
const expected = Buffer.from(createHmac("sha256", secret).update(signed).digest("hex"), "hex");
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
return fresh && received.length === expected.length && timingSafeEqual(received, expected);
}
// Return any 2xx only after recording event.id idempotently. Duplicate IDs are normal on retry.
JavaScript client
@printwright/client wraps catalog reads, the
x402 Hedera signature, optional first-use USDC association, and proof-bundle/HCS comparison.
From this repository install it with npm install ./client; the
registry command after release is npm install @printwright/client.
This five-line read-only quickstart needs no funded account:
import { PrintwrightClient } from "@printwright/client";
const printwright = new PrintwrightClient({ baseUrl: "http://localhost:3000" });
const { models } = await printwright.search({ query: "cable clip", maxPriceCents: 300 });
const model = await printwright.get(models[0].id);
console.log(model.title, model.license_offers);
Catalog results also expose stable category and
collections keys. Filter with, for example,
category=workshop-tools or
collection=under-an-hour; the allowed values are enumerated in the
OpenAPI table below.
Add accountId and
privateKey to the constructor to call
buy(). The key remains local. Use quote()
for an unsigned approval/dry-run view and verify(certId) to compare
the marketplace certificate with its public mirror-node message.
Try the whole flow free
Sandbox mode performs the same 402 → mock verify → mock settle → certificate sequence with no Hedera account, key, association, or funds. It returns only a text receipt, never paid geometry. Every identifier, response, verify page, and topic message says SANDBOX; none is a payment or license:
import { PrintwrightClient } from "@printwright/client";
const sandbox = new PrintwrightClient({ baseUrl: "http://localhost:3000", sandbox: true });
const { models } = await sandbox.search({ query: "cable clip" });
const receipt = await sandbox.buy({ modelId: models[0].id, license: "personal" });
console.log(receipt.warning, await sandbox.verify(receipt.license.cert_id));
The CLI equivalent is
node scripts/buy.mjs --query "cable clip" --sandbox. Raw HTTP
clients send X-Sandbox: true on both the initial download request
and its PAYMENT-SIGNATURE retry.
To validate the raw client contract—including exact v2 fields, body/header
equality, signed retry, local mirror equality, and the absence of real-payment claims—run
node conformance/suite.mjs --url http://localhost:3000. The public
suite needs no wallet or credentials.
Endpoint reference
Generated from /openapi.json at request time — the tables below are that file, not a hand-kept copy.
POST /api/v1/batches
Buy many licenses with one x402 settlement
Send the same JSON body for both legs. The first returns one aggregate x402 challenge to Printwright's treasury; the signed retry settles once and returns one independently verifiable certificate per item, including mixed-designer batches. Designer payout happens separately after delivery. An optional buyer webhook reports certificate.anchored only after the paid commitment lands on HCS; it never creates a license.
| Param | In | Type | Notes |
|---|---|---|---|
PAYMENT-SIGNATURE |
header | string | base64 x402 PaymentPayload from the aggregate challenge |
X-Sandbox |
header | string | zero-funds rehearsal; no printable files or Hedera records |
| Status | Description |
|---|---|
| 200 |
One settlement and one delivery/certificate link per requested license
headers: PAYMENT-RESPONSE, X-Printwright-Sandbox |
| 400 | invalid_batch or invalid_payload |
| 402 |
Aggregate PaymentRequired challenge. After a signed retry, error=offer_changed returns fresh current-revision requirements when an offer changed before reservation.
headers: PAYMENT-REQUIRED, WWW-Authenticate |
| 404 | Not found |
| 409 | Replayed payment (delivered batches return the original BatchDelivery), or error: sales_paused before a new reservation |
| 410 | At least one requested offer is sold out, or error: listing_retired before a new reservation |
| 422 | incompatible_payees or invalid_webhook |
| 429 |
error: "rate_limited" (+ retry_after: 60) — request rate exceeded the endpoint's per-minute limit
headers: Retry-After |
| 503 | facilitator_unavailable, or no_deliverable_file (+ recoverable: true, batch_id, transaction_id, model_ids) when the batch settled but a model in it has no downloadable file. Either way, retry the identical signed batch; nothing is paid again. |
GET /api/v1/models
Search the catalog (keyword + trigram fuzzy matching)
| Param | In | Type | Notes |
|---|---|---|---|
q |
query | string | keywords, e.g. 'beaver hat' |
category |
query | string | |
collection |
query | string | |
material |
query | string | e.g. PLA |
supports |
query | string | |
max_price_cents |
query | integer |
| Status | Description |
|---|---|
| 200 | Ranked results |
| 429 |
error: "rate_limited" (+ retry_after: 60) — request rate exceeded the endpoint's per-minute limit
headers: Retry-After |
GET /api/v1/models/{id}
Full model metadata (offers, terms, file hash)
| Param | In | Type | Notes |
|---|---|---|---|
id |
path | integer |
| Status | Description |
|---|---|
| 200 | Model detail |
| 404 | Not found |
| 429 |
error: "rate_limited" (+ retry_after: 60) — request rate exceeded the endpoint's per-minute limit
headers: Retry-After |
GET /api/v1/models/{id}/download
The paid endpoint: x402 negotiation
Without a payment header, responds 402 with PaymentRequirements (JSON body AND base64 PAYMENT-REQUIRED header). Retry the same request with the base64 PaymentPayload in the PAYMENT-SIGNATURE header (X-PAYMENT also accepted). The server verifies and settles through an x402 facilitator on Hedera. Set X-Sandbox: true on both legs for the built-in zero-funds mock facilitator; sandbox results never contain printable geometry or on-chain proof.
| Param | In | Type | Notes |
|---|---|---|---|
id |
path | integer | |
license |
query | string | |
PAYMENT-SIGNATURE |
header | string | base64 x402 PaymentPayload (v2). X-PAYMENT is accepted as an alias. |
X-Sandbox |
header | string | Opt into the local mock facilitator and throwaway topic. Send on both the 402 probe and retry. No credentials or funds; no real license or printable model. |
| Status | Description |
|---|---|
| 402 |
Payment required — PaymentRequired object in body and PAYMENT-REQUIRED header. `error` is "payment required" when no payment is attached, "invalid_payment_requirements" when the attached payment matches none of the offer's `accepts` entries, or "offer_changed" when a revision won the reservation race (all return fresh requirements to retry against).
headers: PAYMENT-REQUIRED, WWW-Authenticate, X-Printwright-Sandbox |
| 200 |
Paid — delivery payload; settlement receipt in PAYMENT-RESPONSE header (X-PAYMENT-RESPONSE mirror)
headers: PAYMENT-RESPONSE, X-Printwright-Sandbox |
| 400 | error: "invalid_payload" — the PAYMENT-SIGNATURE header is not decodable base64 JSON |
| 404 | Not found |
| 409 | Replay of an already-presented payment. If the purchase already reached "delivered", the original Delivery payload is returned again unchanged (an idempotent replay, not an error); for any other in-flight status the body is {error: "duplicate_payment", status} naming the purchase's current status. A new purchase on a temporarily paused listing returns {error: "sales_paused", listing_status: "paused"} before reservation. |
| 410 | error: "sold_out" — the offer's max_units is exhausted; or error: "listing_retired" when the designer has retired the listing before a new reservation. Capacity is decided under the offer lock before a payment is accepted, so a sold-out offer refuses instead of charging. A sold_out response carrying transaction_id is a defensive backstop that flags the payment for operator review. |
| 429 |
error: "rate_limited" (+ retry_after: 60) — request rate exceeded the endpoint's per-minute limit
headers: Retry-After |
| 503 | error: "facilitator_unavailable" (+ retry_after: 5) — facilitator down or its circuit breaker is open. The purchase is kept alive; retry the same signed payment. Or error: "no_deliverable_file" (+ recoverable: true, cert_id, transaction_id, receipt) — the payment settled and the license is issued, but the model has no downloadable file right now. Retry the same signed payment once it is back, or use the returned receipt capability later; nothing is paid again. |
GET /api/v1/certificates/{cert_id}
Proof bundle for one certificate + HCS anchoring links
| Param | In | Type | Notes |
|---|---|---|---|
cert_id |
path | string |
| Status | Description |
|---|---|
| 200 | The certificate's proof bundle (revealed certificate, blinding nonce, commitment, licensed terms, HCS coordinates) with status anchored|minting|sandbox |
| 404 | Not found |
| 429 |
error: "rate_limited" (+ retry_after: 60) — request rate exceeded the endpoint's per-minute limit
headers: Retry-After |
GET /api/v1/licenses/{id}/can
Decide whether one certificate permits a named use and quantity
Returns a machine-decidable interpretation only when its canonical v1 prose matches the certificate's anchored terms_hash. The prose remains the legal grant. Sandbox receipts always return allowed=false.
| Param | In | Type | Notes |
|---|---|---|---|
id |
path | string | certificate disclosure id returned to the paid holder |
use |
query | string | |
qty |
query | integer |
| Status | Description |
|---|---|
| 200 | Allowed or denied decision with stable reason code, structured permissions, certificate URL, and anchored terms references |
| 404 | Not found |
| 409 | permissions_unavailable — no structured policy matches the certificate's anchored terms hash |
| 422 | invalid_use or invalid_quantity |
| 429 |
error: "rate_limited" (+ retry_after: 60) — request rate exceeded the endpoint's per-minute limit
headers: Retry-After |
POST /api/v1/licenses/{cert_id}/print_reports
Report one successful print using a paid receipt capability
The signed receipt_token is returned only by a real non-sandbox x402 delivery and is bound to one certificate. A report is idempotent per license. This is a paid holder self-report, not independent physical inspection, and it never changes the certificate or payment ledger.
| Param | In | Type | Notes |
|---|---|---|---|
cert_id |
path | string |
| Status | Description |
|---|---|
| 200 | Existing report returned idempotently |
| 201 | Successful-print report recorded |
| 403 | paid_license_required — sandbox or non-delivered license |
| 404 | Not found |
| 429 |
error: "rate_limited" (+ retry_after: 60) — request rate exceeded the endpoint's per-minute limit
headers: Retry-After |
GET /api/v1/licenses/{cert_id}/latest-version
Check the latest printable file for a paid x402 license
Requires the private model_updates.receipt_token returned by the real non-sandbox delivery as an Authorization: Bearer header. Version 1 is the original certified bundle. Later versions are additive: the original PWC-1 model_hash never changes, while each changelog hash and replacement file hash are anchored in a PWV-1 HCS event. `files` lists every part of a multi-file bundle in order; `file_kind`/`file_hash` always describe the primary (first) part.
| Param | In | Type | Notes |
|---|---|---|---|
cert_id |
path | string |
| Status | Description |
|---|---|
| 200 | Latest version metadata and authenticated download endpoint |
| 401 | receipt_required |
| 403 | paid_license_required — sandbox or non-delivered license |
| 404 | Not found |
| 429 |
error: "rate_limited" (+ retry_after: 60) — request rate exceeded the endpoint's per-minute limit
headers: Retry-After |
GET /api/v1/licenses/{cert_id}/latest-version/file
Download the latest printable file for a paid x402 license
Send the same model_updates.receipt_token as an Authorization: Bearer header. Redirects to version 1's original file when no update exists, otherwise to the highest published version file. Pass `f` to download a specific part of a multi-file bundle instead of the primary file.
| Param | In | Type | Notes |
|---|---|---|---|
cert_id |
path | string | |
f |
query | integer | 0-based index into the version's file bundle; omitted serves the primary file |
| Status | Description |
|---|---|
| 302 | Redirect to the latest printable file blob |
| 401 | receipt_required |
| 403 | paid_license_required |
| 404 | Not found |
| 429 |
error: "rate_limited" (+ retry_after: 60) — request rate exceeded the endpoint's per-minute limit
headers: Retry-After |
GET /api/v1/stats
Configured HCS topic and immutable revenue-ledger metrics
Separates the configured HCS topic's latest public message sequence from the settlement totals retained in this deployment's immutable ledger. A reused historical topic can contain superseded certificate formats as well as current commitments and model-version events. Amounts are exact asset base units; sandbox rehearsals are excluded.
| Status | Description |
|---|---|
| 200 | Current open-books snapshot; hcs.status=unavailable labels mirror outages without presenting a local count as on-chain |
| 429 |
error: "rate_limited" (+ retry_after: 60) — request rate exceeded the endpoint's per-minute limit
headers: Retry-After |
GET /api/v1/files/{token}
Redeem a download grant token (expiring, limited uses)
| Param | In | Type | Notes |
|---|---|---|---|
token |
path | string |
| Status | Description |
|---|---|
| 302 | Redirect to the file blob |
| 410 | error: "grant_expired" — the grant is expired or its use-count is exhausted |
| 404 | Not found |
| 429 |
error: "rate_limited" (+ retry_after: 60) — request rate exceeded the endpoint's per-minute limit
headers: Retry-After |
GET /api/v1/sandbox/topics/{topic_id}/messages/{sequence_number}
Read a local sandbox certificate envelope (not HCS)
| Param | In | Type | Notes |
|---|---|---|---|
topic_id |
path | string | |
sequence_number |
path | integer |
| Status | Description |
|---|---|
| 200 | Base64 sandbox message in a mirror-like envelope, always labeled sandbox |
| 404 | Not found |
| 429 |
error: "rate_limited" (+ retry_after: 60) — request rate exceeded the endpoint's per-minute limit
headers: Retry-After |
GET /api/v1/sandbox/files/{cert_id}
Download the non-printable sandbox receipt
| Param | In | Type | Notes |
|---|---|---|---|
cert_id |
path | string |
| Status | Description |
|---|---|
| 200 | Plain-text sandbox receipt; never model geometry |
| 404 | Not found |
| 429 |
error: "rate_limited" (+ retry_after: 60) — request rate exceeded the endpoint's per-minute limit
headers: Retry-After |
GET /api/v1/sandbox/transactions/{transaction_id}
Inspect a fake sandbox settlement
| Param | In | Type | Notes |
|---|---|---|---|
transaction_id |
path | string |
| Status | Description |
|---|---|
| 200 | Local settlement record labeled sandbox; not a Hedera transaction |
| 404 | Not found |
| 429 |
error: "rate_limited" (+ retry_after: 60) — request rate exceeded the endpoint's per-minute limit
headers: Retry-After |
Error contract
| Status | error | Where |
|---|---|---|
| 400 | invalid_payload | download — PAYMENT-SIGNATURE not decodable |
| 402 | payment required | download — no payment attached |
| 402 | invalid_payment_requirements | download — attached payment matches no accepts entry |
| 402 | offer_changed | download/batch — the offer was revised between quote and reservation; sign one of the fresh requirements returned with this response |
| 404 | not_found | any resource missing |
| 409 | duplicate_payment (+ status) | download — replayed payment (a replay of an already-delivered purchase instead returns the original delivery payload, not this error) |
| 409 | sales_paused (+ listing_status) | download/batch — the designer temporarily stopped new sales before reservation; do not submit a new payment, but retry an already-presented payment unchanged for recovery |
| 410 | sold_out | download — offer max_units exhausted (may include transaction_id when it sold out after payment) |
| 410 | listing_retired (+ listing_status) | download/batch — retired for new sales; historical receipts, certificates, entitled downloads, updates, and already-presented payment recovery remain available |
| 410 | grant_expired | files — download grant no longer usable |
| 422 | invalid_webhook | batch — callback is not public HTTPS or its secret is outside 32–256 bytes |
| 429 | rate_limited (+ retry_after: 60) | all API endpoints; also sets Retry-After: 60 |
| 503 | facilitator_unavailable (+ retry_after: 5) | download — facilitator down / circuit breaker open |
Per-endpoint rate limits, all on a 1-minute window (so a 429's
Retry-After is always 60): /models and
/models/{id}, /certificates/{cert_id},
/licenses/{id}/can, and /stats
120/min, /models/{id}/download 120/min,
/files/{token} 240/min,
/licenses/{cert_id}/latest-version and
/batches 60/min, successful-print reports 20/min.
Machine-readable license decisions
An agent never needs to parse legal prose to answer a bounded usage question. Ask one certificate whether it permits a named use and quantity:
GET /api/v1/licenses/pw-000057/can?use=commercial_print&qty=3
# {"cert_id":"pw-000057","use":"commercial_print","qty":3,"allowed":false,
# "reason_code":"commercial_unit_limit","reason":"... acquire one per unit sold", ...}
Supported uses are personal_print,
commercial_print, resell_files,
share_files, personal_remix,
commercial_remix, transfer_license, and
sublicense. A commercial per-unit certificate permits exactly one
commercial print; three units require three certificates.
The response includes the complete permissions object plus links to the
certificate, canonical prose, anchored terms_hash, and JSON permalink.
The JSON is a convenience interpretation; the prose whose hash is anchored on HCS governs.
If the hashes do not match, the API returns permissions_unavailable
instead of guessing.
const decision = await printwright.can({
certId: "pw-000057", use: "commercial_print", qty: 3
});
console.log(decision.allowed, decision.reason_code);
Paid-holder print feedback
A successful non-sandbox delivery includes a private
print_feedback.receipt_token. After the geometry really prints,
the buyer can submit that signed capability once. It is bound to the certificate and cannot be
used for another license; invalid, cross-license, and sandbox tokens create no report.
const result = await printwright.reportPrint({
certId: receipt.license.cert_id,
receiptToken: receipt.print_feedback.receipt_token,
});
// { cert_id: "pw-000059", successful_prints: 1 }
A model with at least one report shows a Print-tested badge and the number of distinct paid-license reports. This is verified receipt possession plus a buyer self-report—not sensor evidence, laboratory testing, or independent inspection. The report does not alter the license certificate or payment ledger.
Paid-holder model updates
A real x402 delivery also includes a private
model_updates.receipt_token. Use it to check and download the newest
file for that certificate. Version 1 remains the original PWC-1 bundle; publishing version 2+
never rewrites its certified hash. Each update records the new file hash and a changelog hash
as a compact PWV-1 message on HCS.
const latest = await printwright.latestVersion({
certId: receipt.license.cert_id,
receiptToken: receipt.model_updates.receipt_token,
});
// Compare latest.original_certificate_hash with latest.file_hash.
// GET latest.download_url with the same Authorization: Bearer token.
MCP clients can call get_latest_version with the
same certificate id and receipt capability. Sandbox receipts do not receive update access.
PWC-1 certificate standard
PWC-1 is the frozen version-1 proof-bundle contract. Its normative JSON Schema fixes the private certificate, exact terms, blinding nonce, commitment, and HCS coordinates. Unknown fields are invalid. The full bundle stays with its holder; Hedera stores only the opaque commitment envelope shown below.
{
"type": "printwright-license-commitment",
"version": 1,
"algorithm": "sha256-jcs-v1",
"commitment": "64 lowercase hex characters"
}
The mirror REST response base64-encodes that UTF-8 JSON at
/api/v1/topics/{topic}/messages/{sequence}.
A verifier validates the disclosed bundle, hashes its terms, recomputes
SHA-256(domain || nonce || JCS(certificate)), derives the public
Mirror Node URL from the reported network/topic/sequence, and requires the decoded envelope
to carry that exact commitment. Consensus proves the commitment occupied that immutable topic
position. It does not prove the buyer still controls an account, interpret the prose, validate
geometry, or independently replay the payment transaction.
# From this checkout (the package is not yet in the npm registry): npx --package ./verifier printwright-verify public/widget-example.bundle.json
Input may instead be a paid bundle_url or
- for stdin. A URL input fetches that untrusted bundle JSON once;
a local file or stdin needs only the public Mirror Node. In either case the verifier derives
its own Mirror Node URL and never trusts Printwright's verification verdict.
Browser pages can use the dependency-free verification widget. Its custom element accepts an inline bundle or fetches the specified bundle URL, then validates PWC-1 against the selected Hedera mirror; the plain HTML example also works from a static web server while this Rails application is stopped.
Printability metadata
Catalog responses use the following stable fields under
printability. They are designer-declared slicing guidance—not a
guarantee that a model fits every printer, and not evidence that Printwright physically tested it.
| Field | Type / unit | Meaning |
|---|---|---|
supports | boolean | Whether the declared orientation requires support material. |
materials | string[] | Suggested materials such as PLA or PETG. |
est_print_minutes | integer minutes | Approximate duration; slicer, profile, scale, and printer can change it. |
bed_min_mm | integer millimetres | Declared minimum square build-plate side at the published scale. |
Load sanity
Operators can run a bounded, read-only catalog burst before release:
node scripts/load-sanity.mjs --url https://your-host --requests 100 --concurrency 10.
A sequential warm-up must pass before the timed burst. The command then reports throughput,
status counts, transport errors, and p50/p95/p99 latency;
non-local targets must use HTTPS. Keep the default below the documented 120/min catalog
rate limit when testing through one client IP.
MCP
The MCP server exposes the same door to any MCP client (Claude Code, Claude Desktop, ...):
cd mcp && npm install && cd .. claude mcp add printwright \ --env PRINTWRIGHT_URL=http://localhost:3000 \ --env BUYER_ACCOUNT_ID=0.0.xxxxxxx \ --env BUYER_PRIVATE_KEY=0x... \ --env MAX_SPEND_CENTS=2500 \ -- node mcp/server.mjs
Six tools: search_models,
get_model, buy_license (refuses without
confirm: true, capped by MAX_SPEND_CENTS),
check_license (machine-decidable use + quantity),
get_latest_version (private paid-holder update access),
and verify_certificate (reads the HCS message from the public
mirror and diffs it against the marketplace copy).
Networks
One switch, HEDERA_NETWORK, flips every
network-dependent value below.
| Item | testnet | mainnet |
|---|---|---|
| CAIP-2 network id | hedera:testnet | hedera:mainnet |
| USDC token id | 0.0.429274 | 0.0.456858 |
| USDC decimals | 6 | 6 |
| HBAR asset id | 0.0.0 — always, on either network | |
| Mirror node | https://<network>.mirrornode.hedera.com, overridable with MIRROR_NODE_URL | |
More
- /openapi.json — the full machine-readable spec
- /llms.txt — a short brief for LLM agents
- /pwc-1.schema.json — normative certificate schema
- Verify a certificate — human-readable example certificate check against the public mirror node