Web app

Dashboard Guide

Account, projects, API keys, CA, webhooks and token packs: everything you do from the web app.
Open it at https://app.fipsign.dev

The way to your first call. Sections 01 to 03 take you from no account to an API key. Section 04 is only needed if you issue certificates. Then continue in the tab for your language: JS SDK, Python SDK or REST API. Sections 05 to 11 are for later: edit and delete a project, rotate keys, webhooks, token packs, two-factor authentication and the activity log.
01 Sign in

There is no separate sign-up and no password. The first time you sign in with an email address, the account is created.

  1. Go to app.fipsign.dev, type your address in Email and click Send code →.
  2. FIPSign emails you a 6-digit code. It expires in 10 minutes. Type it in Verification code and click Sign in →.
  3. Only if you turned on two-factor authentication (section 10): a second screen asks for Authenticator code or backup code. Type it and click Verify →.
The code did not arrive? Click Request a new code. You can ask for up to 3 codes per email address every 10 minutes, and after 5 wrong attempts in 10 minutes you must request a new code. To use another address click ← Change email.

Session: you stay signed in for 24 hours. The Sign out button in the dashboard header ends the session earlier.
02 Create a project

A project has its own signing keypair and its own API keys. Use one project per application or per environment.

  1. In the Projects section click + New project.
  2. Type a Project name (up to 128 characters), for example My SaaS. Description (optional) accepts up to 512 characters.
  3. Under Environment click production, staging or development (the default is production). It is a label to tell your projects apart; it does not change how the project works.
  4. Under Algorithm click one of the three options below (the default is ML-DSA-65).
  5. Click Create project. The project appears as a card: click it to expand it.
Option to clickNIST security levelThe dashboard describes it as
ML-DSA-442Security Level 2 — faster
ML-DSA-653Security Level 3 — recommended
ML-DSA-875Security Level 5 — maximum
The algorithm cannot be changed after the project is created. If you need another one, create a new project.
Project ID. The expanded card shows ID: proj_…. Keep it: the SDKs need it for offline verification (projectId in SDK 11).

Tokens are shared. Free tokens and pack tokens belong to the account, not to a project. What each project spent is in Consumption by project (section 09).

Limit. There is a maximum number of projects per account. When you reach it, the dashboard shows Maximum of N projects allowed per account. The maximum is listed in REST 08.
03 Create an API key

An API key belongs to a project. It is what your code sends in the X-API-Key header.

  1. Click the project card to expand it (the arrow on its right changes from ▼ to ▲).
  2. In the API Keys block click + New key.
  3. Type a name in Key name (e.g. "production"), up to 128 characters. If you leave it empty the key is named default.
  4. Leave Agent key — restricted scope unchecked, unless the key is for an AI agent (see below). Click Create.
  5. A green box says API KEY CREATED — SAVE NOW, WON'T BE SHOWN AGAIN. Click Copy key and store it in an environment variable, never in source code. Then click I've saved it — dismiss.

The key has the form pqa_ followed by 64 hexadecimal characters.

The key cannot be recovered. FIPSign stores only a hash of it. If you lose it, create a new key and revoke the old one.
Agent key — restricted scope

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.

The list of keys

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.

Limit. There is a maximum number of active API keys per account. When you reach it, the dashboard shows Maximum of N active API keys allowed per account. The maximum is listed in REST 08.

Next: set the key in your environment and make your first call: JS SDK 01, Python SDK 01 or REST 00.
04 Create a CA

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.

  1. Expand the project card and go to Certificate Authority. A project without a CA shows No CA for this project yet.
  2. In the list choose the format: PQCert (JSON) is FIPSign's own format; X.509 (PEM) is a standard certificate for tools that read X.509 (details in REST 11 and REST 11b).
  3. Type the name of the CA in the box (CA name, e.g. "My App Root CA", up to 256 characters) and click Create CA (the button also shows its token cost).
  4. Save the root certificate. A green box says ROOT CERTIFICATE — SAVE NOW, WON'T BE SHOWN AGAIN (for X.509, X.509 ROOT CERTIFICATE (PEM) — SAVE NOW, WON'T BE SHOWN AGAIN). For PQCert click Download .json (saves root-cert.json) or Copy JSON. For X.509 click Download .pem (saves root-ca.pem) or Copy PEM.
Save the root certificate now. FIPSign cannot show it again. Without it you cannot verify certificates or the revocation list offline (ca.verifyCert(), ca.verifyX509Cert(), ca.verifyCrl(): SDK 12).
FactDetail
CostSee 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 projectA second one is refused: This project already has an active CA.
AlgorithmAlways ML-DSA-65 (NIST FIPS 204), whatever algorithm the project uses.
Validity10 years from creation. The panel shows the date next to Expires.
KeysThe 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).

05 Edit a project

Change the name, the description or the environment label.

  1. Expand the project card and click Edit project.
  2. Change Name, Description or Environment.
  3. Click Save changes (or Cancel).
Not editable: the algorithm. API keys, the CA and the project ID are not affected, so your code needs no change.
06 Rotate keys

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.

  1. Expand the project card. At the bottom, click Rotate keys and confirm.
  2. The card shows Keys rotated. Previous key valid until … (tokens). Previous key valid for mandates until …. The first date is 1 hour from now and the second is 30 days from now.
After you rotateWhat happens
New tokens and mandatesThey are signed with the new key.
Tokens signed beforeThey 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 beforeThey 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 mandateReceipts 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 rotationsEach 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 keysThey do not change. Your code needs no change.
The CAIt is not touched. There is no rotation for the CA key.
Rotating ends every token signed with the previous key, 1 hour after the rotation. It does not end mandates. If the key may have been stolen, that hour is how long whoever holds it can still make tokens that verify through FIPSign: after it, they cannot. Mandates are different: FIPSign decides what a mandate can do from the record it keeps (status, expiry, scope, budget), not from the signature. Someone with the old key cannot create a mandate, widen one or bring back a revoked one. At most they can present a token for a mandate that already exists, and only within the limits FIPSign has for it (a mandate with an agent key also needs the agent's own signature). If you rotated because of a theft, suspend or revoke the mandates you do not trust (Mandate 03). To stop a single token earlier, revoke it (SDK 05).
Offline verification. 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.

Receipts are different. A receipt of the Mandate audit log is checked with the key that made it, however old, so there you do keep the old keys: GET /public-keys lists them (Mandate 12).
07 Delete a project

Permanent. There is no way to recover a deleted project.

  1. Expand the project card. At the bottom right, click Delete project.
  2. The window DELETE PROJECT lists what will be deleted. Type the exact name of the project to confirm.
  3. Click Delete permanently.
DeletedConsequence
All its API keysThey are revoked immediately: every app using them starts failing.
The CA and every certificate it issuedListed only if the project has a CA. Certificates stop being checkable through FIPSign.
The signing keys and the configurationFIPSign can no longer verify tokens that the project signed.
The key history and the audit log of the mandatesThe 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).
Not affected: your free tokens, your packs, your other projects, your webhook (it belongs to the account) and what the project already spent: it stays in Consumption by project, under the name it had and tagged deleted, for as long as FIPSign keeps the record (the current month and the 5 before it, section 09).
08 Webhooks

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.

  1. In the Webhooks section (below Projects) click + Register webhook.
  2. Type a public HTTPS address in Webhook URL, for example 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.
  3. Under Events to subscribe tick the events you want. All five are ticked by default and at least one is required.
  4. Click Register. A green box says WEBHOOK SECRET — SAVE THIS NOW, IT WON'T BE SHOWN AGAIN. Click Copy secret, store it in an environment variable and click I've saved it — dismiss.
  5. Click Send test event: FIPSign sends a webhook.test event to your URL, whatever events you ticked, so you can check that your server receives it.
EventSent whenprojectId
token.signedA sign() call succeeded.yes
token.rejectedFIPSign refused a token in POST /verify. Offline verification never reaches FIPSign, so it sends nothing.yes
token.revokedA token was revoked with revoke().yes
limit.warningA 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.reachedA 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.

Buttons on a registered webhook
ButtonWhat it does
Send test eventSends webhook.test to your URL.
Edit webhookChanges the URL or the events. The secret stays the same: your server needs no change.
Rotate secretCreates 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 webhookYou stop receiving notifications.
Verify every request. Each one carries an 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.
09 Tokens and packs

Every billable call spends tokens from a single pool shared by all your projects. What each call costs: the Costs tab.

KindDetail
Free tokens2,000 every month. They reset on the 1st (UTC) and unused ones do not carry over.
Pack tokensThey never expire and add up across purchases. They are spent after the free tokens run out.
Where to see your balance

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.

Consumption by project

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.

  1. The 6 bars are the last 6 months (UTC). The number under each bar is the tokens spent in that month, and the bar is divided by project: the 5 projects that spent the most in these 6 months have a colour each, named under the bars, and the rest are one grey part, Others. Click a bar to choose a month; when the page opens, the latest one is chosen.
  2. Under the bars, the list shows the chosen month project by project: its tokens, its share of the month and its change against the previous month (▲ +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.
  3. Click a project to open it. By operation and By API key show how its tokens of the chosen month were spent, and Last 30 days shows a bar for each of the last 30 days, whichever month is chosen. Click the project again to close it.
  4. Click ↻ Refresh to load the figures again and Export CSV to download them (below).
What you seeWhat it means
The project of an operationThe 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 operationThe 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 keyThe name of the key. A key you revoked is marked revoked: what it spent stays counted.
RefundsA 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 nothingThey are not counted: only an operation that costs tokens leaves a record.
Free tokens and packsThe 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 daysUTC, like the free tokens: a month starts on the 1st at 00:00 UTC.
Deleted projectsWhat 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 backThe 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.
Export the consumption (CSV)

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:

ColumnWhat it hasExample
monthThe month, in UTC.2026-10
project_idThe ID of the project, the one its card shows as ID: proj_….proj_...
project_nameThe name of the project. For a deleted project, the name it had; empty if FIPSign has no record of it.My SaaS
environmentproduction, staging or development. Empty for a deleted project.production
deletedtrue for a project you deleted, false for the others.false
tokensThe 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.

It is built from the audit log of your operations, like the chart of the previous months: under very high burst traffic it can show slightly less than was really spent. The balance in the cards at the top of the dashboard is always exact (REST 06).

Only in the dashboard. GET /usage and usage() return the whole account. There is no endpoint with an API key for the consumption of each project.
Buy a pack
  1. In Token Packs click the pack you want. It is marked SELECTED.
  2. Click Pay $19 → (the button shows the price of the selected pack). The payment page opens in a new tab: pay with card or crypto.
  3. When the payment is confirmed the tokens are credited automatically. Back in the dashboard, click ↻ Refresh to see them.
PackTokensPrice
Lite25,000$19
Pro100,000$49
Scale500,000$149
Purchase History lists your last 50 purchases, the newest first, with 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.

Out of tokens? Calls that cost tokens fail with token_quota_exhausted until the 1st of the month or until you buy a pack.
10 Two-factor authentication

Optional second step after the email code, with any authenticator app that supports TOTP (RFC 6238).

Turn it on
  1. In the Two-factor authentication section click + Enable 2FA.
  2. Scan the QR code with your authenticator app. If you cannot scan it, type the key shown under Can't scan? Enter this key manually:.
  3. Type the 6-digit code from the app in Enter the 6-digit code from your app to confirm and click Confirm and enable.
  4. A green box shows 10 backup codes: BACKUP CODES — SAVE THESE NOW, THEY WON'T BE SHOWN AGAIN. Click Copy all, keep them somewhere safe and click I've saved them — dismiss. Each code works once; use one if you lose access to the app.
Signing in with it

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.

Turn it off

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.

11 Activity log and email alerts

What happened in your account, and what FIPSign tells you by email.

Activity Log

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

Email alerts

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

JavaScript / TypeScript

JS SDK Reference

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

Use the SDK from your backend, not from a browser. Your API key is a secret: in browser code anyone can read it. For that reason api.fipsign.dev only accepts browser calls from fipsign.dev and app.fipsign.dev; from any other website the browser blocks them and you will see a CORS error or Failed to fetch (NETWORK_ERROR in the SDK). From your own server (Node.js 20+) there is no such restriction.
About the examples. pq is always a PQAuth you created once (const pq = new PQAuth('pqa_your_api_key')). reject(reason) stands for your own code that refuses the request — for example throw new Error(reason) or res.status(403).json({ error: reason }) — and doTheAction() for the operation you are protecting. Neither is part of the SDK.
00 Environment variables

Set these in your terminal before running any REST/curl command in this guide. If you are using the SDK you only need your API key.

# Replace with your API key from the dashboard (starts with pqa_)
export API_KEY="pqa_YOUR_API_KEY_HERE"
export BASE_URL="https://api.fipsign.dev"
Where do I get my API key? Dashboard → your project → expand the card → + New key. The key is shown only once at creation time. If you lose it, create a new one and revoke the old one. Step by step: Dashboard 03.

Agent key — restricted scope. If the key is for an AI agent, check "Agent key — restricted scope" when you create it (Dashboard 03). A key created this way can only call 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.

For CA examples: you also need a CA for your project. Create it in the dashboard (Dashboard 04). Save the root certificate it shows: it is shown only once. A PQCert certificate is verified with 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.

For offline certificate verification: keep the root certificate — for a PQCert CA as root-cert.json, for an X.509 CA as a .pem file — and pass it to ca.verifyCert(), ca.verifyX509Cert() or ca.verifyCrl() as shown in SDK 12.
SDK 01 Installation

Works in Node.js, Deno and Cloudflare Workers — run it on your backend. In a browser the code runs, but calls to the API only work from FIPSign's own domains (see the note at the top of this tab). TypeScript types included — no separate @types package needed. On Node.js use version 20 or newer: on Node 18 the key-generation functions and zes fail with crypto is not defined.

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

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

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

Using this key for an AI agent? Create it as an agent key — restricted scope (checkbox in the key creation form, Dashboard 03). It can only call 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.
Instantiate the client
// Simple form — just the API key
import { PQAuth } from 'fipsign-sdk'

const pq = new PQAuth('pqa_your_api_key')

// Or with all options (see SDK 11 for full reference):
// const pq = new PQAuth({
//   apiKey:      'pqa_your_api_key',
//   baseUrl:     'https://api.fipsign.dev', // default
//   localVerify: false,   // set to true for offline verification (SDK 04)
//   projectId:   undefined, // required when localVerify: true (SDK 04)
//   timeout:     10_000,  // request timeout in ms, default 10000
// })
SDK 02 sign() — Sign anything

Signs any payload with ML-DSA (44, 65, or 87 — configured per project at creation). The only required field is sub — any string identifying the entity. All other fields are stored in the payload and returned on verify. Token cost: see Costs.

User session
const { token, meta, usage } = await pq.sign({
  sub:              'user_123',
  email:            'user@example.com',
  role:             'admin',
  expiresInSeconds: 3600,    // optional — default: 1 hour
})
Payment order
const { token } = await pq.sign({
  sub:      'order_456',
  amount:   299.99,
  currency: 'USD',
  expiresInSeconds: 300,
})
Document certification
const { token } = await pq.sign({
  sub:      'doc_789',
  hash:     'sha256:abc...',
  signedBy: 'alice',
})
AI agent action
const { token } = await pq.sign({
  sub:     'agent_summarizer_v2',
  action:  'document:summarize',
  userId:  'user_123',
  traceId: 'trace_abc',
})
IoT device / firmware
const { token } = await pq.sign({
  sub:      'device_iot_001',
  firmware: '2.1.4',
  location: 'plant-A',
})
Response — full shape
{
  token: {
    payload: "eyJzdWIi...", // base64 encoded payload
    signature: "oi5UKsTn...", // ML-DSA signature
    algorithm: "ML-DSA-65", // ML-DSA-44 | ML-DSA-65 | ML-DSA-87
    issuedAt: 1778947233
  },
  meta: {
    algorithm: "ML-DSA-65", // ML-DSA-44 | ML-DSA-65 | ML-DSA-87
    standard: "NIST FIPS 204",
    quantumResistant: true,
    expiresIn: 3600, // seconds, as passed to sign()
    issuedFor: "dev@example.com", // your developer account email
    projectId: "proj_...",
    tokenCost: 1,
    source: "free" // "free" | "pack" | "free+pack"
  },
  usage: {
    freeRemaining: 9999,
    packRemaining: 0,
    totalRemaining: 9999,
    month: "2026-06"
  }
}
Monitor quota inline
const { token, meta, usage } = await pq.sign({ sub: 'user_123' })
console.log(`${usage.freeRemaining} free · ${usage.packRemaining} pack`)
console.log(`charged from: ${meta.source}`)  // "free" | "pack" | "free+pack"
console.log(`project: ${meta.projectId} · account: ${meta.issuedFor}`)
Payload limits: sub is required, max 128 characters. Other string fields max 256 characters. Maximum 10 custom fields total (not counting sub, email, role, and expiresInSeconds). Exceeding these returns API_ERROR 400.

expiresInSeconds range: when provided, must be a whole number between 60 and 86,400 seconds (24 hours). Outside this range, or with decimals such as 60.5, returns API_ERROR 400.
Reserved field names. Custom fields whose name starts with an underscore (_, for example _mandate) are reserved for the server. sign() throws PQAuthError with code API_ERROR, status 400 and message Custom fields starting with "_" are reserved.
SDK 03 verify() — Remote verification

Verifies the ML-DSA signature (algorithm read from the token itself), token expiry, and the revocation list. Never throws — always returns an object. 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
What result.failure says
failureWhat happenedWhat to do
'rejected'FIPSign checked the token and refuses it: revoked, expired, tampered with, from another project, malformed, or a Mandate tokenAnswer 401. The user must sign in again
'rate_limited'FIPSign did not check the token: too many requests (REST 10). retryAfter holds the seconds to waitAnswer 503 with Retry-After and try again after that time
'quota_exhausted'FIPSign did not check the token: no tokens left (free tokens and packs)Answer 503. Waiting will not fix it: buy a pack from the dashboard (Dashboard 09)
'unavailable'FIPSign did not check the token: network error, timeout, FIPSign server error, invalid API key, or an answer the SDK cannot readAnswer 503 and try again later. If result.error says the API key is invalid, fix the key
verify() never throws. On any failure it returns { valid: false, payload: null, error: "...", failure: "..." }. failure tells a token that FIPSign refuses from a check that did not happen (table above); retryAfter is only there with 'rate_limited'. What each error text means is in REST 04.

Token cost: see Costs. For high-throughput read paths without revocation checks, use local verification (SDK 04), which makes no API call.
Mandate tokens are refused. Passing a token from mandate.emit() returns { valid: false, error: 'This is a Mandate token. Verify it with POST /mandate/verify' }. Use pq.mandate.verify() (SDK 14), which also checks status, scope and budget.
SDK 04 verify() local — Offline, ~1ms

Enable localVerify: true to verify tokens entirely in memory — no API call, no network latency, no token cost.

const pq = new PQAuth({
  apiKey:      'pqa_your_api_key',
  localVerify: true,
  projectId:   'proj_...',  // required — from the dashboard or meta.projectId in /sign's response
})

// Optional: preload public key at startup to avoid first-request latency
await pq.preloadPublicKey()

const { valid, payload, local } = await pq.verify(token)
console.log(local) // true — verified without an API call
projectId is required when localVerify: true. Local verification rejects tokens issued for a different project — without projectId the constructor throws MISSING_PROJECT_ID immediately.

Why verify() says no: it never throws — it returns { valid: false, error, failure }. Decide on failure (SDK 03), not on the text of error. In local mode failure is 'rejected' for a bad signature, an expired token or another project's token, and 'unavailable' when the SDK could not download the public key because FIPSign could not be reached. A token from a different project gets exactly the same error text as one with a tampered signature — intentional, so the two cases are indistinguishable to whoever presented the token.
Key rotation: if the cached public key no longer matches the token's signature (e.g. after a server key rotation), the SDK automatically clears the cache, fetches the new key, and retries — no action needed on your end.
Same rule applies here. The apiKey above is still your full secret key — this only ever belongs on your own backend, never in a browser.
Important: Local verification does not check the revocation list. A revoked token will pass local verification if its signature is valid and it has not expired. Use remote verification for payments, admin actions, and any security-sensitive operation.
Local verification is for tokens from sign() only. It knows nothing about mandates, so a token from mandate.emit() is rejected: { valid: false, error: 'This is a Mandate token. Verify it with mandate.verify()' }. Verify those with pq.mandate.verify() (SDK 14), which checks the live status, scope and budget on the server.
SDK 05 revoke() — Revoke a token

Immediately and permanently invalidates a token. Future verify() calls will reject it even if the signature is valid and the token has not yet expired. 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'
Other revocation reasons
await pq.revoke(token, 'order cancelled')
await pq.revoke(token, 'suspicious activity detected')

const { valid } = await pq.verify(token)
console.log(valid) // false — error: 'Token has been revoked'
Idempotent: revoking an already-revoked token returns { success: true, message: 'Token was already revoked' } without consuming an extra token.

Expired tokens: calling revoke() on an already-expired token throws PQAuthError with code API_ERROR and status 400. Expired tokens cannot be submitted for revocation.
Mandate tokens cannot be revoked here. revoke() on a token from mandate.emit() throws PQAuthError with code API_ERROR, status 400: This is a Mandate token. Revoke it with PATCH /mandate/:id {"action":"revoke"}. Use pq.mandate.revoke(mandate.id) (SDK 14).
SDK 06 middleware() — Express

Reads Authorization: Bearer <token>, verifies the token, and attaches the decoded payload to req.user. Answers 401 when the token is missing or FIPSign refuses it, and 503 when FIPSign could not check it. Node.js only. It is an Express-style function ((req, res, next), answering with res.status(...).json(...)), so it works as it is in Express; for Fastify see below.

Token encoding: the Bearer value is base64(JSON.stringify(PQToken)) — not a JWT. The login endpoint encodes the token object, and the middleware decodes it. Clients that expect a JWT will not be compatible without adapting the encoding step.
import express from 'express'
import { PQAuth } from 'fipsign-sdk'

const app = express()
const pq  = new PQAuth('pqa_your_api_key')

app.use(express.json())

// Stand-in for your own user check. Replace it with your database lookup
// and a real password comparison (bcrypt, argon2…).
async function authenticate(email, password) {
  if (email === '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)
Try it
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>)"}
What the middleware answers
RequestAnswer
Valid tokennext() is called and req.user holds the payload
No Authorization: Bearer ... header401 { "error": "Authorization header required (Bearer <token>)" }
The Bearer value is not the base64 of a token401 { "error": "Invalid token format" }
The token is rejected: expired, revoked, tampered with, or from another project401 { "error": "<the text verify() returns>" }, for example Token has been revoked
FIPSign could not check the token: rate limit503 { "error": "Authentication service temporarily unavailable" } with a Retry-After: <seconds> header
FIPSign could not check the token: no tokens left, timeout, network error, FIPSign server error, invalid API key503 { "error": "Authentication service temporarily unavailable" } (no Retry-After)
A 503 does not mean the user's token is bad. Keep the user signed in and let the client try again (after Retry-After when it is there). The body does not say why FIPSign could not answer: to log the cause, call pq.verify(token) yourself and read error and failure (SDK 03).
req.user is the whole signed payload: your fields (sub, email, role...), iat and exp (Unix seconds), and _iss, the id of your FIPSign project, which FIPSign adds to every token. You can ignore _iss.

Each protected request runs verify(). In the default remote mode that is one call to POST /verify, 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.

The Bearer value is large, because it carries the whole signed token: about 4.5 KB with ML-DSA-44, 6 KB with ML-DSA-65 and 8.3 KB with ML-DSA-87 (for a payload like the one above). Node.js accepts headers up to 16 KB by default; if a proxy or load balancer sits in front of your server, check its limit (nginx's default is 8 KB per header line).
Fastify

Fastify cannot use an Express-style function as a hook: app.addHook('onRequest', pq.middleware()) fails when you register it, with FST_ERR_HOOK_INVALID_ASYNC_HANDLER. Use it through @fastify/express, which adds app.use() to Fastify.

// npm install fastify @fastify/express
import Fastify from 'fastify'
import fastifyExpress from '@fastify/express'
import { PQAuth } from 'fipsign-sdk'

const app = Fastify()
const pq  = new PQAuth('pqa_your_api_key')

await app.register(fastifyExpress)    // adds app.use() for Express-style middleware
app.use('/api', pq.middleware())

// The middleware sets user on Node's raw request, so in Fastify it is request.raw.user
app.get('/api/profile', async (request) => {
  return { user: request.raw.user }
})

await app.listen({ port: 3000 })
Where the user is. The middleware writes user on Node's raw request, so inside a Fastify handler it is request.raw.user (not request.user). Login and logout are the same as in the Express example: sign the token, then send Buffer.from(JSON.stringify(token)).toString('base64').
SDK 07 usage() — Token balance

Returns the current token balance, 6-month usage history, and purchased packs. 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'}`)
})
Free tokens reset on the 1st of each month (UTC). Unused free tokens do not carry over. Pack tokens never expire and accumulate across purchases. All projects under the same account share a single pool.

Per project: 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.
SDK 08 health() — Service status

Checks the health of the FIPSign service. Does not require an API key and does not consume tokens.

const { status, algorithm, quantumResistant, version } = await pq.health()

console.log(status)           // "ok"
console.log(algorithm)        // "ML-DSA-44/65/87"
console.log(quantumResistant) // true
console.log(version)          // e.g. "2.0.0"
HealthResult type: { status: string, algorithm: string, standard: string, quantumResistant: boolean, version: string }. The service field is also present in the raw response but not included in the typed HealthResult.

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

FIPSign fires webhook events automatically when you call sign(), verify(), or revoke(). The SDK triggers them — you do not call any method to enable them.

Configuration is dashboard-only. Webhook endpoints, secrets, and event subscriptions are managed from app.fipsign.dev → Webhooks (a section of your account, not of a project: see Dashboard 08). There are no SDK methods for webhook management.

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

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

verify(), ca.verifyCert(), 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)
  }
}
429: rate limit or no tokens left. err.serverCode is 'rate_limited' (wait err.retryAfter seconds, then retry) or 'token_quota_exhausted' (retrying will not help: buy a pack from the dashboard, Dashboard 09). err.retryAfter is in seconds and is undefined with any other error.
Retry once after a rate limit
import { PQAuthError } from 'fipsign-sdk'

async function signWithRetry(pq, claims) {
  try {
    return await pq.sign(claims)
  } catch (err) {
    if (err instanceof PQAuthError && err.serverCode === 'rate_limited') {
      // retryAfter: seconds until the one-minute window ends (from the Retry-After header)
      await new Promise(resolve => setTimeout(resolve, (err.retryAfter ?? 1) * 1000))
      return await pq.sign(claims)    // one retry; if it fails again, the error is thrown
    }
    throw err    // 'token_quota_exhausted' and every other error: waiting will not fix it
  }
}
SDK 11 Constructor options — full reference

All available options when instantiating PQAuth.

