Skip to content

Holder · Manage Wallets & Credentials

This page is for the custodial-mode integrator. Your backend uses Holder Service as a hosted wallet on behalf of each end user. You do not implement OID4VCI / OID4VP yourself; you call REST APIs that wrap them.

If you are running non-custodial mode (end users have their own wallet apps), skip this page and read Non-Custodial Mode instead. If you are unsure which mode you need, see Custodial vs Non-Custodial Mode.


Holder Service is a wallet, but exposed as a server. Per end user it:

  • Stores the user’s account (UUID + password) and issues short-lived JWTs.
  • Stores the user’s DID document on the IOTA Tangle (or imports an existing DID).
  • Parses issuer offers and verifier authorization requests.
  • Exchanges pre-authorized codes, builds proof JWTs and key-binding JWTs.
  • Selects which disclosures to reveal during presentation.
  • Persists received credentials and exposes them via list / get / delete.

You call it. It does the wallet work. The one thing it does not do is persist holder private keys — that is your responsibility (see §5).

The integration surface is grouped into four lifecycles:

LifecycleEndpointsSection
User & DID/v1/user/*, /v1/did/*§1
Receive a credential/v1/oid4vci/*§2
Manage credentials/v1/vc/*§3
Present a credential/v1/oid4vp/*§4

A “user” in Holder Service represents one end user of your product. Each user has at least one DID, which is the identifier credentials get bound to. This is one-time setup per end user — you do not redo it for every credential.

Terminal window
curl -X POST https://holder.turingspace.co/v1/user/register \
-H "Content-Type: application/json" \
-d '{
"password": "SecureP@ss123",
"name": "Alice"
}'

Response (201 Created):

{
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"name": "Alice",
"created_at": "2026-04-01T00:00:00.000Z"
}

uuid is the only identifier you need to track for this user. Holder Service stores no email or PII unless you supply it.

Failure modeMeaning
400 Validation failedpassword / name missing or fails policy
409 ConflictThe supplied identity is already registered
Terminal window
curl -X POST https://holder.turingspace.co/v1/user/login \
-H "Content-Type: application/json" \
-d '{
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"password": "SecureP@ss123"
}'

Response (200 OK):

{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 86400,
"user": {
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"name": "Alice"
}
}

Use access_token as a Bearer token for every subsequent call on behalf of this user. The token is opaque to you — treat it as a session token.

Token renewal. Holder Service does not issue a refresh token. When expires_in elapses (default 24 h) any subsequent call returns 401 with invalid_token. Re-authenticate by calling POST /v1/user/login again with the stored (uuid, password) and retry the original request with the new access_token. Keep the credentials in your secret manager keyed by uuid — Holder Service does not surface them again.

Failure modeMeaning
400 Validation faileduuid / password missing or malformed
401 Invalid credentialsuuid / password does not match a Holder Service account
401 invalid_token (any subsequent call)access_token expired — re-call POST /v1/user/login and retry

A DID is the user-side identifier the credentials get bound to. Most custodial deployments use did:iota (TCS-managed) for holders.

Terminal window
curl -X POST https://holder.turingspace.co/v1/did \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"method": "IOTA"
}'

Response (201 Created):

{
"did": "did:iota:testnet:0x7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b...",
"private_key": "nwKZ3iFzL7pZJK5pPJ-8k9vXcL2vL7pZJK5pPJ-8k9vXcL2vL7pZJK..."
}

If the user already has a DID elsewhere and you want Holder Service to manage it, use POST /v1/did/import and supply both the DID URL and the existing private key. Import returns the same did shape (without private_key, since you supplied it); duplicates are rejected with 409, ownership-proof failures with 400, and unreachable Tangle with 503.

Failure modeMeaning (create / import)
400 Bad requestInvalid method, malformed DID, or private key fails ownership proof on import
401 UnauthorizedMissing or invalid Authorization header
404 Not found(Import only) DID does not exist on the IOTA network
409 Conflict(Import only) The DID has already been imported
503 Service unavailableIOTA network is unreachable

List:

Terminal window
curl https://holder.turingspace.co/v1/did \
-H "Authorization: Bearer ${ACCESS_TOKEN}"

Response (200 OK):

{
"dids": [
{
"did": "did:iota:testnet:0x7a8b...",
"method": "IOTA",
"status": "active",
"created_at": "2026-01-30T10:30:00.000Z"
}
]
}

Delete (returns 204 No Content with no body):

Terminal window
curl -X DELETE "https://holder.turingspace.co/v1/did/${DID_URL_ENCODED}" \
-H "Authorization: Bearer ${ACCESS_TOKEN}"

Deleting a DID locally does not revoke it on the Tangle — that requires a separate on-chain action.

Failure modeMeaning
401 UnauthorizedMissing or invalid Authorization header
403 ForbiddenThe DID is not owned by the JWT subject
404 DID not foundDID was deleted or never created on Holder Service

Pass a credential offer URI (delivered out of band by an Issuer) to Holder Service together with the holder DID and private key. Holder Service does the rest of OID4VCI internally.

If you want to show the offer details to the end user before accepting, parse it first:

Terminal window
curl -X POST https://holder.turingspace.co/v1/oid4vci/offer-details \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"credential_offer_uri": "openid-credential-offer://?credential_offer_uri=https%3A%2F%2Fissuer.turingspace.co%2Fv1%2Foffers%2Fabc123"
}'

Response (200 OK):

{
"issuer": "https://issuer.turingspace.co/turing",
"issuer_name": "Turing Space",
"issuer_logo_uri": "https://issuer.turingspace.co/assets/logo.png",
"credential_configurations": [
{
"id": "IdentityCredential",
"format": "dc+sd-jwt",
"vct": "https://schema-registry.turingspace.co/schemas/TuringCerts_Standard_Credential/v2",
"display": {
"name": "Identity Credential",
"description": "Your verified identity",
"locale": "en-US"
}
}
],
"pre_authorized_code_grant": {
"pre_authorized_code": "abc123...",
"user_pin_required": false
},
"expires_at": "2026-04-01T01:00:00Z"
}

Use this to render a consent screen. Two fields drive the next step:

  • credential_configurations[].id — pass it back as credential_configuration_id in the receive call when more than one configuration is offered.
  • pre_authorized_code_grant.user_pin_required — if true, prompt the user for a PIN and pass it as user_pin.
Failure modeMeaning
400 Invalid URIcredential_offer_uri malformed or not an OID4VCI offer
401 UnauthorizedMissing or invalid Authorization header
502 Issuer unavailableHolder Service could not fetch the offer from the issuer
Terminal window
curl -X POST https://holder.turingspace.co/v1/oid4vci/receive \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"credential_offer_uri": "openid-credential-offer://?credential_offer_uri=https%3A%2F%2Fissuer.turingspace.co%2Fv1%2Foffers%2Fabc123",
"holder_did": "did:iota:testnet:0x7a8b...",
"private_key": "${HOLDER_PRIVATE_KEY}",
"credential_configuration_id": "IdentityCredential",
"user_pin": "1234",
"notarize_on_iota": true
}'
FieldRequiredNotes
credential_offer_uriyesThe full offer URI delivered out of band by the Issuer.
holder_didyesDID the credential will be bound to. Must belong to the JWT subject.
private_keyyesBase64url private key for holder_did. EdDSA for did:iota, ES256 (raw d) for did:key. Used in memory only.
credential_configuration_idnoRequired when the offer advertises more than one configuration. Pick the id from the offer-details response.
user_pinnoRequired only if the issuer set require_transaction_code: true (i.e. pre_authorized_code_grant.user_pin_required is true in offer-details).
notarize_on_iotanoWhether to notarize the credential JWT on IOTA. Default true. Set false to skip on-chain anchoring.

Response (201 Created):

{
"credential_id": "f7e8d9c0-1b2a-3c4d-5e6f-7a8b9c0d1e2f",
"vct": "https://schema-registry.turingspace.co/schemas/TuringCerts_Standard_Credential/v2",
"issuer_did": "did:iota:testnet:0xabcdef...",
"holder_did": "did:iota:testnet:0x7a8b...",
"raw_credential": "eyJhbGciOiJFZERTQSIs...~sd_disclosure_1~sd_disclosure_2~",
"issued_at": "2026-04-01T00:00:30Z",
"status": "active"
}

The credential is now in the user’s wallet under credential_id. The response also returns schema_id as a deprecated alias of vct for backwards compatibility — always read vct in new code.

Failure modeMeaning
400 Pre-Authorized Code invalidOffer expired, code consumed, or wrong tenant
403 DID does not belong to userThe holder_did is not owned by the JWT subject
404 DID not foundDID was deleted or never created on Holder Service
502 Issuer unavailableHolder Service could not reach the issuer’s endpoints

Terminal window
curl https://holder.turingspace.co/v1/vc \
-H "Authorization: Bearer ${ACCESS_TOKEN}"

Response (200 OK):

{
"credentials": [
{
"id": "f7e8d9c0-1b2a-3c4d-5e6f-7a8b9c0d1e2f",
"vct": "https://schema-registry.turingspace.co/schemas/TuringCerts_Standard_Credential/v2",
"schema_id": "https://schema-registry.turingspace.co/schemas/TuringCerts_Standard_Credential/v2",
"issuer_did": "did:iota:testnet:0xabcdef...",
"holder_did": "did:iota:testnet:0x5678...",
"issued_at": "2026-04-01T00:00:30.000Z",
"expires_at": "2027-04-01T00:00:30.000Z",
"status": "active",
"created_at": "2026-04-01T00:00:30.000Z"
}
]
}

Each entry’s primary key is id (the same value POST /v1/oid4vci/receive returned as credential_id). The status enum is closed: active (presentable), revoked (issuer revoked — not presentable), expired (past expires_at — not presentable), deleted (holder soft-deleted — hidden from list / get). Use this to render a wallet listing in your product UI.

Failure modeMeaning
401 UnauthorizedMissing or invalid Authorization header
Terminal window
curl "https://holder.turingspace.co/v1/vc/${CREDENTIAL_ID}" \
-H "Authorization: Bearer ${ACCESS_TOKEN}"

Returns the full record including raw_credential (the SD-JWT string) — useful for re-using the credential outside of TCS, or for diagnostic display.

Failure modeMeaning
400 Invalid IDid is not a UUID
401 UnauthorizedMissing or invalid Authorization header
403 ForbiddenThe credential is not owned by the JWT subject
404 VC not foundCredential does not exist or was deleted
Terminal window
curl -X DELETE "https://holder.turingspace.co/v1/vc/${CREDENTIAL_ID}" \
-H "Authorization: Bearer ${ACCESS_TOKEN}"

Soft delete (returns 204 No Content). The row stays in the holder DB but is hidden from list/get; the issuer is not notified and the credential’s revocation status is unchanged — the holder simply no longer surfaces it. Note for data-protection compliance: soft delete is not a hard purge. If your jurisdiction requires the row to be erased (e.g. a PDPA / GDPR right-to-erasure request), reach out to the TCS team — a hard-purge workflow is handled out of band.

Failure modeMeaning
400 Invalid IDid is not a UUID
401 UnauthorizedMissing or invalid Authorization header
403 ForbiddenThe credential is not owned by the JWT subject
404 VC not foundCredential does not exist

Presentation is two steps. First, parse the verifier’s authorization request and let Holder Service tell you which credentials match. Then, submit the chosen one(s) with selective disclosure.

Terminal window
curl -X POST https://holder.turingspace.co/v1/oid4vp/request-details \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"authorization_request_uri": "openid4vp://?client_id=did:iota:testnet:0xverifier...&request_uri=https%3A%2F%2Fverifier.turingspace.co%2Fv1%2Fverifier%2Frequest%2Fabc"
}'

The response includes:

  • client_metadata — verifier name, logo, policy URI for your consent screen.
  • dcql_query — the verifier’s request (which credential type, which claims).
  • matching_credentials — credentials in this user’s wallet that satisfy the query, with the per-credential query_id and the disclosable claims.

If matching_credentials is empty, the user has nothing to present and you should fail the flow before submitting.

Failure modeMeaning
400 Invalid URIauthorization_request_uri malformed or JWT verification of the request object failed
401 UnauthorizedMissing or invalid Authorization header
404 Request URI not foundThe request_uri is unknown to the verifier or has expired
502 Verifier unavailableHolder Service could not fetch the request object
Terminal window
curl -X POST https://holder.turingspace.co/v1/oid4vp/presentation \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"authorization_request_uri": "openid4vp://?client_id=did:iota:testnet:0xverifier...&request_uri=...",
"selected_credentials": [
{
"credential_id": "f7e8d9c0-1b2a-3c4d-5e6f-7a8b9c0d1e2f",
"query_id": "identity_credential",
"disclosed_claims": ["credentialName", "issuedTime"]
}
],
"holder_did": "did:iota:testnet:0x7a8b...",
"private_key": "${HOLDER_PRIVATE_KEY}"
}'

Holder Service builds the SD-JWT presentation (stripping non-disclosed entries), signs the key-binding JWT with the holder’s private key, and submits to the verifier.

Response (200 OK):

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

If the verifier configured a redirect_uri, deep-link the user there to complete the flow on the verifier’s side. From the verifier’s perspective the session moves to completed and your verifier integration polls for the result (see Verifier · Verify Credential).

disclosed_claims is optional. Omit it to reveal all claims — selective disclosure is opt-in. Always disclose the minimum set the verifier requires.

Failure modeMeaning
400 Bad requestMissing required field, or a selected_credentials entry doesn’t satisfy the DCQL query
401 UnauthorizedMissing or invalid Authorization header
403 ForbiddenThe holder_did or a credential_id does not belong to the JWT subject
404 Not foundReferenced DID or credential does not exist
502 Verifier unavailableHolder Service could not submit the VP token to the verifier

Holder Service does not persist holder private keys — by design. The keys never sit in TCS infrastructure. They live in your secret manager / KMS, exactly like any other per-user secret in your product (a session-signing key, a tenant API token, an encrypted attribute). This is a security feature, not a friction.

What you store, and where:

ItemWhereWhy
(user_uuid, did, private_key) triplesSecret manager, KMS, or encrypted DB column. Never plaintext.Required for every receive and presentation on behalf of the user. There is no recovery path if you lose it.
Holder Service access_token per userTreat as session token. Not long-lived (default 86 400 s).Required to call Holder Service on behalf of the user.
User UUID + nameYour application DB.Holder Service stores accounts but does not see PII unless you supply it.

You do not construct, sign, or verify any cryptographic payload yourself. Holder Service does all crypto with the private key you pass in at request time and discards it after the request. The result: TCS, your product, and the end user each see only what they need — and that’s a property of the design, not something you have to enforce yourself.

Sensitive field handling. Holder Service requests carry a private_key field on POST /v1/oid4vci/receive and POST /v1/oid4vp/presentation. Treat these requests like a key transport on every hop you control:

  • Use TLS 1.2 or later for every call to TCS endpoints; the public TCS endpoints accept TLS-only.
  • TCS uses these private_key values in memory to sign the OID4VCI / OID4VP exchange and discards them after the request; they are not persisted. Application-layer access logs and error reports redact the field. For the formal transport / decryption posture between client and TCS ingress (cipher suites, intermediate inspection, etc.) confirm with the TCS team during procurement — see Architecture & Security · Compliance & Data Handling.
  • If you front Holder Service with your own gateway (CDN, WAF, API gateway), confirm that gateway’s request-body logging is off for these two endpoints. Most defaults log request bodies on errors — disable that for the credential and presentation paths.
  • Never carry private_key over a non-TLS channel, and never embed it in a URL query string (only in the JSON body).

For the broader trade-offs and when you might run an external wallet instead, see Custodial vs Non-Custodial Mode.


  • Saving the DID private key only after the request to receive a credential. The POST /v1/did response is the only place you ever see the key. Persist it before you do anything else.
  • Reusing access_token across users. Each token is bound to one Holder Service user. Mixing tokens in your backend is the most common bug — keep a per-end-user mapping.
  • Submitting disclosed_claims: []. That submits an empty disclosure set — many verifiers will reject the presentation as failing the DCQL query. Either omit disclosed_claims (reveal everything) or list the required ones explicitly.
  • holder_did mismatching the credential’s binding. A credential is bound to the DID it was issued to; presenting it under a different DID will be rejected by the verifier signature check.
  • Forgetting to use the user’s access token, not the Issuer API key. Holder Service authenticates per end user, not per organisation. The Issuer API key has no privileges on Holder Service.