Skip to content

Verifier · Verify Credential

This page is for the Verifier role: the service that asks a holder to prove something. From your backend’s perspective, verification is two API calls — create an authorization request, then read the result. Everything between (fetching the signed request object, prompting the user, building the VP token, submitting it) happens on the wallet side.


Rendering diagram...

What you implement:

  1. Create the request. Post your DCQL query and verifier identity to Verifier Service.
  2. Deliver the request URI.
  3. Poll the result. Watch GET /v1/verifier/result/{session_id} until the session is completed.

You never construct or verify the VP token, the SD-JWT signature, or the key-binding JWT yourself. Verifier Service does all of that and exposes the outcome on the result endpoint as status (overall pass/fail) plus check_results[] (per-check breakdown).


Terminal window
curl -X POST https://verifier.turingspace.co/v1/verifier/authorization-request \
-H "X-API-Key: tcs_production_7f3a9b2c1d4e5f6a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e" \
-H "Content-Type: application/json" \
-d '{
"dcql_query": {
"credentials": [
{
"id": "identity_credential",
"format": "dc+sd-jwt",
"meta": {
"vct_values": ["https://schema-registry.turingspace.co/schemas/TuringCerts_Standard_Credential/v2"]
},
"claims": [
{ "path": ["credentialName"] },
{ "path": ["issuedTime"] }
]
}
]
},
"verifier_private_key": "${VERIFIER_PRIVATE_KEY}",
"verifier_client_id": "did:iota:0xae72f18dc7b6af5b740edae8e4e0b2d3c9fb10a4e7d6c5b3a2f1e0d9c8b7a6f5",
"response_mode": "direct_post",
"expires_in": 300
}'

Response (201 Created):

{
"session_id": "b2c3d4e5-f6a7-8b9c-0d1e-2f3a4b5c6d7e",
"request_uri": "https://verifier.turingspace.co/v1/verifier/request/b2c3d4e5-f6a7-8b9c-0d1e-2f3a4b5c6d7e",
"authorization_request": "openid4vp://authorize?client_id=did%3Aiota%3A0xae72...&request_uri=https%3A%2F%2Fverifier.turingspace.co%2Fv1%2Fverifier%2Frequest%2Fb2c3d4e5-...",
"expires_at": "2026-03-26T00:05:00Z"
}
NameTypeRequiredDescription
dcql_queryobjectYesWhich credentials and claims you need. See DCQL Query Structure.
verifier_private_keystringYesBase64url-encoded raw Ed25519 (32 bytes). Used to sign the JAR (request object) so the wallet can verify who is asking.
verifier_client_idstringYesThe verifier’s DID (or URL). Wallets show this to the user during consent.
response_modestringNo"direct_post" (default — plain server-to-server VP delivery) or "direct_post.jwt" (encrypted VP delivery using ECDH-ES; TCS auto-populates client_metadata.jwks with an ephemeral public key).
expires_innumberNoSession TTL in seconds (default: 300).
client_metadataobjectNoclient_name, logo_uri, policy_uri, tos_uri, jwks — shown on the wallet’s consent screen.
redirect_uristringNoWhere the wallet redirects the user after submission. Omit for headless flows.
StatusCause
400Malformed body (missing fields, empty dcql_query.credentials, etc.)
401Invalid or missing X-API-Key

How you put the request in front of the holder depends on the operating mode.

Pass authorization_request to Holder Service on behalf of the user. Holder Service fetches the signed request, prompts the user, builds the VP token, and submits it. See Holder · Manage Wallets & Credentials § 4.

Terminal window
curl -X POST https://holder.turingspace.co/v1/oid4vp/presentation \
-H "Authorization: Bearer ${HOLDER_USER_ACCESS_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"authorization_request_uri": "openid4vp://authorize?client_id=...&request_uri=...",
"selected_credentials": [
{ "credential_id": "...", "query_id": "identity_credential",
"disclosed_claims": ["credentialName", "issuedTime"] }
],
"holder_did": "did:iota:testnet:0x7a8b...",
"private_key": "${HOLDER_PRIVATE_KEY}"
}'