const pq = new PQAuth({
  apiKey:      'pqa_...',
  baseUrl:     'https://api.fipsign.dev',
  timeout:     10_000,
  localVerify: false,
  projectId:   'proj_...',  // required when localVerify: true
})
OptionTypeDefaultDescription
apiKeystring—Required. Must match pqa_ followed by 64 lowercase hex characters — constructor throws INVALID_API_KEY immediately if not.
baseUrlstringhttps://api.fipsign.devOverride for local development or self-hosted instances.
timeoutnumber10000Request timeout in milliseconds. Throws TIMEOUT on exceeded.
localVerifybooleanfalseWhen true, verify() runs in memory using a cached public key — no API call, no token cost. Does not check revocation.
projectIdstring—Required when localVerify: true — constructor throws MISSING_PROJECT_ID immediately if not. Get it from the dashboard or meta.projectId in any /sign response. Local verification rejects tokens issued for a different project: verify() returns { valid: false, error: 'Invalid signature — token was tampered with or not issued by this server' }.
SDK 12 ca — Certificate Authority

Issue and verify post-quantum certificates for devices, services, or any entity that needs a tamper-proof identity. Built on ML-DSA-65 (NIST FIPS 204) — independent of the ML-DSA variant configured for token signing in each project. Token cost of each method: see Costs.

Setup: Create the CA in the dashboard (Dashboard 04). Choose a certificate format:

  • PQCert — FIPSign's native JSON format. Certificates are JSON objects verified with ca.verifyCert(). Simpler to work with in JavaScript/TypeScript environments.
  • X.509 — Standard X.509 v3 PEM certificates signed with ML-DSA-65 (OID 2.16.840.1.101.3.4.3.18, RFC 9881). Compatible with OpenSSL 3.5+, standard PKI tooling, and enterprise infrastructure. Verified with ca.verifyX509Cert().

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

Save the root certificate now. It is shown only once and cannot be retrieved again. Without it, offline verification is not possible for any certificate issued by this CA, nor for its revocation list.
generateKeyPair() — Generate a key pair for a device
import { PQAuth, generateKeyPair } from 'fipsign-sdk'

const { publicKey, secretKey } = await generateKeyPair()
// publicKey: base64-encoded ML-DSA-65 public key  — 1952 bytes raw
// secretKey: base64-encoded ML-DSA-65 expanded key — 4032 bytes raw
// store secretKey securely on the device — never send it to the server
// pass publicKey to ca.issue() to obtain a certificate
secretKey format: the JS SDK returns the full 4032-byte ML-DSA-65 expanded key (not the 32-byte seed). If you are also using the Python SDK, note that the Python SDK returns the 32-byte seed instead — the two formats are not interchangeable.
ca.issue() — Issue a certificate
// ── PQCert CA ──────────────────────────────────────────────────────────────
const { certificate, meta, usage } = await pq.ca.issue({
  subject:          'device-serial-00123',
  publicKey:        devicePublicKey,            // base64 ML-DSA-65 public key
  expiresInSeconds: 365 * 24 * 60 * 60,          // required — whole number, min 60, max 157_680_000 (5 years)
  meta:             { model: 'lock-v2', batch: '2026-05' },  // optional, max 10 keys
})
// certificate is a PQCert JSON object
console.log(certificate.id)        // cert_...
console.log(certificate.caId)      // ca_... — the CA that signed it
console.log(certificate.expiresAt) // Unix timestamp
console.log(meta.certId)           // same as certificate.id
console.log(meta.caId)             // ca_...
console.log(meta.subject)          // 'device-serial-00123'
console.log(meta.format)           // 'pqcert'
console.log(meta.issuedAt)         // Unix timestamp
console.log(meta.expiresAt)        // Unix timestamp
console.log(meta.algorithm)        // 'ML-DSA-65'
console.log(meta.standard)         // 'NIST FIPS 204'
console.log(meta.caExpiry)         // null normally; { truncated: true, requestedExpiresInSeconds: N, resolvedExpiresInSeconds: N } if lifetime was truncated to fit CA root expiry

// ── X.509 CA ───────────────────────────────────────────────────────────────
// Note: publicKey must be exactly 1952 bytes (ML-DSA-65 public key).
// Note: meta is NOT supported for X.509 CAs — passing it returns API_ERROR 400.
const { certificate: certPem, meta, usage } = await pq.ca.issue({
  subject:          'device-serial-00123',
  publicKey:        devicePublicKey,
  expiresInSeconds: 365 * 24 * 60 * 60,
})
// certificate is a PEM string
console.log(typeof certPem)        // "string"
console.log(certPem)              // "-----BEGIN CERTIFICATE-----\n..."
console.log(meta.certId)           // cert_... — use this for revocation
console.log(meta.caId)             // ca_...
console.log(meta.format)           // 'x509'
console.log(meta.sizeNote)         // size advisory for IoT memory planning
For X.509: store meta.certId alongside the PEM certificate — you need it for ca.revokeCert() and ca.isCertRevoked().
ca.verifyCert() — Verify a PQCert certificate offline (synchronous)
import { readFileSync } from 'node:fs'
import { PQAuth } from 'fipsign-sdk'

const pq = new PQAuth('pqa_your_api_key')

// root-cert.json: the root certificate you saved when the CA was created
// device-cert.json: the certificate the device presents (a PQCert JSON object)
const rootCert   = JSON.parse(readFileSync('./root-cert.json', 'utf8'))
const deviceCert = JSON.parse(readFileSync('./device-cert.json', 'utf8'))

const result = pq.ca.verifyCert(deviceCert, rootCert)   // synchronous — no await

if (!result.valid) {
  // result.error is plain text for your logs: decide with result.valid, do not compare the text.
  // It is one of:
  //   'Expected a CA_CERT, got ...' / 'Expected a CA_ROOT, got ...'
  //   'Certificate was not issued by this CA'
  //   'Root CA certificate expired N seconds ago'
  //   'Certificate expired N seconds ago'
  //   'Invalid certificate signature — not issued by this CA'
  //   'Unknown error' (malformed input, for example a certificate that is not an object)
  console.error(result.error)
  throw new Error('Device not authorized')
}

console.log(result.cert.subject)   // 'device-serial-00123'
console.log(result.cert.expiresAt) // Unix timestamp
PQCert format only. For X.509 use ca.verifyX509Cert() instead. Does not check revocation.
ca.verifyX509Cert() — Verify an X.509 certificate offline (async)
// rootCertPem is the PEM string shown at CA creation — save it once
const result = await pq.ca.verifyX509Cert(deviceCertPem, rootCertPem)

if (!result.valid) {
  // Possible error messages:
  //   'Root CA certificate has expired'
  //   'Certificate has expired'
  //   'Invalid certificate signature — not signed by this root CA'
  //   'Unsupported signature algorithm: <OID>. Expected ML-DSA-65 (2.16.840.1.101.3.4.3.18)'
  //   'Unsupported root CA algorithm: <OID>. Expected ML-DSA-65 (2.16.840.1.101.3.4.3.18)'
  //   'Unexpected public key size: N bytes (expected 1952 or 1953 for ML-DSA-65)'
  //   'Unexpected signature size: N bytes (expected 3309 or 3310 for ML-DSA-65)'
  console.error(result.error)
  return reject('Device not authorized')
}

// result.cert is the PEM string of the verified certificate
console.log(result.cert) // "-----BEGIN CERTIFICATE-----\n..."
X.509 format only. Never throws — always returns { valid, cert? } or { valid: false, error }. Does not check revocation.
ca.isCertRevoked() — Look up a certificate in a CRL (synchronous)
const { crl } = await pq.ca.getCrl()

// PQCert CA — pass the certificate object
if (pq.ca.isCertRevoked(deviceCert, crl)) {
  return reject('Device certificate has been revoked')
}

// X.509 CA — pass the certId string from meta.certId
if (pq.ca.isCertRevoked(meta.certId, crl)) {
  return reject('Device certificate has been revoked')
}
Only the lookup is offline. 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.
ca.getCrl() — Get the Certificate Revocation List
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
}
The SDK normalizes the CRL response — 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).
ca.verifyCrl() — Check that the revocation list was signed by your CA (async)
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')
PQCert and X.509. Pass the root certificate of the CA: the 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.
What 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.
ca.getCert() — Get a certificate by ID
const { certificate, status, meta } = await pq.ca.getCert('cert_...')

console.log(status.revoked)   // boolean
console.log(status.expired)   // boolean
console.log(status.revokedAt) // Unix timestamp or null
console.log(status.expiresAt) // Unix timestamp

// For PQCert CAs, certificate is a PQCert object
// For X.509 CAs, certificate is a PEM string
// For X.509 CAs, meta contains additional fields:
if (meta) {
  console.log(meta.certId)    // cert_...
  console.log(meta.caId)      // ca_...
  console.log(meta.subject)   // 'device-serial-00123'
  console.log(meta.format)    // 'x509'
  console.log(meta.algorithm) // 'ML-DSA-65'
}
ca.revokeCert() — Revoke a certificate
const { certId, revokedAt, reason, usage } = await pq.ca.revokeCert(
  'cert_...',
  'device decommissioned'
)

console.log(certId)              // cert_...
console.log(revokedAt)           // Unix timestamp
console.log(reason)              // 'device decommissioned' (or null if omitted)
console.log(usage.freeRemaining) // tokens remaining after this operation
Not idempotent: revoking an already-revoked certificate throws PQAuthError with code API_ERROR and status 409. This is different from revoke() on tokens, which is idempotent.
Full device lifecycle — PQCert
import { readFileSync } from 'node:fs'
import { PQAuth, generateKeyPair } from 'fipsign-sdk'

const pq = new PQAuth('pqa_your_api_key')
// root-cert.json: the root certificate you saved when the CA was created
const rootCert = JSON.parse(readFileSync('./root-cert.json', 'utf8'))

// 1. Factory: generate a key pair for the device
const { publicKey, secretKey } = await generateKeyPair()

// 2. Factory: issue a certificate for the device
const { certificate } = await pq.ca.issue({
  subject:          'lock-serial-00123',
  publicKey,
  expiresInSeconds: 365 * 24 * 60 * 60,
  meta:             { model: 'lock-v3', batch: '2026-05' },
})
// store certificate (PQCert JSON object) and secretKey on the device

// 3. At runtime: verify the device certificate offline
const result = pq.ca.verifyCert(certificate, rootCert)
if (!result.valid) throw new Error(result.error)

// 4. 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')
Full device lifecycle — X.509
import { PQAuth, generateKeyPair } from 'fipsign-sdk'

const pq          = new PQAuth('pqa_your_api_key')
const rootCertPem = process.env.ROOT_CERT_PEM  // PEM saved at CA creation

// 1. Factory: generate a key pair for the device
const { publicKey, secretKey } = await generateKeyPair()

// 2. Factory: issue a certificate for the device
const { certificate: certPem, meta } = await pq.ca.issue({
  subject:          'lock-serial-00123',
  publicKey,
  expiresInSeconds: 365 * 24 * 60 * 60,
})
// store certPem (PEM string), meta.certId, and secretKey on the device

// 3. At runtime: verify the device certificate offline
const result = await pq.ca.verifyX509Cert(certPem, rootCertPem)
if (!result.valid) throw new Error(result.error)

// 4. 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')
SDK 13 zes — Zero-Exposure Signing

Hash sensitive data locally and sign only the hash — the original data never reaches the API. Convenience wrapper over sign()/verify(); see REST 03b for the full explanation of the pattern.

zes.sign() — hash and sign
const { token, hash } = await pq.zes.sign({
  patient:   'Jane Doe',
  diagnosis: 'confidential',
  record_id: 'MR-2026-4491',
})
// hash: the 64-char SHA-256 hex digest that was actually signed
// token.payload decodes to { sub: 'zes:<hash>', zes: true, iat, exp }
zes.verify() — re-hash and confirm
const { valid, dataMatches } = await pq.zes.verify(token, {
  patient:   'Jane Doe',
  diagnosis: 'confidential',
  record_id: 'MR-2026-4491',
})
if (!valid || !dataMatches) return reject('invalid or tampered')
When valid is false, failure and retryAfter say why, exactly as in verify() (SDK 03): 'rejected' means FIPSign refuses the token; any other value means the check did not happen. dataMatches is false whenever valid is false.
Hashing is deterministic regardless of key order — keys are sorted recursively (nested objects too) before hashing, so the same logical data always produces the same hash no matter how the object was constructed.

revoke(), middleware(), and local verification all work unchanged — a ZES token is a regular token as far as they're concerned. There is no zes.revoke(): use pq.revoke(token) directly, same as any other token. zes.verify() also respects localVerify: true — it calls the same verify() internally, so it inherits offline verification with the same trade-off (no revocation check).
SDK 14 mandate — Agent authorization

Bounded, revocable authorization for AI agents, IoT devices, and automated services. Thin typed wrapper over POST/PATCH/GET /mandate — see Mandate 00 for the full explanation of the immutable/mutable layers, lifecycle, and budget semantics. No dashboard setup needed beyond an API key — unlike CA, there's no separate resource to create first. Token cost of each method: see Costs.

Who runs what
WhoHoldsCalls
Your backendProject 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 keygenerateAgentKeyPair() once, then signAgentCall() for every call. No API key needed: nothing is sent to FIPSign.
Whoever performs the action (your executor — or the agent itself)An API key and the mandate tokenmandate.verify(), right before each action. If it is the agent itself, give it an agent key (restricted scope), never a full-access key.
Whoever checks the evidence (you later, an auditor)The saved receipts or export, and the fingerprint of the keyverifyMandateReceipt(), verifyMandateExport(). No API key needed: nothing is sent to FIPSign.
Recommended: your own executor calls mandate.verify() and works out cost itself, so the agent never holds an API key and the budget can be trusted. Letting the agent call verify() itself also works — Mandate 00b explains both architectures and what each one protects.
mandate.emit() — issue a mandate
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.

mandate.verify() — check authorization before the agent acts
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."

Agent-scoped keys and this section. If the API key used to instantiate pq is an agent key (restricted scope), only mandate.verify() above will work. mandate.emit(), narrow(), suspend(), resume(), revoke(), get(), list() and 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.
The fourth argument of 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.
You decide cost. The third argument of verify() is enforced exactly as given — FIPSign does not know what an action really costs. Compute it in the code that performs the action, not in the agent. See Mandate 00b.

