Skip to main content
Skip table of contents

Getting Started - KreditCheck API Integration

Introduction

In addition to the normal data analysis, KreditCheck contains further rules that make it possible to carry out a simple evaluation using a traffic light model.
Extended documents can also be downloaded for documentation purposes.

Please read the documentation about the flow for KreditCheck.

Prerequisites

TL:TR

Used Systems

See Environments page for detailed information.

Used Endpoints

To use the KreditCheck Flow, at least the following endpoints are required:

Description

HTTP Method

Process Controller Endpoint

Link to API Doc

Import data from bank(s)

POST

/dataImports/{processToken}

finAPI API Documentation

Finalize the data import step

POST

/dataImports/{processToken}/finalize

finAPI API Documentation

Start data analysis (B2B only)

POST

/dataImports/{processToken}/analyze

finAPI API Documentation

Get result of an analysis (B2B only)

GET

/dataImports/{processToken}/analyze

finAPI API Documentation

Create credit check B2B (B2B only)

POST

/checks/{processToken}/kreditcheck/b2b

finAPI API Documentation

Create credit check B2C (B2C only)

POST

/checks/{processToken}/kreditcheck/b2c

finAPI API Documentation

Get the result of PDF DAC4Loan as zip file

GET

/checks/{processToken}/kreditcheck/{kreditCheckId}/analysis/dacForLoan/zip

finAPI API Documentation

Get the result of the account analysis as pdf file

GET

/checks/{processToken}/kreditcheck/{kreditCheckId}/analysis/account/pdf

finAPI API Documentation

cURL Example

Translated into cURL it looks like the following:

Step 1 - Import Data from Bank

After the creation of a Process Token and the exchange to an access token, we can start with the Bank import:

BASH
curl --location "https://di-processctl-finapi-general-sandbox.finapi.io/api/v1/dataImports/<processToken>" \
 --header 'Content-Type: application/json' \
 --header 'Authorization: Bearer <access_token>' \
 --data '{
   "search":"finAPI demo bank",
   "iban":"DE77533700080111111100",
   "loadOwnerData":false,
   "maxDaysForDownload":200,
   "webFormProfile":"default",
   "accountTypes":["CHECKING"],
   "callbackUrl":"https://domain.tld/callback/dataImport"
}'

The response looks like this:

JSON
{
  "webFormId": "f6381acb-d670-4467-8f50-6df32adbeb81",
  "webFormUrl": "https://webform-sandbox.finapi.io/wf/f6381acb-d670-4467-8f50-6df32adbeb81",
  "importId": "1916ec9e-13e0-4000-88fe-8a57ade79280"
}

This response now provides a WebForm URL, which must be displayed to the customer in a WebView. The customer can use it to log in to the bank and synchronize their accounts.

After the user has logged in and the data has been retrieved from the bank, finAPI calls the callbackUrl as a POST request.
A request similar to this one is sent for this purpose:

JSON
{
  "processToken": "<processToken>",
  "importId": "1916ec9e-13e0-4000-88fe-8a57ade79280",
  "result": {
    "status": "SUCCESSFUL",
    "accounts": [
      {
        "bank": "Test Bank",
        "iban": "DE77533700080111111100",
        "accountId": "123456",
        "accountHolder": "Max Musterman",
        "accountNumber": "1111111",
        "accountName": [
          "Girokonto",
          "VISA Kreditkarte"
        ],
        "accountCurrency": "EUR",
        "accountType": "CHECKING"
      }
    ]
  }
}

These steps can also be repeated for several banks.

Step 1.5 - Finalize the Bank Import

To complete the import, the system must be informed that all accounts are ready.

This step does not allow any further imports of bank accounts and must always be carried out after importing all accounts from all Banks.
If only one account is imported, this endpoint is called up after this import.
This is done by the “finalize” endpoint:

BASH
curl --location --request POST 'https://di-processctl-finapi-general-sandbox.finapi.io/api/v1/dataImports/<processToken>/finalize' \
 --header 'Content-Type: application/json' \
 --header 'Authorization: Bearer <access_token>'

If the result is 204 (No Content), this call was successful.

Step 2 - Analyze the Bank Import (KreditCheck B2B only)

This step is only necessary for KreditCheck B2B. In the case of KreditCheck B2C, the analysis can be skipped.

In this step, an extended analysis of the data is requested, to be able to recognize transfers and recurring payments more precisely.

BASH
curl --location --request POST 'https://di-processctl-finapi-general-sandbox.finapi.io/api/v1/dataImports/<processToken>/analyze' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer <access_token>'

If the result is 204 (No Content), this call was successful.

As the analysis is an asynchronous job, the result must be requested.
It is recommended that you do not select intervals that are too short in this case so as not to disturb processing due to excessive polling loads.
An ideal value is between 100ms and 500ms.

BASH
curl --location 'https://di-processctl-finapi-general-sandbox.finapi.io/api/v1/dataImports/<processToken>/analyze' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer <access_token>'

The successful result should look like this:

JSON
{
  "status": "SUCCESSFUL"
}

Step 3 - Create KreditCheck

Now the actual creation of the KreditCheck takes place.
The calls for B2B and B2C are listed below as a cURL example.
In the case of B2B, SCHUFA credentials must also be transferred, which are assigned directly by SCHUFA. finAPI has no involvement in the process.

The callback for the KreditCheck is important in order to obtain the result. A detailed description of the callback can be found in the API documentation.

KreditCheck B2B
BASH
curl --location --request POST 'https://di-processctl-finapi-general-sandbox.finapi.io/api/v1/checks/<processToken>/kreditcheck/b2b' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer <access_token>' \
--data '{
  "creditReportingCredentials": {
    "schufaCredentials": {
      "schufaUserId": "300/01182",
      "schufaPassword": "MIEAPI12"
    }
  },
  "applicantData": {
    "personalData": [
      {
        "title": "Dr.",
        "gender": "M",
        "firstName": "Fritz",
        "lastName": "Lang",
        "dateOfBirth": "2000-01-01",
        "placeOfBirth": "Berlin",
        "currentAddress": {
          "street": "Musterstraße 7",
          "zip": "12345",
          "city": "Beispielstadt",
          "country": "DEU"
        }
      }
    ],
    "companyData": {
      "companyType": "GMBH",
      "companyName": "My Company & Co",
      "currentAddress": {
        "companyStreet": "Musterstraße",
        "companyStreetHouseNumber": 5,
        "companyStreetHouseNumberAffix": "B",
        "companyPostcode": 12345,
        "companyCity": "Beispielstadt",
        "companyCountry": "DE"
      },
      "companyRegisterType": "gmbh",
      "companyRegisterId": "HRA 12345",
      "companyRegisterPostcode": 12345,
      "companyRegisterCity": "Beispielstadt",
      "companyVATId": 123456789
    }
  },
  "callbackUrl": "https://domain.tld"
}'

