Advanced and compatibility reference
Read this only for a task explicitly routed here by the main skill. This preserves existing lower-level capabilities; it is not the default new-application path. Check the installed declarations and the deployment’s operation support before using an older route. Never fall back from refused request-bound authorization to JWT/admin authorization. Retry advice here applies only when the operation is known safe to repeat; it never authorizes blindly repeating transaction submissions, native approval POSTs or signing requests with an uncertain outcome.
Getting credentials from Hovi
Section titled “Getting credentials from Hovi”Nothing credential-gated in Tasra runs without an issuer: vpJwt
sessions, the committee path, the Verifier Agent and the wallet flow all present
verifiable credentials. Hovi is the issuer and wallet platform the SDK’s
OpenID4VC implementation mirrors, and Hovi is an optional external issuer integration. Everything here is Hovi’s API, not the SDK’s;
the SDK enters at the end, when the holder receives the credential.
Facts marked observed were true for a trial account on 2026-09-11 and may change with Hovi’s plans.
0. Do you need Hovi at all?
Section titled “0. Do you need Hovi at all?”- Unit tests and local demos:
issueSdJwtVcintasra-sdk/oid4vpissues a valid SD-JWT VC offline from a key you generate (seetasra-oid4vp-wallet-and-verifier-agent). No account needed. - An explicitly selected Hovi-hosted wallet or issuer: the integration below.
- Hovi-compatible without the cloud: a local implementation of the same
API subset exists in the platform’s health demo (
/api/issuer, keys start withopenId-local-); the client below works against it by changing the base URL, and the trial limits do not apply there.
1. Trial account
Section titled “1. Trial account”- Open
https://studio.hovi.id/, click Get started, create the account. - Observed trial limits: 2 organizations, 1 credential template each,
and credential revocation disabled (
POST /credential/revokeanswers 403 “not available during the trial period”). Exceeding a limit answers 403 with a message containing “limit exceeded”.
2. Organization and issuer DID
Section titled “2. Organization and issuer DID”In Studio: Organizations → Create Organization. On creation the organization’s secret and DID seed phrase are shown once; copy them.
Or through the API:
POST https://api.hovi.id/organization/createAuthorization: Bearer <OPENID_ECOSYSTEM_API_KEY>Content-Type: application/json
{"name": "Acme HR", "label": "acme-hr", "secret": "<your secret>", "imageUrl": "https://…/logo.png"}The 201 response carries the organization id, a tenant id, an access token and
the organization’s DIDs, each with its private key — returned only in this
response. Store it like a signing key. GET /organization/ lists your
organizations without secrets:
{"success": true, "response": [{ "organizationId": "ff64…", "tenantId": "…", "name": "Acme HR", "dids": [{"did": "did:key:zDna…", "method": "key"}], "openIdWellKnownCredentialIssuerUrl": "https://core-agent.hovi.id/oid4vci/ff64…/.well-known/openid-credential-issuer"}]}The organization’s first DID is the issuer DID: it is what a Tasra
rule pins with {"path": ["iss"], "values": ["<issuer DID>"]}. It is the same
value that later appears as the credential’s iss claim; decoding that claim
is a check, not a second source.
3. API key and request shape
Section titled “3. API key and request shape”Studio → API Keys → Active API Keys. Use the OpenID ecosystem key;
it starts with openId-. Every call:
Authorization: Bearer <key>x-organization-id: <organization id>on every call except/organization/*- base URL
https://api.hovi.id; configure your app withHOVI_API_KEY, an optionalHOVI_BASE_URL(the local Hovi-compatible issuer, for instance), and the organization and template it works with — the demo deployment’s env names themHOVI_ORGANIZATION_NAME(matched againstGET /organization/, whoseorganizationIdis the header value) andHOVI_TEMPLATE_SCHEMA_TYPE(a template’sschemaType) - JSON envelope in every reply:
{"success": true, "response": …}or{"success": false, "message": "…"}; checksuccess, not only the status.
Keep the key server-side. It is an issuance authority; anyone holding it can mint credentials your slots trust.
4. Credential template
Section titled “4. Credential template”One template per credential type. Its schemaType becomes the SD-JWT vct
that a DCQL rule matches; use dc+sd-jwt, and give every attribute a type
(string, number, list). Give credentials a numeric exp claim in Unix
seconds — declare exp among the template’s attributes, since credentialValues
fills those. Rules need not mention it: a wallet drops an expired credential
from its plan before the rule is evaluated.
POST https://api.hovi.id/credential-template/sd-jwt/createAuthorization: Bearer <key>x-organization-id: <organization id>Content-Type: application/json
{ "name": "Acme Employee", "version": "1.0.0", "schemaType": "EmployeeOf", "description": "Employment credential issued by Acme HR", "sdJwtHeader": "dc+sd-jwt", "supportRevocation": true, "attributes": [ {"name": "dept", "label": "Department", "type": "string", "description": "Department", "required": true}, {"name": "level", "label": "Level", "type": "number", "description": "Seniority", "required": true}, {"name": "exp", "label": "Expires at", "type": "number", "description": "Unix seconds", "required": true} ]}The response is the template; GET /credential-template/organization (with
the organization header) lists them:
{"success": true, "response": [{ "credentialTemplateId": "ede0…", "credentialConfigurationId": "…", "tenantId": "ff64…", "name": "Acme Employee", "schemaType": "EmployeeOf", "type": "sd-jwt", "sdJwtHeader": "dc+sd-jwt", "supportRevocation": true, "attributes": [{"name": "dept", "type": "string", "required": true}, {"name": "exp", "type": "number", "required": true}, {"name": "holderDid", "type": "string", "required": false}]}]}Templates may carry an optional holderDid attribute; fill it at issuance to
pin the credential to one holder, which means the holder key has to exist
first — randomHolderKey() (§5), then holderDid: holder.did. Studio’s
Credential Templates → Create Template does the same by hand.
5. Issue a credential
Section titled “5. Issue a credential”POST https://api.hovi.id/credential/sd-jwt/offerAuthorization: Bearer <key>x-organization-id: <organization id>Content-Type: application/json
{ "credentialTemplateId": "<template id>", "credentialValues": {"dept": "Engineering", "level": 3, "exp": 1790000000}, "codeflow": "pre-authorized"}The response looks like this (ids shortened; the // notes are not part of it):
{"success": true, "response": { "credentialExchangeId": "b1f0…", // the handle for find and revoke — keep it "credentialOfferUri": "openid-credential-offer://?credential_offer_uri=https%3A%2F%2Fcore-agent.hovi.id%2F…", "credentialOfferUrl": "https://…/qr/…", // a page showing the QR for a wallet "state": "pending", "isRevoked": false, "credentialType": "EmployeeOf"}}The offer is not the credential; a wallet redeems the offer URI. The exchange
state is pending until then and done once a wallet has redeemed it. GET /credential/find?credentialExchangeId=… returns an
array: [{"credentialExchangeId": "…", "state": "done", "isRevoked": false}].
The holder receives it with the SDK
Section titled “The holder receives it with the SDK”import {randomHolderKey, receiveCredential} from 'tasra-sdk/oid4vp'
const holder = randomHolderKey() // or the holder's persisted P-256 keyconst {credential, issuer, format, configurationId} = await receiveCredential({offerUri: offer.credentialOfferUri, holder})// credential: the compact SD-JWT VC (dc+sd-jwt) bound to holder.did// issuer: the OpenID4VCI credential_issuer URL — NOT the DID. The issuer DID a rule pins is the// credential's `iss` claim (the JWS `kid` carries it too): decode the first ~-separated segment.const issuerDid = JSON.parse(Buffer.from(credential.split('~')[0]!.split('.')[1]!, 'base64url').toString()).iss as string // throws on a malformed stringreceiveCredential runs the OpenID4VCI pre-authorized flow the Hovi Wallet
runs: offer, issuer metadata, token, proof of possession, credential. It follows
whatever URLs the offer and the issuer metadata carry, http:// included, so
the local Hovi-compatible issuer needs no opt-in: ReceiveCredentialOpts has no
switch for it, and allowInsecureLoopback belongs to did:web resolution on
the presentation side. A human with the Hovi Wallet scans the QR page from the
offer response instead.
6. Use it against Tasra
Section titled “6. Use it against Tasra”Write the slot rule for the template, pinning the issuer:
{"credentials": [{ "id": "e", "format": "dc+sd-jwt", "meta": {"vct_values": ["EmployeeOf"]}, "claims": [{"path": ["iss"], "values": ["<issuer DID>"]}, {"path": ["dept"], "values": ["Engineering"]}]}]}Validate and canonicalise the rule string with validateDcql /
canonicalizeDcql from tasra-sdk (see tasra-dcql-rules; every
DCQL function takes the rule as a JSON string), and check the credential you
received against it before relying on it:
import {evaluateDcql} from 'tasra-sdk'import {parseSdJwt, sdJwtCredentialView} from 'tasra-sdk/oid4vp'
// The view's `types` is [vct]; its claims are the issuer payload plus every disclosure, so `dept` resolves// at ["dept"]. Build it with this, never by hand: a disclosed claim is not in the issuer JWT's payload.const ok = evaluateDcql(rule, [sdJwtCredentialView(parseSdJwt(credential))])Then either open a session with it —
kk.openSession(slotId, {vpJwt: {dcqlRule, credentials: [credential], holderProof}})
(tasra-credentials-and-sessions) — or let a wallet present it to the
Verifier Agent (tasra-oid4vp-wallet-and-verifier-agent), or use the committee path
(tasra-committee-path). The keepers accept the credential because the
verified issuer matches the rule; Hovi’s own status lists
(https://revocation.hovi.id, statuslist+jwt) are not checked by the
SDK, so an application that needs revocation must verify them itself.
Minimal server-side client (yours, not the SDK’s)
Section titled “Minimal server-side client (yours, not the SDK’s)”async function hovi<T>(baseUrl: string, key: string, path: string, org?: string, body?: unknown): Promise<T> { const r = await fetch(baseUrl.replace(/\/$/, '') + path, { method: body === undefined ? 'GET' : 'POST', redirect: 'error', signal: AbortSignal.timeout(20_000), headers: {Authorization: `Bearer ${key}`, Accept: 'application/json', ...(org ? {'x-organization-id': org} : {}), ...(body !== undefined ? {'Content-Type': 'application/json'} : {})}, body: body === undefined ? undefined : JSON.stringify(body), }) const text = await r.text() // not r.json(): a wrong base URL answers HTML, and a SyntaxError loses the status let env: {success?: boolean; message?: string; response?: T} try { env = JSON.parse(text) } catch { throw new Error(`Hovi ${path} → HTTP ${r.status}, not JSON: ${text.slice(0, 200)}`) } if (!r.ok || env.success !== true) throw new Error(`Hovi ${path} → HTTP ${r.status}: ${env.message ?? ''}`) return env.response as T}Common mistakes
Section titled “Common mistakes”- Using a key from another ecosystem. Only the OpenID key (
openId-…) serves this API. - Omitting
x-organization-idon template and credential calls. - Treating the offer response as the credential. The wallet, or
receiveCredential, redeems the offer URI. - Expecting revocation on a trial account. It answers 403; plan the
revocation test for a paid plan, or use time-boxed
expvalues meanwhile. - Putting the API key in a browser bundle. Issue from a server.
expin milliseconds. It is Unix seconds, kept verbatim in the credential.- Pinning the wrong DID. The issuer DID is the organization’s DID from
GET /organization/, not the holder’s, not the tenant id. - Creating a third organization or a second template per organization on the trial. It fails with “limit exceeded”.
Where to read more
Section titled “Where to read more”- Hovi quick start:
https://docs.hovi.id/intro/quickstart; API reference:https://docs.hovi.id/api/open-id; the OpenAPI contract:https://hovi.blob.core.windows.net/api-swagger/1.0.0/open-id.json. - SDK receive side:
node_modules/tasra-sdk/dist/oid4vp/oid4vci.d.ts(ReceiveCredentialOpts,ReceivedCredential).