Response (200 OK):

{
"success": true,
"redirect_uri": "https://verifier.example.com/callback?session_id=b2c3d4e5-..."
}

redirect_uri is present only if the verifier configured one — deep-link the user there to complete the flow on the verifier side. From the verifier’s perspective, the session moves to completed and your verifier integration polls for the result in Step 3 below.

Render authorization_request as a QR code or open the deep link on the user’s device. Their wallet app handles the rest of OID4VP and posts the VP token to TCS directly.


Terminal window
curl https://verifier.turingspace.co/v1/verifier/result/b2c3d4e5-f6a7-8b9c-0d1e-2f3a4b5c6d7e \
-H "X-API-Key: tcs_production_7f3a9b2c1d4e5f6a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e"

Response (200 OK) — completed:

{
"session_id": "b2c3d4e5-f6a7-8b9c-0d1e-2f3a4b5c6d7e",
"status": "completed",
"created_at": "2026-04-01T00:00:00.000Z",
"expires_at": "2026-04-01T00:05:00.000Z",
"verified_at": "2026-04-01T00:01:23.000Z",
"verification_mode": "fail_fast",
"check_results": [
{ "check": "oid4vp_compliance", "status": "success", "reason_code": "oid4vp_request_valid", "message": "OID4VP request and response are spec-compliant" },
{ "check": "credential_format", "status": "success", "reason_code": "credential_format_valid", "message": "Credential format is valid SD-JWT VC" },
{ "check": "signature", "status": "success", "reason_code": "signature_verified", "message": "The issuer signature was verified." },
{ "check": "key_binding", "status": "success", "reason_code": "key_binding_verified", "message": "The holder's proof of possession was verified." },
{ "check": "issuer_trust", "status": "success", "reason_code": "not_implemented", "message": "Trust Registry check not yet wired into the verification pipeline" }
],
"presentation_metadata": {
"claims": {
"credentialName": "Bachelor of Science",
"issuedTime": "2026-01-15"
},
"credential_metadata": [
{
"issuer": "did:iota:testnet:0xabcdef...",
"credential_type": ["UniversityDegreeCredential"],
"issued_at": "2026-01-15T00:00:00.000Z",
"expires_at": "2027-01-15T00:00:00.000Z"
}
]
}
}

The session is completed only when every check in check_results returns status: "success". To branch on overall outcome, check status === "completed" (boolean equivalent of “did the credential verify”). To explain why something failed to a user, walk check_results for the entries with status: "fail" and surface their message.

Response (200 OK) — failed (per-check breakdown):

{
"session_id": "b2c3d4e5-f6a7-8b9c-0d1e-2f3a4b5c6d7e",
"status": "failed",
"created_at": "2026-04-01T00:00:00.000Z",
"expires_at": "2026-04-01T00:05:00.000Z",
"verified_at": "2026-04-01T00:01:23.000Z",
"verification_mode": "fail_fast",
"check_results": [
{ "check": "credential_format", "status": "success", "reason_code": "credential_format_valid", "message": "Credential format is valid SD-JWT VC" },
{ "check": "signature", "status": "fail", "reason_code": "issuer_signature_invalid", "message": "The credential content does not match the issuer signature; authenticity could not be confirmed." },
{ "check": "key_binding", "status": "skipped", "reason_code": "prerequisite_failed", "message": "The signature check did not pass, so key binding was not evaluated." }
]
}

In fail_fast mode, check_results stops at the first failure. In run_all mode every applicable check is reported even after the first failure — useful when you need a full diagnostic for support tickets.

Where the mode is set. verification_mode is not a parameter on POST /v1/verifier/authorization-request. It is sent by the wallet (or the custodial Holder Service acting on the holder’s behalf) in the body of POST /v1/verifier/presentation when the VP is submitted: {"verification_mode": "run_all"} (default "fail_fast"). Verifier-side code only reads it back; it does not select the mode at request creation.