A timeout does not mean "not applied". When no usable answer arrives (a timeout, a network error, an answer the SDK cannot read, a FIPSign server error) the result is { 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.
When a call is not granted — failure
failureWhat happenedAnything 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 malformedNoDo 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 waitNoDo 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 invalidNoDo 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 errorMaybe: the call may have been granted and chargedDo 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()
It is always 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.
Proof of possession with the SDK
What it is. By default a mandate is a bearer credential: whoever holds the token and an API key of your project can use it. With proof of possession the agent has its own key pair and must sign every call with the private key, which never leaves the agent — a copied token is then useless. Use it when the token is handled by code you do not fully control (an agent runtime, a device in the field, a browser). You do not need it when only your own backend ever holds the token. It costs no platform tokens: the signing is local.

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

Node 20 or newer for key generation: on Node 18 generateAgentKeyPair() fails with crypto is not defined.
Step 1 — The agent generates its key pair (once)
import { generateAgentKeyPair } from 'fipsign-sdk'

// On the agent, once. The default variant is ML-DSA-65.
const { publicKey, secretKey, algorithm } = await generateAgentKeyPair()
// Another variant: await generateAgentKeyPair({ algorithm: 'ML-DSA-44' })  // or 'ML-DSA-87'

// publicKey → goes to your backend, for mandate.emit({ agentPublicKey })
// secretKey → stays on the agent, in a secret store. Never send it anywhere.
VariantpublicKeysecretKeySignature
ML-DSA-441312 bytes2560 bytes2420 bytes
ML-DSA-65 (default)1952 bytes4032 bytes3309 bytes
ML-DSA-872592 bytes4896 bytes4627 bytes
Any of the three variants works, whatever the project's algorithm is: the agent's key is independent of the project and of the CA. ML-DSA-44 has the smallest keys and signatures; ML-DSA-87 the highest security level. To choose one, pass its name exactly as written in the first column: generateAgentKeyPair({ algorithm: 'ML-DSA-44' }). Any other text ('ml-dsa-44', 'ML-DSA44'…) throws UNSUPPORTED_ALGORITHM.

Use generateAgentKeyPair() for agents and generateKeyPair() only for CA certificates. ca.issue() accepts ML-DSA-65 keys only, so a key of another variant is rejected there (HTTP 400, nothing is charged).

Keep secretKey secret. Store it in a secret store (or a file only the agent's user can read), never log it, never send it anywhere. If it is lost or may have leaked, revoke the mandate and emit a new one with a new key pair: the public key of a mandate can never be changed.

Key format. secretKey is the full expanded private key (2560, 4032 or 4896 bytes, base64), not the 32-byte seed. The recipes in Mandate 02b and the Python SDK store the 32-byte seed instead: the two formats are not interchangeable, and signAgentCall() rejects a seed with INVALID_SECRET_KEY. Generate and sign with the same tool.
Step 2 — Your backend emits the mandate with the public key
const { mandate } = await pq.mandate.emit({
  agentId:          'agent-reporting-v2',
  issuedBy:         '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.

Step 3 — The agent signs every call
import { signAgentCall } from 'fipsign-sdk'

// On the agent, for EVERY call: no API key needed, nothing is sent to FIPSign.
const agentSignature = await signAgentCall({
  mandate: mandateToken,   // the mandate token (or just the mandate id)
  action:  'send_reply',   // exactly what mandate.verify() will receive
  cost:    1,              // exactly what mandate.verify() will receive
  secretKey,               // from generateAgentKeyPair()
})
// Send agentSignature to whoever calls mandate.verify() — your executor, or the agent itself
With the SDK you do not write the algorithm: leave it out. signAgentCall() reads the variant from the size of secretKey, signs with it and fills in agentSignature.algorithm itself ('ML-DSA-44', 'ML-DSA-65' or 'ML-DSA-87'). Send the object it returns as it is; do not edit it. If you pass algorithm anyway (it is only a safety check), it must be exactly one of those three texts, capitals and hyphens included: anything else throws UNSUPPORTED_ALGORITHM, and a valid text that is not the variant of secretKey throws INVALID_SECRET_KEY. You write the text yourself only when you call the REST API by hand: see the table in Mandate 02b, Step 1.

action and cost must be exactly what you pass to verify(). Change one and you must sign again, otherwise verify() answers agent_signature_mismatch.

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

Clocks. The signature is stamped with the clock of the machine that runs signAgentCall(). The server accepts a signature stamped up to 30 seconds ahead of its own clock, so with the default lifetime the agent's clock may be off by about 30 seconds either way. Keep clocks synchronized (NTP). On devices where you cannot, pass expiresInSeconds: 60: it tolerates an agent clock up to 60 seconds behind.
Step 4 — Verify with the signature
const check = await pq.mandate.verify(mandateToken, 'send_reply', 1, { agentSignature })

if (check.result !== 'granted') {
  // check.reason also covers the agent's signature:
  // 'agent_signature_required' | 'agent_signature_invalid' |
  // 'agent_signature_mismatch' | 'agent_signature_replayed'
  return reject(check.reason)
}
doTheAction()
If the answer is lost (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.
When something goes wrong
You seeTypical causeFix
agent_signature_required (in reason)verify() was called without { agentSignature } on a mandate emitted with agentPublicKey.Sign the call and pass it as the 4th argument.
agent_signature_invalidSigned with a different key than the mandate's agentPublicKey; lifetime above 60 seconds; signature already expired; agent clock more than 30 seconds ahead of the server.Sign with the secretKey of the pair whose publicKey you emitted; keep the default lifetime; synchronize clocks.
agent_signature_mismatchThe action or cost that was signed differ from the ones passed to verify().Sign exactly the values you verify. Change one and you must sign again.
agent_signature_replayedThis exact signature was already accepted.Sign again for every call.
PQAuthError INVALID_ARGUMENTsignAgentCall() got a missing or invalid mandate, action (non-empty), cost (whole number ≥ 0), secretKey or expiresInSeconds (≥ 1).Fix the argument; err.message names it.
PQAuthError INVALID_SECRET_KEYsecretKey is not valid base64, is not an ML-DSA private key (for example a 32-byte seed) or does not match algorithm.Use the secretKey returned by generateAgentKeyPair() and omit algorithm.
PQAuthError UNSUPPORTED_ALGORITHMThe algorithm name given to generateAgentKeyPair() or signAgentCall() is unknown.Use ML-DSA-44, ML-DSA-65 or ML-DSA-87, written exactly like that (capitals, hyphens, no spaces) — or omit it when signing.
import { PQAuthError, signAgentCall } from 'fipsign-sdk'

try {
  await signAgentCall({ mandate: mandateToken, action: 'send_reply', cost: 1, secretKey })
} catch (err) {
  if (err instanceof PQAuthError) console.error(err.code, err.message)
}
Complete example

Generates a key pair, emits a mandate that requires it and runs the four interesting cases against the live API. Save as pop-sdk-demo.mjs, run npm install fipsign-sdk, then FIPSIGN_API_KEY=pqa_... node pop-sdk-demo.mjs (Node 20+). 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)
Expected output
emitted mdt_… · requiresAgentSignature: true
no signature   → denied agent_signature_required
with signature → granted · budgetRemaining: 9
same signature → denied agent_signature_replayed
cost changed   → denied agent_signature_mismatch
mandate.narrow() / suspend() / resume() / revoke() — control the mandate
await pq.mandate.narrow(mandate.id, ['read:crm'])  // shrink scope — monotonic, cannot re-widen
await pq.mandate.suspend(mandate.id)             // pause — verify() denies with 'mandate_suspended' while suspended
await pq.mandate.resume(mandate.id)              // reactivate a suspended mandate
await pq.mandate.revoke(mandate.id)              // permanent — irreversible, no narrow/suspend/resume after this
What narrow() / suspend() / resume() / revoke() return
const patched = await pq.mandate.suspend(mandate.id)
console.log(patched.status, patched.scope, patched.updatedAt)
// 'suspended' [ 'sign', 'verify', 'read:crm' ] 1785691318
narrow(), suspend(), resume() and revoke() return { success, id, status, scope, updatedAt, 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.
Suspension is checked before budget. If a mandate is both suspended and out of budget, verify() always returns reason: 'mandate_suspended' — the backend checks status before it ever looks at the budget counter.
mandate.get() / mandate.list() / mandate.listAll() — inspect state
const { mandate } = await pq.mandate.get('mdt_...')
console.log(mandate.budgetConsumed, mandate.budgetRemaining, mandate.scopeCurrent, mandate.status)

// First page: the 50 most recent mandates of this project
const { mandates, count, nextCursor } = await pq.mandate.list()

// Your own page size (1–100), then the next page
const page1 = await pq.mandate.list({ limit: 20 })
const page2 = page1.nextCursor
  ? await pq.mandate.list({ limit: 20, cursor: page1.nextCursor })
  : null

// Every mandate, page by page — stop whenever you want with break
for await (const m of pq.mandate.listAll()) {
  console.log(m.id, m.status, m.budgetConsumed)
}
list() returns one page — up to 50 mandates by default (limit: 1–100), most recent first. nextCursor is a string while more pages exist and null on the last one: pass it back as cursor, exactly as received. count is the size of this page, not a total (the API has no total). A page can hold fewer than limit mandates — even none — while nextCursor is not null, so drive your loop with the cursor, never with the page size. listAll() does exactly that for you, one page at a time. Keep the id of every mandate you emit rather than listing to find it.

Expired mandates. There is no expired status: check expiresInSeconds (it is 0 once the mandate has expired). An expired mandate can still be read for 24 hours, then get() throws PQAuthError with status 404.

Errors in the control methods. narrow(), suspend(), resume(), revoke(), get() and emit() throw PQAuthError with code API_ERROR and the HTTP status: 409 when the state does not allow the change (already revoked, expired, not suspended), 404 when the mandate does not exist, 400 for invalid input. Revoking twice returns 409 — treat it as success when retrying.

Errors in the reads of the log and the keys. 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.
Receipts and the audit log — prove what an agent was allowed to do

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.

Step 1 — Ask for the receipt and tag the call with your own id
// 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.

A missing receipt is never an error. The call is decided and recorded before the receipt is made, so the event is in the log either way. Save the receipt when it is there: FIPSign does not keep it and cannot make the same one again.

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))
Step 2 — Save the fingerprint of your project's key (once)

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>
Keep the 64 characters with your own configuration (in this guide, the environment variable 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.
Step 3 — Check a receipt
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.

A valid receipt proves what FIPSign recorded and when. It does not prove that your service made the request (Mandate 12).
Step 4 — Read the log
// 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.

An event has 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.
Step 5 — Export the log and check it
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.
What the two checks return
FieldInMeaning
validbothtrue 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.
problemsbothA list of texts: what does not check out. Empty when valid is true.
keyTrustboth'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, eventreceiptWhat the receipt says it is about, when it could be read.
completeexportThe 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, lastSeqexportHow many events were checked, and the seq of the last one.
checkpoints, sealedThrough, headCheckedexportHow 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.
notesexportWhat is not wrong but limits what was checked, for example: the export starts at event 40.
A changed receipt does not pass

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)' ]
Complete example

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)
Expected output
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
Keep what you need to keep. An event stays in the log for 365 days from the moment it was recorded (the log of a mandate that was deleted is still there for that time). FIPSign does not keep your receipts: export the log, or keep the receipts, of what you need to prove for longer. The log is a record: nothing in it grants, denies or changes anything.
Python

Python SDK Reference

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

PY 01 Installation

Works in Flask, FastAPI, Django, scripts, and any Python 3.9+ environment. Type hints included — no separate stubs needed. cryptography>=48.0.0 is included as an automatic dependency — no extra install needed for CA operations or key generation.

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

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

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

Using this key for an AI agent? Create it as an agent key — restricted scope (checkbox in the key creation form, Dashboard 03). It can only call 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.
Instantiate the client
from fipsign import PQAuth

pq = PQAuth("pqa_your_api_key")

# Or with all options (see PY 10 for full reference):
# pq = PQAuth(
#     api_key="pqa_your_api_key",
#     base_url="https://api.fipsign.dev",  # default
#     timeout=10,                          # seconds, default 10
#     session=None,                        # custom requests.Session
# )
PY 02 sign() — Sign anything

Signs any payload with ML-DSA (44, 65, or 87 — configured per project at creation). The only required argument is sub — any string identifying the entity. All other keyword arguments are stored in the payload and returned on verify. Token cost: see Costs.

User session
result = pq.sign("user_123", email="user@example.com", role="admin", expires_in_seconds=3600)
token  = result.token
meta   = result.meta
usage  = result.usage
Payment order
result = pq.sign("order_456", amount=299.99, currency="USD", expires_in_seconds=300)
Document certification
result = pq.sign("doc_789", hash="sha256:abc...", signed_by="alice")
AI agent action
result = pq.sign(
    "agent_summarizer_v2",
    action="document:summarize",
    user_id="user_123",
    trace_id="trace_abc",
)
IoT device / firmware
result = pq.sign("device_iot_001", firmware="2.1.4", location="plant-A")
Monitor quota inline
result = pq.sign("user_123")
print(f"{result.usage.freeRemaining} free · {result.usage.packRemaining} pack")
print(f"charged from: {result.meta.source}")  # "free" | "pack" | "free+pack"
print(f"project: {result.meta.projectId} · account: {result.meta.issuedFor}")
Response shape
SignResult
  .token   PQToken
              .payload     str   # base64 encoded payload
              .signature  str   # ML-DSA signature
              .algorithm  str   # "ML-DSA-44" | "ML-DSA-65" | "ML-DSA-87"
              .issuedAt   int   # Unix timestamp
  .meta    SignMeta
              .algorithm       str
              .standard        str   # "NIST FIPS 204"
              .quantumResistant bool
              .expiresIn       int   # seconds
              .issuedFor       str   # your developer account email
              .projectId       str
              .tokenCost       int   # always 1
              .source          str   # "free" | "pack" | "free+pack"
  .usage   SignUsage
              .freeRemaining   int
              .packRemaining   int
              .totalRemaining  int
              .month           str   # e.g. "2026-06"
Payload limits: sub is required, max 128 characters. Other string fields max 256 characters. Maximum 10 custom keyword arguments (not counting expires_in_seconds). Exceeding these returns PQAuthError(code="API_ERROR", status=400).

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

expires_in_seconds range: when provided, must be a whole number between 60 and 86,400 seconds (24 hours). Outside this range, or with decimals such as 60.5, raises PQAuthError(code="API_ERROR", status=400).
Reserved field names. Custom fields whose name starts with an underscore (_, for example _mandate) are reserved for the server. sign() raises PQAuthError(code="API_ERROR", status=400) with the message Custom fields starting with "_" are reserved.
PY 03 verify() — Verify a token

Verifies the ML-DSA signature (algorithm read from the token itself), token expiry, and the revocation list. Never raises — always returns a VerifyResult. 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
What result.failure says
failureWhat happenedWhat to do
"rejected"FIPSign checked the token and refuses it: revoked, expired, tampered with, from another project, malformed, or a Mandate tokenAnswer 401. The user must sign in again
"rate_limited"FIPSign did not check the token: too many requests (REST 10). retry_after holds the seconds to waitAnswer 503 with Retry-After and try again after that time
"quota_exhausted"FIPSign did not check the token: no tokens left (free tokens and packs)Answer 503. Waiting will not fix it: buy a pack from the dashboard (Dashboard 09)
"unavailable"FIPSign did not check the token: network error, timeout, FIPSign server error, invalid API key, or an answer the SDK cannot readAnswer 503 and try again later. If result.error says the API key is invalid, fix the key
verify() never raises. On any failure it returns VerifyResult(valid=False, payload=None, error="...", failure="..."). failure tells a token that FIPSign refuses from a check that did not happen (table above); retry_after is only there with "rate_limited". What each error text means is in REST 04.

No local verify: the Python SDK always verifies remotely. There is no in-memory local verification mode — unlike the JS SDK which has localVerify: true. Token cost: see Costs.
Mandate tokens are refused. Passing a token from mandate.emit() returns VerifyResult(valid=False, error="This is a Mandate token. Verify it with POST /mandate/verify"). Use pq.mandate.verify() (PY 13), which also checks status, scope and budget.
PY 04 revoke() — Revoke a token

Immediately and permanently invalidates a token. Future verify() calls will reject it even if the signature is valid and it hasn't expired. 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"
Other revocation reasons
pq.revoke(token, "order cancelled")
pq.revoke(token, "suspicious activity detected")
Idempotent: revoking an already-revoked token returns RevokeResult(success=True, message="Token was already revoked") without consuming an extra token.

Expired tokens: calling revoke() on an already-expired token raises PQAuthError(code="API_ERROR", status=400). Expired tokens cannot be submitted for revocation.
Mandate tokens cannot be revoked here. revoke() on a token from mandate.emit() raises PQAuthError(code="API_ERROR", status=400): This is a Mandate token. Revoke it with PATCH /mandate/:id {"action":"revoke"}. Use pq.mandate.revoke(mandate.id) (PY 13).
PY 05 Flask & FastAPI middleware

Reads Authorization: Bearer <token> and attaches the decoded payload to the request context. Returns 401 when the token is missing or FIPSign refuses it, and 503 when FIPSign could not check it.

Token encoding: the Bearer value is base64(json.dumps(token.to_dict())) — not a JWT. The login endpoint encodes the PQToken object, and the middleware decodes it. Clients that expect a JWT will not be compatible without adapting the encoding step.
Flask
from flask import Flask, g, request
from fipsign import PQAuth, flask_middleware
import base64, json
from fipsign.types import PQToken

app  = Flask(__name__)
pq   = PQAuth("pqa_your_api_key")
auth = flask_middleware(pq)

# Stand-in for your own user check. Replace it with your database lookup
# and a real password comparison (bcrypt, argon2…).
def authenticate(email, password):
    if email == "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}
FastAPI
from fastapi import FastAPI, Depends
from fipsign import PQAuth, fastapi_middleware

app          = FastAPI()
pq           = PQAuth("pqa_your_api_key")
require_auth = fastapi_middleware(pq)

@app.get("/api/profile")
def profile(user=Depends(require_auth)):
    return {"sub": user["sub"], "role": user.get("role")}
What the middleware answers
RequestFlask answerFastAPI answer
Valid tokenyour function runs; g.fipsign_user holds the payloadyour function runs; user is the payload
No Authorization: Bearer ... header401 {"error": "Authorization header required (Bearer <token>)"}401 {"detail": "Authorization header required"}
The Bearer value is not the base64 of a token401 {"error": "Invalid token format"}401 {"detail": "Invalid token format"}
The token is rejected: expired, revoked, tampered with, or from another project401 {"error": "<the text verify() returns>"}401 {"detail": "<the text verify() returns>"}
FIPSign could not check the token: rate limit503 {"error": "Authentication service temporarily unavailable"} with a Retry-After: <seconds> header503 {"detail": "Authentication service temporarily unavailable"} with a Retry-After: <seconds> header
FIPSign could not check the token: no tokens left, timeout, network error, FIPSign server error, invalid API key503 {"error": "Authentication service temporarily unavailable"} (no Retry-After)503 {"detail": "Authentication service temporarily unavailable"} (no Retry-After)
A 503 does not mean the user's token is bad. Keep the user signed in and let the client try again (after Retry-After when it is there). The body does not say why FIPSign could not answer: to log the cause, call pq.verify(token) yourself and read error and failure (PY 03). The Flask decorator and the FastAPI dependency both answer this way. flask_middleware(pq) returns a decorator, so put @auth under @app.route on every route you want protected.

The payload (g.fipsign_user / user) is the whole signed payload: your fields (sub, email, role...), iat and exp (Unix seconds), and _iss, the id of your FIPSign project, which FIPSign adds to every token. You can ignore _iss.

Each protected request calls POST /verify, which 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).
PY 06 Async client

All methods are identical to PQAuth but async. Use in FastAPI, aiohttp, or any asyncio-based application. Requires pip install fipsign-sdk[async].

import asyncio
import json
from fipsign.async_client import AsyncPQAuth
from fipsign import generate_key_pair
from fipsign.types import PQCert

async def main():
    async with AsyncPQAuth("pqa_your_api_key") as pq:
        signed = await pq.sign("user_123", role="admin", expires_in_seconds=3600)
        v      = await pq.verify(signed.token)
        print(v.valid, v.payload["sub"])

        # CA operations — network calls are async
        kp   = generate_key_pair()  # generate_key_pair() is synchronous
        cert = await pq.ca.issue(
            subject="device-serial-00123",
            public_key=kp.publicKey,
            expires_in_seconds=365 * 24 * 60 * 60,
        )
        crl = await pq.ca.get_crl()

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

Returns the current token balance, 6-month usage history, and purchased packs. 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'}")
Free tokens reset on the 1st of each month (UTC). Unused free tokens do not carry over. Pack tokens never expire and accumulate across purchases. All projects under the same account share a single pool.

Per project: 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.
PY 08 webhooks — Event notifications

FIPSign fires webhook events automatically when you call sign(), verify(), or revoke(). The SDK triggers them — you do not call any method to enable them.

Configuration is dashboard-only. Webhook endpoints, secrets, and event subscriptions are managed from app.fipsign.dev → Webhooks (a section of your account, not of a project: see Dashboard 08). There are no SDK methods for webhook management.

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

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

Use verify_webhook_signature() from fipsign.middleware to verify the HMAC-SHA256 signature on incoming webhook requests. Each POST from FIPSign includes X-PQAuth-Signature (sha256=...), X-PQAuth-Event, and X-PQAuth-Timestamp headers.

from fipsign.middleware import verify_webhook_signature

# Flask
@app.route("/webhooks/fipsign", methods=["POST"])
def webhook():
    from flask import request
    sig = request.headers.get("X-PQAuth-Signature", "")
    if not verify_webhook_signature(request.data, sig, FIPSIGN_WEBHOOK_SECRET):
        return "Invalid signature", 401
    event = request.json
    kind  = event["event"]
    if kind == "token.signed":
        pass
    elif kind == "token.rejected":
        pass
    elif kind == "token.revoked":
        pass
    elif kind == "limit.warning":
        pass
    elif kind == "limit.reached":
        pass
    return "ok", 200

# FastAPI
from fastapi import Request, HTTPException
@app.post("/webhooks/fipsign")
async def webhook(request: Request):
    body = await request.body()
    sig  = request.headers.get("X-PQAuth-Signature", "")
    if not verify_webhook_signature(body, sig, FIPSIGN_WEBHOOK_SECRET):
        raise HTTPException(status_code=401, detail="Invalid signature")
    event = await request.json()
    return "ok"
Store the webhook secret securely. It is shown only once at registration time and cannot be retrieved from the dashboard afterwards.
PY 09 Error handling

verify() never raises — it returns VerifyResult(valid=False, error="...", failure="...") on any failure (PY 03). All other methods raise PQAuthError.

from fipsign import PQAuth, PQAuthError

try:
    result = pq.sign("user_123")
except PQAuthError as err:
    if err.code == "INVALID_API_KEY":    # key missing or doesn't match pqa_ + 64 hex chars
        pass
    elif err.code == "API_ERROR":        # server returned an error (check err.status)
        if err.status == 429:
            # err.server_code is "rate_limited" (wait err.retry_after seconds)
            # or "token_quota_exhausted"
            pass
    elif err.code == "TIMEOUT":          # request exceeded timeout (default: 10s)
        pass
    elif err.code == "NETWORK_ERROR":    # connection failed
        pass
    elif err.code == "MISSING_SUB":      # sign() called without sub
        pass
    print(err.code, err.message, err.status)
err.statusMeaning
400Invalid parameters — expires_in_seconds out of range or with decimals, invalid public key, meta passed to X.509 CA, expired token submitted for revocation
401API key missing or invalid
404Resource not found — no active CA for the project, certificate does not exist
409Conflict — revoking an already-revoked certificate
429Token quota exhausted or rate limit exceeded. err.server_code says which: "rate_limited" (wait err.retry_after seconds, then retry) or "token_quota_exhausted" (retrying will not help)
Retry once after a rate limit
import time
from fipsign import PQAuthError

def sign_with_retry(pq, sub):
    try:
        return pq.sign(sub)
    except PQAuthError as err:
        if err.server_code == "rate_limited":
            # retry_after: seconds until the one-minute window ends (from the Retry-After header)
            time.sleep(err.retry_after or 1)
            return pq.sign(sub)    # one retry; if it fails again, the error is raised
        raise    # "token_quota_exhausted" and every other error: waiting will not fix it
PY 10 Constructor options
pq = PQAuth(
    api_key="pqa_...",                    # required — pqa_ + 64 lowercase hex chars
    base_url="https://api.fipsign.dev",   # optional, override for self-hosting
    timeout=10,                           # optional, seconds (default: 10)
    session=None,                         # optional, custom requests.Session
)
OptionTypeDefaultDescription
api_keystr—Required. Must match pqa_ followed by 64 lowercase hex characters. Raises INVALID_API_KEY immediately if the format doesn't match.
base_urlstrhttps://api.fipsign.devOverride for local dev or self-hosted instances.
timeoutfloat10Request timeout in seconds (not milliseconds — unlike the JS SDK). Raises TIMEOUT on exceeded.
sessionrequests.SessionNoneCustom session for proxies, custom TLS, or testing.
PY 11 ca — Certificate Authority

Issue post-quantum certificates for devices, services, or any entity that needs a tamper-proof identity. Built on ML-DSA-65 (NIST FIPS 204) — independent of the ML-DSA variant configured for token signing in each project. Token cost of each method: see Costs.

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

Save the root certificate now. It is shown only once at CA creation. Without it, offline verification of certificates and of the revocation list is not possible.
generate_key_pair() — Generate a key pair for a device
from fipsign import generate_key_pair

kp = generate_key_pair()
# kp.publicKey — base64(1952 bytes raw public key) — pass to ca.issue()
# kp.secretKey — base64(32 bytes, seed form) — store securely on the device
secretKey format: the Python SDK returns the 32-byte ML-DSA-65 seed, not the 4032-byte expanded key returned by the JS SDK's generateKeyPair(). The formats are not interchangeable. Use this function when the device runs Python; use the JS SDK's generateKeyPair() if the device runs JavaScript.

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

To sign from Python using the returned secretKey:
from cryptography.hazmat.primitives.asymmetric.mldsa import MLDSA65PrivateKey
import base64

private_key = MLDSA65PrivateKey.from_seed_bytes(base64.b64decode(kp.secretKey))
signature   = private_key.sign(message)   # bytes — 3309 bytes for ML-DSA-65
ca.issue() — Issue a certificate
# ── PQCert CA ──────────────────────────────────────────────────────────────
result = pq.ca.issue(
    subject="device-serial-00123",
    public_key=kp.publicKey,
    expires_in_seconds=365 * 24 * 60 * 60,  # required — whole number, min 60, max 157_680_000 (5 years)
    meta={"model": "lock-v2", "batch": "2026-05"},  # optional, max 10 keys
)
# result.certificate is a PQCert dataclass
print(result.certificate.id)         # cert_...
print(result.certificate.caId)       # ca_...
print(result.certificate.expiresAt)  # Unix timestamp
print(result.meta.certId)            # same as certificate.id
print(result.meta.format)            # "pqcert"
print(result.meta.caExpiry)          # None normally; CaExpiry(truncated=True, requestedExpiresInSeconds=N, resolvedExpiresInSeconds=N) if lifetime was truncated to fit CA root expiry

# ── X.509 CA ───────────────────────────────────────────────────────────────
# Note: publicKey must be exactly 1952 bytes (ML-DSA-65 public key).
# Note: meta is NOT supported for X.509 CAs — passing it returns API_ERROR 400.
result = pq.ca.issue(
    subject="device-serial-00123",
    public_key=kp.publicKey,
    expires_in_seconds=365 * 24 * 60 * 60,
)
# result.certificate is a PEM string
print(type(result.certificate))   # <class 'str'>
print(result.certificate[:27])    # "-----BEGIN CERTIFICATE-----"
print(result.meta.certId)         # cert_... — use this for revocation
print(result.meta.caId)           # ca_...
print(result.meta.expiresAt)      # Unix timestamp
print(result.meta.format)         # "x509"
For X.509: store result.meta.certId alongside the PEM certificate — you need it for ca.revoke_cert() and ca.is_cert_revoked().
ca.verify_cert() — Verify a PQCert certificate offline (synchronous)
import json
from fipsign.types import PQCert

with open("root-cert.json") as f:
    root_cert = PQCert.from_dict(json.load(f))

result = pq.ca.verify_cert(device_cert, root_cert)

if not result.valid:
    # Possible error messages:
    #   "Expected a CA_CERT certificate"
    #   "Expected a CA_ROOT certificate"
    #   "Certificate was not issued by this CA (caId mismatch)"
    #   "Root CA certificate has expired"
    #   "Certificate has expired"
    #   "Invalid certificate signature"
    raise PermissionError(result.error)

print(result.cert.subject)   # "device-serial-00123"
print(result.cert.expiresAt) # Unix timestamp
PQCert format only. Never raises. Does not check revocation — call ca.get_crl() and ca.is_cert_revoked() for that. The error text is for your logs: decide with result.valid and do not compare the text (the JS SDK words these messages differently).

meta integrity: all fields including meta are covered by the ML-DSA-65 signature. Altering any field after issuance will cause verify_cert() to reject the certificate.
ca.verify_x509_cert() — Verify an X.509 certificate offline (synchronous)
import os

root_pem = os.environ["FIPSIGN_ROOT_CERT_PEM"]  # PEM saved at CA creation

result = pq.ca.verify_x509_cert(cert_pem, root_pem)

if not result.valid:
    # Possible error messages:
    #   "Root CA certificate has expired"
    #   "Certificate has expired"
    #   "Invalid certificate signature — not signed by this root CA"
    #   "Unsupported signature algorithm: <OID>. Expected ML-DSA-65 (2.16.840.1.101.3.4.3.18)"
    #   "Unsupported root CA algorithm: <OID>. Expected ML-DSA-65 (2.16.840.1.101.3.4.3.18)"
    raise PermissionError(result.error)

print(result.cert[:27])  # "-----BEGIN CERTIFICATE-----"
X.509 format only. Never raises — always returns VerifyCertResult(valid, cert, error). Synchronous even when used with AsyncPQAuth. Does not check revocation.
ca.is_cert_revoked() — Look up a certificate in a CRL (synchronous)
crl_result = pq.ca.get_crl()

# PQCert CA — pass the PQCert dataclass
if pq.ca.is_cert_revoked(device_cert, crl_result.crl):
    raise PermissionError("Device certificate has been revoked")

# X.509 CA — pass the certId string from meta
if pq.ca.is_cert_revoked(result.meta.certId, crl_result.crl):
    raise PermissionError("Device certificate has been revoked")

# certId string also works for PQCert CAs
if pq.ca.is_cert_revoked(result.meta.certId, crl_result.crl):
    raise PermissionError("Device certificate has been revoked")
Only the lookup is offline. 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.
ca.get_crl() — Get the Certificate Revocation List
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
The CA signs the list, for both formats: the signed object is in result.raw and ca.verify_crl() checks that signature (next step).
ca.verify_crl() — Check that the revocation list was signed by your CA (synchronous)
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 and X.509. Pass the root certificate of the CA: a 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.
What 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.
ca.get_cert() — Get a certificate by ID
result = pq.ca.get_cert("cert_...")

print(result.status.revoked)    # bool
print(result.status.expired)    # bool
print(result.status.revokedAt)  # Unix timestamp or None
print(result.status.expiresAt)  # Unix timestamp

# For PQCert CAs, result.certificate is a PQCert dataclass
# For X.509 CAs, result.certificate is a PEM string and result.meta contains
# additional fields (None for PQCert CAs):
if result.meta:
    print(result.meta.certId)    # cert_...
    print(result.meta.caId)      # ca_...
    print(result.meta.subject)   # "device-serial-00123"
    print(result.meta.format)    # "x509"
    print(result.meta.algorithm) # "ML-DSA-44" | "ML-DSA-65" | "ML-DSA-87"
ca.revoke_cert() — Revoke a certificate
# PQCert CA — use certificate.id
result = pq.ca.revoke_cert(device_cert.id, "device decommissioned")

# X.509 CA — use meta.certId from ca.issue()
result = pq.ca.revoke_cert(meta.certId, "device reported stolen")

# certId string works for both formats
result = pq.ca.revoke_cert("cert_...", "device decommissioned")

print(result.certId)              # cert_...
print(result.revokedAt)           # Unix timestamp
print(result.reason)              # "device decommissioned" (or None if omitted)
print(result.usage.freeRemaining) # tokens remaining after this operation
# result.format == "x509" for X.509 CAs, None for PQCert CAs
Not idempotent: revoking an already-revoked certificate raises PQAuthError(code="API_ERROR", status=409). This is different from revoke() on tokens, which is idempotent.
Full device lifecycle — PQCert
import json
from fipsign import PQAuth, generate_key_pair
from fipsign.types import PQCert

pq = PQAuth("pqa_your_api_key")

# root_cert — the PQCert JSON saved once at CA creation, stored securely
with open("root-cert.json") as f:
    root_cert = PQCert.from_dict(json.load(f))

# 1. Factory: generate key pair for the device
kp = generate_key_pair()
# kp.secretKey — store securely on the device (32-byte seed, base64)

# 2. Factory: issue a certificate for the device
result      = pq.ca.issue(
    subject="lock-serial-00123",
    public_key=kp.publicKey,
    expires_in_seconds=365 * 24 * 60 * 60,
    meta={"model": "lock-v3", "batch": "2026-05"},
)
certificate = result.certificate  # PQCert dataclass — store on device

# 3. At runtime: offline signature verification (no API call)
result = pq.ca.verify_cert(certificate, root_cert)
if not result.valid:
    raise PermissionError(result.error)

# 4. 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")
Full device lifecycle — X.509
import os
from fipsign import PQAuth, generate_key_pair

pq = PQAuth("pqa_your_api_key")

# root_pem — the PEM string shown once at CA creation, stored securely
root_pem = os.environ["FIPSIGN_ROOT_CERT_PEM"]

# 1. Factory: generate key pair for the device
kp = generate_key_pair()
# kp.secretKey — store securely on the device (32-byte seed, base64)

# 2. Factory: issue a certificate for the device
result   = pq.ca.issue(
    subject="lock-serial-00123",
    public_key=kp.publicKey,
    expires_in_seconds=365 * 24 * 60 * 60,
)
cert_pem = result.certificate    # PEM string — store on device
cert_id  = result.meta.certId   # store alongside the PEM — needed for revocation

# 3. At runtime: offline signature verification (no API call)
result = pq.ca.verify_x509_cert(cert_pem, root_pem)
if not result.valid:
    raise PermissionError(result.error)

# 4. 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")
PY 12 zes — Zero-Exposure Signing

Hash sensitive data locally and sign only the hash — the original data never reaches the API. Convenience wrapper over sign()/verify(); see REST 03b for the full explanation of the pattern.

zes.sign() — hash and sign
result = pq.zes.sign({
    "patient":   "Jane Doe",
    "diagnosis": "confidential",
    "record_id": "MR-2026-4491",
})
# result.hash — the 64-char SHA-256 hex digest that was actually signed
# result.token — pass to zes.verify() or revoke() like any token
zes.verify() — re-hash and confirm
check = pq.zes.verify(result.token, {
    "patient":   "Jane Doe",
    "diagnosis": "confidential",
    "record_id": "MR-2026-4491",
})
if not check.valid or not check.dataMatches:
    raise PermissionError("invalid or tampered")
When valid is False, failure and retry_after say why, exactly as in verify() (PY 03): "rejected" means FIPSign refuses the token; any other value means the check did not happen. dataMatches is False whenever valid is False.
Hashing is deterministic regardless of key order — keys are sorted recursively (nested dicts too) before hashing, so the same logical data always produces the same hash no matter how the dict was constructed. Uses the same canonicalize_for_signing() already used by ca.verify_cert() — byte-identical to the JS SDK's hash for the same data.

revoke(), middleware, and AsyncPQAuth all work unchanged — a ZES token is a regular token as far as they're concerned. There is no zes.revoke(): use pq.revoke(token) directly, same as any other token. The async client exposes the identical pq.zes.sign()/pq.zes.verify() API — just await them.
PY 13 mandate — Agent authorization

Bounded, revocable authorization for AI agents, IoT devices, and automated services. Thin typed wrapper over POST/PATCH/GET /mandate — see Mandate 00 for the full explanation of the immutable/mutable layers, lifecycle, and budget semantics. No dashboard setup needed beyond an API key — unlike CA, there's no separate resource to create first. Identical on pq.mandate (sync) and the async client (see PY 06) — every method below has an await-able async twin with the same name and signature, except list_all(), events_all() and query_events_all(), which are async generators (async for). Token cost of each method: see Costs.

Who runs what
WhoHoldsCalls
Your backendProject 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 keygenerate_agent_key_pair() once, then sign_agent_call() for every call. No API key needed: nothing is sent to FIPSign.
Whoever performs the action (your executor — or the agent itself)An API key and the mandate tokenmandate.verify(), right before each action. If it is the agent itself, give it an agent key (restricted scope), never a full-access key.
Whoever checks the evidence (you later, an auditor)The saved receipts or export, and the fingerprint of the keyverify_mandate_receipt(), verify_mandate_export(). No API key needed: nothing is sent to FIPSign.
Recommended: your own executor calls mandate.verify() and works out cost itself, so the agent never holds an API key and the budget can be trusted. Letting the agent call verify() itself also works — Mandate 00b explains both architectures and what each one protects.
mandate.emit() — issue a mandate
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.

mandate.verify() — check authorization before the agent acts
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."

Agent-scoped keys and this section. If the API key used to instantiate pq is an agent key (restricted scope), only mandate.verify() above will work. mandate.emit(), narrow(), suspend(), resume(), revoke(), get(), list(), 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.
The keyword arguments of 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.
You decide cost. The third argument of verify() is enforced exactly as given — FIPSign does not know what an action really costs. Compute it in the code that performs the action, not in the agent. See Mandate 00b.

A timeout does not mean "not applied". When no usable answer arrives (a timeout, a network error, an answer the SDK cannot read, a FIPSign server error) the result is 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.
When a call is not granted — failure
failureWhat happenedAnything 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 malformedNoDo 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 waitNoDo 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 invalidNoDo 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 errorMaybe: the call may have been granted and chargedDo 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()
It is always 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.
Proof of possession with the SDK
What it is. By default a mandate is a bearer credential: whoever holds the token and an API key of your project can use it. With proof of possession the agent has its own key pair and must sign every call with the private key, which never leaves the agent — a copied token is then useless. Use it when the token is handled by code you do not fully control (an agent runtime, a device in the field). You do not need it when only your own backend ever holds the token. It costs no platform tokens: the signing is local.

The rules behind it (what exactly is signed, how long a signature lives) are in Mandate 02b, which also shows how to do it by hand against the REST API. With the SDK, four steps.
Step 1 — The agent generates its key pair (once)
import json

from fipsign import generate_agent_key_pair

# On the agent, once. The default variant is ML-DSA-65.
kp = generate_agent_key_pair()
# Another variant: generate_agent_key_pair("ML-DSA-44")  # or "ML-DSA-87"

# kp.publicKey → goes to your backend, for mandate.emit(agent_public_key=...)
# kp.secretKey → stays on the agent, in a secret store. Never send it anywhere.
# kp.algorithm → "ML-DSA-65" here. Save it next to secretKey (Step 3 needs it)

# Save the two together on the agent (secret store, or a file only the agent's user can read).
# Step 3 reads them back.
saved = json.dumps({"secretKey": kp.secretKey, "algorithm": kp.algorithm})
VariantpublicKeysecretKeySignature
ML-DSA-441312 bytes32 bytes (seed)2420 bytes
ML-DSA-65 (default)1952 bytes32 bytes (seed)3309 bytes
ML-DSA-872592 bytes32 bytes (seed)4627 bytes
Any of the three variants works, whatever the project's algorithm is: the agent's key is independent of the project and of the CA. ML-DSA-44 has the smallest keys and signatures; ML-DSA-87 the highest security level. To choose one, pass its name exactly as written in the first column: generate_agent_key_pair("ML-DSA-44"). Any other text ("ml-dsa-44", "ML-DSA44"…) raises UNSUPPORTED_ALGORITHM.

Use generate_agent_key_pair() for agents and generate_key_pair() only for CA certificates. ca.issue() accepts ML-DSA-65 keys only, so a key of another variant is rejected there (HTTP 400, nothing is charged).

Keep secretKey secret. Store it in a secret store (or a file only the agent's user can read), never log it, never send it anywhere. If it is lost or may have leaked, revoke the mandate and emit a new one with a new key pair: the public key of a mandate can never be changed.

Key format — different from the JS SDK. secretKey is the 32-byte ML-DSA seed (base64), the only private-key form the Python cryptography package can load. The JS SDK's generateAgentKeyPair() returns the full expanded key (2560, 4032 or 4896 bytes) instead. The two secret keys are not interchangeable: sign_agent_call() rejects a JS key with INVALID_SECRET_KEY, so an agent must sign with the SDK that generated its key. The public key and the signature are identical in both SDKs: an agent that signs in Python can be verified by a service that runs the JS SDK, and the other way around. The seed of either recipe in Mandate 02b also works here, with the matching algorithm (the recipes use "ML-DSA-44").
Step 2 — Your backend emits the mandate with the public key
result = pq.mandate.emit(
    agent_id="agent-reporting-v2",
    issued_by="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.

Step 3 — The agent signs every call
import json

from fipsign import sign_agent_call

# On the agent, for EVERY call: no API key needed, nothing is sent to FIPSign.
key = json.loads(saved)         # what Step 1 saved (read it from your secret store)
agent_signature = sign_agent_call(
    mandate_token,              # the mandate token (or just the mandate id)
    "send_reply",               # exactly what mandate.verify() will receive
    1,                          # exactly what mandate.verify() will receive
    key["secretKey"],           # the secretKey from Step 1
    algorithm=key["algorithm"], # required: saved in Step 1, never typed by hand
)
# Send agent_signature to whoever calls mandate.verify() — your executor, or the agent itself
algorithm is required, and you do not make it up. It is the text that generate_agent_key_pair() returned in kp.algorithm — one of "ML-DSA-44", "ML-DSA-65" or "ML-DSA-87" — saved next to secretKey in Step 1. A 32-byte seed does not say which ML-DSA variant it belongs to (all three use the same size), so the SDK cannot detect it the way the JS SDK does from the size of its expanded key. There are two ways to get it wrong. A text that is not exactly one of the three ("ml-dsa-65", "ML-DSA65"…) is refused at once with UNSUPPORTED_ALGORITHM. A valid text that is not the variant of the key is not caught locally: the backend answers agent_signature_invalid.

action and cost must be exactly what you pass to verify(). Change one and you must sign again, otherwise verify() answers agent_signature_mismatch.

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

Clocks. The signature is stamped with the clock of the machine that runs sign_agent_call(). The server accepts a signature stamped up to 30 seconds ahead of its own clock, so with the default lifetime the agent's clock may be off by about 30 seconds either way. Keep clocks synchronized (NTP). On devices where you cannot, pass expires_in_seconds=60: it tolerates an agent clock up to 60 seconds behind.
Step 4 — Verify with the signature
check = pq.mandate.verify(mandate_token, "send_reply", 1, agent_signature=agent_signature)

if check.result != "granted":
    # check.reason also covers the agent's signature:
    # "agent_signature_required" | "agent_signature_invalid" |
    # "agent_signature_mismatch" | "agent_signature_replayed"
    raise PermissionError(check.reason)
do_the_action()
If the answer is lost (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.
When something goes wrong
You seeTypical causeFix
agent_signature_required (in reason)verify() was called without agent_signature= on a mandate emitted with agent_public_key.Sign the call and pass it as agent_signature.
agent_signature_invalidSigned with a different key than the mandate's agent_public_key; an algorithm that is not the variant of the key; signature already expired; agent clock more than 30 seconds ahead of the server.Sign with the secretKey of the pair whose publicKey you emitted, and with the algorithm that pair came with; keep the default lifetime; synchronize clocks.
agent_signature_mismatchThe action or cost that was signed differ from the ones passed to verify().Sign exactly the values you verify. Change one and you must sign again.
agent_signature_replayedThis exact signature was already accepted.Sign again for every call.
TypeError: sign_agent_call() missing 1 required keyword-only argument: 'algorithm'You called sign_agent_call() without algorithm=, as you would with the JS SDK.Add algorithm= with the text saved next to the secret key (kp.algorithm from Step 1).
PQAuthError INVALID_ARGUMENTsign_agent_call() got a missing or invalid mandate, action (non-empty string), cost (whole number ≥ 0) or expires_in_seconds (whole number from 1 to 60). Also raised by every method that takes a mandate_id when it is empty, . or ...Fix the argument; err.message names it.
PQAuthError INVALID_SECRET_KEYsecret_key is not valid base64 or is not a 32-byte seed — for example the expanded key generated by the JS SDK.Use the secretKey returned by generate_agent_key_pair().
PQAuthError UNSUPPORTED_ALGORITHMThe algorithm name given to generate_agent_key_pair() or sign_agent_call() is unknown.Use kp.algorithm, or one of "ML-DSA-44", "ML-DSA-65", "ML-DSA-87" written exactly like that (capitals, hyphens, no spaces).
from fipsign import PQAuthError, sign_agent_call

try:
    agent_signature = sign_agent_call(mandate_token, "send_reply", 1, secret_key, algorithm="ML-DSA-65")
except PQAuthError as err:
    print(err.code, err.message)
Complete example

Generates a key pair, emits a mandate that requires it and runs the four interesting cases against the live API. Save as pop_sdk_demo.py, run pip install fipsign-sdk, then FIPSIGN_API_KEY=pqa_... python pop_sdk_demo.py. 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)
Expected output
emitted mdt_… · requiresAgentSignature: True
no signature   → denied agent_signature_required
with signature → granted · budgetRemaining: 9
same signature → denied agent_signature_replayed
cost changed   → denied agent_signature_mismatch
mandate.narrow() / suspend() / resume() / revoke() — control the mandate
pq.mandate.narrow(mandate.id, ["read:crm"])  # shrink scope — monotonic, cannot re-widen
pq.mandate.suspend(mandate.id)              # pause — verify() denies with "mandate_suspended" while suspended
pq.mandate.resume(mandate.id)               # reactivate a suspended mandate
pq.mandate.revoke(mandate.id)               # permanent — irreversible, no narrow/suspend/resume after this
What narrow() / suspend() / resume() / revoke() return
patched = pq.mandate.suspend(mandate.id)
print(patched.status, patched.scope, patched.updatedAt)
# suspended ['sign', 'verify', 'read:crm'] 1785691318
narrow(), suspend(), resume() and revoke() return a MandatePatchResult with id, status, scope (after the change), 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.
Suspension is checked before budget. If a mandate is both suspended and out of budget, verify() always returns reason="mandate_suspended" — the backend checks status before it ever looks at the budget counter.
mandate.get() / mandate.list() / mandate.list_all() — inspect state
result = pq.mandate.get("mdt_...")
print(result.mandate.budgetConsumed, result.mandate.budgetRemaining, result.mandate.scopeCurrent, result.mandate.status)

# First page: the 50 most recent mandates of this project
page = pq.mandate.list()
print(page.count, page.nextCursor)        # MandateListResult(mandates, count, nextCursor)

# Your own page size (1–100), then the next page
page1 = pq.mandate.list(limit=20)
page2 = pq.mandate.list(limit=20, cursor=page1.nextCursor) if page1.nextCursor else None

# Every mandate, page by page — stop whenever you want with break
for m in pq.mandate.list_all():
    print(m.id, m.status, m.budgetConsumed)

# Async client: same names, list_all() is an async generator (no await before it)
# async for m in pq.mandate.list_all(): ...
list() returns one page — up to 50 mandates by default (limit: 1–100), most recent first. nextCursor is a string while more pages exist and None on the last one: pass it back as cursor, exactly as received. count is the size of this page, not a total (the API has no total). A page can hold fewer than limit mandates — even none — while nextCursor is not None, so drive your loop with the cursor, never with the page size. list_all() does exactly that for you, one page at a time (its limit is the page size, not a cap on the total). Keep the id of every mandate you emit rather than listing to find it.

Expired mandates. There is no expired status: check expiresInSeconds (it is 0 once the mandate has expired). An expired mandate can still be read for 24 hours, then get() raises PQAuthError with status 404.

Errors in the control methods. narrow(), suspend(), resume(), revoke(), get(), list() and emit() raise PQAuthError with code API_ERROR and the HTTP status: 409 when the state does not allow the change (already revoked, expired, not suspended), 404 when the mandate does not exist, 400 for invalid input (for list(): a limit out of range or a cursor that is not one the API gave you). Revoking twice returns 409 — treat it as success when retrying.

Errors in the reads of the log and the keys. 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.
Receipts and the audit log — prove what an agent was allowed to do

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.

Step 1 — Ask for the receipt and tag the call with your own id
# 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.

A missing receipt is never an error. The call is decided and recorded before the receipt is made, so the event is in the log either way. Save the receipt when it is there: FIPSign does not keep it and cannot make the same one again.

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)
Step 2 — Save the fingerprint of your project's key (once)

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>
Keep the 64 characters with your own configuration (in this guide, the environment variable 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.
Step 3 — Check a receipt
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.

A valid receipt proves what FIPSign recorded and when. It does not prove that your service made the request (Mandate 12).
Step 4 — Read the log
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.

An event has 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.
Step 5 — Export the log and check it
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.
What the two checks return
FieldInMeaning
validbothtrue 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.
problemsbothA list of texts: what does not check out. Empty when valid is true.
keyTrustboth"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, eventreceiptWhat the receipt says it is about, when it could be read.
completeexportThe 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, lastSeqexportHow many events were checked, and the seq of the last one.
checkpoints, sealedThrough, headCheckedexportHow 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.
notesexportWhat is not wrong but limits what was checked, for example: the export starts at event 40.
A changed receipt does not pass

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)']
Complete example

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)
Expected output
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
Keep what you need to keep. An event stays in the log for 365 days from the moment it was recorded (the log of a mandate that was deleted is still there for that time). FIPSign does not keep your receipts: export the log, or keep the receipts, of what you need to prove for longer. The log is a record: nothing in it grants, denies or changes anything.
Model Context Protocol

MCP Integration

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

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

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

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

Each tool costs what the operation it calls costs: see Costs.

MCP 02 Claude Desktop setup

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

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

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

TypeScript MCP
claude mcp add fipsign -- env FIPSIGN_API_KEY=pqa_your_api_key npx -y @fipsign/mcp
Python MCP
claude mcp add fipsign -- env FIPSIGN_API_KEY=pqa_your_api_key uvx fipsign-mcp
Verify the server is loaded
claude mcp list
claude mcp get fipsign
Project scope: by default MCP servers are project-scoped. Add --scope global to make it available in all Claude Code sessions.
MCP 03b Environment variables
VariableRequiredDefaultDescription
FIPSIGN_API_KEY Yes (most tools) — Your FIPSign API key. Format: pqa_ + 64 lowercase hex chars. fipsign_health and fipsign_generate_key_pair work without it. fipsign_public_key requires an API key — it returns the public key for the project associated with that key. Do not use an agent-scoped key here (dashboard checkbox "Agent key — restricted scope") — none of the 11 MCP tools call POST /mandate/verify, so an agent key would fail on all of them. Agent keys are only for direct SDK/REST calls to mandate.verify().
FIPSIGN_BASE_URL No https://api.fipsign.dev Override the API base URL. Useful for self-hosted instances or local development tunnels.
MCP 04 Example conversations

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

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

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

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

GitHub repos
TypeScript  →  github.com/fipsign/fipsign-mcp
Python      →  github.com/fipsign/fipsign-mcp-python
Test with MCP Inspector (TypeScript)
# Install inspector globally once
npm install -g @modelcontextprotocol/inspector

# Run inspector against the published package
FIPSIGN_API_KEY=pqa_your_api_key npx @modelcontextprotocol/inspector npx @fipsign/mcp

# Or from a local clone
git clone https://github.com/fipsign/fipsign-mcp
cd fipsign-mcp && npm install && npm run build
FIPSIGN_API_KEY=pqa_your_api_key npx @modelcontextprotocol/inspector node dist/index.js
Test with MCP Inspector (Python)
# Using uvx — no install needed
FIPSIGN_API_KEY=pqa_your_api_key npx @modelcontextprotocol/inspector uvx fipsign-mcp

# Or from a local clone
git clone https://github.com/fipsign/fipsign-mcp-python
cd fipsign-mcp-python && pip install -e .
FIPSIGN_API_KEY=pqa_your_api_key npx @modelcontextprotocol/inspector python -m fipsign_mcp.server
Inspector UI: opens at http://localhost:5173. Click any tool, fill in the arguments, and call it directly to test your API key and inspect the response before connecting to Claude.
REST API · curl

REST API Reference

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

00 Environment setup

Set these in your terminal before running any example in this section.

export API_KEY="pqa_YOUR_API_KEY_HERE"
export BASE_URL="https://api.fipsign.dev"
Where do I get my API key? Dashboard → your project → expand the card → + New key. The key is shown only once at creation time. If you lose it, create a new one and revoke the old one. Step by step: Dashboard 03.

Agent key — restricted scope. If the key is for an AI agent, check "Agent key — restricted scope" when you create it (Dashboard 03). A key created with it can only call 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.
01 Health check (GET /health)

Public endpoint. No authentication required. Token cost: see Costs.

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

Requires X-API-Key. 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
{
  "success": true,
  "publicKey": "base64encodedkey...",
  "algorithm": "ML-DSA-65", // reflects the project's configured algorithm
  "standard": "NIST FIPS 204"
}
Fingerprint. The SHA-256 of the key bytes (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).
03 Sign a payload (POST /sign)

Requires X-API-Key. Token cost: see Costs.

User session
curl -s -X POST $BASE_URL/sign \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $API_KEY" \
  -d '{"sub":"user_123","email":"user@example.com","role":"admin","expiresInSeconds":3600}' | jq
Save the token for subsequent steps
TOKEN=$(curl -s -X POST $BASE_URL/sign \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $API_KEY" \
  -d '{"sub":"user_test","email":"test@example.com"}' \
  | jq -c '.token')
Response — full shape
{
  "success": true,
  "token": {
    "payload": "eyJzdWIi...",
    "signature": "oi5UKsTn...",
    "algorithm": "ML-DSA-65", // ML-DSA-44 | ML-DSA-65 | ML-DSA-87 — set at project creation
    "issuedAt": 1778947233
  },
  "meta": {
    "algorithm": "ML-DSA-65", // ML-DSA-44 | ML-DSA-65 | ML-DSA-87
    "standard": "NIST FIPS 204",
    "quantumResistant": true,
    "expiresIn": 3600,
    "issuedFor": "dev@example.com",
    "projectId": "proj_...",
    "tokenCost": 1,
    "source": "free" // "free" | "pack" | "free+pack"
  },
  "usage": {
    "freeRemaining": 9999,
    "packRemaining": 0,
    "totalRemaining": 9999,
    "month": "2026-06"
  }
}
Payload limits: sub is required, max 128 characters. Other string fields max 256 characters. Maximum 10 custom fields (not counting sub, email, role, expiresInSeconds). Exceeding these returns HTTP 400.

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

expiresInSeconds range: when provided, must be a whole number between 60 and 86,400 seconds (24 hours). Outside this range, or with decimals such as 60.5, returns HTTP 400.

Algorithm: the signing algorithm (ML-DSA-44, ML-DSA-65, or ML-DSA-87) is determined by the project configuration set at creation time in the dashboard. It is immutable — all tokens signed by a project use the same algorithm. The algorithm is reflected in both token.algorithm and meta.algorithm in the response.
Reserved field names. Custom fields whose name starts with an underscore (_) belong to the server — for example _iss and _mandate — and cannot be set through /sign. A field like _mandate or _foo in the body returns HTTP 400 Custom fields starting with "_" are reserved; _iss alone is silently ignored. This keeps tokens from /sign and Mandate tokens (POST /mandate) apart: only POST /mandate can produce a token that /mandate/verify will accept.
03b Zero-Exposure Signing (ZES)

Sign sensitive data without sending it to the API. The data is hashed locally — only a 64-character SHA-256 hex digest (32 bytes) is transmitted. PQ-Sign never sees the original content. 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.

How it works: instead of sending your sensitive payload to POST /sign, you hash it locally using SHA-256 and send only the hash. The server signs the hash — not your data. To verify, you recalculate the hash locally and verify the token normally via POST /verify, while the token is still valid.

No backend changes required. ZES uses the standard POST /sign and POST /verify endpoints — the only difference is what you send as the payload.
Standard: SHA-256 in hex, keys sorted alphabetically
Always serialize your data as JSON with keys sorted alphabetically before hashing. This guarantees the same hash regardless of language or runtime. Always use SHA-256 output as lowercase hex.

Write the JSON the way JavaScript's 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.
Step 1 — Hash your data locally (bash)
# Your sensitive data — never leaves your machine
SENSITIVE='{"amount":"50000","customer":"Acme Corp","invoice":"INV-123"}'
# Keys must be sorted alphabetically: amount → customer → invoice

# Hash locally with SHA-256 → hex
ZES_HASH=$(echo -n "$SENSITIVE" | sha256sum | awk '{print $1}')
echo "Hash: $ZES_HASH"
# Hash: a3f2c1d8e9b4f7c2... (64 hex chars — this is what travels to the API)
Step 2 — Sign the hash (POST /sign)
ZES_TOKEN=$(curl -s -X POST $BASE_URL/sign   -H "Content-Type: application/json"   -H "X-API-Key: $API_KEY"   -d "{\"sub\": \"zes:$ZES_HASH\", \"zes\": true}"   | jq -c '.token')
echo $ZES_TOKEN | jq
Response — same shape as standard POST /sign
{
  "success": true,
  "token": {
    "payload": "eyJzdWIi...", // base64 — contains the hash, not your data
    "signature": "oi5UKsTn...",
    "algorithm": "ML-DSA-65",
    "issuedAt": 1785690939
  },
  // meta and usage fields same as standard sign response
}
Step 3 — Verify (recalculate hash locally, then verify token)
# Recalculate the hash from the original data (on your side)
VERIFY_HASH=$(echo -n "$SENSITIVE" | sha256sum | awk '{print $1}')

# Verify the token — same endpoint as always
curl -s -X POST $BASE_URL/verify   -H "Content-Type: application/json"   -H "X-API-Key: $API_KEY"   -d "{\"token\": $ZES_TOKEN}" | jq
Response — valid ZES token
{
  "success": true,
  "valid": true,
  "payload": {
    "sub": "zes:a3f2c1d8e9b4f7c2...", // the hash you signed — not the original data
    "zes": true,
    "iat": 1785690939,
    "exp": 1785694539
  }
}
To confirm the data matches the token: recalculate SHA-256(your_data) and compare it against payload.sub (strip the zes: prefix). If they match and valid: true, the 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.

Serialization rule: always sort JSON keys alphabetically before hashing — recursively, at every nesting level, not just the top level. {"amount":"5000","customer":"Acme"} and {"customer":"Acme","amount":"5000"} produce different hashes — the token will not verify if the key order differs between sign and verify. This applies to nested objects too: sort keys inside every nested object, not only at the top level, or the hash will differ depending on how the object was originally constructed.

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

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

The zes: true field is a convention that signals to your own systems that the sub field contains a hash rather than a plain identifier. PQ-Sign treats it as any other custom field — it counts as 1 of the 10 allowed custom fields in the payload. If you need all 10 slots for your own fields, omit zes: true and rely on the zes: prefix in sub to identify ZES tokens in your own systems.
JavaScript equivalent (no SDK — raw fetch)
import crypto from 'crypto'

// Step 1 — hash locally (keys sorted recursively — nested objects too)
function canonicalize(obj) {
  if (Array.isArray(obj)) return obj.map(canonicalize)
  if (obj !== null && typeof obj === 'object') {
    return Object.fromEntries(Object.keys(obj).sort().map(k => [k, canonicalize(obj[k])]))
  }
  return obj
}
const sensitive = { amount: '50000', customer: 'Acme Corp', invoice: 'INV-123' }
const sorted    = JSON.stringify(canonicalize(sensitive))
const hash      = crypto.createHash('sha256').update(sorted).digest('hex')

// Step 2 — sign the hash
const res   = await fetch('https://api.fipsign.dev/sign', {
  method:  'POST',
  headers: { 'Content-Type': 'application/json', 'X-API-Key': process.env.PQSIGN_KEY },
  body:    JSON.stringify({ sub: `zes:${hash}`, zes: true })
})
const { token } = await res.json()

// Step 3 — verify: recalculate hash and confirm it matches payload.sub
const verifyRes = await fetch('https://api.fipsign.dev/verify', {
  method:  'POST',
  headers: { 'Content-Type': 'application/json', 'X-API-Key': process.env.PQSIGN_KEY },
  body:    JSON.stringify({ token })
})
const { valid, payload } = await verifyRes.json()

const rehash  = crypto.createHash('sha256').update(sorted).digest('hex')
const matches = payload.sub === `zes:${rehash}`
console.log(`valid: ${valid}, data matches token: ${matches}`)  // true, true
Python equivalent (no SDK — raw requests)
import hashlib, json, requests, os

# Step 1 — hash locally (keys sorted recursively — nested objects too)
def canonicalize(obj):
    if isinstance(obj, list):
        return [canonicalize(x) for x in obj]
    if isinstance(obj, dict):
        return {k: canonicalize(obj[k]) for k in sorted(obj)}
    return obj

sensitive = {'amount': '50000', 'customer': 'Acme Corp', 'invoice': 'INV-123'}
sorted_json = json.dumps(canonicalize(sensitive), separators=(',', ':'), 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
04 Verify a token (POST /verify)

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
Response — valid token
{
  "success": true,
  "valid": true,
  "payload": {
    "sub": "user_test",
    "iat": 1778947233,
    "exp": 1778950833
    // plus any custom fields passed to sign()
  }
}
Response — invalid token
{
  "success": false,
  "valid": false,
  "error": "Token has been revoked" // or one of the messages in "Why a token is rejected" below
}
Tampered token test
FAKE_TOKEN=$(echo $TOKEN | jq -c '.payload = "TAMPERED_PAYLOAD"')
curl -s -X POST $BASE_URL/verify \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $API_KEY" \
  -d "{\"token\": $FAKE_TOKEN}" | jq
Response — HTTP 401
{
  "success": false,
  "valid": false,
  "error": "Invalid signature — token was tampered with or not issued by this server"
}
Why a token is rejected
Decide with the HTTP status and valid. A rejected token is always HTTP 401 with "valid": false; a malformed request is HTTP 400 (no valid field); HTTP 429 is a rate limit or an exhausted quota (REST 10). The error text says why — use it in your logs, but do not build logic on comparing it.
HTTPerrorWhen
401Token has been revokedThe token was revoked with POST /revoke.
401Token expired N seconds agoThe signature is valid but the token's exp is in the past. N is how many seconds ago, for example Token expired 120 seconds ago.
401Invalid signature — token was tampered with or not issued by this serverThe signature does not match the payload or the project's key: the payload was changed, the token was signed by another key, or it was issued for a different project of yours (you get the same text in all three cases).
401Invalid signature — wrong length for ML-DSA-65 (expected 3309 bytes)The signature has the wrong size for the token's algorithm. The text names the token's own algorithm: ML-DSA-44 expects 2420 bytes, ML-DSA-65 3309, ML-DSA-87 4627.
401Invalid token — signature is not valid base64The signature field is not base64 text.
401Unsupported algorithm: XThe token's algorithm is not exactly ML-DSA-44, ML-DSA-65 or ML-DSA-87. X is the value you sent.
401Token payload exceeds the maximum of 16384 charactersThe token's payload text is longer than 16,384 characters.
401This is a Mandate token. Verify it with POST /mandate/verifyThe token came from POST /mandate (see the note below).
400"token" is requiredThe body has no token object. HTTP 400 responses have no valid field.
400Invalid token format — missing payload, signature, or algorithmThe token object lacks payload, signature or algorithm.
issuedAt is informational. The token object carries an issuedAt field, but /verify and /revoke never read it. What counts is the signed payload: iat and exp.
Mandate tokens are refused here. A token issued by POST /mandate is not a normal token: /verify would only check its signature and expiry, and would say valid: true even for a revoked or suspended mandate. It answers HTTP 401 { "success": false, "valid": false, "error": "This is a Mandate token. Verify it with POST /mandate/verify" } instead (the call is billed as a normal /verify). Use POST /mandate/verify, which also checks status, scope and budget.
05 Revoke a token (POST /revoke)

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

Expired tokens: calling /revoke on an already-expired token returns HTTP 400 with "Token is invalid or already expired — cannot revoke". Expired tokens cannot be submitted for revocation.

Rate limit: POST /revoke is limited to 300 requests/minute per API key, same as /sign and /verify. See section 10.
Mandate tokens cannot be revoked here. POST /revoke answers HTTP 400 This is a Mandate token. Revoke it with PATCH /mandate/:id {"action":"revoke"} (nothing is charged). A Mandate is revoked by its id with PATCH /mandate/:id, and that does not require the token.
06 Token usage (GET /usage)

Requires X-API-Key. 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
Response — shape
{
  "success": true,
  "current": {
    "month": "2026-06",
    "freeUsed": 42,
    "freeRemaining": 9958,
    "freeLimit": 2000,
    "packRemaining": 0,
    "totalRemaining": 9958
  },
  "monthlyHistory": [ // always 6 entries, oldest → newest
    { "month": "2026-01", "tokensUsed": 0, "fromFree": 0, "fromPack": 0 },
    // ...
  ],
  "packs": [ // purchased token packs
    { "id": "pack_...", "packType": "lite", "tokensPurchased": 25000, "purchasedAt": 1778900000, "paymentRef": "pay_..." }
  ],
  "developer": { "email": "dev@example.com" }
}
monthlyHistory always returns exactly 6 entries. Months with no activity show tokensUsed: 0.
The whole account, not each project. 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.
Accuracy guarantee: current (the live balance — freeRemaining, packRemaining, totalRemaining) is always exact and is what determines whether a request is accepted or rejected for being over your token quota. monthlyHistory and per-project usage stats are an audit log — under very high burst traffic they may show a slightly lower total than current. If you need the exact token count at any point in time, read current, not the sum of monthlyHistory.
07 Complete test script

Copy, replace your API key, and run to test the full token lifecycle.

#!/bin/bash
API_KEY="pqa_YOUR_API_KEY"
BASE_URL="https://api.fipsign.dev"

echo "[1/7] Health..."
curl -s $BASE_URL/health | jq .status

echo "[2/7] Sign..."
TOKEN=$(curl -s -X POST $BASE_URL/sign \
  -H "Content-Type: application/json" -H "X-API-Key: $API_KEY" \
  -d '{"sub":"user_test","role":"user"}' | jq -c '.token')

echo "[3/7] Verify (must be true)..."
curl -s -X POST $BASE_URL/verify -H "Content-Type: application/json" \
  -H "X-API-Key: $API_KEY" -d "{\"token\": $TOKEN}" | jq .valid

echo "[4/7] Tampered (must be false)..."
FAKE=$(echo $TOKEN | jq -c '.payload = "FAKE"')
curl -s -X POST $BASE_URL/verify -H "Content-Type: application/json" \
  -H "X-API-Key: $API_KEY" -d "{\"token\": $FAKE}" | jq .valid

echo "[5/7] Revoke..."
curl -s -X POST $BASE_URL/revoke -H "Content-Type: application/json" \
  -H "X-API-Key: $API_KEY" -d "{\"token\": $TOKEN, \"reason\": \"test\"}" | jq .message

echo "[6/7] Verify revoked (must be false)..."
curl -s -X POST $BASE_URL/verify -H "Content-Type: application/json" \
  -H "X-API-Key: $API_KEY" -d "{\"token\": $TOKEN}" | jq '{valid,error}'

echo "[7/7] Balance..."
curl -s $BASE_URL/usage -H "X-API-Key: $API_KEY" | jq .current
08 Limits
Account limits: up to 1,000 projects and 1,000 active API keys per account. They are a safety cap.
Payload limits: sub max 128 chars · other string fields max 256 chars · max 10 custom fields.
Token expiry limits: expiresInSeconds on /sign whole number, min 60, max 86,400 (24 hours).
CA certificate limits: subject max 256 chars · meta max 10 keys (PQCert only) · expiresInSeconds whole number, min 60, max 157,680,000 (5 years).
09 Common errors
HTTPErrorCause
400"sub" is requiredsign() called without sub field
400"sub" exceeds maximum length of 128 characterssub field too long
400Maximum of 10 custom fields allowed in payloadsign() called with more than 10 custom fields
400Token is invalid or already expired — cannot revokerevoke() called on expired token, or token issued for a different project
400This is a Mandate token. Revoke it with PATCH /mandate/:id {"action":"revoke"}POST /revoke called with a token issued by POST /mandate
400Custom fields starting with "_" are reservedPOST /sign called with a custom field whose name starts with an underscore (_iss alone is ignored)
400"meta" is not supported for X.509 CAsca.issue() called with meta on an X.509 CA
400"expiresInSeconds" must be at least 60Certificate TTL below minimum
400"expiresInSeconds" must not exceed 157680000 (5 years)Certificate TTL above maximum
400"CA root expires too soon to issue a valid certificate"CA root has less remaining lifetime than the minimum certificate TTL — the CA root itself is near expiry
400"expiresInSeconds" must be at least 60Token TTL below minimum (POST /sign)
400"expiresInSeconds" must not exceed 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 charactersCertificate subject too long
400Invalid body — expected JSONThe request body is not valid JSON
400Invalid body — expected a JSON objectThe body is valid JSON but is not an object: null, an array or a plain value
400"expiresInSeconds" must be an integerexpiresInSeconds has decimals, for example 60.5 (POST /sign and POST /ca/issue)
415Content-Type must be application/jsonRequest body sent with a non-JSON Content-Type (e.g. text/plain, multipart/form-data)
401API key required or invalidMissing or incorrect X-API-Key, or key not matching pqa_ + 64 hex chars
401Token has been revokedToken was previously revoked via POST /revoke
401Invalid signature — token was tampered with or not issued by this serverToken signature is invalid (tampered or wrong key), or token issued for a different project
401Invalid signature — wrong length for ML-DSA-65 (expected 3309 bytes)The signature has the wrong size for the token's algorithm (ML-DSA-44: 2420, ML-DSA-65: 3309, ML-DSA-87: 4627 bytes)
401Invalid token — signature is not valid base64The token's signature field is not base64 text
401Unsupported algorithm: XThe token's algorithm is not ML-DSA-44, ML-DSA-65 or ML-DSA-87
401Token payload exceeds the maximum of 16384 charactersThe token's payload text is longer than 16,384 characters
401Token expired N seconds agoToken has expired
401This is a Mandate token. Verify it with POST /mandate/verifyPOST /verify called with a token issued by POST /mandate
404No active CA found for this projectCA not yet created — create it in the dashboard (Dashboard 04)
404Certificate not foundcertId does not exist or belongs to another project
409Certificate is already revokedca.revokeCert() called on already-revoked cert
429Rate limit exceeded. Maximum 300 requests per minute per API key.Too many requests in the current minute (the number in the text depends on the endpoint). The body has "code": "rate_limited"; wait the seconds in the Retry-After header and retry — see REST 10
429Token limit reached. Free monthly tokens exhausted and no active packs.Monthly quota exhausted. The body has "code": "token_quota_exhausted" (the text can be shorter: Token limit reached.). Purchase a pack from the dashboard (Dashboard 09), retrying won't help
API keys cannot be recovered after creation. If lost, create a new key and revoke the old one from the dashboard.
10 Rate limits

All endpoints are rate limited. 300 requests/minute per API key on write endpoints, 60/minute on CA read endpoints. HTTP 429 on excess, with a Retry-After header.

EndpointLimitWindowScope
GET /public-key300 requests1 minutePer API key
POST /sign300 requests1 minutePer API key
POST /verify300 requests1 minutePer API key
POST /revoke300 requests1 minutePer API key
POST /ca/issue300 requests1 minutePer API key
POST /ca/revoke300 requests1 minutePer API key
GET /ca/crl60 requests1 minutePer API key
GET /ca/certificate/:id60 requests1 minutePer API key
GET /mandate, GET /mandate/:id, GET /mandate/:id/events, GET /mandate/events, GET /mandate/:id/export, GET /public-keys300 requests1 minutePer API key, one counter for all six (Mandate 07)
Two different 429s — tell them apart with code. Every HTTP 429 from an endpoint that uses X-API-Key has a code field in the body. Do not compare the error text: it changes with the endpoint.

"rate_limited" — you sent too many requests in the current minute. The Retry-After header is a whole number of seconds: the time left until the one-minute window ends. Wait that long and the request goes through.

"token_quota_exhausted" — your free tokens and your packs are used up. There is no Retry-After: purchasing a pack from the dashboard (Dashboard 09) is the only remedy, retrying won't help.
429 — rate limit (wait, then retry)
HTTP/2 429
retry-after: 37

{
  "success": false,
  "error": "Rate limit exceeded. Maximum 300 requests per minute per API key.",
  "code": "rate_limited"
}
429 — token quota exhausted (do not retry)
HTTP/2 429

{
  "success": false,
  "error": "Token limit reached. Free monthly tokens exhausted and no active packs.",
  "code": "token_quota_exhausted",
  "usage": {
    "freeRemaining": 0,
    "packRemaining": 0,
    "totalRemaining": 0
  }
}
Retry once after a rate limit (Node.js 20+, no SDK)
// retry.mjs — Node.js 20+, no SDK. Run it with:  FIPSIGN_API_KEY=pqa_... node retry.mjs
const BASE_URL = 'https://api.fipsign.dev'
const API_KEY  = process.env.FIPSIGN_API_KEY

async function fipsign(path, body, retries = 1) {
  const res = await fetch(BASE_URL + path, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-API-Key': API_KEY },
    body: JSON.stringify(body),
  })
  if (res.status !== 429) return res

  const err = await res.json()
  if (err.code === 'rate_limited' && retries > 0) {
    // Retry-After = seconds until the one-minute window ends
    const seconds = Number(res.headers.get('Retry-After') ?? 1)
    await new Promise(resolve => setTimeout(resolve, seconds * 1000))
    return fipsign(path, body, retries - 1)
  }
  // 'token_quota_exhausted' (or still rate limited after the retry): waiting will not fix it now
  throw new Error(`FIPSign answered 429 ${err.code}: ${err.error}`)
}

const res = await fipsign('/sign', { sub: 'user_123' })
console.log(res.status, await res.json())
REST 11 Certificate Authority — curl reference (PQCert)

All CA runtime operations are available via REST. The CA root is created once from the dashboard — there is no REST endpoint for that. All endpoints require X-API-Key. The API key determines the project; no projectId needed in the request body. Token cost of each endpoint: see Costs.

GET /ca/status and POST /ca/create are session-only endpoints used by the dashboard. They are not available via API key.
There is no endpoint to verify a certificate. You check it on your own side, with the root certificate you saved when the CA was created: nothing to call and nothing to pay. The one thing you cannot know alone is whether a certificate was revoked, and FIPSign answers it with two free calls, both with the project's 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.
POST /ca/issue — Issue a certificate
CERT=$(curl -s -X POST $BASE_URL/ca/issue \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $API_KEY" \
  -d '{
    "subject":          "device-serial-00123",
    "publicKey":        "base64_ml_dsa65_public_key",
    "expiresInSeconds": 31536000,
    "meta":             {"model": "lock-v2", "batch": "2026-05"}
  }')

CERT_ID=$(echo $CERT | jq -r '.meta.certId')
echo "Issued: $CERT_ID"
Response
{
  "success": true,
  "certificate": { "type": "CA_CERT", "id": "cert_...", "subject": "device-serial-00123", "caId": "ca_...", "signature": "...", ... },
  "meta": {
    "certId": "cert_...",
    "caId": "ca_...",
    "subject": "device-serial-00123",
    "format": "pqcert",
    "issuedAt": 1778947233,
    "expiresAt":1810483233,
    "algorithm":"ML-DSA-65",
    "standard": "NIST FIPS 204",
    "caExpiry": null /* present only when expiresInSeconds was truncated: { "truncated": true, "requestedExpiresInSeconds": N, "resolvedExpiresInSeconds": N } */
  },
  "usage": { "freeRemaining": 9998, "packRemaining": 0, "totalRemaining": 9998 }
}
GET /ca/crl — Certificate Revocation List
curl -s $BASE_URL/ca/crl -H "X-API-Key: $API_KEY" | jq '.'
{
  "success": true,
  "crl": {
    "caId": "ca_...",
    "subject": "My IoT Root CA",
    "format": "pqcert",
    "algorithm": "ML-DSA-65",
    "generatedAt": 1778947500,
    "revokedCerts": [{ "certId": "cert_...", "revokedAt": 1779000000, "reason": "device decommissioned" }],
    "signature": "base64..."
  },
  "generatedAt": 1778947500,
  "verifyNote": "Verify: (1) take the crl object from this response and remove its signature field, ..."
}
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).
GET /ca/certificate/:id — Certificate status
curl -s $BASE_URL/ca/certificate/$CERT_ID \
  -H "X-API-Key: $API_KEY" | jq '.status'
{ "revoked": false, "expired": false, "revokedAt": null, "expiresAt": 1810483233 }
POST /ca/revoke — Revoke a certificate
curl -s -X POST $BASE_URL/ca/revoke \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $API_KEY" \
  -d "{\"certId\": \"$CERT_ID\", \"reason\": \"device decommissioned\"}" | jq
Response
{
  "success": true,
  "certId": "cert_...",
  "revokedAt": 1779000000,
  "reason": "device decommissioned",
  "usage": { "freeRemaining": 9997, "packRemaining": 0, "totalRemaining": 9997 }
}
Not idempotent: revoking an already-revoked certificate returns HTTP 409 "Certificate is already revoked". This is different from POST /revoke on tokens, which is idempotent.
Verify a PQCert certificate without the SDK
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:

1. The certificate has "type": "CA_CERT", the root has "type": "CA_ROOT", and the caId of the certificate is equal to the id of the root.
2. Neither has expired: expiresAt (Unix seconds) of the certificate and of the root are later than now.
3. Take the certificate without its 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.
4. Encode that text as UTF-8 and verify it with ML-DSA-65 (FIPS 204, plain signature, empty context), using the base64-decoded signature of the certificate and the base64-decoded publicKey of the root.

None of this looks at revocation: for that, use GET /ca/crl or GET /ca/certificate/:id above.
Verify a revocation list without the SDK
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:

1. crl.format is "pqcert", crl.algorithm is "ML-DSA-65" and crl.caId is equal to the id of the root.
2. Take 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.
3. Encode that text as UTF-8 and verify it with ML-DSA-65 (FIPS 204, plain signature, empty context), using the base64-decoded signature of crl and the base64-decoded publicKey of the root.
4. Decide how old a list you accept: 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.

The list of an X.509 CA is checked in the same way (REST 11b).
REST 11b X.509 CA — curl reference

X.509 CA works identically to PQCert CA from the API perspective — same endpoints, same auth model. The difference is the format: X.509 returns a standard PEM certificate (RFC 9881, id-ml-dsa-65) that OpenSSL 3.5+ can parse. Token cost of each endpoint: see Costs.

How to create an X.509 CA: in the dashboard (Dashboard 04) choose 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.

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

OID 2.16.840.1.101.3.4.3.18 (id-ml-dsa-65) — RFC 9881 final. Parseable with OpenSSL 3.5+; full chain verification with Python cryptography>=48.0.0 or the JS SDK's ca.verifyX509Cert().
Generate a device keypair (ML-DSA-65)
node -e "
import('@noble/post-quantum/ml-dsa.js').then(({ml_dsa65}) => {
  const seed = new Uint8Array(32);
  crypto.getRandomValues(seed);
  const kp = ml_dsa65.keygen(seed);
  let pub = '', sec = '';
  for(let i=0;i<kp.publicKey.length;i++) pub += String.fromCharCode(kp.publicKey[i]);
  for(let i=0;i<kp.secretKey.length;i++) sec += String.fromCharCode(kp.secretKey[i]);
  console.log('PUBLIC_KEY=' + btoa(pub));
  console.log('SECRET_KEY=' + btoa(sec));
})"
POST /ca/issue — Issue an X.509 certificate
# publicKey must be exactly 1952 bytes (ML-DSA-65 public key)
# meta is not supported for X.509 CAs — omit it
CERT_PEM=$(curl -s -X POST $BASE_URL/ca/issue \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $API_KEY" \
  -d "{
    \"subject\":          \"device-serial-00123\",
    \"publicKey\":        \"$DEVICE_PUBLIC_KEY\",
    \"expiresInSeconds\": 31536000
  }")

CERT_ID=$(echo $CERT_PEM | jq -r '.meta.certId')
echo $CERT_PEM | jq -r '.certificate'  # prints PEM
Response
{
  "success": true,
  "certificate": "-----BEGIN CERTIFICATE-----\nMIIV...==\n-----END CERTIFICATE-----\n",
  "meta": {
    "certId": "cert_...",
    "caId": "ca_...",
    "subject": "device-serial-00123",
    "format": "x509",
    "issuedAt": 1778947233,
    "expiresAt":1810483233,
    "algorithm":"ML-DSA-65",
    "standard": "NIST FIPS 204",
    "sizeNote": "X.509 ML-DSA-65 certs are ~7.5KB PEM. Plan accordingly for IoT memory constraints."
  },
  "usage": { "freeRemaining": 9998, "packRemaining": 0, "totalRemaining": 9998 }
}
GET /ca/crl — Certificate Revocation List (X.509 CA)
curl -s $BASE_URL/ca/crl -H "X-API-Key: $API_KEY" | jq '.'
{
  "success": true,
  "crl": {
    "caId": "ca_...",
    "subject": "My IoT Root CA",
    "format": "x509",
    "algorithm": "ML-DSA-65",
    "generatedAt": 1778947500,
    "revokedCerts": [{ "certId": "cert_...", "revokedAt": 1779000000, "reason": "device decommissioned" }],
    "signature": "base64..."
  },
  "generatedAt": 1778947500,
  "verifyNote": "Verify: (1) take the crl object from this response, ..."
}
Same shape as REST 11, with "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).
GET /ca/certificate/:id — Certificate status (X.509 CA)
curl -s $BASE_URL/ca/certificate/$CERT_ID \
  -H "X-API-Key: $API_KEY" | jq '{status, meta}'
{
  "status": { "revoked": false, "expired": false, "revokedAt": null, "expiresAt": 1810483233 },
  "meta": { "certId": "cert_...", "caId": "ca_...", "subject": "device-serial-00123", "format": "x509", "algorithm": "ML-DSA-65" }
}
The certificate itself comes as a PEM in 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.
REST 11c X.509 — End-to-end device authentication flow

A complete example: issue a certificate, sign a device message, and verify it on the server including cert chain validation, expiry check, and replay protection.

Step 1 — Generate device keypair
# Set DEVICE_PUBLIC_KEY and DEVICE_SECRET_KEY from REST 11b above
Step 2 — Issue X.509 certificate
# Already covered in REST 11b — CERT_PEM and CERT_ID set
Step 3 — Device signs a message
node -e "
import('@noble/post-quantum/ml-dsa.js').then(({ml_dsa65}) => {
  const message    = JSON.stringify({ deviceId: 'device-serial-00123', timestamp: Date.now(), value: 42.5 });
  const secretKeyB64 = process.env.DEVICE_SECRET_KEY;
  const bin = atob(secretKeyB64);
  const secretKey = new Uint8Array(bin.length);
  for(let i = 0; i < bin.length; i++) secretKey[i] = bin.charCodeAt(i);
  const msgBytes  = new TextEncoder().encode(message);
  const signature = ml_dsa65.sign(msgBytes, secretKey);
  let sigStr = '';
  for(let i = 0; i < signature.length; i++) sigStr += String.fromCharCode(signature[i]);
  console.log(JSON.stringify({ message, signature: btoa(sigStr), certificate: process.env.DEVICE_CERT_PEM }));
})"
Step 4 — Server verifies (offline)

Verify the certificate chain using the JS SDK's pq.ca.verifyX509Cert(certPem, rootPem) or Python's pq.ca.verify_x509_cert(cert_pem, root_pem) (requires cryptography>=48.0.0). Then verify the message signature with ml_dsa65.verify().

Security checklist for production:
✓ Always verify cert signature against root CA before trusting device public key.
✓ Always check cert expiry (notAfter).
✓ Check CRL via GET /ca/crl for any operation where revocation matters, and check its signature with the root certificate (ca.verifyCrl() / ca.verify_crl()) before you trust it.
✓ Include a timestamp in the signed message and reject messages older than N seconds (replay protection).
✓ Store DEVICE_SECRET_KEY in a secrets manager or hardware secure element — never in plaintext.
✓ Revoke the certificate immediately if a device is decommissioned or compromised.
REST 12 Webhooks — Incoming event reference

FIPSign sends HTTP POST requests to your endpoint when events occur. Webhook configuration is dashboard-only — it requires a user session and is not available via API key.

How to configure: Dashboard → Webhooks (a section of your account, not of a project: one webhook receives the events of all your projects) → + Register webhook → enter your HTTPS endpoint URL → select events → Register. The HMAC secret is shown once — store it securely in your environment variables. Step by step: Dashboard 08.
Incoming request headers
X-PQAuth-Event  ·  event type string (e.g. token.signed)
X-PQAuth-Signature  ·  sha256=<hmac-sha256-hex> — HMAC of the raw body
X-PQAuth-Timestamp  ·  Unix timestamp of the event
Content-Type  ·  application/json
Verify the HMAC signature (Node.js)
import crypto from 'crypto'

app.post('/webhooks/fipsign', express.json(), (req, res) => {
  const sig      = req.headers['x-pqauth-signature']
  const expected = 'sha256=' + crypto.createHmac('sha256', process.env.FIPSIGN_WEBHOOK_SECRET)
    .update(JSON.stringify(req.body)).digest('hex')
  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)))
    return res.status(401).send('Invalid signature')

  const { event, data } = req.body
  // handle event...
  res.status(200).send('ok')
})
Payload structure
{ "event": "token.signed", "timestamp": 1781451218, "data": { ... } }
token.signed — fired on every successful sign() call
sub string  ·  email string | null  ·  role string | null
projectId string  ·  apiKeyName string  ·  tokensUsed number
freeRemaining number  ·  packRemaining number  ·  totalRemaining number
source "free" | "pack" | "free+pack"  ·  month string
token.rejected — fired when verify() rejects a token
reason string — why verification failed: "revoked" when the token was revoked; for every other rejection, the same text /verify returns in error, for example "Token expired 120 seconds ago" (all the texts are in REST 04)
sub string | null — subject extracted from payload if decodable
projectId string  ·  apiKeyName string
token.revoked — fired on every successful revoke() call
sub string  ·  reason string
apiKeyName string  ·  projectId string
freeRemaining number  ·  packRemaining number  ·  totalRemaining number
limit.warning — fired on every successful sign() while 1 to 400 free tokens are left (20% of the monthly limit)
freeRemaining number  ·  freeLimit number (always 2000)
packRemaining number  ·  totalRemaining number
percentUsed number (e.g. 82)  ·  month string  ·  apiKeyName string
limit.reached — fired when free tokens are exhausted and no pack is available
freeRemaining number (always 0)  ·  packRemaining number
totalRemaining number  ·  month string  ·  apiKeyName string
limit.warning and limit.reached carry no projectId: the token pool is shared by all the projects of the account. apiKeyName tells you which key triggered them.
Failed deliveries are retried, so the same event can arrive more than once. Each attempt is one HTTP POST with a 10-second timeout, and only a 2xx response counts as delivered. If an attempt fails, FIPSign retries up to 5 more times, after 30 seconds, 2 minutes, 10 minutes, 30 minutes and 2 hours (6 attempts in total); after the last one the event is dropped. Respond with a 2xx quickly, process events asynchronously, and make your handler idempotent.
REST API · curl

Mandate — Bounded Authorization

A Mandate is a signed ML-DSA credential that defines what an agent can do, for how long, and with what budget.
Base URL: https://api.fipsign.dev  ·  Auth: X-API-Key: pqa_your_key

00 How Mandate works

Mandate is a PQ-Sign feature — not a separate product. It uses the same ML-DSA signing infrastructure as /sign, extended with a real-time mutable state layer that gives you per-agent control.

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

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

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

  • Mutable layer — stored server-side, not covered by the signature: scopeCurrent, budgetConsumed, status. scopeCurrent and status change through PATCH /mandate/:id; budgetConsumed grows with every granted POST /mandate/verify. None of this invalidates the token. If you passed agentPublicKey, that key is also stored server-side — it is fixed at emission and can never be changed.

POST /mandate/verify always checks both layers — signature integrity first, then the live mutable state. An agent cannot act unless both pass.

Optional: proof of possession. By default, a mandate is a bearer credential — whoever holds the token can call POST /mandate/verify. If you want to guarantee the caller is the specific agent the mandate was issued to (not just anyone who obtained the token), pass agentPublicKey when emitting the mandate. The agent then signs each action with its own private key, and POST /mandate/verify requires that signature in addition to the token. The agent's signature covers that exact call (mandate, action and cost), lives at most 60 seconds and works only once. See sections 01, 02 and 02b for details.

Optional: agent-scoped API keys. Proof of possession protects the mandate token itself, but the agent also needs an X-API-Key to call POST /mandate/verify — and by default, that same key can call every other endpoint in the system (/sign, /ca/issue, PATCH /mandate/:id, etc.). If the agent process is compromised, a full-access key is a much bigger blast radius than the mandate alone. To avoid this, create the agent's key as an agent key — restricted scope from the dashboard (your project → + New key → check "Agent key — restricted scope"; there is no API or SDK call to create it — see 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.

Always on: the audit log. FIPSign records everything that happens to a mandate — who emitted it, every call it granted or denied, every change — in a log chained by hashes, and signs what it records. Emitting and changing a mandate return a signed 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.
Typical lifecycle
1. Emit  →  POST /mandate — issue a signed mandate to the agent.
2. Agent acts  →  POST /mandate/verify — the executor sends token + action + cost before doing the work.
3. Control  →  PATCH /mandate/:id — narrow scope, suspend, resume, or revoke at any time.
4. Inspect  →  GET /mandate/:id — check current state, budget consumed, status.
5. Prove  →  receipts and GET /mandate/:id/events — FIPSign signs what it records about the mandate; you keep the proof.
Token cost of each step: see Costs.
Budget — abstract units
budgetTotal is abstract — the unit means whatever you define it to mean. It could be dollars, API calls, credits, operations, or any other countable resource. You pass a cost on each POST /mandate/verify call and the system deducts it from the budget. When budgetTotal: 0, budget checking is disabled — the agent can act any number of times until expiry or revocation; cost is still required on every verify call and is still added to budgetConsumed, only the limit check is skipped. All budget and scope rules in one place: 00b — Integration guide.
Use cases
AI agents  — authorize an agent to act with bounded scope and spend during its session
IoT devices  — authorize a device to operate within defined actions for up to 30 days
Microservices  — authorize a service to call another service within a quota
Delegation  — authorize a process to act on behalf of a user with explicit limits
B2B integrations  — authorize an external system to operate on your API with bounded scope
00b Integration guide — who calls what

Read this before you write code. It answers the questions that come up in almost every integration: which credential lives where, who should call verify, what cost really means, and what the scope and budget rules are. Everything here follows the behavior of the API.

Who holds what
CredentialHeld byWhat it is forIf it leaks
Project API key (pqa_…, full access)Your backend onlyEmit, PATCH, GET and verify.Anyone holding it can emit mandates and control every existing one. Revoke it from the dashboard and create a new one. Never give it to an agent.
Agent key (restricted scope)The process that calls verify, when that is not your backendOnly POST /mandate/verify. Every other endpoint rejects it with 401, as if it did not exist. It is created only in the dashboard (your project → + New key → check "Agent key — restricted scope"): there is no API or SDK call to create it (see 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 itProves the mandate was issued by your project. FIPSign does not store it: if you lose it, emit a new mandate.It is a bearer credential: whoever holds it and an API key of your project can call verify — unless the mandate uses proof of possession (below). Revoke the mandate with PATCH /mandate/:id.
Agent private key (only with agentPublicKey)The agent onlySigns every verify call (proof of possession, see 02b). FIPSign only ever sees the public half.Revoke the mandate and emit a new one with a new keypair — the public key of a mandate can never be changed.
Receipt (the receipt object)Your backend, next to the mandate idProves 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.
What is enforced and what is only a label. On every verify call FIPSign checks the token signature, the proof of possession (if the mandate has one), the mandate status, its expiry, the scope and the budget. agentId and issuedBy are labels: they are signed into the token and returned by GET, but nothing is compared against them at verify time — they are for your audit trail: the emitted entry of the audit log records both.
Recommended architecture
your backend / executor              FIPSign                         the agent
────────────────────────             ───────                         ─────────
1. POST /mandate  ─────────────────► signs, registers state
                  ◄───────────────── { id, token }
2. keep the id, hand the token over ──────────────────────────────►  holds the token
                                                                       │ "please do X"
3. ◄───────────────────────────────────────────────────────────────────┘
   work out the real cost of X
   POST /mandate/verify
     { token, action: "X", cost }  ─► checks signature, status, expiry,
                                       scope, budget — atomically
                                    ◄─ granted / denied
4. only if granted: perform X
PatternUse it whenTrade-offs
A. Your executor calls verify (recommended)The agent reaches your systems through code you control: a tool server, a backend, an API gateway.The agent never holds an API key. cost is computed by you, so the budget is trustworthy. Proof of possession is optional.
B. The agent calls verify itselfAutonomous agents, devices in the field, or code you do not control.Give it an agent key (restricted scope, created in the dashboard) and emit the mandate with agentPublicKey so a copied token is useless. cost is declared by the agent, so the budget only holds if the agent is honest (see below).
Two different costs — do not confuse them. Platform tokens are what FIPSign charges you per API call (see Costs). Budget units are the mandate's own abstract currency: the cost you send on each verify call is deducted from budgetTotal. They are independent — a verify call that consumes 500 budget units still costs the same platform tokens as one that consumes 0.

Who decides cost. FIPSign has no table of action prices: the caller of verify reports cost and the server enforces the limit on the number it is given. So the budget is only as trustworthy as the caller. Under pattern A, compute cost yourself from what the action really does (rows read, dollars moved, tokens spent) — never take it from the agent's request. Under pattern B, proof of possession makes sure nobody can change the cost in transit, but a compromised agent can still under-report it.
Scope and budget rules, in one place
TopicRule
Matching an actionExact, case-sensitive string comparison against scopeCurrent. There are no wildcards or prefixes: claims:* does not match claims:approve, and Claims:Approve does not match claims:approve. Leading and trailing spaces are trimmed on both sides.
Scope limits1–20 items, each at most 64 characters, duplicates removed. Choose a naming convention such as resource:verb and use exactly those strings in your executor.
The scope only means something if you call verifyFIPSign cannot know what your code does. A mandate that allows read:crm does not stop your code from doing something else; it only guarantees that the verify call for read:crm was granted. Your executor must call verify with the action it is about to perform, right before performing it.
NarrowingOnly ever shrinks: the new scope must be a non-empty subset of the current scope. An item that was removed cannot be added back — emit a new mandate instead.
Budget checkA call is granted while budgetConsumed + cost ≤ budgetTotal. Landing exactly on budgetTotal is allowed; after that only cost: 0 calls are granted.
No limitbudgetTotal: 0 disables the limit check. cost is still required and still accumulates in budgetConsumed, so you can measure usage. budgetRemaining is always 0 in that case — use budgetConsumed.
ConcurrencyThe state of each mandate is processed one call at a time on the server, so parallel calls cannot overspend it. With a budget of 5 and ten simultaneous calls of cost 1, exactly five are granted and five get budget_exhausted. Different mandates never block each other.
Denied callsNever change budgetConsumed and never cost platform tokens.
More budget / more timeNot possible on an existing mandate: budgetTotal and expiresAt are signed into the token. Emit a new mandate (and revoke the old one if it must stop working). To avoid gaps, emit the replacement a little before the current one expires — expiresInSeconds in every granted response tells you how long is left.
Statusactive ⇄ suspended can be toggled any number of times. revoked is permanent. narrow also works while suspended.
Expiry and retentionThere is no expired status. After expiresAt verify answers mandate_expired and PATCH answers 409 (except revoke). The mandate can still be read with GET for 24 hours, then it is deleted and GET returns 404. Its audit log stays readable for 365 days (11).
Keep the token safe — and keep the id
The token is returned once, by POST /mandate, and is not stored by FIPSign. Store it where only the component that needs it can read it, and store the mandate id next to it in your own database: the id is what you need to suspend, narrow, revoke and inspect the mandate later. Use GET /mandate (section 05) for audits and reconciliation, not to find ids you did not save.

If a token may have leaked, revoke the mandate by id — PATCH /mandate/:id with {"action": "revoke"} — and emit a new one. Revocation is checked on every verify call, so it takes effect for the next call. It cannot undo a call that was already granted.

Keep the receipts too: the ones that POST /mandate and PATCH /mandate/:id return are your proof of what FIPSign recorded, and FIPSign cannot issue them again (12).
01 POST /mandate — Emit a mandate

Issues a signed mandate for an agent or device. Signs the payload with ML-DSA (using the project's configured algorithm) and registers the mutable state server-side. The token is returned to you and not stored on the server — store it securely and pass it to the agent. Token cost: see Costs.

Request
curl -s -X POST https://api.fipsign.dev/mandate \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agentId":          "agent-reporting-v2",
    "issuedBy":         "user@empresa.com",
    "scope":            ["sign", "verify", "read:crm"],
    "budgetTotal":      1000,
    "expiresInSeconds": 28800,
    "correlationId":    "ticket-4821"
  }'
Save the mandate and its receipt for subsequent steps
MANDATE=$(curl -s -X POST https://api.fipsign.dev/mandate \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"agentId":"agent-test","issuedBy":"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
Request body fields
FieldTypeRequiredDescription
agentIdstringYesIdentifier for the agent or device. Max 128 characters. Covered by ML-DSA signature — immutable after emission.
issuedBystringYesWho authorized this mandate (e.g. email, user ID, system name). Max 256 characters. Covered by ML-DSA signature — immutable.
scopestring[]YesActions the agent is authorized to perform. 1–20 items, each max 64 characters. Free-form strings that you define (for example claims:approve). Leading/trailing spaces are trimmed and duplicates are removed automatically. At verify time an action must match one item exactly — case-sensitive, no wildcards (see 00b). Covered by ML-DSA signature as scopeOriginal — immutable.
budgetTotalinteger ≥ 0YesMaximum budget units for this mandate (whole number, max 9,007,199,254,740,991). Abstract — you define what a unit means. 0 disables budget checking entirely (cost is still required on every verify call). It cannot be increased later — to give an agent more budget, emit a new mandate. Covered by ML-DSA signature — immutable.
expiresInSecondsintegerYesMandate lifetime in seconds (whole number). Min 60 (1 minute), max 2,592,000 (30 days). A mandate cannot be extended afterwards — emit a new one.
agentPublicKeystringNoBase64 of the agent's raw ML-DSA public key, generated by you with any ML-DSA library (it has nothing to do with the CA). Any of the three variants is accepted, whatever algorithm your project uses — the decoded key must be exactly 1312 (ML-DSA-44), 1952 (ML-DSA-65) or 2592 (ML-DSA-87) bytes, otherwise the request is rejected with 400 before anything is charged. Max 4096 characters. Stored once: it cannot be changed later (to change the agent's key, emit a new mandate) and is never returned by the API — GET only shows requiresAgentSignature. If provided, POST /mandate/verify requires the agent to sign each call with the matching private key (proof of possession) — see sections 02 and 02b. If omitted, the mandate is a plain bearer token.
correlationIdstringNoYour 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.
Response — 201 Created
{
  "success": true,
  "mandate": {
    "id": "mdt_1418d1072c503dd20ef9416f347d19b9", // mandate ID — use for PATCH and GET
    "agentId": "agent-reporting-v2",
    "issuedBy": "user@empresa.com",
    "scope": ["sign", "verify", "read:crm"],
    "budgetTotal":1000,
    "expiresAt": 1785719739, // Unix timestamp (issuedAt + 28800)
    "status": "active",
    "token": {
      "payload": "eyJzdWIi...", // base64 — give this to the agent
      "signature": "RspqteGl...", // ML-DSA signature
      "algorithm": "ML-DSA-65", // project's configured algorithm
      "issuedAt": 1785690939
    },
    "requiresAgentSignature": true // only present when "agentPublicKey" was provided
  },
  "usage": {
    "freeRemaining": 9999,
    "packRemaining": 0,
    "totalRemaining": 9999,
    "month": "2026-08"
  },
  "receipt": { // FIPSign's signed record of this emission: keep it
    "signed": "{\"at\":1785690939,\"hash\":\"fbf0d9f67b8eaccb5fe7310c48dee0963612da3539e866d76561ffff33bc36fc\",\"kind\":\"mandate.receipt\",\"mandateId\":\"mdt_1418d1072c503dd20ef9416f347d19b9\",\"projectId\":\"proj_8f3c1a52d4e7b609a1c2d3e4f5061728\",\"seq\":1,\"v\":1}",
    "signature": "k3Vq7yRkWm9Ht2...", // ML-DSA signature, base64
    "algorithm": "ML-DSA-65",
    "keyFingerprint": "9c1e4a7b...", // SHA-256 of the public key that signed
    "event": {
      "seq": 1,
      "type": "emitted",
      "at": 1785690939,
      "prevHash": "0000000000000000000000000000000000000000000000000000000000000000",
      "hash": "fbf0d9f67b8eaccb5fe7310c48dee0963612da3539e866d76561ffff33bc36fc",
      "body": "{\"agentId\":\"agent-reporting-v2\",\"at\":1785690939,\"budgetTotal\":1000,\"correlationId\":\"ticket-4821\",\"expiresAt\":1785719739,\"issuedBy\":\"user@empresa.com\",\"keyId\":\"fcf0c9544f41bda5\",\"keyName\":\"backend\",\"mandateId\":\"mdt_1418d1072c503dd20ef9416f347d19b9\",\"scope\":[\"sign\",\"verify\",\"read:crm\"],\"seq\":1,\"tokenDigest\":\"3f904262870078c3418f01ed710a8cf77d8adbe6f5006e5a550b3f93b9c913a8\",\"type\":\"emitted\",\"v\":1}"
    }
  }
}
The token is not stored on the server. Store it securely and pass it to the agent — just like a token from POST /sign. If lost, you must emit a new mandate. The mandate id is stored server-side and is what you use for PATCH and GET operations.
Keep the 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.
Generating the agent's keypair. agentPublicKey is the plain, raw ML-DSA public key of a keypair that you generate — it is not tied to the CA (POST /ca/issue) and needs no certificate. Any of the three variants works (ML-DSA-44, -65 or -87), whatever algorithm your project uses; the size of the decoded key tells them apart: 1312, 1952 or 2592 bytes. Use any FIPS 204 ML-DSA library — for example @noble/post-quantum in JavaScript or cryptography>=48.0.0 in Python (both are used in the copy-paste examples of 02b). Keep the private key on the agent and never send it anywhere: only the public key goes into this request.
02 POST /mandate/verify — Verify before acting

The agent calls this before executing any authorized action. Verification runs in two phases: first the ML-DSA signature (cryptographic integrity), then the live mutable state (current scope, budget, status). Only if both pass is the action granted. Token cost: see Costs. It is charged only on granted responses; denied calls are free.

Denied requests are free. Every denial — invalid_signature, mandate_expired, mandate_revoked, mandate_suspended, scope_not_authorized, budget_exhausted and all four agent_signature_* reasons — consumes neither platform tokens nor mandate budget. A denial is HTTP 403 with a JSON body, not a transport error: read result and reason from the body (some HTTP clients throw on any non-2xx status — see 02c).
Request
curl -s -X POST https://api.fipsign.dev/mandate/verify \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"token\": $MANDATE_TOKEN, \"action\": \"sign\", \"cost\": 5}"
Ask for a receipt, and tag the call with your own id
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\"}"
Request body fields
FieldTypeRequiredDescription
tokenobjectYesThe full PQToken object returned by POST /mandate — all four fields: payload, signature, algorithm, issuedAt.
actionstringYesThe action the agent wants to perform. Must match one item of scopeCurrent exactly (case-sensitive); leading/trailing spaces are trimmed. Max 64 characters.
costinteger ≥ 0YesBudget units to add to budgetConsumed if the call is granted (whole number, max 9,007,199,254,740,991). Required on every call — pass 0 for actions with no budget impact. When budgetTotal is 0 it is still required and still added to budgetConsumed; only the limit check is skipped, so consumption stays visible. If the mandate uses proof of possession, this value must equal the cost signed inside agentSignature. Not to be confused with the platform tokens FIPSign charges you (see 06).
agentSignatureobjectOnly if the mandate has agentPublicKeyProof-of-possession signature: an object with payload, signature and algorithm (plus an optional issuedAt), signed with the agent's private key — not the project's. algorithm is the variant of the agent's key, written exactly "ML-DSA-44", "ML-DSA-65" or "ML-DSA-87". Required when the mandate was emitted with agentPublicKey; ignored for mandates without it. Full contract and code in 02b.
receiptbooleanNotrue 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.
correlationIdstringNoYour 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.
Building agentSignature — short version. The agent signs, with its own private key, this JSON: {"sub": "<mandateId>", "action": "<action>", "cost": <cost>, "iat": <now>, "exp": <now + 30>} — sub, action and cost must be exactly the mandate's id and the action and cost of the same request. It base64-encodes that JSON, signs the base64 text, and sends {payload, signature, algorithm, issuedAt} as agentSignature, where algorithm is the variant of the agent's key written exactly "ML-DSA-44", "ML-DSA-65" or "ML-DSA-87" (any other text is refused). Rules: iat and exp are mandatory, exp − iat may not exceed 60 seconds, iat may not be more than 30 seconds in the future, and each signature works only once. Extra fields in the payload are ignored (handy for a nonce). Step by step, with Node and Python code, in 02b.
Response — granted (HTTP 200)
{
  "success": true,
  "result": "granted",
  "actionMatched": "sign", // the action that matched scopeCurrent
  "budgetRemaining": 995, // budgetTotal - budgetConsumed after this call
  "expiresInSeconds": 3340, // seconds until mandate expiry
  "usage": { "freeRemaining": 9998, "packRemaining": 0, "totalRemaining": 9998, "month": "2026-08" },
  "receipt": { // only when the request has "receipt": true
    "signed": "{\"at\":1785690981,\"hash\":\"6c103d48c15247e20395cafc445cab47bf61e35358870f95300bef55a28f20e0\",\"kind\":\"mandate.receipt\",\"mandateId\":\"mdt_1418d1072c503dd20ef9416f347d19b9\",\"projectId\":\"proj_8f3c1a52d4e7b609a1c2d3e4f5061728\",\"seq\":2,\"v\":1}",
    "signature": "k3Vq7yRkWm9Ht2...", // ML-DSA signature, base64
    "algorithm": "ML-DSA-65",
    "keyFingerprint": "9c1e4a7b...", // SHA-256 of the public key that signed
    "event": {
      "seq": 2,
      "type": "verify_granted",
      "at": 1785690981,
      "prevHash": "fbf0d9f67b8eaccb5fe7310c48dee0963612da3539e866d76561ffff33bc36fc",
      "hash": "6c103d48c15247e20395cafc445cab47bf61e35358870f95300bef55a28f20e0",
      "body": "{\"action\":\"sign\",\"at\":1785690981,\"budgetConsumed\":5,\"correlationId\":\"req-77\",\"cost\":5,\"keyId\":\"fcf0c9544f41bda5\",\"keyName\":\"backend\",\"mandateId\":\"mdt_1418d1072c503dd20ef9416f347d19b9\",\"seq\":2,\"type\":\"verify_granted\",\"v\":1}"
    }
  }
}
Response — denied (HTTP 403)
// scope_not_authorized — action not in scopeCurrent
{ "success": false, "result": "denied", "reason": "scope_not_authorized", "authorizedScope": ["verify"] }

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

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

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

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

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

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

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

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

// agent_signature_replayed — this exact agentSignature was already used (each one works once)
{ "success": false, "result": "denied", "reason": "agent_signature_replayed" }
Denials are written to the log, and can carry a receipt. A verify denied by a check on the mandate (every 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.
Order of checks. 1) The ML-DSA signature of the mandate token: invalid_signature — or mandate_expired when the token is genuine but past its expiry. 2) If the mandate requires agentSignature: is it present, does it verify, is its lifetime valid, does it match this mandate, action and cost (agent_signature_required / _invalid / _mismatch). 3) The live state, in this order: mandate_revoked → mandate_suspended → mandate_expired → agent_signature_replayed → scope_not_authorized → budget_exhausted. Only when everything passes is the budget charged and the action granted; a denial changes nothing.

