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.
What you do vs. what TCS does
Section titled “What you do vs. what TCS does”What you implement:
- Create the request. Post your DCQL query and verifier identity to Verifier Service.
- Deliver the request URI.
- Custodial mode: call Holder Service
POST /v1/oid4vp/presentationon behalf of the user. - Non-custodial mode: render the deep link as a QR code for the user’s wallet app.
- Custodial mode: call Holder Service
- Poll the result. Watch
GET /v1/verifier/result/{session_id}until the session iscompleted.
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).
Step 1 · Create an authorisation request
Section titled “Step 1 · Create an authorisation request”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"}Parameters
Section titled “Parameters”| Name | Type | Required | Description |
|---|---|---|---|
dcql_query | object | Yes | Which credentials and claims you need. See DCQL Query Structure. |
verifier_private_key | string | Yes | Base64url-encoded raw Ed25519 (32 bytes). Used to sign the JAR (request object) so the wallet can verify who is asking. |
verifier_client_id | string | Yes | The verifier’s DID (or URL). Wallets show this to the user during consent. |
response_mode | string | No | "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_in | number | No | Session TTL in seconds (default: 300). |
client_metadata | object | No | client_name, logo_uri, policy_uri, tos_uri, jwks — shown on the wallet’s consent screen. |
redirect_uri | string | No | Where the wallet redirects the user after submission. Omit for headless flows. |
Errors
Section titled “Errors”| Status | Cause |
|---|---|
| 400 | Malformed body (missing fields, empty dcql_query.credentials, etc.) |
| 401 | Invalid or missing X-API-Key |
Step 2 · Deliver the request URI
Section titled “Step 2 · Deliver the request URI”How you put the request in front of the holder depends on the operating mode.
Custodial mode
Section titled “Custodial 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.
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.
Non-custodial mode
Section titled “Non-custodial mode”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.
Step 3 · Poll the result
Section titled “Step 3 · Poll the result”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.
reason_code reference
Section titled “reason_code reference”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.
check | reason_code (success) | reason_code (fail) | What it means |
|---|---|---|---|
oid4vp_compliance | oid4vp_request_valid | oid4vp_non_compliant, presentation_invalid | OID4VP request + response shape conform to the spec |
credential_format | credential_format_valid | credential_format_invalid | The presented payload is parseable as dc+sd-jwt |
signature | signature_verified | issuer_signature_invalid, issuer_chain_untrusted, issuer_chain_expired, issuer_san_mismatch, issuer_key_not_resolvable, credential_expired, signature_verification_failed | The 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_binding | key_binding_verified | kb_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_resolvable | The 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_status | credential_not_revoked | credential_revoked, credential_suspended, credential_status_unknown, status_list_unreachable, status_list_invalid | The 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_trust | not_implemented | — | Trust Registry membership check (currently a no-op pass — surfaced as not_implemented until the pipeline wires it in) |
schema_governance | not_implemented | — | Schema-side governance check (roadmap; surfaced as not_implemented) |
schema_version | not_implemented | — | Schema-version policy check (roadmap; surfaced as not_implemented) |
issuer_governance | not_implemented | — | Issuer-side governance check (roadmap; surfaced as not_implemented) |
| (any check) | — | dcql_not_satisfied, skipped | DCQL 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.
Revocation status
Section titled “Revocation status”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:
| Entry | Result |
|---|---|
| Valid | credential_status reports success / credential_not_revoked |
| Revoked | 400 invalid_vp_token, credential_revoked |
| Suspended | 400 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" }}Status values
Section titled “Status values”| Status | Meaning |
|---|---|
pending | Request created, waiting for the wallet to fetch the request object |
in_progress | Wallet has fetched the request object, user has not yet submitted |
completed | VP token validated; presentation_metadata populated and every entry in check_results is success |
failed | At least one entry in check_results is fail (e.g. bad signature, wrong nonce, expired credential) |
expired | Session TTL elapsed without a wallet response; error populated |
A practical poll loop runs every 2 seconds until status is terminal:
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 2doneA 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 Query Structure
Section titled “DCQL Query Structure”DCQL (Digital Credentials Query Language) describes which credentials and claims you ask for. Every authorization request requires a dcql_query.
Top-level
Section titled “Top-level”{ "dcql_query": { "credentials": [ /* one or more credential descriptors */ ] }}Credential descriptor fields
Section titled “Credential descriptor fields”| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Identifier for this credential in the query — also referenced by Holder Service in selected_credentials[].query_id. |
format | string | Yes | Use "dc+sd-jwt" for SD-JWT VC. |
meta | object | Yes | Container for credential-format-specific constraints. Required even if you only set one inner field. |
meta.vct_values | string[] | No | Acceptable VCT URLs. Wallet matches against the credential’s vct claim. |
claims | object[] | No | Specific claims to request as { "path": [...] }. Omit to let the wallet decide. |
trusted_authorities | object[] | No | Restrict 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.
Common pitfalls
Section titled “Common pitfalls”- Treating
verifier_private_keylike config. It is a signing key — store it in a secret manager and reference it at request time, not in.envfiles committed alongside source. - Polling forever. Always honour the
expires_atfrom 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_idwithout a known JWKS. Wallets useclient_idto look up your signing keys. If you use a non-DID URL, expose JWKS at the standard location and reference it inclient_metadata.jwks.
What’s next
Section titled “What’s next”- Holder · Manage Wallets & Credentials — how the request URI in Step 2 turns into a presentation in custodial mode.
- Issuer · Issue Credential — the other half of the lifecycle.
- Trust & Schema Governance — register your verifier and pick what credentials you accept.
- Authentication — API key, JWT, and DPoP details.