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.
Describe the action
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).
| Field | Type | Notes |
|---|---|---|
| action | string | A registered schema, e.g. payment.release. See the nine published schemas. |
| fields | object | Typed values: { type, value, currency? } |
| render | array | Label/value rows shown to the person |
| statement | object | Instead 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.
The person signs
{ 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.
{ 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.
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
| Method | Path | Purpose |
|---|---|---|
| GET | /.well-known/manav-keys | Issuer public keys, both halves. Cache long. |
| GET | /v2/revocations | Signed revocation list; ETag is its sequence number |
| GET | /v2/health | Issuer status and current key ID |
| POST | /v2/register/options, /v2/register | Create a passkey; starts a session |
| POST | /v2/statement | Describe an action |
| POST | /v2/sign/options, /v2/sign | The signing ceremony |
| GET | /v2/receipts/{nonce} | A receipt and its statement |
| POST | /v2/verify | Server-side verdict (same as offline) |
| POST | /v2/grant-actions | An agent acts under a person's signed agent.payment_grant |
| GET | /v2/log/sth | Signed 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.
| Status | error | Meaning |
|---|---|---|
| 400 | r2_unknown_schema | The action is not a registered schema |
| 401 | no_session | No passkey session; register or sign in first |
| 404 | statement_not_found | Unknown, already used, or started by another person |
| 409 | nonce_replayed | This statement was already signed |
| 410 | r6_expired | The statement expired before it was signed |
| 422 | statement_rejected | Breaks a statement rule; reason says which |
| 429 | rate_limited | See below |
Rate limits
Per IP address: 30 registrations, 120 statements and 300 agent grant actions per hour.