JavaScript / TypeScript

JS SDK Reference

Node.js · Deno · Cloudflare Workers · TypeScript types included.
Base URL: https://api.fipsign.dev  ·  Package: fipsign-sdk

Use the SDK from your backend, not from a browser. Your API key is a secret: in browser code anyone can read it. For that reason 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.
About the examples. 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.
00 Environment variables

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"
Where do I get my API key? Dashboard → your project → expand the card → + New key. The key is shown only once at creation time. If you lose it, create a new one and revoke the old one.

Agent key — restricted scope. When creating a key, you can check "Agent key — restricted scope" instead of creating a normal (full access) key. A key created this way can only call 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.

For CA examples: you also need a CA created for your project. Go to the dashboard → your project → expand it → click "Create CA". Choose a format: PQCert (JSON, verified with ca.verifyCert()) or X.509 (PEM, verified with ca.verifyX509Cert()). Save the root certificate shown after creation — it is shown only once.

For offline certificate verification: keep the root certificate — for a PQCert CA as 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.
SDK 01 Installation

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.

Install
npm install fipsign-sdk
Get your API key
1. Create a free account at app.fipsign.dev — enter your email and verify the OTP sent to your inbox.

2. In the dashboard, create a project — choose your ML-DSA algorithm (44, 65, or 87) at creation time, it is immutable — then create an API key inside that project.

3. Save the key immediately — it is shown only once. Store it in an environment variable, never in source code.

Using this key for an AI agent? Create it as an agent key — restricted scope instead (checkbox on the dashboard's key creation form). An agent key can only call 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.
Instantiate the client
// 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
// })
SDK 02 sign() — Sign anything

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.

User session
const { token, meta, usage } = await pq.sign({
  sub:              'user_123',
  email:            '[email protected]',
  role:             'admin',
  expiresInSeconds: 3600,    // optional — default: 1 hour
})
Payment order
const { token } = await pq.sign({
  sub:      'order_456',
  amount:   299.99,
  currency: 'USD',
  expiresInSeconds: 300,
})
Document certification
const { token } = await pq.sign({
  sub:      'doc_789',
  hash:     'sha256:abc...',
  signedBy: 'alice',
})
AI agent action
const { token } = await pq.sign({
  sub:     'agent_summarizer_v2',
  action:  'document:summarize',
  userId:  'user_123',
  traceId: 'trace_abc',
})
IoT device / firmware
const { token } = await pq.sign({
  sub:      'device_iot_001',
  firmware: '2.1.4',
  location: 'plant-A',
})
Response — full shape
{
  token: {
    payload: "eyJzdWIi...", // base64 encoded payload
    signature: "oi5UKsTn...", // ML-DSA signature
    algorithm: "ML-DSA-65", // ML-DSA-44 | ML-DSA-65 | ML-DSA-87
    issuedAt: 1778947233
  },
  meta: {
    algorithm: "ML-DSA-65", // ML-DSA-44 | ML-DSA-65 | ML-DSA-87
    standard: "NIST FIPS 204",
    quantumResistant: true,
    expiresIn: 3600, // seconds, as passed to sign()
    issuedFor: "[email protected]", // your developer account email
    projectId: "proj_...",
    tokenCost: 1,
    source: "free" // "free" | "pack" | "free+pack"
  },
  usage: {
    freeRemaining: 9999,
    packRemaining: 0,
    totalRemaining: 9999,
    month: "2026-06"
  }
}
Monitor quota inline
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}`)
Payload limits: 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.

expiresInSeconds range: when provided, must be a whole number between 60 and 157,680,000 seconds (5 years). Outside this range, or with decimals such as 60.5, returns API_ERROR 400.
Reserved field names. Custom fields whose name starts with an underscore (_, 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.
SDK 03 verify() — Remote verification

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
What result.failure says
failureWhat happenedWhat to do
'rejected'FIPSign checked the token and refuses it: revoked, expired, tampered with, from another project, malformed, or a Mandate tokenAnswer 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 waitAnswer 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 readAnswer 503 and try again later. If result.error says the API key is invalid, fix the key
verify() never throws. On any failure it returns { 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.

Cost: 1 token per call. For high-throughput read paths without revocation checks, use local verification (SDK 04) at no cost.
Mandate tokens are refused. Passing a token from 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.
SDK 04 verify() local — Offline, ~1ms

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 is required when localVerify: true. Local verification rejects tokens issued for a different project — without projectId the constructor throws MISSING_PROJECT_ID immediately.

Why verify() says no: it never throws — it returns { 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.
Key rotation: if the cached public key no longer matches the token's signature (e.g. after a server key rotation), the SDK automatically clears the cache, fetches the new key, and retries — no action needed on your end.
Same rule applies here. The apiKey above is still your full secret key — this only ever belongs on your own backend, never in a browser.
Important: Local verification does not check the revocation list. A revoked token will pass local verification if its signature is valid and it has not expired. Use remote verification for payments, admin actions, and any security-sensitive operation.
Local verification is for tokens from 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.
SDK 05 revoke() — Revoke a token

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'
Other revocation reasons
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'
Idempotent: revoking an already-revoked token returns { success: true, message: 'Token was already revoked' } without consuming an extra token.

Expired tokens: calling revoke() on an already-expired token throws PQAuthError with code API_ERROR and status 400. Expired tokens cannot be submitted for revocation.
Mandate tokens cannot be revoked here. 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).
SDK 06 middleware() — Express

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.

Token encoding: the Bearer value is 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)
Try it
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>)"}
What the middleware answers
RequestAnswer
Valid tokennext() is called and req.user holds the payload
No Authorization: Bearer ... header401 { "error": "Authorization header required (Bearer <token>)" }
The Bearer value is not the base64 of a token401 { "error": "Invalid token format" }
The token is rejected: expired, revoked, tampered with, or from another project401 { "error": "<the text verify() returns>" }, for example Token has been revoked
FIPSign could not check the token: rate limit503 { "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 key503 { "error": "Authentication service temporarily unavailable" } (no Retry-After)
A 503 does not mean the user's token is bad. Keep the user signed in and let the client try again (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.

Each protected request runs 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.

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 (for a payload like the one above). Node.js accepts headers up to 16 KB by default; if a proxy or load balancer sits in front of your server, check its limit (nginx's default is 8 KB per header line).
Fastify

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 })
Where the user is. The middleware writes 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').
SDK 07 usage() — Token balance

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'}`)
})
Free tokens reset on the 1st of each month (UTC). Unused free tokens do not carry over. Pack tokens never expire and accumulate across purchases. All projects under the same account share a single pool.
SDK 08 health() — Service status

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"
HealthResult type: { 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.

No API key needed: health() calls GET /health without the X-API-Key header. Safe to call from any context including health check scripts and monitoring systems.
SDK 09 webhooks — Event notifications

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.

Configuration is dashboard-only. Webhook endpoints, secrets, and event subscriptions are managed from app.fipsign.dev → your project → Webhooks. There are no SDK methods for webhook management.

Available events: token.signed · token.rejected · token.revoked · limit.warning · limit.reached

Verification and event payloads are documented in the REST API tab (REST 12).
SDK 10 Error handling

verify(), 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)
  }
}
429: rate limit or no tokens left. 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.
Retry once after a rate limit
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
  }
}
SDK 11 Constructor options — full reference

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
})
OptionTypeDefaultDescription
apiKeystring—Required. Must match pqa_ followed by 64 lowercase hex characters — constructor throws INVALID_API_KEY immediately if not.
baseUrlstringhttps://api.fipsign.devOverride for local development or self-hosted instances.
timeoutnumber10000Request timeout in milliseconds. Throws TIMEOUT on exceeded.
localVerifybooleanfalseWhen true, verify() runs in memory using a cached public key — no API call, no token cost. Does not check revocation.
projectIdstring—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' }.
SDK 12 ca — Certificate Authority

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.

Setup: Create a project in the dashboard, then click "Create CA" inside that project. Choose a certificate format:

  • PQCert — FIPSign's native JSON format. Certificates are JSON objects verified with ca.verifyCert(). Simpler to work with in JavaScript/TypeScript environments.
  • X.509 — Standard X.509 v3 PEM certificates signed with ML-DSA-65 (OID 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().

The format is chosen once at CA creation and applies to all certificates issued by that CA. One CA per project — you cannot mix formats within a project.

Save the root certificate now. It is shown only once and cannot be retrieved again. Without it, offline verification is not possible for any certificate issued by this CA.
generateKeyPair() — Generate a key pair for a device
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
secretKey format: the JS SDK returns the full 4032-byte ML-DSA-65 expanded key (not the 32-byte seed). If you are also using the Python SDK, note that the Python SDK returns the 32-byte seed instead — the two formats are not interchangeable.
ca.issue() — Issue a certificate (cost: 1 token)
// ── 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
For X.509: store meta.certId alongside the PEM certificate — you need it for ca.revokeCert() and ca.isCertRevoked().
ca.verifyCert() — Verify a PQCert certificate offline (synchronous)
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
PQCert format only. For X.509 use ca.verifyX509Cert() instead. Does not check revocation.
ca.verifyX509Cert() — Verify an X.509 certificate offline (async)
// 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..."
X.509 format only. Never throws — always returns { valid, cert? } or { valid: false, error }. Does not check revocation.
ca.isCertRevoked() — Check revocation offline (synchronous)
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')
}
ca.getCrl() — Get the Certificate Revocation List (free)
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
}
The SDK normalizes the CRL response — 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.
ca.getCert() — Get a certificate by ID (free)
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'
}
ca.revokeCert() — Revoke a certificate (cost: 1 token)
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
Not idempotent: revoking an already-revoked certificate throws PQAuthError with code API_ERROR and status 409. This is different from revoke() on tokens, which is idempotent.
Full device lifecycle — PQCert
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')
Full device lifecycle — X.509
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')
SDK 13 zes — Zero-Exposure Signing

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.

