Skip to main content
Version: v0.25.0 (Latest)

Hooking Up Your System

There are three concrete ways your code can feed attributes into an EDK issuer, matching the three integration patterns in Attribute Pipeline. This page shows what each one looks like as an integrator: which REST call to make, which source to configure, or what to implement.

Skim the first section to pick your pattern; jump to that section for the how-to. The "Composing a Pipeline" section at the end ties everything together with the configuration the issuer service actually reads at startup.

Pick Your Integration

Your situationApproachSection
Your system has all the attributes when you trigger issuance, and the wallet will pick the credential up promptlyPush them through the REST API at session creationPushing attributes from your code
Your system is online and quick; you want fresh values when the wallet asksConfigure a source the EDK calls at /credential timeConfiguring an HTTP or database source
You need full control over how the EDK calls your system (auth, request shape, error handling)Write your own AttributeSourceWriting a custom source
Your system is slow, asynchronous, or gated by a human stepConfigure an async-callback source and POST attributes back laterThe async-callback path
OIDC userinfo claims from the wallet's login should become attributes or lookup keysConfigure the userinfo claim mappingCapturing OIDC userinfo claims

You can combine several of these in one credential. Pushing a lookup key at session creation, mapping a few OIDC claims at the authorization phase, and pulling the rest from your HR API at credential request is a typical pattern 2 composition.

1. Pushing Attributes from Your Code (Pattern 1)

The simplest integration. Your back-end calls the EDK issuer's REST API to create a session and includes the attributes (or just lookup keys) in the request body.

Step 1: when your business event happens (an employee gets onboarded, a citizen completes an enrolment, a customer requests a credential), your back-end calls POST /oid4vci/sessions:

POST /oid4vci/sessions
Authorization: Bearer <your-backend-token>
Content-Type: application/json
{
"pipelineConfiguration": { "pipelineId": "employee-credential-v1" },
"correlationId": "ord-9af3e2",
"initialAttributes": [
{
"path": "given_name",
"value": { "data": "Ada" },
"sourceId": { "value": "hr-portal" },
"phase": { "value": "session_init" },
"timestamp": "2026-05-18T01:30:00Z"
},
{
"path": "family_name",
"value": { "data": "Lovelace" },
"sourceId": { "value": "hr-portal" },
"phase": { "value": "session_init" },
"timestamp": "2026-05-18T01:30:00Z"
}
]
}

The full request and response shapes are in the REST API. The pipeline configuration referenced by pipelineId is set up once at the issuer level; for Pattern 1 it does not need to bind any sources beyond what the EDK does by default for the OID4VCI protocol itself.

Step 2: your back-end takes the correlationId and uses it to build the offer URL the wallet should scan or open. The IDK issuer guide covers offer creation.

Step 3: the wallet does its thing. When it calls /credential, the EDK assembles the credential from the attributes you pushed in step 1, signs it, and returns it.

You do not poll, do not call back, do not need to be reachable from the EDK. The integration is one POST to create the session and one optional GET to check status if you want to show progress in your UI.

When to step beyond this pattern: the moment your flow takes longer than minutes between session creation and the wallet calling /credential, or the moment the underlying data may change in that window, or the moment the attribute payload becomes large enough that you would rather not have it sitting in the issuer's database. Move to Pattern 2.

Group sourceId maps to a source binding

When using the compact V1 contribution endpoint, each group's sourceId is the provenance label that downstream pipeline machinery uses to attribute every record in that group. A single group binds to one source. If a single push needs to contribute attributes from two different upstream systems (say, HR and a compliance system), it needs two groups, one per sourceId. The decoder fans out each group's attributes into per-record AttributeRecords carrying that group's sourceId, so the source-binding rules and the dependency-ordered scheduling described in Attribute Pipeline work unchanged. The exact field semantics are in the REST API.

2. Configuring an HTTP or Database Source (Pattern 2)

The default for most production integrations. At session creation you push only a lookup key (one short identifier), and you configure a source that the EDK will call when the wallet hits /credential to fetch the actual attributes.

Step 1: push only the lookup key

Your POST /oid4vci/sessions body is small:

{
"pipelineConfiguration": { "pipelineId": "employee-credential-v2" },
"correlationId": "ord-9af3e2",
"initialLookupKeys": [
{
"name": "employee_id",
"value": "E1042",
"producedBy": { "value": "hr-portal" },
"phase": { "value": "session_init" },
"timestamp": "2026-05-18T01:30:00Z"
}
]
}