One consequence worth knowing: for a proof-of-possession mandate, step 2 runs before step 3, so a revoked mandate called without an agentSignature answers agent_signature_required, not mandate_revoked. Also, if a mandate is both suspended and out of budget you get mandate_suspended: status is always checked before budget.
Cost is declared by the caller. There is no server-side table mapping actions to costs — whoever calls POST /mandate/verify reports cost and the server trusts it. That is exactly right when the caller is your own executor, which knows the real cost of the action (recommended — see 00b). If the agent calls this endpoint directly (for example via LLM function-calling with no layer in between), a compromised or misled agent can under-report the cost — even send 0 — and budgetConsumed will never reflect real usage. Proof of possession binds the cost to the agent's signature, so nobody can change it in transit, but it does not make it truthful. If you can't fully trust the caller, put a layer between the agent and this endpoint that computes cost independently, or treat the budget as advisory rather than a hard guarantee.
02b Proof of possession — step by step

Turn a mandate from a bearer token into one that only a specific agent can use. This section is the full recipe: the exact signing contract, copy-paste code for Node.js and Python, and what every error means.

Using the JS or Python SDK? You do not need to write steps 1 to 3 yourself: generateAgentKeyPair() / generate_agent_key_pair() make the key pair and signAgentCall() / sign_agent_call() build the signature in a few lines. See SDK 14 — Proof of possession with the SDK (JS) or PY 13 — Proof of possession with the SDK (Python). Keep reading if you call the REST API directly or want to know what happens under the hood.