zes.sign() — hash and sign
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 }
zes.verify() — re-hash and confirm
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')
When 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.
Hashing is deterministic regardless of key order — keys are sorted recursively (nested objects too) before hashing, so the same logical data always produces the same hash no matter how the object was constructed.

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).
SDK 14 mandate — Agent authorization

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 runs what
WhoHoldsCalls
Your backendProject 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 keygenerateAgentKeyPair() 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 tokenmandate.verify(), right before each action. If it is the agent itself, give it an agent key (restricted scope), never a full-access key.
Recommended: your own executor calls 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.
mandate.emit() — issue a mandate (cost: 2 tokens)
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.

mandate.verify() — check authorization before the agent acts (cost: 2 tokens if granted, free if denied)
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."

Agent-scoped keys and this section. If the API key used to instantiate 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.
You decide 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.

A timeout does not mean "not applied". Timeouts and network errors come back as { 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.
Proof of possession with the SDK
What it is. By default a mandate is a bearer credential: whoever holds the token and an API key of your project can use it. With proof of possession the agent has its own key pair and must sign every call with the private key, which never leaves the agent — a copied token is then useless. Use it when the token is handled by code you do not fully control (an agent runtime, a device in the field, a browser). You do not need it when only your own backend ever holds the token. It costs no platform tokens: the signing is local.

The rules behind it (what exactly is signed, how long a signature lives) are in Mandate 02b, which also shows how to do it by hand against the REST API. With the SDK, four steps.

Node 20 or newer for key generation: on Node 18 generateAgentKeyPair() fails with crypto is not defined.
Step 1 — The agent generates its key pair (once)
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.
VariantpublicKeysecretKeySignature
ML-DSA-441312 bytes2560 bytes2420 bytes
ML-DSA-65 (default)1952 bytes4032 bytes3309 bytes
ML-DSA-872592 bytes4896 bytes4627 bytes
Any of the three variants works, whatever the project's algorithm is: the agent's key is independent of the project and of the CA. ML-DSA-44 has the smallest keys and signatures; ML-DSA-87 the highest security level. To choose one, pass its name exactly as written in the first column: generateAgentKeyPair({ algorithm: 'ML-DSA-44' }). Any other text ('ml-dsa-44', 'ML-DSA44'…) throws UNSUPPORTED_ALGORITHM.

Use 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).

Keep 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.

Key format. 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.
Step 2 — Your backend emits the mandate with the public key
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.

Step 3 — The agent signs every call
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
With the SDK you do not write the algorithm: leave it out. 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.

One signature, one call. It is valid for 30 seconds by default (expiresInSeconds, at most 60) and the server accepts it once. Sign again for every attempt; never cache signatures.

Clocks. The signature is stamped with the clock of the machine that runs 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.
Step 4 — Verify with the signature
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()
If the answer is lost (timeout, network error), re-send the same call with the same 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.
When something goes wrong
You seeTypical causeFix
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_invalidSigned 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_mismatchThe 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_replayedThis exact signature was already accepted.Sign again for every call.
PQAuthError INVALID_ARGUMENTsignAgentCall() 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_KEYsecretKey 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_ALGORITHMThe 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)
}
Complete example

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)
Expected output
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
mandate.narrow() / suspend() / resume() / revoke() — control the mandate (free)
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
What narrow() / suspend() / resume() / revoke() return
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'.
Suspension is checked before budget. If a mandate is both suspended and out of budget, verify() always returns reason: 'mandate_suspended' — the backend checks status before it ever looks at the budget counter.
mandate.get() / mandate.list() / mandate.listAll() — inspect state (free)
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 mandates. There is no 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.

Errors in the control methods. 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.
Python

Python SDK Reference

Flask · FastAPI · Django · Scripts · Python 3.9+ · Type hints included.
Base URL: https://api.fipsign.dev  ·  Package: fipsign-sdk

PY 01 Installation

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.

Install
pip install fipsign-sdk
For async support (httpx-based)
pip install fipsign-sdk[async]
Get your API key
1. Create a free account at app.fipsign.dev — enter your email and verify the OTP sent to your inbox.

2. In the dashboard, create a project — choose your ML-DSA algorithm (44, 65, or 87) at creation time, it is immutable — then create an API key inside that project.

3. Save the key immediately — it is shown only once. Store it in an environment variable, never in source code.

Using this key for an AI agent? Create it as an agent key — restricted scope instead (checkbox on the dashboard's key creation form). An agent key can only call 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.
Instantiate the client
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
# )
PY 02 sign() — Sign anything

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.

User session
result = pq.sign("user_123", email="[email protected]", role="admin", expires_in_seconds=3600)
token  = result.token
meta   = result.meta
usage  = result.usage
Payment order
result = pq.sign("order_456", amount=299.99, currency="USD", expires_in_seconds=300)
Document certification
result = pq.sign("doc_789", hash="sha256:abc...", signed_by="alice")
AI agent action
result = pq.sign(
    "agent_summarizer_v2",
    action="document:summarize",
    user_id="user_123",
    trace_id="trace_abc",
)
IoT device / firmware
result = pq.sign("device_iot_001", firmware="2.1.4", location="plant-A")
Monitor quota inline
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}")
Response shape
SignResult
  .token   PQToken
              .payload     str   # base64 encoded payload
              .signature  str   # ML-DSA signature
              .algorithm  str   # "ML-DSA-44" | "ML-DSA-65" | "ML-DSA-87"
              .issuedAt   int   # Unix timestamp
  .meta    SignMeta
              .algorithm       str
              .standard        str   # "NIST FIPS 204"
              .quantumResistant bool
              .expiresIn       int   # seconds
              .issuedFor       str   # your developer account email
              .projectId       str
              .tokenCost       int   # always 1
              .source          str   # "free" | "pack" | "free+pack"
  .usage   SignUsage
              .freeRemaining   int
              .packRemaining   int
              .totalRemaining  int
              .month           str   # e.g. "2026-06"
Payload limits: 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).

Default expiry: omitting expires_in_seconds uses the backend default of 3600 seconds (1 hour).

expires_in_seconds range: when provided, must be a whole number between 60 and 157,680,000 seconds (5 years). Outside this range, or with decimals such as 60.5, raises PQAuthError(code="API_ERROR", status=400).
Reserved field names. Custom fields whose name starts with an underscore (_, 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.
PY 03 verify() — Verify a token

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
What result.failure says
failureWhat happenedWhat to do
"rejected"FIPSign checked the token and refuses it: revoked, expired, tampered with, from another project, malformed, or a Mandate tokenAnswer 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 waitAnswer 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 readAnswer 503 and try again later. If result.error says the API key is invalid, fix the key
verify() never raises. On any failure it returns 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.

No local verify: the Python SDK always verifies remotely. There is no in-memory local verification mode — unlike the JS SDK which has localVerify: true. Cost is 1 token per call.
Mandate tokens are refused. Passing a token from 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.
PY 04 revoke() — Revoke a token

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"
Other revocation reasons
pq.revoke(token, "order cancelled")
pq.revoke(token, "suspicious activity detected")
Idempotent: revoking an already-revoked token returns RevokeResult(success=True, message="Token was already revoked") without consuming an extra token.

Expired tokens: calling revoke() on an already-expired token raises PQAuthError(code="API_ERROR", status=400). Expired tokens cannot be submitted for revocation.
Mandate tokens cannot be revoked here. 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).
PY 05 Flask & FastAPI middleware

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.

Token encoding: the Bearer value is 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.
Flask
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}
FastAPI
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")}
What the middleware answers
RequestFlask answerFastAPI answer
Valid tokenyour function runs; g.fipsign_user holds the payloadyour function runs; user is the payload
No Authorization: Bearer ... header401 {"error": "Authorization header required (Bearer <token>)"}401 {"detail": "Authorization header required"}
The Bearer value is not the base64 of a token401 {"error": "Invalid token format"}401 {"detail": "Invalid token format"}
The token is rejected: expired, revoked, tampered with, or from another project401 {"error": "<the text verify() returns>"}401 {"detail": "<the text verify() returns>"}
FIPSign could not check the token: rate limit503 {"error": "Authentication service temporarily unavailable"} with a Retry-After: <seconds> header503 {"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 key503 {"error": "Authentication service temporarily unavailable"} (no Retry-After)503 {"detail": "Authentication service temporarily unavailable"} (no Retry-After)
A 503 does not mean the user's token is bad. Keep the user signed in and let the client try again (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.

The payload (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.

Each protected request calls 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).
PY 06 Async client

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())
verify_cert() and verify_x509_cert() are synchronous in both PQAuth and AsyncPQAuth — they perform pure in-memory cryptographic operations with no network I/O. Do not use await with them.
PY 07 usage() — Token balance

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'}")
Free tokens reset on the 1st of each month (UTC). Unused free tokens do not carry over. Pack tokens never expire and accumulate across purchases. All projects under the same account share a single pool.
PY 08 webhooks — Event notifications

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.