KreditCheck B2C
BASH
curl --location --request POST 'https://di-processctl-finapi-general-sandbox.finapi.io/api/v1/checks/<processToken>/kreditcheck/b2c' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer <access_token>' \
--data '{
  "rateAmount": 1000,
  "rateInsteadRent": true,
  "personalData": [
    {
      "title": "Dr.",
      "gender": "M",
      "firstName": "Fritz",
      "lastName": "Lang",
      "dateOfBirth": "2000-01-01",
      "placeOfBirth": "Berlin",
      "currentAddress": {
        "street": "Musterstraße 7",
        "zip": "12345",
        "city": "Beispielstadt",
        "country": "DEU"
      }
    }
  ],
  "callbackUrl": "https://domain.tld"
}'

The result of the KreditCheck request looks like this:

JSON
{
  "kreditCheckId": "19174505-d210-4000-839a-26cd595cb801"
}

The kreditCheckId is necessary to be able to assign the process during the callback.

Step 3.5 - Download PDF Data

The last step after the callback has been called is to download the PDF files for the documentation.

Ideally, the download is called up in the callback itself.

Download DAC4Loan PDF Zip File
BASH
curl --location 'https://di-processctl-finapi-general-sandbox.finapi.io/api/v1/checks/<processToken>/kreditcheck/<kreditCheckId>/analysis/dacForLoan/zip' \
--header 'Accept: application/zip' \
--header 'Authorization: Bearer <access_token>'
Download Account Analysis Result PDF

As the Account Analysis PDF is not intended for customers, it must be accessed by another user.
The predefined user client_admin is provided for this purpose.

BASH
curl --location 'https://di-processctl-finapi-general-sandbox.finapi.io/api/v1/checks/<processToken>/kreditcheck/<kreditCheckId>/analysis/account/pdf' \
--header 'Accept: application/pdf' \
--header 'Authorization: Bearer <access_token>'

Implementation Guide

See a full working project here: finAPI Data Intelligence Product Platform Examples (Bitbucket)
Code from this guide can be found here: KreditCheck (Bitbucket)
Environment overview can be found here: Environments

The guidelines and the example project are written in Kotlin with Spring Boot. But it is easily adoptable for other languages and frameworks.
For the HTTP connection, we are using here plain Java HttpClient, to be not restrictive for the client selection and that everything is transparent.

Only the actual functionality is discussed in this guideline. Used helper classes or the models can be looked up in the source code.

However, we always recommend using a generated client from our API, which reduces the effort of creating models and functions to access the endpoints and eliminates potential sources of error.

To learn how to generate and apply the API client using the Process Controller "Create user and exchange with access_token" as an example, please see the Getting Started - Code Generator (SDK) Integration.

Please also note that the code presented here and also the repository is only an illustration of a possible implementation. Therefore, the code is kept very simple and should not be used 1:1 in a production environment.

Preparation

In order to make the implementation of KreditCheck easier to understand, the individual technical steps in this guide have been outsourced to individual services.
To ensure that the handling of the HTTP client and the process only has to be implemented once, there are separate components that are generally used for this purpose.

Configuration

The following configurations are also required for the KreditCheck, which must be stored in the server:

JAVA
FINAPI_INSTANCES_PROCESSCTL_URL=https://di-processctl-finapi-general-sandbox.finapi.io/api/v1

FINAPI_SECURITY_CREDENTIALS_CLIENTID=<your finAPI Client ID>
FINAPI_SECURITY_CREDENTIALS_CLIENTSECRET=<your finAPI Client Secret>
FINAPI_SECURITY_CLIENTADMIN_USERNAME=client_admin
FINAPI_SECURITY_CLIENTADMIN_PASSWORD=<your client admin user password>
EXAMPLEAPP_INSTANCES_CALLBACK_URL=<domain and base path of the service, which is used to create the callback URL>

# only required for KreditCheck B2B
FINAPI_SECURITY_SCHUFA_CREDENTIALS_USERID=<your SCHUFA userID>
FINAPI_SECURITY_SCHUFA_CREDENTIALS_PASSWORD=<your SCHUFA password>

HttpClient for Process Controller (should be replaced with generated API)

This class is basically just a small wrapper around an HTTP client and is intended to simplify the processing of GET and POST calls in the services.

The public methods execPost() and execGet() are available for this purpose, which returns a string as the response in this example. In a generated client, the response would already be converted into an object by the client itself.

There is also the execGetData() method, which returns a ByteArray in order to download documents.

Code of ProcessControllerHttpClient
KOTLIN
package io.finapi.examples.kreditcheck.components

import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.stereotype.Component
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse

