# Transaction confirmation with the wallet

An application can have a signed-in user confirm one transaction — a consent text, a payment — with their EU Digital Identity Wallet. The wallet shows the transaction and the user's wallet key signs a hash of exactly what was shown (OpenID4VP 1.0 `transaction_data`). Helix checks the hash and tells the application in the ID token. This page also covers two request features that came with it: `verifier_info` and multi-signed Digital Credentials API requests. (Preview, not yet tested with a real national wallet.)

!!! warning "Status: Preview — not yet tested with a real national wallet"
    New in 1.1.0-rc.1. Covered by an end-to-end test with the project's own test wallet and by unit tests; not run against a real wallet. **Only wallets that implement the Helix transaction types below can confirm**: a wallet must refuse a type it does not know, so any other wallet declines.

## Setting it up

1. A **verify-only** wallet connection ([verify-only](eudi-wallet.md#verify-only-step-up), `subject.strategy=NONE`) that requests `dc+sd-jwt`, with `transactionData.types`: the types it may confirm. Console: the connection's **Policies → Transaction confirmation**, with a preview of what the wallet shows. Terraform: `transaction_data_types`.
2. The application's login [flow](../authentication/flows.md) contains the **EU Digital Identity Wallet verification** step (`eudi-wallet-verify`) with that connection, as a **top-level REQUIRED** step.

```json
"config": {
  "subject.strategy": "NONE",
  "verify.linkedProvider": "eudi-wallet",
  "credentialFormats": "[\"dc+sd-jwt\"]",
  "transactionData.types": "[\"urn:helixiam:transaction-data:consent-text:v1\",\"urn:helixiam:transaction-data:payment:v1\"]"
}
```

## The `authorization_details` types

OpenID4VP leaves type identifiers to ecosystems. The EUDI payment ("SCA") transaction types were not final, so Helix defines its own, clearly named types:

| Type | Fields (all strings) |
| --- | --- |
| `urn:helixiam:transaction-data:consent-text:v1` | `consent_text` (required, at most 2000 characters, newlines allowed), `locale` (optional), `reference` (optional, at most 128) |
| `urn:helixiam:transaction-data:payment:v1` | `payee` (required, at most 140), `amount` (required, a decimal such as `125.00`, up to 4 decimals), `currency` (required, ISO 4217 code such as `EUR`), `reference` (optional), `description` (optional, at most 500) |

No other members, and no control or invisible formatting characters: what the wallet shows must be what the user reads.

## How an application asks

An ordinary authorization request with RFC 9396 `authorization_details` (shown unencoded; URL-encode the JSON):

```
GET /realms/acme/oauth2/authorize?response_type=code&client_id=bank&redirect_uri=…&scope=openid&state=…&nonce=…
    &code_challenge=…&code_challenge_method=S256
    &authorization_details=[{"type":"urn:helixiam:transaction-data:consent-text:v1",
                             "consent_text":"I agree to loan agreement L-1234.","reference":"L-1234"}]
```

Refused up front, before any sign-in, with `400 invalid_authorization_details`:

- invalid details: 1–5 entries, Helix types only, no mix with `openid_credential`;
- a client whose flow has no **top-level REQUIRED** wallet verification step whose connection allows every requested type. An ALTERNATIVE step, one in a sub-flow or a DISABLED one may never run, so it does not count;
- `prompt=none`;
- the PAR endpoint (the step reads front-channel parameters only).

The check runs again when the request resumes after sign-in. Such a request always runs the client's flow, once per request, even with an SSO session: a session never confirms a transaction by itself.

**Safety net.** Should a code still be issued for such a request without a completed confirmation bound to it, it is stored invalidated and the token endpoint refuses it (`invalid_grant`).

## The wallet step requirement

1. The wallet step sends, per detail, one OpenID4VP `transaction_data` string: base64url of `{"type", "credential_ids": ["pid_sd_jwt"], "transaction_data_hashes_alg": ["sha-256"], <fields>}`. The strings are stored on the wallet transaction, encrypted. The DCQL query then asks for the SD-JWT VC PID only.
2. The wallet's key-binding JWT must carry `transaction_data_hashes_alg: "sha-256"` and `transaction_data_hashes` equal to the SHA-256 of each string, in order. Missing, different, another algorithm, extra hashes, or hashes in a credential no `credential_ids` names: refused (`TRANSACTION_DATA_MISMATCH`). A presentation made for other transaction data fails the hash check as well as the nonce.
3. The step also checks the usual verify-only binding (the wallet must be linked to this user) and that the confirmed strings are exactly the ones made from this request's `authorization_details`.

**mdoc is not supported for confirmations.** OpenID4VP leaves the mdoc binding to each transaction type and document type; the Helix types define none. So confirmations use the SD-JWT VC PID only, and a connection without `dc+sd-jwt` cannot allow types (`400` at save).

## The token response

For the code of that very request (bound to its client and a hash of `client_id`, `state`, `nonce`, `redirect_uri` and the `authorization_details` value; 5 minutes), the **ID token of the code exchange** carries:

```json
"transaction_confirmation": {
  "authorization_details": [{"type": "urn:helixiam:transaction-data:consent-text:v1", "consent_text": "…", "reference": "L-1234"}],
  "transaction_data": ["eyJ0eXBlIjoi…"],
  "transaction_data_hashes": ["gpwEspYbnuwN…"],
  "transaction_data_hashes_alg": "sha-256",
  "credential_format": "dc+sd-jwt",
  "time": "2026-10-19T10:00:00Z"
}
```

The token response of the code exchange also repeats the confirmed `authorization_details` (RFC 9396 §7).

!!! danger "No claim means not confirmed"
    The application **must** check `transaction_confirmation` in the ID token, as it checks `acr`. The confirmation is reported once: after the code exchange it is removed from the stored authorization, and a refresh carries neither the claim nor `authorization_details`, so a refresh is never a second confirmation.

**Audit.** `WALLET_TRANSACTION_CONFIRMED` (actor: the user) with `transactionId`, `alias`, `client`, `types`, `hashes` and `alg` — never a value (no consent text, amount or payee). Refusals are `WALLET_VERIFY_FAILURE` with `reason=TRANSACTION_DATA_MISMATCH`.

## `verifier_info`

OpenID4VP §5.1 `verifier_info` carries attestations about the verifier to the wallet. Per wallet connection, `verifierInfo.source` (console: **Policies → Verifier information**; Terraform `verifier_info_source`, `verifier_info_format`, `verifier_info_json`):

| `verifierInfo.source` | What is sent |
| --- | --- |
| `none` (default) | nothing |
| `registration_certificate` | one entry `{"format": verifierInfo.format, "data": <base64 DER of the first certificate of the connection's registration certificate>}`. The format identifier is yours to set, because the ARF identifier for registration certificates was not final |
| `custom` | `verifierInfo.json`: an array of 1–5 `{format, data, credential_ids?}` objects (`format` without spaces, `data` a non-empty string or object, `credential_ids` naming this connection's credential queries), at most 16 KiB. Otherwise opaque: the wallet validates attestations, Helix only their shape |

It is sent in every request of the connection: the request object of the QR and same-device flows, the signed and unsigned Digital Credentials API requests, and each signature's protected header of a multi-signed one. Per request, an entry's `credential_ids` keep only the credential queries that request asks for (a transaction confirmation asks for the SD-JWT PID only, so the mdoc PID query drops out), and an entry left with none is not sent.

## Multi-signed Digital Credentials API requests

For the [W3C Digital Credentials API](eudi-wallet.md#the-w3c-digital-credentials-api), a connection can send a request signed by two keys (`dcApi.request=multisigned`, protocol `openid4vp-v1-multisigned`, OpenID4VP A.3.2.2; console: **Policies → Digital Credentials API → Multi-signed**; Terraform `dc_api_request = "multisigned"` and `dc_api_additional_key_ref`):

- Set `dcApi.additionalKeyRef` to a second key imported into the realm, with its own certificate chain (not the access key); it is checked at save like the access key.
- The request is `{"request": {"payload", "signatures"}}`, the JWS JSON Serialization: every parameter except `client_id` and `verifier_info` in the payload (including `expected_origins`), and per signature a protected header with `alg`, `typ=oauth-authz-req+jwt`, its own `x5c` and its own `client_id` (the access key's as configured; the second key's `x509_hash:` of its certificate), plus the `verifier_info`.
- A wallet can verify either signature. The response is bound to the origin as for the other request types.

## Not in this release

- Confirmations for mdoc, for sign-in transactions, through PAR, or through the WYSIWYS transaction-signing API (which signs with an enrolled device key, not the wallet).
- The EUDI payment (SCA) transaction types.
- The `authorization_details_types_supported` discovery entry.
- **ISO 18013-7 Annex C** (`org-iso-mdoc`) over the Digital Credentials API: it needs an HPKE-encrypted request and response and the Annex C session transcript, which must match byte for byte, and the ISO text and test vectors were not available to check an implementation against. It is deferred rather than shipped half-built. mdoc over the Digital Credentials API keeps working through OpenID4VP.

## See also

- [EU Digital Identity Wallet (OpenID4VP)](eudi-wallet.md) — verify-only connections and the Digital Credentials API
- [Authentication flows](../authentication/flows.md) — where the `eudi-wallet-verify` step goes
- [Terraform provider](../integration/terraform.md)