Configuration is dashboard-only. Webhook endpoints, secrets, and event subscriptions are managed from app.fipsign.dev → your project → Webhooks. There are no SDK methods for webhook management.

Available events: token.signed · token.rejected · token.revoked · limit.warning · limit.reached

Event payloads are documented in the REST API tab (REST 12).
Verifying incoming webhook requests

Use 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"
Store the webhook secret securely. It is shown only once at registration time and cannot be retrieved from the dashboard afterwards.
PY 09 Error handling

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.statusMeaning
400Invalid parameters — expires_in_seconds out of range or with decimals, invalid public key, meta passed to X.509 CA, expired token submitted for revocation
401API key missing or invalid
404Resource not found — no active CA for the project, certificate does not exist
409Conflict — revoking an already-revoked certificate
429Token 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)
Retry once after a rate limit
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
PY 10 Constructor options
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
)
OptionTypeDefaultDescription
api_keystr—Required. Must match pqa_ followed by 64 lowercase hex characters. Raises INVALID_API_KEY immediately if the format doesn't match.
base_urlstrhttps://api.fipsign.devOverride for local dev or self-hosted instances.
timeoutfloat10Request timeout in seconds (not milliseconds — unlike the JS SDK). Raises TIMEOUT on exceeded.
sessionrequests.SessionNoneCustom session for proxies, custom TLS, or testing.
PY 11 ca — Certificate Authority

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.

Setup: Create a project in the dashboard, then click "Create CA" inside that project. Choose a format: PQCert (JSON, native Python dataclasses) or X.509 (PEM, compatible with OpenSSL 3.5+). The format is chosen once and cannot be changed. One CA per project.

Save the root certificate now. It is shown only once at CA creation. Without it, offline certificate verification is not possible.
generate_key_pair() — Generate a key pair for a device
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
secretKey format: the Python SDK returns the 32-byte ML-DSA-65 seed, not the 4032-byte expanded key returned by the JS SDK's 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.

cryptography>=48.0.0 is included as an automatic dependency — no extra install needed.

To sign from Python using the returned secretKey:
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
ca.issue() — Issue a certificate (cost: 1 token)
# ── 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"
For X.509: store result.meta.certId alongside the PEM certificate — you need it for ca.revoke_cert() and ca.is_cert_revoked().
ca.verify_cert() — Verify a PQCert certificate offline (synchronous)
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
PQCert format only. Never raises. Does not check revocation — call 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 integrity: all fields including meta are covered by the ML-DSA-65 signature. Altering any field after issuance will cause verify_cert() to reject the certificate.
ca.verify_x509_cert() — Verify an X.509 certificate offline (synchronous)
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-----"
X.509 format only. Never raises — always returns VerifyCertResult(valid, cert, error). Synchronous even when used with AsyncPQAuth. Does not check revocation.
ca.is_cert_revoked() — Check revocation offline (synchronous)
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")
ca.get_crl() — Get the Certificate Revocation List (free)
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
ca.get_cert() — Get a certificate by ID (free)
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"
ca.revoke_cert() — Revoke a certificate (cost: 1 token)
# 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
Not idempotent: revoking an already-revoked certificate raises PQAuthError(code="API_ERROR", status=409). This is different from revoke() on tokens, which is idempotent.
Full device lifecycle — PQCert
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")
Full device lifecycle — X.509
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")
PY 12 zes — Zero-Exposure Signing

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.

zes.sign() — hash and sign
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
zes.verify() — re-hash and confirm
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")
When 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.
Hashing is deterministic regardless of key order — keys are sorted recursively (nested dicts too) before hashing, so the same logical data always produces the same hash no matter how the dict was constructed. Uses the same 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.
PY 13 mandate — Agent authorization

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 runs what
WhoHoldsCalls
Your backendProject 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 keygenerate_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 tokenmandate.verify(), right before each action. If it is the agent itself, give it an agent key (restricted scope), never a full-access key.
Recommended: your own executor calls 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.
mandate.emit() — issue a mandate (cost: 2 tokens)
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.

mandate.verify() — check authorization before the agent acts (cost: 2 tokens if granted, free if denied)
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."

Agent-scoped keys and this section. If the API key used to instantiate 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.
You decide 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.

A timeout does not mean "not applied". Timeouts and network errors come back as 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.
Proof of possession with the SDK
What it is. By default a mandate is a bearer credential: whoever holds the token and an API key of your project can use it. With proof of possession the agent has its own key pair and must sign every call with the private key, which never leaves the agent — a copied token is then useless. Use it when the token is handled by code you do not fully control (an agent runtime, a device in the field). You do not need it when only your own backend ever holds the token. It costs no platform tokens: the signing is local.

The rules behind it (what exactly is signed, how long a signature lives) are in Mandate 02b, which also shows how to do it by hand against the REST API. With the SDK, four steps.
Step 1 — The agent generates its key pair (once)
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})
VariantpublicKeysecretKeySignature
ML-DSA-441312 bytes32 bytes (seed)2420 bytes
ML-DSA-65 (default)1952 bytes32 bytes (seed)3309 bytes
ML-DSA-872592 bytes32 bytes (seed)4627 bytes
Any of the three variants works, whatever the project's algorithm is: the agent's key is independent of the project and of the CA. ML-DSA-44 has the smallest keys and signatures; ML-DSA-87 the highest security level. To choose one, pass its name exactly as written in the first column: generate_agent_key_pair("ML-DSA-44"). Any other text ("ml-dsa-44", "ML-DSA44"…) raises UNSUPPORTED_ALGORITHM.

Use 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).

Keep 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.

Key format — different from the JS SDK. 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").
Step 2 — Your backend emits the mandate with the public key
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.

Step 3 — The agent signs every call
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.

One signature, one call. It is valid for 30 seconds by default (expires_in_seconds, between 1 and 60) and the server accepts it once. Sign again for every attempt; never cache signatures.

Clocks. The signature is stamped with the clock of the machine that runs 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.
Step 4 — Verify with the signature
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()
If the answer is lost (timeout, network error), re-send the same call with the same 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.
When something goes wrong
You seeTypical causeFix
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_invalidSigned 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_mismatchThe 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_replayedThis 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_ARGUMENTsign_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_KEYsecret_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_ALGORITHMThe 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)
Complete example

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)
Expected output
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
mandate.narrow() / suspend() / resume() / revoke() — control the mandate (free)
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
What narrow() / suspend() / resume() / revoke() return
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.
Suspension is checked before budget. If a mandate is both suspended and out of budget, verify() always returns reason="mandate_suspended" — the backend checks status before it ever looks at the budget counter.
mandate.get() / mandate.list() / mandate.list_all() — inspect state (free)
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 mandates. There is no 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.

Errors in the control methods. 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.
Model Context Protocol

MCP Integration

Use FIPSign directly from Claude — sign tokens and issue certificates through natural language.
Available for TypeScript (@fipsign/mcp) and Python (fipsign-mcp).

UNDER CONSTRUCTION — This section is being reviewed and updated to match the current API. Some details here may be out of date. For the exact behavior of every call, use the JS SDK, Python SDK or REST API tabs.
MCP 01 Overview — 11 tools, two packages

Both MCP servers expose the same 11 tools covering the full FIPSign runtime API. Install one — they're equivalent.

TypeScript · Node.js
@fipsign/mcp
npmjs.com/package/@fipsign/mcp
npx @fipsign/mcp
Python · uv / pip
fipsign-mcp
pypi.org/project/fipsign-mcp
uvx fipsign-mcp
11 available tools
fipsign_health
Service status and algorithm info
fipsign_public_key
Get the project's ML-DSA public key
fipsign_sign
Sign any payload (1 token)
fipsign_verify
Verify signature + revocation (1 token)
fipsign_revoke
Revoke a token immediately (1 token)
fipsign_usage
Token balance and 6-month history
fipsign_generate_key_pair
Generate ML-DSA-65 keypair locally
fipsign_ca_issue
Issue a PQCert or X.509 certificate (1 token)
fipsign_ca_revoke_cert
Revoke a certificate (1 token)
fipsign_ca_get_cert
Real-time certificate status (free)
fipsign_ca_get_crl
Certificate Revocation List (free)
MCP 02 Claude Desktop setup

Edit claude_desktop_config.json and restart Claude Desktop. The API key is passed via environment variable — never hardcode it.

Config file location
macOS  →  ~/Library/Application Support/Claude/claude_desktop_config.json
Windows  →  %APPDATA%\Claude\claude_desktop_config.json
Linux  →  ~/.config/Claude/claude_desktop_config.json
TypeScript MCP (npx)
{
  "mcpServers": {
    "fipsign": {
      "command": "npx",
      "args": ["-y", "@fipsign/mcp"],
      "env": {
        "FIPSIGN_API_KEY": "pqa_your_api_key_here"
      }
    }
  }
}
Python MCP (uvx)
{
  "mcpServers": {
    "fipsign": {
      "command": "uvx",
      "args": ["fipsign-mcp"],
      "env": {
        "FIPSIGN_API_KEY": "pqa_your_api_key_here"
      }
    }
  }
}
After editing: restart Claude Desktop completely. The FIPSign tools will appear in the tool picker (🔧) when you open a new conversation.
MCP 03 Claude Code setup