@Component
class ProcessControllerHttpClient(
    @Value("\${finapi.instances.processctl.url}") private val processCtlUrl: String
) {
    fun execPost(
        accessToken: String,
        endpointUri: String,
        body: String,
    ): HttpResponse<String> {
        val client = HttpClient.newBuilder().build()
        return client.send(
            createPostRequest(
                accessToken = accessToken,
                endpointUri = endpointUri,
                body = body
            ),
            HttpResponse.BodyHandlers.ofString()
        )
    }

    fun execGet(
        accessToken: String,
        endpointUri: String,
    ): HttpResponse<String> {
        val client = HttpClient.newBuilder().build()
        return client.send(
            createGetRequest(
                accessToken = accessToken,
                endpointUri = endpointUri,
            ),
            HttpResponse.BodyHandlers.ofString()
        )
    }

    fun execGetData(
        accessToken: String,
        endpointUri: String,
        contentType: AcceptContentType = AcceptContentType.JSON,
    ): HttpResponse<ByteArray> {
        val client = HttpClient.newBuilder().build()
        return client.send(
            createGetRequest(
                accessToken = accessToken,
                endpointUri = endpointUri,
            ),
            HttpResponse.BodyHandlers.ofByteArray()
        )
    }

    /**
     * Creates a POST HTTP request.
     *
     * @param accessToken The access token for authentication.
     * @param endpointUri The URI of the API endpoint.
     * @param body The body of the request.
     * @param acceptHeader The accept header value for the request. Default value is "application/json".
     * @return The created HttpRequest object.
     */
    private fun createPostRequest(
        accessToken: String,
        endpointUri: String,
        body: String,
        acceptHeader: String = MediaType.APPLICATION_JSON_VALUE
    ): HttpRequest {
        return HttpRequest.newBuilder()
            .uri(URI.create("${processCtlUrl}${endpointUri}"))
            // set Content-Type header to application/json
            .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
            .header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken")
            .header(HttpHeaders.ACCEPT, acceptHeader)
            // add the body
            .POST(
                // use empty body to sync all accounts
                HttpRequest.BodyPublishers.ofString(body)
            ).build()
    }

    /**
     * Creates a GET HTTP request.
     *
     * This method takes an access token and an API endpoint URI and creates an HTTP GET request.
     * The request includes the access token in the Authorization header and sets the
     * Accept header.
     * The URL is built using the given endpoint URI and the diUrl variable.
     * The created HttpRequest object is returned.
     *
     * @param accessToken The access token for authentication.
     * @param endpointUri The URI of the API endpoint.
     * @param acceptType The content type for the request.
     * @return The created HttpRequest object.
     */
    private fun createGetRequest(
        accessToken: String,
        endpointUri: String,
        acceptType: AcceptContentType = AcceptContentType.JSON,
    ): HttpRequest {
        return HttpRequest.newBuilder()
            .uri(URI.create("${processCtlUrl}${endpointUri}"))
            .header(HttpHeaders.ACCEPT, acceptType.contentType)
            .header(HttpHeaders.AUTHORIZATION, "Bearer $accessToken")
            // add the body
            .GET()
            .build()
    }

    enum class AcceptContentType(val contentType: String) {
        JSON(MediaType.APPLICATION_JSON_VALUE),
        PDF(MediaType.APPLICATION_PDF_VALUE),
        ZIP("application/zip")
    }
}

Process Entity Manager

In principle, some data must be saved during the process. In addition, the process should contain a status that shows the last point at which the process was.
To make these updates on the entity as simple as possible, the ProcessEntityManager was created for the KreditCheck.

It offers the usual methods for loading a process (processEntity(processId: String)), updating (updateStatus(processEntity: DiProcessEntity, status: EnumProcessStatus) for an update on an already loaded entity, and updateStatus(processId: String, status: EnumProcessStatus) for updating and loading an entity).

Finally, there is of course the save(processEntity: DiProcessEntity) method to be able to save the process initially.

Code of ProcessEntityManager
KOTLIN
package io.finapi.examples.kreditcheck.components

import io.finapi.common.processes.models.DiProcessEntity
import io.finapi.common.processes.models.EnumProcessStatus
import io.finapi.common.processes.repositories.DiProcessesRepository
import org.springframework.stereotype.Component

@Component
class ProcessEntityManager(
    private val diProcessesRepository: DiProcessesRepository
) {
    fun processEntity(processId: String): DiProcessEntity {
        val processEntity = diProcessesRepository.findFirstByProcessId(processId = processId)
        require(processEntity != null) { "Process Entity not found." }
        require(processEntity.accessToken != null) { "Process Entity has no valid access token." }

        return processEntity
    }

    fun updateStatus(processEntity: DiProcessEntity, status: EnumProcessStatus): DiProcessEntity {
        processEntity.status = status
        return save(processEntity)
    }

    fun updateStatus(processId: String, status: EnumProcessStatus): DiProcessEntity {
        val processEntity = processEntity(processId = processId)
        processEntity.status = status
        return save(processEntity)
    }

    fun save(processEntity: DiProcessEntity): DiProcessEntity {
        return diProcessesRepository.save(processEntity)
    }
}

Step 1 - Import Data from Bank

First, we create a DataImportService, which is responsible for the data import calls (importing, finalizing).

The service receives the configuration of the base callback URL via the configuration structure.
This is used to later send a URL to the finAPI service, which can be called by the service for feedback in order to continue the process.

Further parameters are the ProcessCtlAuthorization (See Obtain Authorization via Process Controller), the previously created ProcessEntityManager, and an ObjectMapper.

We also create a logger and create constants for the API calls to be made. The last of these is not necessary if you are using a generated client.

KOTLIN
@Service
class DataImportService(
    @Value("\${exampleapp.instances.callback.url}") private val callbackUrl: String,
    private val httpClient: ProcessControllerHttpClient,
    private val processCtlAuth: ProcessCtlAuthorization,
    private val processEntityManager: ProcessEntityManager,
    private val objectMapper: ObjectMapper
) {
    companion object {
        private val log = KotlinLogging.logger { }
        private const val URI_DATA_IMPORT = "/dataImports"
        private const val URI_DATA_IMPORT_FINALIZE = "/finalize"
    }
}

A ProcessToken is required for the process.
We obtain this via the ProcessCtlAuthorization service described in Obtain Authorization via Process Controller .
The ProcessToken can also be “exchanged” directly into an Access Token.

KOTLIN
@Service
class DataImportService(
    @Value("\${exampleapp.instances.callback.url}") private val callbackUrl: String,
    private val httpClient: ProcessControllerHttpClient,
    private val processCtlAuth: ProcessCtlAuthorization,
    private val processEntityManager: ProcessEntityManager,
    private val objectMapper: ObjectMapper
) {
    // -------- Authorization function
    private fun createProcessAndToken(): Pair<String, CreateProcessTokenResponse> {
        // create a process token
        val createProcessResult = processCtlAuth.createProcessToken(
            clientRef = UUID.randomUUID().toString(),
            processId = ProcessId.CREDIT_CHECK_B2B
        )

        // get a valid access_token from the process token
        val accessToken = processCtlAuth.exchangeProcessTokenToAccessToken(
            processToken = createProcessResult.processes[0].processToken
        )

        return Pair(accessToken, createProcessResult)
    }
    [...]
}

