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 call — POST /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.
What you do vs. what TCS does
Section titled “What you do vs. what TCS does”The two steps you implement:
- Create the offer. Your backend posts the credential payload to Credential Issuer. TCS returns an offer URI and a deep link.
- Deliver the offer.
- Custodial mode: pass the
credential_offer_uridirectly to Holder Service viaPOST /v1/oid4vci/receive. - Non-custodial mode: render
deep_linkas a QR code or send it as a push notification to the end user’s wallet app.
- Custodial mode: pass the
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).
Step 1 · Create a credential offer
Section titled “Step 1 · Create a credential offer”Call Credential Issuer with your Issuer API key. The offer carries the credential type, claims, and flow configuration.
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"}Parameters
Section titled “Parameters”| Name | Type | Required | Description |
|---|---|---|---|
credential.config_id | string | Yes | Key from the issuer’s credential_configurations_supported map. Discover available IDs at GET /.well-known/openid-credential-issuer/:tenant. |
credential.claims | object | Yes | Key-value pairs of credential claims to include |
flow | string | Yes | Issuance flow type. Currently only "pre-authorized" is supported in production. "authorization-code" is on the roadmap — see Standards Roadmap. |
expires_in | number | No | Offer lifetime in seconds (default: 3600) |
issuer_did | string | No | Override the default issuer DID for this offer. Must be supplied together with issuer_private_key — providing one without the other returns 400. |
holder_did | string | No | Bind the offer to a specific holder DID. |
issuer_private_key | string | No | Override the default issuer signing key. Must be supplied together with issuer_did — providing one without the other returns 400. |
notarize_on_iota | boolean | No | Whether 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_url | string | No | HTTPS 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. |
Errors
Section titled “Errors”| Status | Cause |
|---|---|
| 400 | Validation 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 |
| 401 | Missing or invalid X-API-Key |
| 403 | API key not authorised for the requested tenant or schema, or supplied issuer_did is not registered under the caller’s tenant |
Step 2 · Deliver the offer
Section titled “Step 2 · Deliver the offer”How you deliver credential_offer_uri depends on your operating mode.
Custodial mode
Section titled “Custodial 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).
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.
Non-custodial mode
Section titled “Non-custodial mode”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).
Step 3 · Track issuance status
Section titled “Step 3 · Track issuance status”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.
Webhook (push)
Section titled “Webhook (push)”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
POSTwithContent-Type: application/json. - Single attempt, no retries. Receivers should respond with a
2xxstatus; non-2xxresponses 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_iotaistrueand the issuer is on thedid:iotapath. X.509 issuers and offers withnotarize_on_iota: falsenever trigger the webhook (see On-chain notarization).
Polling (pull)
Section titled “Polling (pull)”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.
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 }]Fields
Section titled “Fields”| Field | Values | Meaning |
|---|---|---|
status | ACTIVE / REVOKED / EXPIRED / DELETED | Credential lifecycle |
on_chain_status | PENDING / IN_PROGRESS / COMPLETED / FAILED | Notarization progress. Terminal states are COMPLETED and FAILED. |
object_id | string / null | IOTA object id; populated only when on_chain_status is COMPLETED |
blockchain | iota | Anchor network |
Errors
Section titled “Errors”| Status | Cause |
|---|---|
| 400 | offerId is not a valid UUID |
| 401 | Missing or invalid X-API-Key |
| 404 | Offer not found, or not owned by the caller’s API key (these two cases are intentionally indistinguishable) |
On-chain notarization
Section titled “On-chain notarization”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.
When to choose on-chain
Section titled “When to choose on-chain”- 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.
How to toggle it
Section titled “How to toggle it”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 path | Default notarize_on_iota | Caller can set |
|---|---|---|
did:iota:* | true | true (default) or false to skip on-chain |
X.509 / x5c | false | false (default) only — explicit true returns 400 at offer creation |
Once the offer is stored, the credential request cannot change the outcome.
Path constraint — DID path only
Section titled “Path constraint — DID path only”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.
What the response carries
Section titled “What the response carries”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.
How to verify the on-chain record
Section titled “How to verify the on-chain record”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:
- Fetch the credential’s
reference.object_idfromGET /v1/vc/{credential_id}(or from the issuance response). - Resolve that object on an IOTA Notarization explorer for the network you deployed against (testnet / mainnet) — the object’s
statefield carries the JWT bytes. - 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.
Removing an on-chain record
Section titled “Removing an on-chain record”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.
Advanced · Authorisation code flow
Section titled “Advanced · Authorisation code flow”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.
What’s next
Section titled “What’s next”- Holder · Manage Wallets & Credentials — what your backend does with the offer URI in custodial mode.
- Verifier · Verify Credential — the other half of the lifecycle.
- Non-Custodial Mode — when the holder is an external wallet.
- Authentication — DPoP, API key, JWT details.