Account, projects, API keys, CA, webhooks and token packs: everything you do from the web app.
Open it at https://app.fipsign.dev
There is no separate sign-up and no password. The first time you sign in with an email address, the account is created.
A project has its own signing keypair and its own API keys. Use one project per application or per environment.
My SaaS. Description (optional) accepts up to 512 characters.| Option to click | NIST security level | The dashboard describes it as |
|---|---|---|
| ML-DSA-44 | 2 | Security Level 2 — faster |
| ML-DSA-65 | 3 | Security Level 3 — recommended |
| ML-DSA-87 | 5 | Security Level 5 — maximum |
ID: proj_…. Keep it: the SDKs need it for offline verification (projectId in SDK 11).Maximum of N projects allowed per account. The maximum is listed in REST 08.
An API key belongs to a project. It is what your code sends in the X-API-Key header.
default.The key has the form pqa_ followed by 64 hexadecimal characters.
A key created with that checkbox can only call POST /mandate/verify. Every other endpoint (/sign, /verify, /ca/*…) rejects it. Use it for keys you hand to an AI agent: see Mandate 00. In the list it carries the badge Agent · verify only.
Each key shows its name and Last used, a date or never. To disable a key click Revoke next to it and confirm Revoke this API key? Any app using it will stop working. Revoking is immediate and cannot be undone. FIPSign emails you when a key is created or revoked.
Maximum of N active API keys allowed per account. The maximum is listed in REST 08.Only needed if you issue certificates (ca.issue()). A project has at most one CA, and it is created here: there is no REST endpoint for that.
root-cert.json) or Copy JSON. For X.509 click Download .pem (saves root-ca.pem) or Copy PEM.ca.verifyCert(), ca.verifyX509Cert(), ca.verifyCrl(): SDK 12).
| Fact | Detail |
|---|---|
| Cost | See Costs. You need at least that many tokens left; otherwise the dashboard shows Token limit reached. The tokens are charged only once the CA exists. |
| One CA per project | A second one is refused: This project already has an active CA. |
| Algorithm | Always ML-DSA-65 (NIST FIPS 204), whatever algorithm the project uses. |
| Validity | 10 years from creation. The panel shows the date next to Expires. |
| Keys | The CA key cannot be rotated: Rotate keys (section 06) does not touch it. The only way to start over is to delete the project (section 07), which also deletes the CA and its certificates. |
Once created, the panel shows ● Active, the name, the algorithm and format, the CA ID, and the counters issued · active · revoked · expired. The dashboard does not issue or revoke certificates: that is done from your code with an API key (SDK 12, Python SDK 11, REST 11).
Change the name, the description or the environment label.
Replaces the project's signing keypair with a new one of the same algorithm. It affects what /sign, /verify and Mandate use. It does not affect the CA.
| After you rotate | What happens |
|---|---|
| New tokens and mandates | They are signed with the new key. |
| Tokens signed before | They keep verifying through FIPSign (POST /verify, POST /revoke) until the first date shown, which is 1 hour after the rotation. After that they stop verifying even if they have not expired, and you have to sign new ones. |
| Mandates emitted before | They keep verifying through FIPSign (POST /mandate/verify) until they expire. The previous key is accepted for mandates for up to 30 days after the rotation, the second date shown, which is the longest a mandate can live. |
| Receipts and the audit log of a mandate | Receipts and exports made before the rotation stay valid, however old: each one is checked with the key that made it. FIPSign keeps the public half of every key the project has had (the 200 most recent) and lists them in GET /public-keys. New receipts are signed with the new key. Save the fingerprint of the new key and keep the old ones: Mandate 14. |
| Several rotations | Each previous key keeps its own times, but only the 20 most recent previous keys are kept: on the 21st rotation within 30 days the oldest one stops verifying at once, for tokens and for mandates. Rotating is never refused for that reason. |
| API keys | They do not change. Your code needs no change. |
| The CA | It is not touched. There is no rotation for the CA key. |
GET /public-key returns only the current public key, so after a rotation a token signed with the previous key fails offline as soon as your verifier uses the new key. If you rotated because the key may have been stolen, do not keep the old public key: failing is what you want. If you rotated as a precaution and want the tokens in use to finish, keep the old public key (save it before you rotate, because GET /public-key will not return it afterwards) for at most 1 hour after the rotation, the same as the grace period. Verifying through FIPSign keeps working for those tokens during that hour.GET /public-keys lists them (Mandate 12).
Permanent. There is no way to recover a deleted project.
| Deleted | Consequence |
|---|---|
| All its API keys | They are revoked immediately: every app using them starts failing. |
| The CA and every certificate it issued | Listed only if the project has a CA. Certificates stop being checkable through FIPSign. |
| The signing keys and the configuration | FIPSign can no longer verify tokens that the project signed. |
| The key history and the audit log of the mandates | The list of public keys (GET /public-keys) is deleted, and the log can no longer be read or exported, because that needs an API key of the project. Receipts and exports you already hold can still be checked on your machine if you kept the public keys (the keys of GET /public-keys): save them before you delete the project, with the fingerprints (Mandate 14). |
FIPSign sends an HTTP POST to your server when something happens. A webhook belongs to your account, not to a project: you register one endpoint and it receives the events of all your projects.
https://yourapp.com/webhooks/pqauth. Addresses such as localhost, private networks and hosts ending in .local or .internal are rejected: to test on your machine, expose it through a public tunnel.webhook.test event to your URL, whatever events you ticked, so you can check that your server receives it.| Event | Sent when | projectId |
|---|---|---|
| token.signed | A sign() call succeeded. | yes |
| token.rejected | FIPSign refused a token in POST /verify. Offline verification never reaches FIPSign, so it sends nothing. | yes |
| token.revoked | A token was revoked with revoke(). | yes |
| limit.warning | A sign() call succeeded while 1 to 400 free tokens are left (20% of the monthly 2,000). It is sent on every such call. | no |
| limit.reached | A sign() call was refused because no tokens are left (free tokens and packs). | no |
The two limit.* events have no projectId because the token pool is shared by all your projects; apiKeyName tells you which key triggered them.
| Button | What it does |
|---|---|
| Send test event | Sends webhook.test to your URL. |
| Edit webhook | Changes the URL or the events. The secret stays the same: your server needs no change. |
| Rotate secret | Creates a new secret. The old one stops working immediately and the new one is shown once, so update your server at once. Use it also if you lost the secret. |
| Delete webhook | You stop receiving notifications. |
X-PQAuth-Signature header computed with your secret. The headers, the payload of each event, the signature check in code and the retry policy are in REST 12.
Every billable call spends tokens from a single pool shared by all your projects. What each call costs: the Costs tab.
| Kind | Detail |
|---|---|
| Free tokens | 2,000 every month. They reset on the 1st (UTC) and unused ones do not carry over. |
| Pack tokens | They never expire and add up across purchases. They are spent after the free tokens run out. |
At the top of the dashboard the cards Free tokens left and Pack tokens left show the balance. Below them, Free tokens shows how many you used this month; click ↻ Refresh to update it, and a chart shows the previous months. Each project card shows tokens this month, and the expanded card has Recent operations (click Show): endpoint, free or pack, tokens spent and time.
The balance is one for the whole account, so it does not tell you which project spent it. The Consumption by project section, below the Free tokens chart and above Projects, splits what was spent between your projects, month by month. It is for an account that runs several projects, for example one for each of your customers. Opening it costs nothing.
▲ +20%, ▼ −20%, or new if it spent nothing the month before). The biggest comes first. The 8 biggest are listed: click Show all N projects ↓ for the rest, also the projects that spent nothing in that month, and Show less ↑ to fold the list.| What you see | What it means |
|---|---|
| The project of an operation | The project of the API key that made the call. Creating a CA is done from the dashboard, with no API key: it counts for the project you create the CA in and shows as Dashboard (no API key) under By API key. |
| By operation | The endpoint that spent the tokens: POST /sign, POST /verify, POST /ca/create, POST /ca/issue, POST /mandate or POST /mandate/verify. The SDK methods, Zero-Exposure Signing and the MCP tools count under the endpoint they call. |
| By API key | The name of the key. A key you revoked is marked revoked: what it spent stays counted. |
| Refunds | A refund (Costs 03) is subtracted from the operation and the project that were charged, so each figure is what the project spent in the end. |
| Operations that cost nothing | They are not counted: only an operation that costs tokens leaves a record. |
| Free tokens and packs | The list does not say which of the two paid, because the pool is one for the account. The bars add up to what the whole account spent in each month. |
| Months and days | UTC, like the free tokens: a month starts on the 1st at 00:00 UTC. |
| Deleted projects | What a project spent before you deleted it stays in the figures. It is listed under the name it had, with a deleted tag. If FIPSign has no record of the name, it is listed as (deleted project). |
| How far back | The current month and the 5 before it: the 6 months the section shows. FIPSign keeps the record of each operation for those 6 months and then deletes it, a whole month at a time, so no month shows part of its records. When a new month starts, the oldest one leaves the section. |
To share the cost between your customers, click Export CSV. The button is off while nothing was spent in the 6 months. The file covers the 6 months, whichever month is chosen, and is named after the first and the last one, for example fipsign-consumption-2026-05_2026-10.csv. Under a first line with the names of the columns, it has one line for each project and month in which the project spent tokens, and the tokens of a month add up to the number under its bar. The columns, in this order:
| Column | What it has | Example |
|---|---|---|
| month | The month, in UTC. | 2026-10 |
| project_id | The ID of the project, the one its card shows as ID: proj_…. | proj_... |
| project_name | The name of the project. For a deleted project, the name it had; empty if FIPSign has no record of it. | My SaaS |
| environment | production, staging or development. Empty for a deleted project. | production |
| deleted | true for a project you deleted, false for the others. | false |
| tokens | The tokens the project spent in that month, net of refunds. | 1280 |
The file is UTF-8 and its lines end with CRLF. A project name that starts with =, +, - or @ is written with a ' in front, so a spreadsheet shows it as text and never runs it as a formula.
GET /usage and usage() return the whole account. There is no endpoint with an API key for the consumption of each project.
| Pack | Tokens | Price |
|---|---|---|
| Lite | 25,000 | $19 |
| Pro | 100,000 | $49 |
| Scale | 500,000 | $149 |
Date, Pack, Tokens, Amount, Status and Order ID; the status is completed once the tokens are credited. FIPSign keeps a purchase for the current month and the 5 before it, so an older one is no longer listed; the tokens you bought do not depend on the list. Packs are non-refundable.token_quota_exhausted until the 1st of the month or until you buy a pack.
Optional second step after the email code, with any authenticator app that supports TOTP (RFC 6238).
After the email code (section 01) you have 5 minutes and 5 attempts to type the code of your app or a backup code. If they run out, start again from the email code.
Click Disable 2FA, type your current code or a backup code and click Confirm disable. The session alone is not enough: that is on purpose, so a stolen session cannot remove your second factor. FIPSign emails you when 2FA is turned on or off.
What happened in your account, and what FIPSign tells you by email.
The Activity Log section shows the last 50 account actions made from the dashboard: sign-in and sign-out, API key created or revoked, project created, updated or deleted, project keys rotated, CA created, webhook registered, updated, deleted or secret rotated, and 2FA enabled or disabled. It does not list your sign() or verify() calls: for those use Recent operations in each project (section 09).
FIPSign emails you when someone signs in to your account (with the IP address and the time), when an API key is created or revoked, and when 2FA is turned on or off. If you get one you did not expect, revoke the API key (section 03) and turn on two-factor authentication (section 10).
Node.js · Deno · Cloudflare Workers · TypeScript types included.
Base URL: https://api.fipsign.dev · Package: fipsign-sdk
api.fipsign.dev only accepts browser calls from fipsign.dev and app.fipsign.dev; from any other website the browser blocks them and you will see a CORS error or Failed to fetch (NETWORK_ERROR in the SDK). From your own server (Node.js 20+) there is no such restriction.
pq is always a PQAuth you created once (const pq = new PQAuth('pqa_your_api_key')). reject(reason) stands for your own code that refuses the request — for example throw new Error(reason) or res.status(403).json({ error: reason }) — and doTheAction() for the operation you are protecting. Neither is part of the SDK.
Set these in your terminal before running any REST/curl command in this guide. If you are using the SDK you only need your API key.
# Replace with your API key from the dashboard (starts with pqa_) export API_KEY="pqa_YOUR_API_KEY_HERE" export BASE_URL="https://api.fipsign.dev"
POST /mandate/verify: every other endpoint rejects it with the same 401 as an invalid key. Agent keys can only be created in the dashboard. See Mandate 00.ca.verifyCert(), an X.509 (PEM) one with ca.verifyX509Cert(). The revocation list of the CA is checked with ca.verifyCrl() and the same root certificate.root-cert.json, for an X.509 CA as a .pem file — and pass it to ca.verifyCert(), ca.verifyX509Cert() or ca.verifyCrl() as shown in SDK 12.
Works in Node.js, Deno and Cloudflare Workers — run it on your backend. In a browser the code runs, but calls to the API only work from FIPSign's own domains (see the note at the top of this tab). TypeScript types included — no separate @types package needed. On Node.js use version 20 or newer: on Node 18 the key-generation functions and zes fail with crypto is not defined.
npm install fipsign-sdk
mandate.verify(): every other method in this SDK (sign(), verify(), ca.*, even the other mandate.* methods) fails with an invalid-key error. See Mandate 00.
// Simple form — just the API key import { PQAuth } from 'fipsign-sdk' const pq = new PQAuth('pqa_your_api_key') // Or with all options (see SDK 11 for full reference): // const pq = new PQAuth({ // apiKey: 'pqa_your_api_key', // baseUrl: 'https://api.fipsign.dev', // default // localVerify: false, // set to true for offline verification (SDK 04) // projectId: undefined, // required when localVerify: true (SDK 04) // timeout: 10_000, // request timeout in ms, default 10000 // })
Signs any payload with ML-DSA (44, 65, or 87 — configured per project at creation). The only required field is sub — any string identifying the entity. All other fields are stored in the payload and returned on verify. Token cost: see Costs.
const { token, meta, usage } = await pq.sign({ sub: 'user_123', email: 'user@example.com', role: 'admin', expiresInSeconds: 3600, // optional — default: 1 hour })
const { token } = await pq.sign({ sub: 'order_456', amount: 299.99, currency: 'USD', expiresInSeconds: 300, })
const { token } = await pq.sign({ sub: 'doc_789', hash: 'sha256:abc...', signedBy: 'alice', })
const { token } = await pq.sign({ sub: 'agent_summarizer_v2', action: 'document:summarize', userId: 'user_123', traceId: 'trace_abc', })
const { token } = await pq.sign({ sub: 'device_iot_001', firmware: '2.1.4', location: 'plant-A', })
const { token, meta, usage } = await pq.sign({ sub: 'user_123' }) console.log(`${usage.freeRemaining} free · ${usage.packRemaining} pack`) console.log(`charged from: ${meta.source}`) // "free" | "pack" | "free+pack" console.log(`project: ${meta.projectId} · account: ${meta.issuedFor}`)
sub is required, max 128 characters. Other string fields max 256 characters. Maximum 10 custom fields total (not counting sub, email, role, and expiresInSeconds). Exceeding these returns API_ERROR 400.60.5, returns API_ERROR 400.
_, for example _mandate) are reserved for the server. sign() throws PQAuthError with code API_ERROR, status 400 and message Custom fields starting with "_" are reserved.
Verifies the ML-DSA signature (algorithm read from the token itself), token expiry, and the revocation list. Never throws — always returns an object. Token cost: see Costs.
const result = await pq.verify(token) if (!result.valid) { if (result.failure === 'rejected') { // FIPSign checked the token and refuses it. result.error is one of these (text for your // logs: decide on result.failure, do not compare the text): // 'Token has been revoked' // 'Token expired N seconds ago' // 'Invalid signature — token was tampered with or not issued by this server' // 'Invalid signature — wrong length for ML-DSA-65 (expected 3309 bytes)' // 'Invalid token — signature is not valid base64' // 'Unsupported algorithm: X' // 'Token payload exceeds the maximum of 16384 characters' // 'This is a Mandate token. Verify it with POST /mandate/verify' return res.status(401).json({ error: 'Unauthorized' }) } // FIPSign could not check the token (result.failure is 'rate_limited', 'quota_exhausted' // or 'unavailable'). The token may be fine: do not sign the user out. if (result.retryAfter !== undefined) res.set('Retry-After', String(result.retryAfter)) return res.status(503).json({ error: 'Authentication service temporarily unavailable' }) } const { payload } = result console.log(payload.sub) // 'user_123' console.log(payload.role) // 'admin' console.log(payload.exp) // expiry Unix timestamp console.log(payload.iat) // issued at Unix timestamp // All custom fields passed to sign() are available on payload too
failure | What happened | What to do |
|---|---|---|
'rejected' | FIPSign checked the token and refuses it: revoked, expired, tampered with, from another project, malformed, or a Mandate token | Answer 401. The user must sign in again |
'rate_limited' | FIPSign did not check the token: too many requests (REST 10). retryAfter holds the seconds to wait | Answer 503 with Retry-After and try again after that time |
'quota_exhausted' | FIPSign did not check the token: no tokens left (free tokens and packs) | Answer 503. Waiting will not fix it: buy a pack from the dashboard (Dashboard 09) |
'unavailable' | FIPSign did not check the token: network error, timeout, FIPSign server error, invalid API key, or an answer the SDK cannot read | Answer 503 and try again later. If result.error says the API key is invalid, fix the key |
{ valid: false, payload: null, error: "...", failure: "..." }. failure tells a token that FIPSign refuses from a check that did not happen (table above); retryAfter is only there with 'rate_limited'. What each error text means is in REST 04.mandate.emit() returns { valid: false, error: 'This is a Mandate token. Verify it with POST /mandate/verify' }. Use pq.mandate.verify() (SDK 14), which also checks status, scope and budget.
Enable localVerify: true to verify tokens entirely in memory — no API call, no network latency, no token cost.
const pq = new PQAuth({ apiKey: 'pqa_your_api_key', localVerify: true, projectId: 'proj_...', // required — from the dashboard or meta.projectId in /sign's response }) // Optional: preload public key at startup to avoid first-request latency await pq.preloadPublicKey() const { valid, payload, local } = await pq.verify(token) console.log(local) // true — verified without an API call
projectId the constructor throws MISSING_PROJECT_ID immediately.{ valid: false, error, failure }. Decide on failure (SDK 03), not on the text of error. In local mode failure is 'rejected' for a bad signature, an expired token or another project's token, and 'unavailable' when the SDK could not download the public key because FIPSign could not be reached. A token from a different project gets exactly the same error text as one with a tampered signature — intentional, so the two cases are indistinguishable to whoever presented the token.
apiKey above is still your full secret key — this only ever belongs on your own backend, never in a browser.
sign() only. It knows nothing about mandates, so a token from mandate.emit() is rejected: { valid: false, error: 'This is a Mandate token. Verify it with mandate.verify()' }. Verify those with pq.mandate.verify() (SDK 14), which checks the live status, scope and budget on the server.
Immediately and permanently invalidates a token. Future verify() calls will reject it even if the signature is valid and the token has not yet expired. Token cost: see Costs.
const { success, message, revokedAt, sub, expiresAt, note } = await pq.revoke(token, 'user logged out') console.log(message) // 'Token revoked successfully' console.log(revokedAt) // Unix timestamp console.log(sub) // subject from the token payload console.log(expiresAt) // original expiry from the token console.log(note) // 'This token will be rejected on any future /verify call'
await pq.revoke(token, 'order cancelled') await pq.revoke(token, 'suspicious activity detected') const { valid } = await pq.verify(token) console.log(valid) // false — error: 'Token has been revoked'
{ success: true, message: 'Token was already revoked' } without consuming an extra token.revoke() on an already-expired token throws PQAuthError with code API_ERROR and status 400. Expired tokens cannot be submitted for revocation.
revoke() on a token from mandate.emit() throws PQAuthError with code API_ERROR, status 400: This is a Mandate token. Revoke it with PATCH /mandate/:id {"action":"revoke"}. Use pq.mandate.revoke(mandate.id) (SDK 14).
Reads Authorization: Bearer <token>, verifies the token, and attaches the decoded payload to req.user. Answers 401 when the token is missing or FIPSign refuses it, and 503 when FIPSign could not check it. Node.js only. It is an Express-style function ((req, res, next), answering with res.status(...).json(...)), so it works as it is in Express; for Fastify see below.
base64(JSON.stringify(PQToken)) — not a JWT. The login endpoint encodes the token object, and the middleware decodes it. Clients that expect a JWT will not be compatible without adapting the encoding step.
import express from 'express' import { PQAuth } from 'fipsign-sdk' const app = express() const pq = new PQAuth('pqa_your_api_key') app.use(express.json()) // Stand-in for your own user check. Replace it with your database lookup // and a real password comparison (bcrypt, argon2…). async function authenticate(email, password) { if (email === 'ana@example.com' && password === 'correct-horse') { return { id: 'user_123', email, role: 'admin' } } return null } // Login — sign a token and return it base64-encoded to the client app.post('/login', async (req, res) => { const user = await authenticate(req.body.email, req.body.password) if (!user) { return res.status(401).json({ error: 'Invalid credentials' }) } const { token } = await pq.sign({ sub: user.id, email: user.email, role: user.role, expiresInSeconds: 3600, }) // Encode to base64 — this is what the client puts in Authorization: Bearer <encoded> const encoded = Buffer.from(JSON.stringify(token)).toString('base64') res.json({ token: encoded }) }) // Logout — decode the header and revoke the token immediately app.post('/logout', async (req, res) => { const header = req.headers['authorization'] ?? '' if (header.startsWith('Bearer ')) { try { const token = JSON.parse(Buffer.from(header.slice(7), 'base64').toString('utf8')) await pq.revoke(token, 'user logged out') } catch { /* ignore malformed token */ } } res.json({ success: true }) }) // Protect all routes under /api with the FIPSign middleware app.use('/api', pq.middleware()) // req.user is the verified payload — sub, email, role, exp, iat, plus any custom fields app.get('/api/profile', (req, res) => { res.json({ user: req.user }) }) app.listen(3000)
TOKEN=$(curl -s -X POST localhost:3000/login -H 'Content-Type: application/json' \
-d '{"email":"ana@example.com","password":"correct-horse"}' | jq -r .token)
curl -s localhost:3000/api/profile -H "Authorization: Bearer $TOKEN"
# {"user":{"sub":"user_123","email":"ana@example.com","role":"admin","_iss":"prj_...","iat":...,"exp":...}}
curl -s -i localhost:3000/api/profile
# HTTP/1.1 401 Unauthorized
# ...
# {"error":"Authorization header required (Bearer <token>)"}
| Request | Answer |
|---|---|
| Valid token | next() is called and req.user holds the payload |
No Authorization: Bearer ... header | 401 { "error": "Authorization header required (Bearer <token>)" } |
| The Bearer value is not the base64 of a token | 401 { "error": "Invalid token format" } |
| The token is rejected: expired, revoked, tampered with, or from another project | 401 { "error": "<the text verify() returns>" }, for example Token has been revoked |
| FIPSign could not check the token: rate limit | 503 { "error": "Authentication service temporarily unavailable" } with a Retry-After: <seconds> header |
| FIPSign could not check the token: no tokens left, timeout, network error, FIPSign server error, invalid API key | 503 { "error": "Authentication service temporarily unavailable" } (no Retry-After) |
Retry-After when it is there). The body does not say why FIPSign could not answer: to log the cause, call pq.verify(token) yourself and read error and failure (SDK 03).
req.user is the whole signed payload: your fields (sub, email, role...), iat and exp (Unix seconds), and _iss, the id of your FIPSign project, which FIPSign adds to every token. You can ignore _iss.verify(). In the default remote mode that is one call to POST /verify, which also detects revoked tokens (token cost: see Costs). If you create PQAuth with localVerify: true and a projectId (see SDK 04), the middleware checks the token in memory instead: no token cost, but a revoked token is not detected.Fastify cannot use an Express-style function as a hook: app.addHook('onRequest', pq.middleware()) fails when you register it, with FST_ERR_HOOK_INVALID_ASYNC_HANDLER. Use it through @fastify/express, which adds app.use() to Fastify.
// npm install fastify @fastify/express import Fastify from 'fastify' import fastifyExpress from '@fastify/express' import { PQAuth } from 'fipsign-sdk' const app = Fastify() const pq = new PQAuth('pqa_your_api_key') await app.register(fastifyExpress) // adds app.use() for Express-style middleware app.use('/api', pq.middleware()) // The middleware sets user on Node's raw request, so in Fastify it is request.raw.user app.get('/api/profile', async (request) => { return { user: request.raw.user } }) await app.listen({ port: 3000 })
user on Node's raw request, so inside a Fastify handler it is request.raw.user (not request.user). Login and logout are the same as in the Express example: sign the token, then send Buffer.from(JSON.stringify(token)).toString('base64').
Returns the current token balance, 6-month usage history, and purchased packs. Token cost: see Costs.
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'}`) })
usage() returns the whole account. What each project spent is in the dashboard (Dashboard 09, Consumption by project). The SDK has no method for it.
Checks the health of the FIPSign service. Does not require an API key and does not consume tokens.
const { status, algorithm, quantumResistant, version } = await pq.health() console.log(status) // "ok" console.log(algorithm) // "ML-DSA-44/65/87" console.log(quantumResistant) // true console.log(version) // e.g. "2.0.0"
{ status: string, algorithm: string, standard: string, quantumResistant: boolean, version: string }. The service field is also present in the raw response but not included in the typed HealthResult.health() calls GET /health without the X-API-Key header. Safe to call from any context including health check scripts and monitoring systems.
FIPSign fires webhook events automatically when you call sign(), verify(), or revoke(). The SDK triggers them — you do not call any method to enable them.
token.signed · token.rejected · token.revoked · limit.warning · limit.reachedverify(), ca.verifyCert(), ca.verifyX509Cert() and ca.verifyCrl() 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, a certificate from another CA or an expired one, or a revocation list that your CA did not sign are reported there, not as codes). verify() also returns failure, which tells a token that FIPSign refuses from a check that did not happen (SDK 03). All other methods throw PQAuthError.
import { PQAuth, PQAuthError } from 'fipsign-sdk' try { await pq.sign({ sub: 'user_123' }) } catch (err) { if (err instanceof PQAuthError) { switch (err.code) { case 'INVALID_API_KEY': // key format invalid — must be pqa_ + 64 hex chars break case 'MISSING_PROJECT_ID': // localVerify: true without projectId break case 'API_ERROR': // server error: see err.status (429: err.serverCode) break case 'TIMEOUT': // request exceeded timeout (default: 10s) break case 'NETWORK_ERROR': // connection failed break case 'MISSING_SUB': // sign() called without sub break case 'INVALID_ARGUMENT': // signAgentCall(): missing or invalid argument break case 'INVALID_SECRET_KEY': // signAgentCall(): secretKey is not an ML-DSA private key (e.g. a 32-byte seed) break case 'UNSUPPORTED_ALGORITHM': // generateAgentKeyPair() / signAgentCall(): unknown ML-DSA variant name break } console.error(err.code, err.message, err.status) } }
err.serverCode is 'rate_limited' (wait err.retryAfter seconds, then retry) or 'token_quota_exhausted' (retrying will not help: buy a pack from the dashboard, Dashboard 09). err.retryAfter is in seconds and is undefined with any other error.
import { PQAuthError } from 'fipsign-sdk' async function signWithRetry(pq, claims) { try { return await pq.sign(claims) } catch (err) { if (err instanceof PQAuthError && err.serverCode === 'rate_limited') { // retryAfter: seconds until the one-minute window ends (from the Retry-After header) await new Promise(resolve => setTimeout(resolve, (err.retryAfter ?? 1) * 1000)) return await pq.sign(claims) // one retry; if it fails again, the error is thrown } throw err // 'token_quota_exhausted' and every other error: waiting will not fix it } }
All available options when instantiating PQAuth.
const pq = new PQAuth({ apiKey: 'pqa_...', baseUrl: 'https://api.fipsign.dev', timeout: 10_000, localVerify: false, projectId: 'proj_...', // required when localVerify: true })
| Option | Type | Default | Description |
|---|---|---|---|
| apiKey | string | — | Required. Must match pqa_ followed by 64 lowercase hex characters — constructor throws INVALID_API_KEY immediately if not. |
| baseUrl | string | https://api.fipsign.dev | Override for local development or self-hosted instances. |
| timeout | number | 10000 | Request timeout in milliseconds. Throws TIMEOUT on exceeded. |
| localVerify | boolean | false | When true, verify() runs in memory using a cached public key — no API call, no token cost. Does not check revocation. |
| projectId | string | — | Required when localVerify: true — constructor throws MISSING_PROJECT_ID immediately if not. Get it from the dashboard or meta.projectId in any /sign response. Local verification rejects tokens issued for a different project: verify() returns { valid: false, error: 'Invalid signature — token was tampered with or not issued by this server' }. |
Issue and verify post-quantum certificates for devices, services, or any entity that needs a tamper-proof identity. Built on ML-DSA-65 (NIST FIPS 204) — independent of the ML-DSA variant configured for token signing in each project. Token cost of each method: see Costs.
ca.verifyCert(). Simpler to work with in JavaScript/TypeScript environments.2.16.840.1.101.3.4.3.18, RFC 9881). Compatible with OpenSSL 3.5+, standard PKI tooling, and enterprise infrastructure. Verified with ca.verifyX509Cert().import { PQAuth, generateKeyPair } from 'fipsign-sdk' const { publicKey, secretKey } = await generateKeyPair() // publicKey: base64-encoded ML-DSA-65 public key — 1952 bytes raw // secretKey: base64-encoded ML-DSA-65 expanded key — 4032 bytes raw // store secretKey securely on the device — never send it to the server // pass publicKey to ca.issue() to obtain a certificate
// ── PQCert CA ────────────────────────────────────────────────────────────── const { certificate, meta, usage } = await pq.ca.issue({ subject: 'device-serial-00123', publicKey: devicePublicKey, // base64 ML-DSA-65 public key expiresInSeconds: 365 * 24 * 60 * 60, // required — whole number, min 60, max 157_680_000 (5 years) meta: { model: 'lock-v2', batch: '2026-05' }, // optional, max 10 keys }) // certificate is a PQCert JSON object console.log(certificate.id) // cert_... console.log(certificate.caId) // ca_... — the CA that signed it console.log(certificate.expiresAt) // Unix timestamp console.log(meta.certId) // same as certificate.id console.log(meta.caId) // ca_... console.log(meta.subject) // 'device-serial-00123' console.log(meta.format) // 'pqcert' console.log(meta.issuedAt) // Unix timestamp console.log(meta.expiresAt) // Unix timestamp console.log(meta.algorithm) // 'ML-DSA-65' console.log(meta.standard) // 'NIST FIPS 204' console.log(meta.caExpiry) // null normally; { truncated: true, requestedExpiresInSeconds: N, resolvedExpiresInSeconds: N } if lifetime was truncated to fit CA root expiry // ── X.509 CA ─────────────────────────────────────────────────────────────── // Note: publicKey must be exactly 1952 bytes (ML-DSA-65 public key). // Note: meta is NOT supported for X.509 CAs — passing it returns API_ERROR 400. const { certificate: certPem, meta, usage } = await pq.ca.issue({ subject: 'device-serial-00123', publicKey: devicePublicKey, expiresInSeconds: 365 * 24 * 60 * 60, }) // certificate is a PEM string console.log(typeof certPem) // "string" console.log(certPem) // "-----BEGIN CERTIFICATE-----\n..." console.log(meta.certId) // cert_... — use this for revocation console.log(meta.caId) // ca_... console.log(meta.format) // 'x509' console.log(meta.sizeNote) // size advisory for IoT memory planning
meta.certId alongside the PEM certificate — you need it for ca.revokeCert() and ca.isCertRevoked().
import { readFileSync } from 'node:fs' import { PQAuth } from 'fipsign-sdk' const pq = new PQAuth('pqa_your_api_key') // root-cert.json: the root certificate you saved when the CA was created // device-cert.json: the certificate the device presents (a PQCert JSON object) const rootCert = JSON.parse(readFileSync('./root-cert.json', 'utf8')) const deviceCert = JSON.parse(readFileSync('./device-cert.json', 'utf8')) const result = pq.ca.verifyCert(deviceCert, rootCert) // synchronous — no await if (!result.valid) { // result.error is plain text for your logs: decide with result.valid, do not compare the text. // It is one of: // 'Expected a CA_CERT, got ...' / 'Expected a CA_ROOT, got ...' // 'Certificate was not issued by this CA' // 'Root CA certificate expired N seconds ago' // 'Certificate expired N seconds ago' // 'Invalid certificate signature — not issued by this CA' // 'Unknown error' (malformed input, for example a certificate that is not an object) console.error(result.error) throw new Error('Device not authorized') } console.log(result.cert.subject) // 'device-serial-00123' console.log(result.cert.expiresAt) // Unix timestamp
ca.verifyX509Cert() instead. Does not check revocation.
// rootCertPem is the PEM string shown at CA creation — save it once const result = await pq.ca.verifyX509Cert(deviceCertPem, rootCertPem) if (!result.valid) { // Possible error messages: // 'Root CA certificate has expired' // 'Certificate has expired' // 'Invalid certificate signature — not signed by this root CA' // 'Unsupported signature algorithm: <OID>. Expected ML-DSA-65 (2.16.840.1.101.3.4.3.18)' // 'Unsupported root CA algorithm: <OID>. Expected ML-DSA-65 (2.16.840.1.101.3.4.3.18)' // 'Unexpected public key size: N bytes (expected 1952 or 1953 for ML-DSA-65)' // 'Unexpected signature size: N bytes (expected 3309 or 3310 for ML-DSA-65)' console.error(result.error) return reject('Device not authorized') } // result.cert is the PEM string of the verified certificate console.log(result.cert) // "-----BEGIN CERTIFICATE-----\n..."
{ valid, cert? } or { valid: false, error }. Does not check revocation.
const { crl } = await pq.ca.getCrl() // PQCert CA — pass the certificate object if (pq.ca.isCertRevoked(deviceCert, crl)) { return reject('Device certificate has been revoked') } // X.509 CA — pass the certId string from meta.certId if (pq.ca.isCertRevoked(meta.certId, crl)) { return reject('Device certificate has been revoked') }
isCertRevoked() searches the list you give it, on your machine, with no call. The list itself comes from getCrl(), one call to FIPSign (free; 60 per minute per API key). Download it every so often and keep it in memory or in a file, instead of asking for it on every check, and check its signature with ca.verifyCrl() (below) right after you download it, so a list altered on the way is not taken as good. A certificate revoked after your last download is still accepted until the next one, so choose the age you can live with: generatedAt tells you when the list was made. Before a high-value operation, ask for that one certificate with ca.getCert(): its status.revoked is answered in real time.
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'}`) }) // raw is the whole list as the CA signed it, for PQCert and X.509 CAs: // { caId, subject, format, algorithm, generatedAt, revokedCerts, signature } // ca.verifyCrl() checks it (next step) if (raw) { console.log(raw.signature) // base64 ML-DSA-65 signature over the canonical list }
r.crl is always a flat CrlEntry[] regardless of CA format. Use getCrl() when you check many certificates against one list. For a single certificate in real time before a high-value operation, use ca.getCert() instead. The CA signs the list, for both formats: the signed object is in raw and ca.verifyCrl() checks that signature (next step).
const list = await pq.ca.getCrl() // rootCert: the root certificate you saved when the CA was created // PQCert CA: the CA_ROOT object (JSON.parse of root-cert.json) // X.509 CA: the PEM string const check = await pq.ca.verifyCrl(list, rootCert) if (!check.valid) { // check.error is plain text for your logs: decide with check.valid, do not compare the text. // It is one of: // 'Invalid list signature — not signed by this CA' // 'This list was not issued by this CA' // 'This list is from a PQCert CA: pass its CA_ROOT certificate ...' // 'This list is from an X.509 CA: pass its root certificate in PEM form (a string)' // 'The entries of this result (crl, caId, subject, generatedAt) are not the signed ones (raw)' // 'This list is not signed: ...' // 'The root certificate could not be read: pass the PEM of the CA root certificate' // 'Unsupported root CA algorithm: <OID>. Expected ML-DSA-65 (2.16.840.1.101.3.4.3.18)' console.error(check.error) throw new Error('Revocation list not trusted') } // check.generatedAt is when the CA signed the list (Unix seconds). A correctly signed old list is still // valid: you decide how old a list you accept. const ageSeconds = Math.floor(Date.now() / 1000) - check.generatedAt if (ageSeconds > 15 * 60) throw new Error('Revocation list is too old: download it again') // Now the entries can be trusted if (pq.ca.isCertRevoked(deviceCert, list.crl)) throw new Error('Device certificate has been revoked')
CA_ROOT object for a PQCert CA, the PEM string for an X.509 CA. Never throws — always returns { valid: true, generatedAt } or { valid: false, error }. It runs on your machine: no call to FIPSign and no tokens.
valid: true means: the list is exactly what your CA signed at generatedAt. Nobody hid, added or changed a revocation on the way, and it is not the list of another CA. When you pass the result of getCrl(), what you read from it (crl, caId, subject, generatedAt) has to be the signed list too, so you can use it after the check. What it does not do: it does not decide how old a list you accept (the signature covers generatedAt, so an old list cannot pass as a new one, but a list that was signed correctly and is old is still valid), and it does not look at the expiry of the root certificate.
const { certificate, status, meta } = await pq.ca.getCert('cert_...') console.log(status.revoked) // boolean console.log(status.expired) // boolean console.log(status.revokedAt) // Unix timestamp or null console.log(status.expiresAt) // Unix timestamp // For PQCert CAs, certificate is a PQCert object // For X.509 CAs, certificate is a PEM string // For X.509 CAs, meta contains additional fields: if (meta) { console.log(meta.certId) // cert_... console.log(meta.caId) // ca_... console.log(meta.subject) // 'device-serial-00123' console.log(meta.format) // 'x509' console.log(meta.algorithm) // 'ML-DSA-65' }
const { certId, revokedAt, reason, usage } = await pq.ca.revokeCert( 'cert_...', 'device decommissioned' ) console.log(certId) // cert_... console.log(revokedAt) // Unix timestamp console.log(reason) // 'device decommissioned' (or null if omitted) console.log(usage.freeRemaining) // tokens remaining after this operation
PQAuthError with code API_ERROR and status 409. This is different from revoke() on tokens, which is idempotent.
import { readFileSync } from 'node:fs' import { PQAuth, generateKeyPair } from 'fipsign-sdk' const pq = new PQAuth('pqa_your_api_key') // root-cert.json: the root certificate you saved when the CA was created const rootCert = JSON.parse(readFileSync('./root-cert.json', 'utf8')) // 1. Factory: generate a key pair for the device const { publicKey, secretKey } = await generateKeyPair() // 2. Factory: issue a certificate for the device const { certificate } = await pq.ca.issue({ subject: 'lock-serial-00123', publicKey, expiresInSeconds: 365 * 24 * 60 * 60, meta: { model: 'lock-v3', batch: '2026-05' }, }) // store certificate (PQCert JSON object) and secretKey on the device // 3. At runtime: verify the device certificate offline const result = pq.ca.verifyCert(certificate, rootCert) if (!result.valid) throw new Error(result.error) // 4. Revocation: download the CRL every few minutes, check its signature and keep it (not on every check) const list = await pq.ca.getCrl() const check = await pq.ca.verifyCrl(list, rootCert) if (!check.valid) throw new Error(check.error) // At runtime: look the device up in the list you kept if (pq.ca.isCertRevoked(certificate, list.crl)) throw new Error('Device revoked') // 5. Decommission: revoke the certificate await pq.ca.revokeCert(certificate.id, 'device decommissioned')
import { PQAuth, generateKeyPair } from 'fipsign-sdk' const pq = new PQAuth('pqa_your_api_key') const rootCertPem = process.env.ROOT_CERT_PEM // PEM saved at CA creation // 1. Factory: generate a key pair for the device const { publicKey, secretKey } = await generateKeyPair() // 2. Factory: issue a certificate for the device const { certificate: certPem, meta } = await pq.ca.issue({ subject: 'lock-serial-00123', publicKey, expiresInSeconds: 365 * 24 * 60 * 60, }) // store certPem (PEM string), meta.certId, and secretKey on the device // 3. At runtime: verify the device certificate offline const result = await pq.ca.verifyX509Cert(certPem, rootCertPem) if (!result.valid) throw new Error(result.error) // 4. Revocation: download the CRL every few minutes, check its signature and keep it (not on every check) const list = await pq.ca.getCrl() const check = await pq.ca.verifyCrl(list, rootCertPem) if (!check.valid) throw new Error(check.error) // At runtime: look the device up in the list you kept if (pq.ca.isCertRevoked(meta.certId, list.crl)) throw new Error('Device revoked') // 5. Decommission: revoke the certificate using the certId from meta await pq.ca.revokeCert(meta.certId, 'device decommissioned')
Hash sensitive data locally and sign only the hash — the original data never reaches the API. Convenience wrapper over sign()/verify(); see REST 03b for the full explanation of the pattern.
const { token, hash } = await pq.zes.sign({ patient: 'Jane Doe', diagnosis: 'confidential', record_id: 'MR-2026-4491', }) // hash: the 64-char SHA-256 hex digest that was actually signed // token.payload decodes to { sub: 'zes:<hash>', zes: true, iat, exp }
const { valid, dataMatches } = await pq.zes.verify(token, { patient: 'Jane Doe', diagnosis: 'confidential', record_id: 'MR-2026-4491', }) if (!valid || !dataMatches) return reject('invalid or tampered')
valid is false, failure and retryAfter say why, exactly as in verify() (SDK 03): 'rejected' means FIPSign refuses the token; any other value means the check did not happen. dataMatches is false whenever valid is false.
revoke(), middleware(), and local verification all work unchanged — a ZES token is a regular token as far as they're concerned. There is no zes.revoke(): use pq.revoke(token) directly, same as any other token. zes.verify() also respects localVerify: true — it calls the same verify() internally, so it inherits offline verification with the same trade-off (no revocation check).
Bounded, revocable authorization for AI agents, IoT devices, and automated services. Thin typed wrapper over POST/PATCH/GET /mandate — see Mandate 00 for the full explanation of the immutable/mutable layers, lifecycle, and budget semantics. No dashboard setup needed beyond an API key — unlike CA, there's no separate resource to create first. Token cost of each method: see Costs.
| Who | Holds | Calls |
|---|---|---|
| Your backend | Project API key (full access) | mandate.emit(), narrow(), suspend(), resume(), revoke(), get(), list(), events(), queryEvents(), export(), publicKeys() (the reads of the log also have an …All() version that walks the pages) — every developer-side operation. |
| The agent (only with proof of possession) | The mandate token and its own private key | generateAgentKeyPair() once, then signAgentCall() for every call. No API key needed: nothing is sent to FIPSign. |
| Whoever performs the action (your executor — or the agent itself) | An API key and the mandate token | mandate.verify(), right before each action. If it is the agent itself, give it an agent key (restricted scope), never a full-access key. |
| Whoever checks the evidence (you later, an auditor) | The saved receipts or export, and the fingerprint of the key | verifyMandateReceipt(), verifyMandateExport(). No API key needed: nothing is sent to FIPSign. |
mandate.verify() and works out cost itself, so the agent never holds an API key and the budget can be trusted. Letting the agent call verify() itself also works — Mandate 00b explains both architectures and what each one protects.
const { mandate } = await pq.mandate.emit({ agentId: 'agent-reporting-v2', issuedBy: 'user@empresa.com', 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. It also carries a receipt, FIPSign's signature over the emitted event: see Receipts and the audit log below.
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) // check.failure: 'rejected' | 'rate_limited' | 'quota_exhausted' | 'unavailable' | 'outcome_unknown' — table below 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, failure }, never an exception. failure says whether FIPSign decided or the answer never arrived (table below). There is also no localVerify mode here, unlike pq.verify(): budget and scope are live, mutable state that can only be checked against the server — the signature alone isn't enough to answer "is this action still authorized right now."pq is an agent key (restricted scope), only mandate.verify() above will work. mandate.emit(), narrow(), suspend(), resume(), revoke(), get(), list() and the calls that read the log and the keys (events(), queryEvents(), export(), publicKeys()) 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.
verify() is an object of options, all optional: agentSignature (proof of possession, below), receipt: true (FIPSign's signature over the event this call produced, to keep as proof) and correlationId (your own id for the call). The last two are in Receipts and the audit log.
cost. The third argument of verify() is enforced exactly as given — FIPSign does not know what an action really costs. Compute it in the code that performs the action, not in the agent. See Mandate 00b.{ result: 'denied', reason: 'Request timed out', failure: 'outcome_unknown' } (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" and do not just send it again: see the table below and Mandate 02c.
failurefailure | What happened | Anything consumed? | What to do |
|---|---|---|---|
'rejected' | FIPSign looked at the call and refused it: reason says why (scope, budget, expired, revoked, suspended, agent signature…), or the request was malformed | No | Do not act. Repeating the same call gives the same answer |
'rate_limited' | Too many requests in the current minute (REST 10). retryAfter holds the seconds to wait | No | Do not act yet. Try again after that time |
'quota_exhausted' | No tokens left (free tokens and packs) | No (the mandate budget is given back) | Waiting will not fix it: buy a pack from the dashboard (Dashboard 09) |
'unavailable' | FIPSign answered but could not check the call, for example because the API key is invalid | No | Do not act. reason has the message: fix the cause |
'outcome_unknown' | No usable answer: a timeout, a network error, an answer the SDK cannot read, or a FIPSign server error | Maybe: the call may have been granted and charged | Do not act on it and do not just send it again. With proof of possession, re-send the same call with the same agentSignature while it is still valid (see below). Without it, compare budgetConsumed from mandate.get(id) with the value you had before the call |
const check = await pq.mandate.verify(mandateToken, 'read:crm', 1) if (check.result !== 'granted') { if (check.failure === 'outcome_unknown') { // No usable answer: the call may have been granted and charged. Do NOT act yet. // Without proof of possession: read the budget and compare it with the value you had before the call. const { mandate: now } = await pq.mandate.get(mandateId) // needs your full-access key console.log('budgetConsumed is now', now.budgetConsumed) throw new Error('outcome unknown: do not act yet') } if (check.failure === 'rate_limited') throw new Error(`rate limited: try again in ${check.retryAfter} s`) return reject(check.reason) } doTheAction()
result: 'denied', never a third value, so code that only checks result !== 'granted' keeps working: it never acts on a call that may not have been granted. Decide on failure, not on the text of reason. The SDK never repeats a call by itself: FIPSign does not recognise a repeated request, so a repeated call is a new call and is charged again.mandate.get() needs your full-access key. An agent key (restricted scope) can only call mandate.verify(): if the service that verifies holds an agent key, read the budget from your backend, which holds the full-access key. Details in Mandate 02c.
generateAgentKeyPair() fails with crypto is not defined.
import { generateAgentKeyPair } from 'fipsign-sdk' // On the agent, once. The default variant is ML-DSA-65. const { publicKey, secretKey, algorithm } = await generateAgentKeyPair() // Another variant: await generateAgentKeyPair({ algorithm: 'ML-DSA-44' }) // or 'ML-DSA-87' // publicKey → goes to your backend, for mandate.emit({ agentPublicKey }) // secretKey → stays on the agent, in a secret store. Never send it anywhere.
| Variant | publicKey | secretKey | Signature |
|---|---|---|---|
ML-DSA-44 | 1312 bytes | 2560 bytes | 2420 bytes |
ML-DSA-65 (default) | 1952 bytes | 4032 bytes | 3309 bytes |
ML-DSA-87 | 2592 bytes | 4896 bytes | 4627 bytes |
generateAgentKeyPair({ algorithm: 'ML-DSA-44' }). Any other text ('ml-dsa-44', 'ML-DSA44'…) throws UNSUPPORTED_ALGORITHM.generateAgentKeyPair() for agents and generateKeyPair() only for CA certificates. ca.issue() accepts ML-DSA-65 keys only, so a key of another variant is rejected there (HTTP 400, nothing is charged).secretKey secret. Store it in a secret store (or a file only the agent's user can read), never log it, never send it anywhere. If it is lost or may have leaked, revoke the mandate and emit a new one with a new key pair: the public key of a mandate can never be changed.secretKey is the full expanded private key (2560, 4032 or 4896 bytes, base64), not the 32-byte seed. The recipes in Mandate 02b and the Python SDK store the 32-byte seed instead: the two formats are not interchangeable, and signAgentCall() rejects a seed with INVALID_SECRET_KEY. Generate and sign with the same tool.
const { mandate } = await pq.mandate.emit({ agentId: 'agent-reporting-v2', issuedBy: 'user@empresa.com', scope: ['sign', 'send_reply'], budgetTotal: 1000, expiresInSeconds: 28800, agentPublicKey: publicKey, // base64, from generateAgentKeyPair() }) console.log(mandate.requiresAgentSignature) // true // Keep mandate.id in your database: you need it to narrow, suspend, revoke and inspect. const mandateToken = mandate.token // give this to the agent
A key of the wrong size is rejected with HTTP 400 and nothing is charged, so a mandate that could never be verified cannot be created by mistake. Only the public key travels to your backend; it is stored once and can never change.
import { signAgentCall } from 'fipsign-sdk' // On the agent, for EVERY call: no API key needed, nothing is sent to FIPSign. const agentSignature = await signAgentCall({ mandate: mandateToken, // the mandate token (or just the mandate id) action: 'send_reply', // exactly what mandate.verify() will receive cost: 1, // exactly what mandate.verify() will receive secretKey, // from generateAgentKeyPair() }) // Send agentSignature to whoever calls mandate.verify() — your executor, or the agent itself
signAgentCall() reads the variant from the size of secretKey, signs with it and fills in agentSignature.algorithm itself ('ML-DSA-44', 'ML-DSA-65' or 'ML-DSA-87'). Send the object it returns as it is; do not edit it. If you pass algorithm anyway (it is only a safety check), it must be exactly one of those three texts, capitals and hyphens included: anything else throws UNSUPPORTED_ALGORITHM, and a valid text that is not the variant of secretKey throws INVALID_SECRET_KEY. You write the text yourself only when you call the REST API by hand: see the table in Mandate 02b, Step 1.action and cost must be exactly what you pass to verify(). Change one and you must sign again, otherwise verify() answers agent_signature_mismatch.expiresInSeconds, at most 60) and the server accepts it once. Sign again for every attempt; never cache signatures.signAgentCall(). The server accepts a signature stamped up to 30 seconds ahead of its own clock, so with the default lifetime the agent's clock may be off by about 30 seconds either way. Keep clocks synchronized (NTP). On devices where you cannot, pass expiresInSeconds: 60: it tolerates an agent clock up to 60 seconds behind.
const check = await pq.mandate.verify(mandateToken, 'send_reply', 1, { agentSignature }) if (check.result !== 'granted') { // check.reason also covers the agent's signature: // 'agent_signature_required' | 'agent_signature_invalid' | // 'agent_signature_mismatch' | 'agent_signature_replayed' return reject(check.reason) } doTheAction()
failure: 'outcome_unknown'), 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.
| You see | Typical cause | Fix |
|---|---|---|
agent_signature_required (in reason) | verify() was called without { agentSignature } on a mandate emitted with agentPublicKey. | Sign the call and pass it as the 4th argument. |
agent_signature_invalid | Signed with a different key than the mandate's agentPublicKey; lifetime above 60 seconds; signature already expired; agent clock more than 30 seconds ahead of the server. | Sign with the secretKey of the pair whose publicKey you emitted; keep the default lifetime; synchronize clocks. |
agent_signature_mismatch | The action or cost that was signed differ from the ones passed to verify(). | Sign exactly the values you verify. Change one and you must sign again. |
agent_signature_replayed | This exact signature was already accepted. | Sign again for every call. |
PQAuthError INVALID_ARGUMENT | signAgentCall() got a missing or invalid mandate, action (non-empty), cost (whole number ≥ 0), secretKey or expiresInSeconds (≥ 1). | Fix the argument; err.message names it. |
PQAuthError INVALID_SECRET_KEY | secretKey is not valid base64, is not an ML-DSA private key (for example a 32-byte seed) or does not match algorithm. | Use the secretKey returned by generateAgentKeyPair() and omit algorithm. |
PQAuthError UNSUPPORTED_ALGORITHM | The algorithm name given to generateAgentKeyPair() or signAgentCall() is unknown. | Use ML-DSA-44, ML-DSA-65 or ML-DSA-87, written exactly like that (capitals, hyphens, no spaces) — or omit it when signing. |
import { PQAuthError, signAgentCall } from 'fipsign-sdk' try { await signAgentCall({ mandate: mandateToken, action: 'send_reply', cost: 1, secretKey }) } catch (err) { if (err instanceof PQAuthError) console.error(err.code, err.message) }
Generates a key pair, emits a mandate that requires it and runs the four interesting cases against the live API. Save as pop-sdk-demo.mjs, run npm install fipsign-sdk, then FIPSIGN_API_KEY=pqa_... node pop-sdk-demo.mjs (Node 20+). Token cost: the emit and the one granted verify (see Costs); 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: 'dev@example.com', 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. await pq.mandate.revoke(mandate.id)
emitted mdt_… · requiresAgentSignature: true no signature → denied agent_signature_required with signature → granted · budgetRemaining: 9 same signature → denied agent_signature_replayed cost changed → denied agent_signature_mismatch
await pq.mandate.narrow(mandate.id, ['read:crm']) // shrink scope — monotonic, cannot re-widen await pq.mandate.suspend(mandate.id) // pause — verify() denies with 'mandate_suspended' while suspended await pq.mandate.resume(mandate.id) // reactivate a suspended mandate await pq.mandate.revoke(mandate.id) // permanent — irreversible, no narrow/suspend/resume after this
const patched = await pq.mandate.suspend(mandate.id) console.log(patched.status, patched.scope, patched.updatedAt) // 'suspended' [ 'sign', 'verify', 'read:crm' ] 1785691318
narrow(), suspend(), resume() and revoke() return { success, id, status, scope, updatedAt, receipt }: the new status, the scope after the change, updatedAt (Unix seconds) and the receipt of the change (Receipts and the audit log). Suspending a mandate that is already suspended changes nothing: it returns only success, id, status and message: 'Already suspended', with no receipt. Narrowing to the scope the mandate already has is still a change: it is recorded and has a receipt.
verify() always returns reason: 'mandate_suspended' — the backend checks status before it ever looks at the budget counter.
const { mandate } = await pq.mandate.get('mdt_...') console.log(mandate.budgetConsumed, mandate.budgetRemaining, mandate.scopeCurrent, mandate.status) // First page: the 50 most recent mandates of this project const { mandates, count, nextCursor } = await pq.mandate.list() // Your own page size (1–100), then the next page const page1 = await pq.mandate.list({ limit: 20 }) const page2 = page1.nextCursor ? await pq.mandate.list({ limit: 20, cursor: page1.nextCursor }) : null // Every mandate, page by page — stop whenever you want with break for await (const m of pq.mandate.listAll()) { console.log(m.id, m.status, m.budgetConsumed) }
list() returns one page — up to 50 mandates by default (limit: 1–100), most recent first. nextCursor is a string while more pages exist and null on the last one: pass it back as cursor, exactly as received. count is the size of this page, not a total (the API has no total). A page can hold fewer than limit mandates — even none — while nextCursor is not null, so drive your loop with the cursor, never with the page size. listAll() does exactly that for you, one page at a time. Keep the id of every mandate you emit rather than listing to find it.expired status: check expiresInSeconds (it is 0 once the mandate has expired). An expired mandate can still be read for 24 hours, then get() throws PQAuthError with status 404.narrow(), suspend(), resume(), revoke(), get() and emit() throw PQAuthError with code API_ERROR and the HTTP status: 409 when the state does not allow the change (already revoked, expired, not suspended), 404 when the mandate does not exist, 400 for invalid input. Revoking twice returns 409 — treat it as success when retrying.events(), export(), queryEvents(), publicKeys() and their …All() versions throw PQAuthError in the same way: 404 when there is no log for that mandate id, 400 for a filter or a limit that is not valid, 429 when the 300 reads per minute of the key are used (REST 10). verifyMandateReceipt() and verifyMandateExport() never throw.
FIPSign records everything that happens to a mandate — it was emitted, each call it granted or denied, each change — in a log, and signs it. There is nothing to switch on. With the SDK you ask for a receipt on the calls you may have to prove, keep it, and check it on your own machine; later you can read the log and export it. Receipts, reading the log and the export cost no tokens (Costs). What the log holds, how it is protected and what a receipt does and does not prove: Mandate 11 to 15. The steps below follow one another: each one uses what the one before it made, and the complete example at the end runs all of them.
// emit(): the answer has the receipt next to the mandate const { mandate, receipt: emitted } = await pq.mandate.emit({ agentId: 'agent-reporting-v2', issuedBy: 'user@empresa.com', scope: ['sign', 'verify', 'read:crm'], budgetTotal: 1000, expiresInSeconds: 28800, correlationId: 'ticket-4821', // your own id for this call }) // verify(): a receipt only if you ask for it, granted or denied const check = await pq.mandate.verify(mandate.token, 'read:crm', 1, { receipt: true, correlationId: 'req-77', }) console.log(check.result, check.receipt.event.seq) // granted 2 (1 is the emit) // narrow(), suspend(), resume() and revoke(): always a receipt const { receipt: revoked } = await pq.mandate.revoke(mandate.id, { correlationId: 'ticket-4822', }) console.log(revoked.event.type) // revoked
emit(), narrow(), suspend(), resume() and revoke() always return a receipt. verify() returns one only when you pass receipt: true, and then whether the call was granted or denied; there is none when the token signature is invalid, when the mandate no longer exists or when no answer arrived. correlationId is your own id for the call (a ticket, a request id): 1 to 128 characters, no control characters. It is written inside the event, so the receipt covers it, and you can find the event with it later (Step 4). Suspending a mandate that is already suspended changes nothing and returns no receipt.Keep the receipt next to your own record of the call, as JSON:
import { writeFileSync } from 'node:fs' writeFileSync('receipt-emit.json', JSON.stringify(emitted))
The fingerprint is the SHA-256 of the public key of your project, 64 hexadecimal characters. It tells you which key to trust. Run this once, the day you integrate:
import { PQAuth, publicKeyFingerprint } from 'fipsign-sdk' const pq = new PQAuth(process.env.FIPSIGN_API_KEY) const { keys } = await pq.mandate.publicKeys() const current = keys.find((k) => k.status === 'current') console.log(await publicKeyFingerprint(current.publicKey)) // Prints: <64 hexadecimal characters>
FIPSIGN_KEY_FINGERPRINTS). Every check below compares what FIPSign sends with it. Do not ask FIPSign for it again at the moment of the check: you would only be repeating what FIPSign says. Each project has its own fingerprint, and it changes when you rotate the keys (Dashboard 06): then add the new one after the old one, separated by a comma, because the receipts made before the rotation need the old key. The same value comes from the curl command of Mandate 12: use whichever you prefer.
import { verifyMandateReceipt } from 'fipsign-sdk' // The fingerprints you saved, separated by commas (one for each key you had) const pinned = process.env.FIPSIGN_KEY_FINGERPRINTS.split(',') const { keys } = await pq.mandate.publicKeys() // free; includes retired keys const result = await verifyMandateReceipt(emitted, { pinFingerprint: pinned, keys, expect: { mandateId: mandate.id }, // fails if the receipt is of another mandate }) if (result.valid) { const { seq, type, at } = result.event console.log(`valid: event ${seq} (${type}) of ${result.mandateId}, at ${at}`) } else { console.log('NOT valid:', result.problems) } // Prints: valid: event 1 (emitted) of mdt_…, at 1785690939
verifyMandateReceipt() runs on your machine: it sends nothing to FIPSign, needs no API key and never throws: whatever is wrong comes back in problems. A key is trusted only if its fingerprint is one you pinned; keys is where the candidates come from (pq.mandate.publicKeys() is free and also lists the keys your project retired). Save that list once as a file and read it back, and the check needs no request at all. If you saved the public key itself, pass publicKey instead of pinFingerprint and keys. expect: { mandateId } makes the check fail if the receipt belongs to another mandate.pq.mandate.verifyReceipt() and pq.mandate.verifyExport() do the same, and if you give them neither pinFingerprint nor publicKey they trust the keys FIPSign lists and answer keyTrust: 'fipsign': that detects a receipt that was altered, but not a key that FIPSign itself replaced. For proof you will show to someone else, pin the key as above.// Every event of one mandate, oldest first (the pages are fetched for you) for await (const e of pq.mandate.eventsAll(mandate.id)) { console.log(e.seq, e.type, JSON.parse(e.body).correlationId) } // Search the events of all your mandates, newest first const { events } = await pq.mandate.queryEvents({ correlationId: 'req-77' }) for (const e of events) console.log(e.mandateId, e.seq, e.type) // Every denied call of the last 7 days, page by page for await (const e of pq.mandate.queryEventsAll({ type: 'verify_denied' })) { console.log(e.mandateId, e.at, JSON.parse(e.body).reason) } // Prints: // 1 emitted ticket-4821 // 2 verify_granted req-77 // 3 revoked ticket-4822 // mdt_… 2 verify_granted // mdt_… 1785691402 scope_not_authorized (one line for each denied call)
eventsAll() reads the log of one mandate, oldest first, and shows an event as soon as it happened. queryEvents() searches all the mandates of the project, newest first, and sees an event about 5 seconds after it happened. Without mandateId, correlationId or traceId it looks at the last 7 days unless you give from; from from to to may span at most 90 days. The filters are mandateId, type, action, keyId, correlationId, traceId, from, to, limit (1–200, default 50) and cursor: each one is an exact match, and they combine. events(), queryEvents() and export() return one page (nextAfter or nextCursor says where the next one starts, and is null on the last); eventsAll() and queryEventsAll() walk the pages for you, one request each: stop with break when you have what you need.seq, type, at, prevHash, hash and body. body is a string: the exact text that was hashed. Read it with JSON.parse(e.body) and never serialize it again. All of these need your full-access key (an agent key gets an invalid-key error), cost no tokens and share the 300 reads per minute of the key with get() and list() (REST 10). The fields of an event: Mandate 11. The filters: Mandate 13.
import { readFileSync, writeFileSync } from 'node:fs' import { verifyMandateExport } from 'fipsign-sdk' const pages = await pq.mandate.exportAll(mandate.id) // every page, in order writeFileSync('export.json', JSON.stringify(pages)) // keep it // Now, or years later, with no network and no API key: const saved = JSON.parse(readFileSync('export.json', 'utf8')) const audit = await verifyMandateExport(saved, { pinFingerprint: pinned }) console.log(audit.valid, audit.complete, audit.events, audit.problems) // Prints: true false 3 [] (valid, complete, events, problems)
exportAll() returns every page of the export, in order. The export holds the events of the mandate and the public keys of your project, so the check needs nothing else but the fingerprint you pinned. A mandate that is still alive gives complete: false: the last events are protected by the signed head of the export and by the receipts you hold, until FIPSign seals them with a checkpoint. It writes one at most once an hour while there are events no checkpoint covers, and always one more as the last event before it cleans the mandate up (24 hours after it expires). More in Mandate 14.
| Field | In | Meaning |
|---|---|---|
valid | both | true only if everything checks out. Receipt: the signature, the event it covers and its hash. Export: every event follows the one before it and is what it says it is, and every signature in the export verifies. |
problems | both | A list of texts: what does not check out. Empty when valid is true. |
keyTrust | both | 'pinned': you fixed the key (pinFingerprint or publicKey). 'fipsign': you gave neither, so the keys came from FIPSign (only pq.mandate.verifyReceipt() and pq.mandate.verifyExport() do that): it detects a log or a receipt that was altered, but not a key that FIPSign itself replaced. |
projectId, mandateId, event | receipt | What the receipt says it is about, when it could be read. |
complete | export | The log starts at event 1, ends in a checkpoint that seals everything before it, and nothing follows. A log that is valid but not complete has events at its end that only the signed head of the export, or a receipt you hold, protect. |
events, lastSeq | export | How many events were checked, and the seq of the last one. |
checkpoints, sealedThrough, headChecked | export | How many checkpoints were verified; every event up to sealedThrough is sealed by one of them; the signed head of the export was there and matches the chain. |
notes | export | What is not wrong but limits what was checked, for example: the export starts at event 40. |
Change one number of a receipt and the check says so. Run it after Step 3, with the same pinned and keys:
// Change one number of a receipt: it no longer passes const forged = structuredClone(emitted) const body = forged.event.body forged.event.body = body.replace('"budgetTotal":1000', '"budgetTotal":9999') const bad = await verifyMandateReceipt(forged, { pinFingerprint: pinned, keys }) console.log(bad.valid, bad.problems) // Prints: false [ 'event 1: hash is not sha256(prevHash + "\\n" + body)' ]
Emits a mandate, makes one granted and one denied call, checks the three receipts, shows that a changed receipt fails, revokes the mandate, reads its log, finds an event by your own id and checks the export. Save as audit-sdk-demo.mjs, run npm install fipsign-sdk, set FIPSIGN_API_KEY and FIPSIGN_KEY_FINGERPRINTS (the fingerprint you saved in Step 2) and run node audit-sdk-demo.mjs (Node 20+). Token cost: the emit and the one granted verify (see Costs); the denied call, the receipts, the reads and the checks are free.
// audit-sdk-demo.mjs — receipts, the log and the export with the SDK, end to end. // Setup: npm install fipsign-sdk (Node 20+) // Run: export FIPSIGN_API_KEY=pqa_... // export FIPSIGN_KEY_FINGERPRINTS=<64 hexadecimal characters> // node audit-sdk-demo.mjs import { PQAuth, verifyMandateReceipt, verifyMandateExport } from 'fipsign-sdk' const pq = new PQAuth(process.env.FIPSIGN_API_KEY) const pinned = process.env.FIPSIGN_KEY_FINGERPRINTS.split(',') // see Step 2 const { keys } = await pq.mandate.publicKeys() // 1. Emit a mandate and make two calls on it: one granted, one denied. // Ask for a receipt on each. const { mandate, receipt: emitted } = await pq.mandate.emit({ agentId: 'agent-audit-demo', issuedBy: 'dev@example.com', scope: ['sign'], budgetTotal: 10, expiresInSeconds: 3600, correlationId: 'demo-emit', }) const granted = await pq.mandate.verify(mandate.token, 'sign', 1, { receipt: true, correlationId: 'demo-granted', }) const denied = await pq.mandate.verify(mandate.token, 'delete', 1, { receipt: true, correlationId: 'demo-denied', }) console.log('granted:', granted.result, '· denied:', denied.result, denied.reason) // 2. Check the three receipts on this machine. const options = { pinFingerprint: pinned, keys, expect: { mandateId: mandate.id } } for (const r of [emitted, granted.receipt, denied.receipt]) { const c = await verifyMandateReceipt(r, options) const { seq, type } = r.event console.log(c.valid ? 'valid' : 'NOT valid', `event ${seq} ${type}`, c.problems) } // 3. A receipt in which one number was changed does not pass. const forged = structuredClone(emitted) const body = forged.event.body forged.event.body = body.replace('"budgetTotal":10', '"budgetTotal":99') const bad = await verifyMandateReceipt(forged, { pinFingerprint: pinned, keys }) console.log('forged receipt:', bad.valid ? 'valid' : 'NOT valid') // 4. Revoke the mandate, then read its log. await pq.mandate.revoke(mandate.id, { correlationId: 'demo-revoke' }) for await (const e of pq.mandate.eventsAll(mandate.id)) { console.log(e.seq, e.type, JSON.parse(e.body).correlationId) } // 5. Search by your own id. This search sees an event about 5 seconds later. await new Promise((resolve) => setTimeout(resolve, 6000)) const found = await pq.mandate.queryEvents({ correlationId: 'demo-denied' }) const names = found.events.map((e) => `${e.type} (event ${e.seq})`) console.log('found by correlationId:', names.join(', ')) // 6. Export the whole log and check it on this machine. const pages = await pq.mandate.exportAll(mandate.id) const audit = await verifyMandateExport(pages, { pinFingerprint: pinned }) console.log('export valid:', audit.valid, '· events:', audit.events) if (!audit.valid) console.log(audit.problems)
granted: granted · denied: denied scope_not_authorized valid event 1 emitted [] valid event 2 verify_granted [] valid event 3 verify_denied [] forged receipt: NOT valid 1 emitted demo-emit 2 verify_granted demo-granted 3 verify_denied demo-denied 4 revoked demo-revoke found by correlationId: verify_denied (event 3) export valid: true · events: 4
Flask · FastAPI · Django · Scripts · Python 3.9+ · Type hints included.
Base URL: https://api.fipsign.dev · Package: fipsign-sdk
Works in Flask, FastAPI, Django, scripts, and any Python 3.9+ environment. Type hints included — no separate stubs needed. cryptography>=48.0.0 is included as an automatic dependency — no extra install needed for CA operations or key generation.
pip install fipsign-sdk
pip install fipsign-sdk[async]
mandate.verify(): every other method in this SDK (sign(), verify(), ca.*, even the other mandate.* methods) fails with an invalid-key error. See Mandate 00.
from fipsign import PQAuth pq = PQAuth("pqa_your_api_key") # Or with all options (see PY 10 for full reference): # pq = PQAuth( # api_key="pqa_your_api_key", # base_url="https://api.fipsign.dev", # default # timeout=10, # seconds, default 10 # session=None, # custom requests.Session # )
Signs any payload with ML-DSA (44, 65, or 87 — configured per project at creation). The only required argument is sub — any string identifying the entity. All other keyword arguments are stored in the payload and returned on verify. Token cost: see Costs.
result = pq.sign("user_123", email="user@example.com", role="admin", expires_in_seconds=3600) token = result.token meta = result.meta usage = result.usage
result = pq.sign("order_456", amount=299.99, currency="USD", expires_in_seconds=300)
result = pq.sign("doc_789", hash="sha256:abc...", signed_by="alice")
result = pq.sign(
"agent_summarizer_v2",
action="document:summarize",
user_id="user_123",
trace_id="trace_abc",
)
result = pq.sign("device_iot_001", firmware="2.1.4", location="plant-A")
result = pq.sign("user_123") print(f"{result.usage.freeRemaining} free · {result.usage.packRemaining} pack") print(f"charged from: {result.meta.source}") # "free" | "pack" | "free+pack" print(f"project: {result.meta.projectId} · account: {result.meta.issuedFor}")
sub is required, max 128 characters. Other string fields max 256 characters. Maximum 10 custom keyword arguments (not counting expires_in_seconds). Exceeding these returns PQAuthError(code="API_ERROR", status=400).expires_in_seconds uses the backend default of 3600 seconds (1 hour).60.5, raises PQAuthError(code="API_ERROR", status=400).
_, for example _mandate) are reserved for the server. sign() raises PQAuthError(code="API_ERROR", status=400) with the message Custom fields starting with "_" are reserved.
Verifies the ML-DSA signature (algorithm read from the token itself), token expiry, and the revocation list. Never raises — always returns a VerifyResult. Token cost: see Costs.
result = pq.verify(token) if not result.valid: if result.failure == "rejected": # FIPSign checked the token and refuses it. result.error is one of these (text for your # logs: decide on result.failure, do not compare the text): # "Token has been revoked" # "Token expired N seconds ago" # "Invalid signature — token was tampered with or not issued by this server" # "Invalid signature — wrong length for ML-DSA-65 (expected 3309 bytes)" # "Invalid token — signature is not valid base64" # "Unsupported algorithm: X" # "Token payload exceeds the maximum of 16384 characters" # "This is a Mandate token. Verify it with POST /mandate/verify" raise PermissionError("Unauthorized") # FIPSign could not check the token (result.failure is "rate_limited", "quota_exhausted" # or "unavailable"). The token may be fine: do not sign the user out. # With "rate_limited", result.retry_after is the seconds to wait (None otherwise). raise RuntimeError(f"FIPSign could not check the token: {result.error}") print(result.payload["sub"]) # "user_123" print(result.payload["exp"]) # expiry timestamp (Unix) print(result.payload["iat"]) # issued at timestamp (Unix) # All custom fields passed to sign() are in payload too
failure | What happened | What to do |
|---|---|---|
"rejected" | FIPSign checked the token and refuses it: revoked, expired, tampered with, from another project, malformed, or a Mandate token | Answer 401. The user must sign in again |
"rate_limited" | FIPSign did not check the token: too many requests (REST 10). retry_after holds the seconds to wait | Answer 503 with Retry-After and try again after that time |
"quota_exhausted" | FIPSign did not check the token: no tokens left (free tokens and packs) | Answer 503. Waiting will not fix it: buy a pack from the dashboard (Dashboard 09) |
"unavailable" | FIPSign did not check the token: network error, timeout, FIPSign server error, invalid API key, or an answer the SDK cannot read | Answer 503 and try again later. If result.error says the API key is invalid, fix the key |
VerifyResult(valid=False, payload=None, error="...", failure="..."). failure tells a token that FIPSign refuses from a check that did not happen (table above); retry_after is only there with "rate_limited". What each error text means is in REST 04.localVerify: true. Token cost: see Costs.
mandate.emit() returns VerifyResult(valid=False, error="This is a Mandate token. Verify it with POST /mandate/verify"). Use pq.mandate.verify() (PY 13), which also checks status, scope and budget.
Immediately and permanently invalidates a token. Future verify() calls will reject it even if the signature is valid and it hasn't expired. Token cost: see Costs.
result = pq.revoke(token, "user logged out") print(result.message) # "Token revoked successfully" print(result.revokedAt) # Unix timestamp print(result.sub) # subject from the token payload print(result.expiresAt) # original expiry from the token print(result.note) # "This token will be rejected on any future /verify call"
pq.revoke(token, "order cancelled") pq.revoke(token, "suspicious activity detected")
RevokeResult(success=True, message="Token was already revoked") without consuming an extra token.revoke() on an already-expired token raises PQAuthError(code="API_ERROR", status=400). Expired tokens cannot be submitted for revocation.
revoke() on a token from mandate.emit() raises PQAuthError(code="API_ERROR", status=400): This is a Mandate token. Revoke it with PATCH /mandate/:id {"action":"revoke"}. Use pq.mandate.revoke(mandate.id) (PY 13).
Reads Authorization: Bearer <token> and attaches the decoded payload to the request context. Returns 401 when the token is missing or FIPSign refuses it, and 503 when FIPSign could not check it.
base64(json.dumps(token.to_dict())) — not a JWT. The login endpoint encodes the PQToken object, and the middleware decodes it. Clients that expect a JWT will not be compatible without adapting the encoding step.
from flask import Flask, g, request from fipsign import PQAuth, flask_middleware import base64, json from fipsign.types import PQToken app = Flask(__name__) pq = PQAuth("pqa_your_api_key") auth = flask_middleware(pq) # Stand-in for your own user check. Replace it with your database lookup # and a real password comparison (bcrypt, argon2…). def authenticate(email, password): if email == "ana@example.com" and password == "correct-horse": return {"id": "user_123", "email": email, "role": "admin"} return None @app.route("/login", methods=["POST"]) def login(): body = request.get_json(silent=True) or {} user = authenticate(body.get("email"), body.get("password")) if user is None: return {"error": "Invalid credentials"}, 401 result = pq.sign( user["id"], email=user["email"], role=user["role"], expires_in_seconds=3600, ) # Encode to base64 — this is what the client puts in Authorization: Bearer <encoded> encoded = base64.b64encode(json.dumps(result.token.to_dict()).encode()).decode() return {"token": encoded} @app.route("/logout", methods=["POST"]) def logout(): header = request.headers.get("Authorization", "") if header.startswith("Bearer "): try: token = PQToken.from_dict(json.loads(base64.b64decode(header[7:]).decode())) pq.revoke(token, "user logged out") except Exception: pass # ignore malformed token return {"success": True} @app.route("/api/profile") @auth def profile(): return {"user": g.fipsign_user}
from fastapi import FastAPI, Depends from fipsign import PQAuth, fastapi_middleware app = FastAPI() pq = PQAuth("pqa_your_api_key") require_auth = fastapi_middleware(pq) @app.get("/api/profile") def profile(user=Depends(require_auth)): return {"sub": user["sub"], "role": user.get("role")}
| Request | Flask answer | FastAPI answer |
|---|---|---|
| Valid token | your function runs; g.fipsign_user holds the payload | your function runs; user is the payload |
No Authorization: Bearer ... header | 401 {"error": "Authorization header required (Bearer <token>)"} | 401 {"detail": "Authorization header required"} |
| The Bearer value is not the base64 of a token | 401 {"error": "Invalid token format"} | 401 {"detail": "Invalid token format"} |
| The token is rejected: expired, revoked, tampered with, or from another project | 401 {"error": "<the text verify() returns>"} | 401 {"detail": "<the text verify() returns>"} |
| FIPSign could not check the token: rate limit | 503 {"error": "Authentication service temporarily unavailable"} with a Retry-After: <seconds> header | 503 {"detail": "Authentication service temporarily unavailable"} with a Retry-After: <seconds> header |
| FIPSign could not check the token: no tokens left, timeout, network error, FIPSign server error, invalid API key | 503 {"error": "Authentication service temporarily unavailable"} (no Retry-After) | 503 {"detail": "Authentication service temporarily unavailable"} (no Retry-After) |
Retry-After when it is there). The body does not say why FIPSign could not answer: to log the cause, call pq.verify(token) yourself and read error and failure (PY 03). The Flask decorator and the FastAPI dependency both answer this way. flask_middleware(pq) returns a decorator, so put @auth under @app.route on every route you want protected.g.fipsign_user / user) is the whole signed payload: your fields (sub, email, role...), iat and exp (Unix seconds), and _iss, the id of your FIPSign project, which FIPSign adds to every token. You can ignore _iss.POST /verify, which also detects revoked tokens (token cost: see Costs). The Python SDK has no local verification. The Bearer value is large because it carries the whole signed token: about 4.5 KB with ML-DSA-44, 6 KB with ML-DSA-65 and 8.3 KB with ML-DSA-87. If a proxy or load balancer sits in front of your server, check its header limit (nginx's default is 8 KB per header line).
All methods are identical to PQAuth but async. Use in FastAPI, aiohttp, or any asyncio-based application. Requires pip install fipsign-sdk[async].
import asyncio import json from fipsign.async_client import AsyncPQAuth from fipsign import generate_key_pair from fipsign.types import PQCert async def main(): async with AsyncPQAuth("pqa_your_api_key") as pq: signed = await pq.sign("user_123", role="admin", expires_in_seconds=3600) v = await pq.verify(signed.token) print(v.valid, v.payload["sub"]) # CA operations — network calls are async kp = generate_key_pair() # generate_key_pair() is synchronous cert = await pq.ca.issue( subject="device-serial-00123", public_key=kp.publicKey, expires_in_seconds=365 * 24 * 60 * 60, ) crl = await pq.ca.get_crl() # verify_cert(), verify_x509_cert() and verify_crl() 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) crl_check = pq.ca.verify_crl(crl, root_cert) # no await either if not crl_check.valid: raise PermissionError(crl_check.error) if pq.ca.is_cert_revoked(cert.meta.certId, crl.crl): raise PermissionError("Device revoked") asyncio.run(main())
PQAuth and AsyncPQAuth — they perform pure in-memory cryptographic operations with no network I/O. Do not use await with them.
Returns the current token balance, 6-month usage history, and purchased packs. Token cost: see Costs.
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'}")
usage() returns the whole account. What each project spent is in the dashboard (Dashboard 09, Consumption by project). The SDK has no method for it.
FIPSign fires webhook events automatically when you call sign(), verify(), or revoke(). The SDK triggers them — you do not call any method to enable them.
token.signed · token.rejected · token.revoked · limit.warning · limit.reachedUse verify_webhook_signature() from fipsign.middleware to verify the HMAC-SHA256 signature on incoming webhook requests. Each POST from FIPSign includes X-PQAuth-Signature (sha256=...), X-PQAuth-Event, and X-PQAuth-Timestamp headers.
from fipsign.middleware import verify_webhook_signature # Flask @app.route("/webhooks/fipsign", methods=["POST"]) def webhook(): from flask import request sig = request.headers.get("X-PQAuth-Signature", "") if not verify_webhook_signature(request.data, sig, FIPSIGN_WEBHOOK_SECRET): return "Invalid signature", 401 event = request.json kind = event["event"] if kind == "token.signed": pass elif kind == "token.rejected": pass elif kind == "token.revoked": pass elif kind == "limit.warning": pass elif kind == "limit.reached": pass return "ok", 200 # FastAPI from fastapi import Request, HTTPException @app.post("/webhooks/fipsign") async def webhook(request: Request): body = await request.body() sig = request.headers.get("X-PQAuth-Signature", "") if not verify_webhook_signature(body, sig, FIPSIGN_WEBHOOK_SECRET): raise HTTPException(status_code=401, detail="Invalid signature") event = await request.json() return "ok"
verify() never raises — it returns VerifyResult(valid=False, error="...", failure="...") on any failure (PY 03). All other methods raise PQAuthError.
from fipsign import PQAuth, PQAuthError try: result = pq.sign("user_123") except PQAuthError as err: if err.code == "INVALID_API_KEY": # key missing or doesn't match pqa_ + 64 hex chars pass elif err.code == "API_ERROR": # server returned an error (check err.status) if err.status == 429: # err.server_code is "rate_limited" (wait err.retry_after seconds) # or "token_quota_exhausted" pass elif err.code == "TIMEOUT": # request exceeded timeout (default: 10s) pass elif err.code == "NETWORK_ERROR": # connection failed pass elif err.code == "MISSING_SUB": # sign() called without sub pass print(err.code, err.message, err.status)
| err.status | Meaning |
|---|---|
| 400 | Invalid parameters — expires_in_seconds out of range or with decimals, invalid public key, meta passed to X.509 CA, expired token submitted for revocation |
| 401 | API key missing or invalid |
| 404 | Resource not found — no active CA for the project, certificate does not exist |
| 409 | Conflict — revoking an already-revoked certificate |
| 429 | Token quota exhausted or rate limit exceeded. err.server_code says which: "rate_limited" (wait err.retry_after seconds, then retry) or "token_quota_exhausted" (retrying will not help) |
import time from fipsign import PQAuthError def sign_with_retry(pq, sub): try: return pq.sign(sub) except PQAuthError as err: if err.server_code == "rate_limited": # retry_after: seconds until the one-minute window ends (from the Retry-After header) time.sleep(err.retry_after or 1) return pq.sign(sub) # one retry; if it fails again, the error is raised raise # "token_quota_exhausted" and every other error: waiting will not fix it
pq = PQAuth(
api_key="pqa_...", # required — pqa_ + 64 lowercase hex chars
base_url="https://api.fipsign.dev", # optional, override for self-hosting
timeout=10, # optional, seconds (default: 10)
session=None, # optional, custom requests.Session
)
| Option | Type | Default | Description |
|---|---|---|---|
| api_key | str | — | Required. Must match pqa_ followed by 64 lowercase hex characters. Raises INVALID_API_KEY immediately if the format doesn't match. |
| base_url | str | https://api.fipsign.dev | Override for local dev or self-hosted instances. |
| timeout | float | 10 | Request timeout in seconds (not milliseconds — unlike the JS SDK). Raises TIMEOUT on exceeded. |
| session | requests.Session | None | Custom session for proxies, custom TLS, or testing. |
Issue post-quantum certificates for devices, services, or any entity that needs a tamper-proof identity. Built on ML-DSA-65 (NIST FIPS 204) — independent of the ML-DSA variant configured for token signing in each project. Token cost of each method: see Costs.
from fipsign import generate_key_pair kp = generate_key_pair() # kp.publicKey — base64(1952 bytes raw public key) — pass to ca.issue() # kp.secretKey — base64(32 bytes, seed form) — store securely on the device
generateKeyPair(). The formats are not interchangeable. Use this function when the device runs Python; use the JS SDK's generateKeyPair() if the device runs JavaScript.from cryptography.hazmat.primitives.asymmetric.mldsa import MLDSA65PrivateKey import base64 private_key = MLDSA65PrivateKey.from_seed_bytes(base64.b64decode(kp.secretKey)) signature = private_key.sign(message) # bytes — 3309 bytes for ML-DSA-65
# ── PQCert CA ────────────────────────────────────────────────────────────── result = pq.ca.issue( subject="device-serial-00123", public_key=kp.publicKey, expires_in_seconds=365 * 24 * 60 * 60, # required — whole number, min 60, max 157_680_000 (5 years) meta={"model": "lock-v2", "batch": "2026-05"}, # optional, max 10 keys ) # result.certificate is a PQCert dataclass print(result.certificate.id) # cert_... print(result.certificate.caId) # ca_... print(result.certificate.expiresAt) # Unix timestamp print(result.meta.certId) # same as certificate.id print(result.meta.format) # "pqcert" print(result.meta.caExpiry) # None normally; CaExpiry(truncated=True, requestedExpiresInSeconds=N, resolvedExpiresInSeconds=N) if lifetime was truncated to fit CA root expiry # ── X.509 CA ─────────────────────────────────────────────────────────────── # Note: publicKey must be exactly 1952 bytes (ML-DSA-65 public key). # Note: meta is NOT supported for X.509 CAs — passing it returns API_ERROR 400. result = pq.ca.issue( subject="device-serial-00123", public_key=kp.publicKey, expires_in_seconds=365 * 24 * 60 * 60, ) # result.certificate is a PEM string print(type(result.certificate)) # <class 'str'> print(result.certificate[:27]) # "-----BEGIN CERTIFICATE-----" print(result.meta.certId) # cert_... — use this for revocation print(result.meta.caId) # ca_... print(result.meta.expiresAt) # Unix timestamp print(result.meta.format) # "x509"
result.meta.certId alongside the PEM certificate — you need it for ca.revoke_cert() and ca.is_cert_revoked().
import json from fipsign.types import PQCert with open("root-cert.json") as f: root_cert = PQCert.from_dict(json.load(f)) result = pq.ca.verify_cert(device_cert, root_cert) if not result.valid: # Possible error messages: # "Expected a CA_CERT certificate" # "Expected a CA_ROOT certificate" # "Certificate was not issued by this CA (caId mismatch)" # "Root CA certificate has expired" # "Certificate has expired" # "Invalid certificate signature" raise PermissionError(result.error) print(result.cert.subject) # "device-serial-00123" print(result.cert.expiresAt) # Unix timestamp
ca.get_crl() and ca.is_cert_revoked() for that. The error text is for your logs: decide with result.valid and do not compare the text (the JS SDK words these messages differently).meta are covered by the ML-DSA-65 signature. Altering any field after issuance will cause verify_cert() to reject the certificate.
import os root_pem = os.environ["FIPSIGN_ROOT_CERT_PEM"] # PEM saved at CA creation result = pq.ca.verify_x509_cert(cert_pem, root_pem) if not result.valid: # Possible error messages: # "Root CA certificate has expired" # "Certificate has expired" # "Invalid certificate signature — not signed by this root CA" # "Unsupported signature algorithm: <OID>. Expected ML-DSA-65 (2.16.840.1.101.3.4.3.18)" # "Unsupported root CA algorithm: <OID>. Expected ML-DSA-65 (2.16.840.1.101.3.4.3.18)" raise PermissionError(result.error) print(result.cert[:27]) # "-----BEGIN CERTIFICATE-----"
VerifyCertResult(valid, cert, error). Synchronous even when used with AsyncPQAuth. Does not check revocation.
crl_result = pq.ca.get_crl() # PQCert CA — pass the PQCert dataclass if pq.ca.is_cert_revoked(device_cert, crl_result.crl): raise PermissionError("Device certificate has been revoked") # X.509 CA — pass the certId string from meta if pq.ca.is_cert_revoked(result.meta.certId, crl_result.crl): raise PermissionError("Device certificate has been revoked") # certId string also works for PQCert CAs if pq.ca.is_cert_revoked(result.meta.certId, crl_result.crl): raise PermissionError("Device certificate has been revoked")
is_cert_revoked() searches the list you give it, on your machine, with no call. The list itself comes from get_crl(), one call to FIPSign (free; 60 per minute per API key). Download it every so often and keep it in memory or in a file, instead of asking for it on every check, and check its signature with ca.verify_crl() (below) right after you download it, so a list altered on the way is not taken as good. A certificate revoked after your last download is still accepted until the next one, so choose the age you can live with: generatedAt tells you when the list was made. Before a high-value operation, ask for that one certificate with ca.get_cert(): its status.revoked is answered in real time.
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'}") # result.raw is the whole list as the CA signed it, for PQCert and X.509 CAs: # {"caId", "subject", "format", "algorithm", "generatedAt", "revokedCerts", "signature"} # ca.verify_crl() checks it (next step) if result.raw: print(result.raw["signature"][:16] + "...") # base64 ML-DSA signature
result.raw and ca.verify_crl() checks that signature (next step).
import time result = pq.ca.get_crl() # root_cert: the root certificate you saved when the CA was created # PQCert CA: a PQCert (PQCert.from_dict(json.load(f)) of root-cert.json) or the dict itself # X.509 CA: the PEM string check = pq.ca.verify_crl(result, root_cert) if not check.valid: # check.error is plain text for your logs: decide with check.valid, do not compare the text # (the JS SDK words a few of these differently). It is one of: # "Invalid list signature — not signed by this CA" # "This list was not issued by this CA" # "This list is from a PQCert CA: pass its CA_ROOT certificate (a PQCert, or the dict of its JSON)" # "This list is from an X.509 CA: pass its root certificate in PEM form (a string)" # "The entries of this result (crl, caId, subject, generatedAt) are not the signed ones (raw)" # "This list is not signed: ..." # "The root certificate could not be read: pass the PEM of the CA root certificate" # "Unsupported root CA algorithm: <OID>. Expected ML-DSA-65 (2.16.840.1.101.3.4.3.18)" raise PermissionError("Revocation list not trusted: " + check.error) # check.generatedAt is when the CA signed the list (Unix seconds). A correctly signed old list is still # valid: you decide how old a list you accept. age_seconds = time.time() - check.generatedAt if age_seconds > 15 * 60: raise PermissionError("Revocation list is too old: download it again") # Now the entries can be trusted if pq.ca.is_cert_revoked(device_cert, result.crl): raise PermissionError("Device certificate has been revoked")
PQCert (or the dict of its JSON) for a PQCert CA, the PEM string for an X.509 CA. Never raises — always returns VerifyCrlResult(valid, generatedAt, error). Synchronous even when used with AsyncPQAuth. It runs on your machine: no call to FIPSign and no tokens.
valid means: the list is exactly what your CA signed at generatedAt. Nobody hid, added or changed a revocation on the way, and it is not the list of another CA. When you pass the result of get_crl(), what you read from it (crl, caId, subject, generatedAt) has to be the signed list too, so you can use it after the check. What it does not do: it does not decide how old a list you accept (the signature covers generatedAt, so an old list cannot pass as a new one, but a list that was signed correctly and is old is still valid), and it does not look at the expiry of the root certificate. It needs cryptography>=48.0.0, which the SDK installs.
result = pq.ca.get_cert("cert_...") print(result.status.revoked) # bool print(result.status.expired) # bool print(result.status.revokedAt) # Unix timestamp or None print(result.status.expiresAt) # Unix timestamp # For PQCert CAs, result.certificate is a PQCert dataclass # For X.509 CAs, result.certificate is a PEM string and result.meta contains # additional fields (None for PQCert CAs): if result.meta: print(result.meta.certId) # cert_... print(result.meta.caId) # ca_... print(result.meta.subject) # "device-serial-00123" print(result.meta.format) # "x509" print(result.meta.algorithm) # "ML-DSA-44" | "ML-DSA-65" | "ML-DSA-87"
# PQCert CA — use certificate.id result = pq.ca.revoke_cert(device_cert.id, "device decommissioned") # X.509 CA — use meta.certId from ca.issue() result = pq.ca.revoke_cert(meta.certId, "device reported stolen") # certId string works for both formats result = pq.ca.revoke_cert("cert_...", "device decommissioned") print(result.certId) # cert_... print(result.revokedAt) # Unix timestamp print(result.reason) # "device decommissioned" (or None if omitted) print(result.usage.freeRemaining) # tokens remaining after this operation # result.format == "x509" for X.509 CAs, None for PQCert CAs
PQAuthError(code="API_ERROR", status=409). This is different from revoke() on tokens, which is idempotent.
import json from fipsign import PQAuth, generate_key_pair from fipsign.types import PQCert pq = PQAuth("pqa_your_api_key") # root_cert — the PQCert JSON saved once at CA creation, stored securely with open("root-cert.json") as f: root_cert = PQCert.from_dict(json.load(f)) # 1. Factory: generate key pair for the device kp = generate_key_pair() # kp.secretKey — store securely on the device (32-byte seed, base64) # 2. Factory: issue a certificate for the device result = pq.ca.issue( subject="lock-serial-00123", public_key=kp.publicKey, expires_in_seconds=365 * 24 * 60 * 60, meta={"model": "lock-v3", "batch": "2026-05"}, ) certificate = result.certificate # PQCert dataclass — store on device # 3. At runtime: offline signature verification (no API call) result = pq.ca.verify_cert(certificate, root_cert) if not result.valid: raise PermissionError(result.error) # 4. Revocation: download the CRL every few minutes, check its signature and keep it (not on every check) crl_result = pq.ca.get_crl() check = pq.ca.verify_crl(crl_result, root_cert) if not check.valid: raise PermissionError(check.error) # At runtime: look the device up in the list you kept if pq.ca.is_cert_revoked(certificate, crl_result.crl): raise PermissionError("Device revoked") # 5. Decommission: revoke the certificate pq.ca.revoke_cert(certificate.id, "device decommissioned")
import os from fipsign import PQAuth, generate_key_pair pq = PQAuth("pqa_your_api_key") # root_pem — the PEM string shown once at CA creation, stored securely root_pem = os.environ["FIPSIGN_ROOT_CERT_PEM"] # 1. Factory: generate key pair for the device kp = generate_key_pair() # kp.secretKey — store securely on the device (32-byte seed, base64) # 2. Factory: issue a certificate for the device result = pq.ca.issue( subject="lock-serial-00123", public_key=kp.publicKey, expires_in_seconds=365 * 24 * 60 * 60, ) cert_pem = result.certificate # PEM string — store on device cert_id = result.meta.certId # store alongside the PEM — needed for revocation # 3. At runtime: offline signature verification (no API call) result = pq.ca.verify_x509_cert(cert_pem, root_pem) if not result.valid: raise PermissionError(result.error) # 4. Revocation: download the CRL every few minutes, check its signature and keep it (not on every check) crl_result = pq.ca.get_crl() check = pq.ca.verify_crl(crl_result, root_pem) if not check.valid: raise PermissionError(check.error) # At runtime: look the device up in the list you kept if pq.ca.is_cert_revoked(cert_id, crl_result.crl): raise PermissionError("Device revoked") # 5. Decommission: revoke the certificate using certId pq.ca.revoke_cert(cert_id, "device decommissioned")
Hash sensitive data locally and sign only the hash — the original data never reaches the API. Convenience wrapper over sign()/verify(); see REST 03b for the full explanation of the pattern.
result = pq.zes.sign({
"patient": "Jane Doe",
"diagnosis": "confidential",
"record_id": "MR-2026-4491",
})
# result.hash — the 64-char SHA-256 hex digest that was actually signed
# result.token — pass to zes.verify() or revoke() like any token
check = pq.zes.verify(result.token, {
"patient": "Jane Doe",
"diagnosis": "confidential",
"record_id": "MR-2026-4491",
})
if not check.valid or not check.dataMatches:
raise PermissionError("invalid or tampered")
valid is False, failure and retry_after say why, exactly as in verify() (PY 03): "rejected" means FIPSign refuses the token; any other value means the check did not happen. dataMatches is False whenever valid is False.
canonicalize_for_signing() already used by ca.verify_cert() — byte-identical to the JS SDK's hash for the same data.revoke(), middleware, and AsyncPQAuth all work unchanged — a ZES token is a regular token as far as they're concerned. There is no zes.revoke(): use pq.revoke(token) directly, same as any other token. The async client exposes the identical pq.zes.sign()/pq.zes.verify() API — just await them.
Bounded, revocable authorization for AI agents, IoT devices, and automated services. Thin typed wrapper over POST/PATCH/GET /mandate — see Mandate 00 for the full explanation of the immutable/mutable layers, lifecycle, and budget semantics. No dashboard setup needed beyond an API key — unlike CA, there's no separate resource to create first. Identical on pq.mandate (sync) and the async client (see PY 06) — every method below has an await-able async twin with the same name and signature, except list_all(), events_all() and query_events_all(), which are async generators (async for). Token cost of each method: see Costs.
| Who | Holds | Calls |
|---|---|---|
| Your backend | Project API key (full access) | mandate.emit(), narrow(), suspend(), resume(), revoke(), get(), list(), list_all(), events(), query_events(), export(), public_keys() (the reads of the log also have an …_all() version that walks the pages) — every developer-side operation. |
| The agent (only with proof of possession) | The mandate token and its own private key | generate_agent_key_pair() once, then sign_agent_call() for every call. No API key needed: nothing is sent to FIPSign. |
| Whoever performs the action (your executor — or the agent itself) | An API key and the mandate token | mandate.verify(), right before each action. If it is the agent itself, give it an agent key (restricted scope), never a full-access key. |
| Whoever checks the evidence (you later, an auditor) | The saved receipts or export, and the fingerprint of the key | verify_mandate_receipt(), verify_mandate_export(). No API key needed: nothing is sent to FIPSign. |
mandate.verify() and works out cost itself, so the agent never holds an API key and the budget can be trusted. Letting the agent call verify() itself also works — Mandate 00b explains both architectures and what each one protects.
result = pq.mandate.emit(
agent_id="agent-reporting-v2",
issued_by="user@empresa.com",
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. It also carries a receipt, FIPSign's signature over the emitted event: see Receipts and the audit log below.
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) # check.failure: "rejected" | "rate_limited" | "quota_exhausted" | "unavailable" | "outcome_unknown" — table below 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=..., failure=...), never an exception. failure says whether FIPSign decided or the answer never arrived (table below). The ten values reason can take for a mandate are listed in the type MandateDenyReason. There is also no local/offline mode here, unlike pq.verify(): budget and scope are live, mutable state that can only be checked against the server — the signature alone isn't enough to answer "is this action still authorized right now."pq is an agent key (restricted scope), only mandate.verify() above will work. mandate.emit(), narrow(), suspend(), resume(), revoke(), get(), list(), list_all() and the calls that read the log and the keys (events(), query_events(), export(), public_keys()) 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.
verify() are all optional: agent_signature (proof of possession, below), receipt=True (FIPSign's signature over the event this call produced, to keep as proof) and correlation_id (your own id for the call). The last two are in Receipts and the audit log.
cost. The third argument of verify() is enforced exactly as given — FIPSign does not know what an action really costs. Compute it in the code that performs the action, not in the agent. See Mandate 00b.MandateVerifyResult(result="denied", reason="Network error: …", failure="outcome_unknown"), but the server may have granted the call and consumed budget before the connection dropped. Treat it as "do not act" and do not just send it again: see the table below and Mandate 02c.
failurefailure | What happened | Anything consumed? | What to do |
|---|---|---|---|
"rejected" | FIPSign looked at the call and refused it: reason says why (scope, budget, expired, revoked, suspended, agent signature…), or the request was malformed | No | Do not act. Repeating the same call gives the same answer |
"rate_limited" | Too many requests in the current minute (REST 10). retry_after holds the seconds to wait | No | Do not act yet. Try again after that time |
"quota_exhausted" | No tokens left (free tokens and packs) | No (the mandate budget is given back) | Waiting will not fix it: buy a pack from the dashboard (Dashboard 09) |
"unavailable" | FIPSign answered but could not check the call, for example because the API key is invalid | No | Do not act. reason has the message: fix the cause |
"outcome_unknown" | No usable answer: a timeout, a network error, an answer the SDK cannot read, or a FIPSign server error | Maybe: the call may have been granted and charged | Do not act on it and do not just send it again. With proof of possession, re-send the same call with the same agent_signature while it is still valid (see below). Without it, compare budgetConsumed from mandate.get(id) with the value you had before the call |
check = pq.mandate.verify(mandate_token, "send_reply", 1) if check.result != "granted": if check.failure == "outcome_unknown": # No usable answer: the call may have been granted and charged. Do NOT act yet. # Without proof of possession: read the budget and compare it with the value you had before the call. now = pq.mandate.get(mandate_id).mandate # needs your full-access key print("budgetConsumed is now", now.budgetConsumed) raise RuntimeError("outcome unknown: do not act yet") if check.failure == "rate_limited": raise RuntimeError(f"rate limited: try again in {check.retry_after} s") raise PermissionError(check.reason) do_the_action()
result="denied", never a third value, so code that only checks result != "granted" keeps working: it never acts on a call that may not have been granted. Decide on failure, not on the text of reason. The SDK never repeats a call by itself: FIPSign does not recognise a repeated request, so a repeated call is a new call and is charged again. The async client behaves the same way.mandate.get() needs your full-access key. An agent key (restricted scope) can only call mandate.verify(): if the service that verifies holds an agent key, read the budget from your backend, which holds the full-access key. Details in Mandate 02c.
import json from fipsign import generate_agent_key_pair # On the agent, once. The default variant is ML-DSA-65. kp = generate_agent_key_pair() # Another variant: generate_agent_key_pair("ML-DSA-44") # or "ML-DSA-87" # kp.publicKey → goes to your backend, for mandate.emit(agent_public_key=...) # kp.secretKey → stays on the agent, in a secret store. Never send it anywhere. # kp.algorithm → "ML-DSA-65" here. Save it next to secretKey (Step 3 needs it) # Save the two together on the agent (secret store, or a file only the agent's user can read). # Step 3 reads them back. saved = json.dumps({"secretKey": kp.secretKey, "algorithm": kp.algorithm})
| Variant | publicKey | secretKey | Signature |
|---|---|---|---|
ML-DSA-44 | 1312 bytes | 32 bytes (seed) | 2420 bytes |
ML-DSA-65 (default) | 1952 bytes | 32 bytes (seed) | 3309 bytes |
ML-DSA-87 | 2592 bytes | 32 bytes (seed) | 4627 bytes |
generate_agent_key_pair("ML-DSA-44"). Any other text ("ml-dsa-44", "ML-DSA44"…) raises UNSUPPORTED_ALGORITHM.generate_agent_key_pair() for agents and generate_key_pair() only for CA certificates. ca.issue() accepts ML-DSA-65 keys only, so a key of another variant is rejected there (HTTP 400, nothing is charged).secretKey secret. Store it in a secret store (or a file only the agent's user can read), never log it, never send it anywhere. If it is lost or may have leaked, revoke the mandate and emit a new one with a new key pair: the public key of a mandate can never be changed.secretKey is the 32-byte ML-DSA seed (base64), the only private-key form the Python cryptography package can load. The JS SDK's generateAgentKeyPair() returns the full expanded key (2560, 4032 or 4896 bytes) instead. The two secret keys are not interchangeable: sign_agent_call() rejects a JS key with INVALID_SECRET_KEY, so an agent must sign with the SDK that generated its key. The public key and the signature are identical in both SDKs: an agent that signs in Python can be verified by a service that runs the JS SDK, and the other way around. The seed of either recipe in Mandate 02b also works here, with the matching algorithm (the recipes use "ML-DSA-44").
result = pq.mandate.emit(
agent_id="agent-reporting-v2",
issued_by="user@empresa.com",
scope=["sign", "send_reply"],
budget_total=1000,
expires_in_seconds=28800,
agent_public_key=kp.publicKey, # base64, from generate_agent_key_pair()
)
print(result.mandate.requiresAgentSignature) # True
# Keep result.mandate.id in your database: you need it to narrow, suspend, revoke and inspect.
mandate_token = result.mandate.token # give this to the agent
A key of the wrong size is rejected with HTTP 400 and nothing is charged, so a mandate that could never be verified cannot be created by mistake. Only the public key travels to your backend; it is stored once and can never change.
import json from fipsign import sign_agent_call # On the agent, for EVERY call: no API key needed, nothing is sent to FIPSign. key = json.loads(saved) # what Step 1 saved (read it from your secret store) agent_signature = sign_agent_call( mandate_token, # the mandate token (or just the mandate id) "send_reply", # exactly what mandate.verify() will receive 1, # exactly what mandate.verify() will receive key["secretKey"], # the secretKey from Step 1 algorithm=key["algorithm"], # required: saved in Step 1, never typed by hand ) # Send agent_signature to whoever calls mandate.verify() — your executor, or the agent itself
algorithm is required, and you do not make it up. It is the text that generate_agent_key_pair() returned in kp.algorithm — one of "ML-DSA-44", "ML-DSA-65" or "ML-DSA-87" — saved next to secretKey in Step 1. A 32-byte seed does not say which ML-DSA variant it belongs to (all three use the same size), so the SDK cannot detect it the way the JS SDK does from the size of its expanded key. There are two ways to get it wrong. A text that is not exactly one of the three ("ml-dsa-65", "ML-DSA65"…) is refused at once with UNSUPPORTED_ALGORITHM. A valid text that is not the variant of the key is not caught locally: the backend answers agent_signature_invalid.action and cost must be exactly what you pass to verify(). Change one and you must sign again, otherwise verify() answers agent_signature_mismatch.expires_in_seconds, between 1 and 60) and the server accepts it once. Sign again for every attempt; never cache signatures.sign_agent_call(). The server accepts a signature stamped up to 30 seconds ahead of its own clock, so with the default lifetime the agent's clock may be off by about 30 seconds either way. Keep clocks synchronized (NTP). On devices where you cannot, pass expires_in_seconds=60: it tolerates an agent clock up to 60 seconds behind.
check = pq.mandate.verify(mandate_token, "send_reply", 1, agent_signature=agent_signature) if check.result != "granted": # check.reason also covers the agent's signature: # "agent_signature_required" | "agent_signature_invalid" | # "agent_signature_mismatch" | "agent_signature_replayed" raise PermissionError(check.reason) do_the_action()
failure="outcome_unknown"), 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.
| You see | Typical cause | Fix |
|---|---|---|
agent_signature_required (in reason) | verify() was called without agent_signature= on a mandate emitted with agent_public_key. | Sign the call and pass it as agent_signature. |
agent_signature_invalid | Signed with a different key than the mandate's agent_public_key; an algorithm that is not the variant of the key; signature already expired; agent clock more than 30 seconds ahead of the server. | Sign with the secretKey of the pair whose publicKey you emitted, and with the algorithm that pair came with; keep the default lifetime; synchronize clocks. |
agent_signature_mismatch | The action or cost that was signed differ from the ones passed to verify(). | Sign exactly the values you verify. Change one and you must sign again. |
agent_signature_replayed | This exact signature was already accepted. | Sign again for every call. |
TypeError: sign_agent_call() missing 1 required keyword-only argument: 'algorithm' | You called sign_agent_call() without algorithm=, as you would with the JS SDK. | Add algorithm= with the text saved next to the secret key (kp.algorithm from Step 1). |
PQAuthError INVALID_ARGUMENT | sign_agent_call() got a missing or invalid mandate, action (non-empty string), cost (whole number ≥ 0) or expires_in_seconds (whole number from 1 to 60). Also raised by every method that takes a mandate_id when it is empty, . or ... | Fix the argument; err.message names it. |
PQAuthError INVALID_SECRET_KEY | secret_key is not valid base64 or is not a 32-byte seed — for example the expanded key generated by the JS SDK. | Use the secretKey returned by generate_agent_key_pair(). |
PQAuthError UNSUPPORTED_ALGORITHM | The algorithm name given to generate_agent_key_pair() or sign_agent_call() is unknown. | Use kp.algorithm, or one of "ML-DSA-44", "ML-DSA-65", "ML-DSA-87" written exactly like that (capitals, hyphens, no spaces). |
from fipsign import PQAuthError, sign_agent_call try: agent_signature = sign_agent_call(mandate_token, "send_reply", 1, secret_key, algorithm="ML-DSA-65") except PQAuthError as err: print(err.code, err.message)
Generates a key pair, emits a mandate that requires it and runs the four interesting cases against the live API. Save as pop_sdk_demo.py, run pip install fipsign-sdk, then FIPSIGN_API_KEY=pqa_... python pop_sdk_demo.py. Token cost: the emit and the one granted verify (see Costs); 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="dev@example.com", 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. pq.mandate.revoke(mandate.id)
emitted mdt_… · requiresAgentSignature: True no signature → denied agent_signature_required with signature → granted · budgetRemaining: 9 same signature → denied agent_signature_replayed cost changed → denied agent_signature_mismatch
pq.mandate.narrow(mandate.id, ["read:crm"]) # shrink scope — monotonic, cannot re-widen pq.mandate.suspend(mandate.id) # pause — verify() denies with "mandate_suspended" while suspended pq.mandate.resume(mandate.id) # reactivate a suspended mandate pq.mandate.revoke(mandate.id) # permanent — irreversible, no narrow/suspend/resume after this
patched = pq.mandate.suspend(mandate.id)
print(patched.status, patched.scope, patched.updatedAt)
# suspended ['sign', 'verify', 'read:crm'] 1785691318
narrow(), suspend(), resume() and revoke() return a MandatePatchResult with id, status, scope (after the change), updatedAt (Unix seconds) and the receipt of the change (Receipts and the audit log). Suspending a mandate that is already suspended changes nothing: it returns only id, status and message="Already suspended": scope, updatedAt and receipt are None. Narrowing to the scope the mandate already has is still a change: it is recorded and has a receipt.
verify() always returns reason="mandate_suspended" — the backend checks status before it ever looks at the budget counter.
result = pq.mandate.get("mdt_...") print(result.mandate.budgetConsumed, result.mandate.budgetRemaining, result.mandate.scopeCurrent, result.mandate.status) # First page: the 50 most recent mandates of this project page = pq.mandate.list() print(page.count, page.nextCursor) # MandateListResult(mandates, count, nextCursor) # Your own page size (1–100), then the next page page1 = pq.mandate.list(limit=20) page2 = pq.mandate.list(limit=20, cursor=page1.nextCursor) if page1.nextCursor else None # Every mandate, page by page — stop whenever you want with break for m in pq.mandate.list_all(): print(m.id, m.status, m.budgetConsumed) # Async client: same names, list_all() is an async generator (no await before it) # async for m in pq.mandate.list_all(): ...
list() returns one page — up to 50 mandates by default (limit: 1–100), most recent first. nextCursor is a string while more pages exist and None on the last one: pass it back as cursor, exactly as received. count is the size of this page, not a total (the API has no total). A page can hold fewer than limit mandates — even none — while nextCursor is not None, so drive your loop with the cursor, never with the page size. list_all() does exactly that for you, one page at a time (its limit is the page size, not a cap on the total). Keep the id of every mandate you emit rather than listing to find it.expired status: check expiresInSeconds (it is 0 once the mandate has expired). An expired mandate can still be read for 24 hours, then get() raises PQAuthError with status 404.narrow(), suspend(), resume(), revoke(), get(), list() and emit() raise PQAuthError with code API_ERROR and the HTTP status: 409 when the state does not allow the change (already revoked, expired, not suspended), 404 when the mandate does not exist, 400 for invalid input (for list(): a limit out of range or a cursor that is not one the API gave you). Revoking twice returns 409 — treat it as success when retrying.events(), export(), query_events(), public_keys() and their …_all() versions raise PQAuthError in the same way: 404 when there is no log for that mandate id, 400 for a filter or a limit that is not valid, 429 when the 300 reads per minute of the key are used (REST 10). verify_mandate_receipt() and verify_mandate_export() never raise.
FIPSign records everything that happens to a mandate — it was emitted, each call it granted or denied, each change — in a log, and signs it. There is nothing to switch on. With the SDK you ask for a receipt on the calls you may have to prove, keep it, and check it on your own machine; later you can read the log and export it. Receipts, reading the log and the export cost no tokens (Costs). What the log holds, how it is protected and what a receipt does and does not prove: Mandate 11 to 15. The steps below follow one another: each one uses what the one before it made, and the complete example at the end runs all of them.
# emit(): the answer has the receipt next to the mandate result = pq.mandate.emit( agent_id="agent-reporting-v2", issued_by="user@empresa.com", scope=["sign", "verify", "read:crm"], budget_total=1000, expires_in_seconds=28800, correlation_id="ticket-4821", # your own id for this call ) mandate, emitted = result.mandate, result.receipt # verify(): a receipt only if you ask for it, granted or denied check = pq.mandate.verify( mandate.token, "read:crm", 1, receipt=True, correlation_id="req-77" ) print(check.result, check.receipt.event.seq) # granted 2 (1 is the emit) # narrow(), suspend(), resume() and revoke(): always a receipt revoked = pq.mandate.revoke(mandate.id, correlation_id="ticket-4822").receipt print(revoked.event.type) # revoked
emit(), narrow(), suspend(), resume() and revoke() always return a receipt. verify() returns one only when you pass receipt=True, and then whether the call was granted or denied; there is none when the token signature is invalid, when the mandate no longer exists or when no answer arrived. correlation_id is your own id for the call (a ticket, a request id): 1 to 128 characters, no control characters. It is written inside the event, so the receipt covers it, and you can find the event with it later (Step 4). Suspending a mandate that is already suspended changes nothing and returns no receipt.Keep the receipt next to your own record of the call, as JSON:
import json with open("receipt-emit.json", "w") as f: json.dump(emitted.to_dict(), f)
The fingerprint is the SHA-256 of the public key of your project, 64 hexadecimal characters. It tells you which key to trust. Run this once, the day you integrate:
import os from fipsign import PQAuth, public_key_fingerprint pq = PQAuth(os.environ["FIPSIGN_API_KEY"]) current = next(k for k in pq.mandate.public_keys().keys if k.status == "current") print(public_key_fingerprint(current.publicKey)) # Prints: <64 hexadecimal characters>
FIPSIGN_KEY_FINGERPRINTS). Every check below compares what FIPSign sends with it. Do not ask FIPSign for it again at the moment of the check: you would only be repeating what FIPSign says. Each project has its own fingerprint, and it changes when you rotate the keys (Dashboard 06): then add the new one after the old one, separated by a comma, because the receipts made before the rotation need the old key. The same value comes from the curl command of Mandate 12: use whichever you prefer.
from fipsign import verify_mandate_receipt # The fingerprints you saved, separated by commas (one for each key you had) pinned = os.environ["FIPSIGN_KEY_FINGERPRINTS"].split(",") keys = pq.mandate.public_keys().keys # free; includes retired keys result = verify_mandate_receipt( emitted, pin_fingerprint=pinned, keys=keys, expect_mandate_id=mandate.id, # fails if the receipt is of another mandate ) if result.valid: ev = result.event print(f"valid: event {ev.seq} ({ev.type}) of {result.mandateId}, at {ev.at}") else: print("NOT valid:", result.problems) # Prints: valid: event 1 (emitted) of mdt_…, at 1785690939
verify_mandate_receipt() runs on your machine: it sends nothing to FIPSign, needs no API key and never raises: whatever is wrong comes back in problems. A key is trusted only if its fingerprint is one you pinned; keys is where the candidates come from (pq.mandate.public_keys() is free and also lists the keys your project retired). Save that list once as a file and read it back, and the check needs no request at all. If you saved the public key itself, pass public_key instead of pin_fingerprint and keys. expect_mandate_id= makes the check fail if the receipt belongs to another mandate. A receipt you saved as JSON (json.load) is accepted as it is.pq.mandate.verify_receipt() and pq.mandate.verify_export() do the same, and if you give them neither pin_fingerprint nor public_key they trust the keys FIPSign lists and answer keyTrust="fipsign": that detects a receipt that was altered, but not a key that FIPSign itself replaced. For proof you will show to someone else, pin the key as above.import json # Every event of one mandate, oldest first (the pages are fetched for you) for e in pq.mandate.events_all(mandate.id): print(e.seq, e.type, json.loads(e.body).get("correlationId")) # Search the events of all your mandates, newest first page = pq.mandate.query_events(correlation_id="req-77") for e in page.events: print(e.mandateId, e.seq, e.type) # Every denied call of the last 7 days, page by page for e in pq.mandate.query_events_all(type="verify_denied"): print(e.mandateId, e.at, json.loads(e.body)["reason"]) # Prints: # 1 emitted ticket-4821 # 2 verify_granted req-77 # 3 revoked ticket-4822 # mdt_… 2 verify_granted # mdt_… 1785691402 scope_not_authorized (one line for each denied call)
events_all() reads the log of one mandate, oldest first, and shows an event as soon as it happened. query_events() searches all the mandates of the project, newest first, and sees an event about 5 seconds after it happened. Without mandateId, correlationId or traceId it looks at the last 7 days unless you give from; from from to to may span at most 90 days. The filters are mandate_id, type, action, key_id, correlation_id, trace_id, from_ (with an underscore: from is a Python keyword), to, limit (1–200, default 50) and cursor: each one is an exact match, and they combine. events(), query_events() and export() return one page (nextAfter or nextCursor says where the next one starts, and is None on the last); events_all() and query_events_all() walk the pages for you, one request each: stop with break when you have what you need.seq, type, at, prevHash, hash and body. body is a string: the exact text that was hashed. Read it with json.loads(e.body) and never serialize it again. All of these need your full-access key (an agent key gets an invalid-key error), cost no tokens and share the 300 reads per minute of the key with get() and list() (REST 10). The fields of an event: Mandate 11. The filters: Mandate 13.
import json from fipsign import verify_mandate_export pages = pq.mandate.export_all(mandate.id) # every page, in order with open("export.json", "w") as f: json.dump([p.to_dict() for p in pages], f) # keep it # Now, or years later, with no network and no API key: with open("export.json") as f: saved = json.load(f) audit = verify_mandate_export(saved, pin_fingerprint=pinned) print(audit.valid, audit.complete, audit.events, audit.problems) # Prints: True False 3 [] (valid, complete, events, problems)
export_all() returns every page of the export, in order. The export holds the events of the mandate and the public keys of your project, so the check needs nothing else but the fingerprint you pinned. A mandate that is still alive gives complete: false: the last events are protected by the signed head of the export and by the receipts you hold, until FIPSign seals them with a checkpoint. It writes one at most once an hour while there are events no checkpoint covers, and always one more as the last event before it cleans the mandate up (24 hours after it expires). More in Mandate 14.
| Field | In | Meaning |
|---|---|---|
valid | both | true only if everything checks out. Receipt: the signature, the event it covers and its hash. Export: every event follows the one before it and is what it says it is, and every signature in the export verifies. |
problems | both | A list of texts: what does not check out. Empty when valid is true. |
keyTrust | both | "pinned": you fixed the key (pin_fingerprint or public_key). "fipsign": you gave neither, so the keys came from FIPSign (only pq.mandate.verify_receipt() and pq.mandate.verify_export() do that): it detects a log or a receipt that was altered, but not a key that FIPSign itself replaced. |
projectId, mandateId, event | receipt | What the receipt says it is about, when it could be read. |
complete | export | The log starts at event 1, ends in a checkpoint that seals everything before it, and nothing follows. A log that is valid but not complete has events at its end that only the signed head of the export, or a receipt you hold, protect. |
events, lastSeq | export | How many events were checked, and the seq of the last one. |
checkpoints, sealedThrough, headChecked | export | How many checkpoints were verified; every event up to sealedThrough is sealed by one of them; the signed head of the export was there and matches the chain. |
notes | export | What is not wrong but limits what was checked, for example: the export starts at event 40. |
Change one number of a receipt and the check says so. Run it after Step 3, with the same pinned and keys:
import copy # Change one number of a receipt: it no longer passes forged = copy.deepcopy(emitted.to_dict()) body = forged["event"]["body"] forged["event"]["body"] = body.replace('"budgetTotal":1000', '"budgetTotal":9999') bad = verify_mandate_receipt(forged, pin_fingerprint=pinned, keys=keys) print(bad.valid, bad.problems) # Prints: False ['event 1: hash is not sha256(prevHash + "\\n" + body)']
Emits a mandate, makes one granted and one denied call, checks the three receipts, shows that a changed receipt fails, revokes the mandate, reads its log, finds an event by your own id and checks the export. Save as audit_sdk_demo.py, run pip install fipsign-sdk, set FIPSIGN_API_KEY and FIPSIGN_KEY_FINGERPRINTS (the fingerprint you saved in Step 2) and run python audit_sdk_demo.py. Token cost: the emit and the one granted verify (see Costs); the denied call, the receipts, the reads and the checks are free.
# audit_sdk_demo.py — receipts, the log and the export with the SDK, end to end. # Setup: pip install fipsign-sdk # Run: export FIPSIGN_API_KEY=pqa_... # export FIPSIGN_KEY_FINGERPRINTS=<64 hexadecimal characters> # python audit_sdk_demo.py import copy import json import os import time from fipsign import PQAuth, verify_mandate_export, verify_mandate_receipt pq = PQAuth(os.environ["FIPSIGN_API_KEY"]) pinned = os.environ["FIPSIGN_KEY_FINGERPRINTS"].split(",") # see Step 2 keys = pq.mandate.public_keys().keys # 1. Emit a mandate and make two calls on it: one granted, one denied. # Ask for a receipt on each. result = pq.mandate.emit( agent_id="agent-audit-demo", issued_by="dev@example.com", scope=["sign"], budget_total=10, expires_in_seconds=3600, correlation_id="demo-emit", ) mandate, emitted = result.mandate, result.receipt granted = pq.mandate.verify( mandate.token, "sign", 1, receipt=True, correlation_id="demo-granted" ) denied = pq.mandate.verify( mandate.token, "delete", 1, receipt=True, correlation_id="demo-denied" ) print("granted:", granted.result, "· denied:", denied.result, denied.reason) # 2. Check the three receipts on this machine. for r in [emitted, granted.receipt, denied.receipt]: c = verify_mandate_receipt( r, pin_fingerprint=pinned, keys=keys, expect_mandate_id=mandate.id ) seq, kind = r.event.seq, r.event.type print("valid" if c.valid else "NOT valid", f"event {seq} {kind}", c.problems) # 3. A receipt in which one number was changed does not pass. forged = copy.deepcopy(emitted.to_dict()) body = forged["event"]["body"] forged["event"]["body"] = body.replace('"budgetTotal":10', '"budgetTotal":99') bad = verify_mandate_receipt(forged, pin_fingerprint=pinned, keys=keys) print("forged receipt:", "valid" if bad.valid else "NOT valid") # 4. Revoke the mandate, then read its log. pq.mandate.revoke(mandate.id, correlation_id="demo-revoke") for e in pq.mandate.events_all(mandate.id): print(e.seq, e.type, json.loads(e.body).get("correlationId")) # 5. Search by your own id. This search sees an event about 5 seconds later. time.sleep(6) found = pq.mandate.query_events(correlation_id="demo-denied") names = [f"{e.type} (event {e.seq})" for e in found.events] print("found by correlationId:", ", ".join(names)) # 6. Export the whole log and check it on this machine. pages = pq.mandate.export_all(mandate.id) audit = verify_mandate_export(pages, pin_fingerprint=pinned) print("export valid:", audit.valid, "· events:", audit.events) if not audit.valid: print(audit.problems)
granted: granted · denied: denied scope_not_authorized valid event 1 emitted [] valid event 2 verify_granted [] valid event 3 verify_denied [] forged receipt: NOT valid 1 emitted demo-emit 2 verify_granted demo-granted 3 verify_denied demo-denied 4 revoked demo-revoke found by correlationId: verify_denied (event 3) export valid: True · events: 4
Use FIPSign directly from Claude — sign tokens and issue certificates through natural language.
Available for TypeScript (@fipsign/mcp) and Python (fipsign-mcp).
Both MCP servers expose the same 11 tools covering the full FIPSign runtime API. Install one — they're equivalent.
Each tool costs what the operation it calls costs: see Costs.
Edit claude_desktop_config.json and restart Claude Desktop. The API key is passed via environment variable — never hardcode it.
{
"mcpServers": {
"fipsign": {
"command": "npx",
"args": ["-y", "@fipsign/mcp"],
"env": {
"FIPSIGN_API_KEY": "pqa_your_api_key_here"
}
}
}
}
{
"mcpServers": {
"fipsign": {
"command": "uvx",
"args": ["fipsign-mcp"],
"env": {
"FIPSIGN_API_KEY": "pqa_your_api_key_here"
}
}
}
}
Add FIPSign to Claude Code with a single command. The server runs per-project or globally.
claude mcp add fipsign -- env FIPSIGN_API_KEY=pqa_your_api_key npx -y @fipsign/mcp
claude mcp add fipsign -- env FIPSIGN_API_KEY=pqa_your_api_key uvx fipsign-mcp
claude mcp list claude mcp get fipsign
--scope global to make it available in all Claude Code sessions.
| Variable | Required | Default | Description |
|---|---|---|---|
FIPSIGN_API_KEY |
Yes (most tools) | — | Your FIPSign API key. Format: pqa_ + 64 lowercase hex chars.
fipsign_health and fipsign_generate_key_pair work without it. fipsign_public_key requires an API key — it returns the public key for the project associated with that key.
Do not use an agent-scoped key here (dashboard checkbox "Agent key — restricted scope") — none of the 11 MCP tools call POST /mandate/verify, so an agent key would fail on all of them. Agent keys are only for direct SDK/REST calls to mandate.verify(). |
FIPSIGN_BASE_URL |
No | https://api.fipsign.dev |
Override the API base URL. Useful for self-hosted instances or local development tunnels. |
Once connected, Claude can call FIPSign tools directly in response to natural language.
Both MCP servers are open source. Use MCP Inspector for interactive testing before connecting to a client.
# Install inspector globally once npm install -g @modelcontextprotocol/inspector # Run inspector against the published package FIPSIGN_API_KEY=pqa_your_api_key npx @modelcontextprotocol/inspector npx @fipsign/mcp # Or from a local clone git clone https://github.com/fipsign/fipsign-mcp cd fipsign-mcp && npm install && npm run build FIPSIGN_API_KEY=pqa_your_api_key npx @modelcontextprotocol/inspector node dist/index.js
# Using uvx — no install needed FIPSIGN_API_KEY=pqa_your_api_key npx @modelcontextprotocol/inspector uvx fipsign-mcp # Or from a local clone git clone https://github.com/fipsign/fipsign-mcp-python cd fipsign-mcp-python && pip install -e . FIPSIGN_API_KEY=pqa_your_api_key npx @modelcontextprotocol/inspector python -m fipsign_mcp.server
http://localhost:5173. Click any tool, fill in the arguments, and call it directly to test your API key and inspect the response before connecting to Claude.
Use with any language via curl or HTTP client.
Base URL: https://api.fipsign.dev · Auth: X-API-Key: pqa_your_key
Set these in your terminal before running any example in this section.
export API_KEY="pqa_YOUR_API_KEY_HERE" export BASE_URL="https://api.fipsign.dev"
POST /mandate/verify: every other endpoint rejects it with the same 401 as an invalid key. Agent keys can only be created in the dashboard: there is no API or SDK call for it. See Mandate 00.
Public endpoint. No authentication required. Token cost: see Costs.
curl -s $BASE_URL/health | jq
Requires X-API-Key. Token cost: see Costs. 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
publicKey decoded from base64) is the fingerprint of the key. Receipts and exports of the Mandate audit log name the key that signed them by its fingerprint, and you pin it to trust that key: see Mandate 12. After a rotation this endpoint returns only the new key. The keys the project had before, the retired ones, are listed by GET /public-keys (Mandate 14).
Requires X-API-Key. Token cost: see Costs.
curl -s -X POST $BASE_URL/sign \ -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" \ -d '{"sub":"user_123","email":"user@example.com","role":"admin","expiresInSeconds":3600}' | jq
TOKEN=$(curl -s -X POST $BASE_URL/sign \ -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" \ -d '{"sub":"user_test","email":"test@example.com"}' \ | jq -c '.token')
sub is required, max 128 characters. Other string fields max 256 characters. Maximum 10 custom fields (not counting sub, email, role, expiresInSeconds). Exceeding these returns HTTP 400.expiresInSeconds defaults to 3600 seconds (1 hour).60.5, returns HTTP 400.ML-DSA-44, ML-DSA-65, or ML-DSA-87) is determined by the project configuration set at creation time in the dashboard. It is immutable — all tokens signed by a project use the same algorithm. The algorithm is reflected in both token.algorithm and meta.algorithm in the response.
_) belong to the server — for example _iss and _mandate — and cannot be set through /sign. A field like _mandate or _foo in the body returns HTTP 400 Custom fields starting with "_" are reserved; _iss alone is silently ignored. This keeps tokens from /sign and Mandate tokens (POST /mandate) apart: only POST /mandate can produce a token that /mandate/verify will accept.
Sign sensitive data without sending it to the API. The data is hashed locally — only a 64-character SHA-256 hex digest (32 bytes) is transmitted. PQ-Sign never sees the original content. Use it when plaintext must not leave your infrastructure. A ZES token is a regular token: it lives at most 24 hours (1 hour by default), so use it to check that data did not change between two of your services, not to keep a record for later. Token cost: see Costs.
POST /sign, you hash it locally using SHA-256 and send only the hash. The server signs the hash — not your data. To verify, you recalculate the hash locally and verify the token normally via POST /verify, while the token is still valid.POST /sign and POST /verify endpoints — the only difference is what you send as the payload.
JSON.stringify does, because that is what the SDKs and the JavaScript example below do: no spaces, and non-ASCII characters as they are (UTF-8), not as \uXXXX. In Python, json.dumps needs ensure_ascii=False for that. Numbers with a decimal point are written differently by Python (10.0, 1e-05) and by JavaScript (10, 0.00001), so in your own code send amounts and identifiers as strings, as the examples do, and keep key names to plain ASCII letters (not keys made only of digits). The SDKs already follow all of this, for any JSON value.
# Your sensitive data — never leaves your machine SENSITIVE='{"amount":"50000","customer":"Acme Corp","invoice":"INV-123"}' # Keys must be sorted alphabetically: amount → customer → invoice # Hash locally with SHA-256 → hex ZES_HASH=$(echo -n "$SENSITIVE" | sha256sum | awk '{print $1}') echo "Hash: $ZES_HASH" # Hash: a3f2c1d8e9b4f7c2... (64 hex chars — this is what travels to the API)
ZES_TOKEN=$(curl -s -X POST $BASE_URL/sign -H "Content-Type: application/json" -H "X-API-Key: $API_KEY" -d "{\"sub\": \"zes:$ZES_HASH\", \"zes\": true}" | jq -c '.token') echo $ZES_TOKEN | jq
# Recalculate the hash from the original data (on your side) VERIFY_HASH=$(echo -n "$SENSITIVE" | sha256sum | awk '{print $1}') # Verify the token — same endpoint as always curl -s -X POST $BASE_URL/verify -H "Content-Type: application/json" -H "X-API-Key: $API_KEY" -d "{\"token\": $ZES_TOKEN}" | jq
SHA-256(your_data) and compare it against payload.sub (strip the zes: prefix). If they match and valid: true, the data is exactly what FIPSign signed at iat (the time on FIPSign's clock). Once the token has expired, verify rejects it, so this check only works while the token is valid.{"amount":"5000","customer":"Acme"} and {"customer":"Acme","amount":"5000"} produce different hashes — the token will not verify if the key order differs between sign and verify. This applies to nested objects too: sort keys inside every nested object, not only at the top level, or the hash will differ depending on how the object was originally constructed.{"file_hash":"<sha256_of_bytes>","filename":"report.pdf"} — then hash that JSON with keys sorted.POST /revoke with the token object. The response includes sub with the zes: prefix. See section 05.zes: true field is a convention that signals to your own systems that the sub field contains a hash rather than a plain identifier. PQ-Sign treats it as any other custom field — it counts as 1 of the 10 allowed custom fields in the payload. If you need all 10 slots for your own fields, omit zes: true and rely on the zes: prefix in sub to identify ZES tokens in your own systems.
import crypto from 'crypto' // Step 1 — hash locally (keys sorted recursively — nested objects too) function canonicalize(obj) { if (Array.isArray(obj)) return obj.map(canonicalize) if (obj !== null && typeof obj === 'object') { return Object.fromEntries(Object.keys(obj).sort().map(k => [k, canonicalize(obj[k])])) } return obj } const sensitive = { amount: '50000', customer: 'Acme Corp', invoice: 'INV-123' } const sorted = JSON.stringify(canonicalize(sensitive)) const hash = crypto.createHash('sha256').update(sorted).digest('hex') // Step 2 — sign the hash const res = await fetch('https://api.fipsign.dev/sign', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': process.env.PQSIGN_KEY }, body: JSON.stringify({ sub: `zes:${hash}`, zes: true }) }) const { token } = await res.json() // Step 3 — verify: recalculate hash and confirm it matches payload.sub const verifyRes = await fetch('https://api.fipsign.dev/verify', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': process.env.PQSIGN_KEY }, body: JSON.stringify({ token }) }) const { valid, payload } = await verifyRes.json() const rehash = crypto.createHash('sha256').update(sorted).digest('hex') const matches = payload.sub === `zes:${rehash}` console.log(`valid: ${valid}, data matches token: ${matches}`) // true, true
import hashlib, json, requests, os # Step 1 — hash locally (keys sorted recursively — nested objects too) def canonicalize(obj): if isinstance(obj, list): return [canonicalize(x) for x in obj] if isinstance(obj, dict): return {k: canonicalize(obj[k]) for k in sorted(obj)} return obj sensitive = {'amount': '50000', 'customer': 'Acme Corp', 'invoice': 'INV-123'} sorted_json = json.dumps(canonicalize(sensitive), separators=(',', ':'), ensure_ascii=False) zes_hash = hashlib.sha256(sorted_json.encode('utf-8')).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('utf-8')).hexdigest() matches = v['payload']['sub'] == f'zes:{rehash}' print(f'valid: {v["valid"]}, data matches token: {matches}') # True, True
Requires X-API-Key. Token cost: see Costs. Verifies signature, expiry, and revocation status.
curl -s -X POST $BASE_URL/verify \ -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" \ -d "{\"token\": $TOKEN}" | jq
FAKE_TOKEN=$(echo $TOKEN | jq -c '.payload = "TAMPERED_PAYLOAD"') curl -s -X POST $BASE_URL/verify \ -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" \ -d "{\"token\": $FAKE_TOKEN}" | jq
valid. A rejected token is always HTTP 401 with "valid": false; a malformed request is HTTP 400 (no valid field); HTTP 429 is a rate limit or an exhausted quota (REST 10). The error text says why — use it in your logs, but do not build logic on comparing it.
| HTTP | error | When |
|---|---|---|
| 401 | Token has been revoked | The token was revoked with POST /revoke. |
| 401 | Token expired N seconds ago | The signature is valid but the token's exp is in the past. N is how many seconds ago, for example Token expired 120 seconds ago. |
| 401 | Invalid signature — token was tampered with or not issued by this server | The signature does not match the payload or the project's key: the payload was changed, the token was signed by another key, or it was issued for a different project of yours (you get the same text in all three cases). |
| 401 | Invalid signature — wrong length for ML-DSA-65 (expected 3309 bytes) | The signature has the wrong size for the token's algorithm. The text names the token's own algorithm: ML-DSA-44 expects 2420 bytes, ML-DSA-65 3309, ML-DSA-87 4627. |
| 401 | Invalid token — signature is not valid base64 | The signature field is not base64 text. |
| 401 | Unsupported algorithm: X | The token's algorithm is not exactly ML-DSA-44, ML-DSA-65 or ML-DSA-87. X is the value you sent. |
| 401 | Token payload exceeds the maximum of 16384 characters | The token's payload text is longer than 16,384 characters. |
| 401 | This is a Mandate token. Verify it with POST /mandate/verify | The token came from POST /mandate (see the note below). |
| 400 | "token" is required | The body has no token object. HTTP 400 responses have no valid field. |
| 400 | Invalid token format — missing payload, signature, or algorithm | The token object lacks payload, signature or algorithm. |
issuedAt is informational. The token object carries an issuedAt field, but /verify and /revoke never read it. What counts is the signed payload: iat and exp.
POST /mandate is not a normal token: /verify would only check its signature and expiry, and would say valid: true even for a revoked or suspended mandate. It answers HTTP 401 { "success": false, "valid": false, "error": "This is a Mandate token. Verify it with POST /mandate/verify" } instead (the call is billed as a normal /verify). Use POST /mandate/verify, which also checks status, scope and budget.
Requires X-API-Key. Token cost: see Costs. Permanently and immediately invalidates the token.
curl -s -X POST $BASE_URL/revoke \ -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" \ -d "{\"token\": $TOKEN, \"reason\": \"user logged out\"}" | jq
{ "success": true, "message": "Token was already revoked" } without consuming an extra token./revoke on an already-expired token returns HTTP 400 with "Token is invalid or already expired — cannot revoke". Expired tokens cannot be submitted for revocation.POST /revoke is limited to 300 requests/minute per API key, same as /sign and /verify. See section 10.
POST /revoke answers HTTP 400 This is a Mandate token. Revoke it with PATCH /mandate/:id {"action":"revoke"} (nothing is charged). A Mandate is revoked by its id with PATCH /mandate/:id, and that does not require the token.
Requires X-API-Key. Token cost: see Costs. Returns balance, 6-month history, purchased packs, and account info.
curl -s $BASE_URL/usage \
-H "X-API-Key: $API_KEY" | jq
tokensUsed: 0.GET /usage adds up all your projects, whichever API key you call it with. What each project spent is in the dashboard, Dashboard 09 (Consumption by project): there is no endpoint for it with an API key, so a key never reads what your other projects spent.current (the live balance — freeRemaining, packRemaining, totalRemaining) is always exact and is what determines whether a request is accepted or rejected for being over your token quota. monthlyHistory and per-project usage stats are an audit log — under very high burst traffic they may show a slightly lower total than current. If you need the exact token count at any point in time, read current, not the sum of monthlyHistory.Copy, replace your API key, and run to test the full token lifecycle.
#!/bin/bash API_KEY="pqa_YOUR_API_KEY" BASE_URL="https://api.fipsign.dev" echo "[1/7] Health..." curl -s $BASE_URL/health | jq .status echo "[2/7] Sign..." TOKEN=$(curl -s -X POST $BASE_URL/sign \ -H "Content-Type: application/json" -H "X-API-Key: $API_KEY" \ -d '{"sub":"user_test","role":"user"}' | jq -c '.token') echo "[3/7] Verify (must be true)..." curl -s -X POST $BASE_URL/verify -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" -d "{\"token\": $TOKEN}" | jq .valid echo "[4/7] Tampered (must be false)..." FAKE=$(echo $TOKEN | jq -c '.payload = "FAKE"') curl -s -X POST $BASE_URL/verify -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" -d "{\"token\": $FAKE}" | jq .valid echo "[5/7] Revoke..." curl -s -X POST $BASE_URL/revoke -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" -d "{\"token\": $TOKEN, \"reason\": \"test\"}" | jq .message echo "[6/7] Verify revoked (must be false)..." curl -s -X POST $BASE_URL/verify -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" -d "{\"token\": $TOKEN}" | jq '{valid,error}' echo "[7/7] Balance..." curl -s $BASE_URL/usage -H "X-API-Key: $API_KEY" | jq .current
sub max 128 chars · other string fields max 256 chars · max 10 custom fields.expiresInSeconds on /sign whole number, min 60, max 86,400 (24 hours).subject max 256 chars · meta max 10 keys (PQCert only) · expiresInSeconds whole number, min 60, max 157,680,000 (5 years).
| HTTP | Error | Cause |
|---|---|---|
| 400 | "sub" is required | sign() called without sub field |
| 400 | "sub" exceeds maximum length of 128 characters | sub field too long |
| 400 | Maximum of 10 custom fields allowed in payload | sign() called with more than 10 custom fields |
| 400 | Token is invalid or already expired — cannot revoke | revoke() called on expired token, or token issued for a different project |
| 400 | This is a Mandate token. Revoke it with PATCH /mandate/:id {"action":"revoke"} | POST /revoke called with a token issued by POST /mandate |
| 400 | Custom fields starting with "_" are reserved | POST /sign called with a custom field whose name starts with an underscore (_iss alone is ignored) |
| 400 | "meta" is not supported for X.509 CAs | ca.issue() called with meta on an X.509 CA |
| 400 | "expiresInSeconds" must be at least 60 | Certificate TTL below minimum |
| 400 | "expiresInSeconds" must not exceed 157680000 (5 years) | Certificate TTL above maximum |
| 400 | "CA root expires too soon to issue a valid certificate" | CA root has less remaining lifetime than the minimum certificate TTL — the CA root itself is near expiry |
| 400 | "expiresInSeconds" must be at least 60 | Token TTL below minimum (POST /sign) |
| 400 | "expiresInSeconds" must not exceed 86400 (24 hours) | Token TTL above maximum (POST /sign) |
| 400 | "publicKey" must be a base64-encoded ML-DSA-65 public key (1952 bytes) | Invalid or wrong-size public key for X.509 CA |
| 400 | "subject" exceeds maximum length of 256 characters | Certificate subject too long |
| 400 | Invalid body — expected JSON | The request body is not valid JSON |
| 400 | Invalid body — expected a JSON object | The body is valid JSON but is not an object: null, an array or a plain value |
| 400 | "expiresInSeconds" must be an integer | expiresInSeconds has decimals, for example 60.5 (POST /sign and POST /ca/issue) |
| 415 | Content-Type must be application/json | Request body sent with a non-JSON Content-Type (e.g. text/plain, multipart/form-data) |
| 401 | API key required or invalid | Missing or incorrect X-API-Key, or key not matching pqa_ + 64 hex chars |
| 401 | Token has been revoked | Token was previously revoked via POST /revoke |
| 401 | Invalid signature — token was tampered with or not issued by this server | Token signature is invalid (tampered or wrong key), or token issued for a different project |
| 401 | Invalid signature — wrong length for ML-DSA-65 (expected 3309 bytes) | The signature has the wrong size for the token's algorithm (ML-DSA-44: 2420, ML-DSA-65: 3309, ML-DSA-87: 4627 bytes) |
| 401 | Invalid token — signature is not valid base64 | The token's signature field is not base64 text |
| 401 | Unsupported algorithm: X | The token's algorithm is not ML-DSA-44, ML-DSA-65 or ML-DSA-87 |
| 401 | Token payload exceeds the maximum of 16384 characters | The token's payload text is longer than 16,384 characters |
| 401 | Token expired N seconds ago | Token has expired |
| 401 | This is a Mandate token. Verify it with POST /mandate/verify | POST /verify called with a token issued by POST /mandate |
| 404 | No active CA found for this project | CA not yet created — create it in the dashboard (Dashboard 04) |
| 404 | Certificate not found | certId does not exist or belongs to another project |
| 409 | Certificate is already revoked | ca.revokeCert() called on already-revoked cert |
| 429 | Rate limit exceeded. Maximum 300 requests per minute per API key. | Too many requests in the current minute (the number in the text depends on the endpoint). The body has "code": "rate_limited"; wait the seconds in the Retry-After header and retry — see REST 10 |
| 429 | Token limit reached. Free monthly tokens exhausted and no active packs. | Monthly quota exhausted. The body has "code": "token_quota_exhausted" (the text can be shorter: Token limit reached.). Purchase a pack from the dashboard (Dashboard 09), retrying won't help |
All endpoints are rate limited. 300 requests/minute per API key on write endpoints, 60/minute on CA read endpoints. HTTP 429 on excess, with a Retry-After header.
| Endpoint | Limit | Window | Scope |
|---|---|---|---|
| GET /public-key | 300 requests | 1 minute | Per API key |
| POST /sign | 300 requests | 1 minute | Per API key |
| POST /verify | 300 requests | 1 minute | Per API key |
| POST /revoke | 300 requests | 1 minute | Per API key |
| POST /ca/issue | 300 requests | 1 minute | Per API key |
| POST /ca/revoke | 300 requests | 1 minute | Per API key |
| GET /ca/crl | 60 requests | 1 minute | Per API key |
| GET /ca/certificate/:id | 60 requests | 1 minute | Per API key |
| GET /mandate, GET /mandate/:id, GET /mandate/:id/events, GET /mandate/events, GET /mandate/:id/export, GET /public-keys | 300 requests | 1 minute | Per API key, one counter for all six (Mandate 07) |
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 (Dashboard 09) is the only remedy, retrying won't help.
HTTP/2 429
retry-after: 37
{
"success": false,
"error": "Rate limit exceeded. Maximum 300 requests per minute per API key.",
"code": "rate_limited"
}
HTTP/2 429
{
"success": false,
"error": "Token limit reached. Free monthly tokens exhausted and no active packs.",
"code": "token_quota_exhausted",
"usage": {
"freeRemaining": 0,
"packRemaining": 0,
"totalRemaining": 0
}
}
// retry.mjs — Node.js 20+, no SDK. Run it with: FIPSIGN_API_KEY=pqa_... node retry.mjs const BASE_URL = 'https://api.fipsign.dev' const API_KEY = process.env.FIPSIGN_API_KEY async function fipsign(path, body, retries = 1) { const res = await fetch(BASE_URL + path, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': API_KEY }, body: JSON.stringify(body), }) if (res.status !== 429) return res const err = await res.json() if (err.code === 'rate_limited' && retries > 0) { // Retry-After = seconds until the one-minute window ends const seconds = Number(res.headers.get('Retry-After') ?? 1) await new Promise(resolve => setTimeout(resolve, seconds * 1000)) return fipsign(path, body, retries - 1) } // 'token_quota_exhausted' (or still rate limited after the retry): waiting will not fix it now throw new Error(`FIPSign answered 429 ${err.code}: ${err.error}`) } const res = await fipsign('/sign', { sub: 'user_123' }) console.log(res.status, await res.json())
All CA runtime operations are available via REST. The CA root is created once from the dashboard — there is no REST endpoint for that. All endpoints require X-API-Key. The API key determines the project; no projectId needed in the request body. Token cost of each endpoint: see Costs.
X-API-Key and both limited to 60 requests per minute per API key: GET /ca/crl returns the whole list, which you download every so often and search on your side, and GET /ca/certificate/:id returns the status of one certificate in real time.
CERT=$(curl -s -X POST $BASE_URL/ca/issue \ -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" \ -d '{ "subject": "device-serial-00123", "publicKey": "base64_ml_dsa65_public_key", "expiresInSeconds": 31536000, "meta": {"model": "lock-v2", "batch": "2026-05"} }') CERT_ID=$(echo $CERT | jq -r '.meta.certId') echo "Issued: $CERT_ID"
curl -s $BASE_URL/ca/crl -H "X-API-Key: $API_KEY" | jq '.'
reason may be null if no reason was provided at revocation time. The CA signs the list: crl carries its signature, and the list of an X.509 CA has the same shape (REST 11b). Check the signature before you trust the list: ca.verifyCrl() (JS SDK) and ca.verify_crl() (Python SDK) do it, and the steps for any other language are in Verify a revocation list without the SDK, at the end of this section. Download the list every so often and search it on your side, instead of asking for it on every check: a certificate revoked after your last download is still accepted until the next one, so choose the age you can live with (generatedAt tells when the list was made, and the signature covers it).curl -s $BASE_URL/ca/certificate/$CERT_ID \ -H "X-API-Key: $API_KEY" | jq '.status'
curl -s -X POST $BASE_URL/ca/revoke \ -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" \ -d "{\"certId\": \"$CERT_ID\", \"reason\": \"device decommissioned\"}" | jq
"Certificate is already revoked". This is different from POST /revoke on tokens, which is idempotent.
ca.verifyCert() in the JS SDK and ca.verify_cert() in the Python SDK do this for you. In any other language, with the certificate and the root certificate you saved when the CA was created (both JSON objects), do these checks and reject the certificate if one fails:"type": "CA_CERT", the root has "type": "CA_ROOT", and the caId of the certificate is equal to the id of the root.expiresAt (Unix seconds) of the certificate and of the root are later than now.signature field and write it as JSON with no spaces, the keys of every object sorted alphabetically at every level (meta included; arrays keep their order) and non-ASCII characters written as they are, not as \uXXXX. It is the text JSON.stringify gives once the keys are sorted.signature of the certificate and the base64-decoded publicKey of the root.GET /ca/crl or GET /ca/certificate/:id above.
ca.verifyCrl() in the JS SDK and ca.verify_crl() in the Python SDK do this for you. In any other language, with the answer of GET /ca/crl and the root certificate you saved when the CA was created (the CA_ROOT JSON object), take the crl object of the answer and trust the list only if all of these checks pass:crl.format is "pqcert", crl.algorithm is "ML-DSA-65" and crl.caId is equal to the id of the root.crl without its signature field and write it as JSON in the same way as a certificate (step 3 of the block above): no spaces, the keys of every object sorted alphabetically at every level, arrays in their order (revokedCerts stays as it comes) and non-ASCII characters written as they are, not as \uXXXX. It is the text JSON.stringify gives once the keys are sorted.signature of crl and the base64-decoded publicKey of the root.crl.generatedAt (Unix seconds) is covered by the signature, so an old list cannot pass as a new one, but a list that was signed correctly and is old is still valid. These checks do not look at the expiry of the root certificate.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. Token cost of each endpoint: see Costs.
X.509 (PEM) as the format, type a CA name and click Create CA. Save the PEM shown after creation — it is shown only once.cryptography>=48.0.0 or the JS SDK's ca.verifyX509Cert().
node -e "
import('@noble/post-quantum/ml-dsa.js').then(({ml_dsa65}) => {
const seed = new Uint8Array(32);
crypto.getRandomValues(seed);
const kp = ml_dsa65.keygen(seed);
let pub = '', sec = '';
for(let i=0;i<kp.publicKey.length;i++) pub += String.fromCharCode(kp.publicKey[i]);
for(let i=0;i<kp.secretKey.length;i++) sec += String.fromCharCode(kp.secretKey[i]);
console.log('PUBLIC_KEY=' + btoa(pub));
console.log('SECRET_KEY=' + btoa(sec));
})"
# publicKey must be exactly 1952 bytes (ML-DSA-65 public key) # meta is not supported for X.509 CAs — omit it CERT_PEM=$(curl -s -X POST $BASE_URL/ca/issue \ -H "Content-Type: application/json" \ -H "X-API-Key: $API_KEY" \ -d "{ \"subject\": \"device-serial-00123\", \"publicKey\": \"$DEVICE_PUBLIC_KEY\", \"expiresInSeconds\": 31536000 }") CERT_ID=$(echo $CERT_PEM | jq -r '.meta.certId') echo $CERT_PEM | jq -r '.certificate' # prints PEM
curl -s $BASE_URL/ca/crl -H "X-API-Key: $API_KEY" | jq '.'
"format": "x509": crl is an object with revokedCerts, and it comes signed by the CA. To check the signature, follow Verify a revocation list without the SDK in REST 11, with two differences: the public key is the 1952-byte ML-DSA-65 key in the SubjectPublicKeyInfo of your root certificate PEM (there is no publicKey field to read), and there is no caId to compare (an X.509 root certificate does not carry it). ca.verifyCrl() (JS SDK) and ca.verify_crl() (Python SDK) do all of it with the root PEM. The SDKs return revokedCerts as the flat list crl and keep the signed object in raw. Download the list every so often and search it on your side (see the note under REST 11).
curl -s $BASE_URL/ca/certificate/$CERT_ID \ -H "X-API-Key: $API_KEY" | jq '{status, meta}'
certificate. As with PQCert there is no endpoint to verify it: use ca.verifyX509Cert() (JS SDK) or ca.verify_x509_cert() (Python SDK), or a tool that validates X.509 chains with ML-DSA. Revocation is checked with the list above or with this call.
A complete example: issue a certificate, sign a device message, and verify it on the server including cert chain validation, expiry check, and replay protection.
# Set DEVICE_PUBLIC_KEY and DEVICE_SECRET_KEY from REST 11b above
# Already covered in REST 11b — CERT_PEM and CERT_ID set
node -e "
import('@noble/post-quantum/ml-dsa.js').then(({ml_dsa65}) => {
const message = JSON.stringify({ deviceId: 'device-serial-00123', timestamp: Date.now(), value: 42.5 });
const secretKeyB64 = process.env.DEVICE_SECRET_KEY;
const bin = atob(secretKeyB64);
const secretKey = new Uint8Array(bin.length);
for(let i = 0; i < bin.length; i++) secretKey[i] = bin.charCodeAt(i);
const msgBytes = new TextEncoder().encode(message);
const signature = ml_dsa65.sign(msgBytes, secretKey);
let sigStr = '';
for(let i = 0; i < signature.length; i++) sigStr += String.fromCharCode(signature[i]);
console.log(JSON.stringify({ message, signature: btoa(sigStr), certificate: process.env.DEVICE_CERT_PEM }));
})"
Verify the certificate chain using the JS SDK's pq.ca.verifyX509Cert(certPem, rootPem) or Python's pq.ca.verify_x509_cert(cert_pem, root_pem) (requires cryptography>=48.0.0). Then verify the message signature with ml_dsa65.verify().
notAfter).GET /ca/crl for any operation where revocation matters, and check its signature with the root certificate (ca.verifyCrl() / ca.verify_crl()) before you trust it.DEVICE_SECRET_KEY in a secrets manager or hardware secure element — never in plaintext.FIPSign sends HTTP POST requests to your endpoint when events occur. Webhook configuration is dashboard-only — it requires a user session and is not available via API key.
import crypto from 'crypto' app.post('/webhooks/fipsign', express.json(), (req, res) => { const sig = req.headers['x-pqauth-signature'] const expected = 'sha256=' + crypto.createHmac('sha256', process.env.FIPSIGN_WEBHOOK_SECRET) .update(JSON.stringify(req.body)).digest('hex') if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return res.status(401).send('Invalid signature') const { event, data } = req.body // handle event... res.status(200).send('ok') })
"revoked" when the token was revoked; for every other rejection, the same text /verify returns in error, for example "Token expired 120 seconds ago" (all the texts are in REST 04)apiKeyName tells you which key triggered them.
A Mandate is a signed ML-DSA credential that defines what an agent can do, for how long, and with what budget.
Base URL: https://api.fipsign.dev · Auth: X-API-Key: pqa_your_key
Mandate is a PQ-Sign feature — not a separate product. It uses the same ML-DSA signing infrastructure as /sign, extended with a real-time mutable state layer that gives you per-agent control.
id (the mandate ID), agentId, issuedBy, scopeOriginal, budgetTotal, projectId, issuedAt, expiresAt. Cannot be altered — any change invalidates the signature.scopeCurrent, budgetConsumed, status. scopeCurrent and status change through PATCH /mandate/:id; budgetConsumed grows with every granted POST /mandate/verify. None of this invalidates the token. If you passed agentPublicKey, that key is also stored server-side — it is fixed at emission and can never be changed.POST /mandate/verify. If you want to guarantee the caller is the specific agent the mandate was issued to (not just anyone who obtained the token), pass agentPublicKey when emitting the mandate. The agent then signs each action with its own private key, and POST /mandate/verify requires that signature in addition to the token. The agent's signature covers that exact call (mandate, action and cost), lives at most 60 seconds and works only once. See sections 01, 02 and 02b for details.X-API-Key to call POST /mandate/verify — and by default, that same key can call every other endpoint in the system (/sign, /ca/issue, PATCH /mandate/:id, etc.). If the agent process is compromised, a full-access key is a much bigger blast radius than the mandate alone. To avoid this, create the agent's key as an agent key — restricted scope from the dashboard (your project → + New key → check "Agent key — restricted scope"; there is no API or SDK call to create it — see Dashboard 03): 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.receipt you can keep as proof, and the log can be read and exported at no cost. Nothing to switch on, and nothing in it can grant, deny or change anything. See 11, 12 and 13.
budgetTotal is abstract — the unit means whatever you define it to mean. It could be dollars, API calls, credits, operations, or any other countable resource. You pass a cost on each POST /mandate/verify call and the system deducts it from the budget. When budgetTotal: 0, budget checking is disabled — the agent can act any number of times until expiry or revocation; cost is still required on every verify call and is still added to budgetConsumed, only the limit check is skipped. All budget and scope rules in one place: 00b — Integration guide.
Read this before you write code. It answers the questions that come up in almost every integration: which credential lives where, who should call verify, what cost really means, and what the scope and budget rules are. Everything here follows the behavior of the API.
| Credential | Held by | What it is for | If it leaks |
|---|---|---|---|
Project API key (pqa_…, full access) | Your backend only | Emit, PATCH, GET and verify. | Anyone holding it can emit mandates and control every existing one. Revoke it from the dashboard and create a new one. Never give it to an agent. |
| Agent key (restricted scope) | The process that calls verify, when that is not your backend | Only POST /mandate/verify. Every other endpoint rejects it with 401, as if it did not exist. It is created only in the dashboard (your project → + New key → check "Agent key — restricted scope"): there is no API or SDK call to create it (see Dashboard 03). | The holder can spend budget of mandates it also has the token for — nothing else. Revoke the key from the dashboard. |
Mandate token (the token object) | The agent, or the executor acting for it | Proves the mandate was issued by your project. FIPSign does not store it: if you lose it, emit a new mandate. | It is a bearer credential: whoever holds it and an API key of your project can call verify — unless the mandate uses proof of possession (below). Revoke the mandate with PATCH /mandate/:id. |
Agent private key (only with agentPublicKey) | The agent only | Signs every verify call (proof of possession, see 02b). FIPSign only ever sees the public half. | Revoke the mandate and emit a new one with a new keypair — the public key of a mandate can never be changed. |
Receipt (the receipt object) | Your backend, next to the mandate id | Proves what FIPSign recorded about the mandate, and when (see 12). | It holds no credential and cannot be used to act, but it shows the details of one event (agent, scope, budget, action). FIPSign does not keep receipts and cannot issue one again: do not lose it. |
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: the emitted entry of the audit log records both.
your backend / executor FIPSign the agent
──────────────────────── ─────── ─────────
1. POST /mandate ─────────────────► signs, registers state
◄───────────────── { id, token }
2. keep the id, hand the token over ──────────────────────────────► holds the token
│ "please do X"
3. ◄───────────────────────────────────────────────────────────────────┘
work out the real cost of X
POST /mandate/verify
{ token, action: "X", cost } ─► checks signature, status, expiry,
scope, budget — atomically
◄─ granted / denied
4. only if granted: perform X
| Pattern | Use it when | Trade-offs |
|---|---|---|
| A. Your executor calls verify (recommended) | The agent reaches your systems through code you control: a tool server, a backend, an API gateway. | The agent never holds an API key. cost is computed by you, so the budget is trustworthy. Proof of possession is optional. |
| B. The agent calls verify itself | Autonomous agents, devices in the field, or code you do not control. | Give it an agent key (restricted scope, created in the dashboard) and emit the mandate with agentPublicKey so a copied token is useless. cost is declared by the agent, so the budget only holds if the agent is honest (see below). |
cost you send on each verify call is deducted from budgetTotal. They are independent — a verify call that consumes 500 budget units still costs the same platform tokens as one that consumes 0.cost. FIPSign has no table of action prices: the caller of verify reports cost and the server enforces the limit on the number it is given. So the budget is only as trustworthy as the caller. Under pattern A, compute cost yourself from what the action really does (rows read, dollars moved, tokens spent) — never take it from the agent's request. Under pattern B, proof of possession makes sure nobody can change the cost in transit, but a compromised agent can still under-report it.
| Topic | Rule |
|---|---|
| Matching an action | Exact, case-sensitive string comparison against scopeCurrent. There are no wildcards or prefixes: claims:* does not match claims:approve, and Claims:Approve does not match claims:approve. Leading and trailing spaces are trimmed on both sides. |
| Scope limits | 1–20 items, each at most 64 characters, duplicates removed. Choose a naming convention such as resource:verb and use exactly those strings in your executor. |
| The scope only means something if you call verify | FIPSign cannot know what your code does. A mandate that allows read:crm does not stop your code from doing something else; it only guarantees that the verify call for read:crm was granted. Your executor must call verify with the action it is about to perform, right before performing it. |
| Narrowing | Only ever shrinks: the new scope must be a non-empty subset of the current scope. An item that was removed cannot be added back — emit a new mandate instead. |
| Budget check | A call is granted while budgetConsumed + cost ≤ budgetTotal. Landing exactly on budgetTotal is allowed; after that only cost: 0 calls are granted. |
| No limit | budgetTotal: 0 disables the limit check. cost is still required and still accumulates in budgetConsumed, so you can measure usage. budgetRemaining is always 0 in that case — use budgetConsumed. |
| Concurrency | The state of each mandate is processed one call at a time on the server, so parallel calls cannot overspend it. With a budget of 5 and ten simultaneous calls of cost 1, exactly five are granted and five get budget_exhausted. Different mandates never block each other. |
| Denied calls | Never change budgetConsumed and never cost platform tokens. |
| More budget / more time | Not possible on an existing mandate: budgetTotal and expiresAt are signed into the token. Emit a new mandate (and revoke the old one if it must stop working). To avoid gaps, emit the replacement a little before the current one expires — expiresInSeconds in every granted response tells you how long is left. |
| Status | active ⇄ suspended can be toggled any number of times. revoked is permanent. narrow also works while suspended. |
| Expiry and retention | There is no expired status. After expiresAt verify answers mandate_expired and PATCH answers 409 (except revoke). The mandate can still be read with GET for 24 hours, then it is deleted and GET returns 404. Its audit log stays readable for 365 days (11). |
POST /mandate, and is not stored by FIPSign. Store it where only the component that needs it can read it, and store the mandate id next to it in your own database: the id is what you need to suspend, narrow, revoke and inspect the mandate later. Use GET /mandate (section 05) for audits and reconciliation, not to find ids you did not save.PATCH /mandate/:id with {"action": "revoke"} — and emit a new one. Revocation is checked on every verify call, so it takes effect for the next call. It cannot undo a call that was already granted.POST /mandate and PATCH /mandate/:id return are your proof of what FIPSign recorded, and FIPSign cannot issue them again (12).
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. Token cost: see Costs.
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": "user@empresa.com", "scope": ["sign", "verify", "read:crm"], "budgetTotal": 1000, "expiresInSeconds": 28800, "correlationId": "ticket-4821" }'
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":"dev@example.com","scope":["sign","verify"],"budgetTotal":100,"expiresInSeconds":3600,"correlationId":"ticket-4821"}') MANDATE_ID=$(echo "$MANDATE" | jq -r '.mandate.id') MANDATE_TOKEN=$(echo "$MANDATE" | jq -c '.mandate.token') echo "$MANDATE" | jq -c '.receipt' > emit-receipt.json
| Field | Type | Required | Description |
|---|---|---|---|
| agentId | string | Yes | Identifier for the agent or device. Max 128 characters. Covered by ML-DSA signature — immutable after emission. |
| issuedBy | string | Yes | Who authorized this mandate (e.g. email, user ID, system name). Max 256 characters. Covered by ML-DSA signature — immutable. |
| scope | string[] | Yes | Actions the agent is authorized to perform. 1–20 items, each max 64 characters. Free-form strings that you define (for example claims:approve). Leading/trailing spaces are trimmed and duplicates are removed automatically. At verify time an action must match one item exactly — case-sensitive, no wildcards (see 00b). Covered by ML-DSA signature as scopeOriginal — immutable. |
| budgetTotal | integer ≥ 0 | Yes | Maximum budget units for this mandate (whole number, max 9,007,199,254,740,991). Abstract — you define what a unit means. 0 disables budget checking entirely (cost is still required on every verify call). It cannot be increased later — to give an agent more budget, emit a new mandate. Covered by ML-DSA signature — immutable. |
| expiresInSeconds | integer | Yes | Mandate lifetime in seconds (whole number). Min 60 (1 minute), max 2,592,000 (30 days). A mandate cannot be extended afterwards — emit a new one. |
| agentPublicKey | string | No | Base64 of the agent's raw ML-DSA public key, generated by you with any ML-DSA library (it has nothing to do with the CA). Any of the three variants is accepted, whatever algorithm your project uses — the decoded key must be exactly 1312 (ML-DSA-44), 1952 (ML-DSA-65) or 2592 (ML-DSA-87) bytes, otherwise the request is rejected with 400 before anything is charged. Max 4096 characters. Stored once: it cannot be changed later (to change the agent's key, emit a new mandate) and is never returned by the API — GET only shows requiresAgentSignature. If provided, POST /mandate/verify requires the agent to sign each call with the matching private key (proof of possession) — see sections 02 and 02b. If omitted, the mandate is a plain bearer token. |
| correlationId | string | No | Your own id for this call (a ticket, a request id): 1–128 characters, no control characters. It is written inside the audit entry of the emission, so the receipt covers it, and you can find the entry with it (15). Not part of the token. |
POST /sign. If lost, you must emit a new mandate. The mandate id is stored server-side and is what you use for PATCH and GET operations.
receipt too. It is FIPSign's signature over the first entry of the audit log of the mandate: what was authorized (scope, budget, expiry), by which API key, and when. FIPSign does not keep receipts and cannot issue it again, so save it next to the mandate id, as the command above does (emit-receipt.json). How to check it, and what it proves: 12.
agentPublicKey is the plain, raw ML-DSA public key of a keypair that you generate — it is not tied to the CA (POST /ca/issue) and needs no certificate. Any of the three variants works (ML-DSA-44, -65 or -87), whatever algorithm your project uses; the size of the decoded key tells them apart: 1312, 1952 or 2592 bytes. Use any FIPS 204 ML-DSA library — for example @noble/post-quantum in JavaScript or cryptography>=48.0.0 in Python (both are used in the copy-paste examples of 02b). Keep the private key on the agent and never send it anywhere: only the public key goes into this request.
The agent calls this before executing any authorized action. Verification runs in two phases: first the ML-DSA signature (cryptographic integrity), then the live mutable state (current scope, budget, status). Only if both pass is the action granted. Token cost: see Costs. It is charged only on granted responses; denied calls are free.
invalid_signature, mandate_expired, mandate_revoked, mandate_suspended, scope_not_authorized, budget_exhausted and all four agent_signature_* reasons — consumes neither platform tokens nor mandate budget. A denial is HTTP 403 with a JSON body, not a transport error: read result and reason from the body (some HTTP clients throw on any non-2xx status — see 02c).
curl -s -X POST https://api.fipsign.dev/mandate/verify \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d "{\"token\": $MANDATE_TOKEN, \"action\": \"sign\", \"cost\": 5}"
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, \"receipt\": true, \"correlationId\": \"req-77\"}"
| Field | Type | Required | Description |
|---|---|---|---|
| token | object | Yes | The full PQToken object returned by POST /mandate — all four fields: payload, signature, algorithm, issuedAt. |
| action | string | Yes | The action the agent wants to perform. Must match one item of scopeCurrent exactly (case-sensitive); leading/trailing spaces are trimmed. Max 64 characters. |
| cost | integer ≥ 0 | Yes | Budget units to add to budgetConsumed if the call is granted (whole number, max 9,007,199,254,740,991). Required on every call — pass 0 for actions with no budget impact. When budgetTotal is 0 it is still required and still added to budgetConsumed; only the limit check is skipped, so consumption stays visible. If the mandate uses proof of possession, this value must equal the cost signed inside agentSignature. Not to be confused with the platform tokens FIPSign charges you (see 06). |
| agentSignature | object | Only if the mandate has agentPublicKey | Proof-of-possession signature: an object with payload, signature and algorithm (plus an optional issuedAt), signed with the agent's private key — not the project's. algorithm is the variant of the agent's key, written exactly "ML-DSA-44", "ML-DSA-65" or "ML-DSA-87". Required when the mandate was emitted with agentPublicKey; ignored for mandates without it. Full contract and code in 02b. |
| receipt | boolean | No | true asks FIPSign for its signed receipt of the audit entry this call writes: the response then has a receipt field, on a granted answer and on every denial that is recorded (12). Anything that is not a boolean is rejected with 400. Free. |
| correlationId | string | No | Your own id for this call: 1–128 characters, no control characters. It is written inside the audit entry (15), so it also lets you find out whether a call whose answer was lost was applied. |
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.
reason above except invalid_signature) is recorded as a verify_denied entry, and with "receipt": true the 403 body has a receipt field next to reason. invalid_signature is not recorded, because a token that does not verify cannot be tied to any mandate; neither is a call to a mandate that no longer exists. Denials stay free.
invalid_signature — or mandate_expired when the token is genuine but past its expiry. 2) If the mandate requires agentSignature: is it present, does it verify, is its lifetime valid, does it match this mandate, action and cost (agent_signature_required / _invalid / _mismatch). 3) The live state, in this order: mandate_revoked → mandate_suspended → mandate_expired → agent_signature_replayed → scope_not_authorized → budget_exhausted. Only when everything passes is the budget charged and the action granted; a denial changes nothing.agentSignature answers agent_signature_required, not mandate_revoked. Also, if a mandate is both suspended and out of budget you get mandate_suspended: status is always checked before budget.
POST /mandate/verify reports cost and the server trusts it. That is exactly right when the caller is your own executor, which knows the real cost of the action (recommended — see 00b). If the agent calls this endpoint directly (for example via LLM function-calling with no layer in between), a compromised or misled agent can under-report the cost — even send 0 — and budgetConsumed will never reflect real usage. Proof of possession binds the cost to the agent's signature, so nobody can change it in transit, but it does not make it truthful. If you can't fully trust the caller, put a layer between the agent and this endpoint that computes cost independently, or treat the budget as advisory rather than a hard guarantee.
Turn a mandate from a bearer token into one that only a specific agent can use. This section is the full recipe: the exact signing contract, copy-paste code for Node.js and Python, and what every error means.
generateAgentKeyPair() / generate_agent_key_pair() make the key pair and signAgentCall() / sign_agent_call() build the signature in a few lines. See SDK 14 — Proof of possession with the SDK (JS) or PY 13 — Proof of possession with the SDK (Python). Keep reading if you call the REST API directly or want to know what happens under the hood.algorithm of the key). The JS SDK's secretKey is the full expanded key (2560, 4032 or 4896 bytes) and rejects a seed with INVALID_SECRET_KEY: generate and sign with the same tool.
agentPublicKey. It is stored once and can never be changed.{sub, action, cost, iat, exp} — with its private key.action, cost and that signature as agentSignature. FIPSign runs all the usual checks and, in addition, verifies the signature against the stored public key. Each signature works once.
Any FIPS 204 ML-DSA library works, and any of the three variants: the project's algorithm does not matter, and the agent's key is unrelated to the CA. The examples use ML-DSA-44 (smallest and fastest). Whichever variant you pick, you will write its name in the algorithm field of every signature (Step 3). This table is the complete list: the text in the second column is what you type, character for character.
| Variant of the agent's key | Text for algorithm | Node.js (@noble/post-quantum) | Python (cryptography) | Public key | Signature |
|---|---|---|---|---|---|
| ML-DSA-44 | "ML-DSA-44" | ml_dsa44 | MLDSA44PrivateKey | 1312 bytes | 2420 bytes |
| ML-DSA-65 | "ML-DSA-65" | ml_dsa65 | MLDSA65PrivateKey | 1952 bytes | 3309 bytes |
| ML-DSA-87 | "ML-DSA-87" | ml_dsa87 | MLDSA87PrivateKey | 2592 bytes | 4627 bytes |
"ml-dsa-44", "ML-DSA44", "ml_dsa44", "ML_DSA_44" or "ML-DSA-44 " (with a trailing space) are all refused. The answer is agent_signature_invalid — the same as for a bad signature — so a typo here is easy to miss. Do not take the text from the name of a class or package (ml_dsa44, MLDSA44PrivateKey): those are names of code, not values of algorithm.algorithm must come from the same row of this table. To use another variant, change both, in Step 1 and in Step 3. If you ever lose track of the variant of a key, the size of its public key tells: 1312 bytes is ML-DSA-44, 1952 is ML-DSA-65, 2592 is ML-DSA-87.
// npm install @noble/post-quantum (Node 20+) import { ml_dsa44 } from '@noble/post-quantum/ml-dsa.js' import { randomBytes } from 'node:crypto' // The 32-byte seed IS the private key. It is the only secret you keep. const seed = randomBytes(32) // ml_dsa44 is the "ML-DSA-44" row of the table above const { publicKey } = ml_dsa44.keygen(seed) // Goes into "agentPublicKey" when you emit the mandate const agentPublicKey = Buffer.from(publicKey).toString('base64') // Save this on the agent side (secret store, or a file only the agent can read), never share it. // Step 3 uses it under the same name: storedSeed const storedSeed = Buffer.from(seed).toString('base64')
# pip install "cryptography>=48.0.0" import base64 from cryptography.hazmat.primitives.asymmetric.mldsa import MLDSA44PrivateKey # MLDSA44PrivateKey is the "ML-DSA-44" row of the table above agent_key = MLDSA44PrivateKey.generate() # The 32-byte seed IS the private key. It is the only secret you keep. seed = agent_key.private_bytes_raw() # Goes into "agentPublicKey" when you emit the mandate public_raw = agent_key.public_key().public_bytes_raw() agent_public_key = base64.b64encode(public_raw).decode() # Save this on the agent side (secret store, or a file only the agent can read), never share it. # Step 3 uses it under the same name: stored_seed stored_seed = base64.b64encode(seed).decode()
chmod 600). Only the public key travels to your backend.cryptography needs version 48.0.0 or newer (the same requirement as the FIPSign Python SDK). A cryptography built against an old system OpenSSL may raise UnsupportedAlgorithm; the regular pip wheel works.
curl -s -X POST https://api.fipsign.dev/mandate \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d "{\"agentId\":\"agent-42\",\"issuedBy\":\"dev@example.com\",\"scope\":[\"sign\"],\"budgetTotal\":100,\"expiresInSeconds\":3600,\"agentPublicKey\":\"$AGENT_PUBLIC_KEY\"}"
The response is the usual one, plus "requiresAgentSignature": true. A wrong-sized key is rejected with 400 at this point — nothing is charged — so a mandate that could never be verified cannot be created by mistake.
The signature is a token with the same shape as any FIPSign token: payload (base64 of a JSON), signature, algorithm. This is the whole contract:
| Item | Rule |
|---|---|
| What is signed | The text of the base64 payload string, encoded as UTF-8 bytes — not the JSON itself and not the decoded bytes. This is the most common mistake (see the table below). |
| Signature scheme | Plain ML-DSA (FIPS 204): empty context string, no pre-hash (not HashML-DSA). Both libraries above do this by default. |
payload | base64 (standard alphabet, with padding) of the UTF-8 JSON {"sub", "action", "cost", "iat", "exp"}. At most 16,384 characters. |
sub | Exactly the mandate id. |
action | Exactly the action you send in the same request (without leading/trailing spaces). |
cost | Exactly the cost you send in the same request, as a number. A payload without cost never matches. |
iat, exp | Unix seconds, both mandatory. exp must be after iat; exp − iat may not exceed 60 seconds; iat may not be more than 30 seconds ahead of the server clock; and exp must not have passed. The examples use iat = now, exp = now + 30. |
algorithm | "ML-DSA-44", "ML-DSA-65" or "ML-DSA-87" — exactly one of these three texts (see the table in Step 1), the one that matches the agent's key. Write it in every signature: FIPSign uses this text to pick the verification algorithm, and a raw ML-DSA key carries no label of its own. Any other text ("ml-dsa-44", "ML-DSA44"…) or the wrong variant gives agent_signature_invalid; a missing one, HTTP 400. (The SDKs handle it: the JS SDK works it out from the key, the Python SDK takes it as algorithm=.) |
signature | base64 of the raw signature: 2420, 3309 or 4627 bytes depending on the variant. |
issuedAt | Optional and ignored. Included in the examples only because it is part of the usual token shape. |
| Extra payload fields | Ignored, but covered by the signature. Add a random nonce if your signer is deterministic (see the note below). |
| Lifetime and reuse | Each signature is accepted once. It is only recorded when the call is granted, so a denied call (for example budget_exhausted) does not burn it — but do not rely on that: sign again for every attempt. |
import { ml_dsa44 } from '@noble/post-quantum/ml-dsa.js' // Exactly this text, in every signature. Same row of the table as ml_dsa44 (Step 1). const ALGORITHM = 'ML-DSA-44' // storedSeed: the base64 seed that Step 1 saved function signCall(storedSeed, mandateId, action, cost) { // Re-derive the signing key from the seed const { secretKey } = ml_dsa44.keygen(Buffer.from(storedSeed, 'base64')) const now = Math.floor(Date.now() / 1000) const payload = { sub: mandateId, action, cost, iat: now, exp: now + 30 } const payloadB64 = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64') // Sign the base64 TEXT (UTF-8 bytes), not the JSON const message = new TextEncoder().encode(payloadB64) const signature = ml_dsa44.sign(message, secretKey) return { payload: payloadB64, signature: Buffer.from(signature).toString('base64'), algorithm: ALGORITHM, // exactly the text from the table in Step 1 issuedAt: now, // optional } }
import base64 import json import time from cryptography.hazmat.primitives.asymmetric.mldsa import MLDSA44PrivateKey # Exactly this text, in every signature. Same row of the table as MLDSA44PrivateKey (Step 1). ALGORITHM = "ML-DSA-44" def sign_call(stored_seed: str, mandate_id: str, action: str, cost: int) -> dict: # stored_seed: the base64 seed that Step 1 saved. Re-derive the signing key from it. agent_key = MLDSA44PrivateKey.from_seed_bytes(base64.b64decode(stored_seed)) now = int(time.time()) payload = { "sub": mandate_id, "action": action, "cost": cost, "iat": now, "exp": now + 30, } payload_b64 = base64.b64encode(json.dumps(payload).encode("utf-8")).decode() # Sign the base64 TEXT, not the JSON signature = agent_key.sign(payload_b64.encode("ascii")) return { "payload": payload_b64, "signature": base64.b64encode(signature).decode(), "algorithm": ALGORITHM, # exactly the text from the table in Step 1 "issuedAt": now, # optional }
{
"token": { "payload": "eyJ...", "signature": "...", "algorithm": "ML-DSA-65", "issuedAt": 1785690939 },
"action": "sign",
"cost": 1,
"agentSignature": {
"payload": "eyJzdWIiOiJtZHRfLi4uIn0=",
"signature": "k3Jd8...",
"algorithm": "ML-DSA-44",
"issuedAt": 1785690940
}
}
token is the mandate token exactly as returned by POST /mandate (an object, not a string) and agentSignature is the object built in step 3. Send it to POST /mandate/verify as in section 02.
Two algorithm fields, two different things. token.algorithm belongs to the mandate token: send the token exactly as POST /mandate returned it and never edit it (it is the variant of your project; "ML-DSA-65" in the example). agentSignature.algorithm is the one you write in Step 3: the variant of the agent's key ("ML-DSA-44" in the example). They may differ, and that is fine.
| Response | Typical cause | Fix |
|---|---|---|
agent_signature_required | agentSignature missing, or not an object. | Sign the call and send it. Mandates emitted with agentPublicKey always need it. |
agent_signature_invalid | Signed with the wrong private key; algorithm is not exactly one of "ML-DSA-44", "ML-DSA-65", "ML-DSA-87" (for example "ml-dsa-44") or is not the key's variant; signed the JSON or the decoded bytes instead of the base64 text; payload or signature altered after signing; iat/exp missing or out of range; the signature already expired. | Check the contract table above, then the clock: the agent's clock must be close to real time (see below). |
agent_signature_mismatch | The signature is valid but its sub, action or cost differ from the request — including a payload with no cost, or an action sent with different spelling. | Sign exactly the values you send. Change one and you must sign again. |
agent_signature_replayed | This exact signature was already accepted. | Never cache or reuse signatures: sign again for every call. |
HTTP 400 Invalid "agentSignature" format… | The object lacks payload, signature or algorithm. | Send all three (and issuedAt for completeness). |
exp = now + 60: it tolerates an agent clock up to 60 seconds behind the server; the limit for a clock ahead stays at 30 seconds.agent_signature_replayed. Add a random nonce field to the payload to avoid it.action with accents or other non-ASCII characters then works like any other.Emits a mandate for a fresh keypair and then runs the four interesting cases against the live API. Save as pop-demo.mjs, run npm install @noble/post-quantum, then FIPSIGN_API_KEY=pqa_... node pop-demo.mjs (Node 20+).
// pop-demo.mjs — proof of possession, end to end. // Setup: npm install @noble/post-quantum (Node 20+) // Run: FIPSIGN_API_KEY=pqa_... node pop-demo.mjs import { ml_dsa44 } from '@noble/post-quantum/ml-dsa.js' import { randomBytes } from 'node:crypto' const API = 'https://api.fipsign.dev' const b64 = (bytes) => Buffer.from(bytes).toString('base64') // A denied verify is HTTP 403 with a JSON body: // return status + body instead of throwing. async function call(method, path, body) { const res = await fetch(API + path, { method, headers: { 'Content-Type': 'application/json', 'X-API-Key': process.env.FIPSIGN_API_KEY, }, body: body && JSON.stringify(body), }) return { status: res.status, data: await res.json() } } // ── AGENT SIDE ────────────────────────────────────────────────────────── // The agent creates its own keypair. The seed is the private key: keep it secret // (in real life, store b64(seed) in a secret store). const seed = randomBytes(32) const { publicKey, secretKey } = ml_dsa44.keygen(seed) // Sign ONE call: this mandate + this action + this cost. Valid 30 s, usable once. function signCall(mandateId, action, cost) { const now = Math.floor(Date.now() / 1000) const payload = { sub: mandateId, action, cost, iat: now, exp: now + 30 } const payloadB64 = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64') // Sign the base64 TEXT (UTF-8 bytes), not the JSON const message = new TextEncoder().encode(payloadB64) const signature = ml_dsa44.sign(message, secretKey) return { payload: payloadB64, signature: b64(signature), algorithm: 'ML-DSA-44', // exactly this text; it pairs with ml_dsa44 issuedAt: now, } } // ── YOUR BACKEND ──────────────────────────────────────────────────────── // 1. Emit the mandate, registering the agent's PUBLIC key. const emitted = await call('POST', '/mandate', { agentId: 'agent-pop-demo', issuedBy: 'dev@example.com', scope: ['sign'], budgetTotal: 10, expiresInSeconds: 3600, agentPublicKey: b64(publicKey), }) if (!emitted.data.success) { throw new Error('emit failed: ' + JSON.stringify(emitted.data)) } const { id, token, requiresAgentSignature } = emitted.data.mandate console.log('emitted', id, '· requiresAgentSignature:', requiresAgentSignature) // 2. Without agentSignature the mandate token alone is useless. let r = await call('POST', '/mandate/verify', { token, action: 'sign', cost: 1 }) console.log('no signature →', r.status, r.data.result, r.data.reason) // 3. With the agent's signature over THIS call: granted. const agentSignature = signCall(id, 'sign', 1) const body = { token, action: 'sign', cost: 1, agentSignature } r = await call('POST', '/mandate/verify', body) console.log('with signature →', r.status, r.data.result, '· budgetRemaining:', r.data.budgetRemaining) // 4. Sending the same signature again is refused: each signature works once. r = await call('POST', '/mandate/verify', body) console.log('same signature →', r.status, r.data.result, r.data.reason) // 5. A signature made for cost 1 cannot be used for cost 9. const bad = { token, action: 'sign', cost: 9, agentSignature: signCall(id, 'sign', 1) } r = await call('POST', '/mandate/verify', bad) console.log('cost changed →', r.status, r.data.result, r.data.reason)
The same flow. Save as pop_demo.py, run pip install "cryptography>=48.0.0" requests, then FIPSIGN_API_KEY=pqa_... python pop_demo.py.
# pop_demo.py — proof of possession, end to end. # Setup: pip install "cryptography>=48.0.0" requests # Run: FIPSIGN_API_KEY=pqa_... python pop_demo.py import base64 import json import os import time import requests from cryptography.hazmat.primitives.asymmetric.mldsa import MLDSA44PrivateKey API = "https://api.fipsign.dev" HEADERS = {"X-API-Key": os.environ["FIPSIGN_API_KEY"]} def b64(data: bytes) -> str: return base64.b64encode(data).decode() def call(method, path, body=None): # A denied verify is HTTP 403 with a JSON body: # return status + body instead of raising. r = requests.request(method, API + path, headers=HEADERS, json=body, timeout=30) return r.status_code, r.json() # ── AGENT SIDE ────────────────────────────────────────────────────────── # The agent creates its own keypair. The seed is the private key: keep it secret # (in real life, store b64(agent_key.private_bytes_raw()) in a secret store). agent_key = MLDSA44PrivateKey.generate() agent_public_key = b64(agent_key.public_key().public_bytes_raw()) def sign_call(mandate_id: str, action: str, cost: int) -> dict: """Sign ONE call: mandate + action + cost. Valid 30 s, usable once.""" now = int(time.time()) payload = { "sub": mandate_id, "action": action, "cost": cost, "iat": now, "exp": now + 30, } payload_b64 = b64(json.dumps(payload).encode("utf-8")) # Sign the base64 TEXT, not the JSON signature = agent_key.sign(payload_b64.encode("ascii")) return { "payload": payload_b64, "signature": b64(signature), "algorithm": "ML-DSA-44", # exactly this text; it pairs with MLDSA44PrivateKey "issuedAt": now, } # ── YOUR BACKEND ──────────────────────────────────────────────────────── # 1. Emit the mandate, registering the agent's PUBLIC key. status, data = call("POST", "/mandate", { "agentId": "agent-pop-demo", "issuedBy": "dev@example.com", "scope": ["sign"], "budgetTotal": 10, "expiresInSeconds": 3600, "agentPublicKey": agent_public_key, }) if not data.get("success"): raise SystemExit(f"emit failed: {data}") mandate = data["mandate"] mandate_id, token = mandate["id"], mandate["token"] print("emitted", mandate_id, "· requiresAgentSignature:", mandate["requiresAgentSignature"]) # 2. Without agentSignature the mandate token alone is useless. body = {"token": token, "action": "sign", "cost": 1} status, r = call("POST", "/mandate/verify", body) print("no signature →", status, r["result"], r.get("reason")) # 3. With the agent's signature over THIS call: granted. body["agentSignature"] = sign_call(mandate_id, "sign", 1) status, r = call("POST", "/mandate/verify", body) print("with signature →", status, r["result"], "· budgetRemaining:", r.get("budgetRemaining")) # 4. Sending the same signature again is refused: each signature works once. status, r = call("POST", "/mandate/verify", body) print("same signature →", status, r["result"], r.get("reason")) # 5. A signature made for cost 1 cannot be used for cost 9. body = {"token": token, "action": "sign", "cost": 9, "agentSignature": sign_call(mandate_id, "sign", 1)} status, r = call("POST", "/mandate/verify", body) print("cost changed →", status, r["result"], r.get("reason"))
True in Python and true in Node)emitted mdt_… · requiresAgentSignature: true no signature → 403 denied agent_signature_required with signature → 200 granted · budgetRemaining: 9 same signature → 403 denied agent_signature_replayed cost changed → 403 denied agent_signature_mismatch
What to do with every answer verify can give, how to retry safely, and the mistakes that cost integrators the most time.
| HTTP | Meaning | What your code should do |
|---|---|---|
| 200 | result: "granted". The budget was charged. | Perform the action. |
| 403 | result: "denied" with a reason. A normal answer, not a failure of the call: it is free and changes nothing. | Do not perform the action. Decide what to do from the reason (next table). Some HTTP clients throw on any non-2xx status: catch that and still read the JSON body. |
| 400 / 415 | The request itself is malformed (error says what). | A bug in your code — fix it. Retrying the same request will fail the same way. Nothing was charged. |
| 401 | API key missing or invalid, or an agent key used somewhere other than verify. | Fix the key. Nothing was charged. |
| 429 | Rate limit exceeded… ("code": "rate_limited") or Token limit reached… ("code": "token_quota_exhausted"). In both cases the mandate budget was not consumed. | Rate limit: wait the seconds in the Retry-After header and retry. Token limit: your monthly platform tokens are exhausted — buy a pack from the dashboard (Dashboard 09). |
| 5xx / timeout / network error | Unknown outcome. | See "Retries" below. |
result === "granted". Everything else — a denial, a timeout, a 5xx, a body you cannot parse — means "do not act". This helper does exactly that.
// ok: true only when FIPSign explicitly granted the action. // Anything else means "do not act". async function authorize({ token, action, cost, agentSignature }) { const headers = { 'Content-Type': 'application/json', 'X-API-Key': process.env.FIPSIGN_API_KEY, } const body = { token, action, cost, ...(agentSignature && { agentSignature }) } let res try { res = await fetch('https://api.fipsign.dev/mandate/verify', { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(10_000), }) } catch { // Timeout or network failure: the call may or may not have been applied // (see "Retries and idempotency" below) return { ok: false, reason: 'network_error', unknownOutcome: true } } // A 403 carries a JSON body too: it is a normal answer const data = await res.json().catch(() => null) if (res.status === 200 && data?.result === 'granted') { return { ok: true, budgetRemaining: data.budgetRemaining } } return { ok: false, status: res.status, reason: data?.reason ?? data?.error ?? 'unknown', unknownOutcome: res.status >= 500, } }
import os import requests API = "https://api.fipsign.dev" def authorize(token, action, cost, agent_signature=None): """ok=True only when FIPSign explicitly granted the action. Anything else means "do not act".""" body = {"token": token, "action": action, "cost": cost} if agent_signature: body["agentSignature"] = agent_signature headers = {"X-API-Key": os.environ["FIPSIGN_API_KEY"]} try: r = requests.post(f"{API}/mandate/verify", json=body, headers=headers, timeout=10) except requests.RequestException: # Timeout or network failure: the call may or may not have been applied # (see "Retries and idempotency" below) return {"ok": False, "reason": "network_error", "unknown_outcome": True} try: data = r.json() # a 403 carries a JSON body too: it is a normal answer except ValueError: data = {} if r.status_code == 200 and data.get("result") == "granted": return {"ok": True, "budget_remaining": data.get("budgetRemaining")} return { "ok": False, "status": r.status_code, "reason": data.get("reason") or data.get("error") or "unknown", "unknown_outcome": r.status_code >= 500, }
reason | Meaning | Typical response |
|---|---|---|
invalid_signature | The token was altered, is not a mandate token (for example one from POST /sign), belongs to another project, or was signed by a project key that is no longer accepted. | Reject and log it. Retrying will not help: it is either a bug or an attack. |
mandate_expired | The mandate is past its expiry (or was deleted 24 hours after it). | Stop. Emit a new mandate if the agent should continue. |
mandate_revoked | Permanently revoked. | Stop the agent for good. Never retry. |
mandate_suspended | Paused with PATCH … suspend. | Pause the work and retry later, with a delay — not in a tight loop. It works again after resume. |
scope_not_authorized | The action is not in scopeCurrent. The response includes authorizedScope. | Refuse the action. You may tell the agent what it is allowed to do. |
budget_exhausted | budgetConsumed + cost would exceed budgetTotal. The response includes budgetConsumedUnits and budgetTotalUnits. | Stop, or ask for a new mandate. A cheaper action (smaller cost) may still fit. |
agent_signature_required / _invalid / _mismatch / _replayed | Proof-of-possession failures. | A problem in the signing code — see 02b. |
failure: 'rejected' (or another value that means nothing was consumed), and a lost answer with failure: 'outcome_unknown' — see JS SDK and Python SDK.agentSignature, while the signature is still valid (its exp, at most 60 seconds):granted → the first attempt had not been applied; it is now, exactly once.agent_signature_replayed → the first attempt was applied and its cost is already counted. Perform the action and do not retry again.GET /mandate/:id as described next.cost a second time if the first was applied. When the outcome is unknown, read GET /mandate/:id and compare budgetConsumed with the value you had before the call. For actions where a double charge is unacceptable, use proof of possession.correlationId, the audit log of the mandate says whether that attempt was applied, without guessing from the budget: see 15.429 Token limit reached restores the mandate budget (and, with proof of possession, releases the signature so you can send it again once you have tokens). 400, 401, 403 and 415 never consume anything.
usage.totalRemaining. Alert on it: when it reaches zero, verify calls start answering 429 and no agent can act, even though its mandate is perfectly valid.
expiresAt to decide whether a mandate is still valid — ask verify, or read expiresInSeconds from a granted response or from GET /mandate/:id.| What you see | Usual cause | Fix |
|---|---|---|
400 "token" is required | The token was sent as a string: wrapped in quotes in a shell body ("token": "$MANDATE_TOKEN"), or serialized twice (JSON.stringify(token) inside another JSON.stringify). | Send the token as a JSON object, exactly as returned by POST /mandate. |
400 "cost" must be a non-negative integer | cost is missing, a string ("5"), a decimal (1.5) or negative. | Always send a whole number ≥ 0 — 0 for actions with no budget impact. |
400 "cost" must not exceed 9007199254740991 | cost is larger than 9,007,199,254,740,991 (253 − 1). | Send a smaller whole number. |
403 invalid_signature with a token you just created | It came from POST /sign (or another project) — only tokens from POST /mandate are accepted here. | Use the token returned by POST /mandate. |
401 This is a Mandate token… on /verify, 400 on /revoke | The generic token endpoints refuse Mandate tokens on purpose. | Use POST /mandate/verify and PATCH /mandate/:id. |
401 API key required or invalid although the key is right | It is an agent key (restricted scope) used on emit, PATCH or GET. | Use a full-access key on your backend; agent keys only work on POST /mandate/verify. |
scope_not_authorized for an action that "is in the scope" | Different case or spelling, or a wildcard (claims:*) that is not supported. | Send the exact string, and read authorizedScope in the response. |
budgetRemaining is 0 but calls are granted | budgetTotal is 0 (no limit). | Use budgetConsumed to see usage. |
budgetConsumed is higher than you expected | Retried calls that had already been applied, or a cost that does not reflect the real cost. | See "Retries" above; compute cost in your executor. |
agent_signature_invalid although the code looks right | Signed the JSON instead of the base64 text; wrong algorithm (it must be exactly "ML-DSA-44", "ML-DSA-65" or "ML-DSA-87", and the one of the agent's key); clock drift. | See 02b. |
400 "agentPublicKey" has an invalid size | You sent a private key, a PEM/DER-wrapped key, hex instead of base64, or a truncated value. | Send the base64 of the raw public key: 1312, 1952 or 2592 bytes. |
Modify the mutable state of a mandate. Token cost: see Costs. 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. Every change returns a signed receipt, and the body accepts an optional correlationId (12, 15).
curl -s -X PATCH https://api.fipsign.dev/mandate/$MANDATE_ID \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{"action": "narrow", "scope": ["verify"]}'
scopeCurrent permanently within the mandate's lifetime. The new scope must be a subset of the current scopeCurrent — not just scopeOriginal. Narrowing is monotonic: each call can only shrink further from wherever the scope stands now, so you cannot recover an action a previous narrow already removed, even if it's still within the originally signed scope. You can narrow multiple times, also while the mandate is suspended; narrowing to exactly the current scope is accepted and changes nothing (it is still written to the audit log, and returns a receipt). Items are trimmed like at emission. Use this when an agent's privileges should be reduced in response to a behavioral change or security concern.
curl -s -X PATCH https://api.fipsign.dev/mandate/$MANDATE_ID \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{"action": "suspend"}'
POST /mandate/verify will return mandate_suspended for any action. Does not affect scope or budget. Use when investigating an agent without permanently revoking it. Calling suspend on an already-suspended mandate succeeds with a shorter body — {"success": true, "id": "...", "status": "suspended", "message": "Already suspended"}, without scope, updatedAt or receipt: nothing changed, so nothing is recorded.
curl -s -X PATCH https://api.fipsign.dev/mandate/$MANDATE_ID \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{"action": "resume"}'
suspended mandates — calling resume on an active mandate returns HTTP 409 (Only suspended mandates can be resumed); on a revoked or expired mandate it also returns 409. resume does not undo a narrow — scope stays narrowed after resume.
curl -s -X PATCH https://api.fipsign.dev/mandate/$MANDATE_ID \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{"action": "revoke"}'
POST /mandate/verify will always return mandate_revoked. No further PATCH operations are allowed on a revoked mandate. Use this when an agent is compromised or decommissioned. Note: revoke is the only PATCH action allowed on an expired mandate. Revoking an already-revoked mandate returns 409 (Cannot modify a revoked mandate) — if you retry a revoke after a network error, treat that 409 as success.
receipt is FIPSign's signature over the audit entry this change wrote (here, the narrowed entry), so you can show later what was changed and when. It is in every successful change except the "Already suspended" answer. Send "correlationId": "ticket-4822" (1–128 characters) in the body to tag the change with your own id: it is written inside the entry, and the receipt covers it. How to keep and check receipts: 12.
| action | Effect | Reversible? | Works on expired? | Body required |
|---|---|---|---|---|
| narrow | Reduces scopeCurrent to a subset of its current value (not just scopeOriginal) | No — scope can only shrink further | No | Yes — "scope": [...] |
| suspend | Sets status: suspended — blocks all verify calls | Yes — via resume | No | No |
| resume | Sets status: active — restores verify access | Yes — via suspend | No | No |
| revoke | Sets status: revoked — permanently blocks all verify calls | No | Yes | No |
Returns the current state of a mandate. Use this to inspect budget consumption, current scope, status, or remaining time. Token cost: see Costs.
curl -s https://api.fipsign.dev/mandate/$MANDATE_ID \
-H "X-API-Key: $API_KEY" | jq
cost to budgetConsumed); a denied verify does not change itscopeOriginal vs scopeCurrent — scopeOriginal is what was signed and is immutable. scopeCurrent is the live scope after any narrows. After a narrow, scopeOriginal still shows the full original scope — useful for auditing what was originally granted vs what is currently enforced.
expired status: a mandate that has passed its expiry still reports the last status it had (usually active) with expiresInSeconds: 0 — check that field (or expiresAt) to know whether it can still be used. Verify calls answer mandate_expired and PATCH answers 409 (except revoke). You can still read the mandate for 24 hours after expiry; after that GET returns 404 Mandate not found and it disappears from the list. Its audit log stays readable for 365 days (13).
Returns the mandates of the project associated with the API key, most recent first (issuedAt descending), one page at a time. Token cost: see Costs. It is meant for inspection and auditing: keep the id of every mandate you emit in your own database rather than listing to find it.
curl -s "https://api.fipsign.dev/mandate?limit=50" \ -H "X-API-Key: $API_KEY" | jq
| Query parameter | Type | Required | Description |
|---|---|---|---|
| limit | integer | No | Page size, 1–100. Default 50. A value that is not a whole number in that range is rejected with 400. |
| cursor | string | No | Opaque, URL-safe value copied as is from nextCursor of the previous page. Omit it to get the first page. A value that was modified, truncated or is empty is rejected with 400. |
CURSOR="" while :; do PAGE=$(curl -s "https://api.fipsign.dev/mandate?limit=100${CURSOR:+&cursor=$CURSOR}" \ -H "X-API-Key: $API_KEY") echo "$PAGE" | jq -c '.mandates[] | {id, status, budgetConsumed}' CURSOR=$(echo "$PAGE" | jq -r '.nextCursor // empty') [ -z "$CURSOR" ] && break done
count is the size of the current page, not the total number of mandates in the project. A page can hold fewer than limit mandates (ones deleted after their retention period are skipped), so keep following nextCursor until it is null — do not stop at the first short page.
| Field | Limit |
|---|---|
| agentId | Max 128 characters |
| issuedBy | Max 256 characters |
| scope items | 1–20 items per mandate |
| scope item length | Max 64 characters per item |
| action (verify) | Max 64 characters |
| budgetTotal | Non-negative whole number, max 9,007,199,254,740,991 (253 − 1). 0 disables budget enforcement. |
| expiresInSeconds | Min 60 (1 min) — Max 2,592,000 (30 days) |
| agentPublicKey | Valid base64 that decodes to exactly 1312 (ML-DSA-44), 1952 (ML-DSA-65) or 2592 (ML-DSA-87) bytes; max 4096 characters |
| cost (verify) | Required, non-negative whole number, max 9,007,199,254,740,991 (253 − 1) |
| agentSignature lifetime | exp − iat at most 60 seconds; iat at most 30 seconds in the future; single use |
| GET /mandate page size | limit 1–100, default 50 |
| Retention after expiry | 24 hours (then GET returns 404 and the mandate leaves the list) |
| correlationId | 1–128 characters, no control characters (on emit, verify and PATCH) |
| Audit log: entries per mandate | 100,000 (the last one is a log_limit_reached marker) |
| Audit log: retention | Each entry is kept 365 days from its own at, then deleted |
| Audit log: checkpoints | At most one per hour per mandate, and one as the last entry before the mandate is cleaned up |
| GET /mandate/:id/events page size | limit 1–500, default 100 |
| GET /mandate/events page size and period | limit 1–200, default 50. Without mandateId, correlationId or traceId: the last 7 days unless from is given, and at most 90 days from from to to |
| GET /mandate/:id/export page size | limit 1–1000, default 500 |
| GET /public-keys | The 200 most recent public keys of the project |
| Endpoint | Limit | Window | Scope |
|---|---|---|---|
| POST /mandate | 300 requests | 1 minute | Per API key |
| POST /mandate/verify | 300 requests | 1 minute | Per API key |
| PATCH /mandate/:id | 60 requests | 1 minute | Per API key |
| GET /mandate/:id | 300 requests | 1 minute | Per API key, one counter for all the reads below |
| GET /mandate | 300 requests | 1 minute | Per API key, same counter |
| GET /mandate/:id/events | 300 requests | 1 minute | Per API key, same counter |
| GET /mandate/events | 300 requests | 1 minute | Per API key, same counter |
| GET /mandate/:id/export | 300 requests | 1 minute | Per API key, same counter |
| GET /public-keys | 300 requests | 1 minute | Per API key, same counter |
GET /mandate/:id, GET /mandate, the two endpoints of the audit log, the export and GET /public-keys — share one counter: together they may make 300 requests per minute. Receipts and the audit log never count against emit, verify or PATCH.
| HTTP | Error / reason | Cause |
|---|---|---|
| 400 | Invalid body — expected JSON | The request body is not valid JSON |
| 400 | Invalid body — expected a JSON object | The body is valid JSON but is null, an array or a plain value |
| 400 | "agentId" is required | Missing or empty agentId |
| 400 | "agentId" must be at most 128 characters | agentId too long |
| 400 | "issuedBy" is required | Missing or empty issuedBy |
| 400 | "issuedBy" must be at most 256 characters | issuedBy too long |
| 400 | "scope" must be a non-empty array | scope missing, empty, or not an array (also returned by narrow) |
| 400 | "scope" must have at most 20 items | More than 20 scope items |
| 400 | "scope" items must be non-empty strings | Scope array contains non-string or empty values |
| 400 | "scope" items must be at most 64 characters | A scope item is longer than 64 characters |
| 400 | "budgetTotal" must be a non-negative integer | budgetTotal missing, negative, a string or not a whole number |
| 400 | "budgetTotal" must not exceed 9007199254740991 | budgetTotal is larger than 9,007,199,254,740,991 (253 − 1) |
| 400 | "expiresInSeconds" must be a finite number | expiresInSeconds missing or not a number (for example sent as a string) |
| 400 | "expiresInSeconds" must be an integer | expiresInSeconds has decimals (for example 600.5) |
| 400 | "expiresInSeconds" must be at least 60 | TTL below minimum |
| 400 | "expiresInSeconds" must not exceed 2592000 (30 days) | TTL above maximum |
| 400 | "agentPublicKey" must be a non-empty string | agentPublicKey sent as empty string, or wrong type |
| 400 | "agentPublicKey" exceeds maximum length of 4096 characters | agentPublicKey too long |
| 400 | "agentPublicKey" must be valid base64 | agentPublicKey is not valid base64 |
| 400 | "agentPublicKey" has an invalid size (N bytes). Expected an ML-DSA public key… | The decoded key is not 1312, 1952 or 2592 bytes — you sent something other than a raw ML-DSA public key (a private key, a PEM/DER wrapper, or a truncated value) |
| 400 | "token" is required | token missing from the verify request, or sent as a string instead of the token object |
| 400 | Invalid token format — missing payload, signature, or algorithm | Incomplete token object passed to verify — send it exactly as returned by POST /mandate |
| 400 | "action" is required | action missing or empty in the verify request |
| 400 | "action" must be at most 64 characters | action longer than 64 characters |
| 400 | "cost" must be a non-negative integer | cost missing, negative, a string or not a whole number in verify |
| 400 | "cost" must not exceed 9007199254740991 | cost is larger than 9,007,199,254,740,991 (253 − 1) in verify |
| 400 | Invalid "agentSignature" format — missing payload, signature, or algorithm | agentSignature is an object but lacks one of payload, signature, algorithm (issuedAt is optional) |
| 400 | "action" is required — narrow | suspend | resume | revoke | action missing from PATCH request |
| 400 | "action" must be narrow, suspend, resume, or revoke | Unknown action in PATCH |
| 400 | "limit" must be an integer between 1 and 100 | limit on GET /mandate is not a whole number, or is outside 1–100 |
| 400 | Invalid "cursor". Use the nextCursor value of the previous page as is. | cursor on GET /mandate or GET /mandate/events is empty, was modified or truncated — always copy nextCursor unchanged |
| 400 | "receipt" must be a boolean | receipt in the verify request is something other than true or false (for example the text "true") |
| 400 | "correlationId" must be a non-empty string | correlationId is empty or not text |
| 400 | "correlationId" must be at most 128 characters | correlationId is longer than 128 characters |
| 400 | "correlationId" must not contain control characters | correlationId has a line break, a tab or another control character |
| 400 | "after" must be a seq number: 0 or more | after on /events or /export is not a whole number written in digits |
| 400 | "limit" must be an integer between 1 and 500 | limit on GET /mandate/:id/events is not a whole number, or is outside 1 to 500 |
| 400 | "limit" must be an integer between 1 and 1000 | limit on GET /mandate/:id/export is not a whole number, or is outside 1 to 1000 |
| 400 | "limit" must be an integer between 1 and 200 | limit on GET /mandate/events is not a whole number, or is outside 1 to 200 |
| 400 | "type" must be one of: emitted, verify_granted, verify_denied, verify_released, narrowed, suspended, resumed, revoked, chain_started, checkpoint, log_limit_reached | The type filter of GET /mandate/events is not one of the entry types of 11 |
| 400 | "from" and "to" must be Unix timestamps in seconds | A period filter of GET /mandate/events is not a whole number |
| 400 | "from" must not be after "to" | The period of GET /mandate/events is reversed |
| 400 | The period (from..to) may be at most 90 days unless the query names a mandateId, a correlationId or a traceId | A search of GET /mandate/events with no mandateId, correlationId or traceId spans more than 90 days |
| 400 | "correlationId" must be a non-empty text of at most 128 characters, without control characters | A text filter of GET /mandate/events is empty, too long or has control characters. The message names the filter and its maximum: mandateId 128, action 64, keyId 64, correlationId 128, traceId 32 |
| 400 | "scope" contains actions not in the current scope: ... | narrow attempted to include actions not in the mandate's current scopeCurrent |
| 400 | This is a Mandate token. Revoke it with PATCH /mandate/:id {"action":"revoke"} | A Mandate token was sent to the generic POST /revoke |
| 401 | API key required or invalid | Missing or malformed X-API-Key header — or a valid agent-scoped key was used on an endpoint other than POST /mandate/verify (see REST 00) |
| 401 | This is a Mandate token. Verify it with POST /mandate/verify | A Mandate token was sent to the generic POST /verify |
| 403 | result: denied, reason: invalid_signature | Token tampered, wrong project, not a mandate token (for example one from /sign), or signed with a previous project key that is no longer accepted (see Mandate 10) |
| 403 | result: denied, reason: agent_signature_required | Mandate has agentPublicKey but "agentSignature" was not sent (or is not an object) |
| 403 | result: denied, reason: agent_signature_invalid | Signature doesn't verify against agentPublicKey (wrong key, an algorithm that is not exactly ML-DSA-44, ML-DSA-65 or ML-DSA-87, or the wrong variant for the key), it already expired, or iat/exp are missing, exp − iat is over 60 s, or iat is more than 30 s in the future |
| 403 | result: denied, reason: agent_signature_mismatch | agentSignature is validly signed, but its "sub", "action" or "cost" don't match this mandateId, action and cost (a signed payload without cost never matches) |
| 403 | result: denied, reason: agent_signature_replayed | This exact agentSignature was already used. Sign again for every call |
| 403 | result: denied, reason: mandate_expired | Mandate TTL elapsed or mandate no longer exists |
| 403 | result: denied, reason: mandate_suspended | Mandate is suspended — resume it to allow actions |
| 403 | result: denied, reason: mandate_revoked | Mandate permanently revoked |
| 403 | result: denied, reason: scope_not_authorized | Requested action not in scopeCurrent (the response lists authorizedScope) |
| 403 | result: denied, reason: budget_exhausted | budgetConsumed + cost would exceed budgetTotal |
| 404 | Mandate not found | mandateId does not exist, belongs to another project, or was deleted 24 hours after expiry. On /events and /export: there is no audit log for that id (the log of a deleted mandate stays for 365 days) |
| 409 | Cannot modify a revoked mandate | PATCH attempted on a revoked mandate (including a second revoke) |
| 409 | Cannot modify an expired mandate | PATCH attempted on expired mandate (except revoke) |
| 409 | Only suspended mandates can be resumed | resume called on an active mandate |
| 415 | Content-Type must be application/json | Request body sent without Content-Type: application/json |
| 429 | Rate limit exceeded. Maximum 300 (or 60) requests per minute per API key. | Too many requests. The body has "code": "rate_limited"; wait the seconds in the Retry-After header and retry |
| 429 | Token limit reached. Free monthly tokens exhausted and no active packs. | Monthly quota exhausted. The body has "code": "token_quota_exhausted". Purchase a pack from the dashboard (Dashboard 09). The mandate budget is not consumed |
| 500 | Internal server error | Unexpected failure on our side. Retrying once is safe for GET and PATCH; if it persists, contact support with the time of the request |
| 503 | Could not read the public keys of the project. Try again. | GET /public-keys or the export could not read the key history. Nothing is lost: retry |
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":"dev@example.com","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\":\"dev@example.com\",\"scope\":[\"sign\"],\"budgetTotal\":10,\"expiresInSeconds\":3600,\"agentPublicKey\":\"$AGENT_PUBLIC_KEY\"}") echo "requiresAgentSignature: $(echo "$PO_MANDATE" | jq -r '.mandate.requiresAgentSignature')" PO_ID=$(echo "$PO_MANDATE" | jq -r '.mandate.id') PO_TOKEN=$(echo "$PO_MANDATE" | jq -c '.mandate.token') echo " → verify WITHOUT agentSignature (expect denied / agent_signature_required)" curl -s -X POST $BASE_URL/mandate/verify \ -H "Content-Type: application/json" -H "X-API-Key: $API_KEY" \ -d "{\"token\": $PO_TOKEN, \"action\": \"sign\", \"cost\": 1}" | jq '{result,reason}' # The agent signs THIS call: mandate id + action + cost, valid for 30 seconds, usable once AGENT_SIGNATURE=$(node --input-type=module -e ' import { ml_dsa44 } from "@noble/post-quantum/ml-dsa.js" import { readFileSync } from "fs" const b64 = (u8) => Buffer.from(u8).toString("base64") const { seed } = JSON.parse(readFileSync("/tmp/agent-keys.json", "utf8")) const { secretKey } = ml_dsa44.keygen(Buffer.from(seed, "base64")) const now = Math.floor(Date.now() / 1000) const payload = { sub: process.argv[1], action: "sign", cost: 1, iat: now, exp: now + 30 } const payloadB64 = Buffer.from(JSON.stringify(payload)).toString("base64") const sig = ml_dsa44.sign(new TextEncoder().encode(payloadB64), secretKey) // algorithm: exactly "ML-DSA-44", the variant of this key (ml_dsa44) console.log(JSON.stringify({ payload: payloadB64, signature: b64(sig), algorithm: "ML-DSA-44", issuedAt: now })) ' "$PO_ID") echo " → verify WITH agentSignature (expect granted)" curl -s -X POST $BASE_URL/mandate/verify \ -H "Content-Type: application/json" -H "X-API-Key: $API_KEY" \ -d "{\"token\": $PO_TOKEN, \"action\": \"sign\", \"cost\": 1, \"agentSignature\": $AGENT_SIGNATURE}" | jq '{result,budgetRemaining}' echo " → verify AGAIN with the same agentSignature (expect denied / agent_signature_replayed)" curl -s -X POST $BASE_URL/mandate/verify \ -H "Content-Type: application/json" -H "X-API-Key: $API_KEY" \ -d "{\"token\": $PO_TOKEN, \"action\": \"sign\", \"cost\": 1, \"agentSignature\": $AGENT_SIGNATURE}" | jq '{result,reason}' rm -f /tmp/agent-keys.json
agentPublicKey/agentSignature (proof of possession, see sections 01–02). Skip it if your mandates use plain bearer tokens.
When project keys are rotated from the dashboard, mandates keep working: the previous key is accepted for mandates for longer than for tokens.
POST /mandate/verify automatically tries the current key first, then the previous keys within those 30 days. Mandates emitted before a rotation keep verifying until they expire, with the budget, scope and status they had. A mandate signed with a key that is no longer accepted is denied with invalid_signature.invalid_signature and you emit new ones. Rotating is never refused for that reason.GET /public-keys, so a receipt you hold can still be checked: this list is not limited to the 30 days of this section. Save the fingerprint of each key — see 14.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.
FIPSign keeps a log of everything that happens to a mandate: who emitted it, every call it granted or denied, every change. There is nothing to switch on. Each entry is written in the same step that makes the decision it records, so a decision and its entry never exist apart. This section describes the log; 12 shows how to keep proof of it, 13 how to read it and 14 how to export it. Reading it, and receipts, cost no tokens: see Costs.
Event type | Written when | Fields in body besides the common ones |
|---|---|---|
emitted | POST /mandate succeeds. Always the first event. | agentId, issuedBy, scope (the original one), budgetTotal, expiresAt, tokenDigest (SHA-256 of the bytes of the token's signature) and, when the mandate has an agent key, agentKeyDigest (SHA-256 of the agentPublicKey text you sent). |
verify_granted | A verify is granted. | action, cost, budgetConsumed (after this call) and, when the mandate has an agent key, agentSigHash (SHA-256 of the bytes of the agent's signature). |
verify_denied | A verify is denied by a check on the mandate: mandate_expired, mandate_revoked, mandate_suspended, scope_not_authorized, budget_exhausted or an agent_signature_* reason. | action, cost, reason; budgetConsumed for budget_exhausted; agentSigHash when the call carried an agent signature. |
verify_released | A verify was granted, but charging your platform tokens then failed (the call answered 429 Token limit reached). The budget is given back, and the entry records that. | ofSeq (the seq of the verify_granted it undoes), cost (what was given back), reason (billing_failed), budgetConsumed (after the release). |
narrowed | PATCH with narrow. | scope (the new scope) and status. |
suspended, resumed, revoked | The matching PATCH. | status. |
checkpoint | FIPSign seals the log: at most once an hour while events are not sealed yet, and once more as the last event before the mandate is cleaned up (24 hours after it expires). | coversSeq, coversHash, projectId, signature, keyFp, alg. See 14. It has no keyId: the call that made it is FIPSign's own. |
chain_started | Only for a mandate emitted before the audit log existed: its first entry is a snapshot of its state at that moment. | agentId, issuedBy, scope, scopeOriginal, budgetTotal, budgetConsumed, expiresAt, status, note. |
log_limit_reached | The mandate reached 100,000 entries. Nothing more is recorded for it; its calls carry on exactly as before. | limit. |
PATCH that changes nothing (suspending a mandate that is already suspended), every read (GET), and a verify made after the mandate was deleted (24 hours after it expired).
Every entry has the same six fields. Here is the first one of a mandate, as 13 returns it:
body is a string, not an object. It is the exact text that was hashed. Parse it to read the event (JSON.parse(e.body) in JavaScript, json.loads(e["body"]) in Python) and never write it out again: the hash only matches the text as it was sent. The keys of that text are sorted and it has no spaces, so its bytes are always the same for the same event.
| Field | Meaning |
|---|---|
v | Version of the format. Always 1. |
mandateId | The mandate this entry belongs to. |
seq | Position of the entry in the log of this mandate, counted from 1. |
type | One of the types above. |
at | When it happened, in Unix seconds, by FIPSign's clock. |
keyId, keyName | The API key that made the call: keyId is the first 16 characters of the SHA-256 of the key (the key itself is never written), keyName the name you gave it. A call made with an agent key shows the agent key. |
correlationId | Your own id for the call, if you sent one (15). |
traceId | The trace id of the traceparent header of the call, if it sent a valid one (15). |
hash is the SHA-256, in lowercase hexadecimal, of the UTF-8 text prevHash + a line break (\n) + body. The first entry of a mandate has prevHash set to 64 zeros. So changing, removing or inserting an entry in the middle changes its hash and breaks every link after it. A chain alone proves little, because anyone able to write to the database could recompute all the hashes: what closes that is the signature of FIPSign, in the receipts (12) and the checkpoints (14).
To pick out what one API key did, filter by its keyId (13). You can work out the keyId of a key you hold:
printf %s "$API_KEY" | sha256sum | cut -c1-16
| Question | Answer |
|---|---|
| When can I read an entry? | At once through the endpoints of one mandate (/mandate/:id/events and /mandate/:id/export). The search across the whole project (/mandate/events) sees it a few seconds later (about 5). |
| How many entries does a mandate hold? | Up to 100,000. The 100,000th is a log_limit_reached marker, and nothing more is recorded for that mandate. |
| How long are they kept? | Each entry is kept 365 days from its own at, then deleted. The log of a mandate stays readable long after the mandate itself has been deleted (24 hours after it expired). |
A receipt is FIPSign's signature over one entry of the log. It comes in the response of the call that wrote the entry. You keep it, and any day later you can show that the entry — and everything the log held before it — was exactly what FIPSign recorded: if the history were rewritten afterwards, by anyone, the receipt would no longer fit.
| Call | Does it return a receipt? |
|---|---|
POST /mandate | Always. It is the signed record of event 1. |
PATCH /mandate/:id — narrow, suspend, resume, revoke | Always, for the entry that change wrote. Not when nothing changed: suspending a mandate that is already suspended answers "message": "Already suspended" with no receipt. |
POST /mandate/verify | Only when you send "receipt": true. Then it comes with a granted answer and with every denial that was recorded (all of them except invalid_signature, and a mandate that is already gone). |
Any GET | No. Reads write nothing. |
receipt field and the call is not turned into a failure. Save the receipt when it is there; the entry is in the log either way (13).
| Field | What it is |
|---|---|
signed | The exact text FIPSign signed: the JSON {"at":…,"hash":…,"kind":"mandate.receipt","mandateId":…,"projectId":…,"seq":…,"v":1}, keys sorted and no spaces. hash is the hash of the entry, so it stands for the whole history up to that entry. |
signature | Base64, ML-DSA (FIPS 204) detached signature, made with the project key over "FIPSIGN-MANDATE-v1\n" followed by signed. The first line keeps a signature of the log from ever being taken for a signature of a token or a certificate. |
algorithm | ML-DSA-44, ML-DSA-65 or ML-DSA-87: the variant of your project. |
keyFingerprint | SHA-256, in lowercase hexadecimal, of the bytes of the public key that signed. It tells you which of your keys to check with. |
event | The entry itself (seq, type, at, prevHash, hash, body), so the receipt is complete on its own. |
export FIPSIGN_KEY_FINGERPRINTS=$(curl -s https://api.fipsign.dev/public-key \ -H "X-API-Key: $API_KEY" | jq -r '.publicKey' | base64 --decode | sha256sum | cut -d' ' -f1) echo "$FIPSIGN_KEY_FINGERPRINTS"
Checking needs no token. The only thing fetched is the list of public keys, and a key from it is trusted only if its fingerprint is one you saved. Save the list with the API key of your project:
curl -s https://api.fipsign.dev/public-keys \
-H "X-API-Key: $API_KEY" > keys.json
Then run this with the receipt you saved in 01 (emit-receipt.json). FIPSIGN_KEY_FINGERPRINTS is the fingerprint you saved, or several separated by commas, and MANDATE_ID the id of the mandate.
// npm install @noble/post-quantum (Node 20+) import { createHash } from 'node:crypto' import { readFileSync } from 'node:fs' import { ml_dsa44, ml_dsa65, ml_dsa87 } from '@noble/post-quantum/ml-dsa.js' const VARIANTS = { 'ML-DSA-44': ml_dsa44, 'ML-DSA-65': ml_dsa65, 'ML-DSA-87': ml_dsa87 } const sha256hex = (data) => createHash('sha256').update(data).digest('hex') // The fingerprints you saved, separated by commas: one for each key your project has had. const pinned = process.env.FIPSIGN_KEY_FINGERPRINTS.split(',') // The answer of GET /public-keys. A key from this list is trusted only if its fingerprint is one you pinned. const { keys } = JSON.parse(readFileSync('keys.json', 'utf8')) // Returns the list of problems. An empty list means the receipt is good. function checkReceipt(receipt, mandateId) { // 1. The key that signed, and only if you pinned it. const key = keys.find((k) => k.fingerprint === receipt.keyFingerprint) if (!key) return ['FIPSign lists no key with the fingerprint of this receipt'] const publicKey = Buffer.from(key.publicKey, 'base64') if (!pinned.includes(sha256hex(publicKey))) { return ['the key that signed is not one you pinned'] } // 2. The signature: over "FIPSIGN-MANDATE-v1\n" + the text in "signed", exactly as received. const message = new TextEncoder().encode('FIPSIGN-MANDATE-v1\n' + receipt.signed) let valid = false try { const signature = Buffer.from(receipt.signature, 'base64') valid = VARIANTS[receipt.algorithm].verify(signature, message, publicKey) } catch {} if (!valid) return ['the signature does not verify'] // 3. What was signed is this event of this mandate. const problems = [] const statement = JSON.parse(receipt.signed) const { event } = receipt if (statement.kind !== 'mandate.receipt') problems.push('this is not a receipt') if (statement.mandateId !== mandateId) problems.push('it is the receipt of another mandate') if (statement.seq !== event.seq || statement.hash !== event.hash || statement.at !== event.at) { problems.push('the signature is about another event') } if (sha256hex(event.prevHash + '\n' + event.body) !== event.hash) problems.push('the hash of the event is wrong') if (JSON.parse(event.body).mandateId !== statement.mandateId) problems.push('the event is of another mandate') return problems } const receipt = JSON.parse(readFileSync('emit-receipt.json', 'utf8')) const problems = checkReceipt(receipt, process.env.MANDATE_ID) if (problems.length > 0) { console.log('NOT valid:', problems) } else { console.log(`valid: event ${receipt.event.seq} (${receipt.event.type}) of ${process.env.MANDATE_ID}, at ${receipt.event.at}`) }
# pip install "cryptography>=48.0.0" import base64 import hashlib import json import os from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives.asymmetric.mldsa import MLDSA44PublicKey, MLDSA65PublicKey, MLDSA87PublicKey VARIANTS = {"ML-DSA-44": MLDSA44PublicKey, "ML-DSA-65": MLDSA65PublicKey, "ML-DSA-87": MLDSA87PublicKey} def sha256hex(data): return hashlib.sha256(data).hexdigest() # The fingerprints you saved, separated by commas: one for each key your project has had. pinned = os.environ["FIPSIGN_KEY_FINGERPRINTS"].split(",") # The answer of GET /public-keys. A key from this list is trusted only if its fingerprint is one you pinned. with open("keys.json") as f: keys = json.load(f)["keys"] def check_receipt(receipt, mandate_id): """Returns the list of problems. An empty list means the receipt is good.""" # 1. The key that signed, and only if you pinned it. key = next((k for k in keys if k["fingerprint"] == receipt["keyFingerprint"]), None) if key is None: return ["FIPSign lists no key with the fingerprint of this receipt"] public_key = base64.b64decode(key["publicKey"]) if sha256hex(public_key) not in pinned: return ["the key that signed is not one you pinned"] # 2. The signature: over "FIPSIGN-MANDATE-v1\n" + the text in "signed", exactly as received. message = ("FIPSIGN-MANDATE-v1\n" + receipt["signed"]).encode() try: VARIANTS[receipt["algorithm"]].from_public_bytes(public_key).verify(base64.b64decode(receipt["signature"]), message) except (InvalidSignature, ValueError, KeyError): return ["the signature does not verify"] # 3. What was signed is this event of this mandate. problems = [] statement = json.loads(receipt["signed"]) event = receipt["event"] if statement["kind"] != "mandate.receipt": problems.append("this is not a receipt") if statement["mandateId"] != mandate_id: problems.append("it is the receipt of another mandate") if (statement["seq"], statement["hash"], statement["at"]) != (event["seq"], event["hash"], event["at"]): problems.append("the signature is about another event") if sha256hex((event["prevHash"] + "\n" + event["body"]).encode()) != event["hash"]: problems.append("the hash of the event is wrong") if json.loads(event["body"])["mandateId"] != statement["mandateId"]: problems.append("the event is of another mandate") return problems with open("emit-receipt.json") as f: receipt = json.load(f) mandate_id = os.environ["MANDATE_ID"] problems = check_receipt(receipt, mandate_id) if problems: print("NOT valid:", problems) else: print(f"valid: event {receipt['event']['seq']} ({receipt['event']['type']}) of {mandate_id}, at {receipt['event']['at']}")
It prints one line:
the signature does not verify, the key that signed is not one you pinned, the hash of the event is wrong or it is the receipt of another mandate. The JavaScript and Python SDKs do this check in one call, and also check whole exports (JavaScript SDK · Python SDK). Saving the fingerprint is optional with them: without it they check against the keys FIPSign lists and answer keyTrust: "fipsign", which detects a receipt that was altered but not a key that FIPSign itself replaced.
at is FIPSign's clock, not an independent timestamp authority. A signature made with a key that only you hold, on the request itself, is not part of this change.
id in your own database.
Two endpoints read the log: the whole log of one mandate, in order, and a search across all the mandates of the project. Both need a full-access API key — an agent key gets the same 401 as on any endpoint other than verify — and neither costs tokens. They share the read rate limit with GET /mandate (07).
curl -s https://api.fipsign.dev/mandate/$MANDATE_ID/events \
-H "X-API-Key: $API_KEY" | jq
| Query parameter | Type | Required | Description |
|---|---|---|---|
after | integer | No | Return the entries after this seq (0 or more). Default 0: from the first one. Copy it from nextAfter of the previous page. |
limit | integer | No | Page size, 1–500. Default 100. |
after. nextAfter is the seq to pass as after to get the next page, and null on the last. head says where the log ends right now: its seq, its hash and the seq of the last checkpoint (0 if there is none). It is null once the mandate itself has been deleted, and the entries are still returned for as long as they are kept (365 days). An id that has no entries answers 404 Mandate not found.
curl -s https://api.fipsign.dev/mandate/$MANDATE_ID/events \ -H "X-API-Key: $API_KEY" | jq -c '.events[] | {seq, type, at, detail: (.body | fromjson)}'
It prints one line per entry, for example:
AFTER=0 while :; do PAGE=$(curl -s "https://api.fipsign.dev/mandate/$MANDATE_ID/events?after=$AFTER&limit=100" \ -H "X-API-Key: $API_KEY") echo "$PAGE" | jq -c '.events[] | {seq, type, at}' AFTER=$(echo "$PAGE" | jq -r '.nextAfter // empty') [ -z "$AFTER" ] && break done
Searches the entries of every mandate of the project at once, newest first. Every filter is an exact match and they combine with AND.
| Filter | Type | Matches |
|---|---|---|
mandateId | string | The entries of that mandate (up to 128 characters). |
type | string | One entry type of 11, for example verify_denied. |
action | string | The action of verify entries (up to 64 characters). |
keyId | string | The API key that made the call (how to get it). |
correlationId | string | Your own id, exactly as you sent it (up to 128 characters). |
traceId | string | The trace id of a traceparent header (32 lowercase hexadecimal characters). |
from, to | integer | A period in Unix seconds: from ≤ at ≤ to. |
limit | integer | Page size, 1–200. Default 50. |
cursor | string | Opaque, URL-safe. Copy the nextCursor of the previous page as is. |
mandateId, a correlationId or a traceId is already narrow and takes any period, or none. Any other search looks at the last 7 days unless you give from, and from to to may span at most 90 days. The answer repeats the period it used in from and to. Timing. This search reads the long-term copy of the log, which receives each entry about 5 seconds after it happened. To see an entry at once, read the log of its mandate.
curl -s "https://api.fipsign.dev/mandate/events?correlationId=ticket-4821" \ -H "X-API-Key: $API_KEY" | jq -c '.events[] | {mandateId, seq, type, at}'
FROM=$(( $(date +%s) - 86400 )) curl -s "https://api.fipsign.dev/mandate/events?type=verify_denied&from=$FROM&limit=200" \ -H "X-API-Key: $API_KEY" | jq -c '.events[] | {mandateId, at, detail: (.body | fromjson | {action, reason})}'
mandateId. nextCursor is a string while more entries match and null on the last page: copy it as is into cursor. A value that is empty, modified or truncated is rejected with 400.
CURSOR="" while :; do PAGE=$(curl -s "https://api.fipsign.dev/mandate/events?type=verify_denied${CURSOR:+&cursor=$CURSOR}" \ -H "X-API-Key: $API_KEY") echo "$PAGE" | jq -c '.events[] | {mandateId, seq, at}' CURSOR=$(echo "$PAGE" | jq -r '.nextCursor // empty') [ -z "$CURSOR" ] && break done
The export is the log of one mandate in a form you can keep and check on your own machine, without trusting what FIPSign shows you at the moment you check: the entries, FIPSign's signed statement of where the log ends, and the public keys that signed. Like every read, it is free (Costs) and needs the full-access key of the project.
curl -s https://api.fipsign.dev/mandate/$MANDATE_ID/export \
-H "X-API-Key: $API_KEY" > export-page-0001.json
| Query parameter | Type | Required | Description |
|---|---|---|---|
after | integer | No | Return the entries after this seq. Default 0. |
limit | integer | No | Page size, 1–1000. Default 500. |
| Field | What it is |
|---|---|
format | fipsign.mandate.export.v1. |
projectId, mandateId | The project and the mandate this page belongs to. |
generatedAt | When this page was made, in Unix seconds. |
events, count | The entries of this page, oldest first, as in 13. |
nextAfter | The seq to pass as after for the next page, null on the last. |
head | While the mandate is alive: the end of its log as FIPSign holds it right now, signed (kind mandate.export). null once the mandate has been deleted: its log then ends in a checkpoint that seals the rest. |
publicKeys | Every public key your project has had, the retired ones too: the checkpoints inside the log may have been signed before a rotation. |
AFTER=0 N=1 while :; do FILE=$(printf 'export-page-%04d.json' "$N") curl -s "https://api.fipsign.dev/mandate/$MANDATE_ID/export?after=$AFTER" \ -H "X-API-Key: $API_KEY" > "$FILE" AFTER=$(jq -r '.nextAfter // empty' "$FILE") [ -z "$AFTER" ] && break N=$((N + 1)) done jq -s '.' export-page-*.json > export.json
export.json now holds the pages of the export, in order, as a list. Keep it with your records.
import { createHash } from 'node:crypto' import { readFileSync } from 'node:fs' const sha256hex = (text) => createHash('sha256').update(text).digest('hex') const fail = (message) => { console.log('NOT intact: ' + message) process.exit(1) } const pages = JSON.parse(readFileSync('export.json', 'utf8')) // the file made above: one item per page let prev = '0'.repeat(64) // the "hash" before the first event let last = 0 for (const page of pages) { for (const e of page.events) { if (e.seq !== last + 1) fail(`event ${last + 1} is missing`) if (e.prevHash !== prev) fail(`event ${e.seq} does not follow event ${last}`) if (sha256hex(e.prevHash + '\n' + e.body) !== e.hash) fail(`event ${e.seq} was changed`) prev = e.hash last = e.seq } } console.log(`${last} events, every one follows the one before it. Last hash: ${prev}`)
import hashlib import json def fail(message): print("NOT intact: " + message) raise SystemExit(1) with open("export.json") as f: pages = json.load(f) # the file made above: one item per page prev = "0" * 64 # the "hash" before the first event last = 0 for page in pages: for e in page["events"]: if e["seq"] != last + 1: fail(f"event {last + 1} is missing") if e["prevHash"] != prev: fail(f"event {e['seq']} does not follow event {last}") if hashlib.sha256((e["prevHash"] + "\n" + e["body"]).encode()).hexdigest() != e["hash"]: fail(f"event {e['seq']} was changed") prev = e["hash"] last = e["seq"] print(f"{last} events, every one follows the one before it. Last hash: {prev}")
prevHash instead of the 64 zeros. The other checks need the key: that every checkpoint entry carries a valid signature over the hash it covers (the statement is built like the one of a receipt, with kind mandate.checkpoint, seq equal to coversSeq, hash equal to coversHash and at equal to the at of the checkpoint entry), and that head is signed and is the last entry. The SDKs do all of them in one call (JavaScript SDK · Python SDK).
| What you hold | What protects it |
|---|---|
| A log that ends in a checkpoint | Everything before the checkpoint is sealed: changing, removing or adding an entry before it would break a signature. |
| The export of a mandate that is still alive | The signed head says where the log ends at the moment of the export, and every receipt you hold covers its entry and all before it. Entries written after the last checkpoint are protected by those two until the next checkpoint seals them. |
complete: the log starts at entry 1, ends in a checkpoint whose signature you verified, and nothing follows it.
curl -s https://api.fipsign.dev/public-keys \ -H "X-API-Key: $API_KEY" | jq '.keys[] | {fingerprint, status, recordedAt, retiredAt}'
recordedAt is when FIPSign wrote the key down, and retiredAt is when the project rotated it away: null for the current key and for a key that was retired before this list existed. The list is deleted with the project. It comes from FIPSign, so it is a convenience and not a proof of identity: trust a key in it only if its fingerprint is one you saved (12).
curl -s https://api.fipsign.dev/public-key \ -H "X-API-Key: $API_KEY" | jq -r '.publicKey' | base64 --decode | sha256sum
It is the SHA-256 of the raw bytes of the public key, in lowercase hexadecimal: the value a receipt carries as keyFingerprint and GET /public-keys lists as fingerprint. The command prints the fingerprint of the current key.
keyFingerprint says which one. So, right after each rotation, save the fingerprint of the new key as you did the first time and keep the old ones; a key whose fingerprint you never saved is refused by the check. The list of keys is not limited to the 30 days during which a rotated key still verifies mandates (10).
Give each call an id of your own, and FIPSign writes it into the entry the call produces. One search then finds everything that happened under that id, across mandates and across calls, and a lost answer stops being a guess.
| Id | How to send it | What FIPSign does with it |
|---|---|---|
correlationId | A field of the body of POST /mandate, POST /mandate/verify and PATCH /mandate/:id. A text of 1–128 characters with no control characters (leading and trailing spaces are trimmed). Use a ticket number, a request id, an id of your own workflow. | Writes it into the entry, so a receipt covers it. Find the entries with the filter correlationId (13). |
traceparent | The W3C Trace Context header (traceparent: 00-<trace id>-<parent id>-01) on any of those calls. OpenTelemetry and most monitoring tools send it already. | Writes the 32-character trace id into the entry as traceId. A header that is not valid is ignored: it never makes a call fail. Find the entries with the filter traceId. |
correlationId. They do not send traceparent: to use it, call the REST API.
Replace the API key. It needs curl, jq and openssl.
#!/bin/bash # Requires: curl, jq and openssl API_KEY="pqa_YOUR_API_KEY" BASE_URL="https://api.fipsign.dev" TICKET="ticket-$(date +%s)" # your own id for everything that follows TRACE_ID=$(openssl rand -hex 16) # the trace of your own system (32 hex characters) PARENT_ID=$(openssl rand -hex 8) # the span that makes the call (16 hex characters) TRACEPARENT="00-$TRACE_ID-$PARENT_ID-01" echo "[1/5] Emit..." MANDATE=$(curl -s -X POST $BASE_URL/mandate \ -H "Content-Type: application/json" -H "X-API-Key: $API_KEY" -H "traceparent: $TRACEPARENT" \ -d "{\"agentId\":\"agent-test\",\"issuedBy\":\"dev@example.com\",\"scope\":[\"sign\"],\"budgetTotal\":100,\"expiresInSeconds\":3600,\"correlationId\":\"$TICKET\"}") MANDATE_ID=$(echo "$MANDATE" | jq -r '.mandate.id') MANDATE_TOKEN=$(echo "$MANDATE" | jq -c '.mandate.token') echo "[2/5] Verify (granted)..." curl -s -X POST $BASE_URL/mandate/verify \ -H "Content-Type: application/json" -H "X-API-Key: $API_KEY" -H "traceparent: $TRACEPARENT" \ -d "{\"token\": $MANDATE_TOKEN, \"action\": \"sign\", \"cost\": 5, \"correlationId\": \"$TICKET\"}" | jq -c '{result,budgetRemaining}' echo "[3/5] Verify (denied)..." curl -s -X POST $BASE_URL/mandate/verify \ -H "Content-Type: application/json" -H "X-API-Key: $API_KEY" -H "traceparent: $TRACEPARENT" \ -d "{\"token\": $MANDATE_TOKEN, \"action\": \"delete:records\", \"cost\": 0, \"correlationId\": \"$TICKET\"}" | jq -c '{result,reason}' echo "[4/5] Revoke..." curl -s -X PATCH $BASE_URL/mandate/$MANDATE_ID \ -H "Content-Type: application/json" -H "X-API-Key: $API_KEY" -H "traceparent: $TRACEPARENT" \ -d "{\"action\": \"revoke\", \"correlationId\": \"$TICKET\"}" | jq -c '{status}' echo "[5/5] Everything that happened under $TICKET (the project-wide search lags a few seconds)..." sleep 8 curl -s "$BASE_URL/mandate/events?correlationId=$TICKET" \ -H "X-API-Key: $API_KEY" | jq -c '.events | reverse | .[] | {seq, type, traceId: (.body | fromjson | .traceId)}' echo "Same events, found by the trace id of your own system:" curl -s "$BASE_URL/mandate/events?traceId=$TRACE_ID" \ -H "X-API-Key: $API_KEY" | jq '.count'
It prints this (the ticket number and the trace id are different every time):
correlationId, the log of the mandate says what happened to it, and this is read at once, not a few seconds later. Use a new id for every attempt, keep it in your executor, and look for it:
CALL_ID="call-$(date +%s)-$RANDOM" # a new id for each attempt, kept by your executor curl -s -X POST https://api.fipsign.dev/mandate/verify \ -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \ --max-time 10 \ -d "{\"token\": $MANDATE_TOKEN, \"action\": \"sign\", \"cost\": 5, \"correlationId\": \"$CALL_ID\"}" # The answer never arrived. Was the call applied? Ask the log of that mandate: curl -s "https://api.fipsign.dev/mandate/$MANDATE_ID/events?after=0&limit=500" \ -H "X-API-Key: $API_KEY" \ | jq -c --arg id "$CALL_ID" '.events[] | select(.type == "verify_granted" or .type == "verify_released" or .type == "verify_denied") | select((.body | fromjson).correlationId == $id) | {seq, type, detail: (.body | fromjson | {action, cost, reason, ofSeq})}'
For a call that was applied, the second command prints one line:
| What the log shows for that id | What it means |
|---|---|
A verify_granted and nothing else | The call was applied: its cost is in budgetConsumed. Perform the action. |
A verify_granted and then a verify_released whose ofSeq is its seq | It was granted and then undone: charging your platform tokens failed (the call would have answered 429) and the budget was given back. Treat it as not applied. |
A verify_denied | The call was denied; reason says why. Nothing was consumed. |
| No entry | The call never reached the mandate, or the mandate is gone. Nothing was consumed: you can send it again, with a new id. |
What each operation costs, in tokens. This is the only tab that lists amounts: every other tab links here.
Packs and prices: Dashboard 09
A token is the unit FIPSign bills in. Every operation that costs something spends tokens from one pool, shared by all the projects of your account.
| Kind | Detail |
|---|---|
| Free tokens | 2,000 every month. They reset on the 1st (UTC) and unused ones do not carry over. |
| Pack tokens | They never expire and add up across purchases. They are spent after the free tokens run out. |
usage() (JS SDK 07, Python SDK 07) or with GET /usage (REST 06).Consumption by project.
Amounts are per call. An SDK method calls the endpoint on its row, so it costs the same.
| Operation | Token cost | Endpoint or screen | SDK method |
|---|---|---|---|
| Signing and verification | |||
| Sign a payload | 2 tokens | POST /sign | sign() |
| Zero-Exposure Signing | Same as Sign and Verify | POST /sign, POST /verify | zes.sign(), zes.verify() |
| Verify a token | 1 token | POST /verify | verify() |
| Revoke a token | Free | POST /revoke | revoke() |
| Certificate authority | |||
| Create a CA | 5 tokens | Dashboard 04 (POST /ca/create, session only) | — |
| Issue a certificate | 3 tokens | POST /ca/issue | ca.issue() |
| Revoke a certificate | Free | POST /ca/revoke | ca.revokeCert() |
| Certificate Revocation List | Free | GET /ca/crl | ca.getCrl() |
| Status of one certificate | Free | GET /ca/certificate/:id | ca.getCert() |
| Verify a certificate | Free | No endpoint: it runs on your machine | ca.verifyCert(), ca.verifyX509Cert() |
| Verify the revocation list | Free | No endpoint: it runs on your machine | ca.verifyCrl() |
| Mandate | |||
| Emit a mandate | 3 tokens | POST /mandate | mandate.emit() |
| Check an action | 2 tokens, only if granted | POST /mandate/verify | mandate.verify() |
| Suspend, resume, narrow or revoke a mandate | Free | PATCH /mandate/:id | mandate.suspend(), resume(), narrow(), revoke() |
| Receipt of a call | Free | Inside the answer of POST /mandate and PATCH /mandate/:id; on POST /mandate/verify when the request has "receipt": true | The receipt of the result; the option receipt: true of mandate.verify() |
| Read one mandate | Free | GET /mandate/:id | mandate.get() |
| List mandates | Free | GET /mandate | mandate.list(), mandate.listAll() |
| Read the log of a mandate | Free | GET /mandate/:id/events | mandate.events(), mandate.eventsAll() |
| Search the log of the project | Free | GET /mandate/events | mandate.queryEvents(), mandate.queryEventsAll() |
| Export the log of a mandate | Free | GET /mandate/:id/export | mandate.export(), mandate.exportAll() |
| Public keys of the project, the retired ones too | Free | GET /public-keys | mandate.publicKeys() |
| Check a receipt or an export | Free | No endpoint: it runs on your machine | verifyMandateReceipt(), verifyMandateExport() |
| Account and dashboard | |||
| Token balance and history | Free | GET /usage | usage() |
| Consumption by project | Free | Dashboard 09 (no endpoint for an API key) | — |
| Public key of the project | Free | GET /public-key | — |
| Service status | Free | GET /health | health() |
| Create, edit or delete a project | Free | Dashboard 02, 05, 07 | — |
| Create or revoke an API key | Free | Dashboard 03 | — |
| Rotate the keys of a project | Free | Dashboard 06 | — |
| Webhooks and two-factor authentication | Free | Dashboard 08, 10 | — |
ca.revoke_cert(), ca.get_crl(), ca.get_cert(), ca.verify_crl(), mandate.list_all(), mandate.events_all(), mandate.query_events(), mandate.query_events_all(), mandate.export_all(), mandate.public_keys(), verify_mandate_receipt() and verify_mandate_export(). The Python SDK has no health().fipsign_sign calls POST /sign.| Case | What happens |
|---|---|
| Refused requests | A request refused before it does any work costs nothing: a missing or invalid API key, a malformed body, a field outside its limits or a rate limit. |
| Errors on our side | A request that ends with a FIPSign server error (HTTP 5xx) is not charged. If the tokens had already been taken, they are returned to where they came from, your free tokens or your packs, and the recent operations of the project show it as a refund line (for example +1 tokens). |
| Repeated requests | FIPSign does not recognise a repeated request. If you send the same request again, for example because the answer never arrived, it is a new request: it is charged again and, where the operation creates something, it creates a second token, mandate or certificate. Repeating a revoke that already happened costs nothing. If the answer to a POST /mandate/verify was lost, read Mandate 02c before sending it again. |
| Verify | POST /verify is charged as soon as the request is well formed, whatever the answer is: a token that turns out to be invalid, expired or revoked costs the same as a valid one. |
| Revoke | POST /revoke and POST /ca/revoke are free, and they keep working when you have no tokens left. Revoking is a security operation: charging for it could keep you from cutting off a token or a certificate that has been compromised. |
| Create a CA | Charged only once the CA exists. You need at least that many tokens left to start; if you do not, nothing is created and the dashboard shows Token limit reached. |
| Issue a certificate | POST /ca/issue is charged once the request has passed validation (the key, the expiry and a CA that exists). |
| Mandate | POST /mandate is charged on every successful emission. POST /mandate/verify is charged only when the answer is granted: every denial is free and consumes no mandate budget. Receipts, reading the log, the export and the list of public keys are free, and they keep working when you have no tokens left. If your balance cannot cover a granted call, the call is refused with HTTP 429 and the mandate budget is not consumed. |
| Mandate control | Suspend, resume, narrow and revoke are always free, by design. They are security operations: charging for them would discourage the very behavior you want when an agent misbehaves or is compromised. |
| Mandate budget | The budget of a mandate (budgetTotal, and the cost you send on each verify) is a separate currency that you define. It never spends platform tokens, and platform tokens never spend it. See Mandate 00. |
| On your machine | These run locally, never reach FIPSign and cost nothing: verification with localVerify: true (JS SDK), ca.verifyCert(), ca.verifyX509Cert(), ca.verifyCrl(), ca.isCertRevoked(), generateKeyPair(), generateAgentKeyPair(), signAgentCall(), verifyMandateReceipt() and verifyMandateExport(). The Python SDK has the same functions in snake_case, but no local token verification. |
| Out of tokens | When your free tokens and your packs are used up, operations that cost tokens answer HTTP 429 with "code": "token_quota_exhausted" and operations that cost nothing keep working. Retrying does not help: buy a pack (Dashboard 09). More in REST 09. |