onym audit · Onym audit seat

For developers

HTTP API

Everything you can read, verify and send: addresses, request bodies, the checks the server makes, replies and limits. Every change is a signed document; the server keeps the bytes but cannot forge them.

Overview

The base of every address is https://foldy.io/audit/; the paths below are relative to it. The server has three parts:

  • the main seat “onym audit” — a tree of signed documents at the root and two write addresses (orders, responses);
  • the hub — anyone can become an auditor (hub/api/…); their documents live in a/<name>/;
  • the Discovery provider — signed catalogs in discovery/ that the Onym apps read.

There are no accounts, passwords or cookies. Everything that changes state is signed with the Ed25519 key of whoever acts: the auditor, the customer or the subject. The server checks the signature and stores the bytes exactly as they were signed. It cannot forge a document, because it holds no auditor key. Everything the server returns can be verified without trusting it; in the browser, verify.js does this.

The formats of the documents themselves (manifest, attestation, status list, order, offer, response, revocation) are defined by the implementation profile. This page describes the transport: addresses, request bodies, checks, responses and limits. Source code: github.com/Dubieroj/onym-audit (hub/, server/).

General rules

Canonical JSON

The same as in Discovery-Static-Ed25519 §3:

  • the top level is an object; keys are sorted by their UTF-8 bytes at every depth; the output is compact, with no whitespace;
  • only ", \ and U+0000–U+001F are escaped (two-character forms where they exist, otherwise lowercase \u00xx);
  • numbers are non-negative integers up to 253−1 only;
  • duplicate keys, invalid UTF-8 and lone surrogates are rejected, not repaired.

The server canonicalizes everything it accepts and stores the canonical bytes.

Signatures, keys, digests, time

SignatureEd25519 over the canonical bytes of the document without the signature field (removed at the top level only); the value is standard base64 with padding. Next to every signed file lies a .sig: the same string followed by a newline.
Keyonym:key:<64 lowercase hex> — an Ed25519 public key. The fingerprint shown on pages is the first 8 bytes of the key's SHA-256, separated by colons.
Digestsha256:<64 hex> of the exact file bytes.
TimeRFC 3339 in UTC, to the second: 2026-09-25T10:00:00Z. Other forms are rejected.

Signed request

Addresses marked 🔏 signed accept {"request": {…}}. The inner object is signed by the named key, contains an issuedAt within ±10 minutes of the server clock, and has no extra fields. This is how a browser proves it holds a key, without sessions or passwords:

import { canonical, parseStrict } from "https://foldy.io/audit/verify.js";

const now = new Date().toISOString().replace(/\.\d+Z$/, "Z");
const req = parseStrict(JSON.stringify({ action: "order-status", orderId, sponsor: key, issuedAt: now }));
const sig = await crypto.subtle.sign("Ed25519", privateKey, new TextEncoder().encode(canonical(req)));
req.set("signature", btoa(String.fromCharCode(...new Uint8Array(sig))));

await fetch("https://foldy.io/audit/hub/api/a/alice/order-status", {
  method: "POST", headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ request: JSON.parse(canonical(req)) }),
});

Responses and errors

Responses are JSON with Cache-Control: no-store. A hub error is {"error": "<text>"}; a main seat error is {"error": "<code>", "detail": "<text>"}.

400the body does not parse or contains an unknown field
403signature by the wrong key, or a stale issuedAt
404no such auditor, attestation or open request
409conflict with something already published: an immutable document, a version rollback, a taken name
413the body exceeds the limit
415fetch: the response is neither JSON nor text
422the document failed a check; the reason is in the error text
429rate limit exceeded
502the external address is unreachable or responds unexpectedly
503the queue is full, or the hub is busy with heavy checks
507the auditor's storage on the hub is full (20 MiB)

