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:
- Agent-side: Log in to a Third-Party Service with Agent ID
- Verifier-side: Third-Party Integration — Verify Fluxa Agent ID
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.
| Dimension | Login JWT | VC |
|---|---|---|
| Purpose | Calling FluxA APIs (refresh, payments, payouts) | Handed to a third-party service (SSO, account linking) |
Header typ | JWT (default) | agent-vc |
Payload typ | — | "agent-vc" |
| Lifetime | JWT_EXPIRES_IN (default 15 min) | Agent-chosen, max 24 h |
| Bound to | agent_id / email | sub=agent_id / aud=<audience> / challenge / jti |
| Obtained via | /register, /refresh | /agent/vc/issue (login-JWT-protected) |
| Cross-use | FluxA 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
{
"challenge": "third-party-user-42",
"audience": "https://thirdparty.example.com",
"ttl_seconds": 3600
}| Field | Type | Constraints |
|---|---|---|
challenge | string | Non-empty; ≤ 4096 UTF-8 bytes. Whatever the third party gave you — a nonce, a user id, a JSON blob. |
audience | string | Non-empty. The third party's audience identifier (usually its origin). |
ttl_seconds | integer | 1..86400. Pick the smallest value that fits the third party's flow. |
Response 200
{
"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:
{
"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
| Status | error | Cause |
|---|---|---|
| 400 | challenge required (non-empty string) | challenge missing or not a string |
| 400 | challenge too large (max 4096 bytes) | challenge > 4 KB |
| 400 | ttl_seconds must be integer in [1, 86400] | TTL out of range or not an integer |
| 400 | audience required (non-empty string) | audience missing or empty |
| 401 | missing_bearer | No Authorization: Bearer header |
| 401 | invalid_or_expired_jwt | Login JWT invalid or expired — call /refresh |
| 401 | wrong_token_type | You sent a VC; this endpoint requires a login JWT |
| 404 | agent_not_found | The agent_id encoded in the JWT no longer exists |
Example
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
{
"vc": "eyJhbGci…",
"expected_audience": "https://thirdparty.example.com",
"expected_challenge": "third-party-user-42"
}| Field | Type | Required | Notes |
|---|---|---|---|
vc | string | yes | The VC to verify. |
expected_audience | string | no | If provided, must equal payload.aud exactly. |
expected_challenge | string | no | If provided, must equal payload.challenge exactly. |
Response 200 — valid
{
"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)
{ "valid": false, "error": "audience_mismatch" }or
{ "valid": false, "error": "challenge_mismatch" }Errors
| Status | error | Cause |
|---|---|---|
| 400 | vc required | Body missing vc |
| 401 | invalid_or_expired_vc | Signature invalid, expired, or header.typ ≠ agent-vc |
| 200 | { valid:false, error:"audience_mismatch" } | expected_audience ≠ payload.aud |
| 200 | { valid:false, error:"challenge_mismatch" } | expected_challenge ≠ payload.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
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.
curl https://agentid.fluxapay.xyz/.well-known/jwks.jsonA 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.
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
audper 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
jtiblacklist may come later. - Replay defense. The third party generates the
challengeand 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.
