Skip to content
Last updated

Self-Service — Detailed Reference

Read this when you need the full state machine, every webhook payload, idempotency semantics, or error codes. For a first integration walk through Quickstart instead; for a concrete end-to-end example see Walkthrough — City Card.

For machine-to-machine issuance where your backend already has all the data, use Credential Offers instead — self-service is the share-link flow where the end-user (or an identification provider) supplies the data.

Credential validity is set on the config, not on `/decision`

Self-service flows take the exp value from the config in effect when the request is submitted, not from the approval call. The decision API has no validity_seconds field. Edits to the config affect only future requests — credentials already issued (or invitations already sent) keep the validity snapshot taken when they were created. See the Credential validity section in the Issuance Flow guide for the surrounding model.

Verification modes

Each config picks one of three modes — the mode decides who approves a submission.

ModeWho decidesWhen to use
manualPortal admin (your team, in the DeepCredentials UI)Low volume, human review in the loop.
webhookYour backend, via POST /b2b/v1/self-service/requests/{requestId}/decisionProgrammatic checks against your systems (e.g. HR database lookup).
automaticDeepCredentials, server-side, with no human or backend in the loopSelf-asserted data only, no admin-filled claims, no identification step.

This guide focuses on the webhook mode — it's the one that touches the B2B API. manual and automatic configs use the same lifecycle but skip the webhook round-trip with your backend.

Request lifecycle

User starts flow with identification

User submits self-declaration form

Identification callback (success)

Identification callback (failure)

Decision = approve (email opt-in)

Decision = approve (no email)

Decision = reject

End-user opens redemption link

Wallet accepts the credential

Offer TTL exceeded

Wallet rejected the credential

awaiting_identification

awaiting_verification

failed

invitation_sent

issued

rejected

credential_issued

expired

User starts flow with identification

User submits self-declaration form

Identification callback (success)

Identification callback (failure)

Decision = approve (email opt-in)

Decision = approve (no email)

Decision = reject

End-user opens redemption link

Wallet accepts the credential

Offer TTL exceeded

Wallet rejected the credential

awaiting_identification

awaiting_verification

failed

invitation_sent

issued

rejected

credential_issued

expired

Status reference

StatusDescription
awaiting_identificationEnd-user is mid-flow at the identification provider. Not yet ready for decision.
awaiting_verificationSubmission complete, awaiting your decision. This is when the submitted webhook fires.
invitation_sentYou (or the portal) approved. A redemption link has been emailed to the end-user.
issuedThe end-user opened the redemption link; a live credential offer was created and is waiting for their wallet to accept it.
credential_issuedTerminal success — the wallet has the credential.
rejectedTerminal — you (or the portal) rejected the submission. rejection_reason carries the reason shown to the end-user.
expiredTerminal — the credential offer expired before the wallet picked it up.
failedTerminal — issuance failed (identification error, wallet rejected the credential, etc).

End-to-end interaction (webhook mode)

This sequence covers the full path: end-user clicks the share-link, optionally verifies through identification, submits, your backend approves, end-user picks up the credential.