Add FIPSign to Claude Code with a single command. The server runs per-project or globally.

TypeScript MCP
claude mcp add fipsign -- env FIPSIGN_API_KEY=pqa_your_api_key npx -y @fipsign/mcp
Python MCP
claude mcp add fipsign -- env FIPSIGN_API_KEY=pqa_your_api_key uvx fipsign-mcp
Verify the server is loaded
claude mcp list
claude mcp get fipsign
Project scope: by default MCP servers are project-scoped. Add --scope global to make it available in all Claude Code sessions.
MCP 03b Environment variables
VariableRequiredDefaultDescription
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.
MCP 04 Example conversations

Once connected, Claude can call FIPSign tools directly in response to natural language.

Sign and verify a token
You: Sign a session token for user_123 with role admin, expires in 1 hour
Claude: [calls fipsign_sign] → returns token object with ML-DSA signature

You: Verify that token
Claude: [calls fipsign_verify] → valid: true, payload.sub: "user_123"
Issue a certificate for a device
You: Generate a key pair for a new IoT device and issue a 1-year certificate for device-serial-00123
Claude: [calls fipsign_generate_key_pair, then fipsign_ca_issue] → returns certId and certificate
Check usage and quota
You: How many tokens do I have left this month?
Claude: [calls fipsign_usage] → 9,843 free tokens remaining, 0 pack tokens, resets 2026-07-01
MCP 05 Source, testing & debugging

Both MCP servers are open source. Use MCP Inspector for interactive testing before connecting to a client.

GitHub repos
TypeScript  →  github.com/fipsign/fipsign-mcp
Python      →  github.com/fipsign/fipsign-mcp-python
Test with MCP Inspector (TypeScript)
# 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
Test with MCP Inspector (Python)
# 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
Inspector UI: opens at 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.
REST API · curl

REST API Reference

Use with any language via curl or HTTP client.
Base URL: https://api.fipsign.dev  ·  Auth: X-API-Key: pqa_your_key

00 Environment setup

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"
Where do I get my API key? Dashboard → your project → expand the card → + New key. The key is shown only once at creation time. If you lose it, create a new one and revoke the old one.

Agent key — restricted scope. The same form has a checkbox "Agent key — restricted scope". A key created with it can only call 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.
01 Health check (GET /health)

Public endpoint. No authentication required. No token cost.

curl -s $BASE_URL/health | jq
{
  "success": true,
  "status": "ok",
  "service": "FIPSign",
  "algorithm": "ML-DSA-44/65/87",
  "standard": "NIST FIPS 204",
  "quantumResistant": true,
  "version": "2.0.0"
}
02 Public key (GET /public-key)

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
{
  "success": true,
  "publicKey": "base64encodedkey...",
  "algorithm": "ML-DSA-65", // reflects the project's configured algorithm
  "standard": "NIST FIPS 204"
}
03 Sign a payload (POST /sign)

Requires X-API-Key. Cost: 1 token.

User session
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
Save the token for subsequent steps
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')
Response — full shape
{
  "success": true,
  "token": {
    "payload": "eyJzdWIi...",
    "signature": "oi5UKsTn...",
    "algorithm": "ML-DSA-65", // ML-DSA-44 | ML-DSA-65 | ML-DSA-87 — set at project creation
    "issuedAt": 1778947233
  },
  "meta": {
    "algorithm": "ML-DSA-65", // ML-DSA-44 | ML-DSA-65 | ML-DSA-87
    "standard": "NIST FIPS 204",
    "quantumResistant": true,
    "expiresIn": 3600,
    "issuedFor": "[email protected]",
    "projectId": "proj_...",
    "tokenCost": 1,
    "source": "free" // "free" | "pack" | "free+pack"
  },
  "usage": {
    "freeRemaining": 9999,
    "packRemaining": 0,
    "totalRemaining": 9999,
    "month": "2026-06"
  }
}
Payload limits: 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.

Default expiry: omitting expiresInSeconds defaults to 3600 seconds (1 hour).

expiresInSeconds range: when provided, must be a whole number between 60 and 157,680,000 seconds (5 years). Outside this range, or with decimals such as 60.5, returns HTTP 400.

Algorithm: the signing algorithm (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.
Reserved field names. Custom fields whose name starts with an underscore (_) 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.
03b Zero-Exposure Signing (ZES)

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.

How it works: instead of sending your sensitive payload to 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.

No backend changes required. ZES uses the standard POST /sign and POST /verify endpoints — the only difference is what you send as the payload.
Standard: SHA-256 in hex, keys sorted alphabetically
Always serialize your data as JSON with keys sorted alphabetically before hashing. This guarantees the same hash regardless of language or runtime. Always use SHA-256 output as lowercase hex.
Step 1 — Hash your data locally (bash)
# 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)
Step 2 — Sign the hash (POST /sign)
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
Response — same shape as standard POST /sign
{
  "success": true,
  "token": {
    "payload": "eyJzdWIi...", // base64 — contains the hash, not your data
    "signature": "oi5UKsTn...",
    "algorithm": "ML-DSA-65",
    "issuedAt": 1785690939
  },
  // meta and usage fields same as standard sign response
}
Step 3 — Verify (recalculate hash locally, then verify token)
# 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
Response — valid ZES token
{
  "success": true,
  "valid": true,
  "payload": {
    "sub": "zes:a3f2c1d8e9b4f7c2...", // the hash you signed — not the original data
    "zes": true,
    "iat": 1785690939,
    "exp": 1785694539
  }
}
To confirm the data matches the token: recalculate 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.

Serialization rule: always sort JSON keys alphabetically before hashing — recursively, at every nesting level, not just the top level. {"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.

Non-JSON data: for binary files, images, or arbitrary bytes, hash the raw bytes directly without JSON serialization. Use the filename or a descriptor as a separate field if needed: {"file_hash":"<sha256_of_bytes>","filename":"contract.pdf"} — then hash that JSON with keys sorted.

Revocation: ZES tokens are revoked the same way as standard tokens — POST /revoke with the token object. The response includes sub with the zes: prefix. See section 05.

The 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.
JavaScript equivalent (no SDK — raw fetch)
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
Python equivalent (no SDK — raw requests)
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
04 Verify a token (POST /verify)

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
Response — valid token
{
  "success": true,
  "valid": true,
  "payload": {
    "sub": "user_test",
    "iat": 1778947233,
    "exp": 1778950833
    // plus any custom fields passed to sign()
  }
}
Response — invalid token
{
  "success": false,
  "valid": false,
  "error": "Token has been revoked" // or one of the messages in "Why a token is rejected" below
}
Tampered token test
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
Response — HTTP 401
{
  "success": false,
  "valid": false,
  "error": "Invalid signature — token was tampered with or not issued by this server"
}
Why a token is rejected
Decide with the HTTP status and 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.
HTTPerrorWhen
401Token has been revokedThe token was revoked with POST /revoke.
401Token expired N seconds agoThe 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.
401Invalid signature — token was tampered with or not issued by this serverThe 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).
401Invalid 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.
401Invalid token — signature is not valid base64The signature field is not base64 text.
401Unsupported algorithm: XThe token's algorithm is not exactly ML-DSA-44, ML-DSA-65 or ML-DSA-87. X is the value you sent.
401Token payload exceeds the maximum of 16384 charactersThe token's payload text is longer than 16,384 characters.
401This is a Mandate token. Verify it with POST /mandate/verifyThe token came from POST /mandate (see the note below).
400"token" is requiredThe body has no token object. HTTP 400 responses have no valid field.
400Invalid token format — missing payload, signature, or algorithmThe 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.
Mandate tokens are refused here. A token issued by 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.
05 Revoke a token (POST /revoke)

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
Response
{
  "success": true,
  "message": "Token revoked successfully",
  "revokedAt": 1778947500,
  "sub": "user_test",
  "expiresAt": 1778950833,
  "note": "This token will be rejected on any future /verify call"
}
Idempotent: revoking an already-revoked token returns { "success": true, "message": "Token was already revoked" } without consuming an extra token.

Expired tokens: calling /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.

Rate limit: POST /revoke is limited to 300 requests/minute per API key, same as /sign and /verify. See section 10.
Mandate tokens cannot be revoked here. 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.
06 Token usage (GET /usage)

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
Response — shape
{
  "success": true,
  "current": {
    "month": "2026-06",
    "freeUsed": 42,
    "freeRemaining": 9958,
    "freeLimit": 2000,
    "packRemaining": 0,
    "totalRemaining": 9958
  },
  "monthlyHistory": [ // always 6 entries, oldest → newest
    { "month": "2026-01", "tokensUsed": 0, "fromFree": 0, "fromPack": 0 },
    // ...
  ],
  "packs": [ // purchased token packs
    { "id": "pack_...", "packType": "lite", "tokensPurchased": 25000, "purchasedAt": 1778900000, "paymentRef": "pay_..." }
  ],
  "developer": { "email": "[email protected]" }
}
monthlyHistory always returns exactly 6 entries. Months with no activity show tokensUsed: 0.
Accuracy guarantee: 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.
07 Complete test script

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
08 Token costs and limits
EndpointCostAuthDescription
POST /sign1 tokenX-API-KeySign any payload
POST /verify1 tokenX-API-KeyVerify signature, expiry, and revocation
POST /revoke1 tokenX-API-KeyPermanently revoke a token
GET /usageFreeX-API-Key or sessionBalance and 6-month history
GET /public-keyFreeX-API-KeyPublic key for offline verification
GET /healthFree—Service status
POST /ca/issue1 tokenX-API-KeyIssue a certificate for a device or service
POST /ca/revoke1 tokenX-API-KeyRevoke a certificate immediately
GET /ca/crlFreeX-API-KeyCertificate Revocation List for this project's CA
GET /ca/certificate/:idFreeX-API-KeyReal-time status of a single certificate
Free tier: 2,000 tokens/month. Reset on the 1st (UTC). Unused free tokens do not carry over.
Pack tokens: Never expire. Consumed after free tokens are exhausted.
Payload limits: sub max 128 chars · other string fields max 256 chars · max 10 custom fields.
Token expiry limits: expiresInSeconds on /sign whole number, min 60, max 157,680,000 (5 years).
CA certificate limits: subject max 256 chars · meta max 10 keys (PQCert only) · expiresInSeconds whole number, min 60, max 157,680,000 (5 years).
09 Common errors
HTTPErrorCause
400"sub" is requiredsign() called without sub field
400"sub" exceeds maximum length of 128 characterssub field too long
400Maximum of 10 custom fields allowed in payloadsign() called with more than 10 custom fields
400Token is invalid or already expired — cannot revokerevoke() called on expired token, or token issued for a different project
400This is a Mandate token. Revoke it with PATCH /mandate/:id {"action":"revoke"}POST /revoke called with a token issued by POST /mandate
400Custom fields starting with "_" are reservedPOST /sign called with a custom field whose name starts with an underscore (_iss alone is ignored)
400"meta" is not supported for X.509 CAsca.issue() called with meta on an X.509 CA
400"expiresInSeconds" must be at least 60Certificate 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 60Token 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 charactersCertificate subject too long
400Invalid body — expected JSONThe request body is not valid JSON
400Invalid body — expected a JSON objectThe body is valid JSON but is not an object: null, an array or a plain value
400"expiresInSeconds" must be an integerexpiresInSeconds has decimals, for example 60.5 (POST /sign and POST /ca/issue)
415Content-Type must be application/jsonRequest body sent with a non-JSON Content-Type (e.g. text/plain, multipart/form-data)
401API key required or invalidMissing or incorrect X-API-Key, or key not matching pqa_ + 64 hex chars
401Token has been revokedToken was previously revoked via POST /revoke
401Invalid signature — token was tampered with or not issued by this serverToken signature is invalid (tampered or wrong key), or token issued for a different project
401Invalid 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)
401Invalid token — signature is not valid base64The token's signature field is not base64 text
401Unsupported algorithm: XThe token's algorithm is not ML-DSA-44, ML-DSA-65 or ML-DSA-87
401Token payload exceeds the maximum of 16384 charactersThe token's payload text is longer than 16,384 characters
401Token expired N seconds agoToken has expired
401This is a Mandate token. Verify it with POST /mandate/verifyPOST /verify called with a token issued by POST /mandate
404No active CA found for this projectCA not yet created — go to dashboard
404Certificate not foundcertId does not exist or belongs to another project
409Certificate is already revokedca.revokeCert() called on already-revoked cert
429Rate 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
429Token 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
API keys cannot be recovered after creation. If lost, create a new key and revoke the old one from the dashboard.
10 Rate limits

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.

