Node.js · Deno · Cloudflare Workers · TypeScript types included.
Base URL: https://api.fipsign.dev · Package: fipsign-sdk
api.fipsign.dev only accepts browser calls from fipsign.dev and app.fipsign.dev; from any other website the browser blocks them and you will see a CORS error or Failed to fetch (NETWORK_ERROR in the SDK). From your own server (Node.js 20+) there is no such restriction.
pq is always a PQAuth you created once (const pq = new PQAuth('pqa_your_api_key')). reject(reason) stands for your own code that refuses the request — for example throw new Error(reason) or res.status(403).json({ error: reason }) — and doTheAction() for the operation you are protecting. Neither is part of the SDK.
Set these in your terminal before running any REST/curl command in this guide. If you are using the SDK you only need your API key.
# Replace with your API key from the dashboard (starts with pqa_) export API_KEY="pqa_YOUR_API_KEY_HERE" export BASE_URL="https://api.fipsign.dev"
POST /mandate/verify — every other endpoint in the system (/sign, /verify, /ca/*, the other Mandate endpoints, etc.) rejects it with the same 401 as an invalid key. Use this for keys you hand directly to an AI agent, so a compromised agent process can never do more than check its own mandate. Keys created without the checkbox keep full access, exactly as before — this is opt-in and does not affect any existing key. See Mandate 00 for the full explanation.ca.verifyCert()) or X.509 (PEM, verified with ca.verifyX509Cert()). Save the root certificate shown after creation — it is shown only once.root-cert.json, for an X.509 CA as a .pem file — and pass it to ca.verifyCert() or ca.verifyX509Cert() as shown in SDK 12.
Works in Node.js, Deno and Cloudflare Workers — run it on your backend. In a browser the code runs, but calls to the API only work from FIPSign's own domains (see the note at the top of this tab). TypeScript types included — no separate @types package needed. On Node.js use version 20 or newer: on Node 18 the key-generation functions and zes fail with crypto is not defined.
npm install fipsign-sdk
mandate.verify() — every other method in this SDK (sign(), verify(), ca.*, even the other mandate.* methods) will fail with an invalid-key error if called with an agent key. See Mandate 00.
// Simple form — just the API key import { PQAuth } from 'fipsign-sdk' const pq = new PQAuth('pqa_your_api_key') // Or with all options (see SDK 11 for full reference): // const pq = new PQAuth({ // apiKey: 'pqa_your_api_key', // baseUrl: 'https://api.fipsign.dev', // default // localVerify: false, // set to true for offline verification (SDK 04) // projectId: undefined, // required when localVerify: true (SDK 04) // timeout: 10_000, // request timeout in ms, default 10000 // })
Signs any payload with ML-DSA (44, 65, or 87 — configured per project at creation). The only required field is sub — any string identifying the entity. All other fields are stored in the payload and returned on verify. Cost: 1 token.
const { token, meta, usage } = await pq.sign({ sub: 'user_123', email: '[email protected]', role: 'admin', expiresInSeconds: 3600, // optional — default: 1 hour })
const { token } = await pq.sign({ sub: 'order_456', amount: 299.99, currency: 'USD', expiresInSeconds: 300, })
const { token } = await pq.sign({ sub: 'doc_789', hash: 'sha256:abc...', signedBy: 'alice', })
const { token } = await pq.sign({ sub: 'agent_summarizer_v2', action: 'document:summarize', userId: 'user_123', traceId: 'trace_abc', })
const { token } = await pq.sign({ sub: 'device_iot_001', firmware: '2.1.4', location: 'plant-A', })
const { token, meta, usage } = await pq.sign({ sub: 'user_123' }) console.log(`${usage.freeRemaining} free · ${usage.packRemaining} pack`) console.log(`charged from: ${meta.source}`) // "free" | "pack" | "free+pack" console.log(`project: ${meta.projectId} · account: ${meta.issuedFor}`)
sub is required, max 128 characters. Other string fields max 256 characters. Maximum 10 custom fields total (not counting sub, email, role, and expiresInSeconds). Exceeding these returns API_ERROR 400.60.5, returns API_ERROR 400.
_, for example _mandate) are reserved for the server. sign() throws PQAuthError with code API_ERROR, status 400 and message Custom fields starting with "_" are reserved.
Verifies the ML-DSA signature (algorithm read from the token itself), token expiry, and the revocation list. Never throws — always returns an object. Cost: 1 token.
const result = await pq.verify(token) if (!result.valid) { if (result.failure === 'rejected') { // FIPSign checked the token and refuses it. result.error is one of these (text for your // logs: decide on result.failure, do not compare the text): // 'Token has been revoked' // 'Token expired N seconds ago' // 'Invalid signature — token was tampered with or not issued by this server' // 'Invalid signature — wrong length for ML-DSA-65 (expected 3309 bytes)' // 'Invalid token — signature is not valid base64' // 'Unsupported algorithm: X' // 'Token payload exceeds the maximum of 16384 characters' // 'This is a Mandate token. Verify it with POST /mandate/verify' return res.status(401).json({ error: 'Unauthorized' }) } // FIPSign could not check the token (result.failure is 'rate_limited', 'quota_exhausted' // or 'unavailable'). The token may be fine: do not sign the user out. if (result.retryAfter !== undefined) res.set('Retry-After', String(result.retryAfter)) return res.status(503).json({ error: 'Authentication service temporarily unavailable' }) } const { payload } = result console.log(payload.sub) // 'user_123' console.log(payload.role) // 'admin' console.log(payload.exp) // expiry Unix timestamp console.log(payload.iat) // issued at Unix timestamp // All custom fields passed to sign() are available on payload too
failure | What happened | What to do |
|---|---|---|
'rejected' | FIPSign checked the token and refuses it: revoked, expired, tampered with, from another project, malformed, or a Mandate token | Answer 401. The user must sign in again |
'rate_limited' | FIPSign did not check the token: too many requests (REST 10). retryAfter holds the seconds to wait | Answer 503 with Retry-After and try again after that time |
'quota_exhausted' | FIPSign did not check the token: no tokens left (free tokens and packs) | Answer 503. Waiting will not fix it: buy a pack from the dashboard |
'unavailable' | FIPSign did not check the token: network error, timeout, FIPSign server error, invalid API key, or an answer the SDK cannot read | Answer 503 and try again later. If result.error says the API key is invalid, fix the key |
{ valid: false, payload: null, error: "...", failure: "..." }. failure tells a token that FIPSign refuses from a check that did not happen (table above); retryAfter is only there with 'rate_limited'. What each error text means is in REST 04.mandate.emit() returns { valid: false, error: 'This is a Mandate token. Verify it with POST /mandate/verify' }. Use pq.mandate.verify() (SDK 14), which also checks status, scope and budget.
Enable localVerify: true to verify tokens entirely in memory — no API call, no network latency, no token cost.
const pq = new PQAuth({ apiKey: 'pqa_your_api_key', localVerify: true, projectId: 'proj_...', // required — from the dashboard or meta.projectId in /sign's response }) // Optional: preload public key at startup to avoid first-request latency await pq.preloadPublicKey() const { valid, payload, local } = await pq.verify(token) console.log(local) // true — verified without an API call
projectId the constructor throws MISSING_PROJECT_ID immediately.{ valid: false, error, failure }. Decide on failure (SDK 03), not on the text of error. In local mode failure is 'rejected' for a bad signature, an expired token or another project's token, and 'unavailable' when the SDK could not download the public key because FIPSign could not be reached. A token from a different project gets exactly the same error text as one with a tampered signature — intentional, so the two cases are indistinguishable to whoever presented the token.
apiKey above is still your full secret key — this only ever belongs on your own backend, never in a browser.
sign() only. It knows nothing about mandates, so a token from mandate.emit() is rejected: { valid: false, error: 'This is a Mandate token. Verify it with mandate.verify()' }. Verify those with pq.mandate.verify() (SDK 14), which checks the live status, scope and budget on the server.
Immediately and permanently invalidates a token. Future verify() calls will reject it even if the signature is valid and the token has not yet expired. Cost: 1 token.
const { success, message, revokedAt, sub, expiresAt, note } = await pq.revoke(token, 'user logged out') console.log(message) // 'Token revoked successfully' console.log(revokedAt) // Unix timestamp console.log(sub) // subject from the token payload console.log(expiresAt) // original expiry from the token console.log(note) // 'This token will be rejected on any future /verify call'
await pq.revoke(token, 'order cancelled') await pq.revoke(token, 'suspicious activity detected') const { valid } = await pq.verify(token) console.log(valid) // false — error: 'Token has been revoked'
{ success: true, message: 'Token was already revoked' } without consuming an extra token.revoke() on an already-expired token throws PQAuthError with code API_ERROR and status 400. Expired tokens cannot be submitted for revocation.
revoke() on a token from mandate.emit() throws PQAuthError with code API_ERROR, status 400: This is a Mandate token. Revoke it with PATCH /mandate/:id {"action":"revoke"}. Use pq.mandate.revoke(mandate.id) (SDK 14).
Reads Authorization: Bearer <token>, verifies the token, and attaches the decoded payload to req.user. Answers 401 when the token is missing or FIPSign refuses it, and 503 when FIPSign could not check it. Node.js only. It is an Express-style function ((req, res, next), answering with res.status(...).json(...)), so it works as it is in Express; for Fastify see below.
base64(JSON.stringify(PQToken)) — not a JWT. The login endpoint encodes the token object, and the middleware decodes it. Clients that expect a JWT will not be compatible without adapting the encoding step.
import express from 'express' import { PQAuth } from 'fipsign-sdk' const app = express() const pq = new PQAuth('pqa_your_api_key') app.use(express.json()) // Stand-in for your own user check. Replace it with your database lookup // and a real password comparison (bcrypt, argon2…). async function authenticate(email, password) { if (email === '[email protected]' && password === 'correct-horse') { return { id: 'user_123', email, role: 'admin' } } return null } // Login — sign a token and return it base64-encoded to the client app.post('/login', async (req, res) => { const user = await authenticate(req.body.email, req.body.password) if (!user) { return res.status(401).json({ error: 'Invalid credentials' }) } const { token } = await pq.sign({ sub: user.id, email: user.email, role: user.role, expiresInSeconds: 3600, }) // Encode to base64 — this is what the client puts in Authorization: Bearer <encoded> const encoded = Buffer.from(JSON.stringify(token)).toString('base64') res.json({ token: encoded }) }) // Logout — decode the header and revoke the token immediately app.post('/logout', async (req, res) => { const header = req.headers['authorization'] ?? '' if (header.startsWith('Bearer ')) { try { const token = JSON.parse(Buffer.from(header.slice(7), 'base64').toString('utf8')) await pq.revoke(token, 'user logged out') } catch { /* ignore malformed token */ } } res.json({ success: true }) }) // Protect all routes under /api with the FIPSign middleware app.use('/api', pq.middleware()) // req.user is the verified payload — sub, email, role, exp, iat, plus any custom fields app.get('/api/profile', (req, res) => { res.json({ user: req.user }) }) app.listen(3000)
TOKEN=$(curl -s -X POST localhost:3000/login -H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"correct-horse"}' | jq -r .token)
curl -s localhost:3000/api/profile -H "Authorization: Bearer $TOKEN"
# {"user":{"sub":"user_123","email":"[email protected]","role":"admin","_iss":"prj_...","iat":...,"exp":...}}
curl -s -i localhost:3000/api/profile
# HTTP/1.1 401 Unauthorized
# ...
# {"error":"Authorization header required (Bearer <token>)"}
| Request | Answer |
|---|---|
| Valid token | next() is called and req.user holds the payload |
No Authorization: Bearer ... header | 401 { "error": "Authorization header required (Bearer <token>)" } |
| The Bearer value is not the base64 of a token | 401 { "error": "Invalid token format" } |
| The token is rejected: expired, revoked, tampered with, or from another project | 401 { "error": "<the text verify() returns>" }, for example Token has been revoked |
| FIPSign could not check the token: rate limit | 503 { "error": "Authentication service temporarily unavailable" } with a Retry-After: <seconds> header |
| FIPSign could not check the token: no tokens left, timeout, network error, FIPSign server error, invalid API key | 503 { "error": "Authentication service temporarily unavailable" } (no Retry-After) |
Retry-After when it is there). The body does not say why FIPSign could not answer: to log the cause, call pq.verify(token) yourself and read error and failure (SDK 03).
req.user is the whole signed payload: your fields (sub, email, role...), iat and exp (Unix seconds), and _iss, the id of your FIPSign project, which FIPSign adds to every token. You can ignore _iss.verify(). In the default remote mode that is one call to POST /verify: it costs 1 token and it also detects revoked tokens. If you create PQAuth with localVerify: true and a projectId (see SDK 04), the middleware checks the token in memory instead: no token cost, but a revoked token is not detected.Fastify cannot use an Express-style function as a hook: app.addHook('onRequest', pq.middleware()) fails when you register it, with FST_ERR_HOOK_INVALID_ASYNC_HANDLER. Use it through @fastify/express, which adds app.use() to Fastify.
// npm install fastify @fastify/express import Fastify from 'fastify' import fastifyExpress from '@fastify/express' import { PQAuth } from 'fipsign-sdk' const app = Fastify() const pq = new PQAuth('pqa_your_api_key') await app.register(fastifyExpress) // adds app.use() for Express-style middleware app.use('/api', pq.middleware()) // The middleware sets user on Node's raw request, so in Fastify it is request.raw.user app.get('/api/profile', async (request) => { return { user: request.raw.user } }) await app.listen({ port: 3000 })
user on Node's raw request, so inside a Fastify handler it is request.raw.user (not request.user). Login and logout are the same as in the Express example: sign the token, then send Buffer.from(JSON.stringify(token)).toString('base64').
Returns the current token balance, 6-month usage history, and purchased packs. No token cost.
const { current, monthlyHistory, packs, developer } = await pq.usage() // Current balance console.log(`Month: ${current.month}`) // e.g. "2026-06" console.log(`Free: ${current.freeRemaining} / ${current.freeLimit}`) console.log(`Used: ${current.freeUsed} this month`) console.log(`Pack: ${current.packRemaining}`) console.log(`Total: ${current.totalRemaining}`) console.log(`Account: ${developer.email}`) // 6-month history (always 6 entries, months with no activity show 0) monthlyHistory.forEach(({ month, tokensUsed, fromFree, fromPack }) => { console.log(`${month}: ${tokensUsed} used (${fromFree} free + ${fromPack} pack)`) }) // Purchased packs packs.forEach(({ id, packType, tokensPurchased, purchasedAt, paymentRef }) => { console.log(`${packType}: ${tokensPurchased} tokens — ${new Date(purchasedAt * 1000).toLocaleDateString()}`) console.log(` id: ${id} — paymentRef: ${paymentRef ?? 'n/a'}`) })
Checks the health of the FIPSign service. Does not require an API key and does not consume tokens.
const { status, algorithm, quantumResistant, version } = await pq.health() console.log(status) // "ok" console.log(algorithm) // "ML-DSA-44/65/87" console.log(quantumResistant) // true console.log(version) // e.g. "2.0.0"
{ status: string, algorithm: string, standard: string, quantumResistant: boolean, version: string }. The service field is also present in the raw response but not included in the typed HealthResult.health() calls GET /health without the X-API-Key header. Safe to call from any context including health check scripts and monitoring systems.
FIPSign fires webhook events automatically when you call sign(), verify(), or revoke(). The SDK triggers them — you do not call any method to enable them.
token.signed · token.rejected · token.revoked · limit.warning · limit.reachedverify(), ca.verifyCert() and ca.verifyX509Cert() never throw — they always return { valid: false, error } on failure, with error as text (local verification errors such as an invalid signature, an expired token, another project's token, or a certificate from another CA or an expired one are reported there, not as codes). verify() also returns failure, which tells a token that FIPSign refuses from a check that did not happen (SDK 03). All other methods throw PQAuthError.
import { PQAuth, PQAuthError } from 'fipsign-sdk' try { await pq.sign({ sub: 'user_123' }) } catch (err) { if (err instanceof PQAuthError) { switch (err.code) { case 'INVALID_API_KEY': // key format invalid — must be pqa_ + 64 hex chars break case 'MISSING_PROJECT_ID': // localVerify: true without projectId break case 'API_ERROR': // server error: see err.status (429: err.serverCode) break case 'TIMEOUT': // request exceeded timeout (default: 10s) break case 'NETWORK_ERROR': // connection failed break case 'MISSING_SUB': // sign() called without sub break case 'INVALID_ARGUMENT': // signAgentCall(): missing or invalid argument break case 'INVALID_SECRET_KEY': // signAgentCall(): secretKey is not an ML-DSA private key (e.g. a 32-byte seed) break case 'UNSUPPORTED_ALGORITHM': // generateAgentKeyPair() / signAgentCall(): unknown ML-DSA variant name break } console.error(err.code, err.message, err.status) } }
err.serverCode is 'rate_limited' (wait err.retryAfter seconds, then retry) or 'token_quota_exhausted' (retrying will not help: buy a pack from the dashboard). err.retryAfter is in seconds and is undefined with any other error.
import { PQAuthError } from 'fipsign-sdk' async function signWithRetry(pq, claims) { try { return await pq.sign(claims) } catch (err) { if (err instanceof PQAuthError && err.serverCode === 'rate_limited') { // retryAfter: seconds until the one-minute window ends (from the Retry-After header) await new Promise(resolve => setTimeout(resolve, (err.retryAfter ?? 1) * 1000)) return await pq.sign(claims) // one retry; if it fails again, the error is thrown } throw err // 'token_quota_exhausted' and every other error: waiting will not fix it } }
All available options when instantiating PQAuth.
const pq = new PQAuth({ apiKey: 'pqa_...', baseUrl: 'https://api.fipsign.dev', timeout: 10_000, localVerify: false, projectId: 'proj_...', // required when localVerify: true })
| Option | Type | Default | Description |
|---|---|---|---|
| apiKey | string | — | Required. Must match pqa_ followed by 64 lowercase hex characters — constructor throws INVALID_API_KEY immediately if not. |
| baseUrl | string | https://api.fipsign.dev | Override for local development or self-hosted instances. |
| timeout | number | 10000 | Request timeout in milliseconds. Throws TIMEOUT on exceeded. |
| localVerify | boolean | false | When true, verify() runs in memory using a cached public key — no API call, no token cost. Does not check revocation. |
| projectId | string | — | Required when localVerify: true — constructor throws MISSING_PROJECT_ID immediately if not. Get it from the dashboard or meta.projectId in any /sign response. Local verification rejects tokens issued for a different project: verify() returns { valid: false, error: 'Invalid signature — token was tampered with or not issued by this server' }. |
Issue and verify post-quantum certificates for devices, services, or any entity that needs a tamper-proof identity. Built on ML-DSA-65 (NIST FIPS 204) — independent of the ML-DSA variant configured for token signing in each project.
ca.verifyCert(). Simpler to work with in JavaScript/TypeScript environments.2.16.840.1.101.3.4.3.18, RFC 9881). Compatible with OpenSSL 3.5+, standard PKI tooling, and enterprise infrastructure. Verified with ca.verifyX509Cert().import { PQAuth, generateKeyPair } from 'fipsign-sdk' const { publicKey, secretKey } = await generateKeyPair() // publicKey: base64-encoded ML-DSA-65 public key — 1952 bytes raw // secretKey: base64-encoded ML-DSA-65 expanded key — 4032 bytes raw // store secretKey securely on the device — never send it to the server // pass publicKey to ca.issue() to obtain a certificate
// ── PQCert CA ────────────────────────────────────────────────────────────── const { certificate, meta, usage } = await pq.ca.issue({ subject: 'device-serial-00123', publicKey: devicePublicKey, // base64 ML-DSA-65 public key expiresInSeconds: 365 * 24 * 60 * 60, // required — whole number, min 60, max 157_680_000 (5 years) meta: { model: 'lock-v2', batch: '2026-05' }, // optional, max 10 keys }) // certificate is a PQCert JSON object console.log(certificate.id) // cert_... console.log(certificate.caId) // ca_... — the CA that signed it console.log(certificate.expiresAt) // Unix timestamp console.log(meta.certId) // same as certificate.id console.log(meta.caId) // ca_... console.log(meta.subject) // 'device-serial-00123' console.log(meta.format) // 'pqcert' console.log(meta.issuedAt) // Unix timestamp console.log(meta.expiresAt) // Unix timestamp console.log(meta.algorithm) // 'ML-DSA-65' console.log(meta.standard) // 'NIST FIPS 204' console.log(meta.caExpiry) // null normally; { truncated: true, requestedExpiresInSeconds: N, resolvedExpiresInSeconds: N } if lifetime was truncated to fit CA root expiry // ── X.509 CA ─────────────────────────────────────────────────────────────── // Note: publicKey must be exactly 1952 bytes (ML-DSA-65 public key). // Note: meta is NOT supported for X.509 CAs — passing it returns API_ERROR 400. const { certificate: certPem, meta, usage } = await pq.ca.issue({ subject: 'device-serial-00123', publicKey: devicePublicKey, expiresInSeconds: 365 * 24 * 60 * 60, }) // certificate is a PEM string console.log(typeof certPem) // "string" console.log(certPem) // "-----BEGIN CERTIFICATE-----\n..." console.log(meta.certId) // cert_... — use this for revocation console.log(meta.caId) // ca_... console.log(meta.format) // 'x509' console.log(meta.sizeNote) // size advisory for IoT memory planning
meta.certId alongside the PEM certificate — you need it for ca.revokeCert() and ca.isCertRevoked().
import { readFileSync } from 'node:fs' import { PQAuth } from 'fipsign-sdk' const pq = new PQAuth('pqa_your_api_key') // root-cert.json: the root certificate you saved when the CA was created // device-cert.json: the certificate the device presents (a PQCert JSON object) const rootCert = JSON.parse(readFileSync('./root-cert.json', 'utf8')) const deviceCert = JSON.parse(readFileSync('./device-cert.json', 'utf8')) const result = pq.ca.verifyCert(deviceCert, rootCert) // synchronous — no await if (!result.valid) { // result.error is plain text for your logs: decide with result.valid, do not compare the text. // It is one of: // 'Expected a CA_CERT, got ...' / 'Expected a CA_ROOT, got ...' // 'Certificate was not issued by this CA' // 'Root CA certificate expired N seconds ago' // 'Certificate expired N seconds ago' // 'Invalid certificate signature — not issued by this CA' // 'Unknown error' (malformed input, for example a certificate that is not an object) console.error(result.error) throw new Error('Device not authorized') } console.log(result.cert.subject) // 'device-serial-00123' console.log(result.cert.expiresAt) // Unix timestamp
ca.verifyX509Cert() instead. Does not check revocation.
// rootCertPem is the PEM string shown at CA creation — save it once const result = await pq.ca.verifyX509Cert(deviceCertPem, rootCertPem) if (!result.valid) { // Possible error messages: // 'Root CA certificate has expired' // 'Certificate has expired' // 'Invalid certificate signature — not signed by this root CA' // 'Unsupported signature algorithm: <OID>. Expected ML-DSA-65 (2.16.840.1.101.3.4.3.18)' // 'Unsupported root CA algorithm: <OID>. Expected ML-DSA-65 (2.16.840.1.101.3.4.3.18)' // 'Unexpected public key size: N bytes (expected 1952 or 1953 for ML-DSA-65)' // 'Unexpected signature size: N bytes (expected 3309 or 3310 for ML-DSA-65)' console.error(result.error) return reject('Device not authorized') } // result.cert is the PEM string of the verified certificate console.log(result.cert) // "-----BEGIN CERTIFICATE-----\n..."
{ valid, cert? } or { valid: false, error }. Does not check revocation.
const { crl } = await pq.ca.getCrl() // PQCert CA — pass the certificate object if (pq.ca.isCertRevoked(deviceCert, crl)) { return reject('Device certificate has been revoked') } // X.509 CA — pass the certId string from meta.certId if (pq.ca.isCertRevoked(meta.certId, crl)) { return reject('Device certificate has been revoked') }
const { caId, subject, crl, generatedAt, raw } = await pq.ca.getCrl() console.log(`CA: ${subject}`) console.log(`${crl.length} revoked certificates`) crl.forEach(({ certId, revokedAt, reason }) => { // reason may be null if no reason was provided at revocation time console.log(`${certId} — revoked ${new Date(revokedAt * 1000).toISOString()} — ${reason ?? 'no reason'}`) }) // X.509 CAs only: raw contains the full signed CRL object with ML-DSA-65 signature // raw is undefined for PQCert CAs if (raw) { console.log(raw.signature) // base64 ML-DSA-65 signature over the canonical CRL }
r.crl is always a flat CrlEntry[] regardless of CA format. Use getCrl() to verify revocation status in bulk offline. For checking a single certificate in real time before a high-value operation, use ca.getCert() instead.
const { certificate, status, meta } = await pq.ca.getCert('cert_...') console.log(status.revoked) // boolean console.log(status.expired) // boolean console.log(status.revokedAt) // Unix timestamp or null console.log(status.expiresAt) // Unix timestamp // For PQCert CAs, certificate is a PQCert object // For X.509 CAs, certificate is a PEM string // For X.509 CAs, meta contains additional fields: if (meta) { console.log(meta.certId) // cert_... console.log(meta.caId) // ca_... console.log(meta.subject) // 'device-serial-00123' console.log(meta.format) // 'x509' console.log(meta.algorithm) // 'ML-DSA-65' }
const { certId, revokedAt, reason, usage } = await pq.ca.revokeCert( 'cert_...', 'device decommissioned' ) console.log(certId) // cert_... console.log(revokedAt) // Unix timestamp console.log(reason) // 'device decommissioned' (or null if omitted) console.log(usage.freeRemaining) // tokens remaining after this operation
PQAuthError with code API_ERROR and status 409. This is different from revoke() on tokens, which is idempotent.
import { readFileSync } from 'node:fs' import { PQAuth, generateKeyPair } from 'fipsign-sdk' const pq = new PQAuth('pqa_your_api_key') // root-cert.json: the root certificate you saved when the CA was created const rootCert = JSON.parse(readFileSync('./root-cert.json', 'utf8')) // 1. Factory: generate a key pair for the device const { publicKey, secretKey } = await generateKeyPair() // 2. Factory: issue a certificate for the device const { certificate } = await pq.ca.issue({ subject: 'lock-serial-00123', publicKey, expiresInSeconds: 365 * 24 * 60 * 60, meta: { model: 'lock-v3', batch: '2026-05' }, }) // store certificate (PQCert JSON object) and secretKey on the device // 3. At runtime: verify the device certificate offline const result = pq.ca.verifyCert(certificate, rootCert) if (!result.valid) throw new Error(result.error) // 4. At runtime: check the device is not revoked const { crl } = await pq.ca.getCrl() if (pq.ca.isCertRevoked(certificate, crl)) throw new Error('Device revoked') // 5. Decommission: revoke the certificate await pq.ca.revokeCert(certificate.id, 'device decommissioned')
import { PQAuth, generateKeyPair } from 'fipsign-sdk' const pq = new PQAuth('pqa_your_api_key') const rootCertPem = process.env.ROOT_CERT_PEM // PEM saved at CA creation // 1. Factory: generate a key pair for the device const { publicKey, secretKey } = await generateKeyPair() // 2. Factory: issue a certificate for the device const { certificate: certPem, meta } = await pq.ca.issue({ subject: 'lock-serial-00123', publicKey, expiresInSeconds: 365 * 24 * 60 * 60, }) // store certPem (PEM string), meta.certId, and secretKey on the device // 3. At runtime: verify the device certificate offline const result = await pq.ca.verifyX509Cert(certPem, rootCertPem) if (!result.valid) throw new Error(result.error) // 4. At runtime: check the device is not revoked const { crl } = await pq.ca.getCrl() if (pq.ca.isCertRevoked(meta.certId, crl)) throw new Error('Device revoked') // 5. Decommission: revoke the certificate using the certId from meta await pq.ca.revokeCert(meta.certId, 'device decommissioned')
Hash sensitive data locally and sign only the hash — the original data never reaches the API. Convenience wrapper over sign()/verify(); see REST 03b for the full explanation of the pattern.
const { token, hash } = await pq.zes.sign({ patient: 'Jane Doe', diagnosis: 'confidential', record_id: 'MR-2026-4491', }) // hash: the 64-char SHA-256 hex digest that was actually signed // token.payload decodes to { sub: 'zes:<hash>', zes: true, iat, exp }
const { valid, dataMatches } = await pq.zes.verify(token, { patient: 'Jane Doe', diagnosis: 'confidential', record_id: 'MR-2026-4491', }) if (!valid || !dataMatches) return reject('invalid or tampered')
valid is false, failure and retryAfter say why, exactly as in verify() (SDK 03): 'rejected' means FIPSign refuses the token; any other value means the check did not happen. dataMatches is false whenever valid is false.
revoke(), middleware(), and local verification all work unchanged — a ZES token is a regular token as far as they're concerned. There is no zes.revoke(): use pq.revoke(token) directly, same as any other token. zes.verify() also respects localVerify: true — it calls the same verify() internally, so it inherits offline verification with the same trade-off (no revocation check).
Bounded, revocable authorization for AI agents, IoT devices, and automated services. Thin typed wrapper over POST/PATCH/GET /mandate — see Mandate 00 for the full explanation of the immutable/mutable layers, lifecycle, and budget semantics. No dashboard setup needed beyond an API key — unlike CA, there's no separate resource to create first.
| Who | Holds | Calls |
|---|---|---|
| Your backend | Project API key (full access) | mandate.emit(), narrow(), suspend(), resume(), revoke(), get(), list() — every developer-side operation. |
| The agent (only with proof of possession) | The mandate token and its own private key | generateAgentKeyPair() once, then signAgentCall() for every call. No API key needed: nothing is sent to FIPSign. |
| Whoever performs the action (your executor — or the agent itself) | An API key and the mandate token | mandate.verify(), right before each action. If it is the agent itself, give it an agent key (restricted scope), never a full-access key. |
mandate.verify() and works out cost itself, so the agent never holds an API key and the budget can be trusted. Letting the agent call verify() itself also works — Mandate 00b explains both architectures and what each one protects.
const { mandate } = await pq.mandate.emit({ agentId: 'agent-reporting-v2', issuedBy: '[email protected]', scope: ['sign', 'verify', 'read:crm'], budgetTotal: 1000, // abstract units — 0 disables budget checking expiresInSeconds: 28800, // whole number, min 60, max 2_592_000 (30 days) }) console.log(mandate.id) // mdt_... — use for narrow/suspend/resume/revoke/get console.log(mandate.status) // 'active' const mandateToken = mandate.token // give this to the agent — not stored server-side, save it now
Optional: pass agentPublicKey to require proof of possession — see Proof of possession with the SDK below. The response then carries requiresAgentSignature: true.
const check = await pq.mandate.verify(mandateToken, 'read:crm', 1) // (token, action, cost) // Mandates with proof of possession take the agent's signature as a 4th argument — see below if (check.result !== 'granted') { // check.reason: 'scope_not_authorized' | 'budget_exhausted' | // 'mandate_suspended' | 'mandate_revoked' | 'mandate_expired' | // 'invalid_signature' | // 'agent_signature_required' | 'agent_signature_invalid' | // 'agent_signature_mismatch' | 'agent_signature_replayed' (proof of possession, see below) | // or the real backend error message (e.g. invalid API key, rate limit, token quota) return reject(check.reason) } // granted — check.budgetRemaining and check.expiresInSeconds are also present doTheAction()
mandate.verify() never throws — every failure, including network errors and an invalid API key, comes back as { result: 'denied', reason }, never an exception. There is also no localVerify mode here, unlike pq.verify(): budget and scope are live, mutable state that can only be checked against the server — the signature alone isn't enough to answer "is this action still authorized right now."pq is an agent key (restricted scope), only mandate.verify() above will work. mandate.emit(), narrow(), suspend(), resume(), revoke(), get(), and list() below are all developer-side operations and will fail with an invalid-key error if called with an agent key — use your normal (full access) key for those.
cost. The third argument of verify() is enforced exactly as given — FIPSign does not know what an action really costs. Compute it in the code that performs the action, not in the agent. See Mandate 00b.{ result: 'denied', reason: 'Request timed out' } or reason: 'Network error: …', but the server may have granted the call and consumed budget before the connection dropped. Treat it as "do not act". Before retrying a mandate without proof of possession, compare budgetConsumed from mandate.get(id) with the value you had; with proof of possession, re-send the same agentSignature (see below). Details in Mandate 02c.
generateAgentKeyPair() fails with crypto is not defined.
import { generateAgentKeyPair } from 'fipsign-sdk' // On the agent, once. The default variant is ML-DSA-65. const { publicKey, secretKey, algorithm } = await generateAgentKeyPair() // Another variant: await generateAgentKeyPair({ algorithm: 'ML-DSA-44' }) // or 'ML-DSA-87' // publicKey → goes to your backend, for mandate.emit({ agentPublicKey }) // secretKey → stays on the agent, in a secret store. Never send it anywhere.
| Variant | publicKey | secretKey | Signature |
|---|---|---|---|
ML-DSA-44 | 1312 bytes | 2560 bytes | 2420 bytes |
ML-DSA-65 (default) | 1952 bytes | 4032 bytes | 3309 bytes |
ML-DSA-87 | 2592 bytes | 4896 bytes | 4627 bytes |
generateAgentKeyPair({ algorithm: 'ML-DSA-44' }). Any other text ('ml-dsa-44', 'ML-DSA44'…) throws UNSUPPORTED_ALGORITHM.generateAgentKeyPair() for agents and generateKeyPair() only for CA certificates. ca.issue() accepts ML-DSA-65 keys only, so a key of another variant is rejected there (HTTP 400, nothing is charged).secretKey secret. Store it in a secret store (or a file only the agent's user can read), never log it, never send it anywhere. If it is lost or may have leaked, revoke the mandate and emit a new one with a new key pair: the public key of a mandate can never be changed.secretKey is the full expanded private key (2560, 4032 or 4896 bytes, base64), not the 32-byte seed. The recipes in Mandate 02b and the Python SDK store the 32-byte seed instead: the two formats are not interchangeable, and signAgentCall() rejects a seed with INVALID_SECRET_KEY. Generate and sign with the same tool.
const { mandate } = await pq.mandate.emit({ agentId: 'agent-reporting-v2', issuedBy: '[email protected]', scope: ['sign', 'send_reply'], budgetTotal: 1000, expiresInSeconds: 28800, agentPublicKey: publicKey, // base64, from generateAgentKeyPair() }) console.log(mandate.requiresAgentSignature) // true // Keep mandate.id in your database: you need it to narrow, suspend, revoke and inspect. const mandateToken = mandate.token // give this to the agent
A key of the wrong size is rejected with HTTP 400 and nothing is charged, so a mandate that could never be verified cannot be created by mistake. Only the public key travels to your backend; it is stored once and can never change.
import { signAgentCall } from 'fipsign-sdk' // On the agent, for EVERY call: no API key needed, nothing is sent to FIPSign. const agentSignature = await signAgentCall({ mandate: mandateToken, // the mandate token (or just the mandate id) action: 'send_reply', // exactly what mandate.verify() will receive cost: 1, // exactly what mandate.verify() will receive secretKey, // from generateAgentKeyPair() }) // Send agentSignature to whoever calls mandate.verify() — your executor, or the agent itself
signAgentCall() reads the variant from the size of secretKey, signs with it and fills in agentSignature.algorithm itself ('ML-DSA-44', 'ML-DSA-65' or 'ML-DSA-87'). Send the object it returns as it is; do not edit it. If you pass algorithm anyway (it is only a safety check), it must be exactly one of those three texts, capitals and hyphens included: anything else throws UNSUPPORTED_ALGORITHM, and a valid text that is not the variant of secretKey throws INVALID_SECRET_KEY. You write the text yourself only when you call the REST API by hand: see the table in Mandate 02b, Step 1.action and cost must be exactly what you pass to verify(). Change one and you must sign again, otherwise verify() answers agent_signature_mismatch.expiresInSeconds, at most 60) and the server accepts it once. Sign again for every attempt; never cache signatures.signAgentCall(). The server accepts a signature stamped up to 30 seconds ahead of its own clock, so with the default lifetime the agent's clock may be off by about 30 seconds either way. Keep clocks synchronized (NTP). On devices where you cannot, pass expiresInSeconds: 60: it tolerates an agent clock up to 60 seconds behind.
const check = await pq.mandate.verify(mandateToken, 'send_reply', 1, { agentSignature }) if (check.result !== 'granted') { // check.reason also covers the agent's signature: // 'agent_signature_required' | 'agent_signature_invalid' | // 'agent_signature_mismatch' | 'agent_signature_replayed' return reject(check.reason) } doTheAction()
agentSignature while it is still valid: granted means the first attempt had not been applied and now it is, exactly once; agent_signature_replayed means the first attempt was applied and its cost is already counted — perform the action and do not retry again. Once the signature has expired you can no longer tell: compare budgetConsumed in mandate.get(id). More in Mandate 02c.
| You see | Typical cause | Fix |
|---|---|---|
agent_signature_required (in reason) | verify() was called without { agentSignature } on a mandate emitted with agentPublicKey. | Sign the call and pass it as the 4th argument. |
agent_signature_invalid | Signed with a different key than the mandate's agentPublicKey; lifetime above 60 seconds; signature already expired; agent clock more than 30 seconds ahead of the server. | Sign with the secretKey of the pair whose publicKey you emitted; keep the default lifetime; synchronize clocks. |
agent_signature_mismatch | The action or cost that was signed differ from the ones passed to verify(). | Sign exactly the values you verify. Change one and you must sign again. |
agent_signature_replayed | This exact signature was already accepted. | Sign again for every call. |
PQAuthError INVALID_ARGUMENT | signAgentCall() got a missing or invalid mandate, action (non-empty), cost (whole number ≥ 0), secretKey or expiresInSeconds (≥ 1). | Fix the argument; err.message names it. |
PQAuthError INVALID_SECRET_KEY | secretKey is not valid base64, is not an ML-DSA private key (for example a 32-byte seed) or does not match algorithm. | Use the secretKey returned by generateAgentKeyPair() and omit algorithm. |
PQAuthError UNSUPPORTED_ALGORITHM | The algorithm name given to generateAgentKeyPair() or signAgentCall() is unknown. | Use ML-DSA-44, ML-DSA-65 or ML-DSA-87, written exactly like that (capitals, hyphens, no spaces) — or omit it when signing. |
import { PQAuthError, signAgentCall } from 'fipsign-sdk' try { await signAgentCall({ mandate: mandateToken, action: 'send_reply', cost: 1, secretKey }) } catch (err) { if (err instanceof PQAuthError) console.error(err.code, err.message) }
Generates a key pair, emits a mandate that requires it and runs the four interesting cases against the live API. Save as pop-sdk-demo.mjs, run npm install fipsign-sdk, then FIPSIGN_API_KEY=pqa_... node pop-sdk-demo.mjs (Node 20+). Cost: 4 platform tokens — the emit (2) and the one granted verify (2); denied calls are free.
// pop-sdk-demo.mjs — proof of possession with the SDK, end to end. // Setup: npm install fipsign-sdk (Node 20+) // Run: FIPSIGN_API_KEY=pqa_... node pop-sdk-demo.mjs import { PQAuth, generateAgentKeyPair, signAgentCall } from 'fipsign-sdk' const pq = new PQAuth(process.env.FIPSIGN_API_KEY) // ── AGENT SIDE ────────────────────────────────────────────────────────── // The agent creates its own key pair. The secret key never leaves the agent. const { publicKey, secretKey } = await generateAgentKeyPair() // ── YOUR BACKEND ──────────────────────────────────────────────────────── // 1. Emit the mandate, registering the agent's PUBLIC key. const { mandate } = await pq.mandate.emit({ agentId: 'agent-pop-demo', issuedBy: '[email protected]', scope: ['sign'], budgetTotal: 10, expiresInSeconds: 3600, agentPublicKey: publicKey, }) console.log('emitted', mandate.id, '· requiresAgentSignature:', mandate.requiresAgentSignature) // 2. Without agentSignature the mandate token alone is useless. let r = await pq.mandate.verify(mandate.token, 'sign', 1) console.log('no signature →', r.result, r.reason) // 3. With the agent's signature over THIS call: granted. const signature = await signAgentCall({ mandate: mandate.token, action: 'sign', cost: 1, secretKey }) r = await pq.mandate.verify(mandate.token, 'sign', 1, { agentSignature: signature }) console.log('with signature →', r.result, '· budgetRemaining:', r.budgetRemaining) // 4. Sending the same signature again is refused: each signature works once. r = await pq.mandate.verify(mandate.token, 'sign', 1, { agentSignature: signature }) console.log('same signature →', r.result, r.reason) // 5. A signature made for cost 1 cannot be used for cost 9. const other = await signAgentCall({ mandate: mandate.token, action: 'sign', cost: 1, secretKey }) r = await pq.mandate.verify(mandate.token, 'sign', 9, { agentSignature: other }) console.log('cost changed →', r.result, r.reason) // Clean up: revoke the demo mandate (free). await pq.mandate.revoke(mandate.id)
emitted mdt_… · requiresAgentSignature: true no signature → denied agent_signature_required with signature → granted · budgetRemaining: 9 same signature → denied agent_signature_replayed cost changed → denied agent_signature_mismatch
await pq.mandate.narrow(mandate.id, ['read:crm']) // shrink scope — monotonic, cannot re-widen await pq.mandate.suspend(mandate.id) // pause — verify() denies with 'mandate_suspended' while suspended await pq.mandate.resume(mandate.id) // reactivate a suspended mandate await pq.mandate.revoke(mandate.id) // permanent — irreversible, no narrow/suspend/resume after this
const patched = await pq.mandate.suspend(mandate.id) console.log(patched.status, patched.scope, patched.updatedAt) // 'suspended' [ 'sign', 'verify', 'read:crm' ] 1785691318
narrow(), suspend(), resume() and revoke() return { success, id, status, scope, updatedAt }: the new status, the scope after the change and updatedAt (Unix seconds). Suspending a mandate that is already suspended returns only success, id, status and message: 'Already suspended'.
verify() always returns reason: 'mandate_suspended' — the backend checks status before it ever looks at the budget counter.
const { mandate } = await pq.mandate.get('mdt_...') console.log(mandate.budgetConsumed, mandate.budgetRemaining, mandate.scopeCurrent, mandate.status) // First page: the 50 most recent mandates of this project const { mandates, count, nextCursor } = await pq.mandate.list() // Your own page size (1–100), then the next page const page1 = await pq.mandate.list({ limit: 20 }) const page2 = page1.nextCursor ? await pq.mandate.list({ limit: 20, cursor: page1.nextCursor }) : null // Every mandate, page by page — stop whenever you want with break for await (const m of pq.mandate.listAll()) { console.log(m.id, m.status, m.budgetConsumed) }
list() returns one page — up to 50 mandates by default (limit: 1–100), most recent first. nextCursor is a string while more pages exist and null on the last one: pass it back as cursor, exactly as received. count is the size of this page, not a total (the API has no total). A page can hold fewer than limit mandates — even none — while nextCursor is not null, so drive your loop with the cursor, never with the page size. listAll() does exactly that for you, one page at a time. Keep the id of every mandate you emit rather than listing to find it.expired status: check expiresInSeconds (it is 0 once the mandate has expired). An expired mandate can still be read for 24 hours, then get() throws PQAuthError with status 404.narrow(), suspend(), resume(), revoke(), get() and emit() throw PQAuthError with code API_ERROR and the HTTP status: 409 when the state does not allow the change (already revoked, expired, not suspended), 404 when the mandate does not exist, 400 for invalid input. Revoking twice returns 409 — treat it as success when retrying.
Flask · FastAPI · Django · Scripts · Python 3.9+ · Type hints included.
Base URL: https://api.fipsign.dev · Package: fipsign-sdk
Works in Flask, FastAPI, Django, scripts, and any Python 3.9+ environment. Type hints included — no separate stubs needed. cryptography>=48.0.0 is included as an automatic dependency — no extra install needed for CA operations or key generation.
pip install fipsign-sdk
pip install fipsign-sdk[async]
mandate.verify() — every other method in this SDK (sign(), verify(), ca.*, even the other mandate.* methods) will fail with an invalid-key error if called with an agent key. See Mandate 00.
from fipsign import PQAuth pq = PQAuth("pqa_your_api_key") # Or with all options (see PY 10 for full reference): # pq = PQAuth( # api_key="pqa_your_api_key", # base_url="https://api.fipsign.dev", # default # timeout=10, # seconds, default 10 # session=None, # custom requests.Session # )
Signs any payload with ML-DSA (44, 65, or 87 — configured per project at creation). The only required argument is sub — any string identifying the entity. All other keyword arguments are stored in the payload and returned on verify. Cost: 1 token.
result = pq.sign("user_123", email="[email protected]", role="admin", expires_in_seconds=3600) token = result.token meta = result.meta usage = result.usage
result = pq.sign("order_456", amount=299.99, currency="USD", expires_in_seconds=300)
result = pq.sign("doc_789", hash="sha256:abc...", signed_by="alice")
result = pq.sign(
"agent_summarizer_v2",
action="document:summarize",
user_id="user_123",
trace_id="trace_abc",
)
result = pq.sign("device_iot_001", firmware="2.1.4", location="plant-A")
result = pq.sign("user_123") print(f"{result.usage.freeRemaining} free · {result.usage.packRemaining} pack") print(f"charged from: {result.meta.source}") # "free" | "pack" | "free+pack" print(f"project: {result.meta.projectId} · account: {result.meta.issuedFor}")
sub is required, max 128 characters. Other string fields max 256 characters. Maximum 10 custom keyword arguments (not counting expires_in_seconds). Exceeding these returns PQAuthError(code="API_ERROR", status=400).expires_in_seconds uses the backend default of 3600 seconds (1 hour).60.5, raises PQAuthError(code="API_ERROR", status=400).
_, for example _mandate) are reserved for the server. sign() raises PQAuthError(code="API_ERROR", status=400) with the message Custom fields starting with "_" are reserved.
Verifies the ML-DSA signature (algorithm read from the token itself), token expiry, and the revocation list. Never raises — always returns a VerifyResult. Cost: 1 token.
result = pq.verify(token) if not result.valid: if result.failure == "rejected": # FIPSign checked the token and refuses it. result.error is one of these (text for your # logs: decide on result.failure, do not compare the text): # "Token has been revoked" # "Token expired N seconds ago" # "Invalid signature — token was tampered with or not issued by this server" # "Invalid signature — wrong length for ML-DSA-65 (expected 3309 bytes)" # "Invalid token — signature is not valid base64" # "Unsupported algorithm: X" # "Token payload exceeds the maximum of 16384 characters" # "This is a Mandate token. Verify it with POST /mandate/verify" raise PermissionError("Unauthorized") # FIPSign could not check the token (result.failure is "rate_limited", "quota_exhausted" # or "unavailable"). The token may be fine: do not sign the user out. # With "rate_limited", result.retry_after is the seconds to wait (None otherwise). raise RuntimeError(f"FIPSign could not check the token: {result.error}") print(result.payload["sub"]) # "user_123" print(result.payload["exp"]) # expiry timestamp (Unix) print(result.payload["iat"]) # issued at timestamp (Unix) # All custom fields passed to sign() are in payload too
failure | What happened | What to do |
|---|---|---|
"rejected" | FIPSign checked the token and refuses it: revoked, expired, tampered with, from another project, malformed, or a Mandate token | Answer 401. The user must sign in again |
"rate_limited" | FIPSign did not check the token: too many requests (REST 10). retry_after holds the seconds to wait | Answer 503 with Retry-After and try again after that time |
"quota_exhausted" | FIPSign did not check the token: no tokens left (free tokens and packs) | Answer 503. Waiting will not fix it: buy a pack from the dashboard |
"unavailable" | FIPSign did not check the token: network error, timeout, FIPSign server error, invalid API key, or an answer the SDK cannot read | Answer 503 and try again later. If result.error says the API key is invalid, fix the key |
VerifyResult(valid=False, payload=None, error="...", failure="..."). failure tells a token that FIPSign refuses from a check that did not happen (table above); retry_after is only there with "rate_limited". What each error text means is in REST 04.localVerify: true. Cost is 1 token per call.
mandate.emit() returns VerifyResult(valid=False, error="This is a Mandate token. Verify it with POST /mandate/verify"). Use pq.mandate.verify() (PY 13), which also checks status, scope and budget.
Immediately and permanently invalidates a token. Future verify() calls will reject it even if the signature is valid and it hasn't expired. Cost: 1 token.
result = pq.revoke(token, "user logged out") print(result.message) # "Token revoked successfully" print(result.revokedAt) # Unix timestamp print(result.sub) # subject from the token payload print(result.expiresAt) # original expiry from the token print(result.note) # "This token will be rejected on any future /verify call"
pq.revoke(token, "order cancelled") pq.revoke(token, "suspicious activity detected")
RevokeResult(success=True, message="Token was already revoked") without consuming an extra token.revoke() on an already-expired token raises PQAuthError(code="API_ERROR", status=400). Expired tokens cannot be submitted for revocation.
revoke() on a token from mandate.emit() raises PQAuthError(code="API_ERROR", status=400): This is a Mandate token. Revoke it with PATCH /mandate/:id {"action":"revoke"}. Use pq.mandate.revoke(mandate.id) (PY 13).
Reads Authorization: Bearer <token> and attaches the decoded payload to the request context. Returns 401 when the token is missing or FIPSign refuses it, and 503 when FIPSign could not check it.
base64(json.dumps(token.to_dict())) — not a JWT. The login endpoint encodes the PQToken object, and the middleware decodes it. Clients that expect a JWT will not be compatible without adapting the encoding step.
from flask import Flask, g, request from fipsign import PQAuth, flask_middleware import base64, json from fipsign.types import PQToken app = Flask(__name__) pq = PQAuth("pqa_your_api_key") auth = flask_middleware(pq) # Stand-in for your own user check. Replace it with your database lookup # and a real password comparison (bcrypt, argon2…). def authenticate(email, password): if email == "[email protected]" and password == "correct-horse": return {"id": "user_123", "email": email, "role": "admin"} return None @app.route("/login", methods=["POST"]) def login(): body = request.get_json(silent=True) or {} user = authenticate(body.get("email"), body.get("password")) if user is None: return {"error": "Invalid credentials"}, 401 result = pq.sign( user["id"], email=user["email"], role=user["role"], expires_in_seconds=3600, ) # Encode to base64 — this is what the client puts in Authorization: Bearer <encoded> encoded = base64.b64encode(json.dumps(result.token.to_dict()).encode()).decode() return {"token": encoded} @app.route("/logout", methods=["POST"]) def logout(): header = request.headers.get("Authorization", "") if header.startswith("Bearer "): try: token = PQToken.from_dict(json.loads(base64.b64decode(header[7:]).decode())) pq.revoke(token, "user logged out") except Exception: pass # ignore malformed token return {"success": True} @app.route("/api/profile") @auth def profile(): return {"user": g.fipsign_user}
from fastapi import FastAPI, Depends from fipsign import PQAuth, fastapi_middleware app = FastAPI() pq = PQAuth("pqa_your_api_key") require_auth = fastapi_middleware(pq) @app.get("/api/profile") def profile(user=Depends(require_auth)): return {"sub": user["sub"], "role": user.get("role")}
| Request | Flask answer | FastAPI answer |
|---|---|---|
| Valid token | your function runs; g.fipsign_user holds the payload | your function runs; user is the payload |
No Authorization: Bearer ... header | 401 {"error": "Authorization header required (Bearer <token>)"} | 401 {"detail": "Authorization header required"} |
| The Bearer value is not the base64 of a token | 401 {"error": "Invalid token format"} | 401 {"detail": "Invalid token format"} |
| The token is rejected: expired, revoked, tampered with, or from another project | 401 {"error": "<the text verify() returns>"} | 401 {"detail": "<the text verify() returns>"} |
| FIPSign could not check the token: rate limit | 503 {"error": "Authentication service temporarily unavailable"} with a Retry-After: <seconds> header | 503 {"detail": "Authentication service temporarily unavailable"} with a Retry-After: <seconds> header |
| FIPSign could not check the token: no tokens left, timeout, network error, FIPSign server error, invalid API key | 503 {"error": "Authentication service temporarily unavailable"} (no Retry-After) | 503 {"detail": "Authentication service temporarily unavailable"} (no Retry-After) |
Retry-After when it is there). The body does not say why FIPSign could not answer: to log the cause, call pq.verify(token) yourself and read error and failure (PY 03). The Flask decorator and the FastAPI dependency both answer this way. flask_middleware(pq) returns a decorator, so put @auth under @app.route on every route you want protected.g.fipsign_user / user) is the whole signed payload: your fields (sub, email, role...), iat and exp (Unix seconds), and _iss, the id of your FIPSign project, which FIPSign adds to every token. You can ignore _iss.POST /verify, which costs 1 token and also detects revoked tokens. The Python SDK has no local verification. The Bearer value is large because it carries the whole signed token: about 4.5 KB with ML-DSA-44, 6 KB with ML-DSA-65 and 8.3 KB with ML-DSA-87. If a proxy or load balancer sits in front of your server, check its header limit (nginx's default is 8 KB per header line).
All methods are identical to PQAuth but async. Use in FastAPI, aiohttp, or any asyncio-based application. Requires pip install fipsign-sdk[async].
import asyncio import json from fipsign.async_client import AsyncPQAuth from fipsign import generate_key_pair from fipsign.types import PQCert async def main(): async with AsyncPQAuth("pqa_your_api_key") as pq: signed = await pq.sign("user_123", role="admin", expires_in_seconds=3600) v = await pq.verify(signed.token) print(v.valid, v.payload["sub"]) # CA operations — network calls are async kp = generate_key_pair() # generate_key_pair() is synchronous cert = await pq.ca.issue( subject="device-serial-00123", public_key=kp.publicKey, expires_in_seconds=365 * 24 * 60 * 60, ) crl = await pq.ca.get_crl() # verify_cert() and verify_x509_cert() are synchronous even in AsyncPQAuth # — they are pure in-memory operations with no I/O with open("root-cert.json") as f: # the root certificate you saved when the CA was created root_cert = PQCert.from_dict(json.load(f)) check = pq.ca.verify_cert(cert.certificate, root_cert) # PQCert CA — no await # X.509 CA: pq.ca.verify_x509_cert(cert.certificate, root_pem), with the PEM saved at CA creation if not check.valid: raise PermissionError(check.error) if pq.ca.is_cert_revoked(cert.meta.certId, crl.crl): raise PermissionError("Device revoked") asyncio.run(main())
PQAuth and AsyncPQAuth — they perform pure in-memory cryptographic operations with no network I/O. Do not use await with them.
Returns the current token balance, 6-month usage history, and purchased packs. No token cost.
u = pq.usage() # Current balance print(f"Month: {u.current.month}") print(f"Free: {u.current.freeRemaining} / {u.current.freeLimit}") print(f"Used: {u.current.freeUsed} this month") print(f"Pack: {u.current.packRemaining}") print(f"Total: {u.current.totalRemaining}") print(f"Account: {u.developer['email']}") # 6-month history (always 6 entries, months with no activity show 0) for entry in u.monthlyHistory: print(f"{entry.month}: {entry.tokensUsed} used ({entry.fromFree} free + {entry.fromPack} pack)") # Purchased packs from datetime import datetime for pack in u.packs: date = datetime.fromtimestamp(pack.purchasedAt).strftime("%Y-%m-%d") print(f"{pack.packType}: {pack.tokensPurchased} tokens — {date}") print(f" id: {pack.id} — paymentRef: {pack.paymentRef or 'n/a'}")
FIPSign fires webhook events automatically when you call sign(), verify(), or revoke(). The SDK triggers them — you do not call any method to enable them.
token.signed · token.rejected · token.revoked · limit.warning · limit.reachedUse verify_webhook_signature() from fipsign.middleware to verify the HMAC-SHA256 signature on incoming webhook requests. Each POST from FIPSign includes X-PQAuth-Signature (sha256=...), X-PQAuth-Event, and X-PQAuth-Timestamp headers.
from fipsign.middleware import verify_webhook_signature # Flask @app.route("/webhooks/fipsign", methods=["POST"]) def webhook(): from flask import request sig = request.headers.get("X-PQAuth-Signature", "") if not verify_webhook_signature(request.data, sig, FIPSIGN_WEBHOOK_SECRET): return "Invalid signature", 401 event = request.json kind = event["event"] if kind == "token.signed": pass elif kind == "token.rejected": pass elif kind == "token.revoked": pass elif kind == "limit.warning": pass elif kind == "limit.reached": pass return "ok", 200 # FastAPI from fastapi import Request, HTTPException @app.post("/webhooks/fipsign") async def webhook(request: Request): body = await request.body() sig = request.headers.get("X-PQAuth-Signature", "") if not verify_webhook_signature(body, sig, FIPSIGN_WEBHOOK_SECRET): raise HTTPException(status_code=401, detail="Invalid signature") event = await request.json() return "ok"
verify() never raises — it returns VerifyResult(valid=False, error="...", failure="...") on any failure (PY 03). All other methods raise PQAuthError.
from fipsign import PQAuth, PQAuthError try: result = pq.sign("user_123") except PQAuthError as err: if err.code == "INVALID_API_KEY": # key missing or doesn't match pqa_ + 64 hex chars pass elif err.code == "API_ERROR": # server returned an error (check err.status) if err.status == 429: # err.server_code is "rate_limited" (wait err.retry_after seconds) # or "token_quota_exhausted" pass elif err.code == "TIMEOUT": # request exceeded timeout (default: 10s) pass elif err.code == "NETWORK_ERROR": # connection failed pass elif err.code == "MISSING_SUB": # sign() called without sub pass print(err.code, err.message, err.status)
| err.status | Meaning |
|---|---|
| 400 | Invalid parameters — expires_in_seconds out of range or with decimals, invalid public key, meta passed to X.509 CA, expired token submitted for revocation |
| 401 | API key missing or invalid |
| 404 | Resource not found — no active CA for the project, certificate does not exist |
| 409 | Conflict — revoking an already-revoked certificate |
| 429 | Token quota exhausted or rate limit exceeded. err.server_code says which: "rate_limited" (wait err.retry_after seconds, then retry) or "token_quota_exhausted" (retrying will not help) |
import time from fipsign import PQAuthError def sign_with_retry(pq, sub): try: return pq.sign(sub) except PQAuthError as err: if err.server_code == "rate_limited": # retry_after: seconds until the one-minute window ends (from the Retry-After header) time.sleep(err.retry_after or 1) return pq.sign(sub) # one retry; if it fails again, the error is raised raise # "token_quota_exhausted" and every other error: waiting will not fix it
pq = PQAuth(
api_key="pqa_...", # required — pqa_ + 64 lowercase hex chars
base_url="https://api.fipsign.dev", # optional, override for self-hosting
timeout=10, # optional, seconds (default: 10)
session=None, # optional, custom requests.Session
)
| Option | Type | Default | Description |
|---|---|---|---|
| api_key | str | — | Required. Must match pqa_ followed by 64 lowercase hex characters. Raises INVALID_API_KEY immediately if the format doesn't match. |
| base_url | str | https://api.fipsign.dev | Override for local dev or self-hosted instances. |
| timeout | float | 10 | Request timeout in seconds (not milliseconds — unlike the JS SDK). Raises TIMEOUT on exceeded. |
| session | requests.Session | None | Custom session for proxies, custom TLS, or testing. |
Issue post-quantum certificates for devices, services, or any entity that needs a tamper-proof identity. Built on ML-DSA-65 (NIST FIPS 204) — independent of the ML-DSA variant configured for token signing in each project.
from fipsign import generate_key_pair kp = generate_key_pair() # kp.publicKey — base64(1952 bytes raw public key) — pass to ca.issue() # kp.secretKey — base64(32 bytes, seed form) — store securely on the device
generateKeyPair(). The formats are not interchangeable. Use this function when the device runs Python; use the JS SDK's generateKeyPair() if the device runs JavaScript.from cryptography.hazmat.primitives.asymmetric.mldsa import MLDSA65PrivateKey import base64 private_key = MLDSA65PrivateKey.from_seed_bytes(base64.b64decode(kp.secretKey)) signature = private_key.sign(message) # bytes — 3309 bytes for ML-DSA-65
# ── PQCert CA ────────────────────────────────────────────────────────────── result = pq.ca.issue( subject="device-serial-00123", public_key=kp.publicKey, expires_in_seconds=365 * 24 * 60 * 60, # required — whole number, min 60, max 157_680_000 (5 years) meta={"model": "lock-v2", "batch": "2026-05"}, # optional, max 10 keys ) # result.certificate is a PQCert dataclass print(result.certificate.id) # cert_... print(result.certificate.caId) # ca_... print(result.certificate.expiresAt) # Unix timestamp print(result.meta.certId) # same as certificate.id print(result.meta.format) # "pqcert" print(result.meta.caExpiry) # None normally; CaExpiry(truncated=True, requestedExpiresInSeconds=N, resolvedExpiresInSeconds=N) if lifetime was truncated to fit CA root expiry # ── X.509 CA ─────────────────────────────────────────────────────────────── # Note: publicKey must be exactly 1952 bytes (ML-DSA-65 public key). # Note: meta is NOT supported for X.509 CAs — passing it returns API_ERROR 400. result = pq.ca.issue( subject="device-serial-00123", public_key=kp.publicKey, expires_in_seconds=365 * 24 * 60 * 60, ) # result.certificate is a PEM string print(type(result.certificate)) # <class 'str'> print(result.certificate[:27]) # "-----BEGIN CERTIFICATE-----" print(result.meta.certId) # cert_... — use this for revocation print(result.meta.caId) # ca_... print(result.meta.expiresAt) # Unix timestamp print(result.meta.format) # "x509"
result.meta.certId alongside the PEM certificate — you need it for ca.revoke_cert() and ca.is_cert_revoked().
import json from fipsign.types import PQCert with open("root-cert.json") as f: root_cert = PQCert.from_dict(json.load(f)) result = pq.ca.verify_cert(device_cert, root_cert) if not result.valid: # Possible error messages: # "Expected a CA_CERT certificate" # "Expected a CA_ROOT certificate" # "Certificate was not issued by this CA (caId mismatch)" # "Root CA certificate has expired" # "Certificate has expired" # "Invalid certificate signature" raise PermissionError(result.error) print(result.cert.subject) # "device-serial-00123" print(result.cert.expiresAt) # Unix timestamp
ca.get_crl() and ca.is_cert_revoked() for that. The error text is for your logs: decide with result.valid and do not compare the text (the JS SDK words these messages differently).meta are covered by the ML-DSA-65 signature. Altering any field after issuance will cause verify_cert() to reject the certificate.
import os root_pem = os.environ["FIPSIGN_ROOT_CERT_PEM"] # PEM saved at CA creation result = pq.ca.verify_x509_cert(cert_pem, root_pem) if not result.valid: # Possible error messages: # "Root CA certificate has expired" # "Certificate has expired" # "Invalid certificate signature — not signed by this root CA" # "Unsupported signature algorithm: <OID>. Expected ML-DSA-65 (2.16.840.1.101.3.4.3.18)" # "Unsupported root CA algorithm: <OID>. Expected ML-DSA-65 (2.16.840.1.101.3.4.3.18)" raise PermissionError(result.error) print(result.cert[:27]) # "-----BEGIN CERTIFICATE-----"
VerifyCertResult(valid, cert, error). Synchronous even when used with AsyncPQAuth. Does not check revocation.
crl_result = pq.ca.get_crl() # PQCert CA — pass the PQCert dataclass if pq.ca.is_cert_revoked(device_cert, crl_result.crl): raise PermissionError("Device certificate has been revoked") # X.509 CA — pass the certId string from meta if pq.ca.is_cert_revoked(result.meta.certId, crl_result.crl): raise PermissionError("Device certificate has been revoked") # certId string also works for PQCert CAs if pq.ca.is_cert_revoked(result.meta.certId, crl_result.crl): raise PermissionError("Device certificate has been revoked")
result = pq.ca.get_crl() print(f"CA: {result.subject}") print(f"CA ID: {result.caId}") print(f"Generated: {result.generatedAt}") print(f"Format: {result.format}") # "pqcert" or "x509" print(f"{len(result.crl)} revoked certificates") from datetime import datetime for entry in result.crl: # entry.reason may be None if no reason was provided at revocation time ts = datetime.fromtimestamp(entry.revokedAt).isoformat() print(f"{entry.certId} — {ts} — {entry.reason or 'no reason'}") # X.509 CAs only: result.raw contains the full signed CRL with ML-DSA-65 signature # result.raw is None for PQCert CAs if result.raw: print(result.raw["signature"][:16] + "...") # base64 ML-DSA signature
result = pq.ca.get_cert("cert_...") print(result.status.revoked) # bool print(result.status.expired) # bool print(result.status.revokedAt) # Unix timestamp or None print(result.status.expiresAt) # Unix timestamp # For PQCert CAs, result.certificate is a PQCert dataclass # For X.509 CAs, result.certificate is a PEM string and result.meta contains # additional fields (None for PQCert CAs): if result.meta: print(result.meta.certId) # cert_... print(result.meta.caId) # ca_... print(result.meta.subject) # "device-serial-00123" print(result.meta.format) # "x509" print(result.meta.algorithm) # "ML-DSA-44" | "ML-DSA-65" | "ML-DSA-87"
# PQCert CA — use certificate.id result = pq.ca.revoke_cert(device_cert.id, "device decommissioned") # X.509 CA — use meta.certId from ca.issue() result = pq.ca.revoke_cert(meta.certId, "device reported stolen") # certId string works for both formats result = pq.ca.revoke_cert("cert_...", "device decommissioned") print(result.certId) # cert_... print(result.revokedAt) # Unix timestamp print(result.reason) # "device decommissioned" (or None if omitted) print(result.usage.freeRemaining) # tokens remaining after this operation # result.format == "x509" for X.509 CAs, None for PQCert CAs
PQAuthError(code="API_ERROR", status=409). This is different from revoke() on tokens, which is idempotent.
import json from fipsign import PQAuth, generate_key_pair from fipsign.types import PQCert pq = PQAuth("pqa_your_api_key") # root_cert — the PQCert JSON saved once at CA creation, stored securely with open("root-cert.json") as f: root_cert = PQCert.from_dict(json.load(f)) # 1. Factory: generate key pair for the device kp = generate_key_pair() # kp.secretKey — store securely on the device (32-byte seed, base64) # 2. Factory: issue a certificate for the device result = pq.ca.issue( subject="lock-serial-00123", public_key=kp.publicKey, expires_in_seconds=365 * 24 * 60 * 60, meta={"model": "lock-v3", "batch": "2026-05"}, ) certificate = result.certificate # PQCert dataclass — store on device # 3. At runtime: offline signature verification (no API call) result = pq.ca.verify_cert(certificate, root_cert) if not result.valid: raise PermissionError(result.error) # 4. At runtime: bulk revocation check (offline, from cached CRL) crl_result = pq.ca.get_crl() if pq.ca.is_cert_revoked(certificate, crl_result.crl): raise PermissionError("Device revoked") # 5. Decommission: revoke the certificate pq.ca.revoke_cert(certificate.id, "device decommissioned")
import os from fipsign import PQAuth, generate_key_pair pq = PQAuth("pqa_your_api_key") # root_pem — the PEM string shown once at CA creation, stored securely root_pem = os.environ["FIPSIGN_ROOT_CERT_PEM"] # 1. Factory: generate key pair for the device kp = generate_key_pair() # kp.secretKey — store securely on the device (32-byte seed, base64) # 2. Factory: issue a certificate for the device result = pq.ca.issue( subject="lock-serial-00123", public_key=kp.publicKey, expires_in_seconds=365 * 24 * 60 * 60, ) cert_pem = result.certificate # PEM string — store on device cert_id = result.meta.certId # store alongside the PEM — needed for revocation # 3. At runtime: offline signature verification (no API call) result = pq.ca.verify_x509_cert(cert_pem, root_pem) if not result.valid: raise PermissionError(result.error) # 4. At runtime: bulk revocation check (offline, from cached CRL) crl_result = pq.ca.get_crl() if pq.ca.is_cert_revoked(cert_id, crl_result.crl): raise PermissionError("Device revoked") # 5. Decommission: revoke the certificate using certId pq.ca.revoke_cert(cert_id, "device decommissioned")
Hash sensitive data locally and sign only the hash — the original data never reaches the API. Convenience wrapper over sign()/verify(); see REST 03b for the full explanation of the pattern.
result = pq.zes.sign({
"patient": "Jane Doe",
"diagnosis": "confidential",
"record_id": "MR-2026-4491",
})
# result.hash — the 64-char SHA-256 hex digest that was actually signed
# result.token — pass to zes.verify() or revoke() like any token
check = pq.zes.verify(result.token, {
"patient": "Jane Doe",
"diagnosis": "confidential",
"record_id": "MR-2026-4491",
})
if not check.valid or not check.dataMatches:
raise PermissionError("invalid or tampered")
valid is False, failure and retry_after say why, exactly as in verify() (PY 03): "rejected" means FIPSign refuses the token; any other value means the check did not happen. dataMatches is False whenever valid is False.
canonicalize_for_signing() already used by ca.verify_cert() — byte-identical to the JS SDK's hash for the same data.revoke(), middleware, and AsyncPQAuth all work unchanged — a ZES token is a regular token as far as they're concerned. There is no zes.revoke(): use pq.revoke(token) directly, same as any other token. The async client exposes the identical pq.zes.sign()/pq.zes.verify() API — just await them.
Bounded, revocable authorization for AI agents, IoT devices, and automated services. Thin typed wrapper over POST/PATCH/GET /mandate — see Mandate 00 for the full explanation of the immutable/mutable layers, lifecycle, and budget semantics. No dashboard setup needed beyond an API key — unlike CA, there's no separate resource to create first. Identical on pq.mandate (sync) and the async client (see PY 06) — every method below has an await-able async twin with the same name and signature, except list_all(), which is an async generator (async for).
| Who | Holds | Calls |
|---|---|---|
| Your backend | Project API key (full access) | mandate.emit(), narrow(), suspend(), resume(), revoke(), get(), list(), list_all() — every developer-side operation. |
| The agent (only with proof of possession) | The mandate token and its own private key | generate_agent_key_pair() once, then sign_agent_call() for every call. No API key needed: nothing is sent to FIPSign. |
| Whoever performs the action (your executor — or the agent itself) | An API key and the mandate token | mandate.verify(), right before each action. If it is the agent itself, give it an agent key (restricted scope), never a full-access key. |
mandate.verify() and works out cost itself, so the agent never holds an API key and the budget can be trusted. Letting the agent call verify() itself also works — Mandate 00b explains both architectures and what each one protects.
result = pq.mandate.emit(
agent_id="agent-reporting-v2",
issued_by="[email protected]",
scope=["sign", "verify", "read:crm"],
budget_total=1000, # abstract units — 0 disables budget checking
expires_in_seconds=28800, # whole number, min 60, max 2_592_000 (30 days)
)
print(result.mandate.id) # mdt_... — use for narrow/suspend/resume/revoke/get
print(result.mandate.status) # "active"
mandate_token = result.mandate.token # give this to the agent — not stored server-side, save it now
Optional: pass agent_public_key to require proof of possession — see Proof of possession with the SDK below. The result then carries requiresAgentSignature=True.
check = pq.mandate.verify(mandate_token, "send_reply", 1) # (token, action, cost) # Mandates with proof of possession take the agent's signature as a keyword argument — see below if check.result != "granted": # check.reason: "scope_not_authorized" | "budget_exhausted" | # "mandate_suspended" | "mandate_revoked" | "mandate_expired" | # "invalid_signature" | # "agent_signature_required" | "agent_signature_invalid" | # "agent_signature_mismatch" | "agent_signature_replayed" (proof of possession, see below) | # or the real backend error message (e.g. invalid API key, rate limit, token quota) raise PermissionError(check.reason) # granted — check.budgetRemaining and check.expiresInSeconds are also present do_the_action()
mandate.verify() never raises — every failure, including network errors, an invalid API key and arguments of the wrong type, comes back as MandateVerifyResult(result="denied", reason=...), never an exception. The ten values reason can take for a mandate are listed in the type MandateDenyReason. There is also no local/offline mode here, unlike pq.verify(): budget and scope are live, mutable state that can only be checked against the server — the signature alone isn't enough to answer "is this action still authorized right now."pq is an agent key (restricted scope), only mandate.verify() above will work. mandate.emit(), narrow(), suspend(), resume(), revoke(), get(), list() and list_all() below are all developer-side operations and will fail with an invalid-key error if called with an agent key — use your normal (full access) key for those.
cost. The third argument of verify() is enforced exactly as given — FIPSign does not know what an action really costs. Compute it in the code that performs the action, not in the agent. See Mandate 00b.MandateVerifyResult(result="denied", reason="Network error: …"), but the server may have granted the call and consumed budget before the connection dropped. Treat it as "do not act". Before retrying a mandate without proof of possession, compare budgetConsumed from mandate.get(id) with the value you had; with proof of possession, re-send the same agent_signature (see below). Details in Mandate 02c.
import json from fipsign import generate_agent_key_pair # On the agent, once. The default variant is ML-DSA-65. kp = generate_agent_key_pair() # Another variant: generate_agent_key_pair("ML-DSA-44") # or "ML-DSA-87" # kp.publicKey → goes to your backend, for mandate.emit(agent_public_key=...) # kp.secretKey → stays on the agent, in a secret store. Never send it anywhere. # kp.algorithm → "ML-DSA-65" here. Save it next to secretKey (Step 3 needs it) # Save the two together on the agent (secret store, or a file only the agent's user can read). # Step 3 reads them back. saved = json.dumps({"secretKey": kp.secretKey, "algorithm": kp.algorithm})
| Variant | publicKey | secretKey | Signature |
|---|---|---|---|
ML-DSA-44 | 1312 bytes | 32 bytes (seed) | 2420 bytes |
ML-DSA-65 (default) | 1952 bytes | 32 bytes (seed) | 3309 bytes |
ML-DSA-87 | 2592 bytes | 32 bytes (seed) | 4627 bytes |
generate_agent_key_pair("ML-DSA-44"). Any other text ("ml-dsa-44", "ML-DSA44"…) raises UNSUPPORTED_ALGORITHM.generate_agent_key_pair() for agents and generate_key_pair() only for CA certificates. ca.issue() accepts ML-DSA-65 keys only, so a key of another variant is rejected there (HTTP 400, nothing is charged).secretKey secret. Store it in a secret store (or a file only the agent's user can read), never log it, never send it anywhere. If it is lost or may have leaked, revoke the mandate and emit a new one with a new key pair: the public key of a mandate can never be changed.secretKey is the 32-byte ML-DSA seed (base64), the only private-key form the Python cryptography package can load. The JS SDK's generateAgentKeyPair() returns the full expanded key (2560, 4032 or 4896 bytes) instead. The two secret keys are not interchangeable: sign_agent_call() rejects a JS key with INVALID_SECRET_KEY, so an agent must sign with the SDK that generated its key. The public key and the signature are identical in both SDKs: an agent that signs in Python can be verified by a service that runs the JS SDK, and the other way around. The seed of either recipe in Mandate 02b also works here, with the matching algorithm (the recipes use "ML-DSA-44").
result = pq.mandate.emit(
agent_id="agent-reporting-v2",
issued_by="[email protected]",
scope=["sign", "send_reply"],
budget_total=1000,
expires_in_seconds=28800,
agent_public_key=kp.publicKey, # base64, from generate_agent_key_pair()
)
print(result.mandate.requiresAgentSignature) # True
# Keep result.mandate.id in your database: you need it to narrow, suspend, revoke and inspect.
mandate_token = result.mandate.token # give this to the agent
A key of the wrong size is rejected with HTTP 400 and nothing is charged, so a mandate that could never be verified cannot be created by mistake. Only the public key travels to your backend; it is stored once and can never change.
import json from fipsign import sign_agent_call # On the agent, for EVERY call: no API key needed, nothing is sent to FIPSign. key = json.loads(saved) # what Step 1 saved (read it from your secret store) agent_signature = sign_agent_call( mandate_token, # the mandate token (or just the mandate id) "send_reply", # exactly what mandate.verify() will receive 1, # exactly what mandate.verify() will receive key["secretKey"], # the secretKey from Step 1 algorithm=key["algorithm"], # required: saved in Step 1, never typed by hand ) # Send agent_signature to whoever calls mandate.verify() — your executor, or the agent itself
algorithm is required, and you do not make it up. It is the text that generate_agent_key_pair() returned in kp.algorithm — one of "ML-DSA-44", "ML-DSA-65" or "ML-DSA-87" — saved next to secretKey in Step 1. A 32-byte seed does not say which ML-DSA variant it belongs to (all three use the same size), so the SDK cannot detect it the way the JS SDK does from the size of its expanded key. There are two ways to get it wrong. A text that is not exactly one of the three ("ml-dsa-65", "ML-DSA65"…) is refused at once with UNSUPPORTED_ALGORITHM. A valid text that is not the variant of the key is not caught locally: the backend answers agent_signature_invalid.action and cost must be exactly what you pass to verify(). Change one and you must sign again, otherwise verify() answers agent_signature_mismatch.expires_in_seconds, between 1 and 60) and the server accepts it once. Sign again for every attempt; never cache signatures.sign_agent_call(). The server accepts a signature stamped up to 30 seconds ahead of its own clock, so with the default lifetime the agent's clock may be off by about 30 seconds either way. Keep clocks synchronized (NTP). On devices where you cannot, pass expires_in_seconds=60: it tolerates an agent clock up to 60 seconds behind.
check = pq.mandate.verify(mandate_token, "send_reply", 1, agent_signature=agent_signature) if check.result != "granted": # check.reason also covers the agent's signature: # "agent_signature_required" | "agent_signature_invalid" | # "agent_signature_mismatch" | "agent_signature_replayed" raise PermissionError(check.reason) do_the_action()
agent_signature while it is still valid: granted means the first attempt had not been applied and now it is, exactly once; agent_signature_replayed means the first attempt was applied and its cost is already counted — perform the action and do not retry again. Once the signature has expired you can no longer tell: compare budgetConsumed in mandate.get(id). More in Mandate 02c.
| You see | Typical cause | Fix |
|---|---|---|
agent_signature_required (in reason) | verify() was called without agent_signature= on a mandate emitted with agent_public_key. | Sign the call and pass it as agent_signature. |
agent_signature_invalid | Signed with a different key than the mandate's agent_public_key; an algorithm that is not the variant of the key; signature already expired; agent clock more than 30 seconds ahead of the server. | Sign with the secretKey of the pair whose publicKey you emitted, and with the algorithm that pair came with; keep the default lifetime; synchronize clocks. |
agent_signature_mismatch | The action or cost that was signed differ from the ones passed to verify(). | Sign exactly the values you verify. Change one and you must sign again. |
agent_signature_replayed | This exact signature was already accepted. | Sign again for every call. |
TypeError: sign_agent_call() missing 1 required keyword-only argument: 'algorithm' | You called sign_agent_call() without algorithm=, as you would with the JS SDK. | Add algorithm= with the text saved next to the secret key (kp.algorithm from Step 1). |
PQAuthError INVALID_ARGUMENT | sign_agent_call() got a missing or invalid mandate, action (non-empty string), cost (whole number ≥ 0) or expires_in_seconds (whole number from 1 to 60). Also raised by every method that takes a mandate_id when it is empty, . or ... | Fix the argument; err.message names it. |
PQAuthError INVALID_SECRET_KEY | secret_key is not valid base64 or is not a 32-byte seed — for example the expanded key generated by the JS SDK. | Use the secretKey returned by generate_agent_key_pair(). |
PQAuthError UNSUPPORTED_ALGORITHM | The algorithm name given to generate_agent_key_pair() or sign_agent_call() is unknown. | Use kp.algorithm, or one of "ML-DSA-44", "ML-DSA-65", "ML-DSA-87" written exactly like that (capitals, hyphens, no spaces). |
from fipsign import PQAuthError, sign_agent_call try: agent_signature = sign_agent_call(mandate_token, "send_reply", 1, secret_key, algorithm="ML-DSA-65") except PQAuthError as err: print(err.code, err.message)
Generates a key pair, emits a mandate that requires it and runs the four interesting cases against the live API. Save as pop_sdk_demo.py, run pip install fipsign-sdk, then FIPSIGN_API_KEY=pqa_... python pop_sdk_demo.py. Cost: 4 platform tokens — the emit (2) and the one granted verify (2); denied calls are free.
# pop_sdk_demo.py — proof of possession with the SDK, end to end. # Setup: pip install fipsign-sdk # Run: FIPSIGN_API_KEY=pqa_... python pop_sdk_demo.py import os from fipsign import PQAuth, generate_agent_key_pair, sign_agent_call pq = PQAuth(os.environ["FIPSIGN_API_KEY"]) # ── AGENT SIDE ────────────────────────────────────────────────────────── # The agent creates its own key pair. The secret key never leaves the agent. kp = generate_agent_key_pair() # ML-DSA-65 unless you pass another variant # ── YOUR BACKEND ──────────────────────────────────────────────────────── # 1. Emit the mandate, registering the agent's PUBLIC key. mandate = pq.mandate.emit( agent_id="agent-pop-demo", issued_by="[email protected]", scope=["sign"], budget_total=10, expires_in_seconds=3600, agent_public_key=kp.publicKey, ).mandate print("emitted", mandate.id, "· requiresAgentSignature:", mandate.requiresAgentSignature) # 2. Without agent_signature the mandate token alone is useless. r = pq.mandate.verify(mandate.token, "sign", 1) print("no signature →", r.result, r.reason) # 3. With the agent's signature over THIS call: granted. signature = sign_agent_call(mandate.token, "sign", 1, kp.secretKey, algorithm=kp.algorithm) r = pq.mandate.verify(mandate.token, "sign", 1, agent_signature=signature) print("with signature →", r.result, "· budgetRemaining:", r.budgetRemaining) # 4. Sending the same signature again is refused: each signature works once. r = pq.mandate.verify(mandate.token, "sign", 1, agent_signature=signature) print("same signature →", r.result, r.reason) # 5. A signature made for cost 1 cannot be used for cost 9. other = sign_agent_call(mandate.token, "sign", 1, kp.secretKey, algorithm=kp.algorithm) r = pq.mandate.verify(mandate.token, "sign", 9, agent_signature=other) print("cost changed →", r.result, r.reason) # Clean up: revoke the demo mandate (free). pq.mandate.revoke(mandate.id)
emitted mdt_… · requiresAgentSignature: True no signature → denied agent_signature_required with signature → granted · budgetRemaining: 9 same signature → denied agent_signature_replayed cost changed → denied agent_signature_mismatch
pq.mandate.narrow(mandate.id, ["read:crm"]) # shrink scope — monotonic, cannot re-widen pq.mandate.suspend(mandate.id) # pause — verify() denies with "mandate_suspended" while suspended pq.mandate.resume(mandate.id) # reactivate a suspended mandate pq.mandate.revoke(mandate.id) # permanent — irreversible, no narrow/suspend/resume after this
patched = pq.mandate.suspend(mandate.id)
print(patched.status, patched.scope, patched.updatedAt)
# suspended ['sign', 'verify', 'read:crm'] 1785691318
narrow(), suspend(), resume() and revoke() return a MandatePatchResult with id, status, scope (after the change) and updatedAt (Unix seconds). Suspending a mandate that is already suspended returns only id, status and message="Already suspended": scope and updatedAt are None.
verify() always returns reason="mandate_suspended" — the backend checks status before it ever looks at the budget counter.
result = pq.mandate.get("mdt_...") print(result.mandate.budgetConsumed, result.mandate.budgetRemaining, result.mandate.scopeCurrent, result.mandate.status) # First page: the 50 most recent mandates of this project page = pq.mandate.list() print(page.count, page.nextCursor) # MandateListResult(mandates, count, nextCursor) # Your own page size (1–100), then the next page page1 = pq.mandate.list(limit=20) page2 = pq.mandate.list(limit=20, cursor=page1.nextCursor) if page1.nextCursor else None # Every mandate, page by page — stop whenever you want with break for m in pq.mandate.list_all(): print(m.id, m.status, m.budgetConsumed) # Async client: same names, list_all() is an async generator (no await before it) # async for m in pq.mandate.list_all(): ...
list() returns one page — up to 50 mandates by default (limit: 1–100), most recent first. nextCursor is a string while more pages exist and None on the last one: pass it back as cursor, exactly as received. count is the size of this page, not a total (the API has no total). A page can hold fewer than limit mandates — even none — while nextCursor is not None, so drive your loop with the cursor, never with the page size. list_all() does exactly that for you, one page at a time (its limit is the page size, not a cap on the total). Keep the id of every mandate you emit rather than listing to find it.expired status: check expiresInSeconds (it is 0 once the mandate has expired). An expired mandate can still be read for 24 hours, then get() raises PQAuthError with status 404.narrow(), suspend(), resume(), revoke(), get(), list() and emit() raise PQAuthError with code API_ERROR and the HTTP status: 409 when the state does not allow the change (already revoked, expired, not suspended), 404 when the mandate does not exist, 400 for invalid input (for list(): a limit out of range or a cursor that is not one the API gave you). Revoking twice returns 409 — treat it as success when retrying.
Use FIPSign directly from Claude — sign tokens and issue certificates through natural language.
Available for TypeScript (@fipsign/mcp) and Python (fipsign-mcp).
Both MCP servers expose the same 11 tools covering the full FIPSign runtime API. Install one — they're equivalent.
Edit claude_desktop_config.json and restart Claude Desktop. The API key is passed via environment variable — never hardcode it.
{
"mcpServers": {
"fipsign": {
"command": "npx",
"args": ["-y", "@fipsign/mcp"],
"env": {
"FIPSIGN_API_KEY": "pqa_your_api_key_here"
}
}
}
}
{
"mcpServers": {
"fipsign": {
"command": "uvx",
"args": ["fipsign-mcp"],
"env": {
"FIPSIGN_API_KEY": "pqa_your_api_key_here"
}
}
}
}
Add FIPSign to Claude Code with a single command. The server runs per-project or globally.
claude mcp add fipsign -- env FIPSIGN_API_KEY=pqa_your_api_key npx -y @fipsign/mcp
claude mcp add fipsign -- env FIPSIGN_API_KEY=pqa_your_api_key uvx fipsign-mcp
claude mcp list claude mcp get fipsign
--scope global to make it available in all Claude Code sessions.
| Variable | Required | Default | Description |
|---|---|---|---|
FIPSIGN_API_KEY |
Yes (most tools) | — | Your FIPSign API key. Format: pqa_ + 64 lowercase hex chars.
fipsign_health and fipsign_generate_key_pair work without it. fipsign_public_key requires an API key — it returns the public key for the project associated with that key.
Do not use an agent-scoped key here (dashboard checkbox "Agent key — restricted scope") — none of the 11 MCP tools call POST /mandate/verify, so an agent key would fail on all of them. Agent keys are only for direct SDK/REST calls to mandate.verify(). |
FIPSIGN_BASE_URL |
No | https://api.fipsign.dev |
Override the API base URL. Useful for self-hosted instances or local development tunnels. |
Once connected, Claude can call FIPSign tools directly in response to natural language.
Both MCP servers are open source. Use MCP Inspector for interactive testing before connecting to a client.
# Install inspector globally once npm install -g @modelcontextprotocol/inspector # Run inspector against the published package FIPSIGN_API_KEY=pqa_your_api_key npx @modelcontextprotocol/inspector npx @fipsign/mcp # Or from a local clone git clone https://github.com/fipsign/fipsign-mcp cd fipsign-mcp && npm install && npm run build FIPSIGN_API_KEY=pqa_your_api_key npx @modelcontextprotocol/inspector node dist/index.js
# Using uvx — no install needed FIPSIGN_API_KEY=pqa_your_api_key npx @modelcontextprotocol/inspector uvx fipsign-mcp # Or from a local clone git clone https://github.com/fipsign/fipsign-mcp-python cd fipsign-mcp-python && pip install -e . FIPSIGN_API_KEY=pqa_your_api_key npx @modelcontextprotocol/inspector python -m fipsign_mcp.server
http://localhost:5173. Click any tool, fill in the arguments, and call it directly to test your API key and inspect the response before connecting to Claude.
Use with any language via curl or HTTP client.
Base URL: https://api.fipsign.dev · Auth: X-API-Key: pqa_your_key
Set these in your terminal before running any example in this section.
export API_KEY="pqa_YOUR_API_KEY_HERE" export BASE_URL="https://api.fipsign.dev"
POST /mandate/verify: every other endpoint rejects it with the same 401 as an invalid key. Use it for keys you hand directly to an AI agent (see Mandate 00). Agent keys can only be created in the dashboard: there is no API or SDK call for it.
Public endpoint. No authentication required. No token cost.
curl -s $BASE_URL/health | jq
Requires X-API-Key. No token cost. Rate limited to 300 requests per minute per API key. Returns the project's ML-DSA public key for offline token verification. The algorithm reflects the project's configured algorithm (ML-DSA-44, ML-DSA-65, or ML-DSA-87).
curl -s $BASE_URL/public-key -H "X-API-Key: $API_KEY" | jq
Requires X-API-Key. Cost: 1 token.
curl -s -X POST $BASE_URL/sign \ -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" \ -d '{"sub":"user_123","email":"[email protected]","role":"admin","expiresInSeconds":3600}' | jq
TOKEN=$(curl -s -X POST $BASE_URL/sign \ -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" \ -d '{"sub":"user_test","email":"[email protected]"}' \ | jq -c '.token')
sub is required, max 128 characters. Other string fields max 256 characters. Maximum 10 custom fields (not counting sub, email, role, expiresInSeconds). Exceeding these returns HTTP 400.expiresInSeconds defaults to 3600 seconds (1 hour).60.5, returns HTTP 400.ML-DSA-44, ML-DSA-65, or ML-DSA-87) is determined by the project configuration set at creation time in the dashboard. It is immutable — all tokens signed by a project use the same algorithm. The algorithm is reflected in both token.algorithm and meta.algorithm in the response.
_) belong to the server — for example _iss and _mandate — and cannot be set through /sign. A field like _mandate or _foo in the body returns HTTP 400 Custom fields starting with "_" are reserved; _iss alone is silently ignored. This keeps tokens from /sign and Mandate tokens (POST /mandate) apart: only POST /mandate can produce a token that /mandate/verify will accept.
Sign sensitive data without sending it to the API. The data is hashed locally — only a 64-character SHA-256 hex digest (32 bytes) is transmitted. PQ-Sign never sees the original content. Compatible with HIPAA, PCI-DSS, and any environment where plaintext must not leave your infrastructure. Cost: 1 token.
POST /sign, you hash it locally using SHA-256 and send only the hash. The server signs the hash — not your data. To verify later, you recalculate the hash locally and verify the token normally via POST /verify.POST /sign and POST /verify endpoints — the only difference is what you send as the payload.
# Your sensitive data — never leaves your machine SENSITIVE='{"amount":"50000","customer":"Acme Corp","invoice":"INV-123"}' # Keys must be sorted alphabetically: amount → customer → invoice # Hash locally with SHA-256 → hex ZES_HASH=$(echo -n "$SENSITIVE" | sha256sum | awk '{print $1}') echo "Hash: $ZES_HASH" # Hash: a3f2c1d8e9b4f7c2... (64 hex chars — this is what travels to the API)
ZES_TOKEN=$(curl -s -X POST $BASE_URL/sign -H "Content-Type: application/json" -H "X-API-Key: $API_KEY" -d "{\"sub\": \"zes:$ZES_HASH\", \"zes\": true}" | jq -c '.token') echo $ZES_TOKEN | jq
# Recalculate the hash from the original data (on your side) VERIFY_HASH=$(echo -n "$SENSITIVE" | sha256sum | awk '{print $1}') # Verify the token — same endpoint as always curl -s -X POST $BASE_URL/verify -H "Content-Type: application/json" -H "X-API-Key: $API_KEY" -d "{\"token\": $ZES_TOKEN}" | jq
SHA-256(your_data) and compare it against payload.sub (strip the zes: prefix). If they match and valid: true, the token cryptographically proves that specific data existed at iat.{"amount":"5000","customer":"Acme"} and {"customer":"Acme","amount":"5000"} produce different hashes — the token will not verify if the key order differs between sign and verify. This applies to nested objects too: sort keys inside every nested object, not only at the top level, or the hash will differ depending on how the object was originally constructed.{"file_hash":"<sha256_of_bytes>","filename":"contract.pdf"} — then hash that JSON with keys sorted.POST /revoke with the token object. The response includes sub with the zes: prefix. See section 05.zes: true field is a convention that signals to your own systems that the sub field contains a hash rather than a plain identifier. PQ-Sign treats it as any other custom field — it counts as 1 of the 10 allowed custom fields in the payload. If you need all 10 slots for your own fields, omit zes: true and rely on the zes: prefix in sub to identify ZES tokens in your own systems.
import crypto from 'crypto' // Step 1 — hash locally (keys sorted recursively — nested objects too) function canonicalize(obj) { if (Array.isArray(obj)) return obj.map(canonicalize) if (obj !== null && typeof obj === 'object') { return Object.fromEntries(Object.keys(obj).sort().map(k => [k, canonicalize(obj[k])])) } return obj } const sensitive = { amount: '50000', customer: 'Acme Corp', invoice: 'INV-123' } const sorted = JSON.stringify(canonicalize(sensitive)) const hash = crypto.createHash('sha256').update(sorted).digest('hex') // Step 2 — sign the hash const res = await fetch('https://api.fipsign.dev/sign', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': process.env.PQSIGN_KEY }, body: JSON.stringify({ sub: `zes:${hash}`, zes: true }) }) const { token } = await res.json() // Step 3 — verify: recalculate hash and confirm it matches payload.sub const verifyRes = await fetch('https://api.fipsign.dev/verify', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': process.env.PQSIGN_KEY }, body: JSON.stringify({ token }) }) const { valid, payload } = await verifyRes.json() const rehash = crypto.createHash('sha256').update(sorted).digest('hex') const matches = payload.sub === `zes:${rehash}` console.log(`valid: ${valid}, data matches token: ${matches}`) // true, true
import hashlib, json, requests, os # Step 1 — hash locally (keys sorted recursively — nested objects too) def canonicalize(obj): if isinstance(obj, list): return [canonicalize(x) for x in obj] if isinstance(obj, dict): return {k: canonicalize(obj[k]) for k in sorted(obj)} return obj sensitive = {'amount': '50000', 'customer': 'Acme Corp', 'invoice': 'INV-123'} sorted_json = json.dumps(canonicalize(sensitive), separators=(',', ':')) zes_hash = hashlib.sha256(sorted_json.encode()).hexdigest() headers = {'Content-Type': 'application/json', 'X-API-Key': os.environ['PQSIGN_KEY']} # Step 2 — sign the hash r = requests.post('https://api.fipsign.dev/sign', json={'sub': f'zes:{zes_hash}', 'zes': True}, headers=headers) token = r.json()['token'] # Step 3 — verify v = requests.post('https://api.fipsign.dev/verify', json={'token': token}, headers=headers).json() rehash = hashlib.sha256(sorted_json.encode()).hexdigest() matches = v['payload']['sub'] == f'zes:{rehash}' print(f'valid: {v["valid"]}, data matches token: {matches}') # True, True
Requires X-API-Key. Cost: 1 token. Verifies signature, expiry, and revocation status.
curl -s -X POST $BASE_URL/verify \ -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" \ -d "{\"token\": $TOKEN}" | jq
FAKE_TOKEN=$(echo $TOKEN | jq -c '.payload = "TAMPERED_PAYLOAD"') curl -s -X POST $BASE_URL/verify \ -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" \ -d "{\"token\": $FAKE_TOKEN}" | jq
valid. A rejected token is always HTTP 401 with "valid": false; a malformed request is HTTP 400 (no valid field); HTTP 429 is a rate limit or an exhausted quota (REST 10). The error text says why — use it in your logs, but do not build logic on comparing it.
| HTTP | error | When |
|---|---|---|
| 401 | Token has been revoked | The token was revoked with POST /revoke. |
| 401 | Token expired N seconds ago | The signature is valid but the token's exp is in the past. N is how many seconds ago, for example Token expired 120 seconds ago. |
| 401 | Invalid signature — token was tampered with or not issued by this server | The signature does not match the payload or the project's key: the payload was changed, the token was signed by another key, or it was issued for a different project of yours (you get the same text in all three cases). |
| 401 | Invalid signature — wrong length for ML-DSA-65 (expected 3309 bytes) | The signature has the wrong size for the token's algorithm. The text names the token's own algorithm: ML-DSA-44 expects 2420 bytes, ML-DSA-65 3309, ML-DSA-87 4627. |
| 401 | Invalid token — signature is not valid base64 | The signature field is not base64 text. |
| 401 | Unsupported algorithm: X | The token's algorithm is not exactly ML-DSA-44, ML-DSA-65 or ML-DSA-87. X is the value you sent. |
| 401 | Token payload exceeds the maximum of 16384 characters | The token's payload text is longer than 16,384 characters. |
| 401 | This is a Mandate token. Verify it with POST /mandate/verify | The token came from POST /mandate (see the note below). |
| 400 | "token" is required | The body has no token object. HTTP 400 responses have no valid field. |
| 400 | Invalid token format — missing payload, signature, or algorithm | The token object lacks payload, signature or algorithm. |
issuedAt is informational. The token object carries an issuedAt field, but /verify and /revoke never read it. What counts is the signed payload: iat and exp.
POST /mandate is not a normal token: /verify would only check its signature and expiry, and would say valid: true even for a revoked or suspended mandate. It answers HTTP 401 { "success": false, "valid": false, "error": "This is a Mandate token. Verify it with POST /mandate/verify" } instead (the call is billed as a normal /verify). Use POST /mandate/verify, which also checks status, scope and budget.
Requires X-API-Key. Cost: 1 token. Permanently and immediately invalidates the token.
curl -s -X POST $BASE_URL/revoke \ -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" \ -d "{\"token\": $TOKEN, \"reason\": \"user logged out\"}" | jq
{ "success": true, "message": "Token was already revoked" } without consuming an extra token./revoke on an already-expired token returns HTTP 400 with "Token is invalid or already expired — cannot revoke". Expired tokens cannot be submitted for revocation.POST /revoke is limited to 300 requests/minute per API key, same as /sign and /verify. See section 10.
POST /revoke answers HTTP 400 This is a Mandate token. Revoke it with PATCH /mandate/:id {"action":"revoke"} (nothing is charged). A Mandate is revoked by its id with PATCH /mandate/:id, and that does not require the token.
Requires X-API-Key or a session cookie. No token cost. Returns balance, 6-month history, purchased packs, and account info.
curl -s $BASE_URL/usage \
-H "X-API-Key: $API_KEY" | jq
tokensUsed: 0.current (the live balance — freeRemaining, packRemaining, totalRemaining) is always exact and is what determines whether a request is accepted or rejected for being over your token quota. monthlyHistory and per-project usage stats are an audit log — under very high burst traffic they may show a slightly lower total than current. If you need the exact token count at any point in time, read current, not the sum of monthlyHistory.Copy, replace your API key, and run to test the full token lifecycle.
#!/bin/bash API_KEY="pqa_YOUR_API_KEY" BASE_URL="https://api.fipsign.dev" echo "[1/7] Health..." curl -s $BASE_URL/health | jq .status echo "[2/7] Sign..." TOKEN=$(curl -s -X POST $BASE_URL/sign \ -H "Content-Type: application/json" -H "X-API-Key: $API_KEY" \ -d '{"sub":"user_test","role":"user"}' | jq -c '.token') echo "[3/7] Verify (must be true)..." curl -s -X POST $BASE_URL/verify -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" -d "{\"token\": $TOKEN}" | jq .valid echo "[4/7] Tampered (must be false)..." FAKE=$(echo $TOKEN | jq -c '.payload = "FAKE"') curl -s -X POST $BASE_URL/verify -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" -d "{\"token\": $FAKE}" | jq .valid echo "[5/7] Revoke..." curl -s -X POST $BASE_URL/revoke -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" -d "{\"token\": $TOKEN, \"reason\": \"test\"}" | jq .message echo "[6/7] Verify revoked (must be false)..." curl -s -X POST $BASE_URL/verify -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" -d "{\"token\": $TOKEN}" | jq '{valid,error}' echo "[7/7] Balance..." curl -s $BASE_URL/usage -H "X-API-Key: $API_KEY" | jq .current
| Endpoint | Cost | Auth | Description |
|---|---|---|---|
| POST /sign | 1 token | X-API-Key | Sign any payload |
| POST /verify | 1 token | X-API-Key | Verify signature, expiry, and revocation |
| POST /revoke | 1 token | X-API-Key | Permanently revoke a token |
| GET /usage | Free | X-API-Key or session | Balance and 6-month history |
| GET /public-key | Free | X-API-Key | Public key for offline verification |
| GET /health | Free | — | Service status |
| POST /ca/issue | 1 token | X-API-Key | Issue a certificate for a device or service |
| POST /ca/revoke | 1 token | X-API-Key | Revoke a certificate immediately |
| GET /ca/crl | Free | X-API-Key | Certificate Revocation List for this project's CA |
| GET /ca/certificate/:id | Free | X-API-Key | Real-time status of a single certificate |
sub max 128 chars · other string fields max 256 chars · max 10 custom fields.expiresInSeconds on /sign whole number, min 60, max 157,680,000 (5 years).subject max 256 chars · meta max 10 keys (PQCert only) · expiresInSeconds whole number, min 60, max 157,680,000 (5 years).
| HTTP | Error | Cause |
|---|---|---|
| 400 | "sub" is required | sign() called without sub field |
| 400 | "sub" exceeds maximum length of 128 characters | sub field too long |
| 400 | Maximum of 10 custom fields allowed in payload | sign() called with more than 10 custom fields |
| 400 | Token is invalid or already expired — cannot revoke | revoke() called on expired token, or token issued for a different project |
| 400 | This is a Mandate token. Revoke it with PATCH /mandate/:id {"action":"revoke"} | POST /revoke called with a token issued by POST /mandate |
| 400 | Custom fields starting with "_" are reserved | POST /sign called with a custom field whose name starts with an underscore (_iss alone is ignored) |
| 400 | "meta" is not supported for X.509 CAs | ca.issue() called with meta on an X.509 CA |
| 400 | "expiresInSeconds" must be at least 60 | Certificate TTL below minimum |
| 400 | "expiresInSeconds" must not exceed 157680000 (5 years) | Certificate TTL above maximum |
| 400 | "CA root expires too soon to issue a valid certificate" | CA root has less remaining lifetime than the minimum certificate TTL — the CA root itself is near expiry |
| 400 | "expiresInSeconds" must be at least 60 | Token TTL below minimum (POST /sign) |
| 400 | "expiresInSeconds" must not exceed 157680000 (5 years) | Token TTL above maximum (POST /sign) |
| 400 | "publicKey" must be a base64-encoded ML-DSA-65 public key (1952 bytes) | Invalid or wrong-size public key for X.509 CA |
| 400 | "subject" exceeds maximum length of 256 characters | Certificate subject too long |
| 400 | Invalid body — expected JSON | The request body is not valid JSON |
| 400 | Invalid body — expected a JSON object | The body is valid JSON but is not an object: null, an array or a plain value |
| 400 | "expiresInSeconds" must be an integer | expiresInSeconds has decimals, for example 60.5 (POST /sign and POST /ca/issue) |
| 415 | Content-Type must be application/json | Request body sent with a non-JSON Content-Type (e.g. text/plain, multipart/form-data) |
| 401 | API key required or invalid | Missing or incorrect X-API-Key, or key not matching pqa_ + 64 hex chars |
| 401 | Token has been revoked | Token was previously revoked via POST /revoke |
| 401 | Invalid signature — token was tampered with or not issued by this server | Token signature is invalid (tampered or wrong key), or token issued for a different project |
| 401 | Invalid signature — wrong length for ML-DSA-65 (expected 3309 bytes) | The signature has the wrong size for the token's algorithm (ML-DSA-44: 2420, ML-DSA-65: 3309, ML-DSA-87: 4627 bytes) |
| 401 | Invalid token — signature is not valid base64 | The token's signature field is not base64 text |
| 401 | Unsupported algorithm: X | The token's algorithm is not ML-DSA-44, ML-DSA-65 or ML-DSA-87 |
| 401 | Token payload exceeds the maximum of 16384 characters | The token's payload text is longer than 16,384 characters |
| 401 | Token expired N seconds ago | Token has expired |
| 401 | This is a Mandate token. Verify it with POST /mandate/verify | POST /verify called with a token issued by POST /mandate |
| 404 | No active CA found for this project | CA not yet created — go to dashboard |
| 404 | Certificate not found | certId does not exist or belongs to another project |
| 409 | Certificate is already revoked | ca.revokeCert() called on already-revoked cert |
| 429 | Rate limit exceeded. Maximum 300 requests per minute per API key. | Too many requests in the current minute (the number in the text depends on the endpoint). The body has "code": "rate_limited"; wait the seconds in the Retry-After header and retry — see REST 10 |
| 429 | Token limit reached. Free monthly tokens exhausted and no active packs. | Monthly quota exhausted. The body has "code": "token_quota_exhausted" (the text can be shorter: Token limit reached.). Purchase a pack from the dashboard, retrying won't help |
All endpoints are rate limited. 300 requests/minute per API key on write endpoints, 60/minute on CA read endpoints. HTTP 429 on excess, with a Retry-After header.
| Endpoint | Limit | Window | Scope |
|---|---|---|---|
| GET /public-key | 300 requests | 1 minute | Per API key |
| POST /sign | 300 requests | 1 minute | Per API key |
| POST /verify | 300 requests | 1 minute | Per API key |
| POST /revoke | 300 requests | 1 minute | Per API key |
| POST /ca/issue | 300 requests | 1 minute | Per API key |
| POST /ca/revoke | 300 requests | 1 minute | Per API key |
| GET /ca/crl | 60 requests | 1 minute | Per API key |
| GET /ca/certificate/:id | 60 requests | 1 minute | Per API key |
code. Every HTTP 429 from an endpoint that uses X-API-Key has a code field in the body. Do not compare the error text: it changes with the endpoint."rate_limited" — you sent too many requests in the current minute. The Retry-After header is a whole number of seconds: the time left until the one-minute window ends. Wait that long and the request goes through."token_quota_exhausted" — your free tokens and your packs are used up. There is no Retry-After: purchasing a pack from the dashboard is the only remedy, retrying won't help.
HTTP/2 429
retry-after: 37
{
"success": false,
"error": "Rate limit exceeded. Maximum 300 requests per minute per API key.",
"code": "rate_limited"
}
HTTP/2 429
{
"success": false,
"error": "Token limit reached. Free monthly tokens exhausted and no active packs.",
"code": "token_quota_exhausted",
"usage": {
"freeRemaining": 0,
"packRemaining": 0,
"totalRemaining": 0
}
}
// retry.mjs — Node.js 20+, no SDK. Run it with: FIPSIGN_API_KEY=pqa_... node retry.mjs const BASE_URL = 'https://api.fipsign.dev' const API_KEY = process.env.FIPSIGN_API_KEY async function fipsign(path, body, retries = 1) { const res = await fetch(BASE_URL + path, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': API_KEY }, body: JSON.stringify(body), }) if (res.status !== 429) return res const err = await res.json() if (err.code === 'rate_limited' && retries > 0) { // Retry-After = seconds until the one-minute window ends const seconds = Number(res.headers.get('Retry-After') ?? 1) await new Promise(resolve => setTimeout(resolve, seconds * 1000)) return fipsign(path, body, retries - 1) } // 'token_quota_exhausted' (or still rate limited after the retry): waiting will not fix it now throw new Error(`FIPSign answered 429 ${err.code}: ${err.error}`) } const res = await fipsign('/sign', { sub: 'user_123' }) console.log(res.status, await res.json())
All CA runtime operations are available via REST. The CA root is created once from the dashboard — there is no REST endpoint for that. All endpoints require X-API-Key. The API key determines the project; no projectId needed in the request body.
CERT=$(curl -s -X POST $BASE_URL/ca/issue \ -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" \ -d '{ "subject": "device-serial-00123", "publicKey": "base64_ml_dsa65_public_key", "expiresInSeconds": 31536000, "meta": {"model": "lock-v2", "batch": "2026-05"} }') CERT_ID=$(echo $CERT | jq -r '.meta.certId') echo "Issued: $CERT_ID"
curl -s $BASE_URL/ca/crl -H "X-API-Key: $API_KEY" | jq '.'
reason may be null if no reason was provided at revocation time.curl -s $BASE_URL/ca/certificate/$CERT_ID \ -H "X-API-Key: $API_KEY" | jq '.status'
curl -s -X POST $BASE_URL/ca/revoke \ -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" \ -d "{\"certId\": \"$CERT_ID\", \"reason\": \"device decommissioned\"}" | jq
"Certificate is already revoked". This is different from POST /revoke on tokens, which is idempotent.
X.509 CA works identically to PQCert CA from the API perspective — same endpoints, same auth model. The difference is the format: X.509 returns a standard PEM certificate (RFC 9881, id-ml-dsa-65) that OpenSSL 3.5+ can parse.
X.509 (PEM) → enter a CA name → "Create CA". Save the PEM shown after creation — it is shown only once.cryptography>=48.0.0 or the JS SDK's ca.verifyX509Cert().
node -e "
import('@noble/post-quantum/ml-dsa.js').then(({ml_dsa65}) => {
const seed = new Uint8Array(32);
crypto.getRandomValues(seed);
const kp = ml_dsa65.keygen(seed);
let pub = '', sec = '';
for(let i=0;i<kp.publicKey.length;i++) pub += String.fromCharCode(kp.publicKey[i]);
for(let i=0;i<kp.secretKey.length;i++) sec += String.fromCharCode(kp.secretKey[i]);
console.log('PUBLIC_KEY=' + btoa(pub));
console.log('SECRET_KEY=' + btoa(sec));
})"
# publicKey must be exactly 1952 bytes (ML-DSA-65 public key) # meta is not supported for X.509 CAs — omit it CERT_PEM=$(curl -s -X POST $BASE_URL/ca/issue \ -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" \ -d "{ \"subject\": \"device-serial-00123\", \"publicKey\": \"$DEVICE_PUBLIC_KEY\", \"expiresInSeconds\": 31536000 }") CERT_ID=$(echo $CERT_PEM | jq -r '.meta.certId') echo $CERT_PEM | jq -r '.certificate' # prints PEM
A complete example: issue a certificate, sign a device message, and verify it on the server including cert chain validation, expiry check, and replay protection.
# Set DEVICE_PUBLIC_KEY and DEVICE_SECRET_KEY from REST 11b above
# Already covered in REST 11b — CERT_PEM and CERT_ID set
node -e "
import('@noble/post-quantum/ml-dsa.js').then(({ml_dsa65}) => {
const message = JSON.stringify({ deviceId: 'device-serial-00123', timestamp: Date.now(), value: 42.5 });
const secretKeyB64 = process.env.DEVICE_SECRET_KEY;
const bin = atob(secretKeyB64);
const secretKey = new Uint8Array(bin.length);
for(let i = 0; i < bin.length; i++) secretKey[i] = bin.charCodeAt(i);
const msgBytes = new TextEncoder().encode(message);
const signature = ml_dsa65.sign(msgBytes, secretKey);
let sigStr = '';
for(let i = 0; i < signature.length; i++) sigStr += String.fromCharCode(signature[i]);
console.log(JSON.stringify({ message, signature: btoa(sigStr), certificate: process.env.DEVICE_CERT_PEM }));
})"
Verify the certificate chain using the JS SDK's pq.ca.verifyX509Cert(certPem, rootPem) or Python's pq.ca.verify_x509_cert(cert_pem, root_pem) (requires cryptography>=48.0.0). Then verify the message signature with ml_dsa65.verify().
notAfter).GET /ca/crl for any operation where revocation matters.DEVICE_SECRET_KEY in a secrets manager or hardware secure element — never in plaintext.Requires dashboard session (cookie auth). No token cost. Rotates the ML-DSA keypair for a specific project. The previous key remains valid for as long as a token signed with it could still be alive — currently up to 5 years, matching /sign's maximum expiresInSeconds — so tokens signed before rotation continue to verify successfully throughout that window.
curl -s -X POST $BASE_URL/projects/proj_YOUR_PROJECT_ID/rotate-keys -H "Content-Type: application/json" -b "__Host-pq_session=YOUR_SESSION_COOKIE" | jq
GET /public-key for offline token verification, fetch the new public key after rotation and update your local copy.
FIPSign sends HTTP POST requests to your endpoint when events occur. Webhook configuration is dashboard-only — it requires a user session and is not available via API key.
import crypto from 'crypto' app.post('/webhooks/fipsign', express.json(), (req, res) => { const sig = req.headers['x-pqauth-signature'] const expected = 'sha256=' + crypto.createHmac('sha256', process.env.FIPSIGN_WEBHOOK_SECRET) .update(JSON.stringify(req.body)).digest('hex') if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return res.status(401).send('Invalid signature') const { event, data } = req.body // handle event... res.status(200).send('ok') })
"revoked" when the token was revoked; for every other rejection, the same text /verify returns in error, for example "Token expired 120 seconds ago" (all the texts are in REST 04)A Mandate is a signed ML-DSA credential that defines what an agent can do, for how long, and with what budget.
Base URL: https://api.fipsign.dev · Auth: X-API-Key: pqa_your_key
Mandate is a PQ-Sign feature — not a separate product. It uses the same ML-DSA signing infrastructure as /sign, extended with a real-time mutable state layer that gives you per-agent control.
id (the mandate ID), agentId, issuedBy, scopeOriginal, budgetTotal, projectId, issuedAt, expiresAt. Cannot be altered — any change invalidates the signature.scopeCurrent, budgetConsumed, status. scopeCurrent and status change through PATCH /mandate/:id; budgetConsumed grows with every granted POST /mandate/verify. None of this invalidates the token. If you passed agentPublicKey, that key is also stored server-side — it is fixed at emission and can never be changed.POST /mandate/verify. If you want to guarantee the caller is the specific agent the mandate was issued to (not just anyone who obtained the token), pass agentPublicKey when emitting the mandate. The agent then signs each action with its own private key, and POST /mandate/verify requires that signature in addition to the token. The agent's signature covers that exact call (mandate, action and cost), lives at most 60 seconds and works only once. See sections 01, 02 and 02b for details.X-API-Key to call POST /mandate/verify — and by default, that same key can call every other endpoint in the system (/sign, /ca/issue, PATCH /mandate/:id, etc.). If the agent process is compromised, a full-access key is a much bigger blast radius than the mandate alone. To avoid this, create the agent's key as an agent key — restricted scope from the dashboard (your project → + New key → check "Agent key — restricted scope"; there is no API or SDK call to create it — see REST 00): a key created this way can only call POST /mandate/verify, everywhere else it is rejected exactly as if it didn't exist. This is independent of proof of possession — use either, both, or neither depending on how much you trust the agent's environment.
budgetTotal is abstract — the unit means whatever you define it to mean. It could be dollars, API calls, credits, operations, or any other countable resource. You pass a cost on each POST /mandate/verify call and the system deducts it from the budget. When budgetTotal: 0, budget checking is disabled — the agent can act any number of times until expiry or revocation; cost is still required on every verify call and is still added to budgetConsumed, only the limit check is skipped. All budget and scope rules in one place: 00b — Integration guide.
Read this before you write code. It answers the questions that come up in almost every integration: which credential lives where, who should call verify, what cost really means, and what the scope and budget rules are. Everything here follows the behavior of the API.
| Credential | Held by | What it is for | If it leaks |
|---|---|---|---|
Project API key (pqa_…, full access) | Your backend only | Emit, PATCH, GET and verify. | Anyone holding it can emit mandates and control every existing one. Revoke it from the dashboard and create a new one. Never give it to an agent. |
| Agent key (restricted scope) | The process that calls verify, when that is not your backend | Only POST /mandate/verify. Every other endpoint rejects it with 401, as if it did not exist. It is created only in the dashboard (your project → + New key → check "Agent key — restricted scope"): there is no API or SDK call to create it (see REST 00). | The holder can spend budget of mandates it also has the token for — nothing else. Revoke the key from the dashboard. |
Mandate token (the token object) | The agent, or the executor acting for it | Proves the mandate was issued by your project. FIPSign does not store it: if you lose it, emit a new mandate. | It is a bearer credential: whoever holds it and an API key of your project can call verify — unless the mandate uses proof of possession (below). Revoke the mandate with PATCH /mandate/:id. |
Agent private key (only with agentPublicKey) | The agent only | Signs every verify call (proof of possession, see 02b). FIPSign only ever sees the public half. | Revoke the mandate and emit a new one with a new keypair — the public key of a mandate can never be changed. |
scope and the budget. agentId and issuedBy are labels: they are signed into the token and returned by GET, but nothing is compared against them at verify time — they are for your audit trail.
your backend / executor FIPSign the agent
──────────────────────── ─────── ─────────
1. POST /mandate ─────────────────► signs, registers state
◄───────────────── { id, token }
2. keep the id, hand the token over ──────────────────────────────► holds the token
│ "please do X"
3. ◄───────────────────────────────────────────────────────────────────┘
work out the real cost of X
POST /mandate/verify
{ token, action: "X", cost } ─► checks signature, status, expiry,
scope, budget — atomically
◄─ granted / denied
4. only if granted: perform X
| Pattern | Use it when | Trade-offs |
|---|---|---|
| A. Your executor calls verify (recommended) | The agent reaches your systems through code you control: a tool server, a backend, an API gateway. | The agent never holds an API key. cost is computed by you, so the budget is trustworthy. Proof of possession is optional. |
| B. The agent calls verify itself | Autonomous agents, devices in the field, or code you do not control. | Give it an agent key (restricted scope, created in the dashboard) and emit the mandate with agentPublicKey so a copied token is useless. cost is declared by the agent, so the budget only holds if the agent is honest (see below). |
cost you send on each verify call is deducted from budgetTotal. They are independent — a verify call that consumes 500 budget units still costs the same platform tokens as one that consumes 0.cost. FIPSign has no table of action prices: the caller of verify reports cost and the server enforces the limit on the number it is given. So the budget is only as trustworthy as the caller. Under pattern A, compute cost yourself from what the action really does (rows read, dollars moved, tokens spent) — never take it from the agent's request. Under pattern B, proof of possession makes sure nobody can change the cost in transit, but a compromised agent can still under-report it.
| Topic | Rule |
|---|---|
| Matching an action | Exact, case-sensitive string comparison against scopeCurrent. There are no wildcards or prefixes: claims:* does not match claims:approve, and Claims:Approve does not match claims:approve. Leading and trailing spaces are trimmed on both sides. |
| Scope limits | 1–20 items, each at most 64 characters, duplicates removed. Choose a naming convention such as resource:verb and use exactly those strings in your executor. |
| The scope only means something if you call verify | FIPSign cannot know what your code does. A mandate that allows read:crm does not stop your code from doing something else; it only guarantees that the verify call for read:crm was granted. Your executor must call verify with the action it is about to perform, right before performing it. |
| Narrowing | Only ever shrinks: the new scope must be a non-empty subset of the current scope. An item that was removed cannot be added back — emit a new mandate instead. |
| Budget check | A call is granted while budgetConsumed + cost ≤ budgetTotal. Landing exactly on budgetTotal is allowed; after that only cost: 0 calls are granted. |
| No limit | budgetTotal: 0 disables the limit check. cost is still required and still accumulates in budgetConsumed, so you can measure usage. budgetRemaining is always 0 in that case — use budgetConsumed. |
| Concurrency | The state of each mandate is processed one call at a time on the server, so parallel calls cannot overspend it. With a budget of 5 and ten simultaneous calls of cost 1, exactly five are granted and five get budget_exhausted. Different mandates never block each other. |
| Denied calls | Never change budgetConsumed and never cost platform tokens. |
| More budget / more time | Not possible on an existing mandate: budgetTotal and expiresAt are signed into the token. Emit a new mandate (and revoke the old one if it must stop working). To avoid gaps, emit the replacement a little before the current one expires — expiresInSeconds in every granted response tells you how long is left. |
| Status | active ⇄ suspended can be toggled any number of times. revoked is permanent. narrow also works while suspended. |
| Expiry and retention | There is no expired status. After expiresAt verify answers mandate_expired and PATCH answers 409 (except revoke). The mandate can still be read with GET for 24 hours, then it is deleted and GET returns 404. |
POST /mandate, and is not stored by FIPSign. Store it where only the component that needs it can read it, and store the mandate id next to it in your own database: the id is what you need to suspend, narrow, revoke and inspect the mandate later. Use GET /mandate (section 05) for audits and reconciliation, not to find ids you did not save.PATCH /mandate/:id with {"action": "revoke"} — and emit a new one. Revocation is checked on every verify call, so it takes effect for the next call. It cannot undo a call that was already granted.
Issues a signed mandate for an agent or device. Signs the payload with ML-DSA (using the project's configured algorithm) and registers the mutable state server-side. The token is returned to you and not stored on the server — store it securely and pass it to the agent. Cost: 2 tokens.
curl -s -X POST https://api.fipsign.dev/mandate \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "agentId": "agent-reporting-v2", "issuedBy": "[email protected]", "scope": ["sign", "verify", "read:crm"], "budgetTotal": 1000, "expiresInSeconds": 28800 }'
MANDATE=$(curl -s -X POST https://api.fipsign.dev/mandate \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{"agentId":"agent-test","issuedBy":"[email protected]","scope":["sign","verify"],"budgetTotal":100,"expiresInSeconds":3600}') MANDATE_ID=$(echo $MANDATE | jq -r '.mandate.id') MANDATE_TOKEN=$(echo $MANDATE | jq -c '.mandate.token')
| Field | Type | Required | Description |
|---|---|---|---|
| agentId | string | Yes | Identifier for the agent or device. Max 128 characters. Covered by ML-DSA signature — immutable after emission. |
| issuedBy | string | Yes | Who authorized this mandate (e.g. email, user ID, system name). Max 256 characters. Covered by ML-DSA signature — immutable. |
| scope | string[] | Yes | Actions the agent is authorized to perform. 1–20 items, each max 64 characters. Free-form strings that you define (for example claims:approve). Leading/trailing spaces are trimmed and duplicates are removed automatically. At verify time an action must match one item exactly — case-sensitive, no wildcards (see 00b). Covered by ML-DSA signature as scopeOriginal — immutable. |
| budgetTotal | integer ≥ 0 | Yes | Maximum budget units for this mandate (whole number, max 9,007,199,254,740,991). Abstract — you define what a unit means. 0 disables budget checking entirely (cost is still required on every verify call). It cannot be increased later — to give an agent more budget, emit a new mandate. Covered by ML-DSA signature — immutable. |
| expiresInSeconds | integer | Yes | Mandate lifetime in seconds (whole number). Min 60 (1 minute), max 2,592,000 (30 days). A mandate cannot be extended afterwards — emit a new one. |
| agentPublicKey | string | No | Base64 of the agent's raw ML-DSA public key, generated by you with any ML-DSA library (it has nothing to do with the CA). Any of the three variants is accepted, whatever algorithm your project uses — the decoded key must be exactly 1312 (ML-DSA-44), 1952 (ML-DSA-65) or 2592 (ML-DSA-87) bytes, otherwise the request is rejected with 400 before anything is charged. Max 4096 characters. Stored once: it cannot be changed later (to change the agent's key, emit a new mandate) and is never returned by the API — GET only shows requiresAgentSignature. If provided, POST /mandate/verify requires the agent to sign each call with the matching private key (proof of possession) — see sections 02 and 02b. If omitted, the mandate is a plain bearer token. |
POST /sign. If lost, you must emit a new mandate. The mandate id is stored server-side and is what you use for PATCH and GET operations.
agentPublicKey is the plain, raw ML-DSA public key of a keypair that you generate — it is not tied to the CA (POST /ca/issue) and needs no certificate. Any of the three variants works (ML-DSA-44, -65 or -87), whatever algorithm your project uses; the size of the decoded key tells them apart: 1312, 1952 or 2592 bytes. Use any FIPS 204 ML-DSA library — for example @noble/post-quantum in JavaScript or cryptography>=48.0.0 in Python (both are used in the copy-paste examples of 02b). Keep the private key on the agent and never send it anywhere: only the public key goes into this request.
The agent calls this before executing any authorized action. Verification runs in two phases: first the ML-DSA signature (cryptographic integrity), then the live mutable state (current scope, budget, status). Only if both pass is the action granted. Cost: 2 tokens — only charged on granted responses; denied calls are free.
invalid_signature, mandate_expired, mandate_revoked, mandate_suspended, scope_not_authorized, budget_exhausted and all four agent_signature_* reasons — consumes neither platform tokens nor mandate budget. A denial is HTTP 403 with a JSON body, not a transport error: read result and reason from the body (some HTTP clients throw on any non-2xx status — see 02c).
curl -s -X POST https://api.fipsign.dev/mandate/verify \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d "{\"token\": $MANDATE_TOKEN, \"action\": \"sign\", \"cost\": 5}"
| Field | Type | Required | Description |
|---|---|---|---|
| token | object | Yes | The full PQToken object returned by POST /mandate — all four fields: payload, signature, algorithm, issuedAt. |
| action | string | Yes | The action the agent wants to perform. Must match one item of scopeCurrent exactly (case-sensitive); leading/trailing spaces are trimmed. Max 64 characters. |
| cost | integer ≥ 0 | Yes | Budget units to add to budgetConsumed if the call is granted (whole number, max 9,007,199,254,740,991). Required on every call — pass 0 for actions with no budget impact. When budgetTotal is 0 it is still required and still added to budgetConsumed; only the limit check is skipped, so consumption stays visible. If the mandate uses proof of possession, this value must equal the cost signed inside agentSignature. Not to be confused with the platform tokens FIPSign charges you (see 06). |
| agentSignature | object | Only if the mandate has agentPublicKey | Proof-of-possession signature: an object with payload, signature and algorithm (plus an optional issuedAt), signed with the agent's private key — not the project's. algorithm is the variant of the agent's key, written exactly "ML-DSA-44", "ML-DSA-65" or "ML-DSA-87". Required when the mandate was emitted with agentPublicKey; ignored for mandates without it. Full contract and code in 02b. |
agentSignature — short version. The agent signs, with its own private key, this JSON: {"sub": "<mandateId>", "action": "<action>", "cost": <cost>, "iat": <now>, "exp": <now + 30>} — sub, action and cost must be exactly the mandate's id and the action and cost of the same request. It base64-encodes that JSON, signs the base64 text, and sends {payload, signature, algorithm, issuedAt} as agentSignature, where algorithm is the variant of the agent's key written exactly "ML-DSA-44", "ML-DSA-65" or "ML-DSA-87" (any other text is refused). Rules: iat and exp are mandatory, exp − iat may not exceed 60 seconds, iat may not be more than 30 seconds in the future, and each signature works only once. Extra fields in the payload are ignored (handy for a nonce). Step by step, with Node and Python code, in 02b.
invalid_signature — or mandate_expired when the token is genuine but past its expiry. 2) If the mandate requires agentSignature: is it present, does it verify, is its lifetime valid, does it match this mandate, action and cost (agent_signature_required / _invalid / _mismatch). 3) The live state, in this order: mandate_revoked → mandate_suspended → mandate_expired → agent_signature_replayed → scope_not_authorized → budget_exhausted. Only when everything passes is the budget charged and the action granted; a denial changes nothing.agentSignature answers agent_signature_required, not mandate_revoked. Also, if a mandate is both suspended and out of budget you get mandate_suspended: status is always checked before budget.
POST /mandate/verify reports cost and the server trusts it. That is exactly right when the caller is your own executor, which knows the real cost of the action (recommended — see 00b). If the agent calls this endpoint directly (for example via LLM function-calling with no layer in between), a compromised or misled agent can under-report the cost — even send 0 — and budgetConsumed will never reflect real usage. Proof of possession binds the cost to the agent's signature, so nobody can change it in transit, but it does not make it truthful. If you can't fully trust the caller, put a layer between the agent and this endpoint that computes cost independently, or treat the budget as advisory rather than a hard guarantee.
Turn a mandate from a bearer token into one that only a specific agent can use. This section is the full recipe: the exact signing contract, copy-paste code for Node.js and Python, and what every error means.
generateAgentKeyPair() / generate_agent_key_pair() make the key pair and signAgentCall() / sign_agent_call() build the signature in a few lines. See SDK 14 — Proof of possession with the SDK (JS) or PY 13 — Proof of possession with the SDK (Python). Keep reading if you call the REST API directly or want to know what happens under the hood.algorithm of the key). The JS SDK's secretKey is the full expanded key (2560, 4032 or 4896 bytes) and rejects a seed with INVALID_SECRET_KEY: generate and sign with the same tool.
agentPublicKey. It is stored once and can never be changed.{sub, action, cost, iat, exp} — with its private key.action, cost and that signature as agentSignature. FIPSign runs all the usual checks and, in addition, verifies the signature against the stored public key. Each signature works once.
Any FIPS 204 ML-DSA library works, and any of the three variants: the project's algorithm does not matter, and the agent's key is unrelated to the CA. The examples use ML-DSA-44 (smallest and fastest). Whichever variant you pick, you will write its name in the algorithm field of every signature (Step 3). This table is the complete list: the text in the second column is what you type, character for character.
| Variant of the agent's key | Text for algorithm | Node.js (@noble/post-quantum) | Python (cryptography) | Public key | Signature |
|---|---|---|---|---|---|
| ML-DSA-44 | "ML-DSA-44" | ml_dsa44 | MLDSA44PrivateKey | 1312 bytes | 2420 bytes |
| ML-DSA-65 | "ML-DSA-65" | ml_dsa65 | MLDSA65PrivateKey | 1952 bytes | 3309 bytes |
| ML-DSA-87 | "ML-DSA-87" | ml_dsa87 | MLDSA87PrivateKey | 2592 bytes | 4627 bytes |
"ml-dsa-44", "ML-DSA44", "ml_dsa44", "ML_DSA_44" or "ML-DSA-44 " (with a trailing space) are all refused. The answer is agent_signature_invalid — the same as for a bad signature — so a typo here is easy to miss. Do not take the text from the name of a class or package (ml_dsa44, MLDSA44PrivateKey): those are names of code, not values of algorithm.algorithm must come from the same row of this table. To use another variant, change both, in Step 1 and in Step 3. If you ever lose track of the variant of a key, the size of its public key tells: 1312 bytes is ML-DSA-44, 1952 is ML-DSA-65, 2592 is ML-DSA-87.
// npm install @noble/post-quantum (Node 20+) import { ml_dsa44 } from '@noble/post-quantum/ml-dsa.js' import { randomBytes } from 'node:crypto' // The 32-byte seed IS the private key. It is the only secret you keep. const seed = randomBytes(32) // ml_dsa44 is the "ML-DSA-44" row of the table above const { publicKey } = ml_dsa44.keygen(seed) // Goes into "agentPublicKey" when you emit the mandate const agentPublicKey = Buffer.from(publicKey).toString('base64') // Save this on the agent side (secret store, or a file only the agent can read), never share it. // Step 3 uses it under the same name: storedSeed const storedSeed = Buffer.from(seed).toString('base64')
# pip install "cryptography>=48.0.0" import base64 from cryptography.hazmat.primitives.asymmetric.mldsa import MLDSA44PrivateKey # MLDSA44PrivateKey is the "ML-DSA-44" row of the table above agent_key = MLDSA44PrivateKey.generate() # The 32-byte seed IS the private key. It is the only secret you keep. seed = agent_key.private_bytes_raw() # Goes into "agentPublicKey" when you emit the mandate public_raw = agent_key.public_key().public_bytes_raw() agent_public_key = base64.b64encode(public_raw).decode() # Save this on the agent side (secret store, or a file only the agent can read), never share it. # Step 3 uses it under the same name: stored_seed stored_seed = base64.b64encode(seed).decode()
chmod 600). Only the public key travels to your backend.cryptography needs version 48.0.0 or newer (the same requirement as the FIPSign Python SDK). A cryptography built against an old system OpenSSL may raise UnsupportedAlgorithm; the regular pip wheel works.
curl -s -X POST https://api.fipsign.dev/mandate \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d "{\"agentId\":\"agent-42\",\"issuedBy\":\"[email protected]\",\"scope\":[\"sign\"],\"budgetTotal\":100,\"expiresInSeconds\":3600,\"agentPublicKey\":\"$AGENT_PUBLIC_KEY\"}"
The response is the usual one, plus "requiresAgentSignature": true. A wrong-sized key is rejected with 400 at this point — nothing is charged — so a mandate that could never be verified cannot be created by mistake.
The signature is a token with the same shape as any FIPSign token: payload (base64 of a JSON), signature, algorithm. This is the whole contract:
| Item | Rule |
|---|---|
| What is signed | The text of the base64 payload string, encoded as UTF-8 bytes — not the JSON itself and not the decoded bytes. This is the most common mistake (see the table below). |
| Signature scheme | Plain ML-DSA (FIPS 204): empty context string, no pre-hash (not HashML-DSA). Both libraries above do this by default. |
payload | base64 (standard alphabet, with padding) of the UTF-8 JSON {"sub", "action", "cost", "iat", "exp"}. At most 16,384 characters. |
sub | Exactly the mandate id. |
action | Exactly the action you send in the same request (without leading/trailing spaces). |
cost | Exactly the cost you send in the same request, as a number. A payload without cost never matches. |
iat, exp | Unix seconds, both mandatory. exp must be after iat; exp − iat may not exceed 60 seconds; iat may not be more than 30 seconds ahead of the server clock; and exp must not have passed. The examples use iat = now, exp = now + 30. |
algorithm | "ML-DSA-44", "ML-DSA-65" or "ML-DSA-87" — exactly one of these three texts (see the table in Step 1), the one that matches the agent's key. Write it in every signature: FIPSign uses this text to pick the verification algorithm, and a raw ML-DSA key carries no label of its own. Any other text ("ml-dsa-44", "ML-DSA44"…) or the wrong variant gives agent_signature_invalid; a missing one, HTTP 400. (The SDKs handle it: the JS SDK works it out from the key, the Python SDK takes it as algorithm=.) |
signature | base64 of the raw signature: 2420, 3309 or 4627 bytes depending on the variant. |
issuedAt | Optional and ignored. Included in the examples only because it is part of the usual token shape. |
| Extra payload fields | Ignored, but covered by the signature. Add a random nonce if your signer is deterministic (see the note below). |
| Lifetime and reuse | Each signature is accepted once. It is only recorded when the call is granted, so a denied call (for example budget_exhausted) does not burn it — but do not rely on that: sign again for every attempt. |
import { ml_dsa44 } from '@noble/post-quantum/ml-dsa.js' // Exactly this text, in every signature. Same row of the table as ml_dsa44 (Step 1). const ALGORITHM = 'ML-DSA-44' // storedSeed: the base64 seed that Step 1 saved function signCall(storedSeed, mandateId, action, cost) { // Re-derive the signing key from the seed const { secretKey } = ml_dsa44.keygen(Buffer.from(storedSeed, 'base64')) const now = Math.floor(Date.now() / 1000) const payload = { sub: mandateId, action, cost, iat: now, exp: now + 30 } const payloadB64 = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64') // Sign the base64 TEXT (UTF-8 bytes), not the JSON const message = new TextEncoder().encode(payloadB64) const signature = ml_dsa44.sign(message, secretKey) return { payload: payloadB64, signature: Buffer.from(signature).toString('base64'), algorithm: ALGORITHM, // exactly the text from the table in Step 1 issuedAt: now, // optional } }
import base64 import json import time from cryptography.hazmat.primitives.asymmetric.mldsa import MLDSA44PrivateKey # Exactly this text, in every signature. Same row of the table as MLDSA44PrivateKey (Step 1). ALGORITHM = "ML-DSA-44" def sign_call(stored_seed: str, mandate_id: str, action: str, cost: int) -> dict: # stored_seed: the base64 seed that Step 1 saved. Re-derive the signing key from it. agent_key = MLDSA44PrivateKey.from_seed_bytes(base64.b64decode(stored_seed)) now = int(time.time()) payload = { "sub": mandate_id, "action": action, "cost": cost, "iat": now, "exp": now + 30, } payload_b64 = base64.b64encode(json.dumps(payload).encode("utf-8")).decode() # Sign the base64 TEXT, not the JSON signature = agent_key.sign(payload_b64.encode("ascii")) return { "payload": payload_b64, "signature": base64.b64encode(signature).decode(), "algorithm": ALGORITHM, # exactly the text from the table in Step 1 "issuedAt": now, # optional }
{
"token": { "payload": "eyJ...", "signature": "...", "algorithm": "ML-DSA-65", "issuedAt": 1785690939 },
"action": "sign",
"cost": 1,
"agentSignature": {
"payload": "eyJzdWIiOiJtZHRfLi4uIn0=",
"signature": "k3Jd8...",
"algorithm": "ML-DSA-44",
"issuedAt": 1785690940
}
}
token is the mandate token exactly as returned by POST /mandate (an object, not a string) and agentSignature is the object built in step 3. Send it to POST /mandate/verify as in section 02.
Two algorithm fields, two different things. token.algorithm belongs to the mandate token: send the token exactly as POST /mandate returned it and never edit it (it is the variant of your project; "ML-DSA-65" in the example). agentSignature.algorithm is the one you write in Step 3: the variant of the agent's key ("ML-DSA-44" in the example). They may differ, and that is fine.
| Response | Typical cause | Fix |
|---|---|---|
agent_signature_required | agentSignature missing, or not an object. | Sign the call and send it. Mandates emitted with agentPublicKey always need it. |
agent_signature_invalid | Signed with the wrong private key; algorithm is not exactly one of "ML-DSA-44", "ML-DSA-65", "ML-DSA-87" (for example "ml-dsa-44") or is not the key's variant; signed the JSON or the decoded bytes instead of the base64 text; payload or signature altered after signing; iat/exp missing or out of range; the signature already expired. | Check the contract table above, then the clock: the agent's clock must be close to real time (see below). |
agent_signature_mismatch | The signature is valid but its sub, action or cost differ from the request — including a payload with no cost, or an action sent with different spelling. | Sign exactly the values you send. Change one and you must sign again. |
agent_signature_replayed | This exact signature was already accepted. | Never cache or reuse signatures: sign again for every call. |
HTTP 400 Invalid "agentSignature" format… | The object lacks payload, signature or algorithm. | Send all three (and issuedAt for completeness). |
exp = now + 60: it tolerates an agent clock up to 60 seconds behind the server; the limit for a clock ahead stays at 30 seconds.agent_signature_replayed. Add a random nonce field to the payload to avoid it.action with accents or other non-ASCII characters then works like any other.Emits a mandate for a fresh keypair and then runs the four interesting cases against the live API. Save as pop-demo.mjs, run npm install @noble/post-quantum, then FIPSIGN_API_KEY=pqa_... node pop-demo.mjs (Node 20+).
// pop-demo.mjs — proof of possession, end to end. // Setup: npm install @noble/post-quantum (Node 20+) // Run: FIPSIGN_API_KEY=pqa_... node pop-demo.mjs import { ml_dsa44 } from '@noble/post-quantum/ml-dsa.js' import { randomBytes } from 'node:crypto' const API = 'https://api.fipsign.dev' const b64 = (bytes) => Buffer.from(bytes).toString('base64') // A denied verify is HTTP 403 with a JSON body: // return status + body instead of throwing. async function call(method, path, body) { const res = await fetch(API + path, { method, headers: { 'Content-Type': 'application/json', 'X-API-Key': process.env.FIPSIGN_API_KEY, }, body: body && JSON.stringify(body), }) return { status: res.status, data: await res.json() } } // ── AGENT SIDE ────────────────────────────────────────────────────────── // The agent creates its own keypair. The seed is the private key: keep it secret // (in real life, store b64(seed) in a secret store). const seed = randomBytes(32) const { publicKey, secretKey } = ml_dsa44.keygen(seed) // Sign ONE call: this mandate + this action + this cost. Valid 30 s, usable once. function signCall(mandateId, action, cost) { const now = Math.floor(Date.now() / 1000) const payload = { sub: mandateId, action, cost, iat: now, exp: now + 30 } const payloadB64 = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64') // Sign the base64 TEXT (UTF-8 bytes), not the JSON const message = new TextEncoder().encode(payloadB64) const signature = ml_dsa44.sign(message, secretKey) return { payload: payloadB64, signature: b64(signature), algorithm: 'ML-DSA-44', // exactly this text; it pairs with ml_dsa44 issuedAt: now, } } // ── YOUR BACKEND ──────────────────────────────────────────────────────── // 1. Emit the mandate, registering the agent's PUBLIC key. const emitted = await call('POST', '/mandate', { agentId: 'agent-pop-demo', issuedBy: '[email protected]', scope: ['sign'], budgetTotal: 10, expiresInSeconds: 3600, agentPublicKey: b64(publicKey), }) if (!emitted.data.success) { throw new Error('emit failed: ' + JSON.stringify(emitted.data)) } const { id, token, requiresAgentSignature } = emitted.data.mandate console.log('emitted', id, '· requiresAgentSignature:', requiresAgentSignature) // 2. Without agentSignature the mandate token alone is useless. let r = await call('POST', '/mandate/verify', { token, action: 'sign', cost: 1 }) console.log('no signature →', r.status, r.data.result, r.data.reason) // 3. With the agent's signature over THIS call: granted. const agentSignature = signCall(id, 'sign', 1) const body = { token, action: 'sign', cost: 1, agentSignature } r = await call('POST', '/mandate/verify', body) console.log('with signature →', r.status, r.data.result, '· budgetRemaining:', r.data.budgetRemaining) // 4. Sending the same signature again is refused: each signature works once. r = await call('POST', '/mandate/verify', body) console.log('same signature →', r.status, r.data.result, r.data.reason) // 5. A signature made for cost 1 cannot be used for cost 9. const bad = { token, action: 'sign', cost: 9, agentSignature: signCall(id, 'sign', 1) } r = await call('POST', '/mandate/verify', bad) console.log('cost changed →', r.status, r.data.result, r.data.reason)
The same flow. Save as pop_demo.py, run pip install "cryptography>=48.0.0" requests, then FIPSIGN_API_KEY=pqa_... python pop_demo.py.
# pop_demo.py — proof of possession, end to end. # Setup: pip install "cryptography>=48.0.0" requests # Run: FIPSIGN_API_KEY=pqa_... python pop_demo.py import base64 import json import os import time import requests from cryptography.hazmat.primitives.asymmetric.mldsa import MLDSA44PrivateKey API = "https://api.fipsign.dev" HEADERS = {"X-API-Key": os.environ["FIPSIGN_API_KEY"]} def b64(data: bytes) -> str: return base64.b64encode(data).decode() def call(method, path, body=None): # A denied verify is HTTP 403 with a JSON body: # return status + body instead of raising. r = requests.request(method, API + path, headers=HEADERS, json=body, timeout=30) return r.status_code, r.json() # ── AGENT SIDE ────────────────────────────────────────────────────────── # The agent creates its own keypair. The seed is the private key: keep it secret # (in real life, store b64(agent_key.private_bytes_raw()) in a secret store). agent_key = MLDSA44PrivateKey.generate() agent_public_key = b64(agent_key.public_key().public_bytes_raw()) def sign_call(mandate_id: str, action: str, cost: int) -> dict: """Sign ONE call: mandate + action + cost. Valid 30 s, usable once.""" now = int(time.time()) payload = { "sub": mandate_id, "action": action, "cost": cost, "iat": now, "exp": now + 30, } payload_b64 = b64(json.dumps(payload).encode("utf-8")) # Sign the base64 TEXT, not the JSON signature = agent_key.sign(payload_b64.encode("ascii")) return { "payload": payload_b64, "signature": b64(signature), "algorithm": "ML-DSA-44", # exactly this text; it pairs with MLDSA44PrivateKey "issuedAt": now, } # ── YOUR BACKEND ──────────────────────────────────────────────────────── # 1. Emit the mandate, registering the agent's PUBLIC key. status, data = call("POST", "/mandate", { "agentId": "agent-pop-demo", "issuedBy": "[email protected]", "scope": ["sign"], "budgetTotal": 10, "expiresInSeconds": 3600, "agentPublicKey": agent_public_key, }) if not data.get("success"): raise SystemExit(f"emit failed: {data}") mandate = data["mandate"] mandate_id, token = mandate["id"], mandate["token"] print("emitted", mandate_id, "· requiresAgentSignature:", mandate["requiresAgentSignature"]) # 2. Without agentSignature the mandate token alone is useless. body = {"token": token, "action": "sign", "cost": 1} status, r = call("POST", "/mandate/verify", body) print("no signature →", status, r["result"], r.get("reason")) # 3. With the agent's signature over THIS call: granted. body["agentSignature"] = sign_call(mandate_id, "sign", 1) status, r = call("POST", "/mandate/verify", body) print("with signature →", status, r["result"], "· budgetRemaining:", r.get("budgetRemaining")) # 4. Sending the same signature again is refused: each signature works once. status, r = call("POST", "/mandate/verify", body) print("same signature →", status, r["result"], r.get("reason")) # 5. A signature made for cost 1 cannot be used for cost 9. body = {"token": token, "action": "sign", "cost": 9, "agentSignature": sign_call(mandate_id, "sign", 1)} status, r = call("POST", "/mandate/verify", body) print("cost changed →", status, r["result"], r.get("reason"))
True in Python and true in Node)emitted mdt_… · requiresAgentSignature: true no signature → 403 denied agent_signature_required with signature → 200 granted · budgetRemaining: 9 same signature → 403 denied agent_signature_replayed cost changed → 403 denied agent_signature_mismatch
What to do with every answer verify can give, how to retry safely, and the mistakes that cost integrators the most time.
| HTTP | Meaning | What your code should do |
|---|---|---|
| 200 | result: "granted". The budget was charged. | Perform the action. |
| 403 | result: "denied" with a reason. A normal answer, not a failure of the call: it is free and changes nothing. | Do not perform the action. Decide what to do from the reason (next table). Some HTTP clients throw on any non-2xx status: catch that and still read the JSON body. |
| 400 / 415 | The request itself is malformed (error says what). | A bug in your code — fix it. Retrying the same request will fail the same way. Nothing was charged. |
| 401 | API key missing or invalid, or an agent key used somewhere other than verify. | Fix the key. Nothing was charged. |
| 429 | Rate limit exceeded… ("code": "rate_limited") or Token limit reached… ("code": "token_quota_exhausted"). In both cases the mandate budget was not consumed. | Rate limit: wait the seconds in the Retry-After header and retry. Token limit: your monthly platform tokens are exhausted — buy a pack from the dashboard. |
| 5xx / timeout / network error | Unknown outcome. | See "Retries" below. |
result === "granted". Everything else — a denial, a timeout, a 5xx, a body you cannot parse — means "do not act". This helper does exactly that.
// ok: true only when FIPSign explicitly granted the action. // Anything else means "do not act". async function authorize({ token, action, cost, agentSignature }) { const headers = { 'Content-Type': 'application/json', 'X-API-Key': process.env.FIPSIGN_API_KEY, } const body = { token, action, cost, ...(agentSignature && { agentSignature }) } let res try { res = await fetch('https://api.fipsign.dev/mandate/verify', { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(10_000), }) } catch { // Timeout or network failure: the call may or may not have been applied // (see "Retries and idempotency" below) return { ok: false, reason: 'network_error', unknownOutcome: true } } // A 403 carries a JSON body too: it is a normal answer const data = await res.json().catch(() => null) if (res.status === 200 && data?.result === 'granted') { return { ok: true, budgetRemaining: data.budgetRemaining } } return { ok: false, status: res.status, reason: data?.reason ?? data?.error ?? 'unknown', unknownOutcome: res.status >= 500, } }
import os import requests API = "https://api.fipsign.dev" def authorize(token, action, cost, agent_signature=None): """ok=True only when FIPSign explicitly granted the action. Anything else means "do not act".""" body = {"token": token, "action": action, "cost": cost} if agent_signature: body["agentSignature"] = agent_signature headers = {"X-API-Key": os.environ["FIPSIGN_API_KEY"]} try: r = requests.post(f"{API}/mandate/verify", json=body, headers=headers, timeout=10) except requests.RequestException: # Timeout or network failure: the call may or may not have been applied # (see "Retries and idempotency" below) return {"ok": False, "reason": "network_error", "unknown_outcome": True} try: data = r.json() # a 403 carries a JSON body too: it is a normal answer except ValueError: data = {} if r.status_code == 200 and data.get("result") == "granted": return {"ok": True, "budget_remaining": data.get("budgetRemaining")} return { "ok": False, "status": r.status_code, "reason": data.get("reason") or data.get("error") or "unknown", "unknown_outcome": r.status_code >= 500, }
reason | Meaning | Typical response |
|---|---|---|
invalid_signature | The token was altered, is not a mandate token (for example one from POST /sign), belongs to another project, or was signed by a project key that is no longer accepted. | Reject and log it. Retrying will not help: it is either a bug or an attack. |
mandate_expired | The mandate is past its expiry (or was deleted 24 hours after it). | Stop. Emit a new mandate if the agent should continue. |
mandate_revoked | Permanently revoked. | Stop the agent for good. Never retry. |
mandate_suspended | Paused with PATCH … suspend. | Pause the work and retry later, with a delay — not in a tight loop. It works again after resume. |
scope_not_authorized | The action is not in scopeCurrent. The response includes authorizedScope. | Refuse the action. You may tell the agent what it is allowed to do. |
budget_exhausted | budgetConsumed + cost would exceed budgetTotal. The response includes budgetConsumedUnits and budgetTotalUnits. | Stop, or ask for a new mandate. A cheaper action (smaller cost) may still fit. |
agent_signature_required / _invalid / _mismatch / _replayed | Proof-of-possession failures. | A problem in the signing code — see 02b. |
agentSignature, while the signature is still valid (its exp, at most 60 seconds):granted → the first attempt had not been applied; it is now, exactly once.agent_signature_replayed → the first attempt was applied and its cost is already counted. Perform the action and do not retry again.GET /mandate/:id as described next.cost a second time if the first was applied. When the outcome is unknown, read GET /mandate/:id and compare budgetConsumed with the value you had before the call. For actions where a double charge is unacceptable, use proof of possession.429 Token limit reached restores the mandate budget (and, with proof of possession, releases the signature so you can send it again once you have tokens). 400, 401, 403 and 415 never consume anything.
usage.totalRemaining. Alert on it: when it reaches zero, verify calls start answering 429 and no agent can act, even though its mandate is perfectly valid.
expiresAt to decide whether a mandate is still valid — ask verify, or read expiresInSeconds from a granted response or from GET /mandate/:id.| What you see | Usual cause | Fix |
|---|---|---|
400 "token" is required | The token was sent as a string: wrapped in quotes in a shell body ("token": "$MANDATE_TOKEN"), or serialized twice (JSON.stringify(token) inside another JSON.stringify). | Send the token as a JSON object, exactly as returned by POST /mandate. |
400 "cost" must be a non-negative integer | cost is missing, a string ("5"), a decimal (1.5) or negative. | Always send a whole number ≥ 0 — 0 for actions with no budget impact. |
400 "cost" must not exceed 9007199254740991 | cost is larger than 9,007,199,254,740,991 (253 − 1). | Send a smaller whole number. |
403 invalid_signature with a token you just created | It came from POST /sign (or another project) — only tokens from POST /mandate are accepted here. | Use the token returned by POST /mandate. |
401 This is a Mandate token… on /verify, 400 on /revoke | The generic token endpoints refuse Mandate tokens on purpose. | Use POST /mandate/verify and PATCH /mandate/:id. |
401 API key required or invalid although the key is right | It is an agent key (restricted scope) used on emit, PATCH or GET. | Use a full-access key on your backend; agent keys only work on POST /mandate/verify. |
scope_not_authorized for an action that "is in the scope" | Different case or spelling, or a wildcard (claims:*) that is not supported. | Send the exact string, and read authorizedScope in the response. |
budgetRemaining is 0 but calls are granted | budgetTotal is 0 (no limit). | Use budgetConsumed to see usage. |
budgetConsumed is higher than you expected | Retried calls that had already been applied, or a cost that does not reflect the real cost. | See "Retries" above; compute cost in your executor. |
agent_signature_invalid although the code looks right | Signed the JSON instead of the base64 text; wrong algorithm (it must be exactly "ML-DSA-44", "ML-DSA-65" or "ML-DSA-87", and the one of the agent's key); clock drift. | See 02b. |
400 "agentPublicKey" has an invalid size | You sent a private key, a PEM/DER-wrapped key, hex instead of base64, or a truncated value. | Send the base64 of the raw public key: 1312, 1952 or 2592 bytes. |
Modify the mutable state of a mandate. No token cost. Four actions — each has different semantics and constraints. Requires only the mandate id — not the token itself. Rate limited to 60 req/min per API key.
curl -s -X PATCH https://api.fipsign.dev/mandate/$MANDATE_ID \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{"action": "narrow", "scope": ["verify"]}'
scopeCurrent permanently within the mandate's lifetime. The new scope must be a subset of the current scopeCurrent — not just scopeOriginal. Narrowing is monotonic: each call can only shrink further from wherever the scope stands now, so you cannot recover an action a previous narrow already removed, even if it's still within the originally signed scope. You can narrow multiple times, also while the mandate is suspended; narrowing to exactly the current scope is accepted and changes nothing. Items are trimmed like at emission. Use this when an agent's privileges should be reduced in response to a behavioral change or security concern.
curl -s -X PATCH https://api.fipsign.dev/mandate/$MANDATE_ID \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{"action": "suspend"}'
POST /mandate/verify will return mandate_suspended for any action. Does not affect scope or budget. Use when investigating an agent without permanently revoking it. Calling suspend on an already-suspended mandate succeeds with a shorter body — {"success": true, "id": "...", "status": "suspended", "message": "Already suspended"}, without scope or updatedAt.
curl -s -X PATCH https://api.fipsign.dev/mandate/$MANDATE_ID \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{"action": "resume"}'
suspended mandates — calling resume on an active mandate returns HTTP 409 (Only suspended mandates can be resumed); on a revoked or expired mandate it also returns 409. resume does not undo a narrow — scope stays narrowed after resume.
curl -s -X PATCH https://api.fipsign.dev/mandate/$MANDATE_ID \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{"action": "revoke"}'
POST /mandate/verify will always return mandate_revoked. No further PATCH operations are allowed on a revoked mandate. Use this when an agent is compromised or decommissioned. Note: revoke is the only PATCH action allowed on an expired mandate. Revoking an already-revoked mandate returns 409 (Cannot modify a revoked mandate) — if you retry a revoke after a network error, treat that 409 as success.
| action | Effect | Reversible? | Works on expired? | Body required |
|---|---|---|---|---|
| narrow | Reduces scopeCurrent to a subset of its current value (not just scopeOriginal) | No — scope can only shrink further | No | Yes — "scope": [...] |
| suspend | Sets status: suspended — blocks all verify calls | Yes — via resume | No | No |
| resume | Sets status: active — restores verify access | Yes — via suspend | No | No |
| revoke | Sets status: revoked — permanently blocks all verify calls | No | Yes | No |
Returns the current state of a mandate. Use this to inspect budget consumption, current scope, status, or remaining time. No token cost.
curl -s https://api.fipsign.dev/mandate/$MANDATE_ID \
-H "X-API-Key: $API_KEY" | jq
cost to budgetConsumed); a denied verify does not change itscopeOriginal vs scopeCurrent — scopeOriginal is what was signed and is immutable. scopeCurrent is the live scope after any narrows. After a narrow, scopeOriginal still shows the full original scope — useful for auditing what was originally granted vs what is currently enforced.
expired status: a mandate that has passed its expiry still reports the last status it had (usually active) with expiresInSeconds: 0 — check that field (or expiresAt) to know whether it can still be used. Verify calls answer mandate_expired and PATCH answers 409 (except revoke). You can still read the mandate for 24 hours after expiry; after that GET returns 404 Mandate not found and it disappears from the list.
Returns the mandates of the project associated with the API key, most recent first (issuedAt descending), one page at a time. No token cost. It is meant for inspection and auditing: keep the id of every mandate you emit in your own database rather than listing to find it.
curl -s "https://api.fipsign.dev/mandate?limit=50" \ -H "X-API-Key: $API_KEY" | jq
| Query parameter | Type | Required | Description |
|---|---|---|---|
| limit | integer | No | Page size, 1–100. Default 50. A value that is not a whole number in that range is rejected with 400. |
| cursor | string | No | Opaque, URL-safe value copied as is from nextCursor of the previous page. Omit it to get the first page. A value that was modified, truncated or is empty is rejected with 400. |
CURSOR="" while :; do PAGE=$(curl -s "https://api.fipsign.dev/mandate?limit=100${CURSOR:+&cursor=$CURSOR}" \ -H "X-API-Key: $API_KEY") echo "$PAGE" | jq -c '.mandates[] | {id, status, budgetConsumed}' CURSOR=$(echo "$PAGE" | jq -r '.nextCursor // empty') [ -z "$CURSOR" ] && break done
count is the size of the current page, not the total number of mandates in the project. A page can hold fewer than limit mandates (ones deleted after their retention period are skipped), so keep following nextCursor until it is null — do not stop at the first short page.
| Endpoint | Cost | Notes |
|---|---|---|
| POST /mandate | 2 tokens | Charged on every successful emission (rejected requests are free) |
| POST /mandate/verify | 2 tokens | Charged only on granted responses — denials are free. Independent of the cost you send, which is deducted from the mandate's own budget |
| PATCH /mandate/:id | 0 tokens | Control operations are always free |
| GET /mandate/:id | 0 tokens | Free |
| GET /mandate | 0 tokens | Free |
| Field | Limit |
|---|---|
| agentId | Max 128 characters |
| issuedBy | Max 256 characters |
| scope items | 1–20 items per mandate |
| scope item length | Max 64 characters per item |
| action (verify) | Max 64 characters |
| budgetTotal | Non-negative whole number, max 9,007,199,254,740,991 (253 − 1). 0 disables budget enforcement. |
| expiresInSeconds | Min 60 (1 min) — Max 2,592,000 (30 days) |
| agentPublicKey | Valid base64 that decodes to exactly 1312 (ML-DSA-44), 1952 (ML-DSA-65) or 2592 (ML-DSA-87) bytes; max 4096 characters |
| cost (verify) | Required, non-negative whole number, max 9,007,199,254,740,991 (253 − 1) |
| agentSignature lifetime | exp − iat at most 60 seconds; iat at most 30 seconds in the future; single use |
| GET /mandate page size | limit 1–100, default 50 |
| Retention after expiry | 24 hours (then GET returns 404 and the mandate leaves the list) |
| Endpoint | Limit | Window | Scope |
|---|---|---|---|
| POST /mandate | 300 requests | 1 minute | Per API key |
| POST /mandate/verify | 300 requests | 1 minute | Per API key |
| PATCH /mandate/:id | 60 requests | 1 minute | Per API key |
| GET /mandate/:id | 300 requests | 1 minute | Per API key |
| GET /mandate | 300 requests | 1 minute | Per API key |
| HTTP | Error / reason | Cause |
|---|---|---|
| 400 | Invalid body — expected JSON | The request body is not valid JSON |
| 400 | Invalid body — expected a JSON object | The body is valid JSON but is null, an array or a plain value |
| 400 | "agentId" is required | Missing or empty agentId |
| 400 | "agentId" must be at most 128 characters | agentId too long |
| 400 | "issuedBy" is required | Missing or empty issuedBy |
| 400 | "issuedBy" must be at most 256 characters | issuedBy too long |
| 400 | "scope" must be a non-empty array | scope missing, empty, or not an array (also returned by narrow) |
| 400 | "scope" must have at most 20 items | More than 20 scope items |
| 400 | "scope" items must be non-empty strings | Scope array contains non-string or empty values |
| 400 | "scope" items must be at most 64 characters | A scope item is longer than 64 characters |
| 400 | "budgetTotal" must be a non-negative integer | budgetTotal missing, negative, a string or not a whole number |
| 400 | "budgetTotal" must not exceed 9007199254740991 | budgetTotal is larger than 9,007,199,254,740,991 (253 − 1) |
| 400 | "expiresInSeconds" must be a finite number | expiresInSeconds missing or not a number (for example sent as a string) |
| 400 | "expiresInSeconds" must be an integer | expiresInSeconds has decimals (for example 600.5) |
| 400 | "expiresInSeconds" must be at least 60 | TTL below minimum |
| 400 | "expiresInSeconds" must not exceed 2592000 (30 days) | TTL above maximum |
| 400 | "agentPublicKey" must be a non-empty string | agentPublicKey sent as empty string, or wrong type |
| 400 | "agentPublicKey" exceeds maximum length of 4096 characters | agentPublicKey too long |
| 400 | "agentPublicKey" must be valid base64 | agentPublicKey is not valid base64 |
| 400 | "agentPublicKey" has an invalid size (N bytes). Expected an ML-DSA public key… | The decoded key is not 1312, 1952 or 2592 bytes — you sent something other than a raw ML-DSA public key (a private key, a PEM/DER wrapper, or a truncated value) |
| 400 | "token" is required | token missing from the verify request, or sent as a string instead of the token object |
| 400 | Invalid token format — missing payload, signature, or algorithm | Incomplete token object passed to verify — send it exactly as returned by POST /mandate |
| 400 | "action" is required | action missing or empty in the verify request |
| 400 | "action" must be at most 64 characters | action longer than 64 characters |
| 400 | "cost" must be a non-negative integer | cost missing, negative, a string or not a whole number in verify |
| 400 | "cost" must not exceed 9007199254740991 | cost is larger than 9,007,199,254,740,991 (253 − 1) in verify |
| 400 | Invalid "agentSignature" format — missing payload, signature, or algorithm | agentSignature is an object but lacks one of payload, signature, algorithm (issuedAt is optional) |
| 400 | "action" is required — narrow | suspend | resume | revoke | action missing from PATCH request |
| 400 | "action" must be narrow, suspend, resume, or revoke | Unknown action in PATCH |
| 400 | "limit" must be an integer between 1 and 100 | limit on GET /mandate is not a whole number, or is outside 1–100 |
| 400 | Invalid "cursor". Use the nextCursor value of the previous page as is. | cursor on GET /mandate is empty, was modified or truncated — always copy nextCursor unchanged |
| 400 | "scope" contains actions not in the current scope: ... | narrow attempted to include actions not in the mandate's current scopeCurrent |
| 400 | This is a Mandate token. Revoke it with PATCH /mandate/:id {"action":"revoke"} | A Mandate token was sent to the generic POST /revoke |
| 401 | API key required or invalid | Missing or malformed X-API-Key header — or a valid agent-scoped key was used on an endpoint other than POST /mandate/verify (see REST 00) |
| 401 | This is a Mandate token. Verify it with POST /mandate/verify | A Mandate token was sent to the generic POST /verify |
| 403 | result: denied, reason: invalid_signature | Token tampered, wrong project, not a mandate token (for example one from /sign), or issued by a rotated key outside grace period |
| 403 | result: denied, reason: agent_signature_required | Mandate has agentPublicKey but "agentSignature" was not sent (or is not an object) |
| 403 | result: denied, reason: agent_signature_invalid | Signature doesn't verify against agentPublicKey (wrong key, an algorithm that is not exactly ML-DSA-44, ML-DSA-65 or ML-DSA-87, or the wrong variant for the key), it already expired, or iat/exp are missing, exp − iat is over 60 s, or iat is more than 30 s in the future |
| 403 | result: denied, reason: agent_signature_mismatch | agentSignature is validly signed, but its "sub", "action" or "cost" don't match this mandateId, action and cost (a signed payload without cost never matches) |
| 403 | result: denied, reason: agent_signature_replayed | This exact agentSignature was already used. Sign again for every call |
| 403 | result: denied, reason: mandate_expired | Mandate TTL elapsed or mandate no longer exists |
| 403 | result: denied, reason: mandate_suspended | Mandate is suspended — resume it to allow actions |
| 403 | result: denied, reason: mandate_revoked | Mandate permanently revoked |
| 403 | result: denied, reason: scope_not_authorized | Requested action not in scopeCurrent (the response lists authorizedScope) |
| 403 | result: denied, reason: budget_exhausted | budgetConsumed + cost would exceed budgetTotal |
| 404 | Mandate not found | mandateId does not exist, belongs to another project, or was deleted 24 hours after expiry |
| 409 | Cannot modify a revoked mandate | PATCH attempted on a revoked mandate (including a second revoke) |
| 409 | Cannot modify an expired mandate | PATCH attempted on expired mandate (except revoke) |
| 409 | Only suspended mandates can be resumed | resume called on an active mandate |
| 415 | Content-Type must be application/json | Request body sent without Content-Type: application/json |
| 429 | Rate limit exceeded. Maximum 300 (or 60) requests per minute per API key. | Too many requests. The body has "code": "rate_limited"; wait the seconds in the Retry-After header and retry |
| 429 | Token limit reached. Free monthly tokens exhausted and no active packs. | Monthly quota exhausted. The body has "code": "token_quota_exhausted". Purchase a pack from the dashboard. The mandate budget is not consumed |
| 500 | Internal server error | Unexpected failure on our side. Retrying once is safe for GET and PATCH; if it persists, contact support with the time of the request |
Copy, replace your API key, and run to test the full mandate lifecycle against the live backend. Steps 1–12 cover the core lifecycle; step 13 is optional and covers proof of possession (agentPublicKey / agentSignature). Requires curl and jq; step 13 also needs Node 20+ (Python and Node versions with more detail are in 02b).
#!/bin/bash # Requires: curl and jq. Step 13 (optional) also needs Node 20+ and: npm install @noble/post-quantum API_KEY="pqa_YOUR_API_KEY" BASE_URL="https://api.fipsign.dev" echo "[1/13] Emit mandate..." MANDATE=$(curl -s -X POST $BASE_URL/mandate \ -H "Content-Type: application/json" -H "X-API-Key: $API_KEY" \ -d '{"agentId":"agent-test","issuedBy":"[email protected]","scope":["sign","verify","read:data"],"budgetTotal":100,"expiresInSeconds":3600}') MANDATE_ID=$(echo "$MANDATE" | jq -r '.mandate.id') MANDATE_TOKEN=$(echo "$MANDATE" | jq -c '.mandate.token') echo "id: $MANDATE_ID · status: $(echo "$MANDATE" | jq -r '.mandate.status')" echo "[2/13] Verify — granted (sign, cost 5)..." curl -s -X POST $BASE_URL/mandate/verify \ -H "Content-Type: application/json" -H "X-API-Key: $API_KEY" \ -d "{\"token\": $MANDATE_TOKEN, \"action\": \"sign\", \"cost\": 5}" | jq '{result,budgetRemaining}' echo "[3/13] Verify — denied (scope_not_authorized)..." curl -s -X POST $BASE_URL/mandate/verify \ -H "Content-Type: application/json" -H "X-API-Key: $API_KEY" \ -d "{\"token\": $MANDATE_TOKEN, \"action\": \"delete:records\", \"cost\": 0}" | jq '{result,reason}' echo "[4/13] Read state..." curl -s $BASE_URL/mandate/$MANDATE_ID -H "X-API-Key: $API_KEY" | jq '.mandate | {status,budgetConsumed,budgetRemaining,scopeCurrent}' echo "[5/13] Suspend..." curl -s -X PATCH $BASE_URL/mandate/$MANDATE_ID \ -H "Content-Type: application/json" -H "X-API-Key: $API_KEY" \ -d '{"action": "suspend"}' | jq '.status' echo "[6/13] Verify — denied (mandate_suspended)..." curl -s -X POST $BASE_URL/mandate/verify \ -H "Content-Type: application/json" -H "X-API-Key: $API_KEY" \ -d "{\"token\": $MANDATE_TOKEN, \"action\": \"sign\", \"cost\": 5}" | jq '{result,reason}' echo "[7/13] Resume..." curl -s -X PATCH $BASE_URL/mandate/$MANDATE_ID \ -H "Content-Type: application/json" -H "X-API-Key: $API_KEY" \ -d '{"action": "resume"}' | jq '.status' echo "[8/13] Narrow scope to [verify] only..." curl -s -X PATCH $BASE_URL/mandate/$MANDATE_ID \ -H "Content-Type: application/json" -H "X-API-Key: $API_KEY" \ -d '{"action": "narrow", "scope": ["verify"]}' | jq '.scope' echo "[9/13] Verify — denied (sign no longer in scope)..." curl -s -X POST $BASE_URL/mandate/verify \ -H "Content-Type: application/json" -H "X-API-Key: $API_KEY" \ -d "{\"token\": $MANDATE_TOKEN, \"action\": \"sign\", \"cost\": 5}" | jq '{result,reason,authorizedScope}' echo "[10/13] Revoke..." curl -s -X PATCH $BASE_URL/mandate/$MANDATE_ID \ -H "Content-Type: application/json" -H "X-API-Key: $API_KEY" \ -d '{"action": "revoke"}' | jq '.status' echo "[11/13] Verify — denied (mandate_revoked)..." curl -s -X POST $BASE_URL/mandate/verify \ -H "Content-Type: application/json" -H "X-API-Key: $API_KEY" \ -d "{\"token\": $MANDATE_TOKEN, \"action\": \"verify\", \"cost\": 0}" | jq '{result,reason}' echo "[12/13] List mandates (first 3)..." curl -s "$BASE_URL/mandate?limit=3" -H "X-API-Key: $API_KEY" | jq '.mandates[] | {id,status,budgetConsumed,scopeOriginal,scopeCurrent}' echo "[13/13] Proof of possession — emit with agentPublicKey, verify with/without agentSignature..." # Requires Node 20+ with @noble/post-quantum installed in the current folder: npm install @noble/post-quantum umask 077 # the agent's seed (its private key) is written to a file that only your user can read node --input-type=module -e ' import { ml_dsa44 } from "@noble/post-quantum/ml-dsa.js" const b64 = (u8) => Buffer.from(u8).toString("base64") const seed = new Uint8Array(32); crypto.getRandomValues(seed) const { publicKey } = ml_dsa44.keygen(seed) console.log(JSON.stringify({ publicKey: b64(publicKey), seed: b64(seed) })) ' > /tmp/agent-keys.json AGENT_PUBLIC_KEY=$(jq -r '.publicKey' /tmp/agent-keys.json) PO_MANDATE=$(curl -s -X POST $BASE_URL/mandate \ -H "Content-Type: application/json" -H "X-API-Key: $API_KEY" \ -d "{\"agentId\":\"agent-pop-test\",\"issuedBy\":\"[email protected]\",\"scope\":[\"sign\"],\"budgetTotal\":10,\"expiresInSeconds\":3600,\"agentPublicKey\":\"$AGENT_PUBLIC_KEY\"}") echo "requiresAgentSignature: $(echo "$PO_MANDATE" | jq -r '.mandate.requiresAgentSignature')" PO_ID=$(echo "$PO_MANDATE" | jq -r '.mandate.id') PO_TOKEN=$(echo "$PO_MANDATE" | jq -c '.mandate.token') echo " → verify WITHOUT agentSignature (expect denied / agent_signature_required)" curl -s -X POST $BASE_URL/mandate/verify \ -H "Content-Type: application/json" -H "X-API-Key: $API_KEY" \ -d "{\"token\": $PO_TOKEN, \"action\": \"sign\", \"cost\": 1}" | jq '{result,reason}' # The agent signs THIS call: mandate id + action + cost, valid for 30 seconds, usable once AGENT_SIGNATURE=$(node --input-type=module -e ' import { ml_dsa44 } from "@noble/post-quantum/ml-dsa.js" import { readFileSync } from "fs" const b64 = (u8) => Buffer.from(u8).toString("base64") const { seed } = JSON.parse(readFileSync("/tmp/agent-keys.json", "utf8")) const { secretKey } = ml_dsa44.keygen(Buffer.from(seed, "base64")) const now = Math.floor(Date.now() / 1000) const payload = { sub: process.argv[1], action: "sign", cost: 1, iat: now, exp: now + 30 } const payloadB64 = Buffer.from(JSON.stringify(payload)).toString("base64") const sig = ml_dsa44.sign(new TextEncoder().encode(payloadB64), secretKey) // algorithm: exactly "ML-DSA-44", the variant of this key (ml_dsa44) console.log(JSON.stringify({ payload: payloadB64, signature: b64(sig), algorithm: "ML-DSA-44", issuedAt: now })) ' "$PO_ID") echo " → verify WITH agentSignature (expect granted)" curl -s -X POST $BASE_URL/mandate/verify \ -H "Content-Type: application/json" -H "X-API-Key: $API_KEY" \ -d "{\"token\": $PO_TOKEN, \"action\": \"sign\", \"cost\": 1, \"agentSignature\": $AGENT_SIGNATURE}" | jq '{result,budgetRemaining}' echo " → verify AGAIN with the same agentSignature (expect denied / agent_signature_replayed)" curl -s -X POST $BASE_URL/mandate/verify \ -H "Content-Type: application/json" -H "X-API-Key: $API_KEY" \ -d "{\"token\": $PO_TOKEN, \"action\": \"sign\", \"cost\": 1, \"agentSignature\": $AGENT_SIGNATURE}" | jq '{result,reason}' rm -f /tmp/agent-keys.json
agentPublicKey/agentSignature (proof of possession, see sections 01–02). Skip it if your mandates use plain bearer tokens.
When project keys are rotated from the dashboard, Mandate handles it the same way as POST /verify.
POST /mandate/verify automatically tries the current key first, then the previous key within the grace period. Mandates emitted before a rotation continue to verify successfully throughout that window.status, scopeCurrent, and budgetConsumed are preserved regardless of key rotation. Agent keypairs used for proof of possession belong to the agent and are unrelated to your project keys: rotating the project key does not affect them.