Your BackendDeepCredentialsEnd User(SPA + Wallet)Your BackendDeepCredentialsEnd User(SPA + Wallet)User verifies via DeepID(method choice happens there)opt[identification required]opt[self-declaration only]Look up subject, validate againstadmin_filled_required, decide3 s after verification_started_at, the SPA shows the"email me updates" opt-in panel. If the next step happensfirst (fast webhook decision), the panel never shows.opt[user opts into delivery email]User opens email link(or "Show QR now" on the status page)SPA's polling picks up the offer and shows the QRalt[email was opted in][no email opt-in]Credential now in walletOpen share-link `/self-service/<slug>`1Render landing (credential definition, purpose, "Start")2Click "Open identification"3Create request (status = awaiting_identification)4Hand off to DeepID5Return via callback6status → awaiting_verification,verification_started_at = now7Fill form + Submit8Create request (status = awaiting_verification,verification_started_at = now)9POST /your-webhookX-Webhook-Event: self_service.request.submitted{ "data": { "request_id": "<uuid>" } }10200 OK11GET /b2b/v1/self-service/requests/{id}12200 { status, claims, extra_fields, admin_filled_*, identification, ... }13Set delivery email14200 OK15POST /b2b/v1/self-service/requests/{id}/decision{ "decision": "approve", "admin_claims": { ... } }16200 OK(status: "invitation_sent" if email opt-in,else "issued" + offer_id)17Create credential invitation, status → invitation_sent18Send redemption email19Click redemption link / scan QR20status → issued, create live offer21Create live offer directly, status → issued22POST /your-webhookX-Webhook-Event: self_service.request.approved{ "data": { "request_id": "<uuid>" } }23200 OK24OID4VCI exchange (wallet)25status → credential_issued26POST /your-webhookX-Webhook-Event: self_service.request.credential_issued{ "data": { "request_id": "<uuid>" } }27200 OK28
Your BackendDeepCredentialsEnd User(SPA + Wallet)Your BackendDeepCredentialsEnd User(SPA + Wallet)User verifies via DeepID(method choice happens there)opt[identification required]opt[self-declaration only]Look up subject, validate againstadmin_filled_required, decide3 s after verification_started_at, the SPA shows the"email me updates" opt-in panel. If the next step happensfirst (fast webhook decision), the panel never shows.opt[user opts into delivery email]User opens email link(or "Show QR now" on the status page)SPA's polling picks up the offer and shows the QRalt[email was opted in][no email opt-in]Credential now in walletOpen share-link `/self-service/<slug>`1Render landing (credential definition, purpose, "Start")2Click "Open identification"3Create request (status = awaiting_identification)4Hand off to DeepID5Return via callback6status → awaiting_verification,verification_started_at = now7Fill form + Submit8Create request (status = awaiting_verification,verification_started_at = now)9POST /your-webhookX-Webhook-Event: self_service.request.submitted{ "data": { "request_id": "<uuid>" } }10200 OK11GET /b2b/v1/self-service/requests/{id}12200 { status, claims, extra_fields, admin_filled_*, identification, ... }13Set delivery email14200 OK15POST /b2b/v1/self-service/requests/{id}/decision{ "decision": "approve", "admin_claims": { ... } }16200 OK(status: "invitation_sent" if email opt-in,else "issued" + offer_id)17Create credential invitation, status → invitation_sent18Send redemption email19Click redemption link / scan QR20status → issued, create live offer21Create live offer directly, status → issued22POST /your-webhookX-Webhook-Event: self_service.request.approved{ "data": { "request_id": "<uuid>" } }23200 OK24OID4VCI exchange (wallet)25status → credential_issued26POST /your-webhookX-Webhook-Event: self_service.request.credential_issued{ "data": { "request_id": "<uuid>" } }27200 OK28

The rejected path swaps the decision payload for {"decision": "reject", "reason": "..."} and the subsequent webhook becomes self_service.request.rejected. No invitation is created. The rejection email is sent only if the user opted into delivery; without opt-in we still publish the webhook for the tenant's audit trail and skip the mail.

If the wallet never finishes the OID4VCI exchange (offer TTL exceeded, wallet rejected the credential) DeepCredentials fires self_service.request.offer_expired or self_service.request.failed instead of credential_issued.

Delivery email is opt-in

Email is never collected as a form field. ~3 seconds after the request enters awaiting_verification, the SPA's waiting screen reveals an inline opt-in panel:

  • Self-declaration only: empty input, user types if they want updates.
  • Identification: input is pre-filled (read-only) with the address the identification provider returned — but the user still has to click the button to authorise delivery. We never email an address the user didn't actively confirm.

The 3-second buffer lets fast tenant decisions resolve before we ask: if your /decision call lands while the user is still on the waiting page, the panel never appears and your approve webhook is delivered immediately.

If the user closes the page without opting in (or your decision arrives before the 3-second delay), request.email stays NULL. On approve we then create the live credential offer directly and the SPA picks it up via status polling — no redemption email, but the credential still ships. On reject we publish the webhook and skip the rejection email.

Webhook payload contract

All six self-service webhooks (submitted, approved, rejected, credential_issued, offer_expired, failed) share the slim envelope:

{
  "event": "self_service.request.submitted",
  "tenant_id": "550e8400-e29b-41d4-a716-446655440000",
  "timestamp": "2026-05-16T10:00:00Z",
  "data": {
    "request_id": "8a3b1f2e-9c4d-4e5a-b1c2-d3e4f5a6b7c8"
  }
}

The data field carries only request_id — no captured claims, email, or identification data are sent over the webhook channel. After receiving any of these events, call GET /b2b/v1/self-service/requests/{requestId} with your API credential to fetch the full snapshot.

Reconcile missed callbacks

Webhook delivery currently makes one attempt and can be missed. The B2B self-service API has no list or discovery endpoint: GET works only after your system already knows request_id. If self_service.request.submitted is missed, a portal administrator must find the request in the Requests view and approve or reject it there. Monitor the receiving endpoint and use the portal as the manual recovery path; do not assume the partner backend can discover a missed request through B2B.

Admin-claims schema on the GET snapshot

The GET /b2b/v1/self-service/requests/{requestId} response carries three fields that describe the claims the tenant is expected to fill — mirrors how verification ships requested_fields + credential_subject_types, so you can render a form or validate input dynamically instead of hard-coding what each config wants:

FieldShapeMeaning
admin_filled_fieldsordered string[]Every claim key the config marked admin-filled, in the credential definition's declared claim order.
admin_filled_types{ [key]: "string" | "boolean" | "integer" | "number" | "date" | ... }Value type per key — the same value_type strings the credential definition uses.
admin_filled_requiredstring[]Subset of admin_filled_fields where the credential definition marked the claim as mandatory. Anything in this list must be present in your admin_claims payload on approve.

