iGrant.io holder backend (wallet side: receive, hold, present)
When to use
Build or extend the server that a holder (wallet) frontend talks to - the
organisation is the wallet that receives and presents credentials, e.g. a
European Business Wallet. It composes igrantio-backend-proxy (holder
allow-list) + the igrantio-holder-notifications SSE relay. For the other two
roles use igrantio-issuer-backend / igrantio-verifier-backend. Read
igrantio-ows-overview first; the exact holder endpoint contract lives in
references/holder-api-reference.md.
Before you build: run the integrator intake in igrantio-ows-overview - environment, API key, tenancy, backend host, webhooks, frontend - one question at a time, a recommended default with each. (Webhooks: not needed for the holder role - notifications replace them.)
What it does
- Proxy
GET|POST|PUT|DELETE ${PROXY_PREFIX}/{tenant}/... → OWS, injecting
the tenant's ApiKey. Allow-lists only OWS holder endpoints; RegExp
rules keep the issuer's credential/issue|history and the verifier's
verification/send|history out (least privilege).
- Relay
GET ${PROXY_PREFIX}/{tenant}/v2/config/digital-wallet/openid/notifications/sse
streams OWS holder notifications, injecting the auth as the authorization
query parameter (EventSource cannot send headers).
- No webhooks: holder events arrive on the notifications stream.
Reference implementation
Runnable Express + TypeScript app in ./references:
references/
holder-api-reference.md the full holder endpoint/payload/response contract
src/config.ts env config
src/tenants.ts TenantStore - per-tenant API-key resolution (env or pluggable)
src/proxy.ts API-key-injecting reverse proxy (allow-list param)
src/notificationsSse.ts notifications SSE relay (canonical: igrantio-holder-notifications)
src/server.ts composition (holder allow-list + relay)
.env.example Dockerfile package.json tsconfig.json
Steps
cd references && cp .env.example .env, then set OWS_ENV (demo|staging,
default demo), CORS_ORIGINS, and one OWS_TENANT_<SLUG>_API_KEY per
organisation.
npm install && npm run dev - backend on :6003 (alongside issuer :6001
/ verifier :6002).
- Point the holder frontend (
igrantio-holder-frontend) base URL at
${PROXY_PREFIX}/<tenant>; open the notifications stream on
…/<tenant>/v2/config/digital-wallet/openid/notifications/sse.
Holder contract (what the frontend drives through this backend)
Receive a credential (OpenID4VCI)
POST …/sdjwt/credential/receive with
{ credentialOffer, autoPresent, kid, trustAnchor } → credential
(object or array; each record's id keys the follow-ups).
- Branch on the record (§1.1 of the API reference):
transaction code →
PUT …/credential/{id}/user-pin;
front-channel → open authorizationRequest, then
POST …/credential/exchange-code with { code, state };
deferred → PUT …/credential/{id}/receive-deferred until
credential_acked.
- Review:
PUT …/credential/{id}/accept, or DELETE …/credential/{id} to
reject. List with GET …/sdjwt/credentials (response keys credential +
pagination).
- Lifecycle:
PUT …/credential/{id}/request requests reissuance of an
expired or near-expiry credential; GET …/credential/{id}/revocation-status
checks the IETF Token Status List.
Present credentials (OpenID4VP + DCQL)
POST …/sdjwt/verification/receive (v3) with { vpTokenQrCode, … } →
presentation.presentationId, presentation.dcqlQuery,
presentation.transactionDataDecoded (show before consent).
POST …/sdjwt/verification/{id}/filter (v2, empty body) →
inputDescriptors[].matchedCredentials.
POST …/sdjwt/verification/{presentationId}/send (v3) with
{ credentials: [{ id, credentialId | credentialIds, claimIds? }] }
(inputDescriptors for legacy PEX) → open
presentation.responseRedirectUri when non-empty. Transaction-data
signing happens through this call - there is no separate sign endpoint.
Notifications drive both flows - see igrantio-holder-notifications for the
notification→action decision table. Exact payloads, response fields, and
status vocabularies: references/holder-api-reference.md.
Adapting
- Recommended pairing: a Next.js + TypeScript portal
(
igrantio-holder-frontend) with Better Auth passwordless login. Run
this backend as its own service beside the Next.js app, set
CORS_ORIGINS to the portal origin, and for multi-user portals verify
the Better Auth session in an Express middleware before the proxy
forwards to OWS.
- Key storage: replace
EnvTenantStore with a DB/secret-manager TenantStore.
- Path scope:
HOLDER_PERMITTED_PATHS in server.ts is the allow-list
(strings match as prefixes, RegExps against the whole path).
- SSE auth: if your OWS deployment authenticates SSE with user tokens,
return
Bearer <jwt> from the relay's getAuthorization.
Validation / done criteria
npm run typecheck passes.
…/sdjwt/credential/receive proxies through; the issuer's
…/sdjwt/credential/issue and the verifier's …/sdjwt/verification/send
yield 404 (least privilege).
DELETE …/sdjwt/credential/{id} reaches OWS (the proxy forwards DELETE).
- The notifications SSE relay streams
text/event-stream for a known tenant
and 404s for an unknown one; the API key never appears in the browser.
Documentation & workflows
When anything is unclear, consult the iGrant.io documentation before guessing:
1---2name: igrantio-holder-backend3description: Build the backend for an EUDI Wallet / European Business Wallet HOLDER (the wallet side) against the iGrant.io Organisation Wallet Suite (OWS). A tenant-aware Node/TypeScript (Express) service that hides the organisation's API key behind a proxy scoped to holder endpoints - receive credential offers (OpenID4VCI wallet side, incl. transaction code, front-channel authorization code, and deferred issuance), manage held credentials, receive and answer verifier presentation requests (OpenID4VP + DCQL wallet side), holder configuration, wallet-unit status - and relays the holder notifications SSE stream. Use when an application must act as the wallet, receiving, holding, and presenting verifiable credentials.4license: Apache-2.05---67# iGrant.io holder backend (wallet side: receive, hold, present)89## When to use10Build or extend the server that a **holder** (wallet) frontend talks to - the11organisation is the wallet that receives and presents credentials, e.g. a12European Business Wallet. It composes `igrantio-backend-proxy` (holder13allow-list) + the `igrantio-holder-notifications` SSE relay. For the other two14roles use `igrantio-issuer-backend` / `igrantio-verifier-backend`. Read15`igrantio-ows-overview` first; the exact holder endpoint contract lives in16[`references/holder-api-reference.md`](./references/holder-api-reference.md).1718**Before you build**: run the integrator intake in `igrantio-ows-overview` - environment, API key, tenancy, backend host, webhooks, frontend - one question at a time, a recommended default with each. (Webhooks: not needed for the holder role - notifications replace them.)1920## What it does21- **Proxy** `GET|POST|PUT|DELETE ${PROXY_PREFIX}/{tenant}/...` → OWS, injecting22 the tenant's `ApiKey`. Allow-lists only OWS **holder** endpoints; RegExp23 rules keep the issuer's `credential/issue|history` and the verifier's24 `verification/send|history` out (least privilege).25- **Relay** `GET ${PROXY_PREFIX}/{tenant}/v2/config/digital-wallet/openid/notifications/sse`26 streams OWS holder notifications, injecting the auth as the `authorization`27 query parameter (EventSource cannot send headers).28- **No webhooks**: holder events arrive on the notifications stream.2930## Reference implementation31Runnable Express + TypeScript app in [`./references`](./references):32```33references/34 holder-api-reference.md the full holder endpoint/payload/response contract35 src/config.ts env config36 src/tenants.ts TenantStore - per-tenant API-key resolution (env or pluggable)37 src/proxy.ts API-key-injecting reverse proxy (allow-list param)38 src/notificationsSse.ts notifications SSE relay (canonical: igrantio-holder-notifications)39 src/server.ts composition (holder allow-list + relay)40 .env.example Dockerfile package.json tsconfig.json41```4243## Steps441. `cd references && cp .env.example .env`, then set `OWS_ENV` (demo|staging,45 default demo), `CORS_ORIGINS`, and one `OWS_TENANT_<SLUG>_API_KEY` per46 organisation.472. `npm install && npm run dev` - backend on `:6003` (alongside issuer `:6001`48 / verifier `:6002`).493. Point the holder frontend (`igrantio-holder-frontend`) base URL at50 `${PROXY_PREFIX}/<tenant>`; open the notifications stream on51 `…/<tenant>/v2/config/digital-wallet/openid/notifications/sse`.5253## Holder contract (what the frontend drives through this backend)5455### Receive a credential (OpenID4VCI)561. `POST …/sdjwt/credential/receive` with57 `{ credentialOffer, autoPresent, kid, trustAnchor }` → `credential`58 (object **or array**; each record's `id` keys the follow-ups).592. Branch on the record (§1.1 of the API reference):60 **transaction code** → `PUT …/credential/{id}/user-pin`;61 **front-channel** → open `authorizationRequest`, then62 `POST …/credential/exchange-code` with `{ code, state }`;63 **deferred** → `PUT …/credential/{id}/receive-deferred` until64 `credential_acked`.653. Review: `PUT …/credential/{id}/accept`, or `DELETE …/credential/{id}` to66 reject. List with `GET …/sdjwt/credentials` (response keys `credential` +67 `pagination`).684. Lifecycle: `PUT …/credential/{id}/request` requests reissuance of an69 expired or near-expiry credential; `GET …/credential/{id}/revocation-status`70 checks the IETF Token Status List.7172### Present credentials (OpenID4VP + DCQL)731. `POST …/sdjwt/verification/receive` (v3) with `{ vpTokenQrCode, … }` →74 `presentation.presentationId`, `presentation.dcqlQuery`,75 `presentation.transactionDataDecoded` (show before consent).762. `POST …/sdjwt/verification/{id}/filter` (v2, empty body) →77 `inputDescriptors[].matchedCredentials`.783. `POST …/sdjwt/verification/{presentationId}/send` (v3) with79 `{ credentials: [{ id, credentialId | credentialIds, claimIds? }] }`80 (`inputDescriptors` for legacy PEX) → open81 `presentation.responseRedirectUri` when non-empty. Transaction-data82 signing happens through this call - there is no separate sign endpoint.8384Notifications drive both flows - see `igrantio-holder-notifications` for the85notification→action decision table. Exact payloads, response fields, and86status vocabularies: [`references/holder-api-reference.md`](./references/holder-api-reference.md).8788## Adapting89- **Recommended pairing**: a **Next.js + TypeScript** portal90 (`igrantio-holder-frontend`) with **Better Auth** passwordless login. Run91 this backend as its own service beside the Next.js app, set92 `CORS_ORIGINS` to the portal origin, and for multi-user portals verify93 the Better Auth session in an Express middleware before the proxy94 forwards to OWS.95- **Key storage**: replace `EnvTenantStore` with a DB/secret-manager `TenantStore`.96- **Path scope**: `HOLDER_PERMITTED_PATHS` in `server.ts` is the allow-list97 (strings match as prefixes, RegExps against the whole path).98- **SSE auth**: if your OWS deployment authenticates SSE with user tokens,99 return `Bearer <jwt>` from the relay's `getAuthorization`.100101## Validation / done criteria102- `npm run typecheck` passes.103- `…/sdjwt/credential/receive` proxies through; the issuer's104 `…/sdjwt/credential/issue` and the verifier's `…/sdjwt/verification/send`105 yield 404 (least privilege).106- `DELETE …/sdjwt/credential/{id}` reaches OWS (the proxy forwards DELETE).107- The notifications SSE relay streams `text/event-stream` for a known tenant108 and 404s for an unknown one; the API key never appears in the browser.109110## Documentation & workflows111112When anything is unclear, consult the iGrant.io documentation before guessing:113114- iGrant.io developer APIs (index): https://docs.igrant.io/docs/developer-apis115- Getting started: https://docs.igrant.io/docs/get-started/116- OpenID4VC API (issuer / verifier / webhook): https://docs.igrant.io/docs/category/openid4vc-api/issuer117- Workflow: issue a credential (OID4VCI): https://docs.igrant.io/docs/openID4vci-issue-credential-intime/118- Workflow: send and verify credentials (OID4VP): https://docs.igrant.io/docs/openID4vc-send-verify-credentials/