Keys are not interchangeable. The recipes below and the Python SDK store the 32-byte seed as the private key (the Python SDK also needs the algorithm of the key). The JS SDK's secretKey is the full expanded key (2560, 4032 or 4896 bytes) and rejects a seed with INVALID_SECRET_KEY: generate and sign with the same tool.
When to use it
By default a mandate is a bearer credential: anyone who has the token and a project API key can use it. Proof of possession (PoP) closes that gap: on every verify call the agent must sign that exact call with a private key that never leaves the agent. A copied token is then useless on its own.

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

Any FIPS 204 ML-DSA library works, and any of the three variants: the project's algorithm does not matter, and the agent's key is unrelated to the CA. The examples use ML-DSA-44 (smallest and fastest). Whichever variant you pick, you will write its name in the algorithm field of every signature (Step 3). This table is the complete list: the text in the second column is what you type, character for character.

Variant of the agent's keyText for algorithmNode.js (@noble/post-quantum)Python (cryptography)Public keySignature
ML-DSA-44"ML-DSA-44"ml_dsa44MLDSA44PrivateKey1312 bytes2420 bytes
ML-DSA-65"ML-DSA-65"ml_dsa65MLDSA65PrivateKey1952 bytes3309 bytes
ML-DSA-87"ML-DSA-87"ml_dsa87MLDSA87PrivateKey2592 bytes4627 bytes
Write the text exactly as shown. Capital letters, hyphens, no spaces. The server compares it character by character with these three values, so "ml-dsa-44", "ML-DSA44", "ml_dsa44", "ML_DSA_44" or "ML-DSA-44 " (with a trailing space) are all refused. The answer is agent_signature_invalid — the same as for a bad signature — so a typo here is easy to miss. Do not take the text from the name of a class or package (ml_dsa44, MLDSA44PrivateKey): those are names of code, not values of algorithm.

