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.
What Holder Service does for you
Section titled “What Holder Service does for you”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:
| Lifecycle | Endpoints | Section |
|---|---|---|
| User & DID | /v1/user/*, /v1/did/* | §1 |
| Receive a credential | /v1/oid4vci/* | §2 |
| Manage credentials | /v1/vc/* | §3 |
| Present a credential | /v1/oid4vp/* | §4 |
1 · User & DID lifecycle
Section titled “1 · User & DID lifecycle”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.
Register an end user
Section titled “Register an end user”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 mode | Meaning |
|---|---|
400 Validation failed | password / name missing or fails policy |
409 Conflict | The supplied identity is already registered |
Log the user in
Section titled “Log the user in”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 mode | Meaning |
|---|---|
400 Validation failed | uuid / password missing or malformed |
401 Invalid credentials | uuid / 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 |
Create a DID for the user
Section titled “Create a DID for the user”A DID is the user-side identifier the credentials get bound to. Most custodial deployments use did:iota (TCS-managed) for holders.
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 mode | Meaning (create / import) |
|---|---|
400 Bad request | Invalid method, malformed DID, or private key fails ownership proof on import |
401 Unauthorized | Missing 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 unavailable | IOTA network is unreachable |
List or delete a user’s DIDs
Section titled “List or delete a user’s DIDs”List:
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):
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 mode | Meaning |
|---|---|
401 Unauthorized | Missing or invalid Authorization header |
403 Forbidden | The DID is not owned by the JWT subject |
404 DID not found | DID was deleted or never created on Holder Service |
2 · Receive a credential
Section titled “2 · Receive a credential”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.
Optional: preview the offer
Section titled “Optional: preview the offer”If you want to show the offer details to the end user before accepting, parse it first:
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 ascredential_configuration_idin the receive call when more than one configuration is offered.pre_authorized_code_grant.user_pin_required— iftrue, prompt the user for a PIN and pass it asuser_pin.
| Failure mode | Meaning |
|---|---|
400 Invalid URI | credential_offer_uri malformed or not an OID4VCI offer |
401 Unauthorized | Missing or invalid Authorization header |
502 Issuer unavailable | Holder Service could not fetch the offer from the issuer |
Accept the offer
Section titled “Accept the offer”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 }'| Field | Required | Notes |
|---|---|---|
credential_offer_uri | yes | The full offer URI delivered out of band by the Issuer. |
holder_did | yes | DID the credential will be bound to. Must belong to the JWT subject. |
private_key | yes | Base64url private key for holder_did. EdDSA for did:iota, ES256 (raw d) for did:key. Used in memory only. |
credential_configuration_id | no | Required when the offer advertises more than one configuration. Pick the id from the offer-details response. |
user_pin | no | Required 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_iota | no | Whether 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 mode | Meaning |
|---|---|
400 Pre-Authorized Code invalid | Offer expired, code consumed, or wrong tenant |
403 DID does not belong to user | The holder_did is not owned by the JWT subject |
404 DID not found | DID was deleted or never created on Holder Service |
502 Issuer unavailable | Holder Service could not reach the issuer’s endpoints |
3 · Manage credentials
Section titled “3 · Manage credentials”List a user’s credentials
Section titled “List a user’s credentials”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 mode | Meaning |
|---|---|
401 Unauthorized | Missing or invalid Authorization header |
Get one credential
Section titled “Get one credential”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 mode | Meaning |
|---|---|
400 Invalid ID | id is not a UUID |
401 Unauthorized | Missing or invalid Authorization header |
403 Forbidden | The credential is not owned by the JWT subject |
404 VC not found | Credential does not exist or was deleted |
Delete a credential
Section titled “Delete a credential”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 mode | Meaning |
|---|---|
400 Invalid ID | id is not a UUID |
401 Unauthorized | Missing or invalid Authorization header |
403 Forbidden | The credential is not owned by the JWT subject |
404 VC not found | Credential does not exist |
4 · Present a credential
Section titled “4 · Present a credential”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.
Parse the verifier’s request
Section titled “Parse the verifier’s request”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-credentialquery_idand the disclosable claims.
If matching_credentials is empty, the user has nothing to present and you should fail the flow before submitting.
| Failure mode | Meaning |
|---|---|
400 Invalid URI | authorization_request_uri malformed or JWT verification of the request object failed |
401 Unauthorized | Missing or invalid Authorization header |
404 Request URI not found | The request_uri is unknown to the verifier or has expired |
502 Verifier unavailable | Holder Service could not fetch the request object |
Submit the presentation
Section titled “Submit the presentation”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 mode | Meaning |
|---|---|
400 Bad request | Missing required field, or a selected_credentials entry doesn’t satisfy the DCQL query |
401 Unauthorized | Missing or invalid Authorization header |
403 Forbidden | The holder_did or a credential_id does not belong to the JWT subject |
404 Not found | Referenced DID or credential does not exist |
502 Verifier unavailable | Holder Service could not submit the VP token to the verifier |
5 · Security model
Section titled “5 · Security model”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:
| Item | Where | Why |
|---|---|---|
(user_uuid, did, private_key) triples | Secret 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 user | Treat as session token. Not long-lived (default 86 400 s). | Required to call Holder Service on behalf of the user. |
| User UUID + name | Your 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_keyvalues 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_keyover 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.
Common pitfalls
Section titled “Common pitfalls”- Saving the DID private key only after the request to receive a credential. The
POST /v1/didresponse is the only place you ever see the key. Persist it before you do anything else. - Reusing
access_tokenacross 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 omitdisclosed_claims(reveal everything) or list the required ones explicitly. holder_didmismatching 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.
What’s next
Section titled “What’s next”- Issuer · Issue Credential — how the offer in §2 was created.
- Verifier · Verify Credential — how the request in §4 was created.
- Custodial vs Non-Custodial Mode — the mode-choice context behind this page.
- Non-Custodial Mode — what changes when end users bring their own wallets.
- Authentication — JWT, API key, and DPoP details.