EndpointLimitWindowScope
GET /public-key300 requests1 minutePer API key
POST /sign300 requests1 minutePer API key
POST /verify300 requests1 minutePer API key
POST /revoke300 requests1 minutePer API key
POST /ca/issue300 requests1 minutePer API key
POST /ca/revoke300 requests1 minutePer API key
GET /ca/crl60 requests1 minutePer API key
GET /ca/certificate/:id60 requests1 minutePer API key
Two different 429s — tell them apart with 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.
429 — rate limit (wait, then retry)
HTTP/2 429
retry-after: 37

{
  "success": false,
  "error": "Rate limit exceeded. Maximum 300 requests per minute per API key.",
  "code": "rate_limited"
}
429 — token quota exhausted (do not retry)
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 once after a rate limit (Node.js 20+, no SDK)
// 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())
REST 11 Certificate Authority — curl reference (PQCert)

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.

GET /ca/status and POST /ca/create are session-only endpoints used by the dashboard. They are not available via API key.
POST /ca/issue — Issue a certificate (cost: 1 token)
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"
Response
{
  "success": true,
  "certificate": { "type": "CA_CERT", "id": "cert_...", "subject": "device-serial-00123", "caId": "ca_...", "signature": "...", ... },
  "meta": {
    "certId": "cert_...",
    "caId": "ca_...",
    "subject": "device-serial-00123",
    "format": "pqcert",
    "issuedAt": 1778947233,
    "expiresAt":1810483233,
    "algorithm":"ML-DSA-65",
    "standard": "NIST FIPS 204",
    "caExpiry": null /* present only when expiresInSeconds was truncated: { "truncated": true, "requestedExpiresInSeconds": N, "resolvedExpiresInSeconds": N } */
  },
  "usage": { "freeRemaining": 9998, "packRemaining": 0, "totalRemaining": 9998 }
}
GET /ca/crl — Certificate Revocation List (free)
curl -s $BASE_URL/ca/crl -H "X-API-Key: $API_KEY" | jq '.'
{
  "success": true,
  "caId": "ca_...",
  "subject": "My IoT Root CA",
  "generatedAt": 1778947500,
  "crl": [{ "certId": "cert_...", "revokedAt": 1779000000, "reason": "device decommissioned" }]
}
reason may be null if no reason was provided at revocation time.
GET /ca/certificate/:id — Certificate status (free)
curl -s $BASE_URL/ca/certificate/$CERT_ID \
  -H "X-API-Key: $API_KEY" | jq '.status'
{ "revoked": false, "expired": false, "revokedAt": null, "expiresAt": 1810483233 }
POST /ca/revoke — Revoke a certificate (cost: 1 token)
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
Response
{
  "success": true,
  "certId": "cert_...",
  "revokedAt": 1779000000,
  "reason": "device decommissioned",
  "usage": { "freeRemaining": 9997, "packRemaining": 0, "totalRemaining": 9997 }
}
Not idempotent: revoking an already-revoked certificate returns HTTP 409 "Certificate is already revoked". This is different from POST /revoke on tokens, which is idempotent.
REST 11b X.509 CA — curl reference

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.

How to create an X.509 CA: Dashboard → your project → expand it → select X.509 (PEM) → enter a CA name → "Create CA". Save the PEM shown after creation — it is shown only once.

Certificate sizes: X.509 ML-DSA-65 certificates are ~7.5KB PEM / ~5.5KB DER. PQCert JSON is about 7 KB (base64 public key plus signature), so the two formats weigh about the same.

OID 2.16.840.1.101.3.4.3.18 (id-ml-dsa-65) — RFC 9881 final. Parseable with OpenSSL 3.5+; full chain verification with Python cryptography>=48.0.0 or the JS SDK's ca.verifyX509Cert().
Generate a device keypair (ML-DSA-65)
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));
})"
POST /ca/issue — Issue an X.509 certificate (cost: 1 token)
# 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
Response
{
  "success": true,
  "certificate": "-----BEGIN CERTIFICATE-----\nMIIV...==\n-----END CERTIFICATE-----\n",
  "meta": {
    "certId": "cert_...",
    "caId": "ca_...",
    "subject": "device-serial-00123",
    "format": "x509",
    "issuedAt": 1778947233,
    "expiresAt":1810483233,
    "algorithm":"ML-DSA-65",
    "standard": "NIST FIPS 204",
    "sizeNote": "X.509 ML-DSA-65 certs are ~7.5KB PEM. Plan accordingly for IoT memory constraints."
  },
  "usage": { "freeRemaining": 9998, "packRemaining": 0, "totalRemaining": 9998 }
}
REST 11c X.509 — End-to-end device authentication flow

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.

Step 1 — Generate device keypair
# Set DEVICE_PUBLIC_KEY and DEVICE_SECRET_KEY from REST 11b above
Step 2 — Issue X.509 certificate
# Already covered in REST 11b — CERT_PEM and CERT_ID set
Step 3 — Device signs a message
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 }));
})"
Step 4 — Server verifies (offline)

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().