Pick the variant once. The class that generates the key and the text you write in algorithm must come from the same row of this table. To use another variant, change both, in Step 1 and in Step 3. If you ever lose track of the variant of a key, the size of its public key tells: 1312 bytes is ML-DSA-44, 1952 is ML-DSA-65, 2592 is ML-DSA-87.
Node.js
// npm install @noble/post-quantum        (Node 20+)
import { ml_dsa44 } from '@noble/post-quantum/ml-dsa.js'
import { randomBytes } from 'node:crypto'

// The 32-byte seed IS the private key. It is the only secret you keep.
const seed = randomBytes(32)
// ml_dsa44 is the "ML-DSA-44" row of the table above
const { publicKey } = ml_dsa44.keygen(seed)

// Goes into "agentPublicKey" when you emit the mandate
const agentPublicKey = Buffer.from(publicKey).toString('base64')

// Save this on the agent side (secret store, or a file only the agent can read), never share it.
// Step 3 uses it under the same name: storedSeed
const storedSeed = Buffer.from(seed).toString('base64')
Python
# pip install "cryptography>=48.0.0"
import base64
from cryptography.hazmat.primitives.asymmetric.mldsa import MLDSA44PrivateKey

# MLDSA44PrivateKey is the "ML-DSA-44" row of the table above
agent_key = MLDSA44PrivateKey.generate()
# The 32-byte seed IS the private key. It is the only secret you keep.
seed = agent_key.private_bytes_raw()

# Goes into "agentPublicKey" when you emit the mandate
public_raw = agent_key.public_key().public_bytes_raw()
agent_public_key = base64.b64encode(public_raw).decode()

# Save this on the agent side (secret store, or a file only the agent can read), never share it.
# Step 3 uses it under the same name: stored_seed
stored_seed = base64.b64encode(seed).decode()
What to store. The 32-byte seed is the private key: it is portable, and both libraries above derive exactly the same keypair from the same seed. Keep it in a secret store or a file readable only by the agent's user (chmod 600). Only the public key travels to your backend.

Python note. ML-DSA in cryptography needs version 48.0.0 or newer (the same requirement as the FIPSign Python SDK). A cryptography built against an old system OpenSSL may raise UnsupportedAlgorithm; the regular pip wheel works.
Step 2 — Emit the mandate with the public key
curl -s -X POST https://api.fipsign.dev/mandate \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"agentId\":\"agent-42\",\"issuedBy\":\"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.

Step 3 — Sign each call

The signature is a token with the same shape as any FIPSign token: payload (base64 of a JSON), signature, algorithm. This is the whole contract:

ItemRule
What is signedThe text of the base64 payload string, encoded as UTF-8 bytes — not the JSON itself and not the decoded bytes. This is the most common mistake (see the table below).
Signature schemePlain ML-DSA (FIPS 204): empty context string, no pre-hash (not HashML-DSA). Both libraries above do this by default.
payloadbase64 (standard alphabet, with padding) of the UTF-8 JSON {"sub", "action", "cost", "iat", "exp"}. At most 16,384 characters.
subExactly the mandate id.
actionExactly the action you send in the same request (without leading/trailing spaces).
costExactly the cost you send in the same request, as a number. A payload without cost never matches.
iat, expUnix seconds, both mandatory. exp must be after iat; exp − iat may not exceed 60 seconds; iat may not be more than 30 seconds ahead of the server clock; and exp must not have passed. The examples use iat = now, exp = now + 30.
algorithm"ML-DSA-44", "ML-DSA-65" or "ML-DSA-87" — exactly one of these three texts (see the table in Step 1), the one that matches the agent's key. Write it in every signature: FIPSign uses this text to pick the verification algorithm, and a raw ML-DSA key carries no label of its own. Any other text ("ml-dsa-44", "ML-DSA44"…) or the wrong variant gives agent_signature_invalid; a missing one, HTTP 400. (The SDKs handle it: the JS SDK works it out from the key, the Python SDK takes it as algorithm=.)
signaturebase64 of the raw signature: 2420, 3309 or 4627 bytes depending on the variant.
issuedAtOptional and ignored. Included in the examples only because it is part of the usual token shape.
Extra payload fieldsIgnored, but covered by the signature. Add a random nonce if your signer is deterministic (see the note below).
Lifetime and reuseEach signature is accepted once. It is only recorded when the call is granted, so a denied call (for example budget_exhausted) does not burn it — but do not rely on that: sign again for every attempt.
Node.js
import { ml_dsa44 } from '@noble/post-quantum/ml-dsa.js'

// Exactly this text, in every signature. Same row of the table as ml_dsa44 (Step 1).
const ALGORITHM = 'ML-DSA-44'

// storedSeed: the base64 seed that Step 1 saved
function signCall(storedSeed, mandateId, action, cost) {
  // Re-derive the signing key from the seed
  const { secretKey } = ml_dsa44.keygen(Buffer.from(storedSeed, 'base64'))
  const now = Math.floor(Date.now() / 1000)
  const payload = { sub: mandateId, action, cost, iat: now, exp: now + 30 }
  const payloadB64 = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64')
  // Sign the base64 TEXT (UTF-8 bytes), not the JSON
  const message = new TextEncoder().encode(payloadB64)
  const signature = ml_dsa44.sign(message, secretKey)
  return {
    payload:   payloadB64,
    signature: Buffer.from(signature).toString('base64'),
    algorithm: ALGORITHM,    // exactly the text from the table in Step 1
    issuedAt:  now,           // optional
  }
}
Python
import base64
import json
import time

from cryptography.hazmat.primitives.asymmetric.mldsa import MLDSA44PrivateKey

# Exactly this text, in every signature. Same row of the table as MLDSA44PrivateKey (Step 1).
ALGORITHM = "ML-DSA-44"


def sign_call(stored_seed: str, mandate_id: str, action: str, cost: int) -> dict:
    # stored_seed: the base64 seed that Step 1 saved. Re-derive the signing key from it.
    agent_key = MLDSA44PrivateKey.from_seed_bytes(base64.b64decode(stored_seed))
    now = int(time.time())
    payload = {
        "sub": mandate_id, "action": action, "cost": cost,
        "iat": now, "exp": now + 30,
    }
    payload_b64 = base64.b64encode(json.dumps(payload).encode("utf-8")).decode()
    # Sign the base64 TEXT, not the JSON
    signature = agent_key.sign(payload_b64.encode("ascii"))
    return {
        "payload": payload_b64,
        "signature": base64.b64encode(signature).decode(),
        "algorithm": ALGORITHM,    # exactly the text from the table in Step 1
        "issuedAt": now,            # optional
    }
Step 4 — Send verify with the signature
{
  "token":  { "payload": "eyJ...", "signature": "...", "algorithm": "ML-DSA-65", "issuedAt": 1785690939 },
  "action": "sign",
  "cost":   1,
  "agentSignature": {
    "payload":   "eyJzdWIiOiJtZHRfLi4uIn0=",
    "signature": "k3Jd8...",
    "algorithm": "ML-DSA-44",
    "issuedAt":  1785690940
  }
}

token is the mandate token exactly as returned by POST /mandate (an object, not a string) and agentSignature is the object built in step 3. Send it to POST /mandate/verify as in section 02.

Two algorithm fields, two different things. token.algorithm belongs to the mandate token: send the token exactly as POST /mandate returned it and never edit it (it is the variant of your project; "ML-DSA-65" in the example). agentSignature.algorithm is the one you write in Step 3: the variant of the agent's key ("ML-DSA-44" in the example). They may differ, and that is fine.

When something goes wrong
ResponseTypical causeFix
agent_signature_requiredagentSignature missing, or not an object.Sign the call and send it. Mandates emitted with agentPublicKey always need it.
agent_signature_invalidSigned with the wrong private key; algorithm is not exactly one of "ML-DSA-44", "ML-DSA-65", "ML-DSA-87" (for example "ml-dsa-44") or is not the key's variant; signed the JSON or the decoded bytes instead of the base64 text; payload or signature altered after signing; iat/exp missing or out of range; the signature already expired.Check the contract table above, then the clock: the agent's clock must be close to real time (see below).
agent_signature_mismatchThe signature is valid but its sub, action or cost differ from the request — including a payload with no cost, or an action sent with different spelling.Sign exactly the values you send. Change one and you must sign again.
agent_signature_replayedThis exact signature was already accepted.Never cache or reuse signatures: sign again for every call.
HTTP 400 Invalid "agentSignature" format…The object lacks payload, signature or algorithm.Send all three (and issuedAt for completeness).
Clocks. With the 30-second window used here the agent's clock may be off by about 30 seconds in either direction. Keep clocks synchronized (NTP). On devices where you cannot, use exp = now + 60: it tolerates an agent clock up to 60 seconds behind the server; the limit for a clock ahead stays at 30 seconds.

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

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

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

Emits a mandate for a fresh keypair and then runs the four interesting cases against the live API. Save as pop-demo.mjs, run npm install @noble/post-quantum, then FIPSIGN_API_KEY=pqa_... node pop-demo.mjs (Node 20+).