Limits

  • A request body to the hub is at most 4 MiB; each document at most 1 MiB; one auditor's tree at most 20 MiB.
  • Rate: a token bucket per IP address (for IPv6, per whole /64): capacity 60, refill 1 token per second. The cost of a call is given for each address. GET requests for lists are not limited.
  • “Heavy” calls (digest, conformance) run at most two at a time across the whole hub.
  • The main seat: an order at most 64 KiB, a response at most 16 KiB, an overall limit of 20 requests refilled at 1 per second.

Static files

Documents are served as is, byte for byte, with Access-Control-Allow-Origin: *, X-Content-Type-Options: nosniff and Cache-Control: no-cache; no cookies and no access logs. Pages run under the CSP script-src 'self'.

Reading and verification

The main seat (root /audit/) and every hub auditor (a/<name>/) have the same tree:

manifest.json + .sigthe auditor's signed manifest: operator key, delegated status key, policies, methodologies, offers
status.json + .sigthe status list, signed by the delegated key; re-signed every 6 hours, nextUpdate is 48 hours ahead
attestations/<id>.jsonattestations, immutable
revocations/<id>.jsonsigned revocations
responses/<attestation>/<id>.jsonsigned subject responses
offers/<id>.jsonoffers: methodology, price, term, disclosure terms
orders/<id>.jsonorders with three signatures, published together with the attestation
scopes/, reports/, methodology/, policies/documents pinned by digests

A status list entry:

{"attestationId": "onym-discovery-2026-09-24",
 "attestation": {"uri": "https://foldy.io/audit/attestations/onym-discovery-2026-09-24.json",
                 "digest": "sha256:…"},
 "state": "active" | "revoked" | "superseded" | "expired",
 "supersededBy": null, "revocation": null,
 "responses": [{"uri": "…", "digest": "…"}]}

How to verify an attestation while trusting nothing:

  1. the manifest signature matches its operator;
  2. the status list is signed by the statusKey from the manifest, its auditorKey equals operator, and its nextUpdate has not passed yet;
  3. the digest of the attestation file matches the entry in the list, and the entry is in the active state;
  4. the attestation is signed by the operator key and has not expired; the pinned documents (scope, methodology, report) match their digests.
import { verifyAttestation } from "https://foldy.io/audit/verify.js";
const get = (p) => fetch("https://foldy.io/audit/" + p).then((r) => r.text());
const d = await verifyAttestation({
  manifestText: await get("manifest.json"),
  statusText: await get("status.json"),
  attText: await get("attestations/onym-discovery-2026-09-24.json"),
  target: null, credited: true,
});
// d.display: "attested" | "revoked" | "superseded" | "expired" | "status-unknown" | …

The page of each attestation (verdict/?id=<id> for the main seat, verdict/?a=<name>&id=<id> for a hub auditor) runs these checks in the browser and shows each one.

The main seat “onym audit”

POST orders

Accept an order (Audit.md §6). The body is either a bare order signed by the subject and the sponsor, or an envelope:

{"order": {…}, "scopeText": "what to examine", "contact": "any text, optional"}

In the envelope, order.scope.uri = <base>scopes/order-<orderId>.md, and the SHA-256 of scopeText (1–8192 bytes) equals order.scope.digest. The contact is at most 256 bytes with no control characters; it is not published.

202 {"orderId", "state": "queued-for-review", "digest"} — the order is queued. This is not yet the auditor's acceptance.

Errors: 422 order_invalid, 409 order_conflict (a different order already has this id), 503 inbox_full (1000 orders), 413 oversize, 429 rate_limited.

POST responses

A subject's response to an attestation (Audit.md §5.7.5). The body is a bare signed response, at most 16 KiB; attestationId and responseId are 16–128 characters from [A-Za-z0-9_-]. After the write, the status list is re-signed and the response appears next to the attestation.

201 {"uri", "digest"}

Errors: 404 attestation_unknown, 422 subject_response_invalid, 409 response_conflict (responses are immutable).

