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.
Issue an agent VC
POST/agent/vc/issueBearer agent-jwtMint 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",
"wallet_claims": "included"
}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",
"email": "owner@example.com",
"wallet_address": "0x1111111111111111111111111111111111111111",
"iat": 1700000000,
"exp": 1700003600
}Optional wallet ownership claims
Every valid VC contains the identity and binding claims above. email and wallet_address are optional Wallet ownership claims: they appear together only when the Agent ID is associated with an authorized FluxA Wallet and the owner profile has both values.
wallet_claims is always returned by the issue endpoint:
includedmeans those two claims are present in the VC.unavailablemeans the VC is still valid, but it contains only the standard identity and binding claims.
Verifiers must use sub as the canonical agent identifier and must not reject a valid VC merely because the optional claims are absent. If present, treat them as data carried by the signed VC and handle them as personal information.
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, wallet_claims }. The raw challenge is not persisted — only its SHA-256 fingerprint.
Verify a VC
POST/verify-vcServer-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"
}'JWKS endpoint
GET/.well-known/jwks.jsonThe 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.