Security checklist for production:
✓ Always verify cert signature against root CA before trusting device public key.
✓ Always check cert expiry (notAfter).
✓ Check CRL via GET /ca/crl for any operation where revocation matters.
✓ Include a timestamp in the signed message and reject messages older than N seconds (replay protection).
✓ Store DEVICE_SECRET_KEY in a secrets manager or hardware secure element — never in plaintext.
✓ Revoke the certificate immediately if a device is decommissioned or compromised.
13 Rotate project keys (POST /projects/:projectId/rotate-keys)

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
Response
{
  "success": true,
  "message": "Project keys rotated successfully.",
  "projectId": "proj_...",
  "algorithm": "ML-DSA-65", // same as project's configured algorithm
  "publicKey": "base64encodedkey...",
  "gracePeriod": "Previous key valid until 2126-08-24T19:29:03.000Z"
}
Dashboard only: this endpoint requires a session cookie (dashboard login), not an API key. It is not intended for programmatic use — key rotation is a project management operation.

Grace period: the previous keypair remains valid for as long as any token signed with it could still be within its own expiry — currently up to 5 years from the rotation, matching /sign's maximum expiresInSeconds. Any tokens signed before the rotation continue to verify successfully throughout that window.

Algorithm unchanged: rotation generates a new keypair using the same algorithm configured at project creation. The algorithm cannot be changed after creation.

Offline verification: if you use GET /public-key for offline token verification, fetch the new public key after rotation and update your local copy.
REST 12 Webhooks — Incoming event reference

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.

How to configure: Dashboard → your project → Webhooks → enter your HTTPS endpoint URL → select events → save. The HMAC secret is shown once — store it securely in your environment variables.
Incoming request headers
X-PQAuth-Event  ·  event type string (e.g. token.signed)
X-PQAuth-Signature  ·  sha256=<hmac-sha256-hex> — HMAC of the raw body
X-PQAuth-Timestamp  ·  Unix timestamp of the event
Content-Type  ·  application/json
Verify the HMAC signature (Node.js)
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')
})
Payload structure
{ "event": "token.signed", "timestamp": 1781451218, "data": { ... } }
token.signed — fired on every successful sign() call
sub string  ·  email string | null  ·  role string | null
projectId string  ·  apiKeyName string  ·  tokensUsed number
freeRemaining number  ·  packRemaining number  ·  totalRemaining number
source "free" | "pack" | "free+pack"  ·  month string
token.rejected — fired when verify() rejects a token
reason string — why verification failed: "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)
sub string | null — subject extracted from payload if decodable
projectId string  ·  apiKeyName string
token.revoked — fired on every successful revoke() call
sub string  ·  reason string
apiKeyName string  ·  projectId string
freeRemaining number  ·  packRemaining number  ·  totalRemaining number
limit.warning — fired when free tokens drop below 20% of monthly limit
freeRemaining number  ·  freeLimit number (always 2000)
packRemaining number  ·  totalRemaining number
percentUsed number (e.g. 82)  ·  month string  ·  apiKeyName string
limit.reached — fired when free tokens are exhausted and no pack is available
freeRemaining number (always 0)  ·  packRemaining number
totalRemaining number  ·  month string  ·  apiKeyName string
Delivery is best-effort — one attempt, 10-second timeout, no retry. FIPSign makes a single HTTP POST with a 10-second timeout. If your endpoint is unreachable or responds slowly, the event is lost. Always respond with HTTP 200 quickly and process events asynchronously if needed.
REST API · curl

Mandate — Bounded Authorization

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

00 How Mandate works

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.

The difference from POST /sign
POST /sign issues a token for a single event or action. It has no mutable state — once signed, it cannot be suspended, narrowed, or controlled without full revocation.

POST /mandate issues a session credential for an agent. It has two layers:

  • Immutable layer — covered by the ML-DSA signature: id (the mandate ID), agentId, issuedBy, scopeOriginal, budgetTotal, projectId, issuedAt, expiresAt. Cannot be altered — any change invalidates the signature.

  • Mutable layer — stored server-side, not covered by 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 always checks both layers — signature integrity first, then the live mutable state. An agent cannot act unless both pass.

Optional: proof of possession. By default, a mandate is a bearer credential — whoever holds the token can call 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.

Optional: agent-scoped API keys. Proof of possession protects the mandate token itself, but the agent also needs an 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.
Typical lifecycle
1. Emit  →  POST /mandate — issue a signed mandate to the agent. Cost: 2 tokens.
2. Agent acts  →  POST /mandate/verify — the executor sends token + action + cost before doing the work. Cost: 2 tokens if granted, free if denied.
3. Control  →  PATCH /mandate/:id — narrow scope, suspend, resume, or revoke at any time. Free.
4. Inspect  →  GET /mandate/:id — check current state, budget consumed, status. Free.
Budget — abstract units
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.
Use cases
AI agents  — authorize an agent to act with bounded scope and spend during its session
IoT devices  — authorize a device to operate within defined actions for up to 30 days
Microservices  — authorize a service to call another service within a quota
Delegation  — authorize a process to act on behalf of a user with explicit limits
B2B integrations  — authorize an external system to operate on your API with bounded scope
00b Integration guide — who calls what

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.

Who holds what
CredentialHeld byWhat it is forIf it leaks
Project API key (pqa_…, full access)Your backend onlyEmit, 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 backendOnly 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 itProves 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 onlySigns 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.
What is enforced and what is only a label. On every verify call FIPSign checks the token signature, the proof of possession (if the mandate has one), the mandate status, its expiry, the 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.
Recommended architecture
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
PatternUse it whenTrade-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 itselfAutonomous 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).
Two different costs — do not confuse them. Platform tokens are what FIPSign charges you per API call (section 06). Budget units are the mandate's own abstract currency: the 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.

Who decides 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.
Scope and budget rules, in one place
TopicRule
Matching an actionExact, 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 limits1–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 verifyFIPSign 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.
NarrowingOnly 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 checkA call is granted while budgetConsumed + cost ≤ budgetTotal. Landing exactly on budgetTotal is allowed; after that only cost: 0 calls are granted.
No limitbudgetTotal: 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.
ConcurrencyThe 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 callsNever change budgetConsumed and never cost platform tokens.
More budget / more timeNot 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.
Statusactive ⇄ suspended can be toggled any number of times. revoked is permanent. narrow also works while suspended.
Expiry and retentionThere 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.
Keep the token safe — and keep the id
The token is returned once, by 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.

If a token may have leaked, revoke the mandate by id — 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.
01 POST /mandate — Emit a mandate

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.

Request
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
  }'
Save the mandate for subsequent steps
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')
Request body fields
FieldTypeRequiredDescription
agentIdstringYesIdentifier for the agent or device. Max 128 characters. Covered by ML-DSA signature — immutable after emission.
issuedBystringYesWho authorized this mandate (e.g. email, user ID, system name). Max 256 characters. Covered by ML-DSA signature — immutable.
scopestring[]YesActions 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.
budgetTotalinteger ≥ 0YesMaximum 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.
expiresInSecondsintegerYesMandate 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.
agentPublicKeystringNoBase64 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.
Response — 201 Created
{
  "success": true,
  "mandate": {
    "id": "mdt_1418d1072c503dd20ef9416f347d19b9", // mandate ID — use for PATCH and GET
    "agentId": "agent-reporting-v2",
    "issuedBy": "[email protected]",
    "scope": ["sign", "verify", "read:crm"],
    "budgetTotal":1000,
    "expiresAt": 1785719739, // Unix timestamp (issuedAt + 28800)
    "status": "active",
    "token": {
      "payload": "eyJzdWIi...", // base64 — give this to the agent
      "signature": "RspqteGl...", // ML-DSA signature
      "algorithm": "ML-DSA-65", // project's configured algorithm
      "issuedAt": 1785690939
    },
    "requiresAgentSignature": true // only present when "agentPublicKey" was provided
  },
  "usage": {
    "freeRemaining": 9999,
    "packRemaining": 0,
    "totalRemaining": 9999,
    "month": "2026-08"
  }
}
The token is not stored on the server. Store it securely and pass it to the agent — just like a token from 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.
Generating the agent's keypair. 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.
02 POST /mandate/verify — Verify before acting

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.

Denied requests are free. Every denial — 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).
Request
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}"
Request body fields
FieldTypeRequiredDescription
tokenobjectYesThe full PQToken object returned by POST /mandate — all four fields: payload, signature, algorithm, issuedAt.
actionstringYesThe action the agent wants to perform. Must match one item of scopeCurrent exactly (case-sensitive); leading/trailing spaces are trimmed. Max 64 characters.
costinteger ≥ 0YesBudget 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).
agentSignatureobjectOnly if the mandate has agentPublicKeyProof-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.
Building 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.
Response — granted (HTTP 200)
{
  "success": true,
  "result": "granted",
  "actionMatched": "sign", // the action that matched scopeCurrent
  "budgetRemaining": 995, // budgetTotal - budgetConsumed after this call
  "expiresInSeconds": 3340, // seconds until mandate expiry
  "usage": { "freeRemaining": 9998, "packRemaining": 0, "totalRemaining": 9998, "month": "2026-08" }
}
Response — denied (HTTP 403)
// scope_not_authorized — action not in scopeCurrent
{ "success": false, "result": "denied", "reason": "scope_not_authorized", "authorizedScope": ["verify"] }

// budget_exhausted — budgetConsumed + cost would exceed budgetTotal
{ "success": false, "result": "denied", "reason": "budget_exhausted", "budgetConsumedUnits": 1000, "budgetTotalUnits": 1000 }

// mandate_suspended — mandate was suspended via PATCH
{ "success": false, "result": "denied", "reason": "mandate_suspended" }

