Manav.id
Quickstart

A human-signed receipt in three calls.

Your server describes the action. The person sees it and signs it with their passkey. Anyone can verify the resulting receipt offline, against our published key, without calling us.

const post = (path, body) => fetch('https://manav.id' + path, {
  method: 'POST', credentials: 'include',
  headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
}).then((r) => r.json());

// 1. Describe the action. Every field must also appear in what the person sees.
const { statement } = await post('/v2/statement', {
  action: 'payment.release',
  fields: { amount: { type: 'money', value: '42000.00', currency: 'USD' }, /* … */ },
  render: [['Action', 'RELEASE PAYMENT'], ['Amount', '$42,000.00'], /* … */],
});

// 2. The person signs. The WebAuthn challenge is the statement's hash.
const { publicKey } = await post('/v2/sign/options', { nonce: statement.nonce });
const credential = await navigator.credentials.get({ publicKey: decode(publicKey) });
const { receipt } = await post('/v2/sign', { nonce: statement.nonce, credential: encode(credential) });

// 3. Anyone verifies it, offline.
const { verify } = require('@manav/verify');
verify(receipt, { jwks, revocationV2 }).ok;   // true

decode and encode convert between base64url and the ArrayBuffers WebAuthn uses. See a full, working flow in the contract-signing demo.

Access

The issuer API at https://manav.id/v2 is in early access. The first-party demo mode used above needs no key: our server acts as the relying party and signs the statement for you. To sign statements as your own relying party, request access and we will register your key.

The offline verifier, @manav/verify (Node 18+, Apache-2.0), ships to evaluating customers in the repository today. It is not on npm or PyPI yet.

Step 1

Describe the action

POST/v2/statement

A statement is the exact action, plus the text the person will see. The issuer rejects it unless every machine-readable field also appears in that text (rules R1–R6 in the spec).

FieldTypeNotes
actionstringA registered schema, e.g. payment.release. See the nine published schemas.
fieldsobjectTyped values: { type, value, currency? }
renderarrayLabel/value rows shown to the person
statementobjectInstead of the three above: a statement your relying-party key already signed

Returns { statement, stmt_hash }. The statement carries a single-use nonce and an expiry.

Step 2

The person signs

POST/v2/sign/options

{ nonce } → { render, stmt_hash, publicKey }. Show render to the person, then pass publicKey to navigator.credentials.get(). The challenge is the statement hash, so the signature covers exactly what was shown. User verification is required.

POST/v2/sign

{ nonce, credential } → { receipt, proofUrl, coverUrl }. The nonce is consumed; replaying it returns 409 nonce_replayed.

A person needs a passkey first: POST /v2/register/options with { label }, then POST /v2/register with the created credential. Registration starts a session cookie that the signing calls use.

Step 3

Verify, offline

Fetch the key set once and cache it. Verification needs no network after that, and a Manav outage does not change the answer.

const { verify } = require('@manav/verify');

const res = verify(receipt, {
  jwks,                 // GET /.well-known/manav-keys
  revocationV2,         // GET /v2/revocations
  statement,            // optional: also check the statement matches
  minTier: 'P2',        // optional: reject weaker presence
  maxAgeSeconds: 900,   // optional: reject stale receipts
});
// { ok: true, reason: null, principal, action, render, presence, issuedAt, kid }

A failed check returns ok: false and a stable reason, such as unknown_kid, nonce_revoked, stale or tier_too_low. POST /v2/verify gives the same verdict server-side, for convenience only.

The receipt

A small JSON object: who signed (principal), what (action, the statement hash and the render they saw), how strongly (presence: tier P1–P3, see features), when (issuedAt) and under which issuer key (kid). It carries two signatures, Ed25519 and ML-DSA-87; a verifier must check both.

Endpoints

MethodPathPurpose
GET/.well-known/manav-keysIssuer public keys, both halves. Cache long.
GET/v2/revocationsSigned revocation list; ETag is its sequence number
GET/v2/healthIssuer status and current key ID
POST/v2/register/options, /v2/registerCreate a passkey; starts a session
POST/v2/statementDescribe an action
POST/v2/sign/options, /v2/signThe signing ceremony
GET/v2/receipts/{nonce}A receipt and its statement
POST/v2/verifyServer-side verdict (same as offline)
POST/v2/grant-actionsAn agent acts under a person's signed agent.payment_grant
GET/v2/log/sthSigned tree head of the transparency log

Host and agent endpoints (enrolment, leases, gate logs) are in the protocol spec.

Errors

Every error is { ok: false, error, reason? } with a stable error string.

StatuserrorMeaning
400r2_unknown_schemaThe action is not a registered schema
401no_sessionNo passkey session; register or sign in first
404statement_not_foundUnknown, already used, or started by another person
409nonce_replayedThis statement was already signed
410r6_expiredThe statement expired before it was signed
422statement_rejectedBreaks a statement rule; reason says which
429rate_limitedSee below

Rate limits

Per IP address: 30 registrations, 120 statements and 300 agent grant actions per hour.