NAP v3 — Verified Product Offers
NAP v3 („Name, Address, Product") is CatyAI's signed product-offer envelope. Every offer served to AI crawlers and API consumers carries an Ed25519 signature over a canonicalized payload, verifiable offline against our public JWKS — no trust in the transport required.
- Issuer:
catyai/payaix— PayAi-X FZE (Dubai, UAE) - Protocol version:
nap_version: 3.0(present in every envelope) - Algorithm: EdDSA / Ed25519 (RFC 8032), payload canonicalization RFC 8785 JCS
- Key discovery: JWKS, multi-
kid(active + previous during rotation — always select the key bykid, never assume a fixed one)
Quick check: public compliance endpoint
The fastest way to verify a domain end-to-end (llms.txt presence through redirects, widget resolution, live Ed25519 chain):
curl -s -X POST "https://shop.catyai.io/api/public/nap-verify" \
-H "Content-Type: application/json" \
-d '{"domain": "gotrendy.ro"}'Returns nap_v3_score (0–100), nap_v3_ready, and a per-check report (llms_txt, widget_resolution, jwks, …). No auth required.
Where signed envelopes live
All endpoints are live on https://shop.catyai.io unless noted.
| Surface | Path | What you get |
|---|---|---|
| Public catalog lookup | GET /geo/v2/lookup?q=… or ?gtin=, ?sku=, ?pid= (combinable with max_price, category, merchant + q) | Full envelope per product: tso, ai_knowledge_layer, json_ld (with embedded catyai:tso), trust. Serves the affiliate catalog + direct clients. |
| Per-widget lookup | GET /geo/v2/lookup?widget_id=<UUID>&sku=… (or ?pid=) | Direct-client catalog as JSON-LD. For bot-classified user agents the signature is served in the X-AKL-Signature response header. |
| AI catalog | GET /geo/v2/catalog?domain=<domain> (+ optional ?category=<name>&limit=&page=) | Category sitemap (no category param) or product listing, with a response-level trust block (issuer, kid, signature). |
| Agentic index | GET /geo/v2/catalog/agentic-index.txt?widget_id=<UUID> | Plain-text product index. The full path with widget_id is required — /agentic-index.txt in the root does not exist (404 by design). Rate limit 10/h per IP, no auth. |
| llms.txt | GET https://<client-domain>/llms.txt | Served through the client's Trust Gateway injection; links are attribution-tagged (?caty_source=ai_geo&caty_agent=llms_txt). |
| AI context | GET /.well-known/ai-context?domain=<domain> | Machine-readable capability manifest of the domain. |
| UCP manifest | GET https://api.catyai.io/.well-known/ucp | Universal Commerce Protocol manifest (org identity + service specs per ucp.dev/2026-04-08). |
| MCP descriptor | GET https://api.catyai.io/.well-known/mcp | MCP server descriptor — streamable_http endpoint at /api/mcp, protocol versions, capabilities. |
| JWKS | GET https://api.catyai.io/.well-known/jwks.json | Public Ed25519 keys (kty: OKP, crv: Ed25519), one per kid. |
Envelope format
Real example (captured live, abbreviated), from GET /geo/v2/lookup?sku=RICHTER-MSP1:
{
"success": true,
"protocol": "catyai/akl-affiliate",
"version": "2.0",
"nap_version": "3.0",
"count": 1,
"products": [
{
"name": "Purificator de Aer Portabil cu UV-C RiCHTER MSP1 …",
"price": 403.42,
"currency": "RON",
"gtin": null,
"merchant_name": "gotrendy.ro",
"tso": { "…": "trusted source object — the signed payload" },
"ai_knowledge_layer": { "…": "optional semantic enrichment" },
"json_ld": {
"@context": ["https://schema.org", { "catyai": "https://catyai.io/ns/akl#" }],
"@type": "Product",
"…": "schema.org Product (validators see only standard fields)",
"catyai:tso": {
"product_id": "d88f4d1b-50fb-438d-93f1-c8848859c2e0",
"sku": "RICHTER-MSP1",
"name": "Purificator de Aer Portabil cu UV-C RiCHTER MSP1 …",
"price": 403.42,
"currency": "RON",
"availability": "https://schema.org/InStock",
"condition": "https://schema.org/NewCondition",
"url": "https://shop.catyai.io/aff/6acf01d025f220816e2dd01d?caty_source=ai_geo&caty_agent=other_bot",
"image": "https://gomagcdn.ro/…/purificator-de-aer-….jpg",
"gtin": null,
"brand": "RiCHTER",
"merchant_name": "gotrendy.ro",
"category": "Casă și Grădină > Aparate de climatizare > …",
"verified_at": "2026-10-14T04:42:38.644Z",
"widget_id": "caty_gotrendy_client",
"signature": "0_T7lxve8zkQdbIXcMbo2U00AsQZtp8GP8GUw4npouGh4JZF0mJ3glWhyWqa3-cpBB7flYkY9NesmOylpnGjAg",
"kid": "catyai-akl-v2-2026-05-05",
"alg": "EdDSA",
"canonicalization": "RFC8785-JCS",
"content_hash": "sha256:b917bab9309ab867129d384c867b98333303c54b51e3425bca7150d7e82a9a12",
"signed_at": "2026-10-14T15:53:51.663Z",
"jwks_uri": "https://api.catyai.io/.well-known/jwks.json"
}
},
"trust": {
"issuer": "catyai/payaix",
"issued_by": "PayAi-X FZE (Dubai, UAE)",
"algorithm": "EdDSA",
"kid": "catyai-akl-v2-2026-05-05",
"signature": "0_T7lxve8zkQdbIXcMbo2U00AsQZtp8GP8GUw4npouGh4JZF0mJ3glWhyWqa3-cpBB7flYkY9NesmOylpnGjAg",
"content_hash": "sha256:b917bab9309ab867129d384c867b98333303c54b51e3425bca7150d7e82a9a12",
"canonicalization": "RFC8785-JCS",
"signed_at": "2026-10-14T15:53:51.663Z",
"jwks_uri": "https://api.catyai.io/.well-known/jwks.json"
}
}
]
}Field semantics:
tso(Trusted Source Object) — the offer facts: identity (sku,gtin,brand,merchant_name), commercial (price,currency,availability,condition,url), classification (category), provenance (widget_id,verified_at= when the offer was last validated, not merely written).signature,kid,alg,canonicalization,content_hash,signed_at,jwks_uri— the signature frame. These seven fields are NOT part of the signed message.content_hash—sha256:+ hex of the RFC 8785-canonicalized TSO. Recompute it to detect accidental normalization differences on your side.- Absence of the signature frame (e.g. signing key unreachable) is itself a signal: the offer is served unsigned rather than withheld.
Verification procedure
The signed message is the TSO without the seven signature-frame fields, canonicalized with RFC 8785 JCS, signed with Ed25519.
Steps:
- Extract the
catyai:tsoobject (or thetso/trustpair). - Remove
signature,kid,alg,canonicalization,content_hash,signed_at,jwks_uri. - Canonicalize the remainder with RFC 8785 JCS (UTF-8 bytes).
- Fetch JWKS from
jwks_uri; select the key whosekidmatches the envelope'skid; decodexfrom base64url → 32 raw bytes. - Decode
signaturefrom base64url → 64 bytes; verify Ed25519 over the canonical bytes. - Recompute
content_hashand compare. Checkverified_atfreshness for your use case.
Reference implementation (Node.js):
import canonicalize from 'canonicalize';
import * as ed from '@noble/ed25519';
const SIGNATURE_FIELDS = ['signature', 'kid', 'alg', 'canonicalization', 'content_hash', 'signed_at', 'jwks_uri'];
export async function verifyNapV3(tsoSigned) {
const payload = Object.fromEntries(
Object.entries(tsoSigned).filter(([k]) => !SIGNATURE_FIELDS.includes(k))
);
const canonical = canonicalize(payload); // RFC 8785 JCS
const jwks = await (await fetch(tsoSigned.jwks_uri)).json();
const jwk = jwks.keys.find((k) => k.kid === tsoSigned.kid); // select BY kid
if (!jwk) return { valid: false, reason: 'unknown kid' };
const publicBytes = Buffer.from(jwk.x, 'base64url');
const sigBytes = Buffer.from(tsoSigned.signature, 'base64url');
const message = new TextEncoder().encode(canonical);
const valid = await ed.verifyAsync(sigBytes, message, publicBytes);
return { valid, kid: tsoSigned.kid };
}Negative test: modifying any single field (e.g. price) must make verification fail. If it doesn't, your canonicalization differs from RFC 8785 JCS — the content_hash recomputation in step 6 will show it.
Stability contract & key rotation
- Fields in the TSO are append-only between major versions: existing fields are never renamed or removed without a
nap_versionmajor bump. - JWKS publishes multiple
kids (active + previous) so keys rotate without verification downtime. Verifiers must key offkid, never hardcode a key. - Envelopes are re-verifiable offline indefinitely: JWKS + payload is all you need.
Auth behavior (as deployed) — per endpoint
Auth is not uniform across the surface. Verified live (14 Oct 2026):
| Endpoint | Browser UA, no credentials | Notes |
|---|---|---|
/geo/v2/lookup — public catalog (?q=/?gtin=/?sku=/?pid=, cu sau fără widget_id) | 200 | Public by design — this is the AI-agent-facing surface. It serves public-safe product fields only (identity, price, availability, category, URLs). No X-API-Key, no crawler whitelist; protection is the per-IP rate limit. Same behaviour with or without widget_id. |
/geo/v2/catalog with widget_id/domain/category | 403 AUTH_REQUIRED | Real catalog data requires a whitelisted AI crawler UA or a valid X-API-Key. |
/geo/v2/catalog without parameters, browser UA | 200 | Service metadata only (protocol, version, status: ok) — a deliberate discovery probe for UCP checkers. No catalog data is returned. |
/geo/v2/catalog without parameters, whitelisted crawler UA | 400 VALIDATION_ERROR | Asymmetric with the browser case by design: the crawler passes auth before the probe branch, then fails parameter validation. |
/geo/v2/answer | auth required | See rate limits below. |
Security note: the direct-client catalog behind /geo/v2/lookup?widget_id=… is intentionally public the same way a merchant's product pages are public — the envelope adds provenance and integrity (signature + verified_at), not secrecy. If a client ever needs a private catalog, that is a separate product decision, not something the current gate implies.
Real per-IP rate limits (fixed windows, Redis-backed):
| Endpoint | Limit |
|---|---|
/geo/v2/lookup (public, no widget_id) | 30/min |
/geo/v2/lookup (per-widget) | 120/h |
/geo/v2/catalog | 60/h (+ valid key or crawler UA) |
/geo/v2/answer | 60/h |
/geo/v2/catalog/agentic-index.txt | 10/h |
/api/public/nap-verify | public, no auth |