// pop-demo.mjs — proof of possession, end to end.
// Setup: npm install @noble/post-quantum        (Node 20+)
// Run:   FIPSIGN_API_KEY=pqa_... node pop-demo.mjs
import { ml_dsa44 } from '@noble/post-quantum/ml-dsa.js'
import { randomBytes } from 'node:crypto'

const API = 'https://api.fipsign.dev'
const b64 = (bytes) => Buffer.from(bytes).toString('base64')

// A denied verify is HTTP 403 with a JSON body:
// return status + body instead of throwing.
async function call(method, path, body) {
  const res = await fetch(API + path, {
    method,
    headers: {
      'Content-Type': 'application/json',
      'X-API-Key': process.env.FIPSIGN_API_KEY,
    },
    body: body && JSON.stringify(body),
  })
  return { status: res.status, data: await res.json() }
}

// ── AGENT SIDE ──────────────────────────────────────────────────────────
// The agent creates its own keypair. The seed is the private key: keep it secret
// (in real life, store b64(seed) in a secret store).
const seed = randomBytes(32)
const { publicKey, secretKey } = ml_dsa44.keygen(seed)

// Sign ONE call: this mandate + this action + this cost. Valid 30 s, usable once.
function signCall(mandateId, action, cost) {
  const now = Math.floor(Date.now() / 1000)
  const payload = { sub: mandateId, action, cost, iat: now, exp: now + 30 }
  const payloadB64 = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64')
  // Sign the base64 TEXT (UTF-8 bytes), not the JSON
  const message = new TextEncoder().encode(payloadB64)
  const signature = ml_dsa44.sign(message, secretKey)
  return {
    payload: payloadB64,
    signature: b64(signature),
    algorithm: 'ML-DSA-44',   // exactly this text; it pairs with ml_dsa44
    issuedAt: now,
  }
}

// ── YOUR BACKEND ────────────────────────────────────────────────────────
// 1. Emit the mandate, registering the agent's PUBLIC key.
const emitted = await call('POST', '/mandate', {
  agentId: 'agent-pop-demo',
  issuedBy: '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)
Complete example — Python

The same flow. Save as pop_demo.py, run pip install "cryptography>=48.0.0" requests, then FIPSIGN_API_KEY=pqa_... python pop_demo.py.

# pop_demo.py — proof of possession, end to end.
# Setup: pip install "cryptography>=48.0.0" requests
# Run:   FIPSIGN_API_KEY=pqa_... python pop_demo.py
import base64
import json
import os
import time

import requests
from cryptography.hazmat.primitives.asymmetric.mldsa import MLDSA44PrivateKey

API = "https://api.fipsign.dev"
HEADERS = {"X-API-Key": os.environ["FIPSIGN_API_KEY"]}


def b64(data: bytes) -> str:
    return base64.b64encode(data).decode()


def call(method, path, body=None):
    # A denied verify is HTTP 403 with a JSON body:
    # return status + body instead of raising.
    r = requests.request(method, API + path, headers=HEADERS, json=body, timeout=30)
    return r.status_code, r.json()


# ── AGENT SIDE ──────────────────────────────────────────────────────────
# The agent creates its own keypair. The seed is the private key: keep it secret
# (in real life, store b64(agent_key.private_bytes_raw()) in a secret store).
agent_key = MLDSA44PrivateKey.generate()
agent_public_key = b64(agent_key.public_key().public_bytes_raw())


def sign_call(mandate_id: str, action: str, cost: int) -> dict:
    """Sign ONE call: mandate + action + cost. Valid 30 s, usable once."""
    now = int(time.time())
    payload = {
        "sub": mandate_id, "action": action, "cost": cost,
        "iat": now, "exp": now + 30,
    }
    payload_b64 = b64(json.dumps(payload).encode("utf-8"))
    # Sign the base64 TEXT, not the JSON
    signature = agent_key.sign(payload_b64.encode("ascii"))
    return {
        "payload": payload_b64,
        "signature": b64(signature),
        "algorithm": "ML-DSA-44",   # exactly this text; it pairs with MLDSA44PrivateKey
        "issuedAt": now,
    }


# ── YOUR BACKEND ────────────────────────────────────────────────────────
# 1. Emit the mandate, registering the agent's PUBLIC key.
status, data = call("POST", "/mandate", {
    "agentId": "agent-pop-demo",
    "issuedBy": "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"))
Expected output (both; the first line prints True in Python and true in Node)
emitted mdt_… · requiresAgentSignature: true
no signature      → 403 denied agent_signature_required
with signature    → 200 granted · budgetRemaining: 9
same signature    → 403 denied agent_signature_replayed
cost changed      → 403 denied agent_signature_mismatch
02c Responses, retries & edge cases

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

Read the body, not just the status
HTTPMeaningWhat your code should do
200result: "granted". The budget was charged.Perform the action.
403result: "denied" with a reason. A normal answer, not a failure of the call: it is free and changes nothing.Do not perform the action. Decide what to do from the reason (next table). Some HTTP clients throw on any non-2xx status: catch that and still read the JSON body.
400 / 415The request itself is malformed (error says what).A bug in your code — fix it. Retrying the same request will fail the same way. Nothing was charged.
401API key missing or invalid, or an agent key used somewhere other than verify.Fix the key. Nothing was charged.
429Rate limit exceeded… ("code": "rate_limited") or Token limit reached… ("code": "token_quota_exhausted"). In both cases the mandate budget was not consumed.Rate limit: wait the seconds in the Retry-After header and retry. Token limit: your monthly platform tokens are exhausted — buy a pack from the dashboard (Dashboard 09).
5xx / timeout / network errorUnknown outcome.See "Retries" below.
Fail closed. Allow the action only when the HTTP status is 200 and result === "granted". Everything else — a denial, a timeout, a 5xx, a body you cannot parse — means "do not act". This helper does exactly that.
Node.js
// ok: true only when FIPSign explicitly granted the action.
// Anything else means "do not act".
async function authorize({ token, action, cost, agentSignature }) {
  const headers = {
    'Content-Type': 'application/json',
    'X-API-Key': process.env.FIPSIGN_API_KEY,
  }
  const body = { token, action, cost, ...(agentSignature && { agentSignature }) }
  let res
  try {
    res = await fetch('https://api.fipsign.dev/mandate/verify', {
      method: 'POST',
      headers,
      body: JSON.stringify(body),
      signal: AbortSignal.timeout(10_000),
    })
  } catch {
    // Timeout or network failure: the call may or may not have been applied
    // (see "Retries and idempotency" below)
    return { ok: false, reason: 'network_error', unknownOutcome: true }
  }
  // A 403 carries a JSON body too: it is a normal answer
  const data = await res.json().catch(() => null)
  if (res.status === 200 && data?.result === 'granted') {
    return { ok: true, budgetRemaining: data.budgetRemaining }
  }
  return {
    ok: false,
    status: res.status,
    reason: data?.reason ?? data?.error ?? 'unknown',
    unknownOutcome: res.status >= 500,
  }
}
Python
import os

import requests

API = "https://api.fipsign.dev"


def authorize(token, action, cost, agent_signature=None):
    """ok=True only when FIPSign explicitly granted the action.
    Anything else means "do not act"."""
    body = {"token": token, "action": action, "cost": cost}
    if agent_signature:
        body["agentSignature"] = agent_signature
    headers = {"X-API-Key": os.environ["FIPSIGN_API_KEY"]}
    try:
        r = requests.post(f"{API}/mandate/verify", json=body, headers=headers,
                          timeout=10)
    except requests.RequestException:
        # Timeout or network failure: the call may or may not have been applied
        # (see "Retries and idempotency" below)
        return {"ok": False, "reason": "network_error", "unknown_outcome": True}
    try:
        data = r.json()          # a 403 carries a JSON body too: it is a normal answer
    except ValueError:
        data = {}
    if r.status_code == 200 and data.get("result") == "granted":
        return {"ok": True, "budget_remaining": data.get("budgetRemaining")}
    return {
        "ok": False,
        "status": r.status_code,
        "reason": data.get("reason") or data.get("error") or "unknown",
        "unknown_outcome": r.status_code >= 500,
    }
What to do for each reason
reasonMeaningTypical response
invalid_signatureThe token was altered, is not a mandate token (for example one from POST /sign), belongs to another project, or was signed by a project key that is no longer accepted.Reject and log it. Retrying will not help: it is either a bug or an attack.
mandate_expiredThe mandate is past its expiry (or was deleted 24 hours after it).Stop. Emit a new mandate if the agent should continue.
mandate_revokedPermanently revoked.Stop the agent for good. Never retry.
mandate_suspendedPaused with PATCH … suspend.Pause the work and retry later, with a delay — not in a tight loop. It works again after resume.
scope_not_authorizedThe action is not in scopeCurrent. The response includes authorizedScope.Refuse the action. You may tell the agent what it is allowed to do.
budget_exhaustedbudgetConsumed + cost would exceed budgetTotal. The response includes budgetConsumedUnits and budgetTotalUnits.Stop, or ask for a new mandate. A cheaper action (smaller cost) may still fit.
agent_signature_required / _invalid / _mismatch / _replayedProof-of-possession failures.A problem in the signing code — see 02b.
Retries and idempotency
A granted call takes effect immediately. If the response is lost — timeout, dropped connection, a 5xx — you cannot know whether the call was applied. Denied calls are free and change nothing, so retrying after a denial is always safe; the doubt exists only for calls that may have been granted. The JavaScript and Python SDKs tell the two cases apart for you: a denial that FIPSign decided comes back with failure: 'rejected' (or another value that means nothing was consumed), and a lost answer with failure: 'outcome_unknown' — see JS SDK and Python SDK.

Mandates with proof of possession give you a free idempotency key. Re-send the same request, with the same agentSignature, while the signature is still valid (its exp, at most 60 seconds):
  • granted → the first attempt had not been applied; it is now, exactly once.
  • agent_signature_replayed → the first attempt was applied and its cost is already counted. Perform the action and do not retry again.
After the signature has expired you can no longer tell — read GET /mandate/:id as described next.

Bearer mandates have no such key. Re-sending the same verify call charges its cost a second time if the first was applied. When the outcome is unknown, read GET /mandate/:id and compare budgetConsumed with the value you had before the call. For actions where a double charge is unacceptable, use proof of possession.

Ask the log. If you gave each attempt its own correlationId, the audit log of the mandate says whether that attempt was applied, without guessing from the budget: see 15.

Errors that did not consume anything. 429 Token limit reached restores the mandate budget (and, with proof of possession, releases the signature so you can send it again once you have tokens). 400, 401, 403 and 415 never consume anything.
Monitor your platform quota. Every granted response includes usage.totalRemaining. Alert on it: when it reaches zero, verify calls start answering 429 and no agent can act, even though its mandate is perfectly valid.
Timing, revocation and clocks
Revocation and suspension are immediate for the next call, not for calls in flight. "Verify, then act" is not atomic: if you verify at 10:00:00 and the action takes a minute, a revoke at 10:00:30 does not interrupt it. For long or irreversible actions, verify right before the irreversible step, or split the work into smaller actions that are each verified.

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

Renewing. Mandates cannot be extended. Emit the next one before the current one expires (both are valid at the same time), switch the agent over, and revoke the old one if it should stop working.
Common mistakes
What you seeUsual causeFix
400 "token" is requiredThe token was sent as a string: wrapped in quotes in a shell body ("token": "$MANDATE_TOKEN"), or serialized twice (JSON.stringify(token) inside another JSON.stringify).Send the token as a JSON object, exactly as returned by POST /mandate.
400 "cost" must be a non-negative integercost is missing, a string ("5"), a decimal (1.5) or negative.Always send a whole number ≥ 0 — 0 for actions with no budget impact.
400 "cost" must not exceed 9007199254740991cost is larger than 9,007,199,254,740,991 (253 − 1).Send a smaller whole number.
403 invalid_signature with a token you just createdIt came from POST /sign (or another project) — only tokens from POST /mandate are accepted here.Use the token returned by POST /mandate.
401 This is a Mandate token… on /verify, 400 on /revokeThe generic token endpoints refuse Mandate tokens on purpose.Use POST /mandate/verify and PATCH /mandate/:id.
401 API key required or invalid although the key is rightIt is an agent key (restricted scope) used on emit, PATCH or GET.Use a full-access key on your backend; agent keys only work on POST /mandate/verify.
scope_not_authorized for an action that "is in the scope"Different case or spelling, or a wildcard (claims:*) that is not supported.Send the exact string, and read authorizedScope in the response.
budgetRemaining is 0 but calls are grantedbudgetTotal is 0 (no limit).Use budgetConsumed to see usage.
budgetConsumed is higher than you expectedRetried calls that had already been applied, or a cost that does not reflect the real cost.See "Retries" above; compute cost in your executor.
agent_signature_invalid although the code looks rightSigned the JSON instead of the base64 text; wrong algorithm (it must be exactly "ML-DSA-44", "ML-DSA-65" or "ML-DSA-87", and the one of the agent's key); clock drift.See 02b.
400 "agentPublicKey" has an invalid sizeYou sent a private key, a PEM/DER-wrapped key, hex instead of base64, or a truncated value.Send the base64 of the raw public key: 1312, 1952 or 2592 bytes.
03 PATCH /mandate/:id — Control the mandate

Modify the mutable state of a mandate. 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).

narrow — reduce scope permanently
curl -s -X PATCH https://api.fipsign.dev/mandate/$MANDATE_ID \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"action": "narrow", "scope": ["verify"]}'
narrow reduces scopeCurrent permanently within the mandate's lifetime. The new scope must be a subset of the current scopeCurrent — not just scopeOriginal. Narrowing is monotonic: each call can only shrink further from wherever the scope stands now, so you cannot recover an action a previous narrow already removed, even if it's still within the originally signed scope. You can narrow multiple times, also while the mandate is suspended; narrowing to exactly the current scope is accepted and changes nothing (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.
suspend — pause temporarily
curl -s -X PATCH https://api.fipsign.dev/mandate/$MANDATE_ID \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"action": "suspend"}'
suspend pauses the mandate — POST /mandate/verify will return mandate_suspended for any action. Does not affect scope or budget. Use when investigating an agent without permanently revoking it. Calling suspend on an already-suspended mandate succeeds with a shorter body — {"success": true, "id": "...", "status": "suspended", "message": "Already suspended"}, without scope, updatedAt or receipt: nothing changed, so nothing is recorded.
resume — reactivate after suspend
curl -s -X PATCH https://api.fipsign.dev/mandate/$MANDATE_ID \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"action": "resume"}'
resume reactivates a suspended mandate. Only works on suspended mandates — calling resume on an active mandate returns HTTP 409 (Only suspended mandates can be resumed); on a revoked or expired mandate it also returns 409. resume does not undo a narrow — scope stays narrowed after resume.
revoke — terminate permanently
curl -s -X PATCH https://api.fipsign.dev/mandate/$MANDATE_ID \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"action": "revoke"}'
revoke is permanent and irreversible. Once revoked, POST /mandate/verify will always return mandate_revoked. No further PATCH operations are allowed on a revoked mandate. Use this when an agent is compromised or decommissioned. Note: revoke is the only PATCH action allowed on an expired mandate. Revoking an already-revoked mandate returns 409 (Cannot modify a revoked mandate) — if you retry a revoke after a network error, treat that 409 as success.
Response — all actions
{
  "success": true,
  "id": "mdt_1418d1072c503dd20ef9416f347d19b9",
  "status": "active", // active | suspended | revoked
  "scope": ["verify"], // scopeCurrent after the operation
  "updatedAt": 1785691318,
  "receipt": { // FIPSign's signed record of this change: keep it
    "signed": "{\"at\":1785691318,\"hash\":\"7939159a746ecd258d3a0ab979089b85e2063905d49d51e010228a105e5ba02f\",\"kind\":\"mandate.receipt\",\"mandateId\":\"mdt_1418d1072c503dd20ef9416f347d19b9\",\"projectId\":\"proj_8f3c1a52d4e7b609a1c2d3e4f5061728\",\"seq\":4,\"v\":1}",
    "signature": "k3Vq7yRkWm9Ht2...", // ML-DSA signature, base64
    "algorithm": "ML-DSA-65",
    "keyFingerprint": "9c1e4a7b...", // SHA-256 of the public key that signed
    "event": {
      "seq": 4,
      "type": "narrowed",
      "at": 1785691318,
      "prevHash": "b2f0a4a236f0a6923803c3b4172f258e1db6f0201bfee48a71786061c85d59b8",
      "hash": "7939159a746ecd258d3a0ab979089b85e2063905d49d51e010228a105e5ba02f",
      "body": "{\"at\":1785691318,\"correlationId\":\"ticket-4822\",\"keyId\":\"fcf0c9544f41bda5\",\"keyName\":\"backend\",\"mandateId\":\"mdt_1418d1072c503dd20ef9416f347d19b9\",\"scope\":[\"verify\"],\"seq\":4,\"status\":\"active\",\"type\":\"narrowed\",\"v\":1}"
    }
  }
}
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 summary
actionEffectReversible?Works on expired?Body required
narrowReduces scopeCurrent to a subset of its current value (not just scopeOriginal)No — scope can only shrink furtherNoYes — "scope": [...]
suspendSets status: suspended — blocks all verify callsYes — via resumeNoNo
resumeSets status: active — restores verify accessYes — via suspendNoNo
revokeSets status: revoked — permanently blocks all verify callsNoYesNo
04 GET /mandate/:id — Read mandate state

Returns the current state of a mandate. Use this to inspect budget consumption, current scope, status, or remaining time. Token cost: see Costs.

curl -s https://api.fipsign.dev/mandate/$MANDATE_ID \
  -H "X-API-Key: $API_KEY" | jq
Response
{
  "success": true,
  "mandate": {
    "id": "mdt_1418d1072c503dd20ef9416f347d19b9",
    "agentId": "agent-reporting-v2",
    "issuedBy": "user@empresa.com",
    "scopeOriginal": ["sign", "verify", "read:crm"], // what was signed — immutable
    "scopeCurrent": ["verify"], // active scope — may be narrowed
    "budgetTotal": 1000,
    "budgetConsumed": 5,
    "budgetRemaining": 995, // budgetTotal - budgetConsumed, computed at read time (always 0 when budgetTotal is 0 — use budgetConsumed)
    "status": "active", // active | suspended | revoked
    "issuedAt": 1785690939,
    "expiresAt": 1785694539,
    "expiresInSeconds":3131, // seconds until expiry, computed at read time
    "updatedAt": 1785691318, // last mutable state change (narrow, suspend, resume, revoke, or a granted verify, which adds its cost to budgetConsumed); a denied verify does not change it
    "requiresAgentSignature": false // true if emitted with agentPublicKey (the key itself is never returned)
  }
}
scopeOriginal vs scopeCurrent — scopeOriginal is what was signed and is immutable. scopeCurrent is the live scope after any narrows. After a narrow, scopeOriginal still shows the full original scope — useful for auditing what was originally granted vs what is currently enforced.
Expired mandates. There is no expired status: a mandate that has passed its expiry still reports the last status it had (usually active) with expiresInSeconds: 0 — check that field (or expiresAt) to know whether it can still be used. Verify calls answer mandate_expired and PATCH answers 409 (except revoke). You can still read the mandate for 24 hours after expiry; after that GET returns 404 Mandate not found and it disappears from the list. Its audit log stays readable for 365 days (13).
05 GET /mandate — List mandates

Returns the mandates of the project associated with the API key, most recent first (issuedAt descending), one page at a time. 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 parameterTypeRequiredDescription
limitintegerNoPage size, 1–100. Default 50. A value that is not a whole number in that range is rejected with 400.
cursorstringNoOpaque, URL-safe value copied as is from nextCursor of the previous page. Omit it to get the first page. A value that was modified, truncated or is empty is rejected with 400.
Response
{
  "success": true,
  "mandates": [
    { // same shape as GET /mandate/:id response
      "id": "mdt_...", "agentId": "...", "status": "active", "budgetRemaining": 995, ...
    }
  ],
  "count": 1, // mandates in THIS page
  "nextCursor": null // a string when more pages exist, null on the last page
}
Walk through every page
CURSOR=""
while :; do
  PAGE=$(curl -s "https://api.fipsign.dev/mandate?limit=100${CURSOR:+&cursor=$CURSOR}" \
    -H "X-API-Key: $API_KEY")
  echo "$PAGE" | jq -c '.mandates[] | {id, status, budgetConsumed}'
  CURSOR=$(echo "$PAGE" | jq -r '.nextCursor // empty')
  [ -z "$CURSOR" ] && break
done
Includes all statuses — active, suspended and revoked mandates are all returned while the mandate still exists server-side. Mandates are kept for 24 hours after expiry for auditing purposes, then deleted automatically and dropped from the list. count is the size of the current page, not the total number of mandates in the project. A page can hold fewer than limit mandates (ones deleted after their retention period are skipped), so keep following nextCursor until it is null — do not stop at the first short page.
06 Limits
Field limits
FieldLimit
agentIdMax 128 characters
issuedByMax 256 characters
scope items1–20 items per mandate
scope item lengthMax 64 characters per item
action (verify)Max 64 characters
budgetTotalNon-negative whole number, max 9,007,199,254,740,991 (253 − 1). 0 disables budget enforcement.
expiresInSecondsMin 60 (1 min) — Max 2,592,000 (30 days)
agentPublicKeyValid base64 that decodes to exactly 1312 (ML-DSA-44), 1952 (ML-DSA-65) or 2592 (ML-DSA-87) bytes; max 4096 characters
cost (verify)Required, non-negative whole number, max 9,007,199,254,740,991 (253 − 1)
agentSignature lifetimeexp − iat at most 60 seconds; iat at most 30 seconds in the future; single use
GET /mandate page sizelimit 1–100, default 50
Retention after expiry24 hours (then GET returns 404 and the mandate leaves the list)
correlationId1–128 characters, no control characters (on emit, verify and PATCH)
Audit log: entries per mandate100,000 (the last one is a log_limit_reached marker)
Audit log: retentionEach entry is kept 365 days from its own at, then deleted
Audit log: checkpointsAt most one per hour per mandate, and one as the last entry before the mandate is cleaned up
GET /mandate/:id/events page sizelimit 1–500, default 100
GET /mandate/events page size and periodlimit 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 sizelimit 1–1000, default 500
GET /public-keysThe 200 most recent public keys of the project
07 Rate limits
EndpointLimitWindowScope
POST /mandate300 requests1 minutePer API key
POST /mandate/verify300 requests1 minutePer API key
PATCH /mandate/:id60 requests1 minutePer API key
GET /mandate/:id300 requests1 minutePer API key, one counter for all the reads below
GET /mandate300 requests1 minutePer API key, same counter
GET /mandate/:id/events300 requests1 minutePer API key, same counter
GET /mandate/events300 requests1 minutePer API key, same counter
GET /mandate/:id/export300 requests1 minutePer API key, same counter
GET /public-keys300 requests1 minutePer API key, same counter
PATCH has a lower limit (60/min) because it modifies mutable state. The 300/min limit on verify is what matters for high-throughput agent workflows. Limits are counted per API key, so every agent key has its own counters. The six reads — 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.
08 Common errors
HTTPError / reasonCause
400Invalid body — expected JSONThe request body is not valid JSON
400Invalid body — expected a JSON objectThe body is valid JSON but is null, an array or a plain value
400"agentId" is requiredMissing or empty agentId
400"agentId" must be at most 128 charactersagentId too long
400"issuedBy" is requiredMissing or empty issuedBy
400"issuedBy" must be at most 256 charactersissuedBy too long
400"scope" must be a non-empty arrayscope missing, empty, or not an array (also returned by narrow)
400"scope" must have at most 20 itemsMore than 20 scope items
400"scope" items must be non-empty stringsScope array contains non-string or empty values
400"scope" items must be at most 64 charactersA scope item is longer than 64 characters
400"budgetTotal" must be a non-negative integerbudgetTotal missing, negative, a string or not a whole number
400"budgetTotal" must not exceed 9007199254740991budgetTotal is larger than 9,007,199,254,740,991 (253 − 1)
400"expiresInSeconds" must be a finite numberexpiresInSeconds missing or not a number (for example sent as a string)
400"expiresInSeconds" must be an integerexpiresInSeconds has decimals (for example 600.5)
400"expiresInSeconds" must be at least 60TTL below minimum
400"expiresInSeconds" must not exceed 2592000 (30 days)TTL above maximum
400"agentPublicKey" must be a non-empty stringagentPublicKey sent as empty string, or wrong type
400"agentPublicKey" exceeds maximum length of 4096 charactersagentPublicKey too long
400"agentPublicKey" must be valid base64agentPublicKey is not valid base64
400"agentPublicKey" has an invalid size (N bytes). Expected an ML-DSA public key…The decoded key is not 1312, 1952 or 2592 bytes — you sent something other than a raw ML-DSA public key (a private key, a PEM/DER wrapper, or a truncated value)
400"token" is requiredtoken missing from the verify request, or sent as a string instead of the token object
400Invalid token format — missing payload, signature, or algorithmIncomplete token object passed to verify — send it exactly as returned by POST /mandate
400"action" is requiredaction missing or empty in the verify request
400"action" must be at most 64 charactersaction longer than 64 characters
400"cost" must be a non-negative integercost missing, negative, a string or not a whole number in verify
400"cost" must not exceed 9007199254740991cost is larger than 9,007,199,254,740,991 (253 − 1) in verify
400Invalid "agentSignature" format — missing payload, signature, or algorithmagentSignature is an object but lacks one of payload, signature, algorithm (issuedAt is optional)
400"action" is required — narrow | suspend | resume | revokeaction missing from PATCH request
400"action" must be narrow, suspend, resume, or revokeUnknown action in PATCH
400"limit" must be an integer between 1 and 100limit on GET /mandate is not a whole number, or is outside 1–100
400Invalid "cursor". Use the nextCursor value of the previous page as is.cursor on GET /mandate or GET /mandate/events is empty, was modified or truncated — always copy nextCursor unchanged
400"receipt" must be a booleanreceipt in the verify request is something other than true or false (for example the text "true")
400"correlationId" must be a non-empty stringcorrelationId is empty or not text
400"correlationId" must be at most 128 characterscorrelationId is longer than 128 characters
400"correlationId" must not contain control characterscorrelationId has a line break, a tab or another control character
400"after" must be a seq number: 0 or moreafter on /events or /export is not a whole number written in digits
400"limit" must be an integer between 1 and 500limit 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 1000limit 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 200limit 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_reachedThe type filter of GET /mandate/events is not one of the entry types of 11
400"from" and "to" must be Unix timestamps in secondsA 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
400The period (from..to) may be at most 90 days unless the query names a mandateId, a correlationId or a traceIdA 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 charactersA 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
400This is a Mandate token. Revoke it with PATCH /mandate/:id {"action":"revoke"}A Mandate token was sent to the generic POST /revoke
401API key required or invalidMissing or malformed X-API-Key header — or a valid agent-scoped key was used on an endpoint other than POST /mandate/verify (see REST 00)
401This is a Mandate token. Verify it with POST /mandate/verifyA Mandate token was sent to the generic POST /verify
403result: denied, reason: invalid_signatureToken tampered, wrong project, not a mandate token (for example one from /sign), or signed with a previous project key that is no longer accepted (see Mandate 10)
403result: denied, reason: agent_signature_requiredMandate has agentPublicKey but "agentSignature" was not sent (or is not an object)
403result: denied, reason: agent_signature_invalidSignature doesn't verify against agentPublicKey (wrong key, an algorithm that is not exactly ML-DSA-44, ML-DSA-65 or ML-DSA-87, or the wrong variant for the key), it already expired, or iat/exp are missing, exp − iat is over 60 s, or iat is more than 30 s in the future
403result: denied, reason: agent_signature_mismatchagentSignature is validly signed, but its "sub", "action" or "cost" don't match this mandateId, action and cost (a signed payload without cost never matches)
403result: denied, reason: agent_signature_replayedThis exact agentSignature was already used. Sign again for every call
403result: denied, reason: mandate_expiredMandate TTL elapsed or mandate no longer exists
403result: denied, reason: mandate_suspendedMandate is suspended — resume it to allow actions
403result: denied, reason: mandate_revokedMandate permanently revoked
403result: denied, reason: scope_not_authorizedRequested action not in scopeCurrent (the response lists authorizedScope)
403result: denied, reason: budget_exhaustedbudgetConsumed + cost would exceed budgetTotal
404Mandate not foundmandateId does not exist, belongs to another project, or was deleted 24 hours after expiry. On /events and /export: there is no audit log for that id (the log of a deleted mandate stays for 365 days)
409Cannot modify a revoked mandatePATCH attempted on a revoked mandate (including a second revoke)
409Cannot modify an expired mandatePATCH attempted on expired mandate (except revoke)
409Only suspended mandates can be resumedresume called on an active mandate
415Content-Type must be application/jsonRequest body sent without Content-Type: application/json
429Rate limit exceeded. Maximum 300 (or 60) requests per minute per API key.Too many requests. The body has "code": "rate_limited"; wait the seconds in the Retry-After header and retry
429Token limit reached. Free monthly tokens exhausted and no active packs.Monthly quota exhausted. The body has "code": "token_quota_exhausted". Purchase a pack from the dashboard (Dashboard 09). The mandate budget is not consumed
500Internal server errorUnexpected failure on our side. Retrying once is safe for GET and PATCH; if it persists, contact support with the time of the request
503Could 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
09 Complete test script

Copy, replace your API key, and run to test the full mandate lifecycle against the live backend. Steps 1–12 cover the core lifecycle; step 13 is optional and covers proof of possession (agentPublicKey / agentSignature). Requires curl and jq; step 13 also needs Node 20+ (Python and Node versions with more detail are in 02b).

#!/bin/bash
# Requires: curl and jq. Step 13 (optional) also needs Node 20+ and:  npm install @noble/post-quantum
API_KEY="pqa_YOUR_API_KEY"
BASE_URL="https://api.fipsign.dev"

echo "[1/13] Emit mandate..."
MANDATE=$(curl -s -X POST $BASE_URL/mandate \
  -H "Content-Type: application/json" -H "X-API-Key: $API_KEY" \
  -d '{"agentId":"agent-test","issuedBy":"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
This last step is optional — it only applies if you're using agentPublicKey/agentSignature (proof of possession, see sections 01–02). Skip it if your mandates use plain bearer tokens.
10 Key rotation behavior

When project keys are rotated from the dashboard, mandates keep working: the previous key is accepted for mandates for longer than for tokens.

Grace period. After a key rotation, the previous keypair stays valid for mandates for 30 days, the longest a mandate can live (for tokens it is 1 hour, see Dashboard 06). 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.

Why it is longer than for tokens. A token carries everything it says, so someone with a stolen key could sign a token with any content: that is why the window for tokens is 1 hour. For a mandate the signature is only the first check. What the mandate can do comes from the record FIPSign keeps (status, expiry, scope, budget), and for a mandate with an agent key the agent also has to sign each request. Someone with the old key cannot create a mandate, widen one or bring back a revoked one. If you rotate because the key may have been stolen, suspend or revoke the mandates you do not trust (PATCH /mandate/:id): a rotation does not end them.

Limit. Only the 20 most recent previous keys are kept. On the 21st rotation within 30 days the oldest one stops being accepted: mandates emitted with it are denied with invalid_signature and you emit new ones. Rotating is never refused for that reason.

Receipts and the audit log. Receipts, checkpoints and exports are signed with the project key that is current when they are made, and carry its fingerprint. After a rotation FIPSign keeps the public half of every key the project has had, and lists them in 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.

Key rotation does not affect the mutable mandate state — status, scopeCurrent, and budgetConsumed are preserved regardless of key rotation. Agent keypairs used for proof of possession belong to the agent and are unrelated to your project keys: rotating the project key does not affect them.
11 Audit log — what FIPSign records

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.

What is recorded
Event typeWritten whenFields in body besides the common ones
emittedPOST /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_grantedA 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_deniedA 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_releasedA 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).
narrowedPATCH with narrow.scope (the new scope) and status.
suspended, resumed, revokedThe matching PATCH.status.
checkpointFIPSign 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_startedOnly 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_reachedThe mandate reached 100,000 entries. Nothing more is recorded for it; its calls carry on exactly as before.limit.
What is not recorded. A verify whose token signature is invalid (it cannot be tied to any mandate), a request that fails validation (400) or is refused with 401 or 429, a 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).
What an entry looks like

Every entry has the same six fields. Here is the first one of a mandate, as 13 returns it:

{
  "seq": 1,
  "type": "emitted",
  "at": 1785690939,
  "prevHash": "0000000000000000000000000000000000000000000000000000000000000000",
  "hash": "fbf0d9f67b8eaccb5fe7310c48dee0963612da3539e866d76561ffff33bc36fc",
  "body": "{\"agentId\":\"agent-reporting-v2\",\"at\":1785690939,\"budgetTotal\":1000,\"correlationId\":\"ticket-4821\",\"expiresAt\":1785719739,\"issuedBy\":\"user@empresa.com\",\"keyId\":\"fcf0c9544f41bda5\",\"keyName\":\"backend\",\"mandateId\":\"mdt_1418d1072c503dd20ef9416f347d19b9\",\"scope\":[\"sign\",\"verify\",\"read:crm\"],\"seq\":1,\"tokenDigest\":\"3f904262870078c3418f01ed710a8cf77d8adbe6f5006e5a550b3f93b9c913a8\",\"type\":\"emitted\",\"v\":1}"
}
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.
FieldMeaning
vVersion of the format. Always 1.
mandateIdThe mandate this entry belongs to.
seqPosition of the entry in the log of this mandate, counted from 1.
typeOne of the types above.
atWhen it happened, in Unix seconds, by FIPSign's clock.
keyId, keyNameThe 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.
correlationIdYour own id for the call, if you sent one (15).
traceIdThe trace id of the traceparent header of the call, if it sent a valid one (15).
The chain
Each entry carries the hash of the one before it: 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).
Which entries are yours

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
<16 hexadecimal characters>
Timing, size and retention
QuestionAnswer
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).
The log is a record, not a switch. Whether a call is granted depends only on the state of the mandate, exactly as it did before the log existed: nothing in the log can grant, deny or change anything.
12 Receipts — proof you can keep

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.

