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.
- An API credential with the
deepcredentials.issuescope. 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.
| Branch | Create call | What your system receives | Who delivers it |
|---|---|---|---|
| Immediate offer | POST /b2b/v1/credential-offers | An OID4VCI deeplink and QR image | Your system displays or delivers the offer. The existing optional offer-email delivery is separate from invitations. |
| Invitation | POST /b2b/v1/credential-invitations | Invitation metadata and a one-time code | Your 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.
| Status | Description |
|---|---|
pending | Offer created, waiting for the wallet holder to scan the QR and accept. |
issued | Credential successfully issued to the wallet. issued_credential_id is set. |
expired | Offer exceeded its TTL without being claimed. |
failed | Issuance failed. Check error_code and error_description for details. |
Once issued, a credential has its own lifecycle:
| Status | Description |
|---|---|
active | Credential is valid and can be presented by the holder. |
suspended | Temporarily suspended. Can be reactivated. |
revoked | Permanently revoked. Cannot be reactivated. |
These three values cover the revocation lifecycle (status-list driven). Expiration is an orthogonal axis — see Credential validity below.
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:
validity_secondson the create request — explicit override, only for this offer.default_validity_secondson the credential definition — tenant-set default for thisvct.- No
expclaim — 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.
| Concept | Spec | Carrier | Field |
|---|---|---|---|
| Offer (QR / pre-auth) TTL | How long the wallet has to pick up the offer | Offer record | expires_at |
| Credential lifetime | When the credential itself stops being valid | SD-JWT payload | exp → issued-credential valid_until |
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.
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.
| Status | Description |
|---|---|
pending | The current code can be redeemed, subject to expires_at. |
redeemed | The recipient redeemed the code and DeepCredentials created redeemed_offer_id. |
expired | The pending code passed its expiry and can no longer be redeemed. |
revoked | Your organization revoked the invitation before redemption. |
- Developmenthttps://api.dev.deepcredentials.swiss/b2b/v1/credential-invitations
- Integrationhttps://api.int.deepcredentials.swiss/b2b/v1/credential-invitations
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.
One-time plaintext invitation code. Returned only by create and rotate; the caller owns secure delivery.
Last four normalized code characters for support and delivery confirmation. This is not a redeemable code.
Creation timestamp in RFC 3339 UTC.
Credential definition used for the eventual offer.
Credential definition name captured when the invitation was created.
Normalized recipient email required for public redemption.
Environment selected by X-Environment when the invitation was created.
Invitation-code expiry timestamp in RFC 3339 UTC.
Credential invitation ID.
Current invitation lifecycle state.
Optional correlation identifier from your system; echoed on the resource.
Configured lifetime of the offer created after redemption, when supplied at creation.
Offer created by a successful redemption. Present only after redemption.
Successful redemption timestamp in RFC 3339 UTC.
Revocation timestamp in RFC 3339 UTC.
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 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.
- Developmenthttps://api.dev.deepcredentials.swiss/b2b/v1/credential-invitations
- Integrationhttps://api.int.deepcredentials.swiss/b2b/v1/credential-invitations
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:
- Developmenthttps://api.dev.deepcredentials.swiss/b2b/v1/credential-invitations/{invitationId}
- Integrationhttps://api.int.deepcredentials.swiss/b2b/v1/credential-invitations/{invitationId}
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'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:
- List with the exact
credential_definition_id, normalizedemail, and yourreference_id; do not restrict the first lookup topending, because the invitation may have reached a terminal state while the response was unavailable. - 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_idrather than creating another invitation. - Rotate only when the matching invitation is still
pendingand your system can establish that it belongs to this create attempt, then deliver only the replacement code returned by rotate. - If there is no match, retry create. If that returns
already_exists, list by pending status, definition, and email withoutreference_idand resolve the conflicting resource manually. - 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.
- Developmenthttps://api.dev.deepcredentials.swiss/b2b/v1/credential-invitations/{invitationId}/rotate-code
- Integrationhttps://api.int.deepcredentials.swiss/b2b/v1/credential-invitations/{invitationId}/rotate-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.
- Developmenthttps://api.dev.deepcredentials.swiss/b2b/v1/credential-invitations/{invitationId}/revoke
- Integrationhttps://api.int.deepcredentials.swiss/b2b/v1/credential-invitations/{invitationId}/revoke
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.
- Developmenthttps://api.dev.deepcredentials.swiss/b2b/v1/credential-offers
- Integrationhttps://api.int.deepcredentials.swiss/b2b/v1/credential-offers
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.
UUID v4 identifier.
Lifecycle state of a credential offer.
OID4VCI wallet deep link (e.g. openid-credential-offer://...).
Base64-encoded PNG; QR payload is the same URI as deeplink.
Claim values for the credential (echoed from the request).
Offer TTL deadline (RFC3339).
When the offer was created (RFC3339).
The response includes a QR code and deeplink for the wallet holder.
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.
You have two options to present the issuance offer to the wallet holder:
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.
Poll GET /b2b/v1/credential-offers/{offerId} until the status is no longer pending:
- Developmenthttps://api.dev.deepcredentials.swiss/b2b/v1/credential-offers/{offerId}
- Integrationhttps://api.int.deepcredentials.swiss/b2b/v1/credential-offers/{offerId}
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'Offer id (matches the value returned at creation time). UUID v4 identifier.
Swiyu environment: "beta" or "production".
Credential definition reference.
Current lifecycle state of the offer.
Claim values for the credential.
When a pending offer expires if not completed (RFC3339).
When the offer was created (RFC3339).
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.
Set when the offer leaves pending (issued, failed, or expired).
Credential lifetime to apply at issuance (SD-JWT exp = iat + N). Absent means the credential will be issued without an exp claim.
Only when status is pending: OID4VCI wallet deep link.
Machine-readable failure reason when status is failed or expired.
Human-readable error detail when status is failed or expired.
InvitationID / SelfServiceRequestID expose the upstream lineage anchors when this offer was created from one. nil for plain portal or B2B offers.
Only when status is issued: ID of the issued credential. UUID v4 identifier.
Only when status is pending: Base64-encoded PNG QR code (240×240 px).
Your reference_id from the create request, if any.
UUID v4 identifier.
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.
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
statusisissued,failed, orexpired. - Respect the
expires_attimestamp — stop polling after expiration.
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.
Use GET /b2b/v1/issued-credentials/{credentialId} to retrieve details about an issued credential, including its current status.
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:
- Issue the credential via
POST /b2b/v1/credential-offers. - Wait for
issuedstatus (poll or webhook). - Create a verification session via
POST /b2b/v1/verification-sessionsusing the same credential definition. - Ask the holder to present the credential by scanning the verification QR code.
- 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.
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.
- Webhooks — Receive issuance results in real time.
- Verification Flow — Verify credentials from wallet holders.
- Error Handling — Handle all possible error scenarios.