Skip to content

Issuance API

Issue attestations from your backend with a single call, without going through the Issuance Portal or the Administration Portal. Your system authenticates to the Verifier with its machine credential, requests the issuance and chooses how the attestation is delivered to the holder.

Issuance OpenAPI contract Java SDK — M2M authentication

When to use this guide
  • Your system already knows when to issue an attestation: an employee joins, a professional registers, a course ends.
  • You want to issue from your own processes, without a person logging into the Issuance Portal or the Administration Portal.
  • You need to retry safely, without issuing the same attestation twice.
Prerequisites
  • A machine credential (LEARCredentialMachine) of your organization, with the Onboarding · Execute power in your tenant's domain, and its EC P-256 private key.
  • Your tenant address, for example https://sandbox.stg.eudistack.net.
  • The credential_configuration_id of each attestation type you will issue, enabled in your tenant's catalogue, and the schema of its data.

Feature status

This guide describes the full API contract. Some parts are not deployed yet: they are marked Coming soon in their section and should not be used until they are.

Feature Status
M2M authentication to the Verifier Available
Issuance with email and ui delivery Available
direct delivery of types without holder binding Available
direct delivery with holder_key (types with holder binding) Coming soon
Any type enabled in the tenant catalogue Partial: today only the LEAR types and the product certification
Issuance process identifier in the Location header Coming soon
Renewal of the offer of a draft process Coming soon
organization field Coming soon
Validity dates (validFrom, validUntil) Coming soon
Idempotency with X-Idempotency-Key Partial: see Retries and idempotency
Errors with the codes in this guide Partial: today some errors carry other codes
Rate limit per organization Coming soon: today there is a limit per IP address

The flow, in short

sequenceDiagram
    autonumber
    participant App as Your system
    participant V as Verifier
    participant I as Issuance API

    App->>V: POST /verifier/oidc/token (client_credentials)
    V-->>App: access_token
    App->>I: POST /issuer/api/v1/issuances<br/>Bearer + X-Idempotency-Key
    I->>I: Checks tenant, powers, type and data
    alt delivery = email
        I-->>App: 200 · offer sent to the holder email
    else delivery = ui
        I-->>App: 200 · credential_offer_uri
    else delivery = direct
        I-->>App: 200 · signed attestation
    end
    Note over App,I: Location: issuance process identifier

Each request issues one attestation for one holder, with one delivery mode. The response includes the Location header with the process identifier, which is the reference to query or revoke it later.


Step 1: Get the access token

Your system authenticates to your tenant's Verifier with the client_credentials grant and its machine credential (private_key_jwt). There is no client pre-registration and no shared secret: the credential is enough, and the tenant is derived from its powers.

VerifierM2MClient client = VerifierM2MClient.builder()
    .verifierUrl("https://sandbox.stg.eudistack.net/verifier")
    .privateKey(privateKey)
    .credentialJwt(machineCredentialJwt)
    .build();

AccessToken token = client.authenticate();

Installation and options in Java SDK — M2M authentication.

POST /verifier/oidc/token HTTP/1.1
Host: sandbox.stg.eudistack.net
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&client_id=<your system identifier>
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=<JWT signed with the machine credential key>

The client_assertion carries iss and sub equal to client_id, aud with the Verifier address, a short exp, a unique jti and, in vp_token, the presentation of the machine credential encoded in Base64.

Reuse the access_token until it expires. Send it on every call: Authorization: Bearer <access_token>.


Step 2: Choose the delivery mode

The delivery field decides what EUDIStack does with the attestation and what the API returns. It takes a single value.

delivery What EUDIStack does What the API returns When to use it
email (default) Sends the holder an email with the attestation offer. Confirmation of the delivery. The holder stores the attestation in their wallet from the email.
ui Prepares the offer and returns it. credential_offer_uri. Your application shows the offer to the holder, for example as a QR code.
direct Signs the attestation and returns it, with no wallet involved. signed_credential. Your system receives the signed attestation, for example for a machine.

With email and ui, the optional grant_type field decides how the holder collects the attestation in their wallet: authorization_code (default) or urn:ietf:params:oauth:grant-type:pre-authorized_code.

Open the offer in a specific wallet

With ui, the API returns the credential_offer_uri with the openid-credential-offer:// scheme. The holder's device opens it with whatever wallet is registered for that scheme, and if there is none the link does nothing. If you want a specific wallet to open the offer, for example the EUDIStack web wallet, replace the start of the link with the address where that wallet receives offers and keep the rest as is:

Link
Returned by the API openid-credential-offer://?credential_offer_uri=https%3A%2F%2Fsandbox.stg.eudistack.net%2Fissuer%2Foid4vci%2Fv1%2Fcredential-offer%2FpV7DBgfDTXWGfq0mBPMg8Q
Shown to the holder https://sandbox.stg.eudistack.net/wallet/protocol/callback?credential_offer_uri=https%3A%2F%2Fsandbox.stg.eudistack.net%2Fissuer%2Foid4vci%2Fv1%2Fcredential-offer%2FpV7DBgfDTXWGfq0mBPMg8Q
  • Replace only openid-credential-offer://. Keep the ?credential_offer_uri=… query as is: it is already encoded and must not be encoded again.
  • If you build the link from the decoded credential_offer_uri value, encode it exactly once.
  • The EUDIStack web wallet receives offers at https://<tenant>.<environment>.eudistack.net/wallet/protocol/callback. If the holder is not signed in to the wallet, they are asked to sign in before the offer is shown.
  • For another wallet, use the address that wallet documents for receiving offers. OID4VCI 1.0 calls it credential_offer_endpoint and expects the wallet to publish it in its metadata.
  • The result is a regular https link: it works as a QR code, a button or in an email, and opens the web wallet even if the holder has no app installed.
const WALLET_OFFER_ENDPOINT = "https://sandbox.stg.eudistack.net/wallet/protocol/callback";

function toWalletLink(credentialOfferUri) {
  const query = credentialOfferUri.substring(credentialOfferUri.indexOf("?"));
  return WALLET_OFFER_ENDPOINT + query;
}
static final String WALLET_OFFER_ENDPOINT = "https://sandbox.stg.eudistack.net/wallet/protocol/callback";

static String toWalletLink(String credentialOfferUri) {
    String query = credentialOfferUri.substring(credentialOfferUri.indexOf('?'));
    return WALLET_OFFER_ENDPOINT + query;
}

If you renew the offer, apply the same transformation to the new link.

Attestations bound to a holder key

Coming soon

Today direct delivery is only available for types without holder binding. For types with holder binding, direct with holder_key will come in a later release.

Some attestation types require cryptographic holder binding: the attestation is bound to a key that only the holder controls. With email and ui, the holder's wallet provides that key when it collects the attestation, and you do nothing.

With direct there is no wallet involved, so you must send the holder's public key in holder_key, as a JWK (RFC 7517). EUDIStack does not ask for proof that the holder controls it. A key that includes the private part is rejected with invalid_holder_key.


Step 3: Request the issuance

Coming soon

Issuance is available, but the response does not include the Location header with the process identifier yet.

POST /issuer/api/v1/issuances

Field Required Description
credential_configuration_id Yes Attestation type, enabled in your tenant's catalogue.
payload Yes Attestation data, with the structure of the type's schema.
email Yes Holder email, or the email of the person responsible for a machine attestation.
delivery No email (default), ui or direct.
grant_type No Only with email and ui. See step 2.
holder_key Depends on the type Holder public key as a JWK. Only with direct and types with holder binding.
organization No organizationIdentifier of another organization of your tenant that will own the attestation. Only for the tenant administrator. By default, your system's organization. Coming soon.

Inside payload you can set validFrom and validUntil (ISO 8601) if the type allows setting the validity; otherwise the type's default validity applies. Coming soon.

POST /issuer/api/v1/issuances HTTP/1.1
Host: sandbox.stg.eudistack.net
Authorization: Bearer <access_token>
X-Idempotency-Key: 7d1c9f3a-2b4e-4c8a-9f10-5e6d7a8b9c01
Content-Type: application/json

{
  "credential_configuration_id": "learcredential.employee.w3c.4",
  "delivery": "email",
  "email": "holder@company.example",
  "payload": { "...": "attributes per the type schema" }
}
HTTP/1.1 200 OK
Location: /api/v1/issuances/0b6f1d2e-8c4a-4b1f-9e2d-7a5c3f8e1b90
Content-Type: application/json

