Skip to content
Last updated

Credential Issuance Flow

This guide explains both B2B issuance branches: an immediate credential offer and a partner-delivered invitation code. Both branches end in the same credential-offer and issued-credential lifecycle.

Prerequisites

  • An API credential with the deepcredentials.issue scope. See Authentication.
  • A credential definition owned by your organization and enabled in the environment you call (X-Environment). Offers are refused with failed_precondition for a definition that is not enabled there.
  • Issuer setup completed (DID + status list) in the portal for that environment. Without it, creating an offer returns failed_precondition.
  • For webhook progress notifications, a destination and event subscriptions configured on the B2B API page of the portal (see Webhooks).

Portal configuration comes first. At runtime, your partner integration uses the B2B endpoints below; invitation redemption is the one anonymous public step performed for the recipient.

If issuance follows an eID check, wait for status: success and evaluate credential_status through the Verification Flow, then fetch the disclosed values with authenticated GET. Your system decides how to build the new credential claims: it may copy, transform, supplement, or ignore the verified values. Issuance does not accept a verification-session ID and does not require the new claims to match the eID.

Choose a delivery branch

BranchCreate callWhat your system receivesWho delivers it
Immediate offerPOST /b2b/v1/credential-offersAn OID4VCI deeplink and QR imageYour system displays or delivers the offer. The optional delivery ("via": "email") on the create call is separate from invitations.
InvitationPOST /b2b/v1/credential-invitationsInvitation metadata and a one-time codeYour system delivers the code and controls the redemption experience. DeepCredentials sends no B2B invitation email.

Use the immediate branch when the holder can consume the offer now. Use an invitation when the holder should redeem later or through a separate channel controlled by your system.

Offer lifecycle

POST /credential-offers

Credential delivered to the wallet

Offer TTL exceeded

Issuance error

Offer withdrawn

pending

issued

expired

failed

revoked

POST /credential-offers

Credential delivered to the wallet

Offer TTL exceeded

Issuance error

Offer withdrawn

pending

issued

expired

failed

revoked

Offer statuses

Status    Description
pendingOffer created, waiting for the wallet holder to scan the QR and accept.
issuedCredential successfully issued to the wallet. issued_credential_id is set.
expiredOffer exceeded its TTL without being claimed.
failedIssuance failed. Check error_code and error_description for details.
revokedYour organization withdrew the pending offer; its deeplink and QR code no longer work.

Credential statuses

Once issued, a credential has its own lifecycle:

Status    Description
activeCredential is valid and can be presented by the holder.
suspendedTemporarily suspended. Can be reactivated.
revokedPermanently revoked. Cannot be reactivated.

These three values cover the revocation lifecycle (status-list driven). Expiration is an orthogonal axis — see Credential validity below.

Copies in the wallet

A credential is delivered as a batch of up to ten copies of the same data, each with its own key and status entry, and the wallet presents a fresh copy each time, so verifiers cannot link two presentations of it. The wallet refills the copies in the background, without a new offer, for as long as the credential is active and not expired; a refill keeps the data and the expiry. None of this shows in the API: you see one credential, with one id and one status, and a status change applies to every copy, refilled ones included. Whether a wallet asks for several copies is the wallet's choice; current swiyu wallets mostly take one.

Credential validity

A credential carries an SD-JWT exp claim when a lifetime applies; wallets and verifiers honour it independently of status. The lifetime is resolved from this fallback chain:

  1. validity_seconds or valid_until on the create request — explicit override, only for this offer. validity_seconds is a duration from issuance; valid_until is a calendar day (YYYY-MM-DD) and the credential expires at 23:59:59 of that day, Europe/Zurich time. The two are mutually exclusive.
  2. default_validity_seconds on the credential definition — tenant-set default for this vct.
  3. No exp claim — credential never expires (only reached if both above are absent).

The value is stamped onto the SD-JWT at signing time. The resolved lifetime is echoed on the offer (GET /b2b/v1/credential-offers/{offerId}) as credential_validity_seconds, or as credential_valid_until when it was given as a day; once the credential is issued it surfaces as valid_until (alongside valid_from, which mirrors iat/nbf) on the issued-credential response — GET /b2b/v1/issued-credentials/{credentialId}. These surfaces are read-only — credentials are immutably signed, so once issued the validity window cannot be changed without re-issuing.