Example fragment:

{
  "admin_filled_fields": ["employee_id", "department", "office_number"],
  "admin_filled_types": {
    "employee_id": "string",
    "department": "string",
    "office_number": "integer"
  },
  "admin_filled_required": ["employee_id", "department"]
}

If the config has no admin-filled claims, all three are empty ([], {}, []) and you can approve without an admin_claims body.

Overriding user-supplied claims

admin_filled_* describes only the contract: which keys the admin is expected to supply (and which are mandatory). It is not a whitelist — the decision endpoint accepts any credential definition claim in your admin_claims payload, including keys the end-user already filled in (and keys pre-populated from an identification flow). When a key appears in more than one source, the layering is fixed: user → identification → admin, with later layers winning.

To make this discoverable without a separate credential definition fetch, the snapshot also carries the full credential definition claim set:

FieldShapeMeaning
editable_fieldsordered string[]Every claim key on the credential definition, in declared order.
editable_field_types{ [key]: value_type }Same value_type strings as admin_filled_types, covering every key.
editable_field_sources{ [key]: "user_filled" | "admin_filled" | "identification_filled" }Where each key is sourced by default — mirrors the config's field_assignments. identification_filled means the value was projected from the identification verified attributes via a tenant-managed mapping (configured in the portal).

Typical usage: render a "review & override" form with one input per editable_fields entry. Pre-fill from the snapshot's claims field — it already carries the merged result of user submission + identification mapping, so the same input shows whatever value would be stamped on the credential today. Submit only the keys the admin actually changed plus every admin_filled_required key.

Identification pre-fill

When a config has identification_filled claims, those values are sourced from DeepID's verified attributes at completion time. They land on the snapshot's claims field by the time you see the submitted webhook, just like user-supplied values — so a passive approve already issues the right credential. Mappings are tenant-managed via the portal; the B2B API never sets them.

Override is opt-in

You don't have to override anything. If your backend only fills admin_filled_required keys, the user-supplied values flow through untouched. The override channel exists for cases where the admin spots a typo, normalises capitalisation, or corrects a self-asserted value against an authoritative source.

Calling the decision endpoint

The self_service.request.submitted webhook carries only request_id — no captured data. To decide, your backend goes through two calls:

  1. Fetch the snapshot via GET /b2b/v1/self-service/requests/{requestId}. The response returns the captured user claims, extra fields, identification result (if any), and the admin_filled_* schema you need to assemble admin_claims.
  2. POST your decision to /b2b/v1/self-service/requests/{requestId}/decision.

Both calls use a Bearer token obtained for a DeepAdmin service user through DeepCloud, with the deepcredentials.self-service scope. There is no B2B API-key authentication path. You have 7 days (the invitation TTL) after the submitted event before the request expires.

Step 1: Fetch the request snapshot

Open in API reference →

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

The response shape — including the admin_filled_* schema described above — is documented under the Self-Service API reference.

Step 2a: Approve

Open in API reference →

curl -i -X POST \
  'https://api.dev.deepcredentials.swiss/b2b/v1/self-service/requests/{requestId}/decision' \
  -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 '{
    "decision": "approve",
    "admin_claims": {
      "is_employee": true
    }
  }'

admin_claims is required when the config has any claim keys marked admin-filled. Use admin_filled_required from the GET snapshot to know which keys you must supply. DeepCredentials merges your admin_claims with the user-filled claims captured in the submission, validates the whole set against the credential definition schema, then creates the credential invitation and emails the end-user.

admin_claims may also include keys the end-user already filled in — your value overrides theirs (see Overriding user-supplied claims above). Keys absent from admin_claims flow through from the user submission unchanged.

Step 2b: Reject

Open in API reference →

curl -i -X POST \
  'https://api.dev.deepcredentials.swiss/b2b/v1/self-service/requests/{requestId}/decision' \
  -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 '{
    "decision": "approve",
    "admin_claims": {
      "is_employee": true
    }
  }'

reason is shown to the end-user verbatim in the rejection email and on the status page.

Idempotency & races

The decision endpoint is single-shot — a second call with the same requestId returns 400 failed_precondition with the current state. If your backend retries on transport failure, treat a failed_precondition response as confirmation the request was already decided.

Error handling

HTTPcodeMeaning
400invalid_argumentDecision is not approve / reject, reason is missing on a reject, or admin_claims failed schema validation.
400failed_preconditionThe request is in a state that doesn't accept a decision (already invitation_sent, issued, rejected, failed, or still awaiting_identification). Not idempotent — a second decision on an already-decided request returns this.
401 / 403unauthenticated / permission_deniedAuth missing, or the token lacks the deepcredentials.self-service scope.
404not_foundThe request doesn't exist for this tenant. Common cause: a stale or incorrectly scoped requestId.

See the Error Handling guide for the full taxonomy.

See also