Skip to content

Verifiable Credential (VC) API

Reference for the endpoints used to issue and verify Fluxa Agent ID Verifiable Credentials — the identity tokens an AI agent hands to a third-party service.

Conceptual guides:

Base URL: https://agentid.fluxapay.xyz

VC vs. login JWT

A VC and a login JWT are both RS256 JWTs signed by the same key (same JWKS) — but they are not interchangeable.

DimensionLogin JWTVC
PurposeCalling FluxA APIs (refresh, payments, payouts)Handed to a third-party service (SSO, account linking)
Header typJWT (default)agent-vc
Payload typ"agent-vc"
LifetimeJWT_EXPIRES_IN (default 15 min)Agent-chosen, max 24 h
Bound toagent_id / emailsub=agent_id / aud=<audience> / challenge / jti
Obtained via/register, /refresh/agent/vc/issue (login-JWT-protected)
Cross-useFluxA jwtAuth rejects typ=agent-vc/verify-vc rejects non-VC tokens

The typ separation is enforced at every boundary. Don't try to swap them.


POST /agent/vc/issue

Mint a VC against a specific audience, in response to a specific challenge.

Auth: Authorization: Bearer <login JWT> (the JWT returned by /register or /refresh).

Request body

json
{
  "challenge": "third-party-user-42",
  "audience": "https://thirdparty.example.com",
  "ttl_seconds": 3600
}
FieldTypeConstraints
challengestringNon-empty; ≤ 4096 UTF-8 bytes. Whatever the third party gave you — a nonce, a user id, a JSON blob.
audiencestringNon-empty. The third party's audience identifier (usually its origin).
ttl_secondsinteger1..86400. Pick the smallest value that fits the third party's flow.

Response 200

json
{
  "vc": "eyJhbGciOiJSUzI1NiIsInR5cCI6ImFnZW50LXZjIiwia2lkIjoi…",
  "jti": "5b1f7e0b-…-…",
  "issued_at": 1700000000,
  "expires_at": 1700003600,
  "kid": "agent-did-key"
}

The vc value is the JWT you hand to the third party. Decoded, its payload looks like:

json
{
  "typ": "agent-vc",
  "sub": "<agent_id>",
  "iss": "fluxa-agent-did",
  "aud": "https://thirdparty.example.com",
  "jti": "5b1f7e0b-…",
  "challenge": "third-party-user-42",
  "iat": 1700000000,
  "exp": 1700003600
}

Errors

StatuserrorCause
400challenge required (non-empty string)challenge missing or not a string
400challenge too large (max 4096 bytes)challenge > 4 KB
400ttl_seconds must be integer in [1, 86400]TTL out of range or not an integer
400audience required (non-empty string)audience missing or empty
401missing_bearerNo Authorization: Bearer header
401invalid_or_expired_jwtLogin JWT invalid or expired — call /refresh
401wrong_token_typeYou sent a VC; this endpoint requires a login JWT
404agent_not_foundThe agent_id encoded in the JWT no longer exists

Example

bash
curl -X POST https://agentid.fluxapay.xyz/agent/vc/issue \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $LOGIN_JWT" \
  -d '{
    "challenge": "third-party-user-42",
    "audience": "https://thirdparty.example.com",
    "ttl_seconds": 3600
  }'

Audit

Each successful call emits a VC_ISSUED audit event with meta = { jti, audience, ttl_seconds, challenge_sha256 }. The raw challenge is not persisted — only its SHA-256 fingerprint.


POST /verify-vc

Server-side helper that verifies a VC for you. Useful for debugging or for verifiers that can't link a JWT library. Production verifiers should do local verification with JWKS instead — see the verifier guide.

Auth: none.

Request body

json
{
  "vc": "eyJhbGci…",
  "expected_audience": "https://thirdparty.example.com",
  "expected_challenge": "third-party-user-42"
}
FieldTypeRequiredNotes
vcstringyesThe VC to verify.
expected_audiencestringnoIf provided, must equal payload.aud exactly.
expected_challengestringnoIf provided, must equal payload.challenge exactly.

