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.
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.
Each config picks one of three modes — the mode decides who approves a submission.
| Mode | Who decides | When to use |
|---|---|---|
manual | Portal admin (your team, in the DeepCredentials UI) | Low volume, human review in the loop. |
webhook | Your backend, via POST /b2b/v1/self-service/requests/{requestId}/decision | Programmatic checks against your systems (e.g. HR database lookup). |
automatic | DeepCredentials, server-side, with no human or backend in the loop | Self-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.
| Status | Description |
|---|---|
awaiting_identification | End-user is mid-flow at the identification provider. Not yet ready for decision. |
awaiting_verification | Submission complete, awaiting your decision. This is when the submitted webhook fires. |
invitation_sent | You (or the portal) approved. A redemption link has been emailed to the end-user. |
issued | The end-user opened the redemption link; a live credential offer was created and is waiting for their wallet to accept it. |
credential_issued | Terminal success — the wallet has the credential. |
rejected | Terminal — you (or the portal) rejected the submission. rejection_reason carries the reason shown to the end-user. |
expired | Terminal — the credential offer expired before the wallet picked it up. |
failed | Terminal — issuance failed (identification error, wallet rejected the credential, etc). |
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.
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.
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.
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.
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.
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:
| Field | Shape | Meaning |
|---|---|---|
admin_filled_fields | ordered 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_required | string[] | 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.
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:
| Field | Shape | Meaning |
|---|---|---|
editable_fields | ordered 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.
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.
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.
The self_service.request.submitted webhook carries only request_id — no captured data. To decide, your backend goes through two calls:
- 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 theadmin_filled_*schema you need to assembleadmin_claims. - 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.
- Developmenthttps://api.dev.deepcredentials.swiss/b2b/v1/self-service/requests/{requestId}
- Integrationhttps://api.int.deepcredentials.swiss/b2b/v1/self-service/requests/{requestId}
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.
- Developmenthttps://api.dev.deepcredentials.swiss/b2b/v1/self-service/requests/{requestId}/decision
- Integrationhttps://api.int.deepcredentials.swiss/b2b/v1/self-service/requests/{requestId}/decision
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.
- Developmenthttps://api.dev.deepcredentials.swiss/b2b/v1/self-service/requests/{requestId}/decision
- Integrationhttps://api.int.deepcredentials.swiss/b2b/v1/self-service/requests/{requestId}/decision
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.
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.
| HTTP | code | Meaning |
|---|---|---|
400 | invalid_argument | Decision is not approve / reject, reason is missing on a reject, or admin_claims failed schema validation. |
400 | failed_precondition | The 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 / 403 | unauthenticated / permission_denied | Auth missing, or the token lacks the deepcredentials.self-service scope. |
404 | not_found | The request doesn't exist for this tenant. Common cause: a stale or incorrectly scoped requestId. |
See the Error Handling guide for the full taxonomy.
- Quickstart — the four-step integration path.
- Walkthrough — City Card — a concrete end-to-end example.
- Self-Service API reference —
GET+POST /decisionschemas. - Webhook Events — full payload schemas for
self_service.request.*. - Webhooks guide — event delivery, signature verification, secret rotation.
- Credential Issuance Flow — machine-to-machine alternative when your backend has all the data up front.