// mandate_revoked — mandate was permanently revoked via PATCH
{ "success": false, "result": "denied", "reason": "mandate_revoked" }

// mandate_expired — mandate TTL elapsed or mandate no longer exists
{ "success": false, "result": "denied", "reason": "mandate_expired" }

// invalid_signature — token tampered, wrong project, or not a mandate token
{ "success": false, "result": "denied", "reason": "invalid_signature" }

// agent_signature_required — mandate has agentPublicKey but "agentSignature" was not sent
{ "success": false, "result": "denied", "reason": "agent_signature_required" }

// agent_signature_mismatch — agentSignature is validly signed, but its "sub", "action" or "cost" don't match this request
{ "success": false, "result": "denied", "reason": "agent_signature_mismatch" }

// agent_signature_invalid — signature doesn't verify against agentPublicKey, it has expired, or its iat/exp are missing or out of range
{ "success": false, "result": "denied", "reason": "agent_signature_invalid" }

// agent_signature_replayed — this exact agentSignature was already used (each one works once)
{ "success": false, "result": "denied", "reason": "agent_signature_replayed" }
Order of checks. 1) The ML-DSA signature of the mandate token: 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.

One consequence worth knowing: for a proof-of-possession mandate, step 2 runs before step 3, so a revoked mandate called without an 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.
Cost is declared by the caller. There is no server-side table mapping actions to costs — whoever calls 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.
02b Proof of possession — step by step

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.

Using the JS or Python SDK? You do not need to write steps 1 to 3 yourself: 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.

Keys are not interchangeable. The recipes below and the Python SDK store the 32-byte seed as the private key (the Python SDK also needs the 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.
When to use it
By default a mandate is a bearer credential: anyone who has the token and a project API key can use it. Proof of possession (PoP) closes that gap: on every verify call the agent must sign that exact call with a private key that never leaves the agent. A copied token is then useless on its own.

Use it when the token is handled by code you do not fully control or that is exposed (an agent runtime, a device in the field, a browser). You do not need it when only your own backend ever holds the token (pattern A in 00b). It adds no platform-token cost — the signing happens locally.
How it works
1. The agent generates an ML-DSA keypair. The private key never leaves the agent.
2. You emit the mandate passing only the public key as agentPublicKey. It is stored once and can never be changed.
3. For each call the agent signs a small JSON — {sub, action, cost, iat, exp} — with its private key.
4. Whoever calls verify sends the mandate token, 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.
Step 1 — The agent generates its keypair

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 keyText for algorithmNode.js (@noble/post-quantum)Python (cryptography)Public keySignature
ML-DSA-44"ML-DSA-44"ml_dsa44MLDSA44PrivateKey1312 bytes2420 bytes
ML-DSA-65"ML-DSA-65"ml_dsa65MLDSA65PrivateKey1952 bytes3309 bytes
ML-DSA-87"ML-DSA-87"ml_dsa87MLDSA87PrivateKey2592 bytes4627 bytes
Write the text exactly as shown. Capital letters, hyphens, no spaces. The server compares it character by character with these three values, so "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.

Pick the variant once. The class that generates the key and the text you write in 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.
Node.js
// 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')
Python
# 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()
What to store. The 32-byte seed is the private key: it is portable, and both libraries above derive exactly the same keypair from the same seed. Keep it in a secret store or a file readable only by the agent's user (chmod 600). Only the public key travels to your backend.

Python note. ML-DSA in 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.
Step 2 — Emit the mandate with the public key
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.

Step 3 — Sign each call

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:

ItemRule
What is signedThe 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 schemePlain ML-DSA (FIPS 204): empty context string, no pre-hash (not HashML-DSA). Both libraries above do this by default.
payloadbase64 (standard alphabet, with padding) of the UTF-8 JSON {"sub", "action", "cost", "iat", "exp"}. At most 16,384 characters.
subExactly the mandate id.
actionExactly the action you send in the same request (without leading/trailing spaces).
costExactly the cost you send in the same request, as a number. A payload without cost never matches.
iat, expUnix 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=.)
signaturebase64 of the raw signature: 2420, 3309 or 4627 bytes depending on the variant.
issuedAtOptional and ignored. Included in the examples only because it is part of the usual token shape.
Extra payload fieldsIgnored, but covered by the signature. Add a random nonce if your signer is deterministic (see the note below).
Lifetime and reuseEach 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.
Node.js
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
  }
}
Python
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
    }
Step 4 — Send verify with the signature
{
  "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.

When something goes wrong
ResponseTypical causeFix
agent_signature_requiredagentSignature missing, or not an object.Sign the call and send it. Mandates emitted with agentPublicKey always need it.
agent_signature_invalidSigned 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_mismatchThe 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_replayedThis 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).
Clocks. With the 30-second window used here the agent's clock may be off by about 30 seconds in either direction. Keep clocks synchronized (NTP). On devices where you cannot, use 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.

Deterministic signers. Both libraries above produce a different, randomized signature every time, so two identical calls in the same second are fine. If your signer is deterministic (some HSMs and libraries offer that mode), two calls with identical values in the same second would produce the same signature and the second would be refused as agent_signature_replayed. Add a random nonce field to the payload to avoid it.

Non-ASCII text. Encode the payload JSON as UTF-8 before base64, as the examples do; an action with accents or other non-ASCII characters then works like any other.

Rotating the agent's key. The public key of a mandate is fixed. To change it, emit a new mandate with the new key and revoke the old one.
Complete example — Node.js

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)
Complete example — Python

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"))
Expected output (both; the first line prints 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
02c Responses, retries & edge cases

What to do with every answer verify can give, how to retry safely, and the mistakes that cost integrators the most time.

Read the body, not just the status
HTTPMeaningWhat your code should do
200result: "granted". The budget was charged.Perform the action.
403result: "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 / 415The 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.
401API key missing or invalid, or an agent key used somewhere other than verify.Fix the key. Nothing was charged.
429Rate 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 errorUnknown outcome.See "Retries" below.
Fail closed. Allow the action only when the HTTP status is 200 and result === "granted". Everything else — a denial, a timeout, a 5xx, a body you cannot parse — means "do not act". This helper does exactly that.
Node.js
// 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,
  }
}
Python
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,
    }
What to do for each reason
reasonMeaningTypical response
invalid_signatureThe 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_expiredThe mandate is past its expiry (or was deleted 24 hours after it).Stop. Emit a new mandate if the agent should continue.
mandate_revokedPermanently revoked.Stop the agent for good. Never retry.
mandate_suspendedPaused with PATCH … suspend.Pause the work and retry later, with a delay — not in a tight loop. It works again after resume.
scope_not_authorizedThe action is not in scopeCurrent. The response includes authorizedScope.Refuse the action. You may tell the agent what it is allowed to do.
budget_exhaustedbudgetConsumed + 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 / _replayedProof-of-possession failures.A problem in the signing code — see 02b.
Retries and idempotency
A granted call takes effect immediately. If the response is lost — timeout, dropped connection, a 5xx — you cannot know whether the call was applied. Denied calls are free and change nothing, so retrying after a denial is always safe; the doubt exists only for calls that may have been granted.

Mandates with proof of possession give you a free idempotency key. Re-send the same request, with the same 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.
After the signature has expired you can no longer tell — read GET /mandate/:id as described next.

Bearer mandates have no such key. Re-sending the same verify call charges its 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.

Errors that did not consume anything. 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.
Monitor your platform quota. Every granted response includes 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.
Timing, revocation and clocks
Revocation and suspension are immediate for the next call, not for calls in flight. "Verify, then act" is not atomic: if you verify at 10:00:00 and the action takes a minute, a revoke at 10:00:30 does not interrupt it. For long or irreversible actions, verify right before the irreversible step, or split the work into smaller actions that are each verified.

Expiry uses the server clock. Do not compare your own clock with expiresAt to decide whether a mandate is still valid — ask verify, or read expiresInSeconds from a granted response or from GET /mandate/:id.

Renewing. Mandates cannot be extended. Emit the next one before the current one expires (both are valid at the same time), switch the agent over, and revoke the old one if it should stop working.
Common mistakes
What you seeUsual causeFix
400 "token" is requiredThe 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 integercost 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 9007199254740991cost is larger than 9,007,199,254,740,991 (253 − 1).Send a smaller whole number.
403 invalid_signature with a token you just createdIt 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 /revokeThe 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 rightIt 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 grantedbudgetTotal is 0 (no limit).Use budgetConsumed to see usage.
budgetConsumed is higher than you expectedRetried 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 rightSigned 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 sizeYou 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.
03 PATCH /mandate/:id — Control the mandate

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.