Response 200 — valid

json
{
  "valid": true,
  "payload": {
    "typ": "agent-vc",
    "sub": "<agent_id>",
    "iss": "fluxa-agent-did",
    "aud": "https://thirdparty.example.com",
    "jti": "5b1f7e0b-…",
    "challenge": "third-party-user-42",
    "iat": 1700000000,
    "exp": 1700003600
  }
}

Response 200 — invalid (claim mismatch)

json
{ "valid": false, "error": "audience_mismatch" }

or

json
{ "valid": false, "error": "challenge_mismatch" }

Errors

StatuserrorCause
400vc requiredBody missing vc
401invalid_or_expired_vcSignature invalid, expired, or header.typagent-vc
200{ valid:false, error:"audience_mismatch" }expected_audiencepayload.aud
200{ valid:false, error:"challenge_mismatch" }expected_challengepayload.challenge

Even when this endpoint returns valid: true, the verifier must still enforce single-use of the challenge on its own side. The endpoint does not track which challenges you have already consumed.

Example

bash
curl -X POST https://agentid.fluxapay.xyz/verify-vc \
  -H "Content-Type: application/json" \
  -d '{
    "vc": "eyJhbGci…",
    "expected_audience": "https://thirdparty.example.com",
    "expected_challenge": "third-party-user-42"
  }'

GET /.well-known/jwks.json

The RS256 public-key set used to verify both login JWTs and VCs. Cache by kid; refresh on cache miss or on a schedule.

bash
curl https://agentid.fluxapay.xyz/.well-known/jwks.json

A verifier should always select the key by the VC's header.kid, never assume a single static key — FluxA may rotate or publish multiple keys.


Reference verification (Node.js)

A minimal local verifier — same logic the server-side /verify-vc runs, but without the network hop. Pair it with a challenge store on your side; see the verifier guide for that.

js
import jwt from 'jsonwebtoken'
import jwksClient from 'jwks-rsa'

const AUDIENCE = 'https://thirdparty.example.com'
const ISSUER = 'fluxa-agent-did'

const jwks = jwksClient({
  jwksUri: 'https://agentid.fluxapay.xyz/.well-known/jwks.json',
  cache: true,
  cacheMaxAge: 10 * 60 * 1000,
})

const getKey = (header, cb) =>
  jwks.getSigningKey(header.kid, (e, k) => cb(e, k?.getPublicKey()))

export function verifyVc(vc, expectedChallenge) {
  return new Promise((resolve, reject) => {
    const decoded = jwt.decode(vc, { complete: true })
    if (!decoded || decoded.header?.typ !== 'agent-vc') {
      return reject(new Error('not_a_vc'))
    }
    jwt.verify(
      vc,
      getKey,
      { algorithms: ['RS256'], audience: AUDIENCE, issuer: ISSUER },
      (err, payload) => {
        if (err) return reject(err)
        if (payload.challenge !== expectedChallenge) {
          return reject(new Error('challenge_mismatch'))
        }
        resolve(payload) // payload.sub === agent_id
      },
    )
  })
}

Design notes

  • Why not just hand over the login JWT? The login JWT can call /refresh, authorize payouts, and issue mandates. A leaked login JWT is catastrophic. The VC is identity-only, audience-bound, and short-lived.
  • Why a single aud per VC? One VC = one binding. Issue separate VCs for separate verifiers; never mint a "sweeping" credential.
  • Why no revocation list (yet)? MVP relies on short TTLs (≤ 24 h, typically minutes) and challenge single-use. A jti blacklist may come later.
  • Replay defense. The third party generates the challenge and must enforce single-use; FluxA only signs whatever the agent presents.
  • Challenge privacy. The server only persists sha256(challenge) as an audit field — the original is not stored.

Released under the MIT License.