Issue a credential
One call turns an issuance profile plus claim data into a credential offer URI. Render it as a QR code or a link; the wallet does the rest.
Create an offer
Request
curl "$LUTRAID_API/v1/organizations/$ORG/issuers/$ISSUER/create-credential-offer" \
-H "X-API-Key: $LUTRAID_KEY" \
-H "Content-Type: application/json" \
-d '{
"issuanceProfileId": "8f1b…",
"userPinRequired": true,
"credentialOfferMode": "auto",
"data": [
{ "documentTypeId": "pid-sd-jwt", "credentialSubject": { "given_name": "Ana", "family_name": "Novak" } }
]
}'
{
"credentialOfferId": "3c9e…",
"historyId": "b71d…",
"credentialOffer": "openid-credential-offer://?credential_offer_uri=https%3A%2F%2F…",
"transactionCode": "418302",
"deliveryMode": "direct",
"recipientEmail": null,
"deliveryStatus": "not_requested",
"expiresAt": "2026-10-19T09:51:00.000Z"
}
- Name
issuanceProfileId- Type
- uuid
- Description
Required. The issuance profile assigned to this issuer; it decides formats, policies and grant type. The dashboard calls it a document: copy the Document ID from Setup → Documents.
- Name
data- Type
- array
- Description
Required. One entry per document type:
documentTypeIdplus itscredentialSubjectclaims. This API does not define or validate claim names beyond what the document type was configured with.
- Name
userPinRequired- Type
- boolean
- Description
Returns a
transactionCode, the PIN the wallet asks for. It is returned once, in this response, and never in an idempotent replay. LutraID never puts it in the offer email, so give it to the holder another way (on screen, in person or by phone), separately from the link. The wallet sends it astx_codeto the token endpoint.
- Name
deliveryMode- Type
- string
- Description
direct(default) returns the offer for you to render.emailalso sends it torecipientEmail.
- Name
recipientEmail- Type
- string
- Description
Required with
deliveryMode: "email".
- Name
recipientLanguage- Type
- string
- Description
Language of the offer email:
en(default),deorsl.
- Name
credentialConfigurationIds- Type
- array
- Description
Offer only these credential configurations of the issuance profile, for example to send again only the documents a wallet did not add.
datais then needed only for their document types. Omit to offer all of them.
- Name
credentialOfferMode- Type
- string
- Description
auto(recommended),by_valueorby_reference.
credentialOffer is an openid-credential-offer:// deep link. There is no QR endpoint — render the string yourself. The offer expires 10 minutes after creation; this isn't currently configurable.
Offer modes
| Mode | Behaviour |
|---|---|
auto | By value while the encoded offer stays under ~1000 characters, by reference above it |
by_value | Always inline — fails if the offer is too large |
by_reference | Always a credential_offer_uri the wallet fetches |
Use auto unless you have a wallet that cannot dereference credential_offer_uri.
Delivery
With deliveryMode: "direct" you render the URI yourself. With "email", LutraID also emails the link to recipientEmail in recipientLanguage, and deliveryStatus starts as pending. The email never contains the PIN: either give the holder the transactionCode another way or set userPinRequired to false for email offers.
Retrying safely
Send an Idempotency-Key header (8–200 characters, ^[A-Za-z0-9._~:-]+$) to retry a create without creating a second offer:
| Replay | Result |
|---|---|
| Same key, same request body | The original offer as 200, without transactionCode |
| Same key, a different body, or the first call still running | 409 |
| Same key after the offer's data is no longer kept | 409 |
Success on first creation is 201. Store the transactionCode from that response: a replay can't return it again.
Bulk offers
Creates up to 50 email offers in one call. Generate one batchId (a UUID) per bulk send and reuse it for every request and retry of that send. Each entry has its own id, unique within the batch.
cURL
curl "$LUTRAID_API/v1/organizations/$ORG/issuers/$ISSUER/create-bulk-credential-offer" \
-H "X-API-Key: $LUTRAID_KEY" \
-H "Content-Type: application/json" \
-d '{
"batchId": "0d6f2c1a-…",
"issuanceProfileId": "8f1b…",
"userPinRequired": false,
"recipientLanguage": "de",
"entries": [
{
"id": "row-1",
"recipientEmail": "ana@example.com",
"data": [{ "documentTypeId": "pid-sd-jwt", "credentialSubject": { "given_name": "Ana" } }]
}
]
}'
The response is 200 with one result per entry:
{
"batchId": "0d6f2c1a-…",
"results": [
{ "id": "row-1", "status": "created", "historyId": "…", "credentialOffer": "openid-credential-offer://…", "replayed": false },
{ "id": "row-2", "status": "invalid", "code": "VALIDATION_ERROR", "errors": [] },
{ "id": "row-3", "status": "failed", "code": "INTERNAL_ERROR" }
]
}
created: the offer exists.replayed: truemeans an earlier request of this batch created it;transactionCodeis then absent.invalid: the entry's data doesn't fit the document type.errorspoint into the entry. Fix and resend that row.failed: the row could not be created. Resend it with the samebatchIdandid.
More than 50 entries returns 422; a stopped issuer returns 409 ISSUER_STOPPED. create-bulk-authorization-code-offer works the same way for the authorization code flow.
Errors: 400, 409 (idempotency conflict or ISSUER_STOPPED), 422 (validation), 429 (QUOTA_EXCEEDED, a plan limit) — see Errors.
Next
The holder's wallet now exchanges the pre-authorized code at the token endpoint and fetches the credential. Watch it with issuance status, or switch to the authorization code flow when claims must come from a login instead of your request body.