ConceptSpecCarrierField
Offer (QR / pre-auth) TTLHow long the wallet has to pick up the offerOffer recordexpires_at
Credential lifetimeWhen the credential itself stops being validSD-JWT payloadexp → issued-credential valid_until
Status ≠ expiration

A credential can be active and past its valid_until simultaneously. Wallets show it as expired; verifiers reject it on the exp claim; our status list is unchanged. To shorten the lifetime of an existing credential you must revoke it via PATCH /b2b/v1/issued-credentials/{credentialId}/status and re-issue — the JWT is signed and immutable.

validity_seconds range: 60 seconds to 10 years (315 360 000 seconds). Self-service flows always carry their own explicit validity chosen on the config; the request-level override here is for direct B2B issuance only.

Partner-delivered invitation flow

An invitation stores the credential claims and eventual offer settings until the recipient redeems a one-time code. B2B list, get, and revoke responses never expose the code or claims. Only successful create and rotate responses contain the plaintext code.

Invitation statuses

StatusDescription
pendingThe current code can be redeemed, subject to expires_at.
redeemedThe recipient redeemed the code and DeepCredentials created redeemed_offer_id.
expiredThe pending code passed its expiry and can no longer be redeemed.
revokedYour organization revoked the invitation before redemption.

Create an invitation

Open in API reference →

curl -i -X POST \
  https://api.dev.deepcredentials.swiss/b2b/v1/credential-invitations \
  -H 'Authorization: Bearer <YOUR_JWT_HERE>' \
  -H 'Content-Type: application/json' \
  -H 'X-Environment: beta' \
  -H 'X-Org-Id: 497f6eca-6276-4993-bfeb-53cbbbba6f08' \
  -d '{
    "credential_definition_id": "db167a9d-8182-430c-bff0-f9baa6d48bb6",
    "email": "alice@example.com",
    "claims": {
      "given_name": "Alice",
      "employee_number": "E-1042"
    },
    "reference_id": "partner-invitation-1042",
    "invitation_ttl_seconds": 86400,
    "offer_validity_seconds": 600,
    "validity_seconds": 31536000
  }'

reference_id is optional correlation metadata echoed on the invitation. It is not an idempotency key, uniqueness constraint, or cached-response key. Repeating create can create another resource unless the pending-recipient uniqueness rule rejects it.

The successful response includes code, formatted like 7KMTD-5X7Q9. Send that secret through your own email, SMS, or other channel. DeepCredentials does not send the email and does not return a redemption_url for this B2B operation.

Keep the submitted claims and validity_seconds (or valid_until) in your own durable request record. Invitation create, list, and get responses intentionally omit them, so they cannot be reconstructed from the B2B resource later.

codestring, = 11 characters^[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}$required

One-time plaintext invitation code. Returned only by create and rotate; the caller owns secure delivery.

code_last4string, = 4 characters^[0-9A-HJKMNP-TV-Z]{4}$required

Last four normalized code characters for support and delivery confirmation. This is not a redeemable code.

created_atstring, (date-time)required

Creation timestamp in RFC 3339 UTC.

credential_definition_idstring, (uuid)required

Credential definition used for the eventual offer.

credential_definition_namestringrequired

Credential definition name captured when the invitation was created.

emailstring, (email)required

Normalized recipient email required for public redemption.

environmentstringrequired

Environment selected by X-Environment when the invitation was created.

Enum:"beta""production"
expires_atstring, (date-time)required

Invitation-code expiry timestamp in RFC 3339 UTC.

invitation_idstring, (uuid)required

Credential invitation ID.

statusstring(CredentialInvitationStatus)required

Current invitation lifecycle state.

Enum:"pending""redeemed""expired""revoked"
reference_idstring

Optional correlation identifier from your system; echoed on the resource.

offer_validity_secondsinteger, (int64)

Configured lifetime of the offer created after redemption, when supplied at creation.

redeemed_offer_idstring, (uuid)

Offer created by a successful redemption. Present only after redemption.

redeemed_atstring, (date-time)

Successful redemption timestamp in RFC 3339 UTC.

revoked_atstring, (date-time)

Revocation timestamp in RFC 3339 UTC.

archived_atstring, (date-time)

When an operator put this invitation away in the portal's archive, if they did. A filing decision in the portal's lists; nothing about the invitation changes with it, and it is absent while the invitation is not archived.

Redeem the invitation

Your system decides where and how to present the redemption experience. Once it has the recipient's email address and one-time code, its backend exchanges them through the anonymous public endpoint:

POST /api/v1/public/credential-invitations/redeem
Content-Type: application/json

{
  "email": "alice@example.com",
  "code": "7KMTD-5X7Q9"
}

The response contains a fresh OID4VCI deeplink, qr_content_base64, offer_id, and expires_at. Your system decides how to deliver or display those values to the recipient. This endpoint requires no service-user token; do not expose a B2B token to the recipient or include one in the redemption call.

An unknown email, a wrong code, or a code that is no longer pending returns 404 not_found without saying which. A correct code past its expires_at returns 400 failed_precondition. Repeated failed attempts for the same email from the same client IP return 429 resource_exhausted.

A successful redemption atomically marks the invitation redeemed and creates a fresh OID4VCI offer. The credential_invitation.status_changed webhook contains the resulting offer_id; from that point, track the offer through GET /b2b/v1/credential-offers/{offerId} and issuing.status_changed.

List and get invitations

List results are scoped by your X-Org-Id and X-Environment, ordered newest first, and contain no plaintext code or claims. The optional filters are exact matches.

curl -i -X GET \
  'https://api.dev.deepcredentials.swiss/b2b/v1/credential-invitations?limit=50&offset=0&status=pending&credential_definition_id=497f6eca-6276-4993-bfeb-53cbbbba6f08&email=string&reference_id=string' \
  -H 'Authorization: Bearer <YOUR_JWT_HERE>' \
  -H 'X-Environment: beta' \
  -H 'X-Org-Id: 497f6eca-6276-4993-bfeb-53cbbbba6f08'

Use get when you already have the invitation ID:

curl -i -X GET \
  'https://api.dev.deepcredentials.swiss/b2b/v1/credential-invitations/{invitationId}' \
  -H 'Authorization: Bearer <YOUR_JWT_HERE>' \
  -H 'X-Environment: beta' \
  -H 'X-Org-Id: 497f6eca-6276-4993-bfeb-53cbbbba6f08'

Recover from a lost create response

Because create is not idempotent, do not blindly retry after an ambiguous transport failure. Persist the complete create request locally and use a distinct reference_id for each intended invitation:

  1. List with the exact credential_definition_id, normalized email, and your reference_id; do not restrict the first lookup to pending, because the invitation may have reached a terminal state while the response was unavailable.
  2. If there is exactly one match, compare all returned metadata with your durable request record. Reconcile a terminal match through its status and redeemed_offer_id rather than creating another invitation.
  3. Rotate only when the matching invitation is still pending and your system can establish that it belongs to this create attempt, then deliver only the replacement code returned by rotate.
  4. If there is no match, retry create. If that returns already_exists, list by pending status, definition, and email without reference_id and resolve the conflicting resource manually.
  5. If there is more than one match or the identity is otherwise ambiguous, do not select, rotate, or revoke a resource automatically.

The pending-recipient uniqueness rule prevents two pending invitations for the same organization, environment, credential definition, and normalized email. That rule supports discovery but does not turn reference_id into an idempotency key. List/get responses do not expose claims, validity_seconds, or valid_until, so those hidden inputs can be verified only against the request record retained by your system.

Rotate a code

curl -i -X POST \
  'https://api.dev.deepcredentials.swiss/b2b/v1/credential-invitations/{invitationId}/rotate-code' \
  -H 'Authorization: Bearer <YOUR_JWT_HERE>' \
  -H 'Content-Type: application/json' \
  -H 'X-Environment: beta' \
  -H 'X-Org-Id: 497f6eca-6276-4993-bfeb-53cbbbba6f08' \
  -d '{
    "invitation_ttl_seconds": 86400
  }'

Rotation is deliberately non-idempotent. Every successful call generates a new code, invalidates the previous one, resets failed attempts, and replaces the expiry. If the response is lost, call rotate again until you receive a response; then use only that response's code. DeepCredentials never emails a B2B replacement code.

Revoke an invitation

curl -i -X POST \
  'https://api.dev.deepcredentials.swiss/b2b/v1/credential-invitations/{invitationId}/revoke' \
  -H 'Authorization: Bearer <YOUR_JWT_HERE>' \
  -H 'X-Environment: beta' \
  -H 'X-Org-Id: 497f6eca-6276-4993-bfeb-53cbbbba6f08'

Only a pending invitation can be revoked. Repeating revoke, or revoking an invitation that is already redeemed or expired, returns failed_precondition.