The closed set of check names and reason_code values currently surfaced by the verification pipeline. Use this to triage failures or to map error codes to user-facing copy.

checkreason_code (success)reason_code (fail)What it means
oid4vp_complianceoid4vp_request_validoid4vp_non_compliant, presentation_invalidOID4VP request + response shape conform to the spec
credential_formatcredential_format_validcredential_format_invalidThe presented payload is parseable as dc+sd-jwt
signaturesignature_verifiedissuer_signature_invalid, issuer_chain_untrusted, issuer_chain_expired, issuer_san_mismatch, issuer_key_not_resolvable, credential_expired, signature_verification_failedThe issuer signature verifies against the resolved issuer key. Covers issuer authenticity only — the holder’s proof of possession is reported separately as key_binding. For an issuer signing with an x5c certificate chain, the three chain-specific codes name what to fix: issuer_chain_untrusted is trust anchor configuration, issuer_chain_expired is certificate rotation, and issuer_san_mismatch means the credential’s iss could not be bound to the leaf certificate’s dNSName SANs — its host is not among them, it carries no host, or the SAN list is unusable for the binding (a wildcard, or a non-dNSName entry). issuer_key_not_resolvable is the narrower “no key came out of it” case — a DID that will not resolve, or a chain that cannot be read as a chain
key_bindingkey_binding_verifiedkb_jwt_missing, kb_jwt_malformed, kb_jwt_typ_invalid, kb_jwt_signature_invalid, kb_jwt_nonce_mismatch, kb_jwt_aud_mismatch, kb_jwt_iat_missing, kb_jwt_iat_in_future, kb_jwt_iat_too_old, kb_jwt_sd_hash_mismatch, holder_key_not_resolvableThe holder’s Key Binding JWT proves possession of the credential’s confirmation key, and binds this presentation to this request’s nonce, this verifier’s client_id, and the disclosures actually presented. Reported alongside signature by the same check. Also reports skipped with prerequisite_failed whenever the signature check did not pass, in which case key binding was never evaluated — never read that as a pass
credential_statuscredential_not_revokedcredential_revoked, credential_suspended, credential_status_unknown, status_list_unreachable, status_list_invalidThe credential’s Token Status List entry does not mark it revoked or suspended. Also reports skipped with status_check_disabled (enforcement off for this deployment), status_not_applicable (the credential carries no status reference), or prerequisite_failed (no credential reached this check)
issuer_trustnot_implementedTrust Registry membership check (currently a no-op pass — surfaced as not_implemented until the pipeline wires it in)
schema_governancenot_implementedSchema-side governance check (roadmap; surfaced as not_implemented)
schema_versionnot_implementedSchema-version policy check (roadmap; surfaced as not_implemented)
issuer_governancenot_implementedIssuer-side governance check (roadmap; surfaced as not_implemented)
(any check)dcql_not_satisfied, skippedDCQL match failed (no credential satisfied the query); or check skipped by fail_fast after an earlier failure

status: "success" with reason_code: "not_implemented" is the honest signal that a check is roadmap’d but not yet enforced — it does not mean the underlying property has been verified.

A credential issued on the X.509 / x5c path carries a status.status_list reference — a list URI plus an index — per draft-ietf-oauth-status-list-21. When status enforcement is on for the deployment, the Verifier Service fetches that list on every verification and reads the entry:

EntryResult
Validcredential_status reports success / credential_not_revoked
Revoked400 invalid_vp_token, credential_revoked
Suspended400 invalid_vp_token, credential_suspended
Reserved (0x03)400 invalid_vp_token, credential_status_unknown — no registered meaning, so it is not treated as valid

A credential with no status reference — including every did:iota-issued credential — reports skipped / status_not_applicable and verifies exactly as it did before. skipped never means the credential passed a status check; it means no check applied.

