Skip to content

Issuer · Issue Credential

This page is for the Issuer role: the organisation that signs and delivers credentials. From your backend’s perspective, issuance is essentially one API callPOST /v1/offers. Everything that follows (fetching the offer, exchanging codes, building proof JWTs, retrieving the SD-JWT) happens on the wallet side, which TCS handles for you in custodial mode.


Rendering diagram...

The two steps you implement:

  1. Create the offer. Your backend posts the credential payload to Credential Issuer. TCS returns an offer URI and a deep link.
  2. Deliver the offer.
    • Custodial mode: pass the credential_offer_uri directly to Holder Service via POST /v1/oid4vci/receive.
    • Non-custodial mode: render deep_link as a QR code or send it as a push notification to the end user’s wallet app.

You do not implement the OID4VCI token / nonce / proof / credential dance yourself in either case. In custodial mode TCS Holder Service does it; in non-custodial mode the user’s wallet does it (see Non-Custodial Mode for the wire-level detail).


Call Credential Issuer with your Issuer API key. The offer carries the credential type, claims, and flow configuration.

Terminal window
curl -X POST https://issuer.turingspace.co/v1/offers \
-H "Content-Type: application/json" \
-H "X-API-Key: tcs_production_9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e" \
-d '{
"credential": {
"config_id": "TuringCerts_Standard_Credential_v2_sd_jwt",
"claims": {
"credentialName": "University Degree",
"issuedTime": "2026-03-26T00:00:00Z"
}
},
"flow": "pre-authorized",
"expires_in": 3600
}'

Response (201 Created):

{
"offer_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"credential_offer_uri": "https://issuer.turingspace.co/v1/offers/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"deep_link": "openid-credential-offer://?credential_offer_uri=https%3A%2F%2Fissuer.turingspace.co%2Fv1%2Foffers%2Fa1b2c3d4-e5f6-7890-abcd-ef1234567890",
"expires_at": "2026-03-26T01:00:00Z"
}
NameTypeRequiredDescription
credential.config_idstringYesKey from the issuer’s credential_configurations_supported map. Discover available IDs at GET /.well-known/openid-credential-issuer/:tenant.
credential.claimsobjectYesKey-value pairs of credential claims to include
flowstringYesIssuance flow type. Currently only "pre-authorized" is supported in production. "authorization-code" is on the roadmap — see Standards Roadmap.
expires_innumberNoOffer lifetime in seconds (default: 3600)
issuer_didstringNoOverride the default issuer DID for this offer. Must be supplied together with issuer_private_key — providing one without the other returns 400.
holder_didstringNoBind the offer to a specific holder DID.
issuer_private_keystringNoOverride the default issuer signing key. Must be supplied together with issuer_did — providing one without the other returns 400.
notarize_on_iotabooleanNoWhether to anchor this credential on IOTA. Default true when issuer_did is did:iota:*, false otherwise. Explicit true requires issuer_did = did:iota:*. See On-chain notarization.
callback_urlstringNoHTTPS URL that TCS POSTs to after this offer’s credential completes on-chain notarization. Single-attempt, no retries. Stored even when notarize_on_iota=false (silently never fires). Max 2048 chars. See Step 3 · Track issuance status.
StatusCause
400Validation failure on the body (missing fields, malformed claims, unknown config_id); also returned when issuer_did and issuer_private_key are not both present or both absent
401Missing or invalid X-API-Key
403API key not authorised for the requested tenant or schema, or supplied issuer_did is not registered under the caller’s tenant

How you deliver credential_offer_uri depends on your operating mode.

Hand the URI directly to Holder Service. Your backend already holds the end user’s access token and DID private key (see Holder · Manage Wallets & Credentials § 1).

Terminal window
curl -X POST https://holder.turingspace.co/v1/oid4vci/receive \
-H "Authorization: Bearer ${HOLDER_USER_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%2Fa1b2c3d4-...",
"holder_did": "did:iota:testnet:0x7a8b...",
"private_key": "${HOLDER_PRIVATE_KEY}"
}'

The credential lands in that user’s Holder Service wallet within a single round trip. See Holder · Manage Wallets & Credentials § 2 for the full flow.

Render deep_link as a QR code, email, or push notification. The user’s wallet app handles the rest of the OID4VCI flow against TCS directly (see Non-Custodial Mode).


Once the offer is delivered, two mechanisms let you know when the credential is issued and anchored: a webhook that TCS POSTs to as soon as on-chain notarization completes, and a polling endpoint you can query at any time.

Treat the webhook as the primary signal and the polling endpoint as the fallback. Webhook delivery is single-attempt — production code should poll when the webhook does not arrive within an acceptable window.

Set callback_url on POST /v1/offers. TCS POSTs JSON to that URL once the credential’s on-chain anchor is created.

Payload:

{
"event": "credential.notarized",
"offer_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"credential_id": "8f7d2c3a-9b1e-4f3a-b7c6-1a2b3c4d5e6f",
"object_id": "0x7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b",
"blockchain": "iota",
"notarized_at": "2026-05-12T08:30:00.000Z"
}

Delivery semantics:

  • HTTP POST with Content-Type: application/json.
  • Single attempt, no retries. Receivers should respond with a 2xx status; non-2xx responses are logged at TCS and not retried.
  • Default timeout is 5 seconds.
  • Does not follow redirects.
  • Only fires when the credential is actually anchored on chain — i.e. when notarize_on_iota is true and the issuer is on the did:iota path. X.509 issuers and offers with notarize_on_iota: false never trigger the webhook (see On-chain notarization).

