JavaScript / TypeScript

JS SDK Reference

Node.js · Deno · Cloudflare Workers · Browser · TypeScript types included.
Base URL: https://api.fipsign.dev  ·  Package: fipsign-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.

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

For offline certificate verification in tests:
  • PQCert CA: save the root cert as root-cert.json — pass with FIPSIGN_ROOT_CERT_JSON="$(cat root-cert.json)"
  • X.509 CA: save the root cert as a .pem file — pass with FIPSIGN_ROOT_CERT_PEM="$(cat root-ca.pem)"
SDK 01 Installation

Works in Node.js, Deno, Cloudflare Workers, and the browser. TypeScript types included — no separate @types package needed.

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

2. In the dashboard, create a project, then create an API key inside that project.

3. Save the key immediately — it is shown only once. Store it in an environment variable, never in source code.
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-65. The only required field is sub — any string identifying the entity. All other fields are stored in the payload and returned on verify. Cost: 1 token.

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

expiresInSeconds range: when provided, must be between 60 and 7,776,000 seconds (90 days). Outside this range returns API_ERROR 400.
SDK 03 verify() — Remote verification

Verifies the ML-DSA-65 signature, token expiry, and the revocation list. Never throws — always returns an object. Cost: 1 token.

const { valid, payload, error } = await pq.verify(token)

if (!valid) {
  // error is one of:
  //   'Token has been revoked'
  //   'Firma inválida — el token fue alterado o no fue emitido por este servidor'
  //   'Token expirado hace N segundos'
  return res.status(401).json({ error: 'Unauthorized' })
}

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
verify() never throws. On any failure it returns { valid: false, payload: null, error: "..." }.

Cost: 1 token per call. For high-throughput read paths without revocation checks, use local verification (SDK 04) at no cost.
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.

ISSUER_MISMATCH: a token from a different project throws this code, with the exact same message text as INVALID_SIGNATURE — intentional, so the two cases are indistinguishable to whoever presented the token. Check err.code, not err.message, to tell them apart internally.
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.
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.
SDK 05 revoke() — Revoke a token

Immediately and permanently invalidates a token. Future verify() calls will reject it even if the signature is valid and the token has not yet expired. Cost: 1 token.

const { success, message, revokedAt, sub, expiresAt, note } =
  await pq.revoke(token, 'user logged out')

console.log(message)    // 'Token revoked successfully'
console.log(revokedAt)  // Unix timestamp
console.log(sub)        // subject from the token payload
console.log(expiresAt)  // original expiry from the token
console.log(note)       // 'This token will be rejected on any future /verify call'
Other revocation reasons
await pq.revoke(token, 'order cancelled')
await pq.revoke(token, 'suspicious activity detected')

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

Expired tokens: calling revoke() on an already-expired token throws PQAuthError with code API_ERROR and status 400. Expired tokens cannot be submitted for revocation.
SDK 06 middleware() — Express / Fastify

Reads Authorization: Bearer <token>, verifies the token, and attaches the decoded payload to req.user. Returns 401 automatically on invalid tokens. Node.js only.

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

// Login — sign a token and return it base64-encoded to the client
app.post('/login', async (req, res) => {
  const user = await db.users.findByEmail(req.body.email)
  if (!user || !checkPassword(req.body.password, user.passwordHash)) {
    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)
SDK 07 usage() — Token balance

Returns the current token balance, 6-month usage history, and purchased packs. No token cost.

const { current, monthlyHistory, packs, developer } = await pq.usage()

// Current balance
console.log(`Month: ${current.month}`)                             // e.g. "2026-06"
console.log(`Free:  ${current.freeRemaining} / ${current.freeLimit}`)
console.log(`Used:  ${current.freeUsed} this month`)
console.log(`Pack:  ${current.packRemaining}`)
console.log(`Total: ${current.totalRemaining}`)
console.log(`Account: ${developer.email}`)

// 6-month history (always 6 entries, months with no activity show 0)
monthlyHistory.forEach(({ month, tokensUsed, fromFree, fromPack }) => {
  console.log(`${month}: ${tokensUsed} used (${fromFree} free + ${fromPack} pack)`)
})

// Purchased packs
packs.forEach(({ id, packType, tokensPurchased, purchasedAt, paymentRef }) => {
  console.log(`${packType}: ${tokensPurchased} tokens — ${new Date(purchasedAt * 1000).toLocaleDateString()}`)
  console.log(`  id: ${id} — paymentRef: ${paymentRef ?? 'n/a'}`)
})
Free tokens reset on the 1st of each month (UTC). Unused free tokens do not carry over. Pack tokens never expire and accumulate across purchases. All projects under the same account share a single pool.
SDK 08 health() — Service status

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

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

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

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

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

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

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

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

verify() and ca.verifyX509Cert() never throw — they always return { valid: false, error } on failure. 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 returned an error (check err.status)
        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_SIGNATURE':      // local verify: token tampered
        break
      case 'TOKEN_EXPIRED':          // local verify: token expired
        break
      case 'ISSUER_MISMATCH':        // local verify: token issued for a different project
        break
      case 'UNSUPPORTED_ALGORITHM':  // local verify: unknown algorithm
        break
      case 'INVALID_CERT_TYPE':      // ca.verifyCert(): expected CA_ROOT or CA_CERT
        break
      case 'CA_MISMATCH':            // ca.verifyCert(): cert was not issued by this CA
        break
      case 'CERT_EXPIRED':           // ca.verifyCert(): certificate has expired
        break
      case 'INVALID_CERT_SIGNATURE': // ca.verifyCert(): signature invalid
        break
    }
    console.error(err.code, err.message, err.status)
  }
}
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
apiKeystringRequired. 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.
projectIdstringRequired 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 (ISSUER_MISMATCH).
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 — the same algorithm used for token signing.

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

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

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

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

