External Reconciliation API
The External Reconciliation API enables authorized third-party systems -- such as student information systems, learning management platforms, or institutional enrollment services -- to interact with reconciled identity data managed by the Auth Bridge. Unlike the OID4VP Session and IDV APIs, which are designed for internal service-to-service communication, this API is explicitly designed for consumption by external clients.
All endpoints live under the base path /api/external/v1/reconciliation on the Auth Bridge (port 8090). Every request must include a valid OAuth2 bearer token obtained through the client_credentials grant. The API enforces per-client projection rules that restrict which claims and auxiliary data categories each client can access.
Authentication
External clients authenticate using OAuth2 bearer tokens. The typical flow is:
- The client obtains an access token from the configured OAuth2 authorization server (e.g., Keycloak or the STS) using the
client_credentialsgrant with the scopereconciliation:read. - The client includes the token in the
Authorizationheader of every API request. - The Auth Bridge validates the JWT against the configured JWKS endpoint, checks the issuer, audience, expiration, and scopes.
Obtaining a Token
curl -X POST https://keycloak.example.com/realms/portal/protocol/openid-connect/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=enrollment-service" \
-d "client_secret=<secret>" \
-d "scope=reconciliation:read"
Response:
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 300,
"scope": "reconciliation:read"
}
Using the Token
curl -X GET https://auth-bridge.example.com/api/external/v1/reconciliation/{id}/claims \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
If the token is missing, expired, or lacks the required scope, the API responds with 401 Unauthorized or 403 Forbidden.
Per-Client Projection Configuration
A key security feature of the External API is per-client projection. Each registered client is configured with explicit lists of which identity claims and auxiliary data categories it is allowed to access. The Auth Bridge enforces these restrictions server-side; even if a client requests data outside its projection, the response will only contain the authorized subset.
The configuration lives in the Auth Bridge's application.yml:
external-api:
enabled: true
jwt:
issuer: "http://keycloak:8080/realms/portal"
jwks-uri: "http://keycloak:8080/realms/portal/protocol/openid-connect/certs"
clients:
enrollment-service:
scopes: ["reconciliation:read"]
projected-claims: ["eduid", "eduperson_principal_name", "email"]
auxiliary-categories: ["enrollment", "role"]
analytics-platform:
scopes: ["reconciliation:read"]
projected-claims: ["eduid"]
auxiliary-categories: ["enrollment"]
In this example, enrollment-service can access three identity claims and two auxiliary data categories, while analytics-platform can only see the eduid claim and enrollment auxiliary data. The client identity is extracted from the azp (authorized party) or client_id claim in the JWT. If a client is not listed in the configuration, all requests are rejected with 403 Forbidden.
This projection model follows the principle of least privilege: each client receives only the data it needs to perform its function, reducing the blast radius of a compromised credential.
POST /lookup
Looks up an identity by a hashed identifier. This is the primary entry point for external systems that know a user's wallet key or credential identifier but need to resolve it to the internal identity record.
Request
{
"identifierHash": "dGhpcyBpcyBhIGJhc2U2NHVybC1lbmNvZGVkLWhtYWMtaGFzaA",
"identifierType": "KEY"
}
| Field | Type | Required | Description |
|---|---|---|---|
identifierHash | string | Yes | A Base64url-encoded HMAC-SHA256 hash of the identifier. The caller computes this hash using a shared HMAC key that has been exchanged out-of-band. This design ensures that raw identifiers are never transmitted over the wire. |
identifierType | string | Yes | The type of identifier being looked up. Supported values: KEY (wallet holder key/DID), EDUID (eduID URN), EPPN (eduPersonPrincipalName). |
Response (200 OK)
{
"internalIdentityId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"claims": {
"eduid": "urn:mace:surf.nl:eduid:12345",
"email": "student@institution.nl"
},
"auxiliaryCategories": ["enrollment"],
"assurance": {
"acr": "urn:sphereon:oid4vp:vp",
"amr": ["vp"]
}
}
| Field | Type | Description |
|---|---|---|
internalIdentityId | string (UUID) | The internal identity ID. Use this to call the other endpoints for this identity. |
claims | object | The identity claims, filtered to only those allowed by the client's projection configuration. |
auxiliaryCategories | array of strings | The auxiliary data categories that have data stored for this identity, filtered to those the client is allowed to access. |
assurance | object | Authentication assurance metadata describing how the identity was verified. |
Response (404 Not Found)
{
"error": "identity_not_found",
"error_description": "No identity record matches the provided identifier hash"
}
HMAC Hash Computation
The caller is responsible for computing the HMAC-SHA256 hash of the identifier before sending it to the API. The shared HMAC key is provisioned during client onboarding and must be stored securely. Here is an example in Python:
import hmac
import hashlib
import base64
shared_key = b"your-shared-hmac-key"
identifier = b"did:key:z6Mkf5rGMoatrSj1f..."
hash_bytes = hmac.new(shared_key, identifier, hashlib.sha256).digest()
identifier_hash = base64.urlsafe_b64encode(hash_bytes).rstrip(b"=").decode("ascii")
# Use identifier_hash in the lookup request
GET /{internalIdentityId}
Retrieves the full identity record for a given internal identity ID. The response includes claims (subject to projection), auxiliary data category availability, assurance metadata, and binding information.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
internalIdentityId | string (UUID) | The internal identity identifier, obtained from the lookup endpoint or from a previous API call. |
Response (200 OK)
{
"internalIdentityId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"claims": {
"eduid": "urn:mace:surf.nl:eduid:12345",
"eduperson_principal_name": "student@institution.nl",
"email": "student@institution.nl"
},
"auxiliaryCategories": ["enrollment", "role"],
"assurance": {
"acr": "urn:sphereon:oid4vp:vp",
"amr": ["vp"]
},
"bindings": {
"walletBound": true,
"federationBound": true,
"lastAuthenticatedAt": "2026-03-27T10:30:00Z"
}
}
GET /{internalIdentityId}/claims
Retrieves only the projected identity claims for this identity, without binding or auxiliary metadata. This is a lightweight endpoint for systems that only need to resolve claim values.
Response (200 OK)
{
"eduid": "urn:mace:surf.nl:eduid:12345",
"eduperson_principal_name": "student@institution.nl",
"email": "student@institution.nl"
}
The response body is a flat JSON object containing only the claims that the calling client is authorized to see. If the client's projection allows ["eduid", "email"] but not eduperson_principal_name, the response would omit that field entirely.
GET /{internalIdentityId}/auxiliary/{category}
Retrieves auxiliary data stored under a specific category for this identity. Auxiliary data is arbitrary JSON that external systems store alongside the reconciled identity to enrich it with domain-specific information.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
internalIdentityId | string (UUID) | The internal identity identifier. |
category | string | The auxiliary data category (e.g., enrollment, role, preferences). Must be in the client's allowed auxiliary-categories list. |
Response (200 OK)
{
"category": "enrollment",
"data": {
"enrollment_status": "active",
"programme": "Computer Science",
"institution": "University of Amsterdam",
"start_date": "2025-09-01"
},
"storedBy": "enrollment-service",
"storedAt": "2026-03-15T14:22:00Z",
"expiresAt": "2027-01-01T00:00:00Z"
}
Response (404 Not Found)
Returned if no auxiliary data exists under the specified category for this identity, or if the client is not authorized to access the category.
PUT /{internalIdentityId}/auxiliary/{category}
Stores or updates auxiliary data for a specific category. If data already exists under this category for this identity, it is replaced entirely (not merged). The storedBy field is automatically set to the calling client's identifier.
Request Body
{
"data": {
"enrollment_status": "active",
"programme": "Computer Science",
"institution": "University of Amsterdam",
"start_date": "2025-09-01"
},
"expiresAt": "2027-01-01T00:00:00Z"
}
| Field | Type | Required | Description |
|---|---|---|---|
data | object | Yes | Arbitrary JSON data to store. The structure is entirely defined by the calling system; the Auth Bridge treats it as an opaque blob. Maximum size is 64 KB. |
expiresAt | string (ISO 8601) | No | Optional expiration timestamp. After this time, the data is eligible for automatic cleanup. If omitted, the data does not expire. |
Response (200 OK)
{
"category": "enrollment",
"storedBy": "enrollment-service",
"storedAt": "2026-03-27T11:00:00Z",
"expiresAt": "2027-01-01T00:00:00Z"
}
Response (201 Created)
Returned when the auxiliary category did not previously exist for this identity. The response body is identical to the 200 case.
DELETE /{internalIdentityId}/auxiliary/{category}
Deletes all auxiliary data stored under a specific category for this identity. This operation is idempotent; calling it when no data exists returns 204 No Content without error.
Response (204 No Content)
No response body. The auxiliary data has been deleted (or did not exist).
DELETE /{internalIdentityId}
GDPR erasure endpoint. Permanently and irreversibly deletes all data associated with this identity, including:
- All
identity_matchrecords (the reconciled identity claims) - All
identity_link_bindingrecords (the wallet-to-identity and federation-to-identity links) - All
auxiliary_datarecords across every category - All session artifacts and cached tokens related to this identity
This operation is designed to fulfill GDPR Article 17 (Right to Erasure) requests. It is irreversible -- once executed, the identity cannot be recovered. The user would need to go through the full wallet authentication and IDV reconciliation flow again to re-establish their identity in the system.
Response (204 No Content)
No response body. All data has been deleted.
Audit Trail
Even though the identity data itself is deleted, the Auth Bridge logs an audit event recording that an erasure was performed, who requested it (the client ID from the JWT), the internal identity ID that was erased, and the timestamp. This audit log is retained separately for compliance purposes and does not contain any personal data.
[AUDIT] GDPR_ERASURE client=enrollment-service identity=f47ac10b-58cc-4372-a567-0e02b2c3d479 timestamp=2026-03-27T12:00:00Z
Safety Considerations
Because this operation is destructive and irreversible, consider the following:
- Ensure the calling client has been explicitly authorized for erasure operations. The
reconciliation:readscope alone is not sufficient; a separatereconciliation:deletescope (or equivalent policy) should be required. - Implement a confirmation step in the calling system's workflow before invoking this endpoint.
- Test erasure behavior in a staging environment before enabling it in production.
Common Error Responses
All endpoints share a consistent error response format:
{
"error": "error_code",
"error_description": "Human-readable description of the problem"
}
| HTTP Status | Error Code | Description |
|---|---|---|
400 Bad Request | invalid_request | The request body is malformed or missing required fields. |
401 Unauthorized | invalid_token | The bearer token is missing, expired, or has an invalid signature. |
403 Forbidden | insufficient_scope | The token does not include the required scope, or the client is not configured for this API. |
403 Forbidden | category_not_allowed | The client attempted to access an auxiliary category outside its projection. |
404 Not Found | identity_not_found | No identity exists with the given internal ID. |
404 Not Found | auxiliary_not_found | No auxiliary data exists under the requested category. |
413 Payload Too Large | data_too_large | The auxiliary data payload exceeds the 64 KB limit. |
429 Too Many Requests | rate_limited | The client has exceeded the configured rate limit. Retry after the duration indicated in the Retry-After header. |
500 Internal Server Error | server_error | An unexpected error occurred. Logged server-side for investigation. |