GET /v1/offers/{offerId}/credentials returns the credentials issued from a given offer. Use this when the webhook is unavailable (X.509 path, notarize_on_iota: false), when the webhook did not arrive, or whenever you want a synchronous status check.

Terminal window
curl https://issuer.turingspace.co/v1/offers/a1b2c3d4-e5f6-7890-abcd-ef1234567890/credentials \
-H "X-API-Key: tcs_production_9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e"

Response (200 OK) — offer not yet claimed:

[]

Response (200 OK) — on_chain_status: PENDING (queued):

[
{
"credential_id": "8f7d2c3a-9b1e-4f3a-b7c6-1a2b3c4d5e6f",
"issuer_did": "did:iota:issuer-ntu-001",
"holder_did": "did:iota:holder-xyz",
"vct": "TuringCerts_Standard_Credential_v2_sd_jwt",
"format": "dc+sd-jwt",
"status": "ACTIVE",
"blockchain": "iota",
"on_chain_status": "PENDING",
"object_id": null,
"issued_at": "2026-05-12T08:30:00.000Z",
"expires_at": null,
"revoked_at": null
}
]

Response (200 OK) — on_chain_status: IN_PROGRESS:

[
{ "...same shape as above...", "on_chain_status": "IN_PROGRESS", "object_id": null }
]

Response (200 OK) — on_chain_status: COMPLETED (fully issued):

[
{ "...same shape as above...", "on_chain_status": "COMPLETED", "object_id": "0x7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b" }
]

Response (200 OK) — on_chain_status: FAILED (permanent):

[
{ "...same shape as above...", "on_chain_status": "FAILED", "object_id": null }
]
FieldValuesMeaning
statusACTIVE / REVOKED / EXPIRED / DELETEDCredential lifecycle
on_chain_statusPENDING / IN_PROGRESS / COMPLETED / FAILEDNotarization progress. Terminal states are COMPLETED and FAILED.
object_idstring / nullIOTA object id; populated only when on_chain_status is COMPLETED
blockchainiotaAnchor network
StatusCause
400offerId is not a valid UUID
401Missing or invalid X-API-Key
404Offer not found, or not owned by the caller’s API key (these two cases are intentionally indistinguishable)

TCS can publish each issued credential on the IOTA Tangle in addition to signing the SD-JWT VC. The on-chain object stores the issuer-signed JWT and gives anyone with the object_id a long-lived, third-party-verifiable record — independent of TCS or the issuer’s online availability. This is what most TCS customers select; it is the platform default.

  • Public-sector or compliance use cases where credentials must be auditable years after issuance.
  • Cross-organisation credentials that must be independently verifiable without a runtime call to TCS.
  • Any flow where the customer wants the issuance event itself to be a publicly resolvable artefact.

If none of those apply (e.g. a private internal employee badge with no external relying party), set notarize_on_iota: false to skip the on-chain step and use only the off-chain SD-JWT.

The decision is made once, at offer creation — on POST /v1/offers via the notarize_on_iota field (see Step 1 Parameters). The same offer carries that flag through to whichever delivery mode you use (custodial or non-custodial); the credential request itself has no per-credential override, and any stray is_on_chain on the wallet’s credential request is ignored.

Issuer signing pathDefault notarize_on_iotaCaller can set
did:iota:*truetrue (default) or false to skip on-chain
X.509 / x5cfalsefalse (default) only — explicit true returns 400 at offer creation

Once the offer is stored, the credential request cannot change the outcome.

On-chain notarization runs only when the issuer is on the did:iota (EdDSA) signing path with issuer_private_key available to TCS. Issuers registered on the X.509 / x5c path (HAIP-conformant) cannot anchor on chain — notarize_on_iota: true is rejected with 400 at offer creation when issuer_did is not did:iota:*. Customers whose use case requires on-chain anchoring must register a did:iota issuer; HAIP and on-chain are not currently combined. See DIDs · Two issuer signing paths for the path comparison.

When notarization succeeds, the holder-receive response includes a reference block:

{
"credentials": [{ "credential": "eyJhbGciOiJFZERTQSIs..." }],
"reference": {
"chain": "iota",
"object_id": "0x7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b"
}
}

The same reference is persisted on the credential record and is available later via GET /v1/vc/{credential_id} as reference.block_chain + reference.object_id. Off-chain (notarize_on_iota: false) credentials simply omit the reference block.

The on-chain object stores the issuer-signed JWT (header.payload.signature — the SD-JWT VC’s signing component, before disclosures are appended). To verify independently of TCS:

  1. Fetch the credential’s reference.object_id from GET /v1/vc/{credential_id} (or from the issuance response).
  2. Resolve that object on an IOTA Notarization explorer for the network you deployed against (testnet / mainnet) — the object’s state field carries the JWT bytes.
  3. Resolve the issuer DID (did:iota:...) and verify the JWT signature against the verification method on the DID Document.

A successful resolution + verification is independent proof that the issuer signed the credential at the time the on-chain object was created.

The on-chain object is destroyable in principle (unpublishing the notarization transaction), but the operation is not exposed via a public API today. To remove a specific object_id — e.g. on a privacy or data-protection request — contact the TCS team with the credential_id and object_id. See also Architecture · Compliance & Data Handling · Right to erasure.


By default, offers use the pre-authorised code flow — the issuer authenticates the holder out of band (e.g. inside your product) and embeds a one-shot pre-auth code in the offer. This is the simplest and is what custodial mode uses.

The interactive flow is intended for cases where the holder must authenticate at the Authorization Server (e.g. via a hosted login form) before redeeming the credential. It matters most for non-custodial deployments where your product does not already hold the user’s identity context.