{ "responses": [ { "channel": "email", "status": 200 } ] }
{
  "credential_configuration_id": "learcredential.employee.w3c.4",
  "delivery": "ui",
  "grant_type": "urn:ietf:params:oauth:grant-type:pre-authorized_code",
  "email": "holder@company.example",
  "payload": { "...": "attributes per the type schema" }
}
{
  "responses": [
    {
      "channel": "ui",
      "status": 200,
      "body": { "credential_offer_uri": "openid-credential-offer://?credential_offer_uri=https%3A%2F%2F..." }
    }
  ]
}
{
  "credential_configuration_id": "learcredential.machine.sd.1",
  "delivery": "direct",
  "email": "owner@company.example",
  "holder_key": {
    "kty": "EC",
    "crv": "P-256",
    "x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
    "y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0"
  },
  "payload": { "...": "attributes per the type schema" }
}
{
  "responses": [
    {
      "channel": "direct",
      "status": 200,
      "body": { "signed_credential": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImRjK3NkLWp3dCJ9...~WyJzYWx0Ii...~" }
    }
  ]
}

signed_credential uses the compact serialization: SD-JWT for dc+sd-jwt and JWT for jwt_vc_json.

Store the Location identifier with the holder's record in your system: it is the reference to query or revoke the attestation.


Renew the offer

Coming soon

This operation is not available yet. Today the offer can only be renewed from the link in the email the holder receives.

A credential offer expires after 10 minutes. If the holder has not opened it in time, request a new one for the same process, without creating another issuance:

POST /issuer/api/v1/issuances/{id}/credential-offer

{id} is the process identifier returned by the issuance in Location. The request has no body. EUDIStack creates a new offer with the same grant, voids the previous one and delivers it with the process delivery mode:

Process mode What it returns
email Confirmation that the new offer was sent to the holder email.
ui The new credential_offer_uri, with the same shape as in the issuance.

You can only renew while the process is a draft (DRAFT), that is, while the holder has not collected the attestation. In any other state, or if the mode is direct, it is rejected with 409 offer_not_renewable. A process that does not exist or is outside your scope gets 404 issuance_not_found.

With ui, this lets you always show a valid offer: when the one you show expires, request another and replace the QR code. Once the holder collects the attestation, renewal returns offer_not_renewable: stop renewing.


Retries and idempotency

Always send the X-Idempotency-Key header with a unique value per issuance, for example a UUID generated when preparing the request, and reuse it when retrying that same issuance. This lets you retry after a network cut or a timeout without issuing twice.

Situation Contract behaviour Behaviour today
Same key and same body, after a success Returns the same response without issuing another attestation, for 24 hours. Same, for 1 hour, unless the service has restarted.
Same key with another body Rejected with 422 idempotency_key_reused. Returns the response of the first request.
Same key while the first request is still running Rejected with 409 idempotency_in_progress. Both are processed: it may issue twice.
Retry after an error Processed again: errors are not stored. Same.

While idempotency is partial, do not send two requests with the same key at the same time, and do not reuse a key for a different issuance.


Errors

Coming soon

Today some issuance errors do not follow the table in this section yet: not all of them are application/problem+json, BAD_REQUEST does not include violations, and an unknown delivery mode returns invalid_request. The rate limit is 100 requests per minute per IP address, and its 429 has no body. The contract sets 300 requests per minute per organization for systems.

Errors are returned as application/problem+json (RFC 9457). Build your logic on type, which is a stable code, not on title or detail. A rejected request leaves nothing created.

{
  "type": "payload_validation_error",
  "title": "Payload validation error",
  "status": 400,
  "detail": "The payload does not match the schema of learcredential.machine.sd.1",
  "instance": "/api/v1/issuances",
  "violations": [ { "field": "payload.mandatee.serviceName", "message": "is required" } ]
}
HTTP type Cause What to do
400 BAD_REQUEST A field is missing or has a wrong format. violations says which. Fix the request.
400 payload_validation_error The data does not match the type's schema. Fix payload.
400 unsupported_credential_type The type does not exist or is not enabled in your tenant. Check credential_configuration_id.
400 invalid_delivery_mode delivery is not email, ui or direct, or has several values. Send a single mode.
400 invalid_grant_type grant_type is not one of its two values. Fix or remove the field.
400 invalid_validity The validity end is not after the start, or exceeds the type's maximum duration. Adjust the dates.
400 invalid_holder_key The holder key is missing when required, is not a valid public key, or includes the private part. Send only the public key.
400 unknown_organization organization is not an organization of your tenant. Check the identifier.
401 invalid_token, authentication_error The token is missing, invalid or expired. Get a new token.
403 insufficient_permission Your credential's powers do not allow this issuance. Check the machine credential powers.
403 tenant_mismatch The tenant of the address is not your credential's tenant. Use your tenant address.
409 delivery_mode_not_eligible The type or the tenant does not allow that delivery mode. Use another mode.
409 offer_not_renewable When renewing the offer: the process is no longer a draft or its mode is direct. Stop renewing: the holder already collected the attestation or the process ended.
409 idempotency_in_progress Another request with the same key is running. Wait and retry.
422 idempotency_key_reused The same key was sent with another body. Use a new key for each issuance.
429 rate_limited The rate limit was exceeded. Wait for Retry-After.
500 persistence_failed, delivery_failed Internal error. Retry later with the same key.
503 signing_failed, status_list_unavailable, wallet_delivery_timeout A required service is unavailable. Wait for Retry-After and retry with the same key.
Troubleshooting
Symptom Probable cause Resolution
403 when issuing a given type The type is not enabled in your tenant, or does not support API issuance yet Check your tenant catalogue with your EUDIStack contact
403 tenant_mismatch You are calling another tenant's address Use the address of your machine credential's tenant
401 after working for a while The access_token has expired Get a new one with the same credential and repeat the call
delivery_mode_not_eligible with direct The type requires holder binding Use email or ui until direct with holder_key is available
Security considerations
  • The machine credential's private key is your system's identity: keep it in a secrets manager, never in code or logs. If it is compromised, report it so the credential can be revoked.
  • Send only the holder's public key in holder_key. Their private key must never leave their device.
  • With direct, the signed attestation only travels in the response: EUDIStack does not store it. Treat it as the holder's personal data.

Full reference

The full issuance API contract, with every field, response and error code, is in the Issuer API reference.