const { publicKey, secretKey } = await generateKeyPair()
// publicKey: base64-encoded ML-DSA-65 public key  — 1952 bytes raw
// secretKey: base64-encoded ML-DSA-65 expanded key — 4032 bytes raw
// store secretKey securely on the device — never send it to the server
// pass publicKey to ca.issue() to obtain a certificate
secretKey format: the JS SDK returns the full 4032-byte ML-DSA-65 expanded key (not the 32-byte seed). If you are also using the Python SDK, note that the Python SDK returns the 32-byte seed instead — the two formats are not interchangeable.
ca.issue() — Issue a certificate (cost: 1 token)
// ── PQCert CA ──────────────────────────────────────────────────────────────
const { certificate, meta, usage } = await pq.ca.issue({
  subject:          'device-serial-00123',
  publicKey:        devicePublicKey,            // base64 ML-DSA-65 public key
  expiresInSeconds: 365 * 24 * 60 * 60,          // required — 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'

// ── 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 rootCert from './root-cert.json' assert { type: 'json' }

const result = pq.ca.verifyCert(deviceCert, rootCert)

if (!result.valid) {
  // error is one of:
  //   'Expected a CA_CERT, got ...' / 'Expected a CA_ROOT, got ...' (INVALID_CERT_TYPE)
  //   'Certificate was not issued by this CA' (CA_MISMATCH)
  //   'Certificate expired N seconds ago' (CERT_EXPIRED)
  //   'Invalid certificate signature — not issued by this CA' (INVALID_CERT_SIGNATURE)
  console.error(result.error)
  return reject('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:
  //   'Certificate has expired'
  //   'Invalid certificate signature — not signed by this root CA'
  //   'Unsupported signature algorithm: <OID>. Expected ML-DSA-65 (2.16.840.1.101.3.4.3.18)'
  //   'Unsupported root CA algorithm: <OID>. Expected ML-DSA-65 (2.16.840.1.101.3.4.3.18)'
  //   'Unexpected public key size: N bytes (expected 1952 or 1953 for ML-DSA-65)'
  //   'Unexpected signature size: N bytes (expected 3309 or 3310 for ML-DSA-65)'
  console.error(result.error)
  return reject('Device not authorized')
}

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

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

// X.509 CA — pass the certId string from meta.certId
if (pq.ca.isCertRevoked(meta.certId, crl)) {
  return reject('Device certificate has been revoked')
}
ca.getCrl() — Get the Certificate Revocation List (free)
const { caId, subject, crl, generatedAt, raw } = await pq.ca.getCrl()

console.log(`CA: ${subject}`)
console.log(`${crl.length} revoked certificates`)

crl.forEach(({ certId, revokedAt, reason }) => {
  // reason may be null if no reason was provided at revocation time
  console.log(`${certId} — revoked ${new Date(revokedAt * 1000).toISOString()}${reason ?? 'no reason'}`)
})

// X.509 CAs only: raw contains the full signed CRL object with ML-DSA-65 signature
// raw is undefined for PQCert CAs
if (raw) {
  console.log(raw.signature) // base64 ML-DSA-65 signature over the canonical CRL
}
The SDK normalizes the CRL responser.crl is always a flat CrlEntry[] regardless of CA format. Use getCrl() to verify revocation status in bulk offline. For checking a single certificate in real time before a high-value operation, use ca.getCert() instead.
ca.getCert() — Get a certificate by ID (free)
const { certificate, status, meta } = await pq.ca.getCert('cert_...')

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

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

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

const pq = new PQAuth('pqa_your_api_key')

// 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) return reject(result.error)

// 4. At runtime: check the device is not revoked
const { crl } = await pq.ca.getCrl()
if (pq.ca.isCertRevoked(certificate, crl)) return reject('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) return reject(result.error)

// 4. At runtime: check the device is not revoked
const { crl } = await pq.ca.getCrl()
if (pq.ca.isCertRevoked(meta.certId, crl)) return reject('Device revoked')

// 5. Decommission: revoke the certificate using the certId from meta
await pq.ca.revokeCert(meta.certId, 'device decommissioned')
Python

Python SDK Reference

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

PY 01 Installation

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

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

2. In the dashboard, create a project, then create an API key inside that project.

3. Save the key immediately — it is shown only once. Store it in an environment variable, never in source code.
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-65. The only required argument is sub — any string identifying the entity. All other keyword arguments are stored in the payload and returned on verify. Cost: 1 token.

User session
result = pq.sign("user_123", email="[email protected]", role="admin", expires_in_seconds=3600)
token  = result.token
meta   = result.meta
usage  = result.usage
Payment order
result = pq.sign("order_456", amount=299.99, currency="USD", expires_in_seconds=300)
Document certification
result = pq.sign("doc_789", hash="sha256:abc...", signed_by="alice")
AI agent action
result = pq.sign(
    "agent_summarizer_v2",
    action="document:summarize",
    user_id="user_123",
    trace_id="trace_abc",
)
IoT device / firmware
result = pq.sign("device_iot_001", firmware="2.1.4", location="plant-A")
Monitor quota inline
result = pq.sign("user_123")
print(f"{result.usage.freeRemaining} free · {result.usage.packRemaining} pack")
print(f"charged from: {result.meta.source}")  # "free" | "pack" | "free+pack"
print(f"project: {result.meta.projectId} · account: {result.meta.issuedFor}")
Response shape
SignResult
  .token   PQToken
              .payload     str   # base64 encoded payload
              .signature  str   # ML-DSA-65 signature
              .algorithm  str   # "ML-DSA-65"
              .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 between 60 and 7,776,000 seconds (90 days). Outside this range raises PQAuthError(code="API_ERROR", status=400).
PY 03 verify() — Verify a token

Verifies the ML-DSA-65 signature, token expiry, and the revocation list. Never raises — always returns a VerifyResult. Cost: 1 token.

result = pq.verify(token)

if not result.valid:
    # result.error is one of:
    #   "Token has been revoked"
    #   "Firma inválida — el token fue alterado o no fue emitido por este servidor"
    #   "Token expirado hace N segundos"
    raise PermissionError(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
verify() never raises. On any failure it returns VerifyResult(valid=False, payload=None, error="...").

No local verify: the Python SDK always verifies remotely. There is no in-memory local verification mode — unlike the JS SDK which has localVerify: true. Cost is 1 token per call.
PY 04 revoke() — Revoke a token

Immediately and permanently invalidates a token. Future verify() calls will reject it even if the signature is valid and it hasn't expired. Cost: 1 token.

result = pq.revoke(token, "user logged out")

print(result.message)    # "Token revoked successfully"
print(result.revokedAt)  # Unix timestamp
print(result.sub)        # subject from the token payload
print(result.expiresAt)  # original expiry from the token
print(result.note)       # "This token will be rejected on any future /verify call"
Other revocation reasons
pq.revoke(token, "order cancelled")
pq.revoke(token, "suspicious activity detected")
Idempotent: revoking an already-revoked token returns RevokeResult(success=True, message="Token was already revoked") without consuming an extra token.

Expired tokens: calling revoke() on an already-expired token raises PQAuthError(code="API_ERROR", status=400). Expired tokens cannot be submitted for revocation.
PY 05 Flask & FastAPI middleware

Reads Authorization: Bearer <token> and attaches the decoded payload to the request context. Returns 401 automatically on invalid tokens.

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)

@app.route("/login", methods=["POST"])
def login():
    # authenticate user however you like, then:
    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")}
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].

from fipsign.async_client import AsyncPQAuth
from fipsign import generate_key_pair

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

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

    # verify_cert() and verify_x509_cert() are synchronous even in AsyncPQAuth
    # — they are pure in-memory operations with no I/O
    result = pq.ca.verify_x509_cert(cert.certificate, root_pem)  # no await

    if pq.ca.is_cert_revoked(cert.meta.certId, crl.crl):
        raise PermissionError("Device revoked")
verify_cert() and verify_x509_cert() are synchronous in both PQAuth and AsyncPQAuth — they perform pure in-memory cryptographic operations with no network I/O. Do not use await with them.
PY 07 usage() — Token balance

Returns the current token balance, 6-month usage history, and purchased packs. No token cost.

u = pq.usage()

# Current balance
print(f"Month: {u.current.month}")
print(f"Free:  {u.current.freeRemaining} / {u.current.freeLimit}")
print(f"Used:  {u.current.freeUsed} this month")
print(f"Pack:  {u.current.packRemaining}")
print(f"Total: {u.current.totalRemaining}")
print(f"Account: {u.developer['email']}")

# 6-month history (always 6 entries, months with no activity show 0)
for entry in u.monthlyHistory:
    print(f"{entry.month}: {entry.tokensUsed} used ({entry.fromFree} free + {entry.fromPack} pack)")

# Purchased packs
from datetime import datetime
for pack in u.packs:
    date = datetime.fromtimestamp(pack.purchasedAt).strftime("%Y-%m-%d")
    print(f"{pack.packType}: {pack.tokensPurchased} tokens — {date}")
    print(f"  id: {pack.id} — paymentRef: {pack.paymentRef or 'n/a'}")
Free tokens reset on the 1st of each month (UTC). Unused free tokens do not carry over. Pack tokens never expire and accumulate across purchases. All projects under the same account share a single pool.
PY 08 webhooks — Event notifications

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

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

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

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

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

from fipsign.middleware import verify_webhook_signature

# Flask
@app.route("/webhooks/fipsign", methods=["POST"])
def webhook():
    from flask import request
    sig = request.headers.get("X-PQAuth-Signature", "")
    if not verify_webhook_signature(request.data, sig, FIPSIGN_WEBHOOK_SECRET):
        return "Invalid signature", 401
    event = request.json
    match event["event"]:
        case "token.signed":   pass
        case "token.rejected": pass
        case "token.revoked":  pass
        case "limit.warning":  pass
        case "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="...") on any failure. All other methods raise PQAuthError.

from fipsign import PQAuth, PQAuthError

try:
    result = pq.sign("user_123")
except PQAuthError as err:
    match err.code:
        case "INVALID_API_KEY":  # key missing or doesn't match pqa_ + 64 hex chars
            pass
        case "API_ERROR":        # server returned an error (check err.status)
            pass
        case "TIMEOUT":          # request exceeded timeout (default: 10s)
            pass
        case "NETWORK_ERROR":    # connection failed
            pass
        case "MISSING_SUB":      # sign() called without sub
            pass
    print(err.code, err.message, err.status)
err.statusMeaning
400Invalid parameters — expires_in_seconds out of range, 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
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_keystrRequired. 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 — the same algorithm used for token signing.

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

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

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

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

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

private_key = MLDSA65PrivateKey.from_seed_bytes(base64.b64decode(kp.secretKey))
signature   = private_key.sign(message)   # bytes — 3309 bytes for ML-DSA-65
ca.issue() — Issue a certificate (cost: 1 token)
# ── PQCert CA ──────────────────────────────────────────────────────────────
result = pq.ca.issue(
    subject="device-serial-00123",
    public_key=kp.publicKey,
    expires_in_seconds=365 * 24 * 60 * 60,  # required — 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"

# ── 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)"
    #   "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.

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:
    #   "Certificate has expired"
    #   "Invalid certificate signature — not signed by this root CA"
    #   "Unsupported signature algorithm: <OID>. Expected ML-DSA-65 (2.16.840.1.101.3.4.3.18)"
    #   "Unsupported root CA algorithm: <OID>. Expected ML-DSA-65 (2.16.840.1.101.3.4.3.18)"
    raise PermissionError(result.error)

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

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

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

# certId string also works for PQCert CAs
if pq.ca.is_cert_revoked(result.meta.certId, crl_result.crl):
    raise PermissionError("Device certificate has been revoked")
ca.get_crl() — Get the Certificate Revocation List (free)
result = pq.ca.get_crl()

print(f"CA: {result.subject}")
print(f"CA ID: {result.caId}")
print(f"Generated: {result.generatedAt}")
print(f"Format: {result.format}")       # "pqcert" or "x509"
print(f"{len(result.crl)} revoked certificates")

from datetime import datetime
for entry in result.crl:
    # entry.reason may be None if no reason was provided at revocation time
    ts = datetime.fromtimestamp(entry.revokedAt).isoformat()
    print(f"{entry.certId}{ts}{entry.reason or 'no reason'}")

# X.509 CAs only: result.raw contains the full signed CRL with ML-DSA-65 signature
# result.raw is None for PQCert CAs
if result.raw:
    print(result.raw["signature"][:16] + "...")  # base64 ML-DSA-65 signature
ca.get_cert() — Get a certificate by ID (free)
result = pq.ca.get_cert("cert_...")

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

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

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

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

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

pq = PQAuth("pqa_your_api_key")

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

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

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

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

# 4. At runtime: bulk revocation check (offline, from cached CRL)
crl_result = pq.ca.get_crl()
if pq.ca.is_cert_revoked(certificate, crl_result.crl):
    raise PermissionError("Device revoked")

# 5. Decommission: revoke the certificate
pq.ca.revoke_cert(certificate.id, "device decommissioned")
Full device lifecycle — X.509
import os
from fipsign import PQAuth, generate_key_pair

pq = PQAuth("pqa_your_api_key")

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

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

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

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

# 4. At runtime: bulk revocation check (offline, from cached CRL)
crl_result = pq.ca.get_crl()
if pq.ca.is_cert_revoked(cert_id, crl_result.crl):
    raise PermissionError("Device revoked")

# 5. Decommission: revoke the certificate using certId
pq.ca.revoke_cert(cert_id, "device decommissioned")
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).

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 ML-DSA-65 public key
fipsign_sign
Sign any payload (1 token)
fipsign_verify
Verify signature + revocation (1 token)
fipsign_revoke
Revoke a token immediately (1 token)
fipsign_usage
Token balance and 6-month history
fipsign_generate_key_pair
Generate ML-DSA-65 keypair locally
fipsign_ca_issue
Issue a PQCert or X.509 certificate (1 token)
fipsign_ca_revoke_cert
Revoke a certificate (1 token)
fipsign_ca_get_cert
Real-time certificate status (free)
fipsign_ca_get_crl
Certificate Revocation List (free)
MCP 02 Claude Desktop setup

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

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

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

TypeScript MCP
claude mcp add fipsign -- env FIPSIGN_API_KEY=pqa_your_api_key npx -y @fipsign/mcp
Python MCP
claude mcp add fipsign -- env FIPSIGN_API_KEY=pqa_your_api_key uvx fipsign-mcp
Verify the server is loaded
claude mcp list
claude mcp get fipsign
Project scope: by default MCP servers are project-scoped. Add --scope global to make it available in all Claude Code sessions.
MCP 03b Environment variables
VariableRequiredDefaultDescription
FIPSIGN_API_KEY Yes (most tools) Your FIPSign API key. Format: pqa_ + 64 lowercase hex chars. fipsign_health, fipsign_public_key, and fipsign_generate_key_pair work without it.
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-65 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"
01 Health check (GET /health)

Public endpoint. No authentication required. No token cost.

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

Public endpoint. No authentication required. No token cost. Returns the ML-DSA-65 public key for offline token verification.

curl -s $BASE_URL/public-key | jq
{
  "success": true,
  "publicKey": "base64encodedkey...",
  "algorithm": "ML-DSA-65",
  "standard": "NIST FIPS 204"
}
03 Sign a payload (POST /sign)

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

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

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

expiresInSeconds range: when provided, must be between 60 and 7,776,000 seconds (90 days). Outside this range returns HTTP 400.
04 Verify a token (POST /verify)

Requires X-API-Key. Cost: 1 token. Verifies signature, expiry, and revocation status.

curl -s -X POST $BASE_URL/verify \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $API_KEY" \
  -d "{\"token\": $TOKEN}" | jq
Response — valid token
{
  "success": true,
  "valid": true,
  "payload": {
    "sub": "user_test",
    "iat": 1778947233,
    "exp": 1778950833
    // plus any custom fields passed to sign()
  }
}
Response — invalid token
{
  "success": false,
  "valid": false,
  "error": "Token has been revoked" // or signature/expiry message
}
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
Error strings from the backend: "Token has been revoked" when the token was revoked. Signature and expiry errors are returned in Spanish by the cryptographic core ("Firma inválida…", "Token expirado hace N segundos") — these strings are stable and can be matched programmatically.
05 Revoke a token (POST /revoke)

Requires X-API-Key. Cost: 1 token. Permanently and immediately invalidates the token.

curl -s -X POST $BASE_URL/revoke \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $API_KEY" \
  -d "{\"token\": $TOKEN, \"reason\": \"user logged out\"}" | jq
Response
{
  "success": true,
  "message": "Token revoked successfully",
  "revokedAt": 1778947500,
  "sub": "user_test",
  "expiresAt": 1778950833,
  "note": "This token will be rejected on any future /verify call"
}
Idempotent: revoking an already-revoked token returns { "success": true, "message": "Token was already revoked" } without consuming an extra token.

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

Rate limit: POST /revoke is limited to 300 requests/minute per API key, same as /sign and /verify. See section 10.
06 Token usage (GET /usage)

Requires X-API-Key or a session cookie. No token cost. Returns balance, 6-month history, purchased packs, and account info.

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

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

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

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

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

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

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

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

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

echo "[7/7] Balance..."
curl -s $BASE_URL/usage -H "X-API-Key: $API_KEY" | jq .current
08 Token costs and limits
EndpointCostAuthDescription
POST /sign1 tokenX-API-KeySign any payload
POST /verify1 tokenX-API-KeyVerify signature, expiry, and revocation
POST /revoke1 tokenX-API-KeyPermanently revoke a token
GET /usageFreeX-API-Key or sessionBalance and 6-month history
GET /public-keyFreePublic key for offline verification
GET /healthFreeService status
POST /ca/issue1 tokenX-API-KeyIssue a certificate for a device or service
POST /ca/revoke1 tokenX-API-KeyRevoke a certificate immediately
GET /ca/crlFreeX-API-KeyCertificate Revocation List for this project's CA
GET /ca/certificate/:idFreeX-API-KeyReal-time status of a single certificate
Free tier: 10,000 tokens/month. Reset on the 1st (UTC). Unused free tokens do not carry over.
Pack tokens: Never expire. Consumed after free tokens are exhausted.
Payload limits: sub max 128 chars · other string fields max 256 chars · max 10 custom fields.
Token expiry limits: expiresInSeconds on /sign min 60, max 7,776,000 (90 days).
CA certificate limits: subject max 256 chars · meta max 10 keys (PQCert only) · expiresInSeconds 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
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"expiresInSeconds" must be at least 60Token TTL below minimum (POST /sign)
400"expiresInSeconds" must not exceed 7776000 (90 days)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
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
401Firma inválida — el token fue alterado…Token signature is invalid (tampered or wrong key), or token issued for a different project
401Token expirado hace N segundosToken has expired
404No active CA found for this projectCA not yet created — go to dashboard
404Certificate not foundcertId does not exist or belongs to another project
409Certificate is already revokedca.revokeCert() called on already-revoked cert
429Rate limit exceeded. Maximum 300 requests per minute per API key.Too many requests on /sign, /verify, or CA endpoints — back off and retry
429Token limit reached. Free monthly tokens exhausted and no active packs.Monthly quota exhausted — purchase a pack from the dashboard, retrying won't help
API keys cannot be recovered after creation. If lost, create a new key and revoke the old one from the dashboard.
10 Rate limits

All endpoints are rate limited. 300 requests/minute per API key on write endpoints, 60/minute on CA read endpoints. HTTP 429 on excess.

EndpointLimitWindowScope
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
Token quota vs rate limits are separate controls. Rate limit errors say "Rate limit exceeded" — back off and retry with exponential backoff. Quota errors say "Token limit reached" — purchasing a pack from the dashboard is the only remedy, retrying won't help.
REST 11 Certificate Authority — curl reference (PQCert)

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

GET /ca/status and POST /ca/create are session-only endpoints used by the dashboard. They are not available via API key.
POST /ca/issue — Issue a certificate (cost: 1 token)
CERT=$(curl -s -X POST $BASE_URL/ca/issue \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $API_KEY" \
  -d '{
    "subject":          "device-serial-00123",
    "publicKey":        "base64_ml_dsa65_public_key",
    "expiresInSeconds": 31536000,
    "meta":             {"model": "lock-v2", "batch": "2026-05"}
  }')

CERT_ID=$(echo $CERT | jq -r '.meta.certId')
echo "Issued: $CERT_ID"
Response
{
  "success": true,
  "certificate": { "type": "CA_CERT", "id": "cert_...", "subject": "device-serial-00123", "caId": "ca_...", "signature": "...", ... },
  "meta": {
    "certId": "cert_...",
    "caId": "ca_...",
    "subject": "device-serial-00123",
    "format": "pqcert",
    "issuedAt": 1778947233,
    "expiresAt":1810483233,
    "algorithm":"ML-DSA-65",
    "standard": "NIST FIPS 204"
  },
  "usage": { "freeRemaining": 9998, "packRemaining": 0, "totalRemaining": 9998 }
}
GET /ca/crl — Certificate Revocation List (free)
curl -s $BASE_URL/ca/crl -H "X-API-Key: $API_KEY" | jq '.'
{
  "success": true,
  "caId": "ca_...",
  "subject": "My IoT Root CA",
  "generatedAt": 1778947500,
  "crl": [{ "certId": "cert_...", "revokedAt": 1779000000, "reason": "device decommissioned" }]
}
reason may be null if no reason was provided at revocation time.
GET /ca/certificate/:id — Certificate status (free)
curl -s $BASE_URL/ca/certificate/$CERT_ID \
  -H "X-API-Key: $API_KEY" | jq '.status'
{ "revoked": false, "expired": false, "revokedAt": null, "expiresAt": 1810483233 }
POST /ca/revoke — Revoke a certificate (cost: 1 token)
curl -s -X POST $BASE_URL/ca/revoke \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $API_KEY" \
  -d "{\"certId\": \"$CERT_ID\", \"reason\": \"device decommissioned\"}" | jq
Response
{
  "success": true,
  "certId": "cert_...",
  "revokedAt": 1779000000,
  "reason": "device decommissioned",
  "usage": { "freeRemaining": 9997, "packRemaining": 0, "totalRemaining": 9997 }
}
Not idempotent: revoking an already-revoked certificate returns HTTP 409 "Certificate is already revoked". This is different from POST /revoke on tokens, which is idempotent.
REST 11b X.509 CA — curl reference

X.509 CA works identically to PQCert CA from the API perspective — same endpoints, same auth model. The difference is the format: X.509 returns standard PEM certificates compatible with OpenSSL 3.5+, Java keystores, nginx, and any PKI infrastructure.

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

Certificate sizes: X.509 ML-DSA-65 certificates are ~7.5KB PEM / ~5.5KB DER. PQCert JSON is ~4KB by comparison.

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

CERT_ID=$(echo $CERT_PEM | jq -r '.meta.certId')
echo $CERT_PEM | jq -r '.certificate'  # prints PEM
Response
{
  "success": true,
  "certificate": "-----BEGIN CERTIFICATE-----\nMIIV...==\n-----END CERTIFICATE-----\n",
  "meta": {
    "certId": "cert_...",
    "caId": "ca_...",
    "subject": "device-serial-00123",
    "format": "x509",
    "issuedAt": 1778947233,
    "expiresAt":1810483233,
    "algorithm":"ML-DSA-65",
    "standard": "NIST FIPS 204",
    "sizeNote": "X.509 ML-DSA-65 certs are ~7.5KB PEM. Plan accordingly for IoT memory constraints."
  },
  "usage": { "freeRemaining": 9998, "packRemaining": 0, "totalRemaining": 9998 }
}
REST 11c X.509 — End-to-end device authentication flow

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

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

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

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

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

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

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

  const { event, data } = req.body
  // handle event...
  res.status(200).send('ok')
})
Payload structure
{ "event": "token.signed", "timestamp": 1781451218, "data": { ... } }
token.signed — fired on every successful sign() call
sub string  ·  email string | null  ·  role string | null
projectId string  ·  apiKeyName string  ·  tokensUsed number
freeRemaining number  ·  packRemaining number  ·  totalRemaining number
source "free" | "pack" | "free+pack"  ·  month string
token.rejected — fired when verify() rejects a token
reason string — why verification failed
sub string | null — subject extracted from payload if decodable
projectId string  ·  apiKeyName string
token.revoked — fired on every successful revoke() call
sub string  ·  reason string
apiKeyName string  ·  projectId string
freeRemaining number  ·  packRemaining number  ·  totalRemaining number
limit.warning — fired when free tokens drop below 20% of monthly limit
freeRemaining number  ·  freeLimit number (always 10000)
packRemaining number  ·  totalRemaining number
percentUsed number (e.g. 82)  ·  month string  ·  apiKeyName string
limit.reached — fired when free tokens are exhausted and no pack is available
freeRemaining number (always 0)  ·  packRemaining number
totalRemaining number  ·  month string  ·  apiKeyName string
Delivery is best-effort — one attempt, 10-second timeout, no retry. FIPSign makes a single HTTP POST with a 10-second timeout. If your endpoint is unreachable or responds slowly, the event is lost. Always respond with HTTP 200 quickly and process events asynchronously if needed.
Integration Tests

Integration Tests

Verify the full system end-to-end against the live backend.
Both SDK tests confirm no false positives: distinct signatures for identical payloads, and full certificate lifecycle for both PQCert and X.509. Webhook management is dashboard-only and skipped in tests.

12 What the tests cover
#TestJS SDKPython SDK
01Health check — service status, algorithm, standard
02Invalid API key rejection — wrong prefix, too short, non-hex chars
03sign() — user, order, document, field validation, sub length, quota
04verify() remote — valid, tampered, custom fields preserved
05verify() local — in-memory, no API call, tampered or wrong-project token rejected— (no local verify in Python SDK)
06revoke() — all fields, idempotent, expired token rejected
07Expired token — verify rejects
08usage() — balance, 6-month history, developer email
09Local verify does not check revocation (expected)— (no local verify)
10Default expiry is 3600 seconds
11Malformed tokens — empty, unknown algorithm
12Webhooks — skipped (dashboard-only, not via API key)skippedskipped
13Independent ML-DSA-65 verification via @noble/post-quantum
13bgenerate_key_pair() — publicKey=1952B, secretKey=32B seed, sign/verify roundtrip
14Distinct signatures — same payload produces different signatures
15Webhook delivery + HMAC signatureskipped (dashboard-only)skippedskipped
16CA — generateKeyPair() — correct key sizes (1952 / 4032 bytes)
14bCA — generate_key_pair() end-to-end → ca.issue() → revoke → CRL
16CA — ca.issue() — correct shape, all meta fields, format detection
16CA — ca.issue() — X.509 correctly rejects meta with 400
16CA — ca.issue() — expiresInSeconds / expires_in_seconds min/max
16CA — ca.getCrl() / ca.get_crl() — normalized shape, X.509 signed CRL with raw.signature
16CA — ca.isCertRevoked() / ca.is_cert_revoked() — before and after revocation
16CA — ca.getCert() / ca.get_cert() — correct status, meta for x509, 404 for non-existent
16CA — ca.revokeCert() / ca.revoke_cert() — success, 409 on duplicate
16CA — ca.verifyCert() / ca.verify_cert() offline — valid, tampered, expired, wrong CA (PQCert)✓ pqcert✓ pqcert (requires FIPSIGN_ROOT_CERT_JSON)
16CA — ca.verifyX509Cert() / ca.verify_x509_cert() offline — valid, tampered, wrong root (X.509)✓ x509✓ x509 (requires FIPSIGN_ROOT_CERT_PEM)
Prerequisites — before running any test
1. Create a free account at app.fipsign.dev — enter your email and verify the OTP.

2. In the dashboard, create a project, then create an API key and a CA inside that project. Save the key and the root certificate — both are shown only once.

3. Optional: if you want to verify that webhooks fire, configure an endpoint in the dashboard pointing to webhook.site. The tests themselves do not require this.
Download the test files
↓ test-sdk.mjs  Node.js · JS SDK ↓ test_sdk.py  Python · Python SDK
Run — JS SDK test (Node.js)
# PQCert CA — without root cert JSON (offline verifyCert() skipped)
FIPSIGN_API_KEY=pqa_your_api_key \
node test-sdk.mjs

# PQCert CA — with root cert JSON (includes offline verifyCert() tests)
FIPSIGN_API_KEY=pqa_your_api_key \
FIPSIGN_ROOT_CERT_JSON="$(cat root-cert.json)" \
node test-sdk.mjs

# X.509 CA — without root cert PEM (offline verifyX509Cert() skipped)
FIPSIGN_API_KEY=pqa_your_x509_api_key \
node test-sdk.mjs

# X.509 CA — with root cert PEM (includes offline verifyX509Cert() tests)
FIPSIGN_API_KEY=pqa_your_x509_api_key \
FIPSIGN_ROOT_CERT_PEM="$(cat root-ca.pem)" \
node test-sdk.mjs
Run — Python SDK test
# PQCert CA — without root cert JSON
FIPSIGN_API_KEY=pqa_your_pqcert_api_key \
python tests/test_sdk.py

# PQCert CA — with root cert JSON (includes offline ca.verify_cert() tests)
FIPSIGN_API_KEY=pqa_your_pqcert_api_key \
FIPSIGN_ROOT_CERT_JSON="$(cat root-cert.json)" \
python tests/test_sdk.py

# X.509 CA — without root cert PEM
FIPSIGN_API_KEY=pqa_your_x509_api_key \
python tests/test_sdk.py

# X.509 CA — with root cert PEM (includes offline ca.verify_x509_cert() tests)
FIPSIGN_API_KEY=pqa_your_x509_api_key \
FIPSIGN_ROOT_CERT_PEM="$(cat root-ca.pem)" \
python tests/test_sdk.py
JS SDK — FIPSIGN_ROOT_CERT_JSON — the JSON file downloaded at PQCert CA creation. Enables ca.verifyCert() offline tests, including expiry after 62s wait.

JS SDK — FIPSIGN_ROOT_CERT_PEM — the PEM file downloaded at X.509 CA creation. Enables ca.verifyX509Cert() offline tests.

Python SDK — FIPSIGN_ROOT_CERT_JSON — the JSON file saved at PQCert CA creation. Enables ca.verify_cert() offline tests (valid cert, tampered, wrong CA, cert with meta). meta fields are fully covered by the ML-DSA-65 signature — any modification to meta after issuance will cause verification to fail.

Python SDK — FIPSIGN_ROOT_CERT_PEM — the PEM file saved at X.509 CA creation. Enables ca.verify_x509_cert() offline tests (valid cert, tampered, wrong root).

Webhooks: webhook management tests are skipped in both SDKs — configuration is dashboard-only and requires a user session, not an API key. Webhook delivery itself can be verified manually by configuring an endpoint in the dashboard.