Hub: registration and publishing

A hub auditor keeps their key in the browser (the auditor dashboard); the hub sees only signed documents. The auditor's path: claim → sign the manifest → register → publish attestations → revoke if needed.

POST hub/api/claim 5 tokens

Reserve a name and receive the delegated status key that the manifest must name.

{"slug": "my-auditor"}

Name: ^[a-z][a-z0-9-]{2,31}$, not one of the reserved names (admin, api, hub, audit, onym, onym-audit, foundation, sobor, root, status, www). A reservation lasts 30 minutes; a repeat call within that time returns the same reservation.

200 {"slug", "statusKey", "base", "componentId", "expires"}

Errors: 409 — the name is taken; 422 — the name is not allowed.

POST hub/api/register 10 tokens

Publish or replace the manifest together with the documents it references.

{"slug": "my-auditor",
 "manifest": {…signed manifest…},
 "docs": {"policies/independence.md": "text", "offers/manual-review.json": "{…}", …}}
  • componentId = onym:component:<name>; statusEndpoint = <base>status.json; statusKey is the one issued by claim;
  • contact is any text of at most 256 bytes with no control characters (on pages it becomes a link only if it starts with mailto: or https://);
  • re-registration works only with the same operator key (otherwise 403) and must not be older than the current one: validUntil not earlier, and if equal, the same bytes (otherwise 409);
  • document paths: (policies|methodology|scopes|reports|offers|notes)/<name>.(md|json); only documents that the manifest references are accepted;
  • every reference in the manifest points either to a file of this auditor or to a shared document of the main seat, and the digest matches;
  • offers are signed by the auditor key, belong to a methodology from the manifest, have not expired, and do not roll back a published version.

201 {"page", "manifest", "digest"}

POST hub/api/a/{slug}/publish 5 tokens

Publish an attestation with its documents.

{"attestation": {…},
 "docs": {"scopes/….md": "…", "reports/….json": "…", "orders/<orderId>.json": "…"}}
  • signed by the auditor key, status = statusEndpoint, unsolicited.policy = the digest of the published policy;
  • the id is new: attestations are immutable, and a correction is a new attestation that replaces the old one (supersede);
  • methodology, scope, scale and report are pinned the same way as at registration;
  • for engagement: "commissioned", exactly one order with three signatures (subject, sponsor, auditor) is attached; orderRef is its digest; the subject, sponsor, artifact, methodology and scope match.

201 {"uri", "digest"} — published.
202 {"uri", "digest", "held": true, "releaseAt"} — a fail on an order with an embargo: the hub holds the attestation, report, order and scope, and releases them automatically when the embargo ends.

POST hub/api/a/{slug}/revoke 5 tokens

{"revocation": {…signed revocation…}}

A revocation is always accepted, even when storage is full.

201 {"uri"}

Errors: 404 — no such attestation; 409 — already revoked.

POST a/{slug}/responses 5 tokens

A subject's response to a hub auditor's attestation. The body is a bare signed response. At most 10 responses per attestation; responses are immutable (409).

201 {"uri"}

GET hub/api/a/{slug}/export

A list of all files in the auditor's tree, for moving it to your own server: the hub is a replaceable default.

200 {"base", "files": ["manifest.json", …]}

Orders

Anyone can order an examination from any auditor that has offers. The browser creates a key for the order and signs the order with it as both subject and sponsor. The auditor countersigns it when publishing the attestation.

POST a/{slug}/orders 10 tokens

{"order": {…}, "scopeText": "what to examine", "contact": "optional"}
  • the order is signed by the subject and the sponsor and matches the auditor's offer exactly: methodology, price model, disclosure terms;
  • fee.offerId is an offer from the manifest, or rsp-…, a response to a request that is still open (then the sponsor is the author of the request, and the subject, methodology and artifact match the request);
  • scope.uri = <base>scopes/order-<orderId>.md, and scopeText (1–8000 bytes) hashes to scope.digest;
  • the contact is optional: any text of at most 256 bytes; only the auditor sees it.

202 {"orderId", "state": "queued-for-review", "digest"}. The request answered by the offer is closed.

Errors: 409 — the order is already fulfilled, or a different order has this id; 503 — 100 orders are in the queue.

POST hub/api/a/{slug}/order-status 1 token 🔏 sponsor key

{"request": {"action": "order-status", "orderId": "…", "sponsor": "onym:key:…",
             "issuedAt": "…", "signature": "…"}}

200 {"queued": bool, "held": bool} — whether the order is waiting in the queue, and whether its result is held under embargo. No one but the holder of the order key can learn this.

POST hub/api/a/{slug}/inbox 2 tokens 🔏 auditor key

{"request": {"action": "list" | "decline", "auditor": "onym:component:<name>",
             "orderId": "…decline only", "issuedAt": "…", "signature": "…"}}

list → {"orders": [{"order", "scopeText", "contact", "receivedAt"}], "held": [{"attestationId", "releaseAt"}]}
decline → {"declined": "<orderId>"}

Requests

A customer who does not know whom to ask publishes a request: the exact bytes, the subject component, the methodology and the scope, addressed to all auditors or to one Stellar address. Auditors answer with signed offers; the customer picks one and orders under it.

POST hub/api/requests 5 tokens

{"request": {
  "requestVersion": 1,
  "requestId": "req-<20 characters [a-z0-9]>",
  "to": null | "onym:key:…",
  "subject": "onym:component:<name>",
  "methodologyClass": "security-review" | "conformance-run",
  "artifact": {"kind": "source" | "build" | "deployment", "source": "…", "revision": "…", "artifactHash": null},
  "scopeText": "1–8000 bytes",
  "requester": "onym:key:…",
  "issuedAt": "…", "expiresAt": "… (at most 30 days later)",
  "signature": "…signed by requester"
}}

to: null — the request is visible to everyone; otherwise only to the addressee. conformance-run is accepted only for kind: "deployment".

201 {"requestId", "digest"}

Errors: 409 — this id already exists; 503 — 500 requests are open.

GET hub/api/requests

200 — open public requests, newest first: [{…request, "responses": N}].

POST hub/api/requests/for 2 tokens 🔏 addressee key

{"request": {"action": "requests-for", "key": "onym:key:…", "issuedAt": "…", "signature": "…"}}

200 — requests addressed to this key.

POST hub/api/requests/{id}/responses 5 tokens

The auditor answers with an offer.

{"slug": "my-auditor", "offer": {…}}

offerId = rsp-<requestId suffix>; the offer is signed by the auditor key, and its methodology is the one from the request; only the addressee can answer an addressed request; the scope is pinned; the offer has not expired and does not roll back an earlier one.

201 {"offer": "<uri>"}

Errors: 404 — the request is closed or has expired.

POST hub/api/requests/{id}/mine 2 tokens 🔏 requester key

{"request": {"action": "responses", "requestId": "…", "issuedAt": "…", "signature": "…"}}

200 {"open": bool, "responses": [{"slug", "name", "operator", "fingerprint", "page", "completedOrders", "customers", "offer"}]}

Vault

POST hub/api/vault 2 tokens 🔏 vault key

So that a customer sees their orders and requests on any device, the app stores their list encrypted (AES-256-GCM, with a key derived from the phrase) under a separate signing key derived from the same phrase. The hub stores ciphertext it cannot read, under a key that appears nowhere else.

{"request": {"action": "get" | "put", "key": "onym:key:…", "data": "base64, put only",
             "issuedAt": "…", "signature": "…"}}

get → {"data", "updatedAt"} or {"data": null}
put → {"updatedAt"}; data at most 256 KiB; at most 20,000 vaults on the hub in total.

Verification tools

A browser cannot read most external addresses by itself (CORS), so the hub does it on the browser's behalf: public HTTPS only, no cookies, never into an internal network, at most three redirects.

POST hub/api/fetch 2 tokens

{"url": "https://…"}

JSON and text only, at most 1 MiB (otherwise 415).

200 {"url", "status", "contentType", "setsCookie", "digest", "size", "body"}

POST hub/api/digest 20 tokens

{"url": "https://…/app.apk"}

Hashes the file as a stream, up to 300 MiB, without returning it, to pin the exact bytes of a build.

200 {"url", "status", "digest", "size"}

POST hub/api/conformance/discovery 20 tokens

{"manifestUrl": "https://…/manifest.json"}

Runs the Discovery provider conformance suite (42 checks) against a manifest with "seat": "discovery". The suite does not apply to other services (422). Timeout: 3 minutes.

200 — a canonical report: {suite, suiteVersion, runAt, manifestUri, result, documents: [{role, uri, digest, size}], checks: [{id, clause, level, title, outcome, detail}]}

Lists

GET hub/api/auditors

[{"slug", "name", "operator", "fingerprint", "page", "attestations", "offers", "completedOrders", "customers"}]

GET hub/api/seat

The same for the main seat ("slug": "").

GET hub/api/library

All attestations of all auditors, newest first. This is an index, not an authority: the browser checks every entry against the signed files.

[{"attestationId", "auditor", "auditorSlug", "auditorBase", "fingerprint", "subject", "result", "state", "methodologyClass", "kind", "source", "engagement", "issuedAt", "expiresAt", "orderRef", "uri"}]

curl -s https://foldy.io/audit/hub/api/library | jq '.[0]'

Discovery provider

The provider onym:component:onym-audit-discovery publishes signed catalogs that the Onym apps add under Settings → Discovery Providers. All of them are static files:

discovery/manifest.json + .sigseat: "discovery", profile static-ed25519-v1, capabilities signed-snapshot-v1 and local-filtering-v1
discovery/catalogs/onym-auditors.jsonauditors (seatTypes: ["audit"]), up to 512 entries
discovery/catalogs/onym-audited-services.jsonOnym services that have a current audit, with their status
discovery/policies/*.md, discovery/privacy.mdpolicies pinned by digests in the manifest
discovery/status/<component>/the status page that a catalog entry links to

Snapshots are re-signed on every change on the hub, but at most once every 2 minutes, and are served with Cache-Control: no-cache. Snapshot fields: {version, catalogId, providerId, implementationProfileId, policyDigest, sequence, previousDigest, generatedAt, expiresAt, entries, signature}. An entry in onym-audited-services:

{"componentId": "onym:component:onym-relayer", "seatType": "notary",
 "operator": "onym:key:…", "manifest": {"uri": "…", "digest": "sha256:…"},
 "profiles": […], "relationship": "other-disclosed", "placement": "policy-ranked",
 "listedAt": "…", "evidence": [],
 "status": {"state": "review" | "warning", "uri": "https://foldy.io/audit/discovery/status/onym-relayer/"}}

The app shows status as a badge next to the service and as a link in the consent dialog. In the app, the provider key fingerprint is the first 16 hex characters of the operator key: 4074 4514 be2a 1414.

Stellar anchor

An auditor's register is anchored on the Stellar test network: the data entry onym-audit-status on the auditor's account (the same key as the operator key) holds the SHA-256 of the canonical JSON

{"anchorVersion": 1, "auditor": "onym:component:…",
 "entries": [{"attestationId", "attestation": "sha256:…", "state", "supersededBy", "revocation"}, …]}

with entries sorted by attestationId. Re-signing for freshness does not change the anchor; issuing and revoking do. The network stores every entry with its ledger time, so the register cannot be rewritten after the fact or shown differently to different readers. To verify, use stellar.js: anchorState(account, statusText) queries a public Horizon node and compares.