A body is required for the request to the data import. We create this in a small helper method.
To make the example simple, only an IBAN is transferred.
In addition, the callback URL is created, which is called by finAPI after the data import.

KOTLIN
@Service
class DataImportService(
    @Value("\${exampleapp.instances.callback.url}") private val callbackUrl: String,
    private val httpClient: ProcessControllerHttpClient,
    private val processCtlAuth: ProcessCtlAuthorization,
    private val processEntityManager: ProcessEntityManager,
    private val objectMapper: ObjectMapper
) {
    private fun createDataImportBodyImportModel(
        processId: String,
        iban: String? = null,
    ): KreditCheckDataImportRequestModel {
        val callbackUri = callbackUrl +
                KreditCheckDataImportCallbackApi.URI_BASEPATH +
                KreditCheckDataImportCallbackApi.URI_CB_DATA_IMPORT +
                "/$processId"
        return KreditCheckDataImportRequestModel(
            iban = iban,
            loadOwnerData = true,
            callbackUrl = callbackUri
        )
    }
    [...]
}

The API call is now implemented.

The process entity, which must be created in the calling function, is passed to this function.
This entity contains an internal ProcessID, as well as the Access Token and the Process Token.

This creates the body object using the function just created and executes the request using the execPost() method.
As we only work with strings for the guide, the request object must be converted into a string using the ObjectMapper in this case.
Ideally, a generated client is used at this point, which accepts the object directly and also returns the response as an object.

The result is then checked to ensure that it corresponds to a 2xx status.
If this is not the case, something has gone wrong, which is why we update the status in the Process Entity.

If everything was successful, the result is converted into the webFormResult object, the status is set to DATA_IMPORT_RECEIVED and the response is returned.

CODE
@Service
class DataImportService(
    @Value("\${exampleapp.instances.callback.url}") private val callbackUrl: String,
    private val httpClient: ProcessControllerHttpClient,
    private val processCtlAuth: ProcessCtlAuthorization,
    private val processEntityManager: ProcessEntityManager,
    private val objectMapper: ObjectMapper
) {
    private fun callImportDataApi(
        processEntity: DiProcessEntity,
        iban: String? = null // used for Bank search
    ): KreditCheckDataImportResponseModel {
        log.info("[${processEntity.processId}] Starting data import...")
        // create request body
        val body = createDataImportBodyImportModel(
            processId = processEntity.processId,
            iban = iban
        )

        // create request URI for https://<process_controller>/api/v1/dataImports/{processToken}
        val response = httpClient.execPost(
            accessToken = processEntity.accessToken!!,
            endpointUri = "${URI_DATA_IMPORT}/${processEntity.processToken}",
            body = objectMapper.writeValueAsString(body)
        )

        // check for status code is 2xx or log and throw an exception
        try {
            StatusCodeCheckUtils.checkStatusCodeAndLogErrorMessage(
                response = response,
                errorMessage = "Unable to create data import."
            )
        } catch (@Suppress("TooGenericExceptionCaught") ex: RuntimeException) {
            log.error("[${processEntity.processId}] Starting data import...failed (${response.statusCode()})")
            processEntityManager.updateStatus(
                processEntity = processEntity,
                status = EnumProcessStatus.DATA_IMPORT_FAILED
            )
            throw ex
        }

        // map the result
        val webFormResult = objectMapper.readValue(
            response.body(),
            KreditCheckDataImportResponseModel::class.java
        )

        // save successful state for getting a valid WebForm
        processEntityManager.updateStatus(
            processEntity = processEntity,
            status = EnumProcessStatus.DATA_IMPORT_RECEIVED
        )

        log.info("[${processEntity.processId}] Starting data import...done")
        return webFormResult
    }

    [...]
}

The public function for the import is now created.
This first calls the previously created createProcessAndToken() to obtain a process token and an access token.
A process entity is then created and saved. This contains a unique process ID, which we can use to identify the process, as well as the process and access token.
The initial status is set to DATA_IMPORT_CREATED.

After the process has been saved, the function callImportDataApi() is called.
The webFormUrl contained in the result must now be opened in a browser.

Since the guide is only a very simple demo, which is controlled centrally from a test class, we simply log the WebForm URL so that we can open the browser from the logs.
For a real implementation, the URL should be delivered to the frontend that opens the WebForm.

KOTLIN
@Service
class DataImportService(
    @Value("\${exampleapp.instances.callback.url}") private val callbackUrl: String,
    private val httpClient: ProcessControllerHttpClient,
    private val processCtlAuth: ProcessCtlAuthorization,
    private val processEntityManager: ProcessEntityManager,
    private val objectMapper: ObjectMapper
) {
    // -------- Import Account Data Functions
    fun importData(iban: String): String {
        // Start the process by creating a Process and Access Token
        val accessTokenAndProcess = createProcessAndToken()

        // create a process entity for internal tracking and callback handling
        val processEntity = processEntityManager.save(
            DiProcessEntity(
                processId = UUID.randomUUID().toString(),
                processToken = accessTokenAndProcess.second.processes[0].processToken,
                accessToken = accessTokenAndProcess.first,
                status = EnumProcessStatus.DATA_IMPORT_CREATED
            )
        )

        // start import for data user
        val webForm = callImportDataApi(
            processEntity = processEntity,
            iban = iban
        )

        // Now we have to open a WebView to let the user login with WebForm.
        // The URL is shown in the log.
        // After the sync was successful, the application will continue.
        log.info(webForm.webFormUrl)

        return processEntity.processId
    }
    [...]
}

Implementation of the callback endpoint for data import

As soon as the import has been completed on the finAPI side, the finAPI callback is called. The callback itself is quite simple.

It is a REST endpoint that is publicly accessible. It must be implemented as a POST method.

To make the call as unique as possible, we add our processId to the path. This makes it possible to load the process entity using this identifier.

To be able to set a correct status, we check whether the callback has set the errorMessage field. If this is set, an error has occurred during the import (e.g. aborted by the user, problems with the bank...).