Step 1: Create an offer

Open in API reference →

curl -i -X POST \
  https://api.dev.deepcredentials.swiss/b2b/v1/credential-offers \
  -H 'Authorization: Bearer <YOUR_JWT_HERE>' \
  -H 'Content-Type: application/json' \
  -H 'X-Environment: beta' \
  -H 'X-Org-Id: 497f6eca-6276-4993-bfeb-53cbbbba6f08' \
  -d '{
    "credential_definition_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "claims": {
      "given_name": "Max",
      "family_name": "Mustermann",
      "date_of_birth": "1990-01-15",
      "is_over_18": true
    },
    "reference_id": "order-12345",
    "offer_validity_seconds": 600
  }'

validity_seconds is optional — omit to inherit the credential definition's default_validity_seconds. See Credential validity for the full fallback chain.

Response

idstring, (uuid)required

UUID v4 identifier.

Example:"550e8400-e29b-41d4-a716-446655440000"
statusstring(issuer.OfferStatus)required

Lifecycle state of a credential offer. revoked: a pending offer withdrawn with POST /b2b/v1/credential-offers/{offerId}/revoke.

Enum:"pending""issued""expired""failed""revoked"
deeplinkstringrequired

OID4VCI wallet deep link (e.g. openid-credential-offer://...).

qr_content_base64stringrequired

Base64-encoded PNG; QR payload is the same URI as deeplink.

claimsobjectrequired

Claim values for the credential (echoed from the request).

expires_atstring, (date-time)required

Offer TTL deadline (RFC3339).

created_atstring, (date-time)required

When the offer was created (RFC3339).

The response includes a QR code and deeplink for the wallet holder.

Claim validation

Claim values are validated against the credential definition. Boolean claims must be JSON true/false, integer and number claims must be JSON numbers, string claims must be JSON strings. Missing mandatory claims and claims the definition does not define are rejected with invalid_argument. If the definition has a validity-date claim, omit it: it is derived from the credential lifetime, and a request that sets it is rejected.

Step 2: Present the QR code

You have two options to present the issuance offer to the wallet holder:

QR code (desktop / in-person)

The qr_content_base64 field is a standard Base64-encoded PNG image. Display it directly:

<img src="data:image/png;base64,iVBORw0KGgo..." alt="Scan to receive your credential" />

The deeplink field contains a URI that opens the wallet app directly:

openid-credential-offer://?credential_offer=...

Use this when the user is on a mobile device — redirect them to the deeplink instead of showing a QR code.

Step 3: Track issuance

Option A: Polling

Poll GET /b2b/v1/credential-offers/{offerId} until the status is no longer pending:

Open in API reference →

curl -i -X GET \
  'https://api.dev.deepcredentials.swiss/b2b/v1/credential-offers/{offerId}' \
  -H 'Authorization: Bearer <YOUR_JWT_HERE>' \
  -H 'X-Environment: beta' \
  -H 'X-Org-Id: 497f6eca-6276-4993-bfeb-53cbbbba6f08'

Response

idstring, (uuid)required

Offer id (matches the value returned at creation time). UUID v4 identifier.

Example:"550e8400-e29b-41d4-a716-446655440000"
environmentstringrequired

Swiyu environment: "beta" or "production".

credential_definitionobject(b2b.credentialDefinitionRef)required

Credential definition reference.

statusstring(issuer.OfferStatus)required

Current lifecycle state of the offer.

Enum:"pending""issued""expired""failed""revoked"
claimsobjectrequired

Claim values for the credential.

expires_atstring, (date-time)required

When a pending offer expires if not completed (RFC3339).

created_atstring, (date-time)required

When the offer was created (RFC3339).

sourcestringrequired

Source records where this offer originated: "portal" (admin UI), "b2b" (API caller), "invitation" (redeemed email invitation), or "self_service" (admin approval of a public request). Stable at row level — captured at create and never mutated.

archived_atstring, (date-time)

When an operator put this offer away in the portal's archive, if they did. A filing decision in the portal's lists; nothing about the offer changes with it.

completed_atstring, (date-time)

Set when the offer leaves pending (issued, failed, expired, or revoked).

credential_valid_untilstring, (date-time)

Absolute expiry when the lifetime was given as a day (RFC3339; the end of that day in the issuer's time zone, Europe/Zurich). Absent for a duration or no lifetime.

credential_validity_secondsinteger, (int64)

Credential lifetime to apply at issuance (SD-JWT exp = iat + N). Absent means the credential will be issued without an exp claim.

deeplinkstring

Only when status is pending: OID4VCI wallet deep link.

error_codestring

Machine-readable failure reason when status is failed or expired.

error_descriptionstring

Human-readable error detail when status is failed or expired.

invitation_idstring, (uuid)

InvitationID / SelfServiceRequestID expose the upstream lineage anchors when this offer was created from one. nil for plain portal or B2B offers.

Example:"550e8400-e29b-41d4-a716-446655440000"
issued_credential_idstring, (uuid)

Only when status is issued: ID of the issued credential. UUID v4 identifier.

Example:"550e8400-e29b-41d4-a716-446655440000"
qr_content_base64string

Only when status is pending: Base64-encoded PNG QR code (240×240 px).

reference_idstring

Your reference_id from the create request, if any.

self_service_request_idstring, (uuid)

UUID v4 identifier.

Example:"550e8400-e29b-41d4-a716-446655440000"

While status is pending, deeplink and qr_content_base64 are present. Once status is no longer pending, both are omitted; when it is issued, the issued_credential_id field contains the credential ID.

Polling recommendations:

  • Poll every 2–3 seconds.
  • Stop when status is issued, failed, expired, or revoked.
  • Respect the expires_at timestamp — stop polling after expiration.

Option B: Webhooks

Subscribe to the issuing.status_changed webhook event to receive a slim terminal-state hint (offer_id and status). Follow every callback with authenticated GET /b2b/v1/credential-offers/{offerId} when you need the authoritative offer snapshot. The webhook does not contain the offer claims or duplicate the GET response.

For the delayed branch, also opt in to credential_invitation.status_changed. It fires only for redeemed, expired, or revoked. A redeemed callback contains offer_id, which is the handoff to the normal offer lifecycle; expired and revoked callbacks omit it.

See the Webhooks guide for setup, signature verification, and secret rotation.

Step 4: Manage credentials

Get credential details

Use GET /b2b/v1/issued-credentials/{credentialId} to retrieve details about an issued credential, including its current status.

Important: Issuance does not confirm holder acceptance

Issued ≠ Accepted

When the offer status changes to issued, it means the credential was delivered to the wallet. However, the current swiyu wallet does not send a confirmation callback — the issuer has no way to know whether the holder actually accepted the credential or dismissed it.

This is a known limitation of the swiyu Public Beta infrastructure. The OID4VCI specification defines an optional notification_endpoint for this purpose, but the swiyu wallet does not implement it yet.

To confirm that a holder actually has the credential in their wallet, use the Verification API to request a presentation of the recently issued credential:

  1. Issue the credential via POST /b2b/v1/credential-offers.
  2. Wait for issued status (poll or webhook).
  3. Create a verification session via POST /b2b/v1/verification-sessions with the same credential_definition_id.
  4. Ask the holder to present the credential by scanning the verification QR code.
  5. On success with credential_status: valid — the credential is confirmed to be in the holder's wallet and not suspended or revoked.

This "issue-then-verify" pattern gives you cryptographic proof that the holder possesses the credential, which issued status alone cannot guarantee.

Holder's WalletDeepCredentials APIYour SystemHolder's WalletDeepCredentials APIYour SystemCredential delivered, but accepted?Confirmed — holder has the credentialPOST /credential-offersoffer (QR code)Show QR to holderOID4VCI flowstatus: issuedPOST /verification-sessionsverification QRShow verification QROID4VP presentationstatus: success (claims via GET)
Holder's WalletDeepCredentials APIYour SystemHolder's WalletDeepCredentials APIYour SystemCredential delivered, but accepted?Confirmed — holder has the credentialPOST /credential-offersoffer (QR code)Show QR to holderOID4VCI flowstatus: issuedPOST /verification-sessionsverification QRShow verification QROID4VP presentationstatus: success (claims via GET)

Error handling

On failure: the offer status changes to failed. Create a new offer to retry.

To withdraw an offer: call POST /b2b/v1/credential-offers/{offerId}/revoke. Only a pending offer can be revoked; its status becomes revoked, and any other status returns 400 failed_precondition. To act on a credential that was already issued, use PATCH /b2b/v1/issued-credentials/{credentialId}/status.

On expiration: offers that remain pending past their expires_at transition to expired automatically. Default TTL is 10 minutes.

See Error Handling for a complete list of error codes.

Next steps