narrow — reduce scope permanently
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"]}'
narrow reduces 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.
suspend — pause temporarily
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"}'
suspend pauses the mandate — 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.
resume — reactivate after suspend
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"}'
resume reactivates a suspended mandate. Only works on 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.
revoke — terminate permanently
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"}'
revoke is permanent and irreversible. Once revoked, 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.
Response — all actions
{
  "success": true,
  "id": "mdt_1418d1072c503dd20ef9416f347d19b9",
  "status": "active", // active | suspended | revoked
  "scope": ["verify"], // scopeCurrent after the operation
  "updatedAt": 1785691318
}
Action summary
actionEffectReversible?Works on expired?Body required
narrowReduces scopeCurrent to a subset of its current value (not just scopeOriginal)No — scope can only shrink furtherNoYes — "scope": [...]
suspendSets status: suspended — blocks all verify callsYes — via resumeNoNo
resumeSets status: active — restores verify accessYes — via suspendNoNo
revokeSets status: revoked — permanently blocks all verify callsNoYesNo
04 GET /mandate/:id — Read mandate state

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
Response
{
  "success": true,
  "mandate": {
    "id": "mdt_1418d1072c503dd20ef9416f347d19b9",
    "agentId": "agent-reporting-v2",
    "issuedBy": "[email protected]",
    "scopeOriginal": ["sign", "verify", "read:crm"], // what was signed — immutable
    "scopeCurrent": ["verify"], // active scope — may be narrowed
    "budgetTotal": 1000,
    "budgetConsumed": 5,
    "budgetRemaining": 995, // budgetTotal - budgetConsumed, computed at read time (always 0 when budgetTotal is 0 — use budgetConsumed)
    "status": "active", // active | suspended | revoked
    "issuedAt": 1785690939,
    "expiresAt": 1785694539,
    "expiresInSeconds":3131, // seconds until expiry, computed at read time
    "updatedAt": 1785691318, // last mutable state change (narrow, suspend, resume, revoke, or a granted verify, which adds its cost to budgetConsumed); a denied verify does not change it
    "requiresAgentSignature": false // true if emitted with agentPublicKey (the key itself is never returned)
  }
}
scopeOriginal 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 mandates. There is no 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.
05 GET /mandate — List mandates

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 parameterTypeRequiredDescription
limitintegerNoPage size, 1–100. Default 50. A value that is not a whole number in that range is rejected with 400.
cursorstringNoOpaque, 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.
Response
{
  "success": true,
  "mandates": [
    { // same shape as GET /mandate/:id response
      "id": "mdt_...", "agentId": "...", "status": "active", "budgetRemaining": 995, ...
    }
  ],
  "count": 1, // mandates in THIS page
  "nextCursor": null // a string when more pages exist, null on the last page
}
Walk through every page
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
Includes all statuses — active, suspended and revoked mandates are all returned while the mandate still exists server-side. Mandates are kept for 24 hours after expiry for auditing purposes, then deleted automatically and dropped from the list. 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.
06 Token costs & limits
Endpoint costs
EndpointCostNotes
POST /mandate2 tokensCharged on every successful emission (rejected requests are free)
POST /mandate/verify2 tokensCharged only on granted responses — denials are free. Independent of the cost you send, which is deducted from the mandate's own budget
PATCH /mandate/:id0 tokensControl operations are always free
GET /mandate/:id0 tokensFree
GET /mandate0 tokensFree
Field limits
FieldLimit
agentIdMax 128 characters
issuedByMax 256 characters
scope items1–20 items per mandate
scope item lengthMax 64 characters per item
action (verify)Max 64 characters
budgetTotalNon-negative whole number, max 9,007,199,254,740,991 (253 − 1). 0 disables budget enforcement.
expiresInSecondsMin 60 (1 min) — Max 2,592,000 (30 days)
agentPublicKeyValid 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 lifetimeexp − iat at most 60 seconds; iat at most 30 seconds in the future; single use
GET /mandate page sizelimit 1–100, default 50
Retention after expiry24 hours (then GET returns 404 and the mandate leaves the list)
PATCH operations are free by design. Control operations (suspend, resume, revoke, narrow) are security operations — penalizing them with token costs would disincentivize the behavior you want when an agent is misbehaving or compromised.
07 Rate limits
EndpointLimitWindowScope
POST /mandate300 requests1 minutePer API key
POST /mandate/verify300 requests1 minutePer API key
PATCH /mandate/:id60 requests1 minutePer API key
GET /mandate/:id300 requests1 minutePer API key
GET /mandate300 requests1 minutePer API key
PATCH has a lower limit (60/min) because it modifies mutable state. The 300/min limit on verify is what matters for high-throughput agent workflows. Limits are counted per API key, so every agent key has its own counters.
08 Common errors
HTTPError / reasonCause
400Invalid body — expected JSONThe request body is not valid JSON
400Invalid body — expected a JSON objectThe body is valid JSON but is null, an array or a plain value
400"agentId" is requiredMissing or empty agentId
400"agentId" must be at most 128 charactersagentId too long
400"issuedBy" is requiredMissing or empty issuedBy
400"issuedBy" must be at most 256 charactersissuedBy too long
400"scope" must be a non-empty arrayscope missing, empty, or not an array (also returned by narrow)
400"scope" must have at most 20 itemsMore than 20 scope items
400"scope" items must be non-empty stringsScope array contains non-string or empty values
400"scope" items must be at most 64 charactersA scope item is longer than 64 characters
400"budgetTotal" must be a non-negative integerbudgetTotal missing, negative, a string or not a whole number
400"budgetTotal" must not exceed 9007199254740991budgetTotal is larger than 9,007,199,254,740,991 (253 − 1)
400"expiresInSeconds" must be a finite numberexpiresInSeconds missing or not a number (for example sent as a string)
400"expiresInSeconds" must be an integerexpiresInSeconds has decimals (for example 600.5)
400"expiresInSeconds" must be at least 60TTL below minimum
400"expiresInSeconds" must not exceed 2592000 (30 days)TTL above maximum
400"agentPublicKey" must be a non-empty stringagentPublicKey sent as empty string, or wrong type
400"agentPublicKey" exceeds maximum length of 4096 charactersagentPublicKey too long
400"agentPublicKey" must be valid base64agentPublicKey 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 requiredtoken missing from the verify request, or sent as a string instead of the token object
400Invalid token format — missing payload, signature, or algorithmIncomplete token object passed to verify — send it exactly as returned by POST /mandate
400"action" is requiredaction missing or empty in the verify request
400"action" must be at most 64 charactersaction longer than 64 characters
400"cost" must be a non-negative integercost missing, negative, a string or not a whole number in verify
400"cost" must not exceed 9007199254740991cost is larger than 9,007,199,254,740,991 (253 − 1) in verify
400Invalid "agentSignature" format — missing payload, signature, or algorithmagentSignature is an object but lacks one of payload, signature, algorithm (issuedAt is optional)
400"action" is required — narrow | suspend | resume | revokeaction missing from PATCH request
400"action" must be narrow, suspend, resume, or revokeUnknown action in PATCH
400"limit" must be an integer between 1 and 100limit on GET /mandate is not a whole number, or is outside 1–100
400Invalid "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
400This is a Mandate token. Revoke it with PATCH /mandate/:id {"action":"revoke"}A Mandate token was sent to the generic POST /revoke
401API key required or invalidMissing 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)
401This is a Mandate token. Verify it with POST /mandate/verifyA Mandate token was sent to the generic POST /verify
403result: denied, reason: invalid_signatureToken tampered, wrong project, not a mandate token (for example one from /sign), or issued by a rotated key outside grace period
403result: denied, reason: agent_signature_requiredMandate has agentPublicKey but "agentSignature" was not sent (or is not an object)
403result: denied, reason: agent_signature_invalidSignature 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
403result: denied, reason: agent_signature_mismatchagentSignature is validly signed, but its "sub", "action" or "cost" don't match this mandateId, action and cost (a signed payload without cost never matches)
403result: denied, reason: agent_signature_replayedThis exact agentSignature was already used. Sign again for every call
403result: denied, reason: mandate_expiredMandate TTL elapsed or mandate no longer exists
403result: denied, reason: mandate_suspendedMandate is suspended — resume it to allow actions
403result: denied, reason: mandate_revokedMandate permanently revoked
403result: denied, reason: scope_not_authorizedRequested action not in scopeCurrent (the response lists authorizedScope)
403result: denied, reason: budget_exhaustedbudgetConsumed + cost would exceed budgetTotal
404Mandate not foundmandateId does not exist, belongs to another project, or was deleted 24 hours after expiry
409Cannot modify a revoked mandatePATCH attempted on a revoked mandate (including a second revoke)
409Cannot modify an expired mandatePATCH attempted on expired mandate (except revoke)
409Only suspended mandates can be resumedresume called on an active mandate
415Content-Type must be application/jsonRequest body sent without Content-Type: application/json
429Rate 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
429Token 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
500Internal server errorUnexpected failure on our side. Retrying once is safe for GET and PATCH; if it persists, contact support with the time of the request
09 Complete test script

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
This last step is optional — it only applies if you're using agentPublicKey/agentSignature (proof of possession, see sections 01–02). Skip it if your mandates use plain bearer tokens.
10 Key rotation behavior

When project keys are rotated from the dashboard, Mandate handles it the same way as POST /verify.

Grace period. After a key rotation, the previous keypair remains valid for as long as any token signed with it could still be within its own expiry — currently up to 5 years, matching /sign's maximum expiresInSeconds (comfortably longer than Mandate's own 30-day maximum). 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.

In practice, since Mandate's own maximum lifetime is 30 days and the grace period spans up to 5 years, a mandate should never outlive the grace period of the key that signed it under normal use.

Key rotation does not affect the mutable mandate state — 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.