KOTLIN
package io.finapi.examples.kreditcheck

import io.finapi.common.processes.models.EnumProcessStatus
import io.finapi.examples.kreditcheck.components.ProcessEntityManager
import io.finapi.examples.kreditcheck.models.KreditCheckDataImportCallbackRequestModel
import mu.KotlinLogging
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController

@RestController
class KreditCheckDataImportCallbackApi(
    private val processEntityManager: ProcessEntityManager
) {
    @PostMapping("$URI_BASEPATH$URI_CB_DATA_IMPORT/{processId}")
    fun dataImportCallback(
        @RequestBody data: KreditCheckDataImportCallbackRequestModel,
        @PathVariable(name = "processId") processId: String
    ) {
        if (validateCallback(data = data)) {
            processEntityManager.updateStatus(
                processId = processId,
                status = EnumProcessStatus.DATA_IMPORT_COMPLETED
            )
        } else {
            processEntityManager.updateStatus(
                processId = processId,
                status = EnumProcessStatus.DATA_IMPORT_FAILED
            )
            log.error("[${processId}] Callback has received the errorMessage [${data.errorMessage}]".trimIndent())
        }
    }

    private fun validateCallback(data: KreditCheckDataImportCallbackRequestModel): Boolean {
        return data.errorMessage == null
    }

    companion object {
        private val log = KotlinLogging.logger { }
        const val URI_BASEPATH = "/callbacks"
        const val URI_CB_DATA_IMPORT = "/kreditCheck/dataImport"
    }
}

Step 1.5 - Finalize the Bank Import

Once the import of the accounts and banks has been completed, this must be communicated to the system.
The Finalize endpoint is called up for this purpose.
We add the function to the previously created class DataImportService.
In terms of content, it simply calls the endpoint, checks the HTTP status code, and updates the process entity status.

KOTLIN
@Service
class DataImportService(
    @Value("\${exampleapp.instances.callback.url}") private val callbackUrl: String,
    private val httpClient: ProcessControllerHttpClient,
    private val processCtlAuth: ProcessCtlAuthorization,
    private val processEntityManager: ProcessEntityManager,
    private val objectMapper: ObjectMapper
) {
    fun callFinalizeImportApi(processId: String) {
        log.info("[${processId}] Finalizing the data import...")
        val processEntity = processEntityManager.updateStatus(
            processId = processId,
            status = EnumProcessStatus.DATA_IMPORT_FINALIZE_STARTED
        )

        // create client and execute call for data import
        val response = httpClient.execPost(
            accessToken = processEntity.accessToken!!,
            endpointUri = "${URI_DATA_IMPORT}/${processEntity.processToken}${URI_DATA_IMPORT_FINALIZE}",
            body = "{}" // empty body
        )

        // check for status code is 2xx or log and throw an exception
        try {
            StatusCodeCheckUtils.checkStatusCodeAndLogErrorMessage(
                response = response,
                errorMessage = "Unable to finalize data import."
            )
            processEntityManager.updateStatus(
                processEntity = processEntity,
                status = EnumProcessStatus.DATA_IMPORT_FINALIZE_COMPLETED
            )
        } catch (@Suppress("TooGenericExceptionCaught") ex: RuntimeException) {
            log.error("[${processId}] Finalizing the data import...failed (${response.statusCode()})")
            processEntityManager.updateStatus(
                processEntity = processEntity,
                status = EnumProcessStatus.DATA_IMPORT_FINALIZE_FAILED
            )
            throw ex
        }
        log.info("[${processId}] Finalizing the data import...done")
    }
    [...]
}

Completing Step 1

How all the components work together can be seen in the CreditCheckServiceTest test (BitBucket).

In short, the function importData() must be called first.
After that, you have to wait until the status of the process entity has changed.

The implementation of the step1PollForWebFormFinishedAndFinalize() function is relevant here.
This uses the Awaitlity library to query the database every 5 seconds for a maximum of 5 minutes to see if the status has changed.
If the 5 minutes are up, there is a high probability that something went wrong when the callback was called.
The 5 seconds can of course be set to a much shorter time for production. A value between 100ms and 500ms is recommended here to avoid overloading the database.

In this example, the method callFinalizeImportApi() is called directly after a successful callback.
If multiple accounts from different banks are to be imported, this step may only be performed after all imports have been successfully confirmed.
To do this, the callback URL would have to be extended by another dynamic parameter (e.g.
“<basepath>/{processId}/{importId}”), so that each callback can be assigned to the process and the import.

This can look like this:

KOTLIN
fun testKreditCheck() {
    // --- Step 1: Import Data with IBAN (for simplification)
    val processId = step1StartCreateImportDataWithIban()
    step1PollForWebFormFinishedAndFinalize(processId = processId)
}

/**
  * This method starts the import process for data associated with the given IBAN.
  * It creates a process and access token, saves the process entity for tracking and callback handling,
  * calls the import data API with the provided IBAN, and logs the URL to open a WebView for user login with WebForm.
  *
  * @param iban The International Bank Account Number (IBAN) for which data needs to be imported.
  * @return The ID of the process.
  */
private fun step1StartCreateImportDataWithIban(): String {
    return dataImportService.importData(
        iban = "DE77533700080111111100"
    )
}

/**
  * This method waits for the Callback and finalize the import, if Web Form was successful.
  *
  * @param processId The ID of the process.
  */
private fun step1PollForWebFormFinishedAndFinalize(processId: String) {
    var processEntity: DiProcessEntity? = null
    Awaitility.await()
        .withPollInterval(Durations.FIVE_SECONDS)
        // 5 minutes should be a good timeout time for the user to finish the WebForm
        .atMost(Durations.FIVE_MINUTES)
        .until {
            processEntity = processEntityManager.processEntity(processId = processId)
            // if the status has been changed from DATA_IMPORT_RECEIVED, we can continue
            processEntity!!.status != EnumProcessStatus.DATA_IMPORT_RECEIVED
        }

    require(
        EnumProcessStatus.DATA_IMPORT_COMPLETED == processEntity!!.status
    ) {
        "Web Form was not completed."
    }

    dataImportService.callFinalizeImportApi(processId = processId)
}

