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": "Issuer signature verified against the resolved DID Document" }, { "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": "signature_verification_failed", "message": "Issuer signature did not verify against the resolved DID Document" } ]}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 | signature_verification_failed, signature_payload_mismatch, issuer_key_not_resolvable | Issuer signature verifies against the resolved key |
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.
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.