Skip to content
Last updated

Walkthrough — City Card

A concrete end-to-end example of the self-service flow. Built around a credential definition created from the City Card template, this is what you'd show in an integration meeting: one form, one webhook, one approve call.

If you're a developer and want the smallest possible setup, see Quickstart first. If you need the full state machine, every event payload and edge case, see Detailed Reference.

Scenario

The City of Springfield wants to issue a digital City Card to verified residents. The card carries the resident's name and date of birth, signed by the city, and lives in their wallet — usable for library lookups, public transport, age checks, and so on.

The constraint: residents shouldn't have to type anything. They should be able to prove they're who they say they are — picking whichever of the city's accepted identification methods suits them at the time — and the card should drop into their wallet seconds later.

The interaction

Only the cross-system parts. Everything inside DeepCredentials and the identification provider (identification UI, OID4VP exchange with the wallet, state machine) is intentionally hidden — that's our problem to solve.

Your BackendDeepCredentialsResidentYour BackendDeepCredentialsResidentClaims are pre-filledfrom the verified attributesOpen share-link, complete identification1POST /your-webhookself_service.request.submitted2GET /b2b/v1/self-service/requests/{id}3200 — snapshot with pre-filled claims4POST /decision { decision: "approve" }5City Card lands in wallet6
Your BackendDeepCredentialsResidentYour BackendDeepCredentialsResidentClaims are pre-filledfrom the verified attributesOpen share-link, complete identification1POST /your-webhookself_service.request.submitted2GET /b2b/v1/self-service/requests/{id}3200 — snapshot with pre-filled claims4POST /decision { decision: "approve" }5City Card lands in wallet6

Three roundtrips total. Anything more complex — identification failure, rejection, expiry, retries — is a variation of the same shape and is covered in the Detailed Reference.

What the resident sees

  1. Opens the share-link Springfield published on its website.
  2. Picks one of the identification methods the city accepts (e-ID, QES, passport-based, …) and completes it with the identification provider.
  3. Returns to the share-link page; a moment later a wallet deeplink appears (or arrives by email). Card is in the wallet.

No form to fill in. No data to type.

What Springfield does — once, in the portal

No code. Three UI tasks:

  1. Create the credential definition. Credential Definitions → Create credential definition → From a template → City Card → walk the wizard to Create, enabling the environment the card will be issued in. The template prefills the three claims and the card layout; the definition it produces is the city's own, under the city's own vct, and that is the one you issue from. (A template cannot be issued from directly. If you wanted a custom shape you'd start blank, or edit the claims the template filled in.)
  2. Register a webhook in the DeepCredentials portal under B2B API → Webhooks, for the same environment, and subscribe to at least self_service.request.submitted and self_service.request.credential_issued. This comes before the config: a webhook-mode config can't be saved until the environment has a webhook endpoint. For the runtime B2B calls, use a Bearer token obtained for a DeepAdmin service user through DeepCloud with the deepcredentials.self-service scope; DeepCredentials does not mint an API key.
  3. Create the self-service config. Self-Service → New config:
    • Credential Definition — the City Card definition you just created.
    • Verification mode — webhook.
    • Identification — required. Pick the methods you'll accept (e.g. QES, optionally with Swiss e-ID passport); the resident picks one on the share-link page, and every method returns the same canonical set of verified attributes.
    • Claim mapping — for each City Card claim, pick the identification attribute it draws from:
      • given_name ← given_name
      • family_name ← family_name
      • birth_date ← birth_date
    • Credential validity — 2 years.

That's the entire setup. The share-link is now live at whatever slug you chose.

What Springfield's backend does — per request, in code

Three things, in this order: verify the webhook signature, fetch the snapshot, post the decision. With pre-filled identification claims and no admin-fill fields on this credential definition, the backend doesn't need to provide any data of its own — a passive approve is enough.

@app.post("/webhooks/deepcredentials")
def handle_webhook(req):
    # 1. Verify the HMAC signature (see Webhooks guide for the helper).
    verify_signature(req)

    if req.json["event"] != "self_service.request.submitted":
        return 200  # ignore other events for now

    request_id = req.json["data"]["request_id"]

    # 2. Fetch the snapshot — claims are already pre-filled from identification.
    snapshot = dc.get(f"/b2b/v1/self-service/requests/{request_id}").json()

    # (Optional) cross-check the resident against your roll.
    if not springfield_roll.is_resident(snapshot["claims"]):
        return dc.post(
            f"/b2b/v1/self-service/requests/{request_id}/decision",
            json={"decision": "reject", "reason": "Not on the resident roll."},
        )

    # 3. Approve. No admin_claims needed — every claim is identification-pre-filled.
    return dc.post(
        f"/b2b/v1/self-service/requests/{request_id}/decision",
        json={"decision": "approve"},
    )

That's the whole production-grade integration. ~20 lines.

If you want to override a claim

Maybe Springfield's CRM has the resident's preferred name on file and wants to use it instead of the legal given_name the identification returned. Add the override to the approve payload:

dc.post(
    f"/b2b/v1/self-service/requests/{request_id}/decision",
    json={
        "decision": "approve",
        "admin_claims": {"given_name": "Lisa"},
    },
)

Override precedence is fixed: user → identification → admin. Admin wins on any collision, every other claim flows through as it was pre-filled. See Detailed Reference → Overriding user-supplied claims for the full rules.

Smaller alternative — manual mode

If Springfield's volume is low (a handful of cards a week), they can skip the backend integration entirely:

  1. In the config, set Verification mode = manual (instead of webhook).
  2. No webhook endpoint needed.
  3. Springfield's ops team gets a Requests table in the portal with Approve and Reject buttons. The same identification pre-fill works the same way.

When volume grows, register a webhook endpoint and flip the config to webhook — the rest of the setup (credential definition, identification mapping, validity) stays as is.

Recap

From the resident's tap on the share-link to the card landing in their wallet is under 30 seconds with zero data entry. Springfield wrote about 20 lines of code, and the heavy lifting — identity verification, credential issuance, wallet handover — is on us.

See also

  • Quickstart — the same flow without the use-case framing.
  • Detailed Reference — every state, every event, error codes, idempotency, signature rotation.
  • Webhooks — signature verification, event subscriptions, one-attempt delivery, and reconciliation.
  • Credential Issuance Flow — machine-to-machine alternative when your backend already has the data.