Step 2 - Analyze the Bank Import (KreditCheck B2B only)

The analysis of the bank import is only necessary for KreditCheck B2B.
It carries out extended analyses of the transactions to recognize recurring transactions or transfers.

To start the analysis, one endpoint must be called and another must be polled until the analysis is complete.

The class AnalyzeImportService is created in order to be able to properly organize the code.

The class again requires the ProcessControllerHttpClient to communicate with the API, the ProcessEntityManager for state management, and an ObjectMapper to manage the strings if no generated client is available.

The first function that is implemented is the call to the API to start the analysis.
No body is required for this, which is why we set an empty body. However, this is more due to the function itself, because it requires a body for POST.

Finally, the status is set in the process entity so that it is clear that we are in the data analysis status.

KOTLIN
@Service
class AnalyzeImportService(
    private val httpClient: ProcessControllerHttpClient,
    private val processEntityManager: ProcessEntityManager,
    private val objectMapper: ObjectMapper
) {
    // -------- Analyze Account Data Functions
    private fun callAnalyzeImportApi(processId: String) {
        log.info("[${processId}] Start extended analysis of the data import...")
        val processEntity = processEntityManager.updateStatus(
            processId = processId,
            status = EnumProcessStatus.DATA_IMPORT_ANALYZE_STARTED
        )

        // create request URI for https://<process_controller>/api/v1/dataImports/{processToken}/analyze
        val response = httpClient.execPost(
            accessToken = processEntity.accessToken!!,
            endpointUri = "$URI_DATA_IMPORT/${processEntity.processToken}$URI_DATA_IMPORT_ANALYZE",
            body = "{}" // empty body
        )

        // check for status code is 2xx or log and throw an exception
        try {
            StatusCodeCheckUtils.checkStatusCodeAndLogErrorMessage(
                response = response,
                errorMessage = "Unable to analyze data import."
            )
        } catch (@Suppress("TooGenericExceptionCaught") ex: RuntimeException) {
            log.error("[${processId}] Start extended analysis of the data import...failed (${response.statusCode()})")
            processEntityManager.updateStatus(
                processEntity = processEntity,
                status = EnumProcessStatus.DATA_IMPORT_ANALYZE_FAILED
            )
            throw ex
        }
        processEntityManager.updateStatus(
            processEntity = processEntity,
            status = EnumProcessStatus.DATA_IMPORT_ANALYZE_STARTED
        )
        log.info("[${processId}] Start extended analysis of the data import...done")
    }
    
    companion object {
        private val log = KotlinLogging.logger { }
        private const val URI_DATA_IMPORT = "/dataImports"
        private const val URI_DATA_IMPORT_ANALYZE = "/analyze"
    }
}

In the next step, the functions callGetAnalyzeResultApi() and validateResult() are created.
The former retrieves the status of the analysis via a GET request, checks the HTTP status again, maps the JSON string into an object, and passes the result to the validateResult() function.
This checks the status of the analysis and sets it accordingly in our Process Entity so that it remains traceable.

KOTLIN
@Service
class AnalyzeImportService(
    private val httpClient: ProcessControllerHttpClient,
    private val processEntityManager: ProcessEntityManager,
    private val objectMapper: ObjectMapper
) {
    private fun callGetAnalyzeResultApi(processId: String): StatusEnum {
        log.info("[${processId}] Check extended analysis of the data import to be finished...")
        val processEntity = processEntityManager.processEntity(processId = processId)

        // create request URI for https://<process_controller>/api/v1/dataImports/{processToken}/analyze
        val response = httpClient.execGet(
            accessToken = processEntity.accessToken!!,
            endpointUri = "$URI_DATA_IMPORT/${processEntity.processToken}$URI_DATA_IMPORT_ANALYZE",
        )

        // check for status code is 2xx or log and throw an exception
        try {
            StatusCodeCheckUtils.checkStatusCodeAndLogErrorMessage(
                response = response,
                errorMessage = "Unable to analyze data import."
            )
        } catch (_: RuntimeException) {
            // If the service has an error, we ignore it here, because it could be a temporary problem.
            // The polling is also stopped after a period of time, so we do not have the risk of an endless loop.
            // For production, a better exception handling is good.
            // To be able to ignore temporary problems, we return the status IN_PROGRESS.
            log.info("[${processId}] Check extended analysis of the data import to be finished...failed" +
                    " (${response.statusCode()})")
            return StatusEnum.IN_PROGRESS
        }

        // map the result
        val analyzeResult = objectMapper.readValue(
            response.body(),
            StatusModel::class.java
        )

        validateResult(
            analyzeResult = analyzeResult,
            processEntity = processEntity
        )

        return analyzeResult.status
    }

    private fun validateResult(analyzeResult: StatusModel, processEntity: DiProcessEntity) {
        when (analyzeResult.status) {
            StatusEnum.IN_PROGRESS -> {
                if (processEntity.status != EnumProcessStatus.DATA_IMPORT_ANALYZE_IN_PROGRESS) {
                    processEntityManager.updateStatus(
                        processEntity = processEntity,
                        status = EnumProcessStatus.DATA_IMPORT_ANALYZE_IN_PROGRESS
                    )
                }
            }

            StatusEnum.SUCCESSFUL -> {
                if (processEntity.status != EnumProcessStatus.DATA_IMPORT_ANALYZE_COMPLETED) {
                    processEntityManager.updateStatus(
                        processEntity = processEntity,
                        status = EnumProcessStatus.DATA_IMPORT_ANALYZE_COMPLETED
                    )
                }
                log.info(
                    "[${processEntity.processId}] Check extended analysis of the data import to be finished...done"
                )
            }

            StatusEnum.FAILED -> {
                if (processEntity.status != EnumProcessStatus.DATA_IMPORT_ANALYZE_FAILED) {
                    processEntityManager.updateStatus(
                        processEntity = processEntity,
                        status = EnumProcessStatus.DATA_IMPORT_ANALYZE_FAILED
                    )
                }
                // Throws an exception if it failed to produce an error.
                // Should be more concrete for production.
                @Suppress("TooGenericExceptionThrown")
                throw RuntimeException("Analysis failed")
            }
        }
    }
    [...]
}

