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
| Signature | Ed25519 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. |
| Key | onym: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. |
| Digest | sha256:<64 hex> of the exact file bytes. |
| Time | RFC 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>"}.
| 400 | the body does not parse or contains an unknown field |
| 403 | signature by the wrong key, or a stale issuedAt |
| 404 | no such auditor, attestation or open request |
| 409 | conflict with something already published: an immutable document, a version rollback, a taken name |
| 413 | the body exceeds the limit |
| 415 | fetch: the response is neither JSON nor text |
| 422 | the document failed a check; the reason is in the error text |
| 429 | rate limit exceeded |
| 502 | the external address is unreachable or responds unexpectedly |
| 503 | the queue is full, or the hub is busy with heavy checks |
| 507 | the 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 + .sig | the auditor's signed manifest: operator key, delegated status key, policies, methodologies, offers |
status.json + .sig | the status list, signed by the delegated key; re-signed every 6 hours, nextUpdate is 48 hours ahead |
attestations/<id>.json | attestations, immutable |
revocations/<id>.json | signed revocations |
responses/<attestation>/<id>.json | signed subject responses |
offers/<id>.json | offers: methodology, price, term, disclosure terms |
orders/<id>.json | orders 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:
- the manifest signature matches its
operator;
- the status list is signed by the
statusKey from the manifest, its auditorKey equals operator, and its nextUpdate has not passed yet;
- the digest of the attestation file matches the entry in the list, and the entry is in the
active state;
- 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", …]}
POST hub/api/a/{slug}/stellar-link 5 tokens 🔏 auditor key
Link the auditor to an account it already has on the Stellar public network. First the account's holder writes two data entries on that account in one transaction (the auditor dashboard forms it; any wallet signs it: from a QR code, a SEP-7 link or Stellar Lab):
onym-audit-auditor | the auditor's public key, 32 bytes |
onym-audit-proof | the auditor key's signature, 64 bytes, over "onym-audit-link-v1\nPublic Global Stellar Network ; September 2015\n" + G address |
Both sides consent on the network: the account signed the transaction, the auditor key signed the proof. The auditor needs no account on the public network. Then:
{"request": {"action": "stellar-link" | "stellar-unlink", "auditor": "onym:component:<name>",
"account": "G…", "issuedAt": "…", "signature": "…"}}
The hub reads the account from horizon.stellar.org and checks both entries. If they hold, it publishes the signed request itself as a/<name>/stellar-link.json; the auditor's page checks it again in the browser the same way.
201 {"account", "uri"}; stellar-unlink → {"unlinked": true} (the entries on the account are removed by its holder).
Errors: 403 — not signed by the auditor key, or not fresh; 422 — the entries are missing, name another key, or the proof does not verify.
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.
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 + .sig | seat: "discovery", profile static-ed25519-v1, capabilities signed-snapshot-v1 and local-filtering-v1 |
discovery/catalogs/onym-auditors.json | auditors (seatTypes: ["audit"]), up to 512 entries |
discovery/catalogs/onym-audited-services.json | Onym services that have a current audit, with their status |
discovery/policies/*.md, discovery/privacy.md | policies 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.