No initialAttributes. The session stores the single lookup key (encrypted at rest) and nothing else.

Step 2: configure the EDK to fetch attributes from your system at /credential time

You have three options for this, depending on how you want the EDK to call you.

Option A: HTTP. Your system exposes a JSON endpoint keyed by the lookup key. Configure the built-in HTTP source for the issuer:

attribute-pipeline.source.http.url=https://hr.internal.example/api/employees
attribute-pipeline.source.http.lookup-keys=employee_id

When /credential is hit, the EDK calls GET https://hr.internal.example/api/employees?employee_id=E1042 (using the IDK SSRF-safe HTTP client), parses the JSON response, and maps each top-level key into an attribute named after that key. Your endpoint returns:

{
"given_name": "Ada",
"family_name": "Lovelace",
"department": "Research",
"start_date": "2024-01-15"
}

and those four attributes land in the session bag, the EDK assembles the credential from them and signs it, and the wallet has its credential within the same request.

Use this when your authoritative system already speaks JSON over HTTP and the response shape lines up with your credential claims (or close enough that you can rename a few fields through the attributeMapping on the source binding).

Option B: Direct database lookup. Your data lives in a SQL table the EDK issuer can read. Implement the DatabaseLookupExecutor SPI in your deployment module:

@Inject
@SingleIn(SessionScope::class)
@ContributesBinding(SessionScope::class, binding = binding<DatabaseLookupExecutor>())
class MyEmployeeLookupExecutor(
private val db: MyDatabase,
) : DatabaseLookupExecutor {
override suspend fun lookupRow(
tenantId: String, keyName: String, keyValue: String,
): IdkResult<Map<String, String>?, IdkError> {
val row = db.employees.findByEmployeeId(keyValue) ?: return Ok(null)
return Ok(mapOf(
"given_name" to row.firstName,
"family_name" to row.lastName,
"department" to row.department,
"start_date" to row.startDate.toString(),
))
}
}

Then configure the database source to use this executor:

attribute-pipeline.source.database.lookup-key=employee_id

The wiring around the executor (the actual source class, the scheduling, the binding into the pipeline) is done. You write the lookup, you do not write the integration.

Use this when your data lives in a relational store the issuer reaches directly and you do not want an HTTP hop in the middle.

Option C: Custom source. When your integration needs strict control over auth headers, request bodies, transport semantics, or error mapping that the configuration-driven HTTP source cannot express. See Writing a custom source below.

Step 3: nothing

The wallet flow runs unchanged. The EDK calls your system inside the /credential request, assembles, signs, and responds. The session held one lookup key for the duration; the attributes existed in the bag for a few seconds during assembly; nothing was persisted after issuance unless you opted in to vault retention.

3. The Async-Callback Path (Pattern 3)

For systems that cannot answer in a synchronous window. The shape from your side is: the EDK calls you with a callback URL when issuance asks for attributes, you do your work asynchronously, you POST the attributes back to the URL when ready.

Step 1: configure the source as async on the EDK side

You still configure a source (an HTTP one in most cases, or a custom one). The difference is its binding: declare callbackStyle = ASYNC_CALLBACK and a syncWaitWindow that caps how long /credential may hold open waiting for the answer.

AttributeSourceBinding(
sourceId = AttributeProvenanceRef("http"),
phases = setOf(Oid4vciPipelinePhase.CREDENTIAL_REQUEST),
required = true,
callbackStyle = CallbackStyle.ASYNC_CALLBACK,
syncWaitWindow = Duration.parse("5s"),
)

The sync window is a UX knob. A small one (a few seconds) lets the wallet wait briefly for a fast callback before flipping to a deferred response. Duration.ZERO defers immediately.

Step 2: receive the dispatch from the EDK

When /credential runs, the EDK invokes the source. The source calls your system with the lookup keys it has plus a callback URL it minted for this dispatch. The exact request body shape is up to your source implementation; the HTTP source sends a GET with the lookup keys as query parameters, and your system reads the callback URL from a response header or a separate setup. For full control, write a custom source whose contribute() makes whatever outbound call your system expects, including the callback URL in the body.

The callback URL the EDK gives you looks like:

https://issuer.example.com/oid4vci/sessions/ord-9af3e2/callbacks/eyJhbGciOiJFUzI1NiIsImtpZCI6Im...

The opaque token at the end is a one-shot capability artefact: it is bound to one specific session and one specific source, validated by the EDK's CallbackTokenService. Leaking the URL does not let an attacker tamper with other sessions; using it twice does not let one source overwrite another. Hold onto it as long as you need.