When you get one
CallDoes it return a receipt?
POST /mandateAlways. It is the signed record of event 1.
PATCH /mandate/:id — narrow, suspend, resume, revokeAlways, 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/verifyOnly 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 GETNo. Reads write nothing.
A missing receipt is never an error. The decision is taken and recorded before the receipt is made. In the rare case it cannot be made, the response simply has no 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).
What it looks like
{
  "receipt": {
    "signed": "{\"at\":1785690939,\"hash\":\"fbf0d9f67b8eaccb5fe7310c48dee0963612da3539e866d76561ffff33bc36fc\",\"kind\":\"mandate.receipt\",\"mandateId\":\"mdt_1418d1072c503dd20ef9416f347d19b9\",\"projectId\":\"proj_8f3c1a52d4e7b609a1c2d3e4f5061728\",\"seq\":1,\"v\":1}",
    "signature": "k3Vq7yRkWm9Ht2...", // ML-DSA signature, base64
    "algorithm": "ML-DSA-65",
    "keyFingerprint": "9c1e4a7b...", // SHA-256 of the public key that signed
    "event": {
      "seq": 1,
      "type": "emitted",
      "at": 1785690939,
      "prevHash": "0000000000000000000000000000000000000000000000000000000000000000",
      "hash": "fbf0d9f67b8eaccb5fe7310c48dee0963612da3539e866d76561ffff33bc36fc",
      "body": "{\"agentId\":\"agent-reporting-v2\",\"at\":1785690939,\"budgetTotal\":1000,\"correlationId\":\"ticket-4821\",\"expiresAt\":1785719739,\"issuedBy\":\"user@empresa.com\",\"keyId\":\"fcf0c9544f41bda5\",\"keyName\":\"backend\",\"mandateId\":\"mdt_1418d1072c503dd20ef9416f347d19b9\",\"scope\":[\"sign\",\"verify\",\"read:crm\"],\"seq\":1,\"tokenDigest\":\"3f904262870078c3418f01ed710a8cf77d8adbe6f5006e5a550b3f93b9c913a8\",\"type\":\"emitted\",\"v\":1}"
    }
  }
}
FieldWhat it is
signedThe 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.
signatureBase64, 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.
algorithmML-DSA-44, ML-DSA-65 or ML-DSA-87: the variant of your project.
keyFingerprintSHA-256, in lowercase hexadecimal, of the bytes of the public key that signed. It tells you which of your keys to check with.
eventThe entry itself (seq, type, at, prevHash, hash, body), so the receipt is complete on its own.
Keep the fingerprint of your key, once
A receipt names the key that signed it. If you accepted whatever key FIPSign shows you on the day you check, a receipt would only prove that FIPSign agrees with itself. So, the day you integrate, save the fingerprint of your project's key in your own system, and check every receipt against it. Each project has its own key and its own fingerprint, and the fingerprint changes when you rotate the keys: save the new one right after each rotation and keep the old ones, because a receipt is checked with the key that made it (14).
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"
<64 hexadecimal characters>
Check a receipt on your own machine

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.

Node.js
// 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}`)
}
Python
# 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:

valid: event 1 (emitted) of mdt_1418d1072c503dd20ef9416f347d19b9, at 1785690939
If a receipt does not pass, the program prints the reason instead: 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.
What a receipt proves — and what it does not
A receipt proves what FIPSign recorded and when. It does not prove that your service, or the person you issued the mandate to, made the request. FIPSign holds the signing key, so the signature says "FIPSign recorded this entry, with this content, at this moment". What it protects you against is a later rewrite of the history, by anyone: the hash of the entry contains the hash of every entry before it. 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.
FIPSign does not keep receipts and cannot issue one again. The entries stay in the log, and the export (14) gives you a signed copy of the whole log at any time within the 365 days, but a receipt you did not save at the time cannot be recovered afterwards. Save it next to the mandate id in your own database.
13 Read the log

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

One mandate: GET /mandate/:id/events
curl -s https://api.fipsign.dev/mandate/$MANDATE_ID/events \
  -H "X-API-Key: $API_KEY" | jq
Response — 200
{
  "success": true,
  "mandateId": "mdt_1418d1072c503dd20ef9416f347d19b9",
  "events": [
    {
      "seq": 1,
      "type": "emitted",
      "at": 1785690939,
      "prevHash": "0000000000000000000000000000000000000000000000000000000000000000",
      "hash": "fbf0d9f67b8eaccb5fe7310c48dee0963612da3539e866d76561ffff33bc36fc",
      "body": "{\"agentId\":\"agent-reporting-v2\",\"at\":1785690939,\"budgetTotal\":1000,\"correlationId\":\"ticket-4821\",\"expiresAt\":1785719739,\"issuedBy\":\"user@empresa.com\",\"keyId\":\"fcf0c9544f41bda5\",\"keyName\":\"backend\",\"mandateId\":\"mdt_1418d1072c503dd20ef9416f347d19b9\",\"scope\":[\"sign\",\"verify\",\"read:crm\"],\"seq\":1,\"tokenDigest\":\"3f904262870078c3418f01ed710a8cf77d8adbe6f5006e5a550b3f93b9c913a8\",\"type\":\"emitted\",\"v\":1}"
    },
    {
      "seq": 2,
      "type": "verify_granted",
      "at": 1785690981,
      "prevHash": "fbf0d9f67b8eaccb5fe7310c48dee0963612da3539e866d76561ffff33bc36fc",
      "hash": "6c103d48c15247e20395cafc445cab47bf61e35358870f95300bef55a28f20e0",
      "body": "{\"action\":\"sign\",\"at\":1785690981,\"budgetConsumed\":5,\"correlationId\":\"req-77\",\"cost\":5,\"keyId\":\"fcf0c9544f41bda5\",\"keyName\":\"backend\",\"mandateId\":\"mdt_1418d1072c503dd20ef9416f347d19b9\",\"seq\":2,\"type\":\"verify_granted\",\"v\":1}"
    },
    // … events 3 and 4 follow
  ],
  "count": 4,
  "nextAfter": null,
  "head": { "seq": 4, "hash": "7939159a746ecd258d3a0ab979089b85e2063905d49d51e010228a105e5ba02f", "lastCheckpointSeq": 0 }
}
Query parameterTypeRequiredDescription
afterintegerNoReturn the entries after this seq (0 or more). Default 0: from the first one. Copy it from nextAfter of the previous page.
limitintegerNoPage size, 1–500. Default 100.
Entries come oldest first, from the one after 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.
Read what each entry says
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:

{"seq":2,"type":"verify_granted","at":1785690981,"detail":{"action":"sign","at":1785690981,"budgetConsumed":5,"correlationId":"req-77","cost":5,"keyId":"fcf0c9544f41bda5","keyName":"backend","mandateId":"mdt_1418d1072c503dd20ef9416f347d19b9","seq":2,"type":"verify_granted","v":1}}
{"seq":3,"type":"verify_denied","at":1785690990,"detail":{"action":"delete:records","at":1785690990,"correlationId":"req-78","cost":0,"keyId":"fcf0c9544f41bda5","keyName":"backend","mandateId":"mdt_1418d1072c503dd20ef9416f347d19b9","reason":"scope_not_authorized","seq":3,"type":"verify_denied","v":1}}
Walk through every page
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
{"seq":1,"type":"emitted","at":1785690939}
{"seq":2,"type":"verify_granted","at":1785690981}
{"seq":3,"type":"verify_denied","at":1785690990}
{"seq":4,"type":"narrowed","at":1785691318}
The whole project: GET /mandate/events

Searches the entries of every mandate of the project at once, newest first. Every filter is an exact match and they combine with AND.

FilterTypeMatches
mandateIdstringThe entries of that mandate (up to 128 characters).
typestringOne entry type of 11, for example verify_denied.
actionstringThe action of verify entries (up to 64 characters).
keyIdstringThe API key that made the call (how to get it).
correlationIdstringYour own id, exactly as you sent it (up to 128 characters).
traceIdstringThe trace id of a traceparent header (32 lowercase hexadecimal characters).
from, tointegerA period in Unix seconds: from ≤ at ≤ to.
limitintegerPage size, 1–200. Default 50.
cursorstringOpaque, URL-safe. Copy the nextCursor of the previous page as is.
The period. A search that names a 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.
Everything that happened under one of your ids
curl -s "https://api.fipsign.dev/mandate/events?correlationId=ticket-4821" \
  -H "X-API-Key: $API_KEY" | jq -c '.events[] | {mandateId, seq, type, at}'
{"mandateId":"mdt_1418d1072c503dd20ef9416f347d19b9","seq":1,"type":"emitted","at":1785690939}
Every denied call of the last 24 hours
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":"mdt_1418d1072c503dd20ef9416f347d19b9","at":1785690990,"detail":{"action":"delete:records","reason":"scope_not_authorized"}}
Response — 200
{
  "success": true,
  "events": [
    {
      "mandateId": "mdt_1418d1072c503dd20ef9416f347d19b9",
      "seq": 1,
      "type": "emitted",
      "at": 1785690939,
      "prevHash": "0000000000000000000000000000000000000000000000000000000000000000",
      "hash": "fbf0d9f67b8eaccb5fe7310c48dee0963612da3539e866d76561ffff33bc36fc",
      "body": "{\"agentId\":\"agent-reporting-v2\",\"at\":1785690939,\"budgetTotal\":1000,\"correlationId\":\"ticket-4821\",\"expiresAt\":1785719739,\"issuedBy\":\"user@empresa.com\",\"keyId\":\"fcf0c9544f41bda5\",\"keyName\":\"backend\",\"mandateId\":\"mdt_1418d1072c503dd20ef9416f347d19b9\",\"scope\":[\"sign\",\"verify\",\"read:crm\"],\"seq\":1,\"tokenDigest\":\"3f904262870078c3418f01ed710a8cf77d8adbe6f5006e5a550b3f93b9c913a8\",\"type\":\"emitted\",\"v\":1}"
    }
  ],
  "count": 1,
  "nextCursor": null,
  "from": null,
  "to": null
}
Each entry has the same six fields as before, plus 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.
Walk through every page
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
14 Export a log, keys and rotation

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.

GET /mandate/:id/export
curl -s https://api.fipsign.dev/mandate/$MANDATE_ID/export \
  -H "X-API-Key: $API_KEY" > export-page-0001.json
Response — 200
{
  "success": true,
  "format": "fipsign.mandate.export.v1",
  "projectId": "proj_8f3c1a52d4e7b609a1c2d3e4f5061728",
  "mandateId": "mdt_1418d1072c503dd20ef9416f347d19b9",
  "generatedAt": 1785691400,
  "events": [ … the entries, as in 13 … ],
  "count": 4,
  "nextAfter": null,
  "head": {
    "seq": 4,
    "hash": "7939159a746ecd258d3a0ab979089b85e2063905d49d51e010228a105e5ba02f",
    "at": 1785691400,
    "source": "live",
    "lastCheckpointSeq": 0,
    "signed": "{\"at\":1785691400,\"hash\":\"7939159a746ecd258d3a0ab979089b85e2063905d49d51e010228a105e5ba02f\",\"kind\":\"mandate.export\",\"mandateId\":\"mdt_1418d1072c503dd20ef9416f347d19b9\",\"projectId\":\"proj_8f3c1a52d4e7b609a1c2d3e4f5061728\",\"seq\":4,\"v\":1}",
    "signature": "k3Vq7yRkWm9Ht2...",
    "algorithm": "ML-DSA-65",
    "keyFingerprint": "9c1e4a7b..."
  },
  "publicKeys": [
    {
      "fingerprint": "9c1e4a7b...",
      "algorithm": "ML-DSA-65",
      "publicKey": "tGhjKTFsV16o...",
      "status": "current",
      "recordedAt": 1785600000,
      "retiredAt": null
    }
  ]
}
Query parameterTypeRequiredDescription
afterintegerNoReturn the entries after this seq. Default 0.
limitintegerNoPage size, 1–1000. Default 500.
FieldWhat it is
formatfipsign.mandate.export.v1.
projectId, mandateIdThe project and the mandate this page belongs to.
generatedAtWhen this page was made, in Unix seconds.
events, countThe entries of this page, oldest first, as in 13.
nextAfterThe seq to pass as after for the next page, null on the last.
headWhile 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.
publicKeysEvery public key your project has had, the retired ones too: the checkpoints inside the log may have been signed before a rotation.
Save every page into one file
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.

Check that the chain is intact
Node.js
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}`)
Python
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}")
4 events, every one follows the one before it. Last hash: 7939159a746ecd258d3a0ab979089b85e2063905d49d51e010228a105e5ba02f
This is the first of the checks, and the only one that needs no key. If the first entry of an export is not entry 1, the entries before it have been deleted (each is kept 365 days): start from its 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).
When is a log sealed?
What you holdWhat protects it
A log that ends in a checkpointEverything 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 aliveThe 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.
FIPSign writes a checkpoint at most once an hour, while a mandate has entries that no checkpoint covers, and always one more as the last entry before it cleans the mandate up (24 hours after it expires). So the log of a mandate that has been gone for a day always ends sealed. The SDKs report this as complete: the log starts at entry 1, ends in a checkpoint whose signature you verified, and nothing follows it.
GET /public-keys — the keys that signed
curl -s https://api.fipsign.dev/public-keys \
  -H "X-API-Key: $API_KEY" | jq '.keys[] | {fingerprint, status, recordedAt, retiredAt}'
Response — 200
{
  "success": true,
  "projectId": "proj_8f3c1a52d4e7b609a1c2d3e4f5061728",
  "keys": [
    {
      "fingerprint": "9c1e4a7b...", // the same value a receipt carries as keyFingerprint
      "algorithm": "ML-DSA-65",
      "publicKey": "tGhjKTFsV16o...", // base64
      "status": "current",
      "recordedAt": 1785600000,
      "retiredAt": null
    },
    {
      "fingerprint": "e07d52b3...",
      "algorithm": "ML-DSA-65",
      "publicKey": "Jq3s9YtRw2Lk...",
      "status": "retired",
      "recordedAt": 1780000000,
      "retiredAt": 1785600000 // when the project rotated it away
    }
  ],
  "count": 2
}
The current key comes first, then the retired ones, the most recently retired first. FIPSign keeps the public half of every key your project has had (the 200 most recent), so a receipt can be checked for as long as you keep it. 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).
The fingerprint of a key
curl -s https://api.fipsign.dev/public-key \
  -H "X-API-Key: $API_KEY" | jq -r '.publicKey' | base64 --decode | sha256sum
<64 hexadecimal characters> -

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.

After you rotate the keys
After a rotation (Dashboard 06) the new receipts, checkpoints and exports are signed with the new key and carry its fingerprint. Receipts and exports you already hold stay valid: each is checked with the key that made it, and its 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).
15 Find a request in the log

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.

Two ids you can send
IdHow to send itWhat FIPSign does with it
correlationIdA 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).
traceparentThe 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.
The JavaScript and Python SDKs send correlationId. They do not send traceparent: to use it, call the REST API.
Example: one ticket, four calls, one search

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):

[1/5] Emit...
[2/5] Verify (granted)...
{"result":"granted","budgetRemaining":95}
[3/5] Verify (denied)...
{"result":"denied","reason":"scope_not_authorized"}
[4/5] Revoke...
{"status":"revoked"}
[5/5] Everything that happened under ticket-1785691400 (the project-wide search lags a few seconds)...
{"seq":1,"type":"emitted","traceId":"0af7651916cd43dd8448eb211c80319c"}
{"seq":2,"type":"verify_granted","traceId":"0af7651916cd43dd8448eb211c80319c"}
{"seq":3,"type":"verify_denied","traceId":"0af7651916cd43dd8448eb211c80319c"}
{"seq":4,"type":"revoked","traceId":"0af7651916cd43dd8448eb211c80319c"}
Same events, found by the trace id of your own system:
4
Did my call go through? Ask the log
When the answer to a verify is lost (a timeout, a dropped connection, a 5xx) you cannot know whether it was granted (02c). If each attempt had its own 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:

{"seq":2,"type":"verify_granted","detail":{"action":"sign","cost":5,"reason":null,"ofSeq":null}}
What the log shows for that idWhat it means
A verify_granted and nothing elseThe call was applied: its cost is in budgetConsumed. Perform the action.
A verify_granted and then a verify_released whose ofSeq is its seqIt 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_deniedThe call was denied; reason says why. Nothing was consumed.
No entryThe call never reached the mandate, or the mandate is gone. Nothing was consumed: you can send it again, with a new id.
Tokens

Costs

What each operation costs, in tokens. This is the only tab that lists amounts: every other tab links here.
Packs and prices: Dashboard 09

01 Tokens

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.

KindDetail
Free tokens2,000 every month. They reset on the 1st (UTC) and unused ones do not carry over.
Pack tokensThey never expire and add up across purchases. They are spent after the free tokens run out.
Buy a pack: Dashboard 09 lists the packs and their prices and explains how to pay.

Check your balance: in the dashboard (Dashboard 09), with usage() (JS SDK 07, Python SDK 07) or with GET /usage (REST 06).

See what each project spent: in the dashboard only, Dashboard 09, Consumption by project.
02 Cost per operation

Amounts are per call. An SDK method calls the endpoint on its row, so it costs the same.

OperationToken costEndpoint or screenSDK method
Signing and verification
Sign a payload2 tokensPOST /signsign()
Zero-Exposure SigningSame as Sign and VerifyPOST /sign, POST /verifyzes.sign(), zes.verify()
Verify a token1 tokenPOST /verifyverify()
Revoke a tokenFreePOST /revokerevoke()
Certificate authority
Create a CA5 tokensDashboard 04 (POST /ca/create, session only)—
Issue a certificate3 tokensPOST /ca/issueca.issue()
Revoke a certificateFreePOST /ca/revokeca.revokeCert()
Certificate Revocation ListFreeGET /ca/crlca.getCrl()
Status of one certificateFreeGET /ca/certificate/:idca.getCert()
Verify a certificateFreeNo endpoint: it runs on your machineca.verifyCert(), ca.verifyX509Cert()
Verify the revocation listFreeNo endpoint: it runs on your machineca.verifyCrl()
Mandate
Emit a mandate3 tokensPOST /mandatemandate.emit()
Check an action2 tokens, only if grantedPOST /mandate/verifymandate.verify()
Suspend, resume, narrow or revoke a mandateFreePATCH /mandate/:idmandate.suspend(), resume(), narrow(), revoke()
Receipt of a callFreeInside the answer of POST /mandate and PATCH /mandate/:id; on POST /mandate/verify when the request has "receipt": trueThe receipt of the result; the option receipt: true of mandate.verify()
Read one mandateFreeGET /mandate/:idmandate.get()
List mandatesFreeGET /mandatemandate.list(), mandate.listAll()
Read the log of a mandateFreeGET /mandate/:id/eventsmandate.events(), mandate.eventsAll()
Search the log of the projectFreeGET /mandate/eventsmandate.queryEvents(), mandate.queryEventsAll()
Export the log of a mandateFreeGET /mandate/:id/exportmandate.export(), mandate.exportAll()
Public keys of the project, the retired ones tooFreeGET /public-keysmandate.publicKeys()
Check a receipt or an exportFreeNo endpoint: it runs on your machineverifyMandateReceipt(), verifyMandateExport()
Account and dashboard
Token balance and historyFreeGET /usageusage()
Consumption by projectFreeDashboard 09 (no endpoint for an API key)—
Public key of the projectFreeGET /public-key—
Service statusFreeGET /healthhealth()
Create, edit or delete a projectFreeDashboard 02, 05, 07—
Create or revoke an API keyFreeDashboard 03—
Rotate the keys of a projectFreeDashboard 06—
Webhooks and two-factor authenticationFreeDashboard 08, 10—
SDK methods are shown with their JavaScript names. The Python SDK uses the same names in snake_case: 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().

MCP tools cost what the operation they call costs: for example fipsign_sign calls POST /sign.

Operations that run on your machine are not in the table: they never reach FIPSign and cost nothing (see 03).
03 When you are charged
CaseWhat happens
Refused requestsA 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 sideA 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 requestsFIPSign 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.
VerifyPOST /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.
RevokePOST /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 CACharged 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 certificatePOST /ca/issue is charged once the request has passed validation (the key, the expiry and a CA that exists).
MandatePOST /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 controlSuspend, 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 budgetThe 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 machineThese 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 tokensWhen 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.