The check fails closed. If the list cannot be fetched or does not validate, verification is rejected (status_list_unreachable, status_list_invalid) rather than waved through — an unreachable list must never read as “not revoked”. A brief Status Issuer outage is cushioned by the last token successfully retrieved, but only within that token’s advertised ttl.

Response (200 OK) — expired:

{
"session_id": "b2c3d4e5-f6a7-8b9c-0d1e-2f3a4b5c6d7e",
"status": "expired",
"created_at": "2026-04-01T00:00:00.000Z",
"expires_at": "2026-04-01T00:05:00.000Z",
"error": {
"code": "session_expired",
"message": "Session TTL elapsed without a wallet response"
}
}
StatusMeaning
pendingRequest created, waiting for the wallet to fetch the request object
in_progressWallet has fetched the request object, user has not yet submitted
completedVP token validated; presentation_metadata populated and every entry in check_results is success
failedAt least one entry in check_results is fail (e.g. bad signature, wrong nonce, expired credential)
expiredSession TTL elapsed without a wallet response; error populated

A practical poll loop runs every 2 seconds until status is terminal:

Terminal window
while true; do
RESULT=$(curl -s "$URL/v1/verifier/result/$SESSION_ID" -H "X-API-Key: $KEY")
STATUS=$(echo "$RESULT" | jq -r '.status')
case "$STATUS" in completed|failed|expired) echo "$RESULT" | jq .; break;; esac
sleep 2
done

A push-based webhook delivery mechanism (so verifier backends can avoid polling) is on the roadmap; until it ships, polling the result endpoint is the supported integration path. Contact us if push delivery is a hard requirement for your deployment.


DCQL (Digital Credentials Query Language) describes which credentials and claims you ask for. Every authorization request requires a dcql_query.

{
"dcql_query": {
"credentials": [ /* one or more credential descriptors */ ]
}
}
FieldTypeRequiredDescription
idstringYesIdentifier for this credential in the query — also referenced by Holder Service in selected_credentials[].query_id.
formatstringYesUse "dc+sd-jwt" for SD-JWT VC.
metaobjectYesContainer for credential-format-specific constraints. Required even if you only set one inner field.
meta.vct_valuesstring[]NoAcceptable VCT URLs. Wallet matches against the credential’s vct claim.
claimsobject[]NoSpecific claims to request as { "path": [...] }. Omit to let the wallet decide.
trusted_authoritiesobject[]NoRestrict to specific issuers, e.g. [{ "type": "did", "values": ["did:iota:0x..."] }].

Each claim path is a JSON path from the credential root: ["credentialName"], ["degreeName"]. Anything not listed remains hidden via SD-JWT selective disclosure.

Example — university degree with trusted issuer pinning

Section titled “Example — university degree with trusted issuer pinning”
{
"dcql_query": {
"credentials": [
{
"id": "degree_credential",
"format": "dc+sd-jwt",
"meta": {
"vct_values": ["https://schema-registry.turingspace.co/schemas/UniversityDegreeCredential/v1"]
},
"claims": [
{ "path": ["credentialName"] },
{ "path": ["studentId"] },
{ "path": ["degreeName"] }
],
"trusted_authorities": [
{ "type": "did", "values": ["did:iota:0xabc123def456"] }
]
}
]
}
}

The holder sees a consent screen listing only these three claims; everything else stays hidden.


  • Treating verifier_private_key like config. It is a signing key — store it in a secret manager and reference it at request time, not in .env files committed alongside source.
  • Polling forever. Always honour the expires_at from Step 1. Once the session expires (status: expired), stop polling and surface the failure to the user.
  • Asking for too many claims. Wallets will show every claim path on the consent screen. Asking for ten optional fields trains users to consent without reading. Ask for the minimum.
  • Forgetting vct_values. Without it the wallet may match credentials of unrelated types.
  • Using non-DID verifier_client_id without a known JWKS. Wallets use client_id to look up your signing keys. If you use a non-DID URL, expose JWKS at the standard location and reference it in client_metadata.jwks.