# Authentication
Source: https://docs.hopae.com/v2/api-reference/authentication
Authenticate the Verification REST API and the OIDC token endpoint with your App ID and App Secret.
Every app has an **App ID** and an **App Secret**. Find both in the [Console](https://console.hopae.com) under **Developers → API Settings**.
| Where | How | Credentials |
| :- | :- | :- |
| Verification REST API (`/connect/v2/...`) | `Authorization: Basic base64(appId:appSecret)` | App ID + App Secret |
| OIDC `/v2/auth` | `client_id` query parameter | App ID |
| OIDC `/token` | HTTP Basic (`client_secret_basic`) or form fields (`client_secret_post`) | App ID + App Secret |
In OIDC terms, the App ID is your `client_id` and the App Secret is your `client_secret`.
Keep the App Secret on your backend. Never put it in a browser bundle, a mobile app, or a public repository. If it leaks, contact [support@hopae.com](mailto:support@hopae.com) to rotate it.
Sandbox and production apps have separate credentials. See [Apps & Environments](/v2/guides/concepts/apps).
## Example
```bash theme={null}
curl "https://api.hopae.com/connect/v2/connections" \
-u "APP_ID:APP_SECRET"
```
`-u` builds the Basic header for you. In the API playground, enter the App ID as the username and the App Secret as the password.
## Troubleshooting
| Error | Cause |
| :- | :- |
| `401 AUTH_INVALID_CREDENTIALS` | Wrong App ID or App Secret, or a scheme other than Basic |
| `404` on a verification you created | Another app's credentials, or the verification is past its `expiresAt` |
| `invalid_client` at `/token` | The secret is missing or does not belong to the `client_id` |
# Error Code Reference
Source: https://docs.hopae.com/v2/api-reference/error-codes
The error envelope returned by the Verification REST API, and every error code you can receive from v2 endpoints.
## Error Response Format
Every Verification REST API error uses one envelope:
```json theme={null}
{
"error": {
"code": "SESSION_VERIFICATION_NOT_FOUND",
"message": "Verification not found",
"details": {
"verificationId": "019bc4f2-8a31-7c5e-9d02-4f7a1b3e60d8",
"hint": "Check that the verification ID is correct and belongs to your client"
}
},
"request_id": "9f4c2e8b-31d7-4a06-b25e-8c1f70a9d34e"
}
```
### Response Fields
| Field | Type | Description |
| - | - | - |
| `error.code` | string | Machine-readable identifier in `SCREAMING_SNAKE_CASE`. Stable across releases. Switch on this. |
| `error.message` | string | Human-readable description. May change. Do not switch on it. |
| `error.details` | object | Optional structured context (which parameter failed, allowed values, a hint). |
| `request_id` | string | UUID for this request. Quote it when you contact support. |
Request-body validation failures (a missing or mistyped field) use the lowercase code `validation_error` and list the problems under `details.validation_errors`. Everything else uses the uppercase codes below.
## Error Code Structure
Codes are domain-prefixed:
* `AUTH_*`: authentication and access
* `VALIDATION_*`: request validation
* `SESSION_*`: verification session state
* `PROVIDER_*`: eID provider and connection availability
* `RESOURCE_*`: referenced resources (workflows, connection instances)
* `SYSTEM_*`: system errors
## Authentication Errors
**Code**: `AUTH_INVALID_CREDENTIALS`
**HTTP Status**: 401
The `Authorization` header is missing, is not Basic, is malformed, or the App ID / App Secret pair is wrong.
**Common Causes**:
* Sending a Workspace `Bearer` key to a Verification REST endpoint
* Using credentials from a different app
* The secret has been rotated
**Code**: `AUTH_DATA_PROTECTED`
**HTTP Status**: 403
Tokens and user data cannot be released for this session. Not reachable for sessions created with the REST API.
The OIDC token endpoint (`/v2/token`) does not use this envelope. It answers with standard OAuth 2.0 errors, `{ "error": "...", "error_description": "..." }`: for example `400 invalid_grant` when the authorization code has expired (5 minutes), was already used, or the PKCE verifier does not match, and `401 invalid_client` when client authentication fails. See [Token](/v2/api-reference/oidc/token).
## Validation Errors
**Code**: `VALIDATION_INVALID_PARAMETER`
**HTTP Status**: 400
A parameter is present but not acceptable. `details.parameter` names it and `error.message` explains why.
**Common Causes**:
* An unknown `workflowId` was supplied at session creation
* The app is not provisioned for v2 verification
* Not exactly one of `connectionId`, `connectionInstanceId`, and `providerId` was sent, `credentialId` was sent without `providerId`, or the provider key is ambiguous
* The connection is not activated for the app or not available in its environment
* The connection is not available in this app environment (sandbox or production)
* A required `userInput` field is missing, or a `select` field has a value outside its options
**Example Response**:
```json theme={null}
{
"error": {
"code": "VALIDATION_INVALID_PARAMETER",
"message": "Invalid parameter 'userInput.personalNumber': 'personalNumber' is required.",
"details": { "parameter": "userInput.personalNumber" }
},
"request_id": "1e6b40df-7a91-4c33-95f8-2d0ba48e7f16"
}
```
**Code**: `VALIDATION_INVALID_USER_DATA`
**HTTP Status**: 422
A `userInput` value is present but the connection or its provider rejected it, for example an `ng-nin` `nationalIdentityNumber` that is not 11 digits. `details.required` names the expected input and `details.hint` explains the rule. A missing required field returns `400 VALIDATION_INVALID_PARAMETER` instead.
**Code**: `VALIDATION_REDIRECT_URI_REQUIRED`
**HTTP Status**: 400
The verification would start a `redirect` flow, but the request has no `redirectUri`. REST creation does not fall back to the app's redirect URI allowlist. Send `redirectUri` in the Create Verification body.
**Code**: `validation_error`
**HTTP Status**: 400
The request body does not match the schema, for example `connectionInstanceId` is a number instead of a string.
**Example Response**:
```json theme={null}
{
"error": {
"code": "validation_error",
"message": "Request validation failed",
"details": {
"validation_errors": ["connectionInstanceId must be a string"],
"url": "/connect/v2/verifications",
"method": "POST"
}
},
"request_id": "3a5e91c7-284b-4f60-8d17-b6c04e92a1f8"
}
```
## Session Errors
**Code**: `SESSION_VERIFICATION_NOT_FOUND`
**HTTP Status**: 404
No verification with this id is visible to your app.
**Common Causes**:
* Wrong or expired verification id
* The session belongs to another app
* The stored session environment does not match the authenticated app
Foreign-app and environment-mismatched lookups return the same response as an unknown ID.
**Code**: `SESSION_INVALID_STATUS_TRANSITION`
**HTTP Status**: 409
The session is not in a state that allows the operation. `details.currentStatus` and `details.allowed_statuses` explain the mismatch.
**Common Causes**:
* Calling `/userinfo` before the status is `completed`
* Cancelling a session that is already `completed`, `failed`, or `cancelled`
**Code**: `SESSION_INVALID_FLOW`
**HTTP Status**: 400
The requested flow cannot be started for this connection.
## Workflow configuration
`409 WORKFLOW_NOT_CONFIGURED` means the app has no workflow. Create one before starting a verification. Get Connections remains available but returns instances as disabled until a workflow enables them.
## Provider and Connection Errors
**Code**: `PROVIDER_DISABLED_IN_WORKFLOW`
**HTTP Status**: 403
The connection is activated for your app but is not enabled in the workflow this verification resolved to. Enable it on the workflow's **Connections** tab, or pass a `workflowId` where it is enabled.
**Example Response**:
```json theme={null}
{
"error": {
"code": "PROVIDER_DISABLED_IN_WORKFLOW",
"message": "The requested eID is not enabled in the selected workflow",
"details": {
"hint": "Enable this eID in the workflow configuration, or use a workflow where it is enabled."
}
},
"request_id": "c9a35b71-8e4f-4d20-b6a1-5f82e07d3c94"
}
```
**Code**: `PROVIDER_INITIALIZATION_FAILED`
**HTTP Status**: 502
The eID provider did not accept the new session. The verification is recorded as `failed`. Start a new one.
**Common Causes**:
* Provider outage or timeout
* Provider rejected the request data
**Code**: `PROVIDER_ERROR`
**HTTP Status**: 502
Generic provider error during the session.
**Code**: `PROVIDER_UNAVAILABLE`
**HTTP Status**: 503
The provider service is temporarily unavailable. Retry with backoff.
The v1 codes `PROVIDER_NOT_FOUND` and `PROVIDER_NOT_ACTIVATED` do not occur on v2 endpoints. On v2 you reference a **connection instance** that already exists because it was activated. An unknown or foreign instance id answers `RESOURCE_NOT_FOUND` instead.
## Resource Errors
**Code**: `RESOURCE_NOT_FOUND`
**HTTP Status**: 404
A referenced resource does not exist. `details.resource` is `Workflow`, `Connection`, or `ConnectionInstance`.
**Common Causes**:
* `workflowId` does not exist for this app on Get Connections. Create Verification reports this as `400 VALIDATION_INVALID_PARAMETER`
* `connectionId` is not a known catalog connection, or `connectionInstanceId` is unknown or belongs to another app
**Example Response**:
```json theme={null}
{
"error": {
"code": "RESOURCE_NOT_FOUND",
"message": "ConnectionInstance not found",
"details": { "resource": "ConnectionInstance", "identifier": "conn_01J8XK2P4M9QR3TV" }
},
"request_id": "b21d7f30-2c9a-41ee-8a56-90d4f1c72b03"
}
```
## System Errors
**Code**: `SYSTEM_INTERNAL_ERROR`
**HTTP Status**: 500
Unexpected server error. Retry once after a short delay. If it persists, contact support with the `request_id`.
## Error Categories
| Category | HTTP Status Range | Description | Retry Strategy |
| - | - | - | - |
| `client` | 400 to 499 | Client errors | Do not retry automatically |
| `failure` | 502 to 503 | External service failures | Retry with exponential backoff |
| `error` | 500 | System errors | Retry once after delay |
## Error Handling Best Practices
### Retry Strategy
```javascript theme={null}
const RETRYABLE_CODES = [
'PROVIDER_INITIALIZATION_FAILED',
'PROVIDER_ERROR',
'PROVIDER_UNAVAILABLE',
'SYSTEM_INTERNAL_ERROR',
];
async function retryWithBackoff(fn, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (err) {
const code = err.body?.error?.code;
if (!RETRYABLE_CODES.includes(code) || i === maxRetries - 1) throw err;
const delay = Math.min(1000 * Math.pow(2, i), 10000);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
```
```javascript theme={null}
const NON_RETRYABLE_CODES = [
'AUTH_INVALID_CREDENTIALS',
'AUTH_DATA_PROTECTED',
'VALIDATION_INVALID_PARAMETER',
'VALIDATION_INVALID_USER_DATA',
'VALIDATION_REDIRECT_URI_REQUIRED',
'validation_error',
'SESSION_VERIFICATION_NOT_FOUND',
'SESSION_INVALID_STATUS_TRANSITION',
'PROVIDER_DISABLED_IN_WORKFLOW',
'RESOURCE_NOT_FOUND',
];
function handleError(err) {
const code = err.body?.error?.code;
if (NON_RETRYABLE_CODES.includes(code)) {
console.error('Non-retryable error:', err.body.error, err.body.request_id);
return { success: false, error: err.body.error };
}
}
```
### User Communication
| Error Code | User-Friendly Message |
| - | - |
| `AUTH_INVALID_CREDENTIALS` | "Authentication failed. Please check your credentials." |
| `VALIDATION_INVALID_PARAMETER` | "Some required information is missing or invalid. Please try again." |
| `SESSION_VERIFICATION_NOT_FOUND` | "Your session has expired. Please start over." |
| `SESSION_INVALID_STATUS_TRANSITION` | "This verification has already been completed." |
| `PROVIDER_DISABLED_IN_WORKFLOW` | "This identity method is not available." |
| `PROVIDER_UNAVAILABLE` | "The identity service is temporarily unavailable. Please try again later." |
| `SYSTEM_INTERNAL_ERROR` | "Something went wrong on our end. Please try again." |
### Error Context
Always log the `request_id` together with your own context:
```javascript theme={null}
function logError(body, appContext) {
console.error({
code: body.error.code,
message: body.error.message,
details: body.error.details,
requestId: body.request_id,
verificationId: appContext.verificationId,
environment: appContext.environment,
// Never log the App Secret, access tokens, or user data
});
}
```
## Support
For persistent errors or issues not covered here:
**Email**: [support@hopae.com](mailto:support@hopae.com)
**Include in your report**:
* Error code and `request_id`
* Timestamp
* Verification ID (if available)
* Application environment (sandbox / production)
# API Overview
Source: https://docs.hopae.com/v2/api-reference/introduction
Hopae Connect v2 has two surfaces: the OIDC provider for the hosted flow, and the Verification REST API. Both use your App ID and App Secret.
| Surface | Base URL | Use it for |
| :- | :- | :- |
| **OIDC** | `https://connect.hopae.com/v2` (issuer `https://connect.hopae.com`) | The hosted flow. See [Base URL](/v2/api-reference/oidc/base-url) |
| **Verification REST API** | `https://api.hopae.com/connect/v2` | Driving a verification from your backend |
Both authenticate with the app's App ID and App Secret. See [Authentication](/v2/api-reference/authentication).
## REST endpoints
| Method | Path | Purpose |
| :- | :- | :- |
| `GET` | `/connections` | Connections your app can verify with |
| `POST` | `/verifications` | Start a verification |
| `GET` | `/verifications/{verificationId}` | Check the status |
| `GET` | `/verifications/{verificationId}/userinfo` | Read the result |
| `DELETE` | `/verifications/{verificationId}` | Cancel |
| `POST` | `/verifications/{verificationId}/dc-request` | `dc` only: get the wallet request to run on your own page |
| `POST` | `/verifications/{verificationId}/dc-response` | `dc` only: submit the wallet's answer |
Errors share one envelope. See [Error Codes](/v2/api-reference/error-codes).
In the API playground, enter the App ID as the username and the App Secret as the password.
Support: [support@hopae.com](mailto:support@hopae.com)
# Start Authorization
Source: https://docs.hopae.com/v2/api-reference/oidc/auth
GET /auth
Initiates the OIDC Authorization Code flow. Front-channel redirect. No Authorization header.
Start a verification by redirecting the user's browser to the shared `/v2/auth` endpoint with your app's `client_id`. Hopae hosts the verification UI, runs the eID flow, and returns the user to your `redirect_uri` with an authorization code.
## Query Parameters
Your App ID.
Exact match to a redirect URI registered for this app (Console → Developers → API Settings → Redirect URL Allowlist). Saving the app's allowlist takes effect immediately. See [Apps & Environments](/v2/guides/concepts/apps).
Must be `code`.
Space-delimited scopes. Include `openid` and `hopae` (or the equivalent `idv`). Without `hopae`/`idv`, the userinfo response contains no `user`, `provenance`, `match`, or `missing_claims`.
Opaque value echoed back on the redirect. Use it for CSRF protection.
Bound into the ID token. Recommended for browser-based clients.
`login` forces a fresh verification. Hopae never reuses a previous verification, so this is mainly for client libraries that expect it.
The workflow to run (Console → Workflow → copy the workflow ID). Defaults to the app's default workflow. The workflow decides which connections are offered and which claims are requested. An unknown explicit workflow produces `invalid_request`. An app with no workflow shows a hosted error page (`WORKFLOW_NOT_CONFIGURED`) and does not redirect back.
Filter enabled connections by a minimum supported Level of Assurance: `urn:hopae:loa:{level}` (1 to 5). Hopae records the achieved level in `acr` / `hopae_loa`. If it is lower than requested, the verification is still finished, and userinfo requested with `provenance=true` carries `provenance._metadata.error.code = "loa_insufficient"` so you can decide. A filter leaving no eligible connection finishes with `invalid_request`. The v1 `urn:hopae:id:{providerId}` token is ignored on `/v2`: it neither narrows nor rejects. Use the `ui_*` pre-selection hints instead. See [Level of Assurance](/v2/guides/verifications/assurance).
Pre-select a connection so the user skips country and credential selection. Use the catalog connection id (for example `smart-id`) of a connection enabled in the workflow. An id that is not enabled simply means no pre-selection. Replaces the v1 `ui_provider` parameter. `ui_connection_instance_id` with the instance id (`conn_…`) is also accepted.
Catalog provider id. Pre-selects its single enabled connection. If none or several match, the hosted selection screen remains visible. Unlike REST creation, ambiguity does not return a 400.
Use with `ui_provider_id` to narrow to one credential. Hint precedence is instance → connection → provider. An unmatched connection hint does not fall through to the provider hint.
Pre-select the country step (ISO 3166-1 alpha-2).
`true` skips the hosted intro screen and lands on the selection step.
`true` hides the back button in the hosted UI.
`true` runs the verification but blocks token and userinfo issuance (`403 AUTH_DATA_PROTECTED`). Use it when you only need the result recorded on Hopae's side.
The v1 parameters `ui_provider`, `ui_auth_flow`, `ui_hide_header`, and `ui_need_consent` have no effect on v2. The hosted flow always runs country → credential → provider.
## Behavior
* On success, responds with `302 Found` to your `redirect_uri` with `code` and `state` query params. The code is single-use and expires after 5 minutes.
* On failure or cancellation, redirects with `error=access_denied` and an `error_description`, plus `state` if provided. A missing or unregistered `redirect_uri` cannot be redirected and shows a hosted error page instead.
## Examples
```http theme={null}
GET https://connect.hopae.com/v2/auth?client_id=xhdh8a13&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&response_type=code&scope=openid%20hopae&state=rf9Xy1&nonce=n-0S6_WzA2Mj&workflow_id=wf_01J8XJ4Q2R7TPX9K
```
```http Success theme={null}
HTTP/1.1 302 Found
Location: https://app.example.com/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=rf9Xy1&iss=https%3A%2F%2Fconnect.hopae.com
```
```http Cancelled theme={null}
HTTP/1.1 302 Found
Location: https://app.example.com/callback?error=access_denied&error_description=Authentication%20cancelled%20by%20user&state=rf9Xy1
```
# Base URL
Source: https://docs.hopae.com/v2/api-reference/oidc/base-url
The issuer, the v2 endpoints, and where discovery and keys live.
| | URL |
| :- | :- |
| Issuer | `https://connect.hopae.com` |
| Discovery | `https://connect.hopae.com/.well-known/openid-configuration` |
| JWKS | `https://connect.hopae.com/jwks` |
| **Authorization (v2)** | `https://connect.hopae.com/v2/auth` |
| Token (v2) | `https://connect.hopae.com/v2/token` |
| UserInfo (v2) | `https://connect.hopae.com/v2/userinfo` |
Discovery lists the v1 authorization endpoint, so override it with `/v2/auth`. Why and how: [OIDC Integration](/v2/guides/oidc-integration#endpoints).
# JWKS
Source: https://docs.hopae.com/v2/api-reference/oidc/jwks
GET /jwks
Public keys for verifying Hopae-issued ID tokens. Served at the root of the OIDC host, not under /v2.
Download the JSON Web Key Set (JWKS) to validate ID token signatures. It lives at `https://connect.hopae.com/jwks`, shared by v1 and v2 and by sandbox and production apps. Prefer resolving it from the [discovery document](/v2/api-reference/oidc/well-known) (`jwks_uri`).
## Response
Array of JWK objects containing public keys (`kty`, `kid`, `use`, `alg`, and the key material).
```bash theme={null}
curl https://connect.hopae.com/jwks
```
```json Response theme={null}
{
"keys": [
{
"kty": "RSA",
"kid": "rs256-1759391683564",
"use": "sig",
"alg": "RS256",
"n": "oahUI3nmdQ1...",
"e": "AQAB"
}
]
}
```
The set holds more than one key (for example an ES256 and an RS256 key). Pick the key whose `kid` matches the `kid` header of the ID token. When Hopae rotates signing keys, the JWKS is updated with the new key before tokens start referencing it. The same keys also sign integrator [evidence](/v2/guides/verifications/evidence).
## Related Resources
Full issuer metadata via `/.well-known/openid-configuration`
Exchange codes for ID and access tokens
# Exchange Code for Token
Source: https://docs.hopae.com/v2/api-reference/oidc/token
POST /token
Exchange an authorization code for v2 ID and access tokens using your app credentials.
Exchanges an authorization code for an access token and an ID token, following the OIDC standard. Call it from your backend, because it needs the App Secret.
Send parameters as `application/x-www-form-urlencoded` and authenticate with your App ID and App Secret: HTTP Basic (`client_secret_basic`, recommended) or form fields (`client_secret_post`). Public clients without a secret (`none`) are not supported.
## Request Body
Must be `authorization_code`. Refresh tokens are not issued.
The authorization code received on your redirect URI. Single-use, 5-minute lifetime.
Must exactly match the redirect URI used in the authorization request.
Required only if you sent `code_challenge` on the authorization request (PKCE, `S256`).
## Response
Bearer token for the `/userinfo` endpoint. Valid for 10 minutes.
Always `Bearer`.
Access token lifetime in seconds (`600`).
A signed JWT with authentication context only: `sub`, `iss`, `aud`, `iat`, `exp`, `nonce`, plus `acr`, `hopae_loa`, `hopae_loa_label`, and `hopae_verification` (`provider_id`, `connection_id`). Personal data is never in the ID token. Read it from `/userinfo`.
The granted scopes.
```bash theme={null}
curl --request POST \
--url 'https://connect.hopae.com/v2/token' \
--user '{appId}:{appSecret}' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data 'grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback'
```
```json Response theme={null}
{
"access_token": "",
"token_type": "Bearer",
"expires_in": 600,
"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"scope": "openid hopae"
}
```
## ID token claims
```json Decoded id_token payload theme={null}
{
"sub": "019bc4f2-8a31-7c5e-9d02-4f7a1b3e60d8",
"iss": "https://connect.hopae.com",
"aud": "xhdh8a13",
"iat": 1788427192,
"exp": 1788428992,
"nonce": "n-0S6_WzA2Mj",
"acr": "urn:hopae:loa:4",
"hopae_loa": 4,
"hopae_loa_label": "high",
"hopae_verification": {
"provider_id": "smart-id",
"connection_id": "smart-id"
}
}
```
* `sub` is the verification id, a new value for every verification, not a stable user identifier.
* `hopae_verification.provider_id` and `hopae_verification.connection_id` identify the connection that ran (catalog ids). They are nested under `hopae_verification`, not top-level claims. There is no `amr` claim on v2 tokens.
* `iss` is `https://connect.hopae.com`. Validate it, and verify `aud` against your App ID. Keys are at [`/jwks`](/v2/api-reference/oidc/jwks).
* ID tokens are valid for 30 minutes.
# UserInfo
Source: https://docs.hopae.com/v2/api-reference/oidc/userinfo
GET /userinfo
Returns the verified claims and match outcome, and on request the provenance and missing claims. Requires a valid Bearer access token.
Fetch the verification result using the access token from `/token`. With `hopae`/`idv`, the response follows the same data model as [Get Verification UserInfo](/v2/api-reference/verifications/get-verification-userinfo) on the REST API.
The ID token contains no personal data. `user`, `provenance`, `match`, and `missing_claims` are only returned here, and only when the authorization request included the `hopae` (or `idv`) scope. With `openid` alone you receive `sub`, the available LoA fields, and `verification_model`. `provenance` (which carries the connection identity) and `missing_claims` are also opt-in per request: add `provenance=true` and `missing_claims=true`.
## Headers
Bearer access token issued by `/token` (valid for 10 minutes).
## Query Parameters
Send these on the query string of a `GET`, or in the form body of a `POST`.
`true` (or `1`) adds the `provenance` block, including the connection identity under `provenance._metadata`. Omitted or any other value leaves it out.
`true` (or `1`) adds the `missing_claims` list. Omitted or any other value leaves it out.
## Response
OIDC filters fields by the granted scopes. Unlike REST, it does not expose a top-level `error`. Assurance failures are available under `provenance._metadata.error`.
Connection identity appears only under `provenance._metadata`, never as top-level userinfo fields, so request `provenance=true` to read it. The IDs are snapshotted when the session is created.
The verification id. A new value for every verification. Use `user.source_id` to recognise a returning person.Authentication Context Class Reference, present when the provider asserted a LoA.Numeric [Level of Assurance](/v2/guides/verifications/assurance).Human-readable label (e.g. `substantial`).The provider of the connection that ran, as a catalog provider id (e.g. `smart-id`).The catalog connection id (e.g. `google-wallet-us-mdl`). Opaque. Read `provider_id` and `credential_id` for its parts.The credential that was verified (e.g. `us-mdl`). Requires the `hopae` scope.The activated connection instance the session ran on (`conn_…`). Requires the `hopae` scope.`disclosure` or `match`.Returned only with `missing_claims=true`. Requested claims the source could not provide (including `source_id` when it could not be derived).Verified attributes limited to the workflow's requested claims. For `match`, the verified subset of the values you submitted. See [Normalized User Data](/v2/guides/verifications/normalized-user-data).Match envelope (`matched`, `granularity`, `submitted_fields`, `details`). Present only for `match` connections.Returned only with `provenance=true`. What the source returned: `presentation.credentials[]` (each with the provider's raw `claims` and, when issued, `evidence`) and `_metadata`. See [Return Data Model](/v2/guides/verifications/return-data-model).Present only when the verification finished with a lower LoA than requested (`code: "loa_insufficient"`).
Workflow decision nodes add their outputs here (`metLoa`, `hasClaim`, `computed.*`). Dynamic `evaluate` outputs that are not on the OIDC claims whitelist are only available through the REST userinfo endpoint.
## Example
```bash theme={null}
curl -X GET 'https://connect.hopae.com/v2/userinfo?provenance=true&missing_claims=true' \
-H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIs...'
```
```json Disclosure (provenance=true&missing_claims=true) theme={null}
{
"user": {
"given_name": "OK",
"family_name": "TESTNUMBER",
"name": "OK TESTNUMBER",
"birthdate": "1905-04-04",
"nationality": "LT",
"source_id": "PNOLT-40504040001"
},
"missing_claims": [
"email"
],
"acr": "urn:hopae:loa:4",
"hopae_loa": 4,
"hopae_loa_label": "high",
"verification_model": "disclosure",
"provenance": {
"presentation": {
"credentials": [
{
"claims": {
"documentNumber": "PNOLT-40504040001-MOCK-Q",
"birthdate": "1905-04-04",
"countryCode": "LT",
"givenName": "OK",
"surname": "TESTNUMBER"
},
"evidence": {
"token": {
"id_token": "",
"expires_at": "2026-09-03T10:19:52.000Z",
"access_token": "",
"token_type": "Bearer"
},
"names": "id_token;expires_at;access_token;token_type"
}
}
]
},
"_metadata": {
"verification_id": "019bc4f2-8a31-7c5e-9d02-4f7a1b3e60d8",
"verified_at": "2026-09-03T09:19:52.110Z",
"status": "completed",
"provider_id": "smart-id",
"connection_id": "smart-id",
"credential_id": "smart-id",
"connection_instance_id": "conn_01J8XK2P4M9QR3TV"
}
},
"sub": "019bc4f2-8a31-7c5e-9d02-4f7a1b3e60d8"
}
```
```json openid scope only theme={null}
{
"sub": "019bc4f2-8a31-7c5e-9d02-4f7a1b3e60d8",
"acr": "urn:hopae:loa:4",
"hopae_loa": 4,
"hopae_loa_label": "high",
"verification_model": "disclosure"
}
```
## Errors
| HTTP | When |
| :- | :- |
| 401 `invalid_token` | The access token is missing, expired (10 minutes), or is not valid for this OIDC provider |
| 403 `AUTH_DATA_PROTECTED` | The authorization request used `private_mode=true` |
# Discovery Document
Source: https://docs.hopae.com/v2/api-reference/oidc/well-known
GET /.well-known/openid-configuration
OIDC discovery metadata. Served at the root of the OIDC host, not under /v2.
The discovery document lives at the root of the OIDC host and is shared by v1 and v2. Use it for the issuer and the signing keys.
`authorization_endpoint` here is the v1 endpoint. For v2, start authorization at `https://connect.hopae.com/v2/auth`. See [Base URL](/v2/api-reference/oidc/base-url).
## Response
`https://connect.hopae.com`. The `iss` of every ID token and of the authorization response. The same on a [custom domain](/v2/guides/concepts/custom-domain).
The v1 authorization endpoint. Replace it with `/v2/auth` for v2.
Token endpoint. Returns v2 data for verifications started on `/v2/auth`.
UserInfo endpoint. Returns v2 data for verifications started on `/v2/auth`.
Keys for validating ID tokens. Shared by sandbox and production apps.
```bash theme={null}
curl https://connect.hopae.com/.well-known/openid-configuration
```
```json Response (abridged) theme={null}
{
"issuer": "https://connect.hopae.com",
"authorization_endpoint": "https://connect.hopae.com/auth",
"token_endpoint": "https://connect.hopae.com/token",
"userinfo_endpoint": "https://connect.hopae.com/userinfo",
"jwks_uri": "https://connect.hopae.com/jwks",
"scopes_supported": ["openid", "hopae", "idv", "profile", "email", "phone", "address"],
"response_types_supported": ["code id_token", "code", "id_token", "none"],
"grant_types_supported": ["implicit", "authorization_code", "refresh_token"],
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "none"],
"code_challenge_methods_supported": ["S256"],
"id_token_signing_alg_values_supported": ["ES256", "RS256"],
"authorization_response_iss_parameter_supported": true
}
```
The document lists what the server software supports, not what your app is registered for. Your app uses `response_type=code`, `grant_type=authorization_code`, and `client_secret_basic` or `client_secret_post`. PKCE (`S256`) is optional. Refresh tokens are never issued.
# Base URL
Source: https://docs.hopae.com/v2/api-reference/verifications/base-url
The base URL for every Verification REST API call.
```text theme={null}
https://api.hopae.com/connect/v2
```
Sandbox and production apps use this shared base URL. HTTP Basic credentials identify the app and therefore select the application environment. The URL has no environment or stage segment.
```text theme={null}
https://api.hopae.com/connect/v2/verifications
```
Authenticate with HTTP Basic using your App ID and App Secret. See [Authentication](/v2/api-reference/authentication).
# Cancel Verification
Source: https://docs.hopae.com/v2/api-reference/verifications/cancel-verification
DELETE /verifications/{verificationId}
Cancel a verification session that has not finished.
Cancels a verification session. The session moves to `cancelled` and can no longer be completed by the user. It stays readable through [Get Verification Status](/v2/api-reference/verifications/get-verification).
## Headers
`Basic `. See [Authentication](/v2/api-reference/authentication).
## Path Parameters
The session id.
## Response
Returns an empty response with HTTP status `204` on success.
```bash cURL theme={null}
curl --request DELETE \
--url 'https://api.hopae.com/connect/v2/verifications/{verificationId}' \
--user '{appId}:{appSecret}'
```
## Errors
| HTTP | Code | When |
| :- | :- | :- |
| 404 | `SESSION_VERIFICATION_NOT_FOUND` | Unknown id, a session of another app, or an environment mismatch |
| 409 | `SESSION_INVALID_STATUS_TRANSITION` | The session is already `completed`, `failed`, or `cancelled` |
Cancelling does not notify the eID provider. A provider-side session the user already opened may stay alive until it expires on its own, but its result will never be applied to the cancelled verification.
# Create Verification
Source: https://docs.hopae.com/v2/api-reference/verifications/create-verification
POST /verifications
Start a new identity verification session against one of your activated connections.
Starts a verification session against a **connection instance**, a connection you activated for your app. Get the id from [Get Connections](/v2/api-reference/verifications/get-connections).
## Headers
`Basic `. See [Authentication](/v2/api-reference/authentication).
Must be `application/json`.
## Request Body
The catalog connection id, as listed in the Console and returned by [Get Connections](/v2/api-reference/verifications/get-connections) (for example `smart-id`, `google-wallet-us-mdl`). It resolves to your app's activated instance of that connection. Provide exactly one of `connectionId`, `connectionInstanceId`, or `providerId`.
Alternative to `connectionId` or `providerId`: the activated connection instance (`conn_…`) from Get Connections.
Catalog provider id (`provider.id` from Get Connections). Resolves among the workflow's enabled connections. If several match, the request returns `400` and names the candidates. Add `credentialId` or use `connectionId`.
Catalog credential id (`credential.id` from Get Connections). Only accepted together with `providerId`. The pair identifies one catalog connection. A credential id alone does not identify a provider.
The workflow that governs this verification: which connections are enabled and which claims are requested. Defaults to the app's default workflow. An unknown id returns `400 VALIDATION_INVALID_PARAMETER`. An app with no workflow returns `409 WORKFLOW_NOT_CONFIGURED`.
Values the connection needs before it can start: a lookup key such as a registration id, or the values to compare for a `match` connection. One flat map. The fields come from the connection's `userInputSchema`. Required fields are validated before the session starts: a missing field or an out-of-range `select` value returns HTTP 400, and a value the connection rejects returns HTTP 422 `VALIDATION_INVALID_USER_DATA`. Keys the schema does not declare are dropped. See [User Input](/v2/api-reference/verifications/user-input).
```json theme={null}
{
"registrationId": "PNOEE-38001085718"
}
```
Where to send the user after a redirect-based flow completes. Required when the session starts a `redirect` flow (otherwise `400 VALIDATION_REDIRECT_URI_REQUIRED`). REST creation does not select a default from the app's OIDC redirect URI allowlist.
Preferred flow when the connection supports more than one: `qr`, `redirect`, `push`, `dc`, or `query`. The response's `flowType` reports the flow that was actually started.
The claims requested from the provider are decided by the workflow (the common claims of the verification step plus any credential-specific claims for this connection), not by the request body. `requestedClaims`, `requestedLoa`, and `matchData` from v1 are not accepted. Unknown fields are ignored. See [Workflows](/v2/guides/verifications/workflows).
### Provider-key example
```json theme={null}
{
"providerId": "google-wallet",
"credentialId": "us-mdl"
}
```
The selected connection must still be activated and enabled in the resolved workflow.
## Response
Returns `201 Created`.
The session id (UUID v7). It is also the `sub` of the userinfo response.
`awaiting_user_action` immediately after creation. Later values: `authenticating`, `completed`, `failed`, `cancelled` (`processing` is reserved and not currently returned). An unfinished verification expires at `expiresAt` and is then deleted. See [Verification Flow](/v2/guides/verifications/verification-flow).
A `query` connection (lookup eID such as `ng-nin` or `br-cpf`) runs its lookup inside this call, so `status` is already `completed` or `failed`. See [Flow Types](/v2/guides/reference/flow-types).
The flow that was started: `qr`, `redirect`, `push`, `dc`, or `query`. See [Flow Types](/v2/guides/reference/flow-types).
What your UI needs to continue. The keys depend on `flowType`.
`qr` flows: the payload to render as a QR code.
`qr` and `dc` flows: a Hopae-hosted URL that opens the wallet or app on a phone. Safe to open directly on the user's device.
`qr` flows on some providers: token for same-device app launch.
`redirect` flows: the URL to send the user to.
`push` flows: the code the user must confirm in their app. Absent for providers that do not issue one (then `flowDetails` is omitted).
`push` flows: instruction to show next to `verificationCode`.
Present only when the session already failed at creation, which happens only on a `query` connection. Same shape as in [Get Verification](/v2/api-reference/verifications/get-verification): `type`, `code` (for example `INVALID_ID_NUMBER`), `message`.
ISO 8601 creation time.
ISO 8601 expiry, 30 minutes after creation. A verification not finished by then has expired and is deleted: `GET /verifications/{id}` returns `404`. Stop polling at this time and start a new verification. See [Expiry](/v2/guides/verifications/verification-flow#expiry).
Echo of the connection instance used.
The catalog connection id, for example `google-wallet-us-mdl`.
The credential being verified, for example `us-mdl`.
The provider presenting the credential, for example `google-wallet`.
`disclosure` or `match`. Tells you which userinfo shape to expect.
```bash cURL theme={null}
curl --request POST \
--url 'https://api.hopae.com/connect/v2/verifications' \
--user '{appId}:{appSecret}' \
--header 'Content-Type: application/json' \
--data '{
"connectionId": "smart-id",
"userInput": { "registrationId": "PNOEE-38001085718" }
}'
```
```json Push flow theme={null}
{
"verificationId": "019bc4f2-8a31-7c5e-9d02-4f7a1b3e60d8",
"status": "awaiting_user_action",
"flowType": "push",
"flowDetails": {
"verificationCode": "4823",
"description": "Confirm this code matches the one shown in your Smart-ID app"
},
"createdAt": "2026-09-03T09:18:31.774Z",
"expiresAt": "2026-09-03T09:48:31.774Z",
"polling": { "intervalMs": 2000, "maxDurationMs": 1800000 },
"connectionInstanceId": "conn_01J8XK2P4M9QR3TV",
"connectionId": "smart-id",
"credentialId": "smart-id",
"providerId": "smart-id",
"verificationModel": "disclosure"
}
```
```json Query flow (completed) theme={null}
{
"verificationId": "019bc4f6-2b41-7f0c-9a55-3e8d1c7b2a90",
"status": "completed",
"flowType": "query",
"createdAt": "2026-09-03T09:24:10.512Z",
"expiresAt": "2026-09-03T09:54:10.512Z",
"connectionInstanceId": "conn_01J8XP4T2H7MCV5N",
"connectionId": "ng-nin",
"credentialId": "ng-nin",
"providerId": "ng-nin",
"verificationModel": "disclosure"
}
```
```json Query flow (failed) theme={null}
{
"verificationId": "019bc4f6-7d18-7a2e-8c61-5b9f0e3d4c21",
"status": "failed",
"flowType": "query",
"error": {
"type": "verification_error",
"code": "INVALID_ID_NUMBER",
"message": "Invalid or unknown ID number. Please check and try again."
},
"createdAt": "2026-09-03T09:25:47.093Z",
"expiresAt": "2026-09-03T09:55:47.093Z",
"connectionInstanceId": "conn_01J8XP4T2H7MCV5N",
"connectionId": "ng-nin",
"credentialId": "ng-nin",
"providerId": "ng-nin",
"verificationModel": "disclosure"
}
```
```json Redirect flow theme={null}
{
"verificationId": "019bc4f3-1c02-7d4a-8b7e-2e0f9c5a7d11",
"status": "awaiting_user_action",
"flowType": "redirect",
"flowDetails": {
"authorizationUrl": "https://connect.hopae.com/v/019bc4f3-1c02-7d4a-8b7e-2e0f9c5a7d11"
},
"createdAt": "2026-09-03T09:15:02.118Z",
"expiresAt": "2026-09-03T09:45:02.118Z",
"polling": { "intervalMs": 2000, "maxDurationMs": 1800000 },
"connectionInstanceId": "conn_01J8XM0R7C2VHK4Y",
"connectionId": "mitid",
"credentialId": "mitid",
"providerId": "mitid",
"verificationModel": "disclosure"
}
```
```json QR flow theme={null}
{
"verificationId": "019bc4f4-5e77-7a9c-b0d1-6c3a2f8e9b44",
"status": "awaiting_user_action",
"flowType": "qr",
"flowDetails": {
"qrData": ""
},
"createdAt": "2026-09-03T09:12:44.301Z",
"expiresAt": "2026-09-03T09:42:44.301Z",
"polling": { "intervalMs": 2000, "maxDurationMs": 1800000 },
"connectionInstanceId": "conn_01J8XN2C6QF9W1AZ",
"connectionId": "freja-plus",
"credentialId": "freja-plus",
"providerId": "freja",
"verificationModel": "disclosure"
}
```
```json DC flow theme={null}
{
"verificationId": "019bc4f5-9a10-7e3b-8f42-7d1c0b9a3e55",
"status": "awaiting_user_action",
"flowType": "dc",
"flowDetails": {
"linkData": "https://connect.hopae.com/dc/us-mdl?verification_id=019bc4f5-9a10-7e3b-8f42-7d1c0b9a3e55"
},
"createdAt": "2026-09-03T09:21:09.442Z",
"expiresAt": "2026-09-03T09:51:09.442Z",
"polling": { "intervalMs": 2000, "maxDurationMs": 1800000 },
"connectionInstanceId": "conn_01J8XN9W3D6BQZ1F",
"connectionId": "google-wallet-us-mdl",
"credentialId": "us-mdl",
"providerId": "google-wallet",
"verificationModel": "disclosure"
}
```
## Errors
| HTTP | Code | When |
| :- | :- | :- |
| 400 | `VALIDATION_INVALID_PARAMETER` | Unknown `workflowId`. Missing or multiple start keys. `credentialId` without `providerId`. An ambiguous `providerId`. The connection is not available for this app or not activated for your app. A required `userInput` field is missing or invalid |
| 400 | `VALIDATION_REDIRECT_URI_REQUIRED` | The connection starts a `redirect` flow and the request has no `redirectUri` |
| 403 | `PROVIDER_DISABLED_IN_WORKFLOW` | The connection is activated but not enabled in the resolved workflow |
| 404 | `RESOURCE_NOT_FOUND` | Unknown `connectionId` or provider/credential pair, or a `connectionInstanceId` that does not belong to your app |
| 409 | `WORKFLOW_NOT_CONFIGURED` | The app has no workflow. Create one before starting a verification |
| 422 | `VALIDATION_INVALID_USER_DATA` | A `userInput` value is present but the connection or its provider rejects it |
| 502 | `PROVIDER_INITIALIZATION_FAILED` | The provider refused to start the session. The verification is recorded as `failed` |
See [Error Codes](/v2/api-reference/error-codes) for the envelope.
# Get Connections
Source: https://docs.hopae.com/v2/api-reference/verifications/get-connections
GET /connections
List the connections activated for your app, with everything you need to start a verification against each one.
Returns the [connections](/v2/guides/concepts/connections) that are activated for your app. Each item carries the connection's catalog `connectionId` and its `connectionInstanceId` (either one starts a verification with [Create Verification](/v2/api-reference/verifications/create-verification)), the connection's `userInputSchema`, the claims it can return, and whether it is enabled in the resolved workflow.
Only activated connections appear here. A connection that is `requested`, `processing`, or `rejected` in the Console is not listed. Activation happens in the Console under **Configuration → Connections**. See [Connection Activation](/v2/guides/concepts/connections/activation).
## Headers
`Basic `. See [Authentication](/v2/api-reference/authentication).
## Query Parameters
The workflow whose enabled set and claim curation apply. Defaults to the app's default workflow. An unknown id returns `404 RESOURCE_NOT_FOUND`. There is no silent fallback.
`all` lists every activated connection with its `enabled` flag. `enabled` returns only the connections that can be started in the resolved workflow, exactly what `POST /verifications` will accept.
## Response
Returns an array of connection objects.
Your app's activated instance of this connection. Format `conn_…` (connections activated before August 2026 carry `cred_…`). Accepted by Create Verification as an alternative to `connectionId`. Also reported as `provenance._metadata.connection_instance_id` in userinfo.
The catalog connection id (for example `smart-id`, `cz-bankid-identify`, `google-wallet-us-mdl`). This is the id you pass to Create Verification and to `ui_connection_id` on OIDC. Human-readable but opaque. Read `provider.id` and `credential.id` instead of parsing it.
Catalog provider with required `id`, `name`, and `logoUrl`. Use `provider.id` as the `providerId` start key.
Catalog credential with required `id`, `name`, `logoUrl`, `countries` (lowercase ISO country codes), `loa` (numeric assurance levels), `types` (presentation types `1`, `2`, `3`), and `verificationModel` (`disclosure` or `match`).
Optional fields: `displayName`, `displayGroupId`, and `matchGranularity` (`per_field` or `aggregate`). Use `credential.displayName ?? credential.name` for the label, and `credential.displayGroupId ?? connectionId` for the display group. Use `credential.id` with `providerId` to select a specific credential.
Supported delivery mechanics, primary first: `redirect`, `qr`, `push`, `dc`, or `query`. See [Flow Types](/v2/guides/reference/flow-types).
The `userInput` fields this connection needs at creation time. Absent when the connection needs no input. See [User Input](/v2/api-reference/verifications/user-input).
A map of field name → rule. Each rule may carry `type` (`text`, `password`, `image`, `select`, or `date`, omitted for plain text), `required`, `pattern`, `enum` (static options for `select`), `optionsUrl` (dynamic options), and `hint`.
Every claim this connection can return for your app. Includes `source_id` when the connection can derive a stable per-person identifier.
Present only when `availableClaims` contains `source_id`. `primary` lists the claims Hopae requests to derive `source_id`. `fallback` lists the claims tried only when a primary value is absent. Both empty means `source_id` is derived from protocol data and nothing extra is requested.
The claims the resolved workflow requests specifically for this connection (the workflow's **Claims** tab in the Console). Empty when the workflow does not curate this connection.
Whether the resolved workflow enables this connection. A connection listed with `enabled: false` is activated for the app but cannot be started in that workflow. `POST /verifications` answers `403 PROVIDER_DISABLED_IN_WORKFLOW`.
```bash cURL theme={null}
curl --request GET \
--url 'https://api.hopae.com/connect/v2/connections?status=enabled' \
--user '{appId}:{appSecret}'
```
```json theme={null}
[
{
"connectionInstanceId": "conn_01J8XK2P4M9QR3TV",
"connectionId": "smart-id",
"provider": {
"id": "smart-id",
"name": "Smart-ID",
"logoUrl": "https://static.hopae.com/images/wallets/smartid.png"
},
"credential": {
"id": "smart-id",
"name": "Smart-ID",
"logoUrl": "https://static.hopae.com/images/wallets/smartid.png",
"countries": [
"ee",
"lt",
"lv",
"be"
],
"loa": [
4
],
"types": [
1
],
"verificationModel": "disclosure",
"displayName": "Smart-ID"
},
"flowTypes": [
"push"
],
"userInputSchema": {
"fields": {
"registrationId": {
"required": true,
"hint": "Smart-ID registrationId must be at least 8 characters"
}
}
},
"availableClaims": [
"name",
"given_name",
"family_name",
"birthdate",
"nationality",
"personal_code",
"source_id"
],
"sourceIdBackedBy": {
"primary": [
"personal_code"
],
"fallback": []
},
"connectionClaims": [
"given_name",
"family_name",
"birthdate"
],
"enabled": true
}
]
```
`provider`, `credential`, `flowTypes`, and `userInputSchema` are read live from the catalog. `availableClaims` belongs to your activated instance. `connectionClaims` and `enabled` come from the resolved workflow. Refresh cached lists when configuration or catalog data changes.
# Get Verification Status
Source: https://docs.hopae.com/v2/api-reference/verifications/get-verification
GET /verifications/{verificationId}
Retrieve the status and timing of a verification session. No personal attributes are returned.
Retrieves the current status of a verification session. Poll this endpoint until the status is terminal, then call [Get Verification UserInfo](/v2/api-reference/verifications/get-verification-userinfo) to read the result.
## Headers
`Basic `. See [Authentication](/v2/api-reference/authentication).
## Path Parameters
The session id returned by [Create Verification](/v2/api-reference/verifications/create-verification).
## Response
The session id.
`awaiting_user_action`, `authenticating`, `completed`, `failed`, or `cancelled`. `processing` is reserved and not currently returned. You do not receive `expired`: an unfinished verification is deleted at `expiresAt`, and this endpoint then returns `404`. See [Expiry](/v2/guides/verifications/verification-flow#expiry).
The catalog provider id (for example `smart-id`, `freja`, `google-wallet`), the same value [Create Verification](/v2/api-reference/verifications/create-verification) returned.
The workflow that governed the session: the one you passed, or the app's default.
`qr`, `redirect`, `push`, `dc`, or `query`. A `query` session is already terminal at creation. Reading it again returns the stored result.
The same flow details returned at creation, while the session is still waiting for the user.
ISO 8601 creation time.
ISO 8601 expiry time, 30 minutes after creation. If the verification has not finished by then, it has expired and is deleted.
ISO 8601 completion time. Present when `status` is `completed`.
Present when `status` is `failed`.
Present when `status` is `cancelled`.
Present when `status` is `failed`. Carries `type`, `code` (for example `user_cancelled`, `loa_insufficient`), `message`, and optionally `providerError` with the provider's own code.
Authentication Context Class Reference, present when the provider asserted a LoA explicitly.
Numeric [Level of Assurance](/v2/guides/verifications/assurance), 1 to 5.
Human-readable LoA label (for example `substantial`).
The activated connection instance the session runs on (`conn_…`).
The catalog connection id, for example `google-wallet-us-mdl`.
The credential being verified, for example `us-mdl`.
`disclosure` or `match`.
```bash cURL theme={null}
curl --request GET \
--url 'https://api.hopae.com/connect/v2/verifications/{verificationId}' \
--user '{appId}:{appSecret}'
```
Non-terminal responses carry a `polling` hint: `intervalMs` is how long to wait between polls (per connection — some providers reject faster status reads), `maxDurationMs` is the session window.
```json Completed theme={null}
{
"verificationId": "019bc4f2-8a31-7c5e-9d02-4f7a1b3e60d8",
"status": "completed",
"providerId": "smart-id",
"workflowId": "wf_01J8XJ4Q2R7TPX9K",
"flowType": "push",
"createdAt": "2026-09-03T09:18:31.774Z",
"verifiedAt": "2026-09-03T09:19:52.110Z",
"expiresAt": "2026-09-03T09:48:31.774Z",
"acr": "urn:hopae:loa:4",
"hopae_loa": 4,
"hopae_loa_label": "high",
"connectionInstanceId": "conn_01J8XK2P4M9QR3TV",
"connectionId": "smart-id",
"credentialId": "smart-id",
"verificationModel": "disclosure"
}
```
```json Awaiting user action theme={null}
{
"verificationId": "019bc4f4-5e77-7a9c-b0d1-6c3a2f8e9b44",
"status": "awaiting_user_action",
"providerId": "freja",
"workflowId": "wf_01J8XJ4Q2R7TPX9K",
"flowType": "qr",
"flowDetails": {
"qrData": ""
},
"createdAt": "2026-09-03T09:12:44.301Z",
"expiresAt": "2026-09-03T09:42:44.301Z",
"polling": { "intervalMs": 2000, "maxDurationMs": 1800000 },
"hopae_loa": 3,
"hopae_loa_label": "substantial",
"connectionInstanceId": "conn_01J8XN2C6QF9W1AZ",
"connectionId": "freja-plus",
"credentialId": "freja-plus",
"verificationModel": "disclosure"
}
```
```json Failed theme={null}
{
"verificationId": "019bc4f4-5e77-7a9c-b0d1-6c3a2f8e9b44",
"status": "failed",
"providerId": "freja",
"workflowId": "wf_01J8XJ4Q2R7TPX9K",
"flowType": "qr",
"error": {
"code": "provider_error",
"message": "The user cancelled the authentication",
"details": { "provider_code": "", "provider_type": "provider_error" }
},
"createdAt": "2026-09-03T09:12:44.301Z",
"failedAt": "2026-09-03T09:13:51.204Z",
"expiresAt": "2026-09-03T09:42:44.301Z",
"connectionInstanceId": "conn_01J8XN2C6QF9W1AZ",
"connectionId": "freja-plus",
"credentialId": "freja-plus",
"verificationModel": "disclosure"
}
```
## Errors
| HTTP | Code | When |
| :- | :- | :- |
| 404 | `SESSION_VERIFICATION_NOT_FOUND` | Unknown id, a session of another app, an environment mismatch, or a verification past its `expiresAt` |
# Get Verification UserInfo
Source: https://docs.hopae.com/v2/api-reference/verifications/get-verification-userinfo
GET /verifications/{verificationId}/userinfo
Return the verified claims for a completed verification, and on request the provenance and missing claims.
Returns the verified user data and, for `match` connections, the match outcome. The audit trail (`provenance`) and `missing_claims` are returned only when you request them with the query parameters below. Available when the session is `completed`, or when it failed with `error.code: "loa_insufficient"` (the lax assurance result).
## Headers
`Basic `. See [Authentication](/v2/api-reference/authentication).
## Path Parameters
The session id.
## Query Parameters
`true` (or `1`) adds the `provenance` block, including the connection identity under `provenance._metadata`. Omitted or any other value leaves it out.
`true` (or `1`) adds the `missing_claims` list. Omitted or any other value leaves it out.
## Response
Connection identity appears only under `provenance._metadata`, never as top-level userinfo fields, so request `provenance=true` to read it. The IDs are snapshotted when the session is created.
Subject identifier. In Hopae Connect this is the `verificationId`. A verification is a one-time event, not an account. Use `user.source_id` to recognise a returning person.Numeric [Level of Assurance](/v2/guides/verifications/assurance), 1 to 5.Human-readable assurance label (e.g. `substantial`).Authentication Context Class Reference. Present when the provider asserted a LoA explicitly.With `provenance=true`. The provider of the connection that ran, as a catalog provider id (e.g. `smart-id`, `google-wallet`).The catalog connection id (e.g. `google-wallet-us-mdl`). Human-readable but opaque: read `provider_id` and `credential_id` instead of parsing it.The credential that was verified (e.g. `us-mdl`).With `provenance=true`. The activated connection instance the session ran on (`conn_…`), the instance resolved at creation.`disclosure` (attributes under `user`) or `match` (comparison under `match`, with `user` echoing the verified subset of what you submitted). See [Verification Model](/v2/guides/concepts/verification-model).Returned only with `missing_claims=true`. Requested claims the source could not provide. Includes `source_id` when it was requested but could not be derived for this person. `[]` when nothing is missing.
The verified attributes, limited to the claims the workflow requested. For `match` connections it contains the verified subset of the values you submitted in `userInput`. Per-field providers: only fields with `matched: true`. Aggregate providers: every submitted field when the aggregate outcome is `true`, otherwise empty.
Full nameGiven nameFamily nameMiddle name(s)`YYYY-MM-DD`Gender valueISO 3166-1 alpha-2Email addressWhether the email is verifiedE.164 phone numberWhether the phone number is verifiedStructured addressBase64-encoded portraitBCP 47 localeStable identifier for the same person on this connection. Present only when `source_id` is selected in the workflow's data request and the connection can derive it.
See [Normalized User Data](/v2/guides/verifications/normalized-user-data) for the full catalog. Provider-specific claims may also appear.
Present only when `verification_model` is `match`.
Aggregate outcome, `true` only when every submitted field matched.`per_field` or `aggregate`.The `userInput` keys that were compared.Per-field outcomes keyed by field name (`per_field` only): `matched`, `submitted_value`, and optionally `similarity` (0 to 100).
Returned only with `provenance=true`. What the source returned, as handed over by the provider. See [Return Data Model](/v2/guides/verifications/return-data-model).
One entry per upstream credential presented for this verification.The provider's raw response for this credential, in the provider's own field names. Returned for every verification model, match included.Signed proof material relayed from the source, when the provider issues it. See [Evidence & Data Integrity](/v2/guides/verifications/evidence).The session id.ISO 8601 completion time.Session status at read time.Present when the session ended with `loa_insufficient`.
Catalog identity is carried by `provenance._metadata.provider_id`, `connection_id`, `credential_id`, and `connection_instance_id`. It is never repeated at the top level of userinfo.
Present only for the one non-completed state this endpoint serves: a session that `failed` with `code: "loa_insufficient"` (the provider returned a lower LoA than the workflow asked for). The data is still returned so you can decide how to handle it.
Workflow decision nodes (`check-min-loa`, `check-claim`, `evaluate`) add their outputs to this response, for example `metLoa`, `hasClaim`, or `computed.`. See [Workflow Nodes](/v2/guides/reference/workflow-nodes).
```bash cURL theme={null}
curl --request GET \
--url 'https://api.hopae.com/connect/v2/verifications/{verificationId}/userinfo?provenance=true&missing_claims=true' \
--user '{appId}:{appSecret}'
```
```json Disclosure (provenance=true&missing_claims=true) theme={null}
{
"user": {
"given_name": "OK",
"family_name": "TESTNUMBER",
"name": "OK TESTNUMBER",
"birthdate": "1905-04-04",
"nationality": "LT",
"source_id": "PNOLT-40504040001"
},
"missing_claims": [
"email"
],
"acr": "urn:hopae:loa:4",
"hopae_loa": 4,
"hopae_loa_label": "high",
"verification_model": "disclosure",
"provenance": {
"presentation": {
"credentials": [
{
"claims": {
"documentNumber": "PNOLT-40504040001-MOCK-Q",
"birthdate": "1905-04-04",
"countryCode": "LT",
"givenName": "OK",
"surname": "TESTNUMBER"
},
"evidence": {
"token": {
"id_token": "",
"expires_at": "2026-09-03T10:19:52.000Z",
"access_token": "",
"token_type": "Bearer"
},
"names": "id_token;expires_at;access_token;token_type"
}
}
]
},
"_metadata": {
"verification_id": "019bc4f2-8a31-7c5e-9d02-4f7a1b3e60d8",
"verified_at": "2026-09-03T09:19:52.110Z",
"status": "completed",
"provider_id": "smart-id",
"connection_id": "smart-id",
"credential_id": "smart-id",
"connection_instance_id": "conn_01J8XK2P4M9QR3TV"
}
},
"sub": "019bc4f2-8a31-7c5e-9d02-4f7a1b3e60d8"
}
```
```json Match (provenance=true&missing_claims=true) theme={null}
{
"user": {
"name": "Manuela Elisa da Mota",
"cpf": "12345678900"
},
"missing_claims": [],
"hopae_loa": 4,
"hopae_loa_label": "high",
"verification_model": "match",
"match": {
"matched": false,
"granularity": "per_field",
"submitted_fields": [
"name",
"birthdate",
"cpf"
],
"details": {
"name": {
"matched": true,
"submitted_value": "Manuela Elisa da Mota",
"similarity": 100
},
"birthdate": {
"matched": false,
"submitted_value": "1975-06-05"
},
"cpf": {
"matched": true,
"submitted_value": "12345678900"
}
}
},
"provenance": {
"presentation": {
"credentials": [
{
"claims": {
"rfb_existe": true,
"cnh_existe": true,
"qrcode": {
"nome": true,
"data_nascimento": false
}
},
"evidence": {
"token": {
"integrator_jws": "eyJhbGciOiJSUzI1NiIsInR5cCI6...",
"token_type": "integrator"
},
"names": "integrator_jws;token_type"
}
}
]
},
"_metadata": {
"verification_id": "019bc4f7-2b44-7f1a-9c3d-8e5b0a6f2c77",
"verified_at": "2026-09-03T10:02:19.551Z",
"status": "completed",
"provider_id": "br-cpf",
"connection_id": "br-cpf-cnh-match",
"credential_id": "br-cnh-match",
"connection_instance_id": "conn_01J8XK7T1B5NM0WD"
}
},
"sub": "019bc4f7-2b44-7f1a-9c3d-8e5b0a6f2c77"
}
```
```json Disclosure (no query parameters) theme={null}
{
"user": {
"given_name": "OK",
"family_name": "TESTNUMBER",
"name": "OK TESTNUMBER",
"birthdate": "1905-04-04",
"nationality": "LT",
"source_id": "PNOLT-40504040001"
},
"acr": "urn:hopae:loa:4",
"hopae_loa": 4,
"hopae_loa_label": "high",
"verification_model": "disclosure",
"sub": "019bc4f2-8a31-7c5e-9d02-4f7a1b3e60d8"
}
```
`provenance.presentation.credentials[].claims` is the provider's own response in its native field names (for match providers, typically per-field confirmations). `userInput`, `match.submitted_fields`, and `match.details` use OIDC-normalized keys (e.g. `name`, `birthdate`), so the audit trail stays aligned with the source while your request and the outcome stay normalized.
## Errors
| HTTP | Code | When |
| :- | :- | :- |
| 404 | `SESSION_VERIFICATION_NOT_FOUND` | Unknown id, a session of another app, or an environment mismatch |
| 409 | `SESSION_INVALID_STATUS_TRANSITION` | The session is not `completed` and is not a `failed` session with `error.code: "loa_insufficient"` |
| 403 | `AUTH_DATA_PROTECTED` | The session was started over OIDC with `private_mode` |
# User Input
Source: https://docs.hopae.com/v2/api-reference/verifications/user-input
How to fill the userInput field when you create a verification. The schema comes from the connection, not from a fixed list of providers.
Some connections need data from you before the provider can start: a lookup key that selects the person (a registration id, a phone number, a national id number), or the values a `match` connection should compare. You send these in the single `userInput` object of [Create Verification](/v2/api-reference/verifications/create-verification).
## Where the schema comes from
Every connection publishes its requirements as `userInputSchema` in [Get Connections](/v2/api-reference/verifications/get-connections). The Console shows the same fields on a connection's detail sheet under **Data → Request Params**.
Illustrative schema (read the actual fields from your connection):
```json theme={null}
{
"userInputSchema": {
"fields": {
"registrationId": {
"required": true,
"hint": "Smart-ID registrationId must be at least 8 characters"
},
"country": {
"type": "select",
"required": true,
"enum": [
"EE",
"LV",
"LT"
]
}
}
}
}
```
A connection with no `userInputSchema` needs no input. The user selects themselves in the provider's own UI (most redirect-based eIDs work this way).
## Field rules
| Rule | Meaning |
| :- | :- |
| `type` | The kind of input: `text`, `password`, `image` (base64-encoded image), `select`, or `date` (`YYYY-MM-DD`). Omitted for plain text fields. |
| `required` | Validated before the session starts. A missing or empty required field returns `400 VALIDATION_INVALID_PARAMETER` naming the field. |
| `enum` | Allowed values for a `select` field. Any other value returns `400`. |
| `optionsUrl` | For `select` fields whose options come from the provider at runtime (for example a bank list). Fetch the options from that URL and let the user choose. |
| `pattern` | A regular expression describing the expected format. Shown as guidance. Validate it client-side. |
| `hint` | Human-readable help text for your form. |
Keys the connection's `userInputSchema` does not declare are dropped before the provider is called. Only a connection without a `userInputSchema` receives `userInput` unchanged. A value the connection or its provider rejects (for example a malformed national ID number) returns `422 VALIDATION_INVALID_USER_DATA`. Record-level rules that a schema cannot express (for example "provide one of email, phone, or identification number") are enforced by the provider and surface as a failed session.
## One field, two purposes
`userInput` is one flat map. Hopae decides what each value is for based on the connection's `credential.verificationModel`:
* **`disclosure`**: the values select the person or authorise the lookup. The provider then discloses the person's attributes.
* **`match`**: the same values are also the claims to compare. The provider answers whether they match, field by field, under `match` in the userinfo response. See [Verification Model](/v2/guides/concepts/verification-model).
You never need to split the map into lookup keys and comparison values yourself.
## Examples
```json Smart-ID (disclosure) theme={null}
{
"connectionId": "smart-id",
"userInput": {
"registrationId": "PNOEE-38001085718"
}
}
```
```json Brazil CNH (match) theme={null}
{
"connectionId": "br-cpf-cnh-match",
"userInput": {
"cpf": "12345678900",
"name": "Manuela Elisa da Mota",
"birthdate": "1975-06-04",
"picture": "/9j/4AAQSkZJRgABAQAAAQABAAD..."
}
}
```
```json iDIN (select with runtime options) theme={null}
{
"connectionId": "idin-full-identification",
"userInput": {
"presetBank": "INGBNL2A"
}
}
```
Keys are the OIDC-normalized names listed in the schema (for example `name`, `birthdate`). They are echoed back unchanged in `match.submitted_fields` and `match.details`. The `provenance` block keeps the provider's native names so the audit trail stays aligned with the source.
# REST API Integration
Source: https://docs.hopae.com/v2/guides/api-integration
Drive a verification from your backend with the Verification REST API and build your own UI.
With the REST API, your backend starts the verification, your UI handles the user step, and your backend reads the result.
**Before you start**
* An app's **App ID** and **App Secret**. See [Authentication](/v2/api-reference/authentication).
* At least one connection that is activated and enabled in a workflow. See [Activate Connections](/v2/guides/concepts/connections/activation).
Every call uses HTTP Basic auth with the App ID and App Secret against `https://api.hopae.com/connect/v2`.
```mermaid theme={null}
sequenceDiagram
participant UI as Your UI
participant B as Your backend
participant H as Hopae
B->>H: GET /connections
B->>H: POST /verifications
H-->>B: flowType + flowDetails
B-->>UI: Show QR / redirect / waiting screen
loop Until terminal
B->>H: GET /verifications/{id}
end
B->>H: GET /verifications/{id}/userinfo
```
## 1. Find a connection
```bash theme={null}
curl "https://api.hopae.com/connect/v2/connections?status=enabled" \
-u "APP_ID:APP_SECRET"
```
`status=enabled` returns exactly the connections you can start for the default workflow. For each one, note `connectionId`, `flowTypes`, and `userInputSchema` (the fields to send). See [Get Connections](/v2/api-reference/verifications/get-connections).
## 2. Create a verification
```bash cURL theme={null}
curl -X POST "https://api.hopae.com/connect/v2/verifications" \
-u "APP_ID:APP_SECRET" \
-H "Content-Type: application/json" \
-d '{
"connectionId": "smart-id",
"userInput": { "registrationId": "PNOLT-40504040001" }
}'
```
```javascript Node.js theme={null}
const res = await fetch("https://api.hopae.com/connect/v2/verifications", {
method: "POST",
headers: {
Authorization: `Basic ${Buffer.from(`${APP_ID}:${APP_SECRET}`).toString("base64")}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
connectionId: "smart-id",
userInput: { registrationId: "PNOLT-40504040001" },
}),
});
const verification = await res.json();
```
```python Python theme={null}
import requests
verification = requests.post(
"https://api.hopae.com/connect/v2/verifications",
auth=(APP_ID, APP_SECRET),
json={
"connectionId": "smart-id",
"userInput": {"registrationId": "PNOLT-40504040001"},
},
).json()
```
* Send one of `connectionId`, `connectionInstanceId`, or `providerId`. Add `credentialId` to narrow a `providerId` that has several connections. An ambiguous one returns `400`.
* Optional: `workflowId` (otherwise the default workflow), `redirectUri` (for redirect flows).
* The workflow decides which claims are returned. You do not send claims.
See [Create Verification](/v2/api-reference/verifications/create-verification) and [User Input](/v2/api-reference/verifications/user-input).
## 3. Handle the flow
Act on `flowType`:
```javascript theme={null}
switch (verification.flowType) {
case "redirect": redirectTo(verification.flowDetails.authorizationUrl); break;
case "qr": showQr(verification.flowDetails.qrData); break; // on a phone: open linkData
case "push": showCode(verification.flowDetails?.verificationCode); break;
case "dc": openOnPhone(verification.flowDetails.linkData); break;
case "query": /* already completed or failed: skip to step 5 */ break;
}
```
Full responses and diagrams for each type: [Flow Types](/v2/guides/reference/flow-types).
## 4. Poll until finished
Skip this for `query`: its create response is already final.
Poll `GET /verifications/{id}` until the status is `completed`, `failed`, or `cancelled`. Wait `polling.intervalMs` between calls (each non-terminal response has it). Stop at `expiresAt`: a verification not finished by then has expired and is deleted, so later calls return `404`. See [Expiry](/v2/guides/verifications/verification-flow#expiry).
For `qr` and `push`, your polls drive the verification: Hopae checks the provider only when you poll. If you stop polling, the status stops updating and no terminal [webhook](/v2/guides/webhook-signing) is sent. Redirect and `dc` verifications finish without polling.
```javascript Node.js theme={null}
// `created` is the create response
async function waitForResult(created) {
const deadline = Date.parse(created.expiresAt);
while (Date.now() < deadline) {
const v = await get(`/verifications/${created.verificationId}`);
if (["completed", "failed", "cancelled"].includes(v.status)) return v;
await sleep(v.polling?.intervalMs ?? 3000);
}
return { status: "expired" }; // past expiresAt: the verification is gone
}
```
```python Python theme={null}
import time
from datetime import datetime, timezone
# `created` is the create response
def wait_for_result(created):
deadline = datetime.fromisoformat(created["expiresAt"].replace("Z", "+00:00"))
while datetime.now(timezone.utc) < deadline:
v = get(f"/verifications/{created['verificationId']}")
if v["status"] in ("completed", "failed", "cancelled"):
return v
time.sleep((v.get("polling") or {}).get("intervalMs", 3000) / 1000)
return {"status": "expired"} # past expiresAt: the verification is gone
```
Statuses are explained in [Verification Flow](/v2/guides/verifications/verification-flow#lifecycle).
## 5. Read the result
When the status is `completed`:
```bash theme={null}
curl "https://api.hopae.com/connect/v2/verifications/{verificationId}/userinfo" \
-u "APP_ID:APP_SECRET"
```
```json Response (abridged) theme={null}
{
"user": {
"name": "OK TESTNUMBER",
"birthdate": "1905-04-04",
"nationality": "LT",
"source_id": "PNOLT-40504040001"
},
"hopae_loa": 4,
"hopae_loa_label": "high",
"verification_model": "disclosure",
"sub": "019bc4f2-8a31-7c5e-9d02-4f7a1b3e60d8"
}
```
* Read identity data from `user`. To recognise a returning person, use `user.source_id`, not `sub`.
* Add `provenance=true` and `missing_claims=true` to the query for the audit trail and the claims the source could not provide.
* Read it before `expiresAt`. After that the result is gone. See [Expiry](/v2/guides/verifications/verification-flow#expiry).
The full payload, including `match` results, is in [Return Data](/v2/guides/verifications/return-data-model).
## Cancel
`DELETE /verifications/{id}` cancels a verification that has not finished (`204`). A completed, failed, or cancelled one answers `409 SESSION_INVALID_STATUS_TRANSITION`. See [Cancel Verification](/v2/api-reference/verifications/cancel-verification).
## Errors
Errors share one envelope. Switch on `error.code` and quote `request_id` to support.
```json theme={null}
{
"error": { "code": "PROVIDER_DISABLED_IN_WORKFLOW", "message": "The requested eID is not enabled in the selected workflow" },
"request_id": "c9a35b71-8e4f-4d20-b6a1-5f82e07d3c94"
}
```
Every code and what to do: [Error Codes](/v2/api-reference/error-codes).
# Apps & Environments
Source: https://docs.hopae.com/v2/guides/concepts/apps
An app is a sandbox app or a production app. Its credentials select it. The URLs are the same for both.
Your **organization** owns **apps**. Each app is either a **sandbox app** or a **production app**, chosen when you create it. Each app has its own credentials, connections, workflows, redirect URIs, and webhooks. Sandbox and production apps are not linked.
```mermaid theme={null}
graph TD
O[Organization] --> S[Sandbox app]
O --> P[Production app]
S --> SW[Workflows]
P --> PW[Workflows]
```
## Sandbox vs production
| | Sandbox app | Production app |
| :- | :- | :- |
| **Purpose** | Build and test | Real users |
| **Identities** | Test identities | Real eIDs |
| **Billing** | Not billed | Billed |
| **Activation** | Instant | Provider review or contract. See [Activate Connections](/v2/guides/concepts/connections/activation) |
| **Tester Info** | Yes | No |
| **Custom domain** | No | Yes |
| **Plan** | Any | Enterprise |
To move to production, create a production app and set it up again. Nothing is copied from the sandbox app. See [Go Live](/v2/guides/getting-started/go-live).
## Endpoints
Both kinds of app use the same URLs. The credentials you send select the app.
| Surface | URL |
| :- | :- |
| Verification REST API | `https://api.hopae.com/connect/v2` |
| OIDC | `https://connect.hopae.com/v2/auth`. Details: [OIDC Integration](/v2/guides/oidc-integration#endpoints) |
| Console | `https://console.hopae.com` |
Credentials are under **Developers → API Settings**. See [Authentication](/v2/api-reference/authentication).
## Saving changes
A save takes effect **immediately**, also on a production app's live traffic. There is no separate publish step and no staging copy, so test a change in a sandbox app first.
# Branding
Source: https://docs.hopae.com/v2/guides/concepts/branding
The name and logo users see in the hosted flow, set per app and optionally per workflow.
The hosted flow shows a **name and logo**. You set them in two places.
| Level | Where | Name | Logo |
| :- | :- | :- | :- |
| **App** (default) | **Configuration → General** | Company Info display name, else the app name | App logo, else the Company Info logo |
| **Workflow** (override) | Workflow editor → **Branding** | The workflow's display name (required) | The workflow's logo |
A workflow uses the app's branding while **Use app-level branding** is on. Turn it off to use the workflow's own name and logo.
Providers may show a separate display name on their consent screens. That one comes from the Company Info you submit when you [activate a connection](/v2/guides/concepts/connections/activation).
# Connections
Source: https://docs.hopae.com/v2/guides/concepts/connections
A connection is one credential presented through one provider. You activate it, enable it in a workflow, and verify with it.
A **connection** is one way to verify an identity: a **credential** (what is proven, such as a US mobile driver's license) presented through a **provider** (where it lives, such as Google Wallet).
```mermaid theme={null}
flowchart LR
P1[Google Wallet] --> C1[US mDL]
P2[Samsung Wallet] --> C1
P3[CA DMV Wallet] --> C1
P1 --> C2[US passport]
P4[Smart-ID] --> C3[Smart-ID]
classDef cred fill:#DBEAFE,stroke:#2563EB,color:#111;
classDef prov fill:#ECECEC,stroke:#222,color:#111;
class C1,C2,C3 cred
class P1,P2,P3,P4 prov
```
Each arrow above is one connection, for example `google-wallet-us-mdl` or `smart-id`. The same credential through three providers is three connections, because each provider has its own activation and availability.
## Using a connection
1. **Activate** it for your app. See [Activate Connections](/v2/guides/concepts/connections/activation).
2. **Enable** it in a workflow. See [Workflows](/v2/guides/verifications/workflows).
3. **Verify** with it over OIDC or REST.
## Identifiers
| Identifier | Example | Use it for |
| :- | :- | :- |
| **Connection ID** | `smart-id` | The id you send: `connectionId` (REST) or `ui_connection_id` (OIDC). From the catalog, the same for every app. |
| **Connection instance ID** | `conn_01J8XK2P4M9QR3TV` | Created when you activate a connection for one app. Send it as `connectionInstanceId` (REST) or `ui_connection_instance_id` (OIDC). |
| **Provider ID / Credential ID** | `google-wallet` / `us-mdl` | Filtering and display. |
A connection ID is readable but opaque. Do not parse it. Read `provider.id` and `credential.id` from [Get Connections](/v2/api-reference/verifications/get-connections).
## What a connection tells you
[Get Connections](/v2/api-reference/verifications/get-connections) and the connection's detail sheet in the Console show:
* **Countries** and the **display name** users see
* **Flow type**: how the user completes it. See [Verification Flow](/v2/guides/verifications/verification-flow#flow-types)
* **Verification model**: `disclosure` or `match`. See [Verification Model](/v2/guides/concepts/verification-model)
* **Level of assurance** it can reach. See [Assurance](/v2/guides/verifications/assurance)
* **Available claims**, and the **user input** it needs. See [User Input](/v2/api-reference/verifications/user-input)
The Console also shows whether a connection is available in Sandbox, Production, or both.
For how credentials and providers relate in depth, see [Credentials & Providers](/v2/guides/concepts/connections/providers-and-credentials).
# Activate Connections
Source: https://docs.hopae.com/v2/guides/concepts/connections/activation
Activation is the one-time setup that makes a connection usable for your app.
Most eID providers need to know who relies on their data before they release it. **Activation** is that onboarding, done once per app and connection in the Console under **Configuration → Connections**.
Activation belongs to one app. Activating a connection in a sandbox app does not activate it in a production app.
## Activation types
The provider decides the type:
| Type | What you do | Sandbox | Production |
| :- | :- | :- | :- |
| **Direct** | Confirm your company and display name | Click **Instant Activate** | Active on submit |
| **Registration** | Submit a few company details | Click **Instant Activate** | The provider reviews it |
| **Contract** | Sign an agreement | Click **Demo activate** | Book an onboarding call. Hopae activates it |
Company details you enter once are saved to the app's **Company Info** and prefilled for the next provider.
When you activate, you can include several connections of the same provider. They are submitted together and activate together.
## Statuses
```mermaid theme={null}
stateDiagram-v2
direction LR
[*] --> NotActivated
NotActivated --> Activated: direct (production)
NotActivated --> Requested: submit
Requested --> Activated: Instant Activate (sandbox)
Requested --> Processing: review
Processing --> Activated: approved
Processing --> Rejected
Rejected --> Requested: fix and resubmit
```
| Status | Meaning |
| :- | :- |
| **Not Activated** | Not set up yet |
| **Requested** | Submitted, waiting for review |
| **Processing** | Being reviewed and set up |
| **Rejected** | Something needs fixing. The reason is on the row |
| **Activated** | Ready to use |
| **N/A** | Not available for this app's environment, for example a production-only connection in a sandbox app |
Webhooks report the outcome as `activation.activated` or `activation.rejected`. See [Webhooks](/v2/guides/webhook-signing#events).
## Activated is not enabled
An activated connection is **available** to the app. Each [workflow](/v2/guides/verifications/workflows) still decides whether to **offer** it. New activations are enabled only in workflows with **Automatically enable newly activated connections** turned on. Elsewhere, enable them yourself.
# Credentials & Providers
Source: https://docs.hopae.com/v2/guides/concepts/connections/providers-and-credentials
The two layers behind every connection: what a credential proves, what a provider presents, and how the pairing shapes activation, assurance, and claims.
A [connection](/v2/guides/concepts/connections) pairs a **credential** with a **provider**. Keeping them separate is what lets the same identity be presented from more than one place, and what lets you reason about assurance and claims independently of the channel.
## Credentials
A **credential** is the verifiable identity artifact, the thing that actually proves who the user is. It determines **what** is proven:
* **Issuer**: the authority behind the identity (a government, a bank, an identity scheme).
* **Assurance**: the strength of the verification, surfaced as a [Level of Assurance](/v2/guides/verifications/assurance). A credential advertises the levels it can reach. The achieved level is reported per verification.
* **Claims**: the normalized attributes it can return (`name`, `birthdate`, `nationality`, …). See [Normalized User Data](/v2/guides/verifications/normalized-user-data).
* **Verification model**: `disclosure` or `match`. See [Verification Model](/v2/guides/concepts/verification-model).
* **Countries** and the **display name** end users see.
Credential ids are scoped and descriptive: `us-mdl` (US mobile driver's license), `cz-bankid-identify` (a BankiD CZ product tier), `id-nik-bio-match` (Indonesian NIK with biometric match).
### Credentials across eID types
| eID type | Example credential | Presented via (provider) |
| :- | :- | :- |
| **Type 1: Centralized IdP** | Smart-ID, MitID, Singpass | the identity service itself |
| **Type 2: Chip-based** | passport, EU ID card | an NFC-scanning app |
| **Type 3: Wallet** | US mDL, EUDI PID | a wallet (Google Wallet, Samsung Wallet, CA DMV Wallet) |
See [Types of eID](/v2/guides/concepts/digital-id-types).
### Product variants
Some credentials come in **variants** that differ in assurance or claims, such as BankiD CZ *Connect* / *Identify* / *Identify Plus*, Indonesia NIK *basic* / *biometric*. Each variant is its own credential, and therefore its own connection. Variants the end user cannot tell apart share one row in the hosted selection UI. Your workflow decides which variant runs. See [Workflows](/v2/guides/verifications/workflows).
## Providers
A **provider** is the holder or channel that presents a credential. It determines **where** the identity lives and **how** it is presented, and it owns the **activation procedure**, because it is the provider, not the document, that needs to know who is relying on it.
* **Direct** providers (most national-ID lookups, wallets like CA DMV Wallet or Samsung Wallet) activate instantly.
* **Registration** providers (Smart-ID, MitID, BankID Sweden, Google Wallet) collect a few company details and review them.
* **Contract** providers (Singpass, BankID Norway, eParaksts) need an agreement arranged with Hopae.
Provider ids are short slugs: `google-wallet`, `smart-id`, `cz-bankid`, `freja`.
## How the pairing plays out
| Relationship | Example | What it means for you |
| :- | :- | :- |
| **1 : 1** | `smart-id` ↔ Smart-ID | One connection. Activate it, done. |
| **one provider, many credentials** | Google Wallet → US mDL, passports, Aadhaar | One activation submission can cover several of the provider's connections at once. Each connection is still enabled per workflow. |
| **one credential, many providers** | US mDL ← Google Wallet, Samsung Wallet, CA DMV Wallet | Activate each provider path you want to offer. They can differ in activation type and mode availability. |
Activation happens per connection (grouped by provider when you submit), never for a whole provider. Activating Google Wallet for the US mDL does not make Google Wallet's passport connections available. See [Connection Activation](/v2/guides/concepts/connections/activation).
## Related
The unit you activate and verify againstDirect, registration, and contract activationHow assurance is determined per credentialThe claims a credential returns
# Custom Domain
Source: https://docs.hopae.com/v2/guides/concepts/custom-domain
Serve the hosted verification screens and OIDC endpoints from your own domain.
A **custom domain** puts the hosted flow on your brand's hostnames. Set it up in the Console under **Developers → Custom Domain**. Custom domains are available for **production apps**.
## Two hostnames
| Host | Serves | Example |
| :- | :- | :- |
| **Web Frontend URL** | The hosted verification screens | `verify.example.com` |
| **OIDC Backend URL** | The OIDC endpoints | `connect.example.com` |
The two hosts must be **different** and share the **same base domain**.
With an active domain, users start at `https:///v2/auth`.
The issuer does not change: ID tokens and the discovery document on your domain still say `https://connect.hopae.com`. Keep your library's issuer at `https://connect.hopae.com` and override only the authorization endpoint with `https:///v2/auth`.
## Set up
The Console shows the DNS records to add.
Routing (`CNAME`), ownership (`TXT`), and certificate validation records. A missing record keeps the domain from going live.
Keep the page open or click **Refresh**. The Console re-checks DNS and the certificate. Hopae does not check in the background and sends no webhook. A missing record keeps the domain in `awaiting_dns`.
## Statuses
```mermaid theme={null}
stateDiagram-v2
direction LR
[*] --> unconfigured
unconfigured --> awaiting_dns: enter hosts
awaiting_dns --> verifying: records found
verifying --> active: certificate issued
verifying --> invalid: provisioning failed
```
| Status | Meaning |
| :- | :- |
| `unconfigured` | No custom domain |
| `awaiting_dns` | Add the DNS records |
| `verifying` | Ownership confirmed, certificate being issued. Not live yet |
| `active` | Live |
| `invalid` | Provisioning failed repeatedly. Contact [support@hopae.com](mailto:support@hopae.com) |
Rely on the domain only once it is `active`. Until then, authorization requests to your OIDC host are redirected to Hopae's own host.
# Types of eID
Source: https://docs.hopae.com/v2/guides/concepts/digital-id-types
Overview of eID types supported by Hopae Connect: Centralized IdP, Chip-based, and Decentralized Wallet.
Hopae Connect supports three eID types. Here’s the quick overview you can rely on during integration.
An eID is modelled as two layers, a **credential** and a **provider**, so one credential can be presented from many apps or wallets. See [Connections](/v2/guides/concepts/connections).
| eID type | Examples | Hopae role |
| :- | :- | :- |
| **Type 1: Centralized IdP** | OIDC providers, national or customer IdPs | **Relay** (normalize + return original + evidence) |
| **Type 2: Chip-based** | Passport (ICAO 9303), eIDAS 1.0 card | **Verifier** (decode/decrypt, trust registry check, normalize + evidence) |
| **Type 3: Wallet** | EUDI / eIDAS 2.0 wallet, ISO 18013 mDL | **Verifier** (validate VP, trust registry check, normalize + evidence) |
### Type 1: Centralized IdP
Hopae Connect integrates with centralized identity providers to authenticate the user in real time.
* **User**: Completes OTP, PIN, ID/PW, or FIDO challenge at the provider.
* **Hopae**: Relays the provider response, normalizes attributes, returns original data + evidence.
```mermaid theme={null}
flowchart LR
U[User] -->|Request| P[Platform]
P -->|Request| H[h.Connect]
H -->|Request| I[ID Provider]
I -->|Generate Response + evidence| H
H -->|Normalized Result| P
classDef node fill:#ECECEC,stroke:#222,color:#111;
class U,P,H,I node
```
### Type 2: Chip-based
Chip-based credentials rely on NFC and trust registries to validate secure elements in documents.
* **User**: Unlocks the chip (PIN or CAN) and scans the document via NFC.
* **Hopae**: Decodes/decrypts the payload, verifies status with ICAO PKD or eIDAS registry, normalizes data, returns original + evidence.
```mermaid theme={null}
flowchart LR
U[User] -->|Request| P[Platform]
P -->|Request| H[h.Connect]
H -->|Scan NFC| C[Passport or ID]
C -->|Return Response| H
H -->|Verify Remote| T[Trust Registry ICAO PKD / eIDAS]
H -->|Normalize Response + Generate Evidence| P
classDef node fill:#ECECEC,stroke:#222,color:#111;
class U,P,H,C,T node
```
### Type 3: Decentralized Wallet
Decentralized wallet flows use verifiable presentations from user-controlled wallets.
* **User**: Approves in-wallet consent (PIN or biometric) after an OID4VP request.
* **Hopae**: Validates the presentation, checks registry status, normalizes data, returns original + evidence.
```mermaid theme={null}
flowchart LR
U[User] -->|Request| P[Platform]
P -->|Request| H[h.Connect]
H -->|Request OID4VP| W[Wallet]
W -->|Generate Response| H
H -->|Verify Remote| T[Trust Registry eIDAS 2.0 Registry]
H -->|Validate Response + Generate Evidence| P
classDef node fill:#ECECEC,stroke:#222,color:#111;
class U,P,H,W,T node
```
# Sandbox Testing
Source: https://docs.hopae.com/v2/guides/concepts/testing/sandbox-test
Test with a sandbox app: instant activation, provider test credentials, and simulated verifications.
A sandbox app runs verifications with test identities. No real eIDs, no real users, no billing.
In the [Console](https://console.hopae.com), create a **sandbox** app. **Quickstart** activates the sandbox connections and creates a default workflow. **Start from scratch** leaves the setup to you.
Under **Configuration → Connections**, activate what you need. In Sandbox nothing waits for a review: see [Activate Connections](/v2/guides/concepts/connections/activation#activation-types). Use **Sample Fill** to complete company forms with demo values.
Some providers only work with a registered test identity, such as a demo-app account or a whitelisted phone. Add a tester under **Verification → Tester Info**: name, an app-store email per device, phone, and photo. Hopae requests the credentials for you.
Provisioning can take **several days**. Request credentials early.
Open a connection in the Connections table and switch to its **Testing Guide** tab. It lists what the provider needs and how to run a test.
Open the workflow's verification link (**Verification → Workflow**), or call your own integration with the sandbox app's credentials. The hosted flow shows a **Sandbox Mode** banner. For connections with a Testing Guide, its **Tester Info** button opens it.
## What to test
* Success, user cancellation, provider failure, and expiry (30 minutes)
* Redirect URIs, `state` and `nonce` handling (OIDC)
* The claims you requested, including `missing_claims`
* Webhook delivery and duplicate handling
Tester Info and Testing Guides exist only in Sandbox. A few connections exist only in Sandbox (for example `dk-altid-eudi-pid`). The Connections table marks them **Sandbox Only**.
Next: [Go Live](/v2/guides/getting-started/go-live).
# Verification Model
Source: https://docs.hopae.com/v2/guides/concepts/verification-model
How the disclosure and match verification models shape the response you read
## Overview
Every verification result carries a top-level `verification_model` field that tells you **which question the provider answered**, and therefore which part of the response to read. It is always present.
There are two models:
* **`disclosure`**: the provider *discloses* user attributes from an authoritative source. You read who the user is.
* **`match`**: the provider *matches* values you submitted against the authoritative source and returns a pass/fail outcome. You read whether your values were confirmed.
The model is a property of the **connection**. [Get Connections](/v2/api-reference/verifications/get-connections) returns it as `credential.verificationModel` for every connection activated for your app, and [Create Verification](/v2/api-reference/verifications/create-verification) echoes it so you know which shape to expect before the user has done anything.
## `disclosure` model
The default for classic eID providers and digital wallets. The provider returns verified personal attributes.
* Read identity attributes from `user.*` (for example, `user.name`, `user.birthdate`).
* `provenance` describes how the identity was verified.
* Claims the workflow requested but the provider could not supply appear in `missing_claims`.
Some disclosure connections need a lookup key before they can start (a registration id, a phone number). Send it in `userInput`. The connection's `userInputSchema` lists the fields. See [User Input](/v2/api-reference/verifications/user-input).
## `match` model
Rather than disclosing attributes, the provider compares values **you** submit against the source of truth and returns a pass/fail outcome.
* **Input: `userInput`.** Supply the values to check when you create the verification, keyed by [OIDC-normalized names](/v2/guides/verifications/normalized-user-data). The same single map carries any lookup key the provider needs. Hopae decides which values are compared and which select the record. The connection's `userInputSchema` tells you which fields are required.
* **Outcome: `match.*`.**
* `match.matched`: the overall result, `true` only when **all** submitted fields matched.
* `match.granularity`: `per_field` or `aggregate` (also advertised as `credential.matchGranularity` on the connection).
* `match.submitted_fields`: the fields you submitted.
* `match.details`: per-field results (`matched`, `submitted_value`, optionally `similarity` 0 to 100). Populated for `per_field`. For `aggregate` the per-field flags are omitted. See [Return Data](/v2/guides/verifications/return-data-model) for the schema.
* **Echoed `user`.** `user` carries only the **verified subset** of your submitted fields, restricted to the connection's recognized match fields, primitive values only:
* `per_field` → fields whose `matched` is `true`.
* `aggregate` → every submitted field when `match.matched` is `true`. Otherwise none.
* **Partial or failed matches.** A field the provider does not confirm counts as not matched. If the match cannot be computed at all, the verification **fails** instead of returning a result.
* **Dual providers.** Some match connections can *also* disclose provider-asserted claims under `user` when the workflow requests them. On a key collision the disclosed value wins, and the result still reports `verification_model: "match"`.
Every match connection in the catalog today uses `per_field` granularity. `aggregate` is the all-or-nothing fallback used when a provider reports only an overall result.
## Comparison
| | `disclosure` | `match` |
| - | - | - |
| **Question answered** | "Who is this user?" | "Do these values match the source?" |
| **Read result from** | `user.*` and `provenance` | `match.*` and `provenance` |
| **`user` contains** | Disclosed PII | Verified subset of your submitted fields |
| **Your input** | Claims chosen in the workflow (plus a lookup key in `userInput` when the connection needs one) | The values to compare, in `userInput` |
| **Typical connections** | Classic eIDs, digital wallets | National registry lookups such as `br-cpf-cnh-match`, `id-nik-match` |
Both models return the full `provenance` block, so you always have the verification audit trail. For match flows, the per-credential `provenance` entries carry the source's own confirmation response (for example per-field booleans) rather than the attribute values. For the complete payload schema and the field-by-field `match` reference, see [Return Data](/v2/guides/verifications/return-data-model).
## Related
* [Connections](/v2/guides/concepts/connections): where the verification model is defined
* [Workflows](/v2/guides/verifications/workflows): configure claims, connections, and branding for a verification
* [Return Data](/v2/guides/verifications/return-data-model): the full UserInfo payload and `match` schema
* [Create Verification](/v2/api-reference/verifications/create-verification): supply `userInput` for match flows
* [Normalized User Data](/v2/guides/verifications/normalized-user-data): the OIDC-normalized keys used in `userInput`
# Provenance
Source: https://docs.hopae.com/v2/guides/data-types/provenance
Provenance shape and copy-paste types for /userinfo responses.
**Scope**
* OIDC: `GET /v2/userinfo`
* REST API: `GET /connect/v2/verifications/{id}/userinfo`
## Provenance (shape)
```ts theme={null}
type ProvenanceShape = {
presentation: {
credentials: Array<{
claims: Record // the source's own response, as received (match connections too)
evidence?: {
token?: Record
names?: string // semicolon-delimited list of keys present under token
}
}>
}
_metadata: {
provider_id: string
connection_id?: string
credential_id?: string
connection_instance_id: string
verification_id: string
verified_at?: string
status: string
error?: { code: string; message?: string } // only when the session ended with an error (e.g. loa_insufficient)
}
}
```
The connection that produced the credential is identified by `provider_id`, `connection_id`, `credential_id`, and `connection_instance_id` inside `provenance._metadata`. These are not top-level userinfo fields. New sessions include all four. Older sessions created before identity snapshots may omit `connection_id` or `credential_id` until they expire.
Learn more: Provenance
## Copy & Paste Types
```ts theme={null}
export interface Proof {
presentation: Presentation
_metadata: Metadata
}
export interface Presentation {
credentials: Credential[]
}
export interface Credential {
claims: Record // the source's own response, as received
evidence?: Evidence // may or may not be present
}
export interface Evidence {
token?: Record
names?: string // semicolon-delimited list of keys under token
}
export interface Metadata {
provider_id: string
connection_id?: string
credential_id?: string
connection_instance_id: string
verification_id: string
verified_at?: string // ISO8601
status: string // session status at read time (e.g., completed)
error?: { code: string; message?: string }
}
```
```go theme={null}
package models
type Proof struct {
Presentation Presentation `json:"presentation"`
Metadata Metadata `json:"_metadata"`
}
type Presentation struct {
Credentials []Credential `json:"credentials"`
}
type Credential struct {
Claims map[string]any `json:"claims"` // the source's own response, as received
Evidence *Evidence `json:"evidence,omitempty"`
}
type Evidence struct {
Token map[string]any `json:"token,omitempty"` // provider-supplied key/value pairs
Names string `json:"names,omitempty"` // semicolon-delimited key list
}
type MetadataError struct {
Code string `json:"code"`
Message string `json:"message,omitempty"`
}
type Metadata struct {
ProviderID string `json:"provider_id"`
ConnectionID string `json:"connection_id,omitempty"`
CredentialID string `json:"credential_id,omitempty"`
ConnectionInstanceID string `json:"connection_instance_id"`
VerificationID string `json:"verification_id"`
VerifiedAt string `json:"verified_at,omitempty"`
Status string `json:"status"`
Error *MetadataError `json:"error,omitempty"`
}
```
```csharp theme={null}
using System.Collections.Generic;
using System.Text.Json.Serialization;
public class Proof {
[JsonPropertyName("presentation")] public Presentation Presentation { get; set; } = default!;
[JsonPropertyName("_metadata")] public Metadata Metadata { get; set; } = default!;
}
public class Presentation {
[JsonPropertyName("credentials")] public List Credentials { get; set; } = new();
}
public class Credential {
[JsonPropertyName("claims")] public Dictionary? Claims { get; set; } // the source's own response, as received
[JsonPropertyName("evidence")] public Evidence? Evidence { get; set; } // may or may not be present
}
public class Evidence {
[JsonPropertyName("token")] public Dictionary? Token { get; set; } // provider-supplied key/value pairs
[JsonPropertyName("names")] public string? Names { get; set; } // semicolon-delimited key list
}
public class MetadataError {
[JsonPropertyName("code")] public string Code { get; set; } = default!;
[JsonPropertyName("message")] public string? Message { get; set; }
}
public class Metadata {
[JsonPropertyName("provider_id")] public string ProviderId { get; set; } = default!;
[JsonPropertyName("connection_id")] public string? ConnectionId { get; set; }
[JsonPropertyName("credential_id")] public string? CredentialId { get; set; }
[JsonPropertyName("connection_instance_id")] public string ConnectionInstanceId { get; set; } = default!;
[JsonPropertyName("verification_id")] public string VerificationId { get; set; } = default!;
[JsonPropertyName("verified_at")] public string? VerifiedAt { get; set; }
[JsonPropertyName("status")] public string Status { get; set; } = default!;
[JsonPropertyName("error")] public MetadataError? Error { get; set; }
}
```
```java theme={null}
import java.util.List;
import java.util.Map;
public class Proof {
public Presentation presentation;
public Metadata _metadata;
}
public class Presentation {
public List credentials;
}
public class Credential {
public Map claims; // the source's own response, as received
public Evidence evidence; // May or may not be present
}
public class Evidence {
public Map token; // Provider-supplied key/value pairs
public String names; // Semicolon-delimited list of keys under token
}
public class MetadataError {
public String code;
public String message;
}
public class Metadata {
public String provider_id;
public String connection_id; // optional for older sessions
public String credential_id; // optional for older sessions
public String connection_instance_id;
public String verification_id;
public String verified_at; // ISO8601
public String status; // session status at read time
public MetadataError error; // only when the session ended with an error
}
```
## Notes
* `claims` keeps the source's native field names and can vary by connection. Treat unknown fields as opaque.
* `evidence` shape varies by provider. Most use `{ token, names }`. Wallet credentials (mdoc, Verifiable Presentation) place fields such as `issuerAuth` or `vpClaims` directly on `evidence`. See [Evidence & Data Integrity](/v2/guides/verifications/evidence).
# User
Source: https://docs.hopae.com/v2/guides/data-types/user
User object shape and copy‑paste types for OIDC/REST /userinfo responses.
**Scope**
* OIDC: `GET /v2/userinfo`
* REST API: `GET /connect/v2/verifications/{id}/userinfo`
The fields present depend on the claims the [workflow](/v2/guides/verifications/workflows) requested (its **Claims** tab) and on what the source could provide. Anything requested but missing is listed in `missing_claims`.
## User (shape)
```ts theme={null}
// TS-like structural shape (snake_case keys)
type UserShape = {
// Profile
name?: string
given_name?: string
family_name?: string
middle_name?: string
preferred_username?: string
nickname?: string
picture?: string
// Personal details
birthdate?: string // YYYY-MM-DD
gender?: string
nationality?: string // ISO-3166-1 alpha-2
// Contacts
email?: string
email_verified?: boolean
phone_number?: string // E.164
phone_number_verified?: boolean
// Address (OIDC Address Claim)
address?: {
formatted?: string
street_address?: string
locality?: string
region?: string
postal_code?: string
country?: string
[k: string]: unknown // provider-specific extensions
}
// The source's own identifier for this person (opt-in: "Source ID" on the workflow's Claims tab).
// Not every connection provides one, and it can be missing for a given verification — check missing_claims.
source_id?: string
[k: string]: unknown // provider-specific extensions
}
```
## Copy & Paste Types
```ts theme={null}
export interface User {
// Profile
name?: string
given_name?: string
family_name?: string
middle_name?: string
preferred_username?: string
nickname?: string
picture?: string
// Personal details
birthdate?: string // YYYY-MM-DD
gender?: string
nationality?: string // ISO-3166-1 alpha-2
// Contacts
email?: string
email_verified?: boolean
phone_number?: string
phone_number_verified?: boolean
// Address (OIDC Address Claim)
address?: {
formatted?: string
street_address?: string
locality?: string
region?: string
postal_code?: string
country?: string
[key: string]: any
}
// The source's own identifier for this person (opt-in; may be absent — see missing_claims)
source_id?: string
[key: string]: any
}
```
```go theme={null}
package models
type User struct {
// Profile
Name string `json:"name,omitempty"`
GivenName string `json:"given_name,omitempty"`
FamilyName string `json:"family_name,omitempty"`
MiddleName string `json:"middle_name,omitempty"`
PreferredUsername string `json:"preferred_username,omitempty"`
Nickname string `json:"nickname,omitempty"`
Picture string `json:"picture,omitempty"`
// Personal details
Birthdate string `json:"birthdate,omitempty"`
Gender string `json:"gender,omitempty"`
Nationality string `json:"nationality,omitempty"`
// Contacts
Email string `json:"email,omitempty"`
EmailVerified *bool `json:"email_verified,omitempty"`
PhoneNumber string `json:"phone_number,omitempty"`
PhoneNumberVerified *bool `json:"phone_number_verified,omitempty"`
// Address
Address *Address `json:"address,omitempty"`
// The source's own identifier for this person (opt-in; may be absent — see missing_claims)
SourceID string `json:"source_id,omitempty"`
}
type Address struct {
Formatted string `json:"formatted,omitempty"`
StreetAddress string `json:"street_address,omitempty"`
Locality string `json:"locality,omitempty"`
Region string `json:"region,omitempty"`
PostalCode string `json:"postal_code,omitempty"`
Country string `json:"country,omitempty"`
}
```
```csharp theme={null}
using System.Text.Json.Serialization;
public class User {
// Profile
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("given_name")] public string? GivenName { get; set; }
[JsonPropertyName("family_name")] public string? FamilyName { get; set; }
[JsonPropertyName("middle_name")] public string? MiddleName { get; set; }
[JsonPropertyName("preferred_username")] public string? PreferredUsername { get; set; }
[JsonPropertyName("nickname")] public string? Nickname { get; set; }
[JsonPropertyName("picture")] public string? Picture { get; set; }
// Personal details
[JsonPropertyName("birthdate")] public string? Birthdate { get; set; }
[JsonPropertyName("gender")] public string? Gender { get; set; }
[JsonPropertyName("nationality")] public string? Nationality { get; set; }
// Contacts
[JsonPropertyName("email")] public string? Email { get; set; }
[JsonPropertyName("email_verified")] public bool? EmailVerified { get; set; }
[JsonPropertyName("phone_number")] public string? PhoneNumber { get; set; }
[JsonPropertyName("phone_number_verified")] public bool? PhoneNumberVerified { get; set; }
// Address
[JsonPropertyName("address")] public Address? Address { get; set; }
// The source's own identifier for this person (opt-in; may be absent — see missing_claims)
[JsonPropertyName("source_id")] public string? SourceId { get; set; }
}
public class Address {
[JsonPropertyName("formatted")] public string? Formatted { get; set; }
[JsonPropertyName("street_address")] public string? StreetAddress { get; set; }
[JsonPropertyName("locality")] public string? Locality { get; set; }
[JsonPropertyName("region")] public string? Region { get; set; }
[JsonPropertyName("postal_code")] public string? PostalCode { get; set; }
[JsonPropertyName("country")] public string? Country { get; set; }
}
```
```java theme={null}
public class User {
// Profile
public String name;
public String given_name;
public String family_name;
public String middle_name;
public String preferred_username;
public String nickname;
public String picture;
// Personal details
public String birthdate; // YYYY-MM-DD
public String gender;
public String nationality; // ISO-3166-1 alpha-2
// Contacts
public String email;
public Boolean email_verified;
public String phone_number;
public Boolean phone_number_verified;
// Address
public static class Address {
public String formatted;
public String street_address;
public String locality;
public String region;
public String postal_code;
public String country;
}
public Address address;
// The source's own identifier for this person (opt-in; may be absent — see missing_claims)
public String source_id;
}
```
## Notes
* Providers may include additional properties under `user.*` (credential-specific claims).
* For `match` connections, `user` contains only the verified subset of the values you submitted in `userInput`. See [Verification Model](/v2/guides/concepts/verification-model).
* `sub` is **not** part of `user` and is not a stable identifier. It is the verification id. Use `source_id` to recognize a returning person.
# FAQ
Source: https://docs.hopae.com/v2/guides/faq
Short answers to common questions, each with a link to the details.
OIDC if you want Hopae to host the verification screens (fastest). REST if you need your own UI. See [Choose an Integration](/v2/guides/integration-methods).
**Developers → API Settings** of the app: the App ID and App Secret. Sandbox and production apps have separate credentials. See [Authentication](/v2/api-reference/authentication).
Use the other app's credentials. The URLs are the same. See [Apps & Environments](/v2/guides/concepts/apps).
One credential through one provider, such as Smart-ID or a US mDL in Google Wallet. See [Connections](/v2/guides/concepts/connections).
Direct: immediately. Registration: a provider review in production, instant in Sandbox. Contract: after an onboarding call. See [Activate Connections](/v2/guides/concepts/connections/activation).
It is not available for this app's environment, for example a production-only connection in a sandbox app.
Activate a connection and enable it in the workflow you run. See [Workflows](/v2/guides/verifications/workflows).
On the workflow's **Claims** tab. See [Workflows](/v2/guides/verifications/workflows).
Tick **Source ID** on the workflow's **Claims** tab and read `user.source_id`. See [Return Data](/v2/guides/verifications/return-data-model#which-claims-you-receive).
`disclosure` returns the user's attributes. `match` confirms values you sent. See [Verification Model](/v2/guides/concepts/verification-model).
Send `acr_values` (OIDC) or add a `check-min-loa` node. See [Assurance](/v2/guides/verifications/assurance).
Use the credentials of the app that created it. Another app's verification, or one past its `expiresAt`, returns `404`. See [Expiry](/v2/guides/verifications/verification-flow#expiry).
Use a sandbox app and, where a provider needs it, request test credentials under **Tester Info**. See [Sandbox Testing](/v2/guides/concepts/testing/sandbox-test).
Yes. It uses real credentials and real provider traffic. See [Go Live](/v2/guides/getting-started/go-live).
No. Hopae is API-first: your backend calls Hopae and your app performs the user step. See [Mobile App Integration](/v2/guides/mobile-app-integration).
Yes. See [Custom Domain](/v2/guides/concepts/custom-domain).
See [Webhooks](/v2/guides/webhook-signing#verify-the-signature).
Still stuck? Email [support@hopae.com](mailto:support@hopae.com).
# Go Live
Source: https://docs.hopae.com/v2/guides/getting-started/go-live
Take an integration from a sandbox app to production traffic.
You build with a **sandbox app**, then repeat the setup in a separate **production app** and test it with real users before opening it up.
Your integration works end to end with a sandbox app, including failure, cancellation, and expiry. See [Sandbox Testing](/v2/guides/concepts/testing/sandbox-test).
Production apps require an **Enterprise** subscription. Contact Hopae to upgrade. Nothing is copied from the sandbox app: create a production app in the [Console](https://console.hopae.com) and copy its App ID and App Secret. The URLs stay the same. See [Apps & Environments](/v2/guides/concepts/apps).
Activate every connection you need. Registration connections wait for the provider's review, and contract connections need an onboarding call, so start early. See [Activate Connections](/v2/guides/concepts/connections/activation).
Set up the workflow, the redirect URIs, and webhooks, as in Sandbox. Saves take effect immediately.
Run real verifications with internal users or a pilot group. Test each connection on its own: one working connection of a provider says nothing about another.
Production traffic uses real identity data and is billed.
Check the list below, then expand access.
## Launch checklist
* Every connection you need shows **Activated** in the production app and is enabled in its workflow.
* Claims, assurance level, and countries are confirmed for each connection.
* Success, cancellation, failure, and expiry paths pass in production.
* The production App ID and App Secret are in use, and the secret lives only on your backend.
* Every callback is in the production app's **Redirect URL Allowlist**.
* OIDC: `state`, `nonce`, `iss`, and the ID token signature are validated.
* Webhooks: signatures are verified and duplicate deliveries are handled.
* A [custom domain](/v2/guides/concepts/custom-domain), if you use one, is `active`.
* Alerts cover provider errors and webhook failures.
* Owners for support and incidents are named.
* Billing reports match your own counts.
# Welcome
Source: https://docs.hopae.com/v2/guides/getting-started/introduction
Hopae Connect verifies identities with eIDs from around the world through one integration.
Hopae Connect is one API for identity verification with global eIDs: government IDs, bank IDs, and digital wallets. You integrate once. Hopae handles each provider.
## How it works
An app holds your credentials and configuration. Start with a **sandbox app**. See [Apps & Environments](/v2/guides/concepts/apps).
A connection is one eID you can verify with, such as Smart-ID or a US mDL in Google Wallet. See [Connections](/v2/guides/concepts/connections).
The workflow decides which connections are offered and which data you receive. See [Workflows](/v2/guides/verifications/workflows).
Use the hosted **OIDC** flow or the **REST API**. See [Choose an Integration](/v2/guides/integration-methods).
## Where to start
Run a verification in a few minutes
From sandbox to production traffic
What changes for a v1 integration
OIDC endpoints and the Verification REST API
# Quickstart
Source: https://docs.hopae.com/v2/guides/getting-started/quickstart
Run your first verification in a few minutes with a sandbox app: from the Console without code, or with three API calls.
You will verify a Smart-ID test identity with a sandbox app. No sample project, no redirect setup.
## 1. Create a sandbox app
In the [Console](https://console.hopae.com), create a **sandbox** app and choose **Quickstart**. Quickstart activates every connection available in Sandbox and creates a default workflow, so the app is ready to verify.
## 2. Run a verification
1. Open **Verification → Workflow** and open the workflow's **verification link**.
2. In the hosted flow, choose **Lithuania**, then **Smart-ID**.
3. Enter the test registration id `PNOLT-40504040001`.
When the verification finishes, the Console shows the data your app would receive.
Copy the **App ID** and **App Secret** from **Developers → API Settings**, then:
```bash Create theme={null}
curl -X POST "https://api.hopae.com/connect/v2/verifications" \
-u "APP_ID:APP_SECRET" \
-H "Content-Type: application/json" \
-d '{"connectionId": "smart-id", "userInput": {"registrationId": "PNOLT-40504040001"}}'
```
The response has a `verificationId` and `status: "awaiting_user_action"`. The test identity needs no phone. It answers within a few seconds. Check the status:
```bash Status theme={null}
curl "https://api.hopae.com/connect/v2/verifications/VERIFICATION_ID" \
-u "APP_ID:APP_SECRET"
```
Repeat until `status` is `completed`, then read the result:
```bash Result theme={null}
curl "https://api.hopae.com/connect/v2/verifications/VERIFICATION_ID/userinfo" \
-u "APP_ID:APP_SECRET"
```
```json theme={null}
{
"user": { "name": "OK TESTNUMBER", "birthdate": "1905-04-04", "nationality": "LT" },
"hopae_loa": 4,
"verification_model": "disclosure",
"sub": "019bc4f2-8a31-7c5e-9d02-4f7a1b3e60d8"
}
```
## Next steps
Hosted OIDC or the REST API
Choose the data you receive
Test identities for other providers
The path to production traffic
# Choose an Integration
Source: https://docs.hopae.com/v2/guides/integration-methods
Use the hosted OIDC flow for the fastest integration, or the REST API when you need your own UI.
Both methods use the same [connections](/v2/guides/concepts/connections), [workflows](/v2/guides/verifications/workflows), and returned data. They differ in who owns the user interface.
Redirect the user to Hopae. Hopae runs country and provider selection and every flow type, then returns a code. **Start here.**
Your backend creates the verification and your UI shows the QR code, redirect, or waiting screen for each flow type.
| | Hosted OIDC | REST API |
| :- | :- | :- |
| **User interface** | Hosted by Hopae, with your [branding](/v2/guides/concepts/branding) | Yours |
| **Flow types** | All handled for you | You handle each `flowType` |
| **Result** | Authorization code → token → `/userinfo` | Poll status → `/userinfo` |
| **Libraries** | Any OIDC client library | Plain HTTP |
| **Effort** | Low | Higher |
Building a native app or embedding Hopae in your own IDV SDK? Your backend calls Hopae. The app only performs the user step. See [Mobile App Integration](/v2/guides/mobile-app-integration).
# Migrate from v1
Source: https://docs.hopae.com/v2/guides/migration/v1-to-v2
Move a v1 integration to the v2 API with a few small changes.
Your v1 integration keeps working while you move. Use your app's **App ID** and **App Secret** from **Developers → API Settings**.
## What changes in your code
| | v1 | v2 |
| :- | :- | :- |
| **REST base URL** | `https://api.hopae.com/connect/v1` (sandbox: `sandbox.api.hopae.com`) | `https://api.hopae.com/connect/v2` for every app |
| **OIDC authorization** | `/auth` | `/v2/auth` |
| **Which eID** | `providerId: "smartid"` | `connectionId: "smart-id"` |
| **Which data** | `requestedClaims` in each request | The workflow's **Claims** tab in the Console |
| **User input** | `userData`, `matchData` | One `userInput` map, same keys |
| **Minimum assurance** | `requestedLoa` | `acr_values` (OIDC) or a [`check-min-loa` node](/v2/guides/reference/workflow-nodes) |
Sandbox and production apps use the same v2 URLs. The credentials select the app. See [Apps & Environments](/v2/guides/concepts/apps).
The OIDC issuer for v2 is `https://connect.hopae.com` for every app. If your sandbox integration validated `https://sandbox.connect.hopae.com`, update it when you move to `/v2/auth`. See [OIDC Integration](/v2/guides/oidc-integration#endpoints).
```json v1 request theme={null}
{ "providerId": "smartid", "requestedClaims": ["name", "birthdate"], "userData": { "registrationId": "PNOEE-38001085718" } }
```
```json v2 request theme={null}
{ "connectionId": "smart-id", "userInput": { "registrationId": "PNOEE-38001085718" } }
```
## What changes in the result
`user` and `match` are unchanged. Check these details:
* `provenance` and `missing_claims` are opt-in: add `provenance=true` and `missing_claims=true` to the userinfo request.
* Provider ids are catalog ids (`smart-id`, not `smartid`), and there is no `amr`. The connection that ran is in `provenance._metadata`.
* `provenance.presentation.credentials[]` holds only the source's `claims` and `evidence`.
* `source_id` is returned only when **Source ID** is ticked on the workflow's **Claims** tab.
* Errors use one envelope: `{ "error": { "code", "message" }, "request_id" }`.
See [Return Data](/v2/guides/verifications/return-data-model).
## Webhooks
Turn on the **Webhook v2** endpoint under **Developers → Webhooks** when your receiver handles the v2 format, then turn **Webhook v1** off. You can run both while you switch. See [Webhooks](/v2/guides/webhook-signing).
## Step by step
Look up the connection ID for each v1 provider you use (below), and check that it is activated and enabled in the workflow.
Move the claims you sent in `requestedClaims` to the workflow's **Claims** tab. See [Workflows](/v2/guides/verifications/workflows).
Apply the changes above and run the flow with test credentials. See [Sandbox Testing](/v2/guides/concepts/testing/sandbox-test).
Deploy the same changes with your production app's credentials.
## Finding your connections
A v1 `providerId` does not map 1:1 to a v2 `connectionId`. A v1 provider corresponds to a v2 **provider**, and a provider offers one connection per credential it can present. Some v1 providers covered several credentials at once (for example `us-id-pass` covered Google Wallet passports from several countries and Aadhaar), so they become several connections in v2.
To find yours:
1. Open **Configuration → Connections** in the Console and filter by provider or country.
2. Pick the row whose credential matches what your v1 integration verified. Its **Connection ID** is the value you use.
3. Activate it, and repeat for every credential you need.
The same information is available from [Get Connections](/v2/api-reference/verifications/get-connections): each item carries `provider.id`, `credential.id`, and `connectionId`.
If you are unsure which connection replaces a v1 provider, ask [support@hopae.com](mailto:support@hopae.com) with the v1 `providerId` and we will map it for you.
# Mobile App Integration
Source: https://docs.hopae.com/v2/guides/mobile-app-integration
Integrate Hopae Connect into native mobile apps and existing IDV SDKs using an API-first architecture.
Hopae Connect is API-first. If your product already ships an iOS, Android, React Native, or Expo SDK, keep that client experience and call Hopae Connect through your backend.
Hopae does not publish a separate mobile SDK. The [Expo repository](https://github.com/hopae-official/hconnect-expo-sdk) is a reference example, not an SDK contract. Use the Hopae Connect API reference as the source of truth.
## Recommended architecture
For an embedded IDV experience, use this boundary:
```mermaid theme={null}
sequenceDiagram
participant App as Your mobile app or SDK
participant Backend as Your backend
participant Hopae as Hopae Connect API (/connect/v2)
participant Wallet as Browser, wallet, or OS credential UI
App->>Backend: Start verification
Backend->>Hopae: POST /verifications (connectionId, userInput)
Hopae-->>Backend: verificationId + flowType + flowDetails
Backend-->>App: Short-lived flow data
App->>Wallet: Launch the required user interaction
Wallet-->>App: Return via callback URL
App->>Backend: Resume session
Backend->>Hopae: GET /verifications/{id} (poll), then /userinfo
Hopae-->>Backend: Verified user data and provenance
Backend-->>App: Application result
```
Your mobile app or SDK owns the user experience. Your backend owns the App ID and App Secret, lists connections, creates and polls verification sessions, and retrieves verified data.
Never embed the App Secret in a mobile binary, JavaScript bundle, browser storage, or public repository.
### Hosted OIDC alternative
You can also start the [hosted OIDC flow](/v2/guides/oidc-integration) from a native app. Use a platform browser session such as `ASWebAuthenticationSession` on iOS or a Custom Tab on Android against `https://connect.hopae.com/v2/auth`, then return to your app through a registered callback URI. Register the app's custom scheme in its Redirect URL Allowlist. Avoid embedded WebViews for authentication: several providers (BankID Sweden, AusweisApp, Google Wallet credentials, …) cannot run inside one.
## Choose your application boundary
An Application is a relying-party and configuration boundary. It does not have to map one-to-one to a platform or legal entity. Each application is either sandbox or production, with its own configuration and credentials. See [Apps & Environments](/v2/guides/concepts/apps).
Create separate Web and Mobile Applications when they need different:
* App Secrets or redirect URIs
* workflows or enabled connections
* security or data-access boundaries
* Sandbox and production rollout schedules
* operational owners or analytics
* legal-entity or relying-party registrations
Web, iOS, and Android can share one Application when those settings and responsibilities are genuinely the same. Split iOS and Android as well when their platform support, workflow, or release schedule differs.
## Handle mobile flow types
Your backend returns `flowType` and `flowDetails` from [Create Verification](/v2/api-reference/verifications/create-verification). Your app performs the user-facing action.
| `flowType` | Mobile handling |
| - | - |
| `redirect` (or hosted OIDC) | Open `flowDetails.authorizationUrl` in a secure system browser session and resume the app through an allowlisted callback URI. |
| `qr` | On the same device, open `flowDetails.linkData`. It launches the eID app and returns the user. Use `qrData` only for cross-device journeys (a QR rendered on another screen). |
| `push` | Show `flowDetails.verificationCode` and a waiting state while your backend polls. Handle cancellation and expiry. |
| `query` | Nothing on the device. The create response is already `completed` or `failed`. Your backend fetches `userinfo`. |
| `dc` (digital credential) | Open `flowDetails.linkData` in the **system browser** (Chrome 141+ on Android, Safari 26+ on iOS) with `&return_url=` appended. The hosted page invokes the wallet through the Digital Credentials API, submits the response to Hopae, and redirects to `return_url`. The Digital Credentials API is not available inside WebViews. |
Full responses for each flow type: [Flow Types](/v2/guides/reference/flow-types).
Availability depends on the connection, platform, and app environment. Confirm the complete combination in Sandbox before a billable production test.
## Same-device journeys
A QR displayed on a phone cannot be scanned by that same phone. Design a separate same-device path:
1. Detect that the verification started on a mobile device.
2. Use `flowDetails.linkData` (QR and DC flows) or `authorizationUrl` (redirect flows) instead of rendering a QR.
3. Preserve the `verificationId` before leaving your app.
4. Resume through an allowlisted callback and reject mismatched or replayed state.
5. Offer a cross-device fallback (render `qrData`) when the user is on a device without the eID app.
Do not transform `qrData` into a link yourself. `linkData` is the supported same-device entry point.
## Existing SDKs and JavaScript
If you already expose an IDV SDK, it can call your API exactly as it does for your other verification methods. Your API then calls Hopae Connect. You do not need to embed a Hopae client library in the mobile SDK.
A pure JavaScript wrapper can reduce HTTP and redirect boilerplate, but it cannot replace native platform APIs in every iOS and Android flow. Treat JavaScript and Expo code as examples around the API contract, with small platform adapters where a system browser or OS credential UI is required.
## Network connectivity
Hopae Connect is an online identity-verification and orchestration service. A verification requires network connectivity to create or resume the session, coordinate the flow, validate or submit the result, and return normalized evidence.
Some underlying digital credentials can support offline or proximity presentation. That capability does not make the Hopae Connect service offline. Offline verification is outside the current product scope.
## Mobile testing checklist
Test each required connection on every supported platform and app environment.
* Use physical devices for wallet, NFC, camera, app-link, and biometric behavior.
* Test both same-device and cross-device journeys.
* Test cancellation, timeout (30-minute session TTL), missing credential, app backgrounding, and process restart.
* Confirm callback and universal/app-link routing for every release build, and that the callback scheme is allowlisted per app.
* Verify that no secret or sensitive response is logged by the app.
* Complete a controlled, billable production test before launch. See [Go Live](/v2/guides/getting-started/go-live).
## Troubleshooting information
When asking Hopae for help, include:
* Application (App ID/client ID) and environment (sandbox / production)
* `connectionId` and `connectionInstanceId`
* iOS or Android version and device model
* your app version
* `verificationId` and timestamp
* integration method and observed `flowType`
* sanitized error (`error.code`, `request_id`) and relevant callback details
Do not send the App Secret, API keys, private test credentials, or unredacted personal data.
# OIDC Integration
Source: https://docs.hopae.com/v2/guides/oidc-integration
Integrate the hosted verification flow with the standard OpenID Connect Authorization Code flow.
Hopae Connect is an OpenID Provider. You redirect the user to Hopae, Hopae runs the verification, and your backend exchanges the returned code for the verified data. Any certified OIDC client library works.
```mermaid theme={null}
sequenceDiagram
participant U as User
participant A as Your app
participant H as Hopae
A->>U: Redirect to /auth
U->>H: Choose an eID and verify
H->>A: Redirect to redirect_uri with code
A->>H: POST /token (App ID + App Secret)
H-->>A: access_token, id_token
A->>H: GET /userinfo
H-->>A: Verified data
```
**Before you start**
* An app's **App ID** (`client_id`) and **App Secret** (`client_secret`) from **Developers → API Settings**.
* Your redirect URI in that app's **Redirect URL Allowlist**. Matching is exact.
* At least one connection that is activated and enabled in the app's [workflow](/v2/guides/verifications/workflows).
## Endpoints
| | URL |
| :- | :- |
| Issuer (discovery, JWKS) | `https://connect.hopae.com` |
| **Authorization** | `https://connect.hopae.com/v2/auth` |
| Token | `https://connect.hopae.com/v2/token` |
| UserInfo | `https://connect.hopae.com/v2/userinfo` |
Configure your library with the issuer `https://connect.hopae.com` and **override the authorization endpoint** with `/v2/auth`. Discovery lists `/auth`, which is the v1 API. The endpoint where the verification starts decides the response format, so the token and userinfo endpoints from discovery also work.
## 1. Redirect to /auth
```javascript theme={null}
const url = new URL("https://connect.hopae.com/v2/auth");
url.search = new URLSearchParams({
client_id: APP_ID,
redirect_uri: "https://app.example.com/callback",
response_type: "code",
scope: "openid hopae",
state: crypto.randomUUID(),
nonce: crypto.randomUUID(),
});
```
| Parameter | Required | Value |
| :- | :- | :- |
| `client_id` | Yes | Your App ID |
| `redirect_uri` | Yes | An allowlisted URI, exact match |
| `response_type` | Yes | `code` |
| `scope` | Yes | `openid hopae`. Without `hopae`, userinfo returns no identity data |
| `state`, `nonce` | Recommended | Random values you check on return |
| `workflow_id` | Optional | Run a workflow other than the default |
| `acr_values` | Optional | Minimum level of assurance, for example `urn:hopae:loa:3`. See [Assurance](/v2/guides/verifications/assurance) |
All parameters, including the `ui_*` display options and `private_mode`: [Authorization reference](/v2/api-reference/oidc/auth).
### Pre-select a connection
Pass `ui_connection_id` (for example `smart-id`) to skip the country and eID picker. The connection must be enabled in the workflow. Otherwise the user picks as usual. `ui_country` pre-selects only the country.
## 2. Handle the callback
```text Success theme={null}
https://app.example.com/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=b2a6c120-...&iss=https%3A%2F%2Fconnect.hopae.com
```
```text Failure theme={null}
https://app.example.com/callback?error=access_denied&error_description=Authentication%20cancelled%20by%20user&state=b2a6c120-...
```
Check `state`, and `iss` if your library supports it (`https://connect.hopae.com`). Every user-facing failure (cancel, provider error, expiry) arrives as `error=access_denied`. An unknown `workflow_id`, or `acr_values` that no enabled connection meets, arrives as `error=invalid_request`. An app without a workflow shows an error page instead of redirecting.
## 3. Exchange the code
On your backend, with your App ID and App Secret as HTTP Basic credentials (`client_secret_basic`). Form fields (`client_secret_post`) also work. Codes are single-use and expire after 5 minutes. PKCE (`S256`) is supported but optional.
```bash theme={null}
curl -X POST "https://connect.hopae.com/v2/token" \
-u "APP_ID:APP_SECRET" \
-d "grant_type=authorization_code" \
-d "code=SplxlOBeZQQYbYS6WxSbIA" \
-d "redirect_uri=https://app.example.com/callback"
```
You receive an `access_token` (10 minutes) and an `id_token` (30 minutes). No refresh token is issued. Every verification is a new authorization.
The ID token carries no personal data. Validate its signature ([JWKS](/v2/api-reference/oidc/jwks)), `iss` (`https://connect.hopae.com`), `aud` (your App ID), and `nonce`. It names the level of assurance (`acr`, `hopae_loa`) and the connection that ran (`hopae_verification.connection_id`). Details: [Token reference](/v2/api-reference/oidc/token).
## 4. Read the result
```bash theme={null}
curl "https://connect.hopae.com/v2/userinfo" \
-H "Authorization: Bearer ACCESS_TOKEN"
```
Read identity data from `user`. Add `?provenance=true&missing_claims=true` for the audit trail and the claims the source could not provide. The payload is the same as the REST API's. See [Return Data](/v2/guides/verifications/return-data-model).
To recognise a returning person, use `user.source_id`, not `sub`. See [Return Data](/v2/guides/verifications/return-data-model#which-claims-you-receive).
## Library examples
Any certified OIDC library works: set the issuer to `https://connect.hopae.com` and override the authorization endpoint with `https://connect.hopae.com/v2/auth`. These libraries also validate the ID token for you.
```ts theme={null}
import { Issuer } from "openid-client"; // v5
const discovered = await Issuer.discover("https://connect.hopae.com");
const issuer = new Issuer({
...discovered.metadata,
authorization_endpoint: "https://connect.hopae.com/v2/auth",
});
const client = new issuer.Client({
client_id: process.env.APP_ID!,
client_secret: process.env.APP_SECRET!,
redirect_uris: ["https://app.example.com/callback"],
response_types: ["code"],
});
const authUrl = client.authorizationUrl({ scope: "openid hopae", state, nonce });
// On callback:
const tokenSet = await client.callback("https://app.example.com/callback", client.callbackParams(req), { state, nonce });
const userinfo = await client.userinfo(tokenSet.access_token!);
```
```yaml theme={null}
spring.security.oauth2.client:
registration.hopae:
client-id: ${APP_ID}
client-secret: ${APP_SECRET}
authorization-grant-type: authorization_code
scope: openid, hopae
redirect-uri: https://app.example.com/login/oauth2/code/hopae
provider.hopae:
issuer-uri: https://connect.hopae.com
authorization-uri: https://connect.hopae.com/v2/auth
```
## Troubleshooting
The `redirect_uri` is not on the allowlist of the app named by `client_id`. Scheme, host, port, path, and trailing slash must match exactly.
The code expired (5 minutes) or was already used, or `redirect_uri` differs from the authorization request.
Send the App ID and App Secret of the same app as HTTP Basic credentials.
Add `hopae` to `scope`.
Activate a connection and enable it in the workflow you run. Production apps need their own activations.
Native app? Open `/auth` in `ASWebAuthenticationSession` (iOS) or a Custom Tab (Android), never a WebView, and keep the token exchange on your backend. See [Mobile App Integration](/v2/guides/mobile-app-integration).
# Flow Types
Source: https://docs.hopae.com/v2/guides/reference/flow-types
How each flow type runs over the REST API: the sequence, the create response, and what your app does.
The `flowType` in the [Create Verification](/v2/api-reference/verifications/create-verification) response tells you what happens between create and userinfo. Every create response also carries `connectionInstanceId`, `connectionId`, `credentialId`, `providerId`, `verificationModel`, and `expiresAt` (30 minutes after creation).
For the status list, see [Verification Flow](/v2/guides/verifications/verification-flow#lifecycle). For polling, see [REST API Integration](/v2/guides/api-integration#4-poll-until-finished).
The user signs in on the provider's page and returns to your `redirectUri`. Examples: `mitid`, `id-austria`, `cz-bankid-identify`.
```mermaid theme={null}
sequenceDiagram
participant U as User (browser)
participant C as Your server
participant H as Hopae
participant P as Provider
C->>H: POST /verifications
H-->>C: awaiting_user_action (authorizationUrl)
C->>U: Send the user to authorizationUrl
U->>P: Sign in
P-->>H: Result
H-->>U: Back to your redirectUri
C->>H: GET /verifications/{id}
H-->>C: completed
C->>H: GET /verifications/{id}/userinfo
```
```json Create response theme={null}
{
"verificationId": "019bc4f3-1c02-7d4a-8b7e-2e0f9c5a7d11",
"status": "awaiting_user_action",
"flowType": "redirect",
"flowDetails": {
"authorizationUrl": "https://connect.hopae.com/v/019bc4f3-1c02-7d4a-8b7e-2e0f9c5a7d11"
},
"createdAt": "2026-09-03T09:15:02.118Z",
"expiresAt": "2026-09-03T09:45:02.118Z",
"connectionInstanceId": "conn_01J8XM0R7C2VHK4Y",
"connectionId": "mitid",
"credentialId": "mitid",
"providerId": "mitid",
"verificationModel": "disclosure"
}
```
**Your app:** send the user to `flowDetails.authorizationUrl`. When the user is back on your `redirectUri`, check the status.
The user scans a QR code with the eID app and approves there. Examples: `freja-plus`, `diia`.
```mermaid theme={null}
sequenceDiagram
participant U as User (phone app)
participant C as Your server
participant H as Hopae
participant P as Provider
C->>H: POST /verifications
H-->>C: awaiting_user_action (qrData)
C->>U: Show qrData as a QR code
U->>P: Scan and approve
loop Until terminal
C->>H: GET /verifications/{id}
H->>P: Check status
H-->>C: awaiting_user_action, then completed
end
C->>H: GET /verifications/{id}/userinfo
```
```json Create response (abridged) theme={null}
{
"verificationId": "019bc4f4-5e77-7a9c-b0d1-6c3a2f8e9b44",
"status": "awaiting_user_action",
"flowType": "qr",
"flowDetails": {
"qrData": "",
"linkData": ""
},
"expiresAt": "2026-09-03T09:42:44.301Z",
"connectionId": "freja-plus"
}
```
**Your app:** on a desktop, render `flowDetails.qrData` as a QR code. On a phone, open `flowDetails.linkData` instead, when present. Some QR connections have none. Then poll.
Hopae sends the request to the eID app on the user's registered phone. Examples: `smart-id`, `sk-mobile-id`, `evrotrust-identity-attestation`.
```mermaid theme={null}
sequenceDiagram
participant U as User (phone app)
participant C as Your server
participant H as Hopae
participant P as Provider
C->>H: POST /verifications (userInput)
H->>P: Send request to the user's device
H-->>C: awaiting_user_action (verificationCode)
P->>U: Notification
U->>P: Confirm
loop Until terminal
C->>H: GET /verifications/{id}
H->>P: Check status
H-->>C: awaiting_user_action, then completed
end
C->>H: GET /verifications/{id}/userinfo
```
```json Create response theme={null}
{
"verificationId": "019bc4f2-8a31-7c5e-9d02-4f7a1b3e60d8",
"status": "awaiting_user_action",
"flowType": "push",
"flowDetails": {
"verificationCode": "4823",
"description": "Confirm this code matches the one shown in your Smart-ID app"
},
"createdAt": "2026-09-03T09:18:31.774Z",
"expiresAt": "2026-09-03T09:48:31.774Z",
"connectionInstanceId": "conn_01J8XK2P4M9QR3TV",
"connectionId": "smart-id",
"credentialId": "smart-id",
"providerId": "smart-id",
"verificationModel": "disclosure"
}
```
**Your app:** show a waiting screen with `flowDetails.verificationCode` and `description` so the user can match the code, then poll. If the provider issues no code, `flowDetails` is absent.
The user shares a credential from a phone wallet through the Digital Credentials API. Example: `google-wallet-us-mdl`.
```mermaid theme={null}
sequenceDiagram
participant U as User (phone wallet)
participant C as Your server
participant H as Hopae
C->>H: POST /verifications
H-->>C: awaiting_user_action (linkData)
C->>U: Open linkData on the phone
U->>H: Hosted page asks the wallet, user shares
C->>H: GET /verifications/{id}
H-->>C: completed
C->>H: GET /verifications/{id}/userinfo
```
```json Create response theme={null}
{
"verificationId": "019bc4f5-9a10-7e3b-8f42-7d1c0b9a3e55",
"status": "awaiting_user_action",
"flowType": "dc",
"flowDetails": {
"linkData": "https://connect.hopae.com/dc/us-mdl?verification_id=019bc4f5-9a10-7e3b-8f42-7d1c0b9a3e55"
},
"createdAt": "2026-09-03T09:21:09.442Z",
"expiresAt": "2026-09-03T09:51:09.442Z",
"connectionInstanceId": "conn_01J8XN9W3D6BQZ1F",
"connectionId": "google-wallet-us-mdl",
"credentialId": "us-mdl",
"providerId": "google-wallet",
"verificationModel": "disclosure"
}
```
**Your app:**
* **Same device (phone):** open `flowDetails.linkData` in the system browser. Append `&return_url=` to bring the user back.
* **Cross-device (desktop):** render `flowDetails.linkData` as a QR code for the phone to scan.
* Then poll.
The hosted page needs a browser with the Digital Credentials API, such as Chrome 141+ on Android or Safari 26+ on iOS.
To run the wallet request on your own page instead, call `POST /verifications/{id}/dc-request`, pass its result to `navigator.credentials.get`, and send the wallet's answer to `POST /verifications/{id}/dc-response`. That call returns the finished verification.
In Sandbox, issuer trust-chain checks are not enabled for the DC flow. A sandbox `completed` is not a fraud signal.
Hopae checks the data you sent (an ID number, sometimes a photo) with the source registry inside the create call. Nothing is sent to the user. Examples: `ng-nin`, `za-nid`, `br-cpf`.
```mermaid theme={null}
sequenceDiagram
participant C as Your server
participant H as Hopae
participant S as Source registry
C->>H: POST /verifications (userInput)
H->>S: Look up the record
S-->>H: Record, or no match
H-->>C: completed (or failed + error)
C->>H: GET /verifications/{id}/userinfo
```
```json Create response theme={null}
{
"verificationId": "019bc4f6-2b41-7f0c-9a55-3e8d1c7b2a90",
"status": "completed",
"flowType": "query",
"createdAt": "2026-09-03T09:24:10.512Z",
"expiresAt": "2026-09-03T09:54:10.512Z",
"connectionInstanceId": "conn_01J8XP4T2H7MCV5N",
"connectionId": "ng-nin",
"credentialId": "ng-nin",
"providerId": "ng-nin",
"verificationModel": "disclosure"
}
```
**Your app:** no screen and no polling. On `completed`, read userinfo. On `failed`, read `error` in the create response: `error.code` is a general code such as `provider_error`, and the source's own reason (for example `INVALID_ID_NUMBER`) is in `error.details.provider_code`.
* Create answers `201` either way. A failed lookup is a result, not a request error.
* The call waits for the source, usually a few seconds. Set your HTTP timeout to at least 30 seconds.
* A later `GET /verifications/{id}` returns the stored result without asking the source again.
| Connection | Credential |
| :- | :- |
| `ng-nin` | Nigeria NIN |
| `ng-bvn` | Nigeria BVN |
| `ng-vid` | Nigeria Voter ID |
| `za-nid` | South Africa National ID |
| `ug-nin` | Uganda National ID |
| `ke-nid` | Kenya National ID |
| `ke-passport` | Kenya Passport |
| `ke-alien-card` | Kenya Alien Card |
| `ao-nin` | Angola National ID |
| `gh-card` | Ghana Card |
| `ci-nid` | Côte d'Ivoire National ID |
| `br-cpf` | Brazil CPF |
| `br-cpf-cnh-match` | Brazil CNH + biometrics |
| `mx-curp` | Mexico CURP |
| `mx-ine-qr` | Mexico INE QR Code |
| `cl-run` | Chile RUN |
| `pe-dni` | Peru DNI |
| `in-pan-match` | India PAN |
| `id-nik-match` | Indonesia NIK |
| `id-nik-bio-match` | Indonesia NIK + biometrics |
| `ph-nid` | Philippine National ID |
| `co-runt-bio-match` | Colombia RUNT + biometrics |
The v1 API reports these connections as `flowType: push`. They behave the same way: the create response is already final.
## Same-device journeys
A phone cannot scan a QR code it is showing. When the verification starts on a phone, use `linkData` (QR and DC) or `authorizationUrl` (redirect) instead of a QR code, keep the `verificationId` before leaving your app, and resume through an allowlisted callback. See [Mobile App Integration](/v2/guides/mobile-app-integration#same-device-journeys).
# Workflow Nodes
Source: https://docs.hopae.com/v2/guides/reference/workflow-nodes
Add rules between the verification and the response: minimum assurance, required claims, computed values, and branches.
By default a workflow is a straight line: **request → verification → response**. Insert nodes between the verification and the response to add rules. For the basics, see [Workflows](/v2/guides/verifications/workflows).
```mermaid theme={null}
flowchart LR
R[request] --> V[verification] --> L[check-min-loa] --> C[check-claim] --> S[response]
```
| Node | Purpose | Key `config` |
| :- | :- | :- |
| `request` | Entry point | (none) |
| `verification` | Runs the verification. Holds the channel, claims, enabled connections, credential-specific claims, and branding | `channel`, `claims`, `credentials`, `credential_claims`, `branding` |
| `check-min-loa` | Checks a minimum [level of assurance](/v2/guides/verifications/assurance) | `minLoa` (1 to 5) |
| `check-claim` | Checks that specific claims are present | `claimChecks` |
| `evaluate` | Stores a boolean result under `computed.` | `outputField`, `conditions` |
| `if` | Branches on conditions | routes via `next` |
| `response` | Terminal node | (none) |
## Conditions
Conditions use dot notation over the verification output, for example `input.user.nationality` or `input.hopae_loa`.
## Outputs
Node results are added to the userinfo response: `metLoa` from `check-min-loa`, `hasClaim` from `check-claim`, and `computed.*` from `evaluate`. Nodes only report results. Your integration enforces them. For example, read `metLoa` and reject the user in your own system when it is `false`.
## Channel
The `verification` node has a `channel`: `oidc` for the hosted flow or `api` for the REST API. Both use the same connections and data request. The workflow-level `type` (`managed` or `api`) only records intent and does not change how it runs.
# Release Notes
Source: https://docs.hopae.com/v2/guides/releases/v2-preview
What is new in Hopae Connect v2.
**Connections.** You verify with a connection: one credential through one provider. Start a verification with `connectionId`. See [Connections](/v2/guides/concepts/connections).
**Workflows.** Each app chooses its offered connections, requested claims, and branding in a workflow, instead of in each request. See [Workflows](/v2/guides/verifications/workflows).
**Apps and environments.** Sandbox and production are separate apps on the same URLs. The credentials select the app. Saves apply immediately. There is no publish step. See [Apps & Environments](/v2/guides/concepts/apps).
**Query flow.** Lookup connections such as `ng-nin` and `br-cpf` return a final result from the create call. See [Flow Types](/v2/guides/reference/flow-types).
**Webhooks.** A new v2 payload with five verification events and two activation events, and a signing secret per endpoint. See [Webhooks](/v2/guides/webhook-signing).
**Results.** `provenance` and `missing_claims` are opt-in. Identifiers are catalog ids. See [Return Data](/v2/guides/verifications/return-data-model).
Moving from v1? See [Migrate from v1](/v2/guides/migration/v1-to-v2).
# Level of Assurance (LoA)
Source: https://docs.hopae.com/v2/guides/verifications/assurance
How to request and validate authentication assurance levels.
## Overview
Level of Assurance (LoA) indicates the confidence level of an identity verification. Based on the OIDC `acr` (Authentication Context Class Reference) claim, Hopae returns:
* `hopae_loa`: integer level from 1 to 5 for programmatic checks
* `hopae_loa_label`: human-readable label (e.g., `substantial`)
* `acr`: the `urn:hopae:loa:{level}` URN, present when the provider asserted the level explicitly
## LoA levels
| acr | hopae\_loa | hopae\_loa\_label | Description | eIDAS | NIST |
| - | - | - | - | - | - |
| `urn:hopae:loa:1` | 1 | `none` | No verified ID link | - | IAL1/AAL1 |
| `urn:hopae:loa:2` | 2 | `low` | Limited KYC | Low | IAL1 to 2 |
| `urn:hopae:loa:3` | 3 | `substantial` | Trusted eID, strong single factor | Substantial | IAL2/AAL2 |
| `urn:hopae:loa:4` | 4 | `high` | Multi-factor + crypto binding | High | IAL3/AAL3 |
| `urn:hopae:loa:5` | 5 | `qualified` | Qualified signature | High+/QES | IAL3+ |
Every connection advertises the levels its credential can reach as `credential.loa[]` in [Get Connections](/v2/api-reference/verifications/get-connections) and in the Console's Connections table.
## Require a minimum LoA
Add a `check-min-loa` node after the verification step so the requirement travels with the workflow and applies to both OIDC and REST:
```json theme={null}
{ "id": "nd_gate", "type": "check-min-loa", "next": "nd_resp", "config": { "minLoa": 3 } }
```
The userinfo response then carries `metLoa` so you can branch on it. See [Workflow Nodes](/v2/guides/reference/workflow-nodes).
Add the `acr_values` query parameter:
```http theme={null}
GET https://connect.hopae.com/v2/auth
?client_id=YOUR_APP_ID
&redirect_uri=https://example.com/callback
&response_type=code
&scope=openid hopae
&acr_values=urn:hopae:loa:3
```
The v2 Verification REST API has no per-request LoA parameter. Choose connections whose `credential.loa[]` meets your requirement, gate with a workflow `check-min-loa` node, or check `hopae_loa` on the userinfo response yourself.
When no level is requested, the connection's minimum supported LoA applies.
## When the achieved LoA is lower than requested
This can occur when the user chooses a weaker authentication method than expected, or the provider downgrades the session through a fallback mechanism.
The verification is still **finished** and the result is returned, so you can decide what to do. REST userinfo carries the achieved level in `hopae_loa` and a top-level `error`. In OIDC, check the achieved level and `provenance._metadata.error`. The top-level `error` is not included in the OIDC claims whitelist:
```json theme={null}
{
"user": {
"given_name": "Lars",
"family_name": "Nielsen"
},
"missing_claims": [],
"hopae_loa": 2,
"hopae_loa_label": "low",
"verification_model": "disclosure",
"error": {
"code": "loa_insufficient",
"message": "Provider LoA 2 is below the requested minimum 3"
},
"provenance": {
"...": "...",
"_metadata": {
"provider_id": "mitid",
"connection_id": "mitid",
"credential_id": "mitid",
"connection_instance_id": "conn_01J8XM0R7C2VHK4Y"
}
},
"sub": "019bc4f2-8a31-7c5e-9d02-4f7a1b3e60d8"
}
```
Over REST, [Get Verification Status](/v2/api-reference/verifications/get-verification) reports `status: "failed"` with `error.code: "loa_insufficient"`. The userinfo endpoint still serves this one failed state.
## Response example
LoA fields are included in both the ID token and the userinfo response:
```json theme={null}
{
"sub": "019bc4f2-8a31-7c5e-9d02-4f7a1b3e60d8",
"acr": "urn:hopae:loa:4",
"hopae_loa": 4,
"hopae_loa_label": "high",
"user": {
"name": "Anders Eriksson"
}
}
```
Always validate `hopae_loa` server-side before granting access to sensitive operations.
```js theme={null}
if (claims.hopae_loa >= 3) {
// allow sensitive action
}
```
## See also
* [OIDC Integration Guide](/v2/guides/oidc-integration): `acr_values` parameter usage
* [Workflow Nodes](/v2/guides/reference/workflow-nodes): the `check-min-loa` node
* [Get Connections](/v2/api-reference/verifications/get-connections): `credential.loa[]` per connection
# Evidence & Data Integrity
Source: https://docs.hopae.com/v2/guides/verifications/evidence
How Hopae Connect lets you independently prove that returned identity data came from the source provider, untampered.
Hopae Connect is a **gateway**: every verification result travels from the eID provider, through Hopae, to your application. **Evidence** is how you prove that what you received is exactly what the provider issued, and that nothing was altered in transit.
When it is available, each credential in a result carries an `evidence` object under [`provenance`](/v2/guides/verifications/return-data-model#what-provenance-means). It preserves the **signed proof material** behind the verification so you (or an auditor) can verify the result against the signer's own keys, rather than taking Hopae's word for it.
## Why evidence exists
Because Hopae sits in the middle, a sceptical RP could ask: *how do I know Hopae returned the provider's data faithfully, and didn't change a name or a birthdate along the way?* Evidence answers that. A cryptographic signature travels end-to-end, so the proof you verify was produced by the signer, not fabricated by the gateway.
```mermaid theme={null}
sequenceDiagram
participant eID as eID Provider (issuer)
participant Hopae as Hopae Connect (gateway)
participant App as Your App (RP)
eID->>Hopae: Verified claims + signed assertion
Note over Hopae: Relays the signature untouched
Hopae->>App: Normalized claims + evidence (signed assertion)
Note over App: Verify the signature against the signer's keys
App->>App: Confirm data is authentic & unaltered
```
**What you can establish depends on the artifact:**
* A **signed assertion** (JWS, SAML, COSE, passport SOD, detached signature) gives you **integrity**, **authenticity**, and **non-repudiation** against the signer's keys.
* **Integrator evidence** (see below) proves Hopae received and attests to exactly this data: integrity and non-repudiation *from Hopae*, for providers that issue no signature of their own.
* **Opaque companions** (such as a Bearer `access_token`) and unsigned fields carry no independent proof. Don't treat them as verification material.
* **Wallet presentations** (mdoc, Verifiable Presentation) carry their own proofs that *you* validate against the issuer's trust chain.
## Where to find it
Evidence appears per credential, alongside the source's `claims`:
```jsonc theme={null}
provenance.presentation.credentials[].evidence // one per credential
```
Read it from the userinfo response, either [OIDC](/v2/api-reference/oidc/userinfo) or [REST](/v2/api-reference/verifications/get-verification-userinfo). There is no separate evidence endpoint in v2. Every credential's evidence travels inside `provenance`.
## Two kinds of token evidence
Not all eID providers issue a signed assertion. So evidence comes from one of two sources. **Detect them by key presence**, not by a single field:
* **Integrator**: `evidence.token.integrator_jws` is present (with `token_type: "integrator"`).
* **Pass-through**: anything else with a `token` object: the provider's own artifacts.
| | Pass-through | Integrator |
| - | - | - |
| **What it is** | The issuer's **original** signed artifact, relayed untouched | A JWS **Hopae signs** over the provider's claims |
| **Detect by** | a `token` object **without** `integrator_jws` | `evidence.token.integrator_jws` present |
| **Who signed it** | the source provider / issuing authority | Hopae |
| **Verify against** | the **signer's** keys or certificate | **Hopae's OIDC JWKS** |
| **Used when** | the provider returns its own signed assertion | the provider issues none (and signing is configured) |
A `token` object can also hold non-proof material, e.g. an opaque `access_token` with `token_type: "Bearer"`. Verify the **signed artifact** (a JWS, SAML assertion, COSE signature, or SOD), not its opaque companions.
### Pass-through evidence
The provider's signed artifact is relayed **as-is**. Hopae never re-signs it. (Token-string artifacts like JWS and SAML pass through unchanged. Wallet artifacts may be re-encoded for transport, e.g. CBOR→base64, but the issuer signature is preserved.) Common signed forms:
* **`id_token` / `userinfo_jws`**: a JWS signed by the provider.
* **`saml_response`**: a signed SAML assertion (base64).
* **`signature` + `certificate` (+ `signed_hash`)**: a detached signature with the signer's certificate.
* **`sod` + data groups (`dg1`…`dg14`)**: a passport's Document Security Object (ICAO 9303 passive authentication).
* **`signed_pdf` (+ `signed_hash`)**: a signed PDF document.
* **`issuerAuth`**: an ISO 18013-5 mdoc COSE signature (see [Wallet evidence](#wallet-evidence-mdoc--vp)).
Verifying these requires the **signer's** public keys or certificate. Discovery is provider-specific (an OIDC JWKS, SAML metadata certificate, or an ICAO CSCA/Document-Signer chain) and is real cryptographic work. The signer is the issuer behind the connection named by the response's `provenance._metadata.provider_id` and `credential_id`. The connection's details in the Console tell you which authority that is.
### Integrator evidence
When a provider does not return a signed assertion of its own, Hopae signs the **provider's claims** with its own OIDC signing key (RS256), so the payload is still integrity-protected and attributable to Hopae.
```jsonc theme={null}
"evidence": {
"token": {
"integrator_jws": "eyJhbGciOiJSUzI1NiIsInR5cCI6...", // RS256 JWS
"token_type": "integrator"
},
"names": "integrator_jws;token_type"
}
```
The JWS payload carries the provider id and the signed claims, with standard time claims:
```jsonc theme={null}
{
"iss": "https://connect.hopae.com", // Hopae's OIDC issuer
"iat": 1730352000,
"exp": 1730438400, // ~24h after issuance, enforced on verify
"providerId": "pass",
"data": { /* the provider's claims */ }
}
```
Verify it against Hopae's OIDC keys at `https://connect.hopae.com/jwks`. These are the same keys that sign ID tokens.
```ts theme={null}
import { jwtVerify, createRemoteJWKSet } from 'jose'
const issuer = 'https://connect.hopae.com'
const JWKS = createRemoteJWKSet(new URL(`${issuer}/jwks`))
const { payload } = await jwtVerify(evidence.token.integrator_jws, JWKS, { issuer })
// payload.data holds the provider's claims, integrity-protected by Hopae.
// jwtVerify enforces `exp`: integrator evidence expires ~24h after issuance, so
// verify it promptly or persist the verified result.
```
The correct key is selected automatically by the JWS `kid`.
## Wallet evidence (mdoc / VP)
Wallet credentials do **not** use the `{ token, names }` shape. They place their fields directly on `evidence`, and you validate their proofs yourself against the issuer's trust chain:
| Family | Fields on `evidence` | `token_type` | You verify by |
| - | - | - | - |
| mdoc (ISO 18013-5) | `docType`, `issuerAuth`, `nameSpaces`, `deviceAuth?` | `iso_18013_5_mdoc` | validating the `issuerAuth` COSE signature (MSO) against the issuer/IACA certificate and checking the MSO digests against `nameSpaces` |
| Verifiable Presentation | `vpClaims` | (none) | validating the VP proof and issuer trust chain, **only when** `vpClaims` carries the original presentation (some providers store decoded claims only) |
Wallet evidence is only as strong as the validation **you** perform, and its contents vary by provider. `issuerAuth` may be empty when issuer authentication wasn't captured. `vpClaims` may hold **decoded claims rather than the original signed presentation**. Treat empty or claims-only material as **unverifiable**: independent cryptographic verification is possible only when the evidence carries the original signed artifact. Never infer authenticity from mere presence.
## Reading the evidence object
Detect the shape before reading, because `names` exists only for the token shape:
```ts theme={null}
function readEvidence(evidence) {
if (!evidence) return null
const tokenType = evidence.token_type ?? evidence.token?.token_type
if (evidence.token?.integrator_jws) return { kind: 'integrator', token: evidence.token }
if (tokenType === 'iso_18013_5_mdoc' || evidence.docType || evidence.nameSpaces)
return { kind: 'mdoc', evidence }
if (evidence.vpClaims) return { kind: 'vp', evidence }
if (evidence.token) {
// Standard shape: enumerate via `names`, falling back to the token keys
const keys = evidence.names ? evidence.names.split(';') : Object.keys(evidence.token)
return { kind: 'token', keys, token: evidence.token }
}
return { kind: 'other', evidence }
}
```
## What is relayed vs. set by the gateway
The signed artifact itself is always the signer's (pass-through) or Hopae's (integrator). That is what you verify. A few **convenience fields** around it are added by the gateway and are *not* covered by any signature:
* `expires_at`: derived from the artifact (e.g. the `id_token`'s `exp`).
* Some `token_type` labels (`Bearer`, `icao_9303_lds`, `iso_18013_5_mdoc`, `integrator`): set by Hopae to describe the artifact.
When you verify, verify the **artifact**, not the surrounding helper fields, and not opaque tokens.
## Related
* [Return Data Model](/v2/guides/verifications/return-data-model): the full payload `evidence` sits inside
* [Data Types: Provenance](/v2/guides/data-types/provenance): copy-paste types
* [Get Verification UserInfo](/v2/api-reference/verifications/get-verification-userinfo): the REST response that carries `provenance`
* [OIDC Integration](/v2/guides/oidc-integration): issuer, discovery, and JWKS
# Normalized User Data
Source: https://docs.hopae.com/v2/guides/verifications/normalized-user-data
Flat, standard OIDC claims (plus LoA metadata) you can rely on across providers
## Overview
Hopae Connect solves the fragmentation problem of identity providers by standardizing user attributes across all eID providers. No matter which provider your users choose, you receive consistent, predictable data formats.
### **The Problem: Data Chaos**
Each identity provider uses different attribute names for the same user information, creating significant integration complexity:
**Example: Attribute Naming Chaos**
| Information | Provider A | Provider B | Provider C |
| - | - | - | - |
| First Name | `firstName` | `given_name` | `firstname` |
| Last Name | `lastName` | `family_name` | `surname` |
| Birth Date | `dateOfBirth` | `birthdate` | `dob` |
| National ID | `personalNumber` | `ssn` | `nationalNumber` |
Without standardization, you would need to:
* Write custom mapping logic for each provider
* Maintain provider-specific code branches
* Handle different date formats and data types
* Update your code when providers change their schemas
### Normalized Attributes
Hopae Connect maps all provider variations into a attribute set based on [OpenID Connect Standard Claims](https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims).
These are standard OpenID Connect claims representing the user's identity.
| Attributes | Type | Required | Notes/Example |
| - | - | - | - |
| `name` | string | No | Full name |
| `given_name` | string | No | |
| `family_name` | string | No | |
| `middle_name` | string | No | |
| `nickname` | string | No | |
| `preferred_username` | string | No | |
| `profile` | string | No | URL |
| `picture` | string | No | Base64-encoded image data |
| `gender` | string | No | `female`, `male`, `other` |
| `birthdate` | string | No | `YYYY-MM-DD` |
| `locale` | string | No | BCP 47 (`en-US`) |
| `email` | string | No | `user@example.com` |
| `email_verified` | boolean | No | true/false |
| `phone_number` | string | No | E.164 (`+1415555…`) |
| `phone_number_verified` | boolean | No | true/false |
| `address` | object | No | See Address structure below |
| `nationality` | string | No | ISO 3166-1 alpha-2 |
#### Address Claim Structure
| Attributes | Type | Notes/Format |
| - | - | - |
| `street_address` | string | Street/house/PO Box |
| `locality` | string | City |
| `region` | string | State/Province/Region |
| `postal_code` | string | ZIP/Postal code |
| `country` | string | ISO 3166-1 alpha-2 |
Personal attributes are returned under `/userinfo.user.*`. The ID Token contains no PII. It carries only technical claims (for example: `sub`, `acr`, `hopae_loa`).
### Choosing which claims to request
You choose claims in the [workflow](/v2/guides/verifications/workflows), not per request: its **Claims** tab selects the claims for every connection, plus claims that only a particular connection can return. [Get Connections](/v2/api-reference/verifications/get-connections) shows each connection's `availableClaims` so you know what can be requested.
One claim is special: **`source_id`**, the identifier the source uses for this person, stable for the same person on the same connection. It is opt-in (tick **Source ID** on the workflow's **Claims** tab), not every connection provides one, and even a connection that does may not for a given verification, depending on the identity provider and the user's status with it. When it cannot be derived it is listed in `missing_claims`, and its value can change for the same person when the source claim behind it changes (for example a reissued document). See [Source ID](/v2/guides/verifications/return-data-model#which-claims-you-receive).
**Examples**: Provider data vs. normalized `/userinfo.user`:
```json Provider Response (BankID Sweden) theme={null}
{
"personalNumber": "198507124567",
"givenName": "Anders",
"surname": "Eriksson",
"name": "Anders Eriksson"
}
```
```json UserInfo (user.*) theme={null}
{
"user": {
"name": "OK TESTNUMBER",
"given_name": "OK",
"family_name": "TESTNUMBER",
"birthdate": "1905-04-04"
},
"missing_claims": ["email", "phone_number"],
"hopae_loa": 3,
"sub": "019bc4f2-8a31-7c5e-9d02-4f7a1b3e60d8"
}
```
### Data Availability
Not all attributes are available from all providers. The availability depends on each provider's capabilities and user consent.
If a claim is absent, check `missing_claims` in `/userinfo` (request it with `missing_claims=true`). Claims listed there weren’t provided by the source, so handle fallbacks accordingly.
### Match Flows Use the Same Names
The normalization catalog above also applies to match connections: `userInput` keys are submitted using the normalized names (e.g. `name`, `birthdate`), and the result echoes them back unchanged in `match.submitted_fields` and as `match.details` keys. Fields with no normalized catalog entry (e.g. a provider-specific national ID lookup key) keep their provider-native name, as listed in the connection's `userInputSchema`. Hopae translates to the upstream source's native names at the provider boundary, so the `provenance` audit trail stays aligned with the source (e.g. `fullName`) while everything you submit and read back is normalized. See [User Input](/v2/api-reference/verifications/user-input).
## Next Steps
Implement standardized attributes
Retrieve detailed context and evidence
Understanding the flow
# Return Data Model
Source: https://docs.hopae.com/v2/guides/verifications/return-data-model
What we return after User Verification
Both integrations end at the same payload.
```mermaid theme={null}
flowchart LR
O["OIDC: POST /token"] --> OU["GET /userinfo"]
R["REST: GET /verifications/{id} until completed"] --> RU["GET /verifications/{id}/userinfo"]
OU --> P["Same payload: user, match, and on request provenance and missing_claims"]
RU --> P
```
## Overview
* Who is the user? → returned under `user`.
* Which connection verified them? → `provenance._metadata.provider_id`, `connection_id`, `credential_id`, and `connection_instance_id` within the same metadata block.
* How was this identity verified? → described by `provenance`.
* Did the user's claimed values match the source? → returned under `match` for match connections.
This data model is served by OIDC `/userinfo` (subject to granted scopes and the OIDC claim whitelist) and by the REST [Get Verification UserInfo](/v2/api-reference/verifications/get-verification-userinfo) endpoint. On both, `provenance` and `missing_claims` are returned only when the request asks for them with `provenance=true` and `missing_claims=true`.
## Connection identity
Under `provenance._metadata`, every userinfo result requested with `provenance=true` names the [connection](/v2/guides/concepts/connections) that ran, using the same ids you see in the Console and in [Get Connections](/v2/api-reference/verifications/get-connections):
| Field | Example | Meaning |
| :- | :- | :- |
| `provider_id` | `smart-id`, `google-wallet` | The provider that presented the credential. |
| `credential_id` | `smart-id`, `us-mdl` | The credential that was verified. |
| `connection_id` | `smart-id`, `google-wallet-us-mdl` | The catalog connection. Human-readable but opaque: read `provider_id` and `credential_id` instead of parsing it. |
| `connection_instance_id` | `conn_01J8XK2P4M9QR3TV` | Your app's activated instance of that connection, the id you started the verification with. |
There is no `amr` claim in v2. The ID token carries `provider_id` and `connection_id` inside one `hopae_verification` claim. `credential_id` and `connection_instance_id` are only in the userinfo response's `provenance._metadata`. All four identifiers are nested there in userinfo, never top-level.
## Verification models
The top-level `verification_model` field tells you which payload shape to read:
| `verification_model` | When | Read from | `user` |
| - | - | - | - |
| `disclosure` | The provider returned user attributes from an authoritative source. Default for classic providers and wallets. | `user.*` (PII) and `provenance` | object with PII |
| `match` | The provider compared the values you submitted against the authoritative source and returned a match outcome. | `match.*` and `provenance` | verified subset of the submitted fields |
`verification_model` is always present and is decided by the connection you verify against.
For match flows, `user` echoes back the verified subset of the values you submitted. Per-field providers: only fields with `matched: true`. Aggregate providers: every submitted field when the aggregate outcome is `true`, otherwise empty. A match connection may additionally expose disclosed (provider-asserted) claims. Those appear under `user` only when the workflow requests them, and a requested-but-unavailable claim is listed in `missing_claims`.
For match connections, the values to compare are supplied at session creation in `userInput`. The keys are **OIDC-normalized** names (see [Normalized User Data](/v2/guides/verifications/normalized-user-data)) and appear back in `match.submitted_fields` and as `match.details` keys. See [User Input](/v2/api-reference/verifications/user-input).
## Which claims you receive
The claims under `user` are the ones the [workflow](/v2/guides/verifications/workflows) requested: the claims on its **Claims** tab, plus any claims set for the connection that ran. Nothing in the request body changes this set.
`source_id`, a stable identifier for the same person on the same connection, is **opt-in** in v2. Tick **Source ID** on the workflow's **Claims** tab to receive it. When the connection cannot derive it for this person, `source_id` is listed in `missing_claims`. `sub` is **not** a stable user identifier: it is the verification id and changes on every verification.
## What "Provenance" means
Provenance is the audit trail of the verification: what the source actually returned, and when.
* Presentation
* Credentials\[]: one entry per credential the source presented, carrying the source's own `claims` exactly as received (for match connections too) and, when the provider issues it, `evidence`.
* Evidence (when present)
* The original signed proof from the source (or a Hopae-signed equivalent when the provider issues none), so you can independently verify the result wasn't altered in transit. Shape and keys vary by provider. See [Evidence & Data Integrity](/v2/guides/verifications/evidence).
* Metadata
* Catalog identity, verification identifiers, and timestamps for audit and support (`verification_id`, `verified_at`, `status`, and `error` when the session ended with an error).
Read `provenance._metadata` for the catalog provider, credential, connection, and activated instance IDs. These are snapshotted when the session starts. Resolve issuer details through the catalog and source evidence.
### UserInfo payload structure
Here is a conceptual map of the payload. For detailed field specifications, refer to the [Data Types](/v2/guides/data-types/user) pages.
```jsonc theme={null}
{
"user": { "name": "Test User", "birthdate": "1990-01-01" },
"missing_claims": ["email"],
"acr": "urn:hopae:loa:4", // when reported by the provider
"hopae_loa": 4,
"hopae_loa_label": "high",
"verification_model": "disclosure",
"provenance": {
"presentation": {
"credentials": [
{
"claims": { "givenName": "Test", "surname": "User" }
// evidence is optional and provider-specific
}
]
},
"_metadata": {
"verification_id": "019bc4f2-8a31-7c5e-9d02-4f7a1b3e60d8",
"verified_at": "2026-09-21T09:19:52.110Z",
"status": "completed",
"provider_id": "smart-id",
"connection_id": "smart-id",
"credential_id": "smart-id",
"connection_instance_id": "conn_01J8XK2P4M9QR3TV"
}
},
"sub": "019bc4f2-8a31-7c5e-9d02-4f7a1b3e60d8"
}
```
### Match payload example
A complete userinfo response from a match connection (`id-nik-match`, Indonesia NIK):
```json theme={null}
{
"user": {
"name": "Test User",
"birthdate": "1990-01-01"
},
"missing_claims": [],
"hopae_loa": 4,
"hopae_loa_label": "high",
"verification_model": "match",
"match": {
"matched": true,
"granularity": "per_field",
"submitted_fields": [
"name",
"birthdate"
],
"details": {
"name": {
"matched": true,
"submitted_value": "Test User"
},
"birthdate": {
"matched": true,
"submitted_value": "1990-01-01"
}
}
},
"provenance": {
"presentation": {
"credentials": [
{
"claims": {
"fullName": true,
"dateOfBirth": true,
"nationalIdNo": true
},
"evidence": {
"token": {
"integrator_jws": "eyJhbGciOiJSUzI1NiIsInR5cCI6...",
"token_type": "integrator"
},
"names": "integrator_jws;token_type"
}
}
]
},
"_metadata": {
"verification_id": "019bc4f7-2b44-7f1a-9c3d-8e5b0a6f2c77",
"verified_at": "2026-09-03T10:02:19.551Z",
"status": "completed",
"provider_id": "id-nik",
"connection_id": "id-nik-match",
"credential_id": "id-nik-match",
"connection_instance_id": "conn_01J8XQ4M2T7VBK8R"
}
},
"sub": "019bc4f7-2b44-7f1a-9c3d-8e5b0a6f2c77"
}
```
For match connections, `provenance.presentation.credentials[].claims` carries the source's own response. This is the audit trail for what the source agreed with. It is not a duplicate of `match.details`. Note the intentional naming asymmetry: `userInput`, `match.submitted_fields`, and `match.details` use OIDC-normalized keys (e.g. `name`, `birthdate`), while `provenance` claims keep the source's native keys (e.g. `fullName`, `dateOfBirth`) so the audit trail stays aligned with the source.
`details` may include keys that are not in `submitted_fields` when the upstream provider emits additional verifier results (e.g., a liveness signal alongside the demographic match outcome).
## Related
* [Data Types: User](/v2/guides/data-types/user)
* [Data Types: Provenance](/v2/guides/data-types/provenance)
* [Level Of Assurance (LoA)](/v2/guides/verifications/assurance)
* [OIDC UserInfo](/v2/api-reference/oidc/userinfo)
* [REST UserInfo](/v2/api-reference/verifications/get-verification-userinfo)
## Next Steps
How OIDC fits into User Verification
Understand the end-to-end lifecycle
# Verification Flow
Source: https://docs.hopae.com/v2/guides/verifications/verification-flow
The statuses a verification moves through, and the five flow types that decide what the user does.
## Lifecycle
A verification is created against one [connection](/v2/guides/concepts/connections) and ends in one of four terminal statuses. It expires at `expiresAt`, **30 minutes** after creation, if it has not finished.
```mermaid theme={null}
stateDiagram-v2
direction LR
[*] --> awaiting_user_action: create
awaiting_user_action --> authenticating: user acts (some providers)
awaiting_user_action --> completed
authenticating --> completed
awaiting_user_action --> failed
authenticating --> failed
awaiting_user_action --> cancelled
authenticating --> cancelled
awaiting_user_action --> expired: expiresAt
authenticating --> expired: expiresAt
```
| Status | Meaning |
| :- | :- |
| `awaiting_user_action` | Waiting for the user. Returned by create, except for `query` connections. |
| `authenticating` | The user started at the provider. Not every provider reports it. |
| `completed` | Done. Read the result from userinfo. |
| `failed` | Ended in failure. `error.code` says why. |
| `cancelled` | You or the user cancelled. |
| `expired` | Not finished by `expiresAt`. Start a new verification. See below. |
`processing` appears in the schema but is never returned. If you see it, treat it like `authenticating`.
A verification belongs to the app that created it. Read and cancel it with the same app's credentials. Any other app gets `404`.
### Expiry
`expired` names an outcome. The API does not return it. Expiry is decided by time alone: a verification that has not finished by its `expiresAt` has expired.
Every verification, finished or not, is kept only until `expiresAt`. After that, every call for it returns `404`. So read `expiresAt` from the create response, stop waiting when it passes, and read userinfo as soon as the status is `completed`.
The Console's **Security & Compliance → Audit Log** keeps the record and shows it as `expired`. No webhook is sent for expiry.
## Flow types
The connection decides the flow type. With [OIDC](/v2/guides/oidc-integration), Hopae handles every flow for you. With the [REST API](/v2/guides/api-integration), your app does the user-facing part.
| Flow type | What the user does | Your app (REST) | Examples |
| :- | :- | :- | :- |
| `redirect` | Signs in on the provider's page | Send the user to `authorizationUrl` | `mitid`, `id-austria` |
| `qr` | Scans a QR code with an eID app | Show `qrData`, then poll | `freja-plus`, `diia` |
| `push` | Approves on a registered phone | Show `verificationCode`, then poll | `smart-id`, `sk-mobile-id` |
| `dc` | Shares a credential from a phone wallet | Open `linkData` on the phone, then poll | `google-wallet-us-mdl` |
| `query` | Nothing. Hopae checks the data you sent with the source | Read the create response. It is already final | `ng-nin`, `br-cpf` |
Sequence diagrams, full responses, and same-device handling for each flow type.
## The hosted flow
With OIDC, the user picks **country → credential → provider** on a Hopae-hosted page. Only connections enabled in the [workflow](/v2/guides/verifications/workflows) are offered. The provider step appears only when a credential is available through more than one provider. To skip the picker, pass `ui_connection_id`. See [OIDC Integration](/v2/guides/oidc-integration#pre-select-a-connection).
# Workflows
Source: https://docs.hopae.com/v2/guides/verifications/workflows
A workflow decides which connections are offered, which data you receive, and how the hosted screen looks.
A **workflow** is one verification setup inside an app. Every verification runs one workflow. Edit workflows in the Console under **Verification → Workflow**.
## The default workflow
* A **Quickstart** sandbox app and a production app start with a default workflow.
* In a **Start from scratch** sandbox app, create one. The first workflow becomes the default.
* Verifications use the default unless you pass `workflow_id` (OIDC) or `workflowId` (REST). Copy the id next to the workflow name in the editor.
Without any workflow, verifications fail with `409 WORKFLOW_NOT_CONFIGURED`.
## What a workflow controls
The editor has one tab for each part:
| Tab | Controls |
| :- | :- |
| **Claims** | The [normalized claims](/v2/guides/verifications/normalized-user-data) you receive, plus extra claims for specific connections. Tick **Source ID** to receive `source_id`, a stable identifier for the person. Claims the source could not provide are listed in `missing_claims`. |
| **Connections** | Which [activated connections](/v2/guides/concepts/connections/activation) are offered. Only enabled connections appear in the hosted flow or can be started with the API. Others return `403 PROVIDER_DISABLED_IN_WORKFLOW`. |
| **Branding** | The name and logo users see. See [Branding](/v2/guides/concepts/branding). |
| **Flow** | Rules after the verification, such as a minimum level of assurance (advanced). See [Workflow Nodes](/v2/guides/reference/workflow-nodes). |
| **Settings** | **Automatically enable newly activated connections**: enrol future activations in this workflow. |
## Use a workflow
```text OIDC theme={null}
https://connect.hopae.com/v2/auth?client_id=APP_ID&response_type=code&scope=openid%20hopae&redirect_uri=...&workflow_id=wf_01J8XJ4Q2R7TPX9K
```
```bash REST theme={null}
curl -X POST "https://api.hopae.com/connect/v2/verifications" \
-u "APP_ID:APP_SECRET" \
-H "Content-Type: application/json" \
-d '{"connectionId": "smart-id", "workflowId": "wf_01J8XJ4Q2R7TPX9K"}'
```
`GET /connections?workflowId=…` shows which connections a workflow enables.
## Try it without code
The **verification link** at the top of the editor opens the hosted flow for this workflow. Open it or copy it. Its settings let you choose the redirect URI and whether to show the intro page.
Changes to a production app's workflow apply to live traffic as soon as you save. See [Apps & Environments](/v2/guides/concepts/apps#saving-changes).
# Webhooks
Source: https://docs.hopae.com/v2/guides/webhook-signing
Receive verification and activation events, and verify their signatures.
Hopae sends an HTTP `POST` to your endpoint when a verification or a connection activation changes. Webhooks carry no personal data: fetch the result from userinfo with your app credentials.
## Set up
In the Console, open **Developers → Webhooks**. An app has two endpoints, one per payload format:
| Endpoint | Payload | Use |
| :- | :- | :- |
| **Webhook v2** | The v2 format on this page | New integrations |
| **Webhook v1** | The [v1 format](/guides/webhook-signing), unchanged | Existing v1 receivers |
Every event goes to each enabled endpoint in that endpoint's format, so with both enabled you receive it on both. Each endpoint has its own **Signing Secret**, in sandbox and production apps alike.
For `qr` and `push` verifications created with the REST API, events follow your status polls. If you stop polling, no terminal event is sent. See [REST API Integration](/v2/guides/api-integration#4-poll-until-finished).
## Events
| Event | When |
| :- | :- |
| `verification.initiated` | The verification was created |
| `verification.started` | The user started at the provider (only when the provider reports it) |
| `verification.succeeded` | Completed. Fetch the result from userinfo |
| `verification.failed` | Failed. `data.error.code` says why |
| `verification.cancelled` | Cancelled. `data.cancelledBy` is `client` or `user` |
| `activation.activated` | A connection activation was approved |
| `activation.rejected` | A connection activation was rejected. `data.rejection.reason` says why |
A verification sends at most one terminal event. There is **no expiry event**: a verification that has not finished by its `expiresAt` has expired. See [Expiry](/v2/guides/verifications/verification-flow#expiry).
## Payload
Every event has the same envelope: `id`, `type`, `apiVersion` (`v2`), `occurredAt`, `environment` (`sandbox` or `production`), `appId`, and `data`.
* `id` is the same on every redelivery of an event. Use it to deduplicate.
* Verification events always carry `data.verificationId`, `workflowId`, `connection`, and `createdAt`.
```json verification.succeeded theme={null}
{
"id": "evt_wpzjo4lgjljz3sloadupxpq64j",
"type": "verification.succeeded",
"apiVersion": "v2",
"occurredAt": "2026-09-29T17:01:49.797Z",
"environment": "sandbox",
"appId": "sb_k7g0p7by",
"data": {
"verificationId": "01a0ee1d-8f53-79f3-b7ec-39447dae7ff5",
"workflowId": "wf_01M3Q1TDE2J65BPPMFZXH3MHA9",
"connection": {
"connectionId": "co-runt-bio-match",
"credentialId": "co-runt-bio-match",
"connectionInstanceId": "conn_01M3Q1TDE2R58D3RJ2CM49V58R",
"provider": { "id": "co-runt", "name": "RUNT" }
},
"device": { "type": "desktop" },
"createdAt": "2026-09-29T17:01:49.779Z",
"result": {
"channel": { "type": "centralized_idp", "transport": "internet" }
},
"attributes": {
"requested": ["name", "given_name", "family_name", "document_number", "source_id"],
"provided": ["name", "given_name", "family_name", "document_number", "source_id"],
"missing": []
}
}
}
```
```json activation.activated theme={null}
{
"id": "evt_bjudhaqj7ulij5lfqtnpn4buyh",
"type": "activation.activated",
"apiVersion": "v2",
"occurredAt": "2026-09-29T17:02:15.066Z",
"environment": "production",
"appId": "11km4n0l",
"data": {
"activationInstanceId": "act_01M3Q1VWGS8GA7VQ13RVAXRCR0",
"provider": { "id": "ng-vid", "name": "Nigeria Voter ID" },
"connections": [
{
"connectionId": "ng-vid",
"credentialId": "ng-vid",
"connectionInstanceId": "conn_01M3Q1VWGVR5JCJDBKJ3V7HCW5"
}
]
}
}
```
### Headers
| Header | Value |
| :- | :- |
| `X-Hopae-Event-Id` | The event `id` |
| `X-Hopae-Event-Type` | The event `type` |
| `X-Hopae-Timestamp` | Unix seconds at send time |
| `X-Hopae-Signature` | `t=,v1=`, only when the endpoint has a signing secret |
| `User-Agent` | `Hopae-Connect-Webhook/1.0` |
## Signing secret
Under **Developers → Webhooks**, find the endpoint's **Signing Secret**:
* **Rotate secret** (rotate icon) creates or replaces the secret. Store it on your server, for example as an environment variable.
* **Remove secret** (trash icon) stops signing.
An endpoint without a secret sends unsigned deliveries.
## Verify the signature
The signature is an HMAC-SHA256, keyed with the endpoint's secret, over `.`. Recompute it and compare. Reject deliveries whose `t` is more than 5 minutes old.
```javascript Node.js theme={null}
const crypto = require('crypto');
function verifyWebhookSignature(req, secret) {
const sigHeader = req.headers['x-hopae-signature'];
if (!sigHeader) {
throw new Error('Missing X-Hopae-Signature header');
}
// Parse t= and v1= fields
const parts = Object.fromEntries(
sigHeader.split(',').map((p) => p.split('='))
);
const timestamp = parseInt(parts['t'], 10);
const v1 = parts['v1'];
if (!timestamp || !v1) {
throw new Error('Malformed X-Hopae-Signature header');
}
// Replay protection: reject if timestamp is older than 5 minutes
const nowSeconds = Math.floor(Date.now() / 1000);
if (Math.abs(nowSeconds - timestamp) > 300) {
throw new Error('Webhook timestamp too old: possible replay attack');
}
// Use the raw body exactly as received, never re-serialized JSON
if (typeof req.body !== 'string') throw new Error('Pass the raw request body');
const rawBody = req.body;
const toSign = `${timestamp}.${rawBody}`;
const expected = crypto.createHmac('sha256', secret).update(toSign).digest('hex');
// Constant-time comparison to prevent timing attacks
const expectedBuf = Buffer.from(expected, 'hex');
const receivedBuf = Buffer.from(v1, 'hex');
if (expectedBuf.length !== receivedBuf.length || !crypto.timingSafeEqual(expectedBuf, receivedBuf)) {
throw new Error('Signature mismatch: webhook not authenticated');
}
return true;
}
```
```python Python theme={null}
import hashlib
import hmac
import time
def verify_webhook_signature(headers: dict, raw_body: bytes, secret: str) -> bool:
sig_header = headers.get('x-hopae-signature') or headers.get('X-Hopae-Signature')
if not sig_header:
raise ValueError('Missing X-Hopae-Signature header')
parts = dict(p.split('=', 1) for p in sig_header.split(','))
timestamp = int(parts.get('t', 0))
v1 = parts.get('v1', '')
# Replay protection: reject if older than 5 minutes
if abs(time.time() - timestamp) > 300:
raise ValueError('Webhook timestamp too old: possible replay attack')
to_sign = f"{timestamp}.{raw_body.decode('utf-8')}"
expected = hmac.new(secret.encode('utf-8'), to_sign.encode('utf-8'), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, v1):
raise ValueError('Signature mismatch: webhook not authenticated')
return True
```
Use the **raw request body bytes**. Parsing the JSON and serializing it again changes the bytes and breaks the comparison.
In Express, use `express.raw({ type: 'application/json' })` on the webhook route and call `verifyWebhookSignature({ headers: req.headers, body: req.body.toString('utf8') }, secret)`.