To be able to use both functions, we now extend the class with a public function that controls everything.

The analyzeImport() function first calls the POST endpoint of the analysis to start an analysis.
Then the Awaitibility framework is used again to poll the status.
In this example, 1 second is used as the interval and polled for a maximum of 1 minute.
The values can also be adjusted depending on the account size.
If the target group tends to be smaller companies, it is also possible to poll only every 500ms.
As a rule, however, the analysis should be completed after 1 second.

KOTLIN
@Service
class AnalyzeImportService(
    private val httpClient: ProcessControllerHttpClient,
    private val processEntityManager: ProcessEntityManager,
    private val objectMapper: ObjectMapper
) {
    fun analyzeImport(processId: String): StatusEnum {
        callAnalyzeImportApi(processId = processId)
        // Polling the analyze endpoint until the result is completed before continuing
        var status: StatusEnum? = null
        Awaitility.await()
            .withPollInterval(Durations.ONE_SECOND)
            // Depending on your customer base, this value can be set different.
            // The analysis depends on the amount of transactions.
            // Big enterprise accounts can take longer than regular end-customer accounts.
            .atMost(Durations.ONE_MINUTE)
            .until {
                status = callGetAnalyzeResultApi(processId = processId)
                status != StatusEnum.IN_PROGRESS
            }
        // If something went completely wrong (status is null), we return FAILED
        return status ?: StatusEnum.FAILED
    }
    
    [...]
}

Once this is done, this function can be called by the main program and when the method is finished, the KreditCheck itself can be created.

Step 3 - Create KreditCheck

The functionality for creating the KreditCheck itself consists of just one call.
In the case of KreditCheck B2C, the URL and the body are slightly different, but the procedure remains the same.

The KreditCheck works again asynchronously via a callback.
First, the service for creating the KreditCheck is created.

The class again contains a public function to create the credit check (createKreditCheck()) and a private function for the actual API call (callKreditCheckApi()).
The callbackUrl() function is also public so that the caller of the createKreditCheck() function can generate a correct URL which is used as a callback.

KOTLIN
@Service
class KreditCheckService(
    @Value("\${exampleapp.instances.callback.url}") private val callbackUrl: String,
    private val httpClient: ProcessControllerHttpClient,
    private val processEntityManager: ProcessEntityManager,
    private val objectMapper: ObjectMapper
) {
    fun createKreditCheck(processId: String, kreditCheckRequestBody: KreditCheckB2bRequestModel) {
        log.info("[${processId}] Starting KreditCheck...")
        callKreditCheckApi(
            processId = processId,
            body = kreditCheckRequestBody
        )
        log.info("[${processId}] Starting KreditCheck...done")
    }

    fun callbackUrl(processId: String): String {
        return callbackUrl +
                KreditCheckResultCallbackApi.URI_BASEPATH +
                KreditCheckResultCallbackApi.URI_CB_RESULT +
                "/$processId"
    }

    private fun callKreditCheckApi(processId: String, body: KreditCheckB2bRequestModel) {
        val processEntity = processEntityManager.updateStatus(
            processId = processId,
            status = EnumProcessStatus.KREDITCHECK_STARTED
        )

        // create request URI for https://<process_controller>/api/v1/checks/{processToken}/kreditcheck/b2b
        val response = httpClient.execPost(
            accessToken = processEntity.accessToken!!,
            endpointUri = "$URI_CHECKS/${processEntity.processToken}$URI_KREDITCHECK",
            body = objectMapper.writeValueAsString(body)
        )

        // check for status code is 2xx or log and throw an exception
        try {
            StatusCodeCheckUtils.checkStatusCodeAndLogErrorMessage(
                response = response,
                errorMessage = "Unable to start KreditCheck."
            )
            val result = objectMapper.readValue(
                response.body(),
                KreditCheckResponseModel::class.java
            )
            processEntity.kreditCheckId = result.kreditCheckId.toString()
        } catch (@Suppress("TooGenericExceptionCaught") ex: RuntimeException) {
            processEntityManager.updateStatus(
                processEntity = processEntity,
                status = EnumProcessStatus.KREDITCHECK_FAILED
            )
            throw ex
        }
        processEntityManager.updateStatus(
            processEntity = processEntity,
            status = EnumProcessStatus.KREDITCHECK_IN_PROGRESS
        )
    }

    companion object {
        private val log = KotlinLogging.logger { }
        private const val URI_CHECKS = "/checks"
        private const val URI_KREDITCHECK = "/kreditcheck/b2b"
    }
}

To ensure that the code remains compilable, we build the PDF download in the next step and then the callback service.

Step 3.5 - Download PDF Data

A separate service KreditCheckPdfService is created for downloading the PDF.
If the account analysis as PDF file is to be downloaded, it is necessary that the client credentials (clientAdminUser and clientAdminPassword) are transferred. The reason for this is that this PDF is primarily intended for the bank and should not be downloadable via a user token.

In the first step, we create the function callKreditCheckResultDataApi(), which takes over the API call and the validation of the HTTP status code.

KOTLIN
@Service
class KreditCheckPdfService(
    @Value("\${finapi.security.clientAdmin.username}") private val clientAdminUser: String,
    @Value("\${finapi.security.clientAdmin.password}") private val clientAdminPassword: String,
    private val httpClient: ProcessControllerHttpClient,
    private val processEntityManager: ProcessEntityManager,
    private val oAuthAuthorization: OAuthAuthorization
) {
    private fun callKreditCheckResultDataApi(
        processEntity: DiProcessEntity,
        uri: String,
        acceptType: ProcessControllerHttpClient.AcceptContentType,
        // default token is processEntity.accessToken
        accessToken: String = processEntity.accessToken!!,
    ): ByteArray {
        val response = httpClient.execGetData(
            accessToken = accessToken,
            endpointUri = uri,
            contentType= acceptType
        )

        // check for status code is 2xx or log and throw an exception
        try {
            StatusCodeCheckUtils.checkStatusCodeAndLogErrorMessage(
                response = response,
                errorMessage = "Unable to get KreditCheck data."
            )
        } catch (@Suppress("TooGenericExceptionCaught") ex: RuntimeException) {
            processEntityManager.updateStatus(
                processEntity = processEntity,
                status = EnumProcessStatus.KREDITCHECK_PDF_REPORT_FAILED
            )
            throw ex
        }

        return response.body()
    }

    companion object {
        private val log = KotlinLogging.logger { }
        private const val URI_CHECKS = "/checks"
        private const val URI_KREDITCHECK = "/kreditcheck"
        private const val URI_DAC_FOR_LOAN = "/analysis/dacForLoan/zip"
        private const val URI_ACCOUNT_ANALYSIS = "/analysis/account/pdf"
    }
}

