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 approved, not from the approval call. The decision API has no validity_seconds field. Edits to the config apply to requests approved after the edit, including ones already waiting for a decision — 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 | No admin-filled claims. Without identification, every mandatory claim is self-asserted by the end-user; with identification, every mandatory claim must come from the identification mapping. |
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.
Any state before issued also moves to expired when the request's 7-day window lapses, counted from when the request was created (the self-declaration submit, or the start of identification).
| Status | Description |
|---|---|
awaiting_identification | End-user is mid-flow at the identification provider. Not yet ready for decision. |
awaiting_details | Identification succeeded; the end-user still has to fill in the claims or extra fields identification doesn't cover. Not yet ready for decision. |
awaiting_email | Email-only delivery: the end-user still has to enter their delivery email. 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 request with email delivery. A redemption link has been emailed to the end-user. |
issued | A live credential offer was created — directly on approval (QR delivery), or when the end-user opened the redemption link — 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, or the request's 7-day window lapsed before an offer was created. |
failed | Terminal — identification failed, automatic issuance failed, or the wallet rejected the credential. failure_code carries a machine-readable reason when one is known. |
revoked | Terminal — the organization withdrew the invitation or credential offer before the wallet accepted it: an operator revoked it, or the environment was disabled on the credential definition, which revokes pending offers and invitations. |
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. offer_expired is also sent when a request's 7-day window lapses before an offer was created. A failed identification sends no webhook. If the organization revokes the invitation or offer before the wallet accepts it, the request moves to revoked and DeepCredentials fires self_service.request.revoked.
Email is never collected as a form field. How the address is collected depends on the config's Credential delivery setting:
- QR only — no email is asked for. On approve the live credential offer is created directly.
- Email only — the SPA asks for the address right after submission. The request waits in
awaiting_email, and thesubmittedwebhook fires only once the address is set. On approve the end-user always gets a redemption email. - Let the user choose — ~10 seconds after the request enters
awaiting_verification, the SPA's waiting screen reveals an inline opt-in panel, described below.
In the 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 ~10-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 the credential is delivered by QR.
If the user closes the page without opting in (or your decision arrives before the panel appears), email stays unset on the snapshot. 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 seven self-service webhooks (submitted, approved, rejected, credential_issued, offer_expired, failed, revoked) 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 service-user access token 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 a verification session ships requested_attributes with a value_type each, 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" | "integer" | "number" | "boolean" | "date" | "enum" | "image/jpeg" } | Canonical value type per key — the same value_type the credential definition uses. Dates are ISO 8601 YYYY-MM-DD; enum values are one of the definition claim's option keys; the claim's rules (pattern, min/max, …) apply to what you send. |
admin_filled_required | string[] | Subset of admin_filled_fields where the credential definition marked the claim as mandatory. |
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"]
}On approve, your admin_claims must contain every key in admin_filled_fields — optional ones included — each with a valid value; a missing key is refused with 400 invalid_argument (admin_claims is missing required key "…").
If the config has no admin-filled claims, all three are empty ([], {}, []) and you can approve without admin_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:
| 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_fields key.
When a config has identification_filled claims, those values are sourced from the identification provider'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 the admin_filled_fields 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; request the deepcredentials.self-service scope for it (see Service Users → Scopes). There is no B2B API-key authentication path. A request expires 7 days after it was created (the self-declaration submit, or the start of identification); decide within that window, as an expired request can no longer be decided.
- 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_fields from the GET snapshot to know which keys you must supply. DeepCredentials merges your admin_claims with the user-filled and identification-mapped claims, validates the whole set against the credential definition schema, then — depending on delivery — creates a credential invitation and emails the end-user the redemption link (response status: "invitation_sent"), or creates the live credential offer directly (status: "issued" with offer_id).
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 and the identification mapping 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 required on reject (up to 1000 characters) and is shown to the end-user verbatim on the status page, and in the rejection email if they opted into email delivery.
The decision endpoint is single-shot — a second call with the same requestId returns 400 failed_precondition, with the current status in the message. If your backend retries on transport failure, treat a failed_precondition response as a cue to re-fetch the snapshot: its status tells you whether (and how) the request was decided.
| HTTP | code | Meaning |
|---|---|---|
400 | invalid_argument | Decision is not approve / reject, reason is missing or longer than 1000 characters on a reject, admin_claims is not an object or misses a key from admin_filled_fields, or the merged claims fail the credential definition's schema (details.claim / details.rule name the claim and the rule). |
400 | failed_precondition | The request is not in awaiting_verification: already decided (invitation_sent, issued, credential_issued, rejected), terminal (expired, failed, revoked), or not yet submitted (awaiting_identification, awaiting_details, awaiting_email). Not idempotent — a second decision on an already-decided request returns this. |
401 / 403 | unauthenticated / permission_denied | Token missing or invalid (401); the account is not a DeepAdmin service user, or the token lacks the deepcredentials.self-service scope (403). |
404 | not_found | The request doesn't exist for the organization in X-Org-Id. 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.