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 configured for your organization in the portal.
  • Issuer setup completed (DID + status list) in the portal.
  • For webhook progress notifications, a destination and event subscriptions configured in Settings > Webhooks in the portal.

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 existing optional offer-email delivery 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

Wallet accepts the credential

Offer TTL exceeded

Issuance error

pending

issued

expired

failed

POST /credential-offers

Wallet accepts the credential

Offer TTL exceeded

Issuance error

pending

issued

expired

failed

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.

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.

Credential validity

Every credential carries an SD-JWT exp claim (when set) that wallets and verifiers honour independently of status. The lifetime is resolved at issuance time from this fallback chain:

  1. validity_seconds on the create request — explicit override, only for this offer.
  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 back on the offer response as credential_validity_seconds; 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/{id}. 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/{id}/status and re-issue — the JWT is signed and immutable.

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 in your own durable request record. Invitation create, list, and get responses intentionally omit both, 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.

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.

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 or validity_seconds, 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.

Enum:"pending""issued""accepted""expired""failed"
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 are 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""accepted""expired""failed"
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.

completed_atstring, (date-time)

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

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. When status changes to issued, the issued_credential_id field contains the credential ID and deeplink/qr_content_base64 are omitted.

Note

When status is no longer pending, deeplink and qr_content_base64 are omitted from the response — the wallet flow is complete.

Polling recommendations:

  • Poll every 2–3 seconds.
  • Stop when status is issued, failed, or expired.
  • 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 using the same credential definition.
  4. Ask the holder to present the credential by scanning the verification QR code.
  5. On success — the credential is confirmed to be in the holder's wallet and valid.

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
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

Error handling

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

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