Now we can add the functions that prepare the corresponding calls.
The function fetchReportPdfAsZip() retrieves the DAC4Loan PDF. In this case, the user's access token can be used.
In the fetchAccountAnalysisPdf() function, as mentioned before, a login is made for the client_admin user, as the endpoint does not allow the PDF to be downloaded in the context of the user.
Both functions return the result as a byte array, which can then be saved.

KOTLIN
@Service
class KreditCheckPdfService(
    @Value("\${finapi.security.clientAdmin.username}") private val clientAdminUser: String,
    @Value("\${finapi.security.clientAdmin.password}") private val clientAdminPassword: String,
    private val httpClient: ProcessControllerHttpClient,
    private val processEntityManager: ProcessEntityManager,
    private val oAuthAuthorization: OAuthAuthorization
) {
    fun fetchReportPdfAsZip(processId: String): ByteArray {
        log.info("[${processId}] Fetch the KreditCheck Report PDF zip file...")
        val processEntity = processEntityManager.updateStatus(
            processId = processId,
            status = EnumProcessStatus.KREDITCHECK_PDF_REPORT_STARTED
        )

        val zip = callKreditCheckResultDataApi(
            processEntity = processEntity,
            acceptType = ProcessControllerHttpClient.AcceptContentType.ZIP,
            uri = "$URI_CHECKS/${processEntity.processToken}$URI_KREDITCHECK" +
                    "/${processEntity.kreditCheckId}$URI_DAC_FOR_LOAN"
        )

        processEntityManager.updateStatus(
            processEntity = processEntity,
            status = EnumProcessStatus.KREDITCHECK_PDF_REPORT_COMPLETED
        )

        log.info("[${processId}] Fetch the KreditCheck Report PDF zip file...done")

        return zip
    }

    /**
     * This has to be done with the Client Admin client.
     * Reason for that is, that the data is just for the Bank and not for the user.
     */
    fun fetchAccountAnalysisPdf(processId: String): ByteArray {
        log.info("[${processId}] Fetch the KreditCheck account analysis PDF file...")
        val processEntity = processEntityManager.updateStatus(
            processId = processId,
            status = EnumProcessStatus.KREDITCHECK_PDF_REPORT_STARTED
        )

        val clientAdminToken = oAuthAuthorization.obtainToken(
            username = clientAdminUser,
            password = clientAdminPassword
        )
        val pdf = callKreditCheckResultDataApi(
            accessToken = clientAdminToken,
            processEntity = processEntity,
            acceptType = ProcessControllerHttpClient.AcceptContentType.PDF,
            uri = "$URI_CHECKS/${processEntity.processToken}$URI_KREDITCHECK" +
                    "/${processEntity.kreditCheckId}$URI_ACCOUNT_ANALYSIS"
        )

        processEntityManager.updateStatus(
            processEntity = processEntity,
            status = EnumProcessStatus.KREDITCHECK_PDF_REPORT_COMPLETED
        )

        log.info("[${processId}] Fetch the KreditCheck account analysis PDF file...done")

        return pdf
    }

    [...]
}

Creating the Callback for the KreditCheck

The last step is to implement the server part of the callback for the credit check.
This is called by finAPI with the results once the credit check has been completed.

The structure of the callback is also quite simple.
The function kreditCheckResultCallback() provides the endpoint to be called for finAPI.
It checks whether the call contains any error messages, updates the status of the process, and calls the downloadPdfData() method if successful.
This uses the previously created KreditCheckPdfService to download the two files and, in this example, saves the results as a file.
In a real application, the PDF files should of course not only be saved locally as a file.

KOTLIN
@RestController
class KreditCheckResultCallbackApi(
    private val processEntityManager: ProcessEntityManager,
    private val kreditCheckPdfService: KreditCheckPdfService
) {
    @PostMapping("$URI_BASEPATH$URI_CB_RESULT/{processId}")
    fun kreditCheckResultCallback(
        @RequestBody result: KreditCheckResultCallbackRequestModel,
        @PathVariable(name = "processId") processId: String
    ) {
        if (validateCallback(data = result)) {
            // update the status, that the KreditCheck itself was completed
            processEntityManager.updateStatus(
                processId = processId,
                status = EnumProcessStatus.KREDITCHECK_COMPLETED
            )

            // download PDF data for documentation
            downloadPdfData(processId = processId)
        } else {
            processEntityManager.updateStatus(
                processId = processId,
                status = EnumProcessStatus.KREDITCHECK_FAILED
            )
            log.error("[${processId}] Result callback has received the errorMessage [${result.errorMessage}]"
                .trimIndent())
        }
    }

    private fun validateCallback(data: KreditCheckResultCallbackRequestModel): Boolean {
        return data.errorMessage == null
    }

    private fun downloadPdfData(processId: String) {
        val pdfReportAsZip = kreditCheckPdfService.fetchReportPdfAsZip(processId = processId)
        storeData("$processId-report.zip", pdfReportAsZip)
        val pdfAccountAnalysis = kreditCheckPdfService.fetchAccountAnalysisPdf(processId = processId)
        storeData("$processId-account-analysis.pdf", pdfAccountAnalysis)
    }

    private fun storeData(fileName: String, data: ByteArray) {
        Files.createFile(Path.of(fileName))
            .writeBytes(data)
    }

    companion object {
        private val log = KotlinLogging.logger { }
        const val URI_BASEPATH = "/callbacks"
        const val URI_CB_RESULT = "/kreditCheck/result"
    }
}

JavaScript errors detected

Please note, these errors can depend on your browser setup.

If this problem persists, please contact our support.