Step 3: do the work asynchronously

Your system queues the request, runs the slow process (human review, batch process, external verification, whatever), and at some point has the answer.

Step 4: POST attributes back to the callback URL

POST https://issuer.example.com/oid4vci/sessions/ord-9af3e2/callbacks/eyJhbGciOiJ...
Content-Type: application/json
{
"attributes": [
{
"path": "clearance_level",
"value": { "data": "L3" },
"sourceId": { "value": "http" },
"phase": { "value": "oid4vci_deferred" },
"timestamp": "2026-05-18T01:32:45Z"
}
]
}

The callback endpoint does not need a bearer token; the capability token in the URL is the authentication.

What happens to the wallet

Two cases:

  • Fast callback (within the sync window): the wallet's /credential request, which is still being held open by the EDK, completes synchronously. The wallet gets the credential in the same response it sent. There is no deferred-credential flow visible to the user.
  • Slow callback (after the sync window): the wallet's /credential got a deferred-credential response before the callback arrived. The wallet polls /credential_deferred. The first poll after the callback lands returns the credential.

Both cases are the same to your back-end: you POST when ready, the EDK figures out which path the wallet is on and does the right thing.

When the callback never comes

The session's deferral policy on the credential binding controls the maximum time the EDK keeps the session alive waiting for callbacks (maxDeferralSeconds, default 7 days). After that, the session moves to FAILED and the wallet's polls stop returning anything useful.

If your back-end knows the work has failed before the timeout, hit POST /oid4vci/sessions/{correlationId}/fail with the source id you were responsible for. The session moves to FAILED immediately and the wallet stops polling.

Capturing OIDC Userinfo Claims

Most authorization-code OID4VCI flows go through an OIDC AS. The wallet logs in there, the AS produces a token, the EDK accepts it. The userinfo claims from that token (email, given_name, family_name, custom claims) are useful to integrators in two ways: as attributes that go straight into the credential, and as lookup keys that drive further fetches.

You do not write code for this. You configure the userinfo mapping per tenant:

issuance.as-claim-mapping.email.claim=email
issuance.as-claim-mapping.email.target=lookup:email

issuance.as-claim-mapping.firstName.claim=given_name
issuance.as-claim-mapping.firstName.target=attribute:given_name

issuance.as-claim-mapping.lastName.claim=family_name
issuance.as-claim-mapping.lastName.target=attribute:family_name

Each entry has a free-form id (email, firstName, ...), the OIDC claim name (.claim=...), and a target (.target=lookup:<keyName> or .target=attribute:<dotted.path>). The reason claim is a value rather than a key segment is that OIDC claim names often contain underscores and the property normaliser would otherwise mangle them.

The mapping runs automatically during the authorization phase. After authorization, the configured lookup keys and attributes are in the session bag, and later sources can use them (the typical chain: OIDC email becomes a lookup key, the identity resolver turns it into an identity_id, the database source uses identity_id to fetch the rest).

Writing a Custom Source

When you need a level of control the configuration-driven sources cannot give you (a proprietary protocol, an exotic auth scheme, an integration where the lookup-key-to-attributes mapping is non-trivial), implement AttributeSource directly. It is a small interface and a few dozen lines of Kotlin.

@Inject
@SingleIn(SessionScope::class)
@ContributesIntoMap(SessionScope::class, binding = binding<AttributeSource>())
@StringKey(MyHrSource.SOURCE_ID)
class MyHrSource(
private val hrClient: MyHrClient,
private val clock: Clock,
) : AttributeSource {
override val sourceId = AttributeProvenanceRef(SOURCE_ID)
override val supportedPhases = setOf(Oid4vciPipelinePhase.CREDENTIAL_REQUEST)
override val consumedLookupKeys = setOf("employee_id")

override suspend fun contribute(
context: PipelineExecutionContext,
phase: PipelinePhase,
): IdkResult<SourceContribution, IdkError> {
val employeeId = context.lookupKeys["employee_id"]
?: return Ok(SourceContribution()) // input missing: skip
val employee = hrClient.fetch(employeeId).getOrElse { return Err(it) }
val now = clock.now()
return Ok(SourceContribution(attributes = listOf(
attribute("given_name", employee.firstName, now, phase),
attribute("family_name", employee.lastName, now, phase),
attribute("department", employee.department, now, phase),
)))
}

private fun attribute(path: String, value: String, now: Instant, phase: PipelinePhase) =
AttributeRecord(
path = AttributePath(path),
value = AttributeData(JsonPrimitive(value)),
sourceId = sourceId,
phase = phase,
timestamp = now,
)

companion object { const val SOURCE_ID = "my-hr" }
}

Drop the file in any module on the EDK service classpath. Metro DI picks it up through @ContributesIntoMap. Reference it from a pipeline configuration with AttributeProvenanceRef("my-hr").

The conventions you need to know:

  • consumedLookupKeys is declarative; the engine uses it to schedule your source after the sources that produce those keys. Declare what you really need or your source will run too early.
  • producedLookupKeys is what later sources can rely on you producing. Useful when your source emits an identity_id that a downstream database source uses.
  • Return an empty SourceContribution() for "I had nothing to do this time" (missing optional input). Return Err(...) only for genuine failure. The binding's required flag determines whether your Err is fatal for the phase.
  • Use the injected Clock for timestamps. Never Clock.System.now() directly.

For an async-callback source, your contribute() dispatches an outbound call (containing the callback URL the EDK passes through PipelineExecutionContext), returns a SourceContribution(deferralSignal = ...), and waits for the callback REST endpoint to deliver the actual attributes later.

Composing a Pipeline

A pipeline is the declarative wiring that ties sources together for one credential type. You register one PipelineConfiguration per credential flow you run; the pipelineId is what your POST /oid4vci/sessions calls reference.

A typical employee-credential pipeline using Pattern 2:

val employeeCredentialPipeline = PipelineConfiguration(
pipelineId = "employee-credential-v2",
sourceBindings = listOf(
// Resolve email from OIDC userinfo to an internal identity_id
AttributeSourceBinding(
sourceId = AttributeProvenanceRef("identity-resolver"),
phases = setOf(Oid4vciPipelinePhase.TOKEN),
required = true,
),
// Fetch HR record by employee_id or identity_id
AttributeSourceBinding(
sourceId = AttributeProvenanceRef("my-hr"),
phases = setOf(Oid4vciPipelinePhase.CREDENTIAL_REQUEST),
required = true,
),
),
claimsBindings = listOf(
CredentialClaimsBinding(
id = "EmployeeCredential",
semanticAttributeSetRef = employeeSetRef,
deferralPolicy = DeferralPolicy(enabled = false),
),
),
expectedInitialLookupKeys = setOf("employee_id"),
)

The pieces:

  • sourceBindings is the ordered list of sources the pipeline uses, each with its phases and runtime properties. The engine validates this at registration time: every lookup key a source consumes must either come from another source's producedLookupKeys or be in expectedInitialLookupKeys.
  • claimsBindings maps the credential's credential_configuration_id to its semantic attribute set (the credential design does the rest, including the OIDC userinfo source for protocol-level metadata) and its deferral policy.
  • expectedInitialLookupKeys is your contract with the caller of POST /oid4vci/sessions: which lookup keys the caller must supply at creation time.

You register pipelines through whatever mechanism your deployment uses (configuration, a startup hook, an admin endpoint specific to your service). The pipeline id is the durable identifier.

What Is Shipped Out of the Box

The EDK ships a handful of sources you can configure without writing Kotlin. The ones a typical integrator will reach for:

Source idWhat it doesWhen to use
auth-session-claimMaps OIDC userinfo claims to attributes and/or lookup keysCapturing AS-provided values into the bag
identity-resolverResolves a lookup key (email, DID, phone) to an internal identity_id via the IDK identity-resolution chainBridging external identifiers to internal ones
httpCalls an external JSON endpoint with the lookup keys as query parametersPattern 2 over HTTP
databaseReads one row from a relational store via your DatabaseLookupExecutor SPIPattern 2 over SQL
vaultReads previously-retained attributes from the EDK encrypted vaultRe-issuance flows where you opted in to vault retention
wallet-presentationFlattens the disclosed claims of an OID4VP-as-auth presentation into the bagWhen the AS uses OID4VP and you want those claims as attributes
invitation-contextReads attributes embedded in an invitation when one was used to start the flowWhen invitations are part of your flow

You will not need all of them, and most integrations use two or three. Pick by your pattern: Pattern 1 typically needs no source beyond what your POST /oid4vci/sessions carries; Pattern 2 typically uses one of http, database, or a custom source, plus auth-session-claim if the AS provides useful claims; Pattern 3 uses http or a custom source with callbackStyle = ASYNC_CALLBACK.

For anything beyond these, write a custom source (above) or extend an existing one. Implementing AttributeSource is a deliberate, well-scoped interface; integrations rarely outgrow it.