12.23. DD 23: Taler KYC

12.23.1. Summary

This document discusses the Know-your-customer (KYC) and Anti-Money Laundering (AML) processes supported by Taler.

12.23.2. Motivation

To legally operate, Taler has to comply with KYC/AML regulation that requires banks to identify parties involved in transactions at certain points.

12.23.3. Requirements

Taler needs to take measures based on the following primary triggers:

  • Customer withdraws money over a monthly threshold

    • exchange triggers KYC

    • key: IBAN (encoded as payto:// URI)

  • Wallet receives (via refunds) money resulting in a balance over a threshold

    • this is a client-side restriction

    • key: reserve (=KYC account) long term public key per wallet (encoded as payto:// URI)

  • Wallet receives money via P2P payments

    • there are two sub-cases: PUSH and PULL payments

    • key: reserve (=KYC account) long term public key per wallet (encoded as payto:// URI)

  • Merchant receives money (Q: any money, or above a monthly threshold?)

    • key: IBAN (encoded as payto:// URI)

  • Reserve is “opened” for invoicing.

    • key: reserve (=KYC account) long term public key per wallet (encoded as payto:// URI)

  • Import of new sanctions lists and triggering of measures against matches of existing customer records against the list

For the different operation types, there can be both soft and hard limits. Soft limits are those that the customer may raise by providing data and passing KYC checks. Hard limits cannot be lifted, for example because an exchange forbids crossing those limits in its terms of service for all customers. Process requirements

The key consideration here is plausibilization: staff needs to check that the client-provided information is plausible. As this is highly case-dependent, this cannot be automated.

For the different measures, there are various different possible KYC/AML checks that could happen:

  • In-person validation by AML staff

  • Various forms to be filled by AML staff

  • Validation involving local authorities and post-office

  • Online validation, sometimes with multiple options (like KYC for multiple people):

    • Forms to be supplied by user (different types of ID)

    • Interactive video

    • Documents to be supplied (business register)

    • Address validation (e-mail or phone or postal)

Additionally, the process is dynamic and conditional upon various decisions:

  • Individual vs. business

  • PEP or non-PEP

  • Hit on sanctions list

  • Type of business (trust, foundation, listed on stock market, etc.)

  • Need for plausibilization (via documents by user or staff research)

  • Periodic updates (of customer data, of sanction lists) and re-assessment

There are also various outcomes:

  • normal operation (with expiration date)

  • normal operation but with AML staff investigating (new measure)

  • held, requesting customer documentation (new measure)

  • held, AML staff reviewing evidence for plausibilization (new measure)

  • automatically frozen until certain day (due to sanctions)

  • institutionally frozen until certain day (due to order by state authority)

  • operation is categorically not allowed (at least above certain limits)

Outcomes may also be (partially) public, that is exposed to the client. For example, we may want to tell a wallet that it has hit a hard withdraw limit, but might succeed at withdrawing a smaller amount.

The outcome of a check can set new rules or trigger another measure (the latter is conditional on reaching the expiration time of the outcome).

As a result, we largely end up in a large state machine where the AML staff has serious flexibiltiy while the user needs guidance as to the possible next moves and/or to the current state of their account (where some information must not be disclosed). Documentation requirements

For each account we must:

  • define risk-profile (902.4, 905.1)

  • document the specific setup, likely not just the INI file

  • should have some key AMLA file attributes, such as:

    • File opened, file closed (keep data for X years afterwards!)

    • low-risk or high-risk business relationship

    • PEP status

    • business domain

    • authority notification dates (possibly multiple) with voluntary or mandatory notification classification

Finally, we need to produce statistics:

  • There must be a page with an overview of AMLA files with opening and closing dates and an easy way to determine for any day the number of open AMLA files

  • Technically, we also need a list of at-risk transactions and of frozen transactions, but given that we can really only freeze on an account-basis, I think there is nothing to do here

  • number of incidents reported (voluntarily, required)

  • number of business relationships at any point in time

  • number of risky business relationships (PEP, etc.)

  • number of frozen transactions (authority vs. sanction) with start-date and end-date

  • start-data and end-date of relationships (data retained for X years after end of relationship)

For this high-level monitoring, we need certain designated critical events to be tracked in the system statistics:

  • account opened

  • set to high risk

  • set to low risk

  • suspicious activity report filed with authority

  • account frozen

  • account unfrozen

  • account closed Security requirements

IBANs are predictable. We (probably) do not want random people to be able to initate KYC processes for other parties. Similarly, the attestation API requires us to somehow authenticate the user to ensure we only give out attestation data to the data subject themselves. For P2P payments and withdrawals, we have the reserve public key that is only known to the data subject and thus can be used to authenticate the client via a signature. Only pure deposits (by merchants or directly from a wallet) are a problem as the only thing we know about the receiver is the IBAN at that time, and literally any user could just deposit money into some bank account, so knowledge of the IBAN is insufficient to determine that we actually are communicating with the owner of the bank account. Further considerations

On top of all of this, we need to plan some diagnostics to determine when components fail (such as scripts or external services providing malformed results).

Optionally, in the future, the solution should support fees to be paid by the user for voluntary KYC processes related to attestation (#7365).

12.23.4. Proposed Solution

We allow certain conditions to trigger a single specific measures. For the different measures, we define:

  • Who has to do something (AML staff, user, nobody)

  • Contextual input data to be provided (with templating, e.g. amount set dynamically based on the trigger)

  • A check to be performed (user-interactive or staff-interactive)

  • Another measure to take on failure of a user-interactive check

  • A program that uses data from the check as well as context data to determine an outcome which is the specific operational state (normal, held on staff, held on user, frozen, etc.) the account is to transition to

  • What information about the state to show to the user (normal, information required, pending, etc.)

For the user-interactive checks we need a SPA (for KYC) that is given:

  • instructions to render (with either a form to fill or links to external checks); here the context could provide an array of choices!

  • possibly an external check that was set up (if any); for cost-reasons, we should only do one at a time, and probably should then always redirect the browser to that check.

For the staff-interactive checks we need a SPA (for AML):

  • to file forms and upload documentation (without state transition)

  • to decide on next measure (providing context); here, the exchange needs to expose the list of available measures and required context for each

For non-interactive measures (normal operation, account frozen) we need:

  • Expiration time (in context)

  • Measure to trigger upon expiration, again with context (renew documents, resume normal operation, etc.)

We need some customer-driven interactivity in KYB/KYC process, for example the user may need to be given choices (address vs. phone, individual vs. business, order in which to provide KYC data of beneficiaries). As a result, the exchange needs to serve some SPA for measures where the user is shown the next step(s) or choices (which person to collect KYC data on, whether to run challenger on phone number of physical address, etc.). The SPA should also potentially contain a form to allow the customer to directly upload documents to us (like business registration) instead of to some KYC provider. This is because KYC providers may not be flexible enough.

Similarly, the AML staff will need to be able to trigger rather complex KYB/KYC processes, like “need KYC on X and Y and Z” or “phone number or mailing address” or “please upload form A/T/S”. Here in particular it should be possible to request not only filled forms, but arbitrary documents. Terminology

  • Check: A check establishes a particular attribute of a user, such as their name based on an ID document and lifeness, mailing address, phone number, taxpayer identity, etc. Checks may be given context (such as whether a customer is an individual or a business) to run correctly. Checks can also be AML staff inserting information for plausibilization. Checks result in an outcome being decided by an external AML program.

  • Condition: A condition specifies when KYC is required. Conditions include the type of operation, a threshold amount (e.g. above EUR:1000) and possibly a time period (e.g. over the last month).

  • Configuration: The configuration determines the legitimization rules, and specifies which providers offer which checks at what cost.

  • Context: Context is information provided as input into a check and program to customize their execution. The context is initially set by the trigger, but may evolve as the account undergoes measures. For each check and program, the required context data must be specified.

  • Cost: How much would a client have to pay for a KYC process (if they voluntarily choose to do so for attestation).

  • Expiration: KYC legitimizations may be outdated. Expiration rules determine when checks have to be performed again.

  • Legitimization rules: The legitimization rules determine under which conditions which checks must be performend and the expiration time period for the checks.

  • Logic: Logic refers to a specific bit of code (realized as an exchange plugin) that enables the interaction with a specific provider. Logic typically requires configuration for access control (such as an authorization token) and possibly the endpoint of the specific provider implementing the respective API.

  • Measure: Describes the possible outgoing edges from one state in the state machine (including how to show the current state). Each edge is given some context and a check to be performed as well as a program to decide the outcome and the next measure.

  • Outcome: Describes the account state that an account ends up in due to the result of a check. Outcomes can be that an account is frozen (no transactions possible until freeze expires), held (no transactions possible until another measure has been taken), or operating normally. Outcomes also include a new set of legitimization rules to apply and an expiration time at which point a new measure will be automatically taken. Finally, parts of the outcome may be explained to the client (for example, to allow a wallet to stay below hard withdraw thresholds).

  • Provider: A provider performs a specific set of checks at a certain cost. Interaction with a provider is performed by provider-specific logic.

  • Program: An AML helper program is given context about the current state of an account and the attribute data from a check to compute the outcome. For example, a program may look at the “PEP” field of a KYC check and decide if the outcome is to put the account into normal or held-for-manual-review state.

  • Type of operation: The operation type determines which Taler-specific operation has triggered the KYC requirement. We support four types of operation: withdraw (by customer), deposit (by merchant), P2P receive (by wallet) and (high) wallet balance. Account owner authentication

When we need to authenticate a bank account owner, we will simply require them to make an outgoing wire transfer into the exchange bank account with a public key in the wire transfer subject (just like when withdrawing), but augmented with the string “KYC” so we can distinguish the wire transfer from a regular withdrawal. Typically, we would put the merchant public key into the wire transfer subject; wallets MAY put their long-term reserve public key instead. The amount to be transferred is the KYC fee.

This has several advantages:

  • Only the account owner can provide us with the public key, so we already have also one super-hard piece of KYC evidence.

  • If the account owner looses their public key, it’s not a problem: they would just have to do the transfer again with a new key. No need for us to do any kind of intervention for key management.

  • We could theoretically get paid to do the KYC process, or just “charge” a nominal amount.

  • This also somewhat addresses the payment for voluntary KYC processes where a merchant wants to do KYC to get us to attest their identity for their customers even if we do not yet have a legal need. The only issue here is that this does not work if voluntary KYC is invoiced while mandatory KYC is gratis. But, that kind of configuration is a business decision and there is no hard need to support it immediately.

  • This definitively addresses the need for authentication to access the attestation API, which so far was only available for P2P payments as we could not authenticate merchants.

  • The “KYC” string allows us to distinguish the authentication transfers from withdrawal transfers; by keeping the KYC fee at or below the closing fee, we can even deploy this without fully updating the logic everywhere to distinguish KYC transfers

TODO: update wire gateway specification, update/new tables for KYC wire transfers, update API spec for attestation, update exchange API (below) to signal need for auth-payment via wire transfer, update merchant logic to expose merchant public key to SPA for wire transfer if needed for KYC. 451 Response

When KYC operations are required, various endpoints may respond with a 451 Unavailable for Legal Reasons status code and a KycNeededRedirect body.

interface KycNeededRedirect {

  // Numeric error code unique to the condition.
  code: number;

  // Human-readable description of the error, i.e. "missing parameter", "commitment violation", ...
  // Should give a human-readable hint about the error's nature. Optional, may change without notice!
  hint?: string;

  // Public key associated with the account. The client must sign
  // the initial request for the KYC status using the corresponding
  // private key.  Will be either a reserve public key or a merchant
  // (instance) public key.
  account_pub: EddsaPublicKey;

  // Legitimization target that the merchant should
  // use to check for its KYC status using
  // the /kyc-check/$REQUIREMENT_ROW endpoint.
  requirement_row: Integer;

} New endpoints


Checks the KYC status of a particular payment target and possibly begins the KYC process. This endpoint is typically used by wallets or merchants that have been told about a KYC requirement and now want to check if the KYC requirement has been fulfilled. Long-polling may be used to instantly observe a change in the KYC requirement status.

The requirement row of the /kyc-check/ endpoint encodes the legitimization measure’s serial number. It is returned in KycNeededRedirect responses via the requirement_row field.

Given a valid pair of requirement row and account owner signature, the /kyc-check/ endpoint returns either just the KYC status or redirects the client (202) to the next required stage of the KYC process. The redirection must be for an HTTP(S) endpoint to be triggered via a simple HTTP GET. It must always be the same endpoint for the same client, as the wallet/merchant backend are not required to check for changes to this endpoint. Clients that received a 202 status code may repeat the request and use long-polling to detect a change of the HTTP status.


Account-Owner-Signature: The client must provide Base-32 encoded EdDSA

signature with $ACCOUNT_PRIV, affirming the desire to obtain KYC data. Note that this is merely a simple authentication mechanism, the details of the request are not protected by the signature. The $ACCOUNT_PRIV is either the (wallet long-term) reserve private key or the merchant instance private key.

Query Parameters:
  • timeout_ms=NUMBEROptional. If specified, the exchange will wait up to timeout_ms milliseconds if the requirement continues to be mandatory provisioning of KYC data by the client. Ignored if the HTTP status code is already 200 Ok. Note that clients cannot long-poll for AML staff actions, so status information about an account being under AML review needs to be requested periodically.


200 Ok:

No mandatory KYC actions are required by the client at this time. The client may still visit the KYC URL to initiate voluntary checks. The response will be an AccountKycStatus object which specifies restrictions that currently apply to the account. If the client attempts to exceed soft limits, the status may change to a 202 Accepted. Hard limits cannot be lifted by passing KYC checks.

202 Accepted:

The account holder performed an operation that would have crossed soft limits and must be redirected to the provided location to perform the required KYC checks to satisfy the legal requirements. Afterwards, the /kyc-check/ request should be repeated to check whether the user has completed the process. The response will be an AccountKycStatus object.

204 No content:

The exchange is not configured to perform KYC and thus the legal requirements are already satisfied.

403 Forbidden:

The provided hash does not match the requirement row.

404 Not found:

The requirement row is unknown.


interface AccountKycStatus {

  // Current time of the exchange, used as part of
  // what the exchange signs over.
  now: Timestamp;

  // Current AML state for the target account.  True if
  // operations are not happening due to staff processing
  // paperwork *or* due to legal requirements (so the
  // client cannot do anything but wait).
  // Note that not every AML staff action may be legally
  // exposed to the client, so this is merely a hint that
  // a client should be told that AML staff is currently
  // reviewing the account.  AML staff *may* review
  // accounts without this flag being set!
  aml_review: boolean;

  // URL that the user should open in a browser to
  // proceed with the KYC process (optional if
  // the status type is 200 Ok, mandatory if the
  // HTTP status is 202 Accepted).
  kyc_url: string;

  // Array with limitations that currently apply to this
  // account and that may be increased or lifted if the
  // KYC check is passed.
  // Note that additional limits *may* exist and not be
  // communicated to the client. If such limits are
  // reached, this *may* be indicated by the account
  // going into aml_review state. However, it is
  // also possible that the exchange may legally have
  // to deny operations without being allowed to provide
  // any justification.
  // The limits should be used by the client to
  // possibly structure their operations (e.g. withdraw
  // what is possible below the limit, ask the user to
  // pass KYC checks or withdraw the rest after the time
  // limit is passed, warn the user to not withdraw too
  // much or even prevent the user from generating a
  // request that would cause it to exceed hard limits).
  limits?: AccountLimit[];

interface AccountLimit {

  // Operation that is limited.
  // Must be one of "WITHDRAW", "DEPOSIT", "P2P-RECEIVE"
  operation_type: string;

  // Timeframe during which the limit applies.
  timeframe: RelativeTime;

  // Maximum amount allowed during the given timeframe.
  // Zero if the operation is simply forbidden.
  threshold: Amount;

  // True if this is a soft limit that could be raised
  // by passing KYC checks.  Clients *may* deliberately
  // try to cross limits and trigger measures resulting
  // in 451 responses to begin KYC processes.
  // Clients that are aware of hard limits *should*
  // inform users about the hard limit and prevent flows
  // in the UI that would cause violations of hard limits.
  soft_limit: boolean;
GET /kyc-spa/$FILENAME

A set of /kyc-spa/$TARGET_TOKEN GET endpoints is created per account hash that serves the KYC SPA. This is where the /kyc-check/ endpoint will in principle redirect clients. The KYC SPA will use the $TARGET_TOKEN of its URL to initialize itself via the /kyc-info/$TARGET_TOKEN endpoint family. The KYC SPA may download additional resources via /kyc-spa/$FILENAME. The filenames must not match base32-encoded 256-bit values.


A new set of /kyc-info/$TARGET_TOKEN GET endpoints is created per client account hash to return information about the state of the KYC or AML process to the KYC SPA. The SPA uses this information to show the user an appropriate dialog. The SPA should also long-poll this endpoint for changes to the AML/KYC state. Note that this is a client-facing endpoint, so it will only provide a restricted amount of information to the customer (as some laws may forbid us to inform particular customers about their true status). The endpoint will typically inform the SPA about possible choices to proceed, such as directly uploading files, contacting AML staff, or proceeding with a particular KYC process at an external provider (such as Challenger). If the user chooses to initate a KYC process at an external provider, the SPA must request the respective process to be set-up by the exchange via the /kyc-start/ endpoint.


If-None-Match: The client MAY provide an If-None-Match header with an ETag.

query timeout_ms=MILLISECONDS:

Optional. If specified, the exchange will wait up to MILLISECONDS for a change to a more recent legitimization measure before returning a 304 Not Modified status.


200 OK:

The body is a KycProcessClientInformation.

Etag: Will be set to the serial ID of the measure. Used for long-polling.

interface KycProcessClientInformation {

  // List of requirements.
  requirements?: { name : KycRequirementInformation};

  // True if the client is expected to eventually satisfy all requirements.
  // Default (if missing) is false.
  is_and_combinator?: boolean

  // List of available voluntary checks the client could pay for.
  // Since **vATTEST**.
  voluntary_checks?: { name : KycCheckInformation};
interface KycRequirementInformation {

  // Which form should be used? Common values include "INFO"
  // (to just show the descriptions but allow no action),
  // "LINK" (to enable the user to obtain a link via
  // /kyc-start/) or any build-in form name supported
  // by the SPA.
  form: string;

  // English description of the requirement.
  description: string;

  // Map from IETF BCP 47 language tags to localized
  // description texts.
  description_i18n ?: { [lang_tag: string]: string };

  // ID of the requirement, useful to construct the
  // /kyc-upload/$ID or /kyc-start/$ID endpoint URLs.
  // Present if and only if "form" is not "INFO".  The
  // $ID value may itself contain / or ? and
  // basically encode any URL path (and optional arguments).
  id?: string;

// Since **vATTEST**.
interface KycCheckInformation {

  // How much would this check cost the client?
  cost: Amount;

  // English description of the check.
  description: string;

  // Map from IETF BCP 47 language tags to localized
  // description texts.
  description_i18n ?: { [lang_tag: string]: string };

204 No Content:

There are no open KYC requirements or possible voluntary checks the client might perform.

304 Not Modified:

The KYC requirements did not change.

POST /kyc-upload/$ID

The /kyc-upload/$ID POST endpoint allows the SPA to upload client-provided evidence. The $ID will be provided as part of the /kyc-info body. This is for checks of type FORM. In practice, $ID will encode both the $TARGET_TOKEN and the index of the selected measure (but this should be irrelevant for the client).


Basically oriented along the possible formats of a HTTP form being POSTed. Details will depend on the form. The server will try to decode the uploaded body from whatever format it is provided in.


204 No Content:

The information was successfully uploaded. The SPA should fetch an updated /kyc-info/.

404 Not Found:

The $ID is unknown to the exchange.

409 Conflict:

The upload conflicts with a previous upload.

413 Content Too Large:

The body is too large.

POST /kyc-start/$ID

The /kyc-start/$ID POST endpoint allows the SPA to set up a new external KYC process. It will return the URL that the client must GET to begin the KYC process. The SPA should probably open this URL in a new window or tab. The $ID will be provided as part of the /kyc-info body. In practice, $ID will encode both the $TARGET_TOKEN and the index of the selected measure (but this should be irrelevant for the client).


Use empty JSON body for now.


200 Ok:

The KYC process was successfully initiated. The URL is in a KycProcessStartInformation object.

interface KycProcessStartInformation {

  // URL to open.
  redirect_url: string;
404 Not Found:

The $ID is unknown to the exchange.


As this endpoint is involved in every KYC check at the beginning, this is also the place where we could integrate the payment process for the KYC fee in the future (since vATTEST).


Upon completion of the process at the external KYC provider, the provider must redirect the client (browser) to trigger a GET request to a new /kyc-proof/$H_PAYTO/$PROVIDER_SECTION endpoint. Once this endpoint is triggered, the exchange will pass the received arguments to the respective logic plugin. The logic plugin will then (asynchronously) update the KYC status of the user. The logic plugin should return a human-readable HTML page with the KYC result to the user. This endpoint deliberately does not use the $TARGET_TOKEN as the provider should not learn that token.

This endpoint is thus accessed from the user’s browser at the end of a KYC process, possibly providing the exchange with additional credentials to obtain the results of the KYC process. Specifically, the URL arguments should provide information to the exchange that allows it to verify that the user has completed the KYC process. The details depend on the logic, which is selected by the “$PROVIDER_SECTION”.

While this is a GET (and thus safe, and idempotent), the operation may actually trigger significant changes in the exchange’s state. In particular, it may update the KYC status of a particular payment target.


Details on the request depend on the specific KYC logic that was used.

If the KYC plugin logic is OAuth 2.0, the query parameters are:

Query Parameters:
  • code=CODE – OAuth 2.0 code argument.

  • state=STATE – OAuth 2.0 state argument with the H_PAYTO.


Depending on the OAuth variant used, additional query parameters may need to be passed here.


Given that the response is returned to a user using a browser and not to a Taler wallet, the response format is in human-readable HTML and not in machine-readable JSON.

302 Found:

The KYC operation succeeded and the payment target is now authorized to transact. The browser is redirected to a human-readable page configured by the exchange operator.

401 Unauthorized:

The provided authorization token is invalid.

404 Not found:

The payment target is unknown.

502 Bad Gateway:

The exchange received an invalid reply from the legitimization service.

504 Gateway Timeout:

The exchange did not receive a reply from the legitimization service within a reasonable time period.

GET /kyc-webhook/$PROVIDER_SECTION/*
GET /kyc-webhook/$LOGIC/*
POST /kyc-webhook/$LOGIC/*

Alternatively, the KYC confirmation may be triggered by a /kyc-webhook request. As KYC providers do not necessarily support passing detailed information in the URL arguments, the /kyc-webhook only needs to specify either the PROVIDER_SECTION or the LOGIC (the name of the plugin implementing the KYC API). The API-specific webhook logic must then figure out what exactly the webhook is about on its own. The /kyc-webhook/ endpoint works for GET or POST, again as details depend on the KYC provider. In contrast to kyc-proof, the response does NOT go to the end-users’ browser and should thus only indicate success or failure.


Details on the request depend on the specific KYC logic that was used.


204 No content:

The operation succeeded.

404 Not found:

The specified logic is unknown.

POST /kyc-wallet``

The /wallet-kyc POST endpoint allows a wallet to notify an exchange if it will cross a balance threshold. Here, the balance specified should be the threshold (from the wallet_balance_limit_without_kyc array) that the wallet would cross, and not the exact balance of the wallet. The exchange will respond with a wire target UUID. The wallet can then use this UUID to being the KYC process at /kyc-check/. The wallet must only proceed to obtain funds exceeding the threshold after the KYC process has concluded. While wallets could be “hacked” to bypass this measure (we cannot cryptographically enforce this), such modifications are a terms of service violation which may have legal consequences for the user.

Setup KYC identification for a wallet. Returns the KYC UUID. This endpoint is used by compliant Taler wallets when they are about to hit the balance threshold and thus need to have the customer provide their personal details to the exchange. The wallet is identified by its long-lived reserve public key (which is used for P2P payments, not for withdrawals).


The request body must be a WalletKycRequest object.


204 No Content:

KYC is disabled at this exchange, or the balance is below the threshold that requires KYC, or this wallet already satisfied the KYC check for the given balance.

403 Forbidden:

The provided signature is invalid. This response comes with a standard ErrorDetail response.

451 Unavailable for Legal Reasons:

The wallet must undergo a KYC check. A KYC ID was created. The response will be a WalletKycUuid object.


 interface WalletKycRequest {

  // Balance threshold (not necessarily exact balance)
  // to be crossed by the wallet that (may) trigger
  // additional KYC requirements.
  balance: Amount;

  // EdDSA signature of the wallet affirming the
  // request, must be of purpose
  reserve_sig: EddsaSignature;

  // long-term wallet reserve-account
  // public key used to create the signature.
  reserve_pub: EddsaPublicKey;
interface WalletKycUuid {

  // UUID that the wallet should use when initiating
  // the KYC check.
  requirement_row: number;

  // Hash of the payto:// account URI for the wallet.
  h_payto: PaytoHash;

GET /aml/$OFFICER_PUB/measures

To enable the AML staff SPA to give AML staff a choice of possible measures, a new endpoint /aml/$OFFICER_PUB/measures is added that allows the AML SPA to dynamically GET the list of available measures. It returns a list of known KYC checks (by name) with their descriptions and a list of AML programs with information about the required context.


Taler-AML-Officer-Signature: The client must provide Base-32 encoded EdDSA

signature with $OFFICER_PRIV, affirming the desire to obtain AML data. Note that this is merely a simple authentication mechanism, the details of the request are not protected by the signature.


200 Ok:

Information about possible measures is returned in a AvailableMeasureSummary object.

interface AvailableMeasureSummary {

  // Available original measures that can be
  // triggered directly by default rules.
  roots: { "$measure_name" : MeasureInformation };

  // Available AML programs.
  programs: { "$prog_name" : AmlProgramRequirement };

  // Available KYC checks.
  checks: { "$check_name" : KycCheckInformation };

interface MeasureInformation {

  // Name of a KYC check.
  check_name: string;

  // Name of an AML program.
  prog_name: string;

  // Context for the check. Optional.
  context?: Object;

interface AmlProgramRequirement {

  // Description of what the AML program does.
  description: string;

  // List of required field names in the context to run this
  // AML program. SPA must check that the AML staff is providing
  // adequate CONTEXT when defining a measure using this program.
  context: string[];

  // List of required attribute names in the
  // input of this AML program.  These attributes
  // are the minimum that the check must produce
  // (it may produce more).
  inputs: string[];

interface KycCheckInformation {

  // Description of the KYC check.  Should be shown
  // to the AML staff but will also be shown to the
  // client when they initiate the check in the KYC SPA.
  description: string;
  description_i18n: {};

  // Names of the fields that the CONTEXT must provide
  // as inputs to this check.
  // SPA must check that the AML staff is providing
  // adequate CONTEXT when defining a measure using
  // this check.
  requires: string[];

  // Names of the attributes the check will output.
  // SPA must check that the outputs match the
  // required inputs when combining a KYC check
  // with an AML program into a measure.
  outputs: string[];

  // Name of a root measure taken when this check fails.
  fallback: string;
GET /aml/kyc-statistics/$NAME

Returns the number of KYC events matching the given event type $NAME in the specified time range. Note that this query can be slow as the statistics are computed on-demand. (This is OK as such requests should be rare.)


Taler-AML-Officer-Signature: The client must provide Base-32 encoded EdDSA

signature with $OFFICER_PRIV, affirming the desire to obtain AML data. Note that this is merely a simple authentication mechanism, the details of the request are not protected by the signature.

query start_date=TIMESTAMP:

Optional. Specifies the date when to start looking (inclusive). If not given, the start time of the exchange operation is used.

query end_date=TIMESTAMP:

Optional. Specifies the date when to stop looking (exclusive). If not given, the current date is used.


interface EventCounter {
  // Number of events of the specified type in
  // the given range.
  counter: Integer;
GET /aml/$OFFICER_PUB/decisions/$STATE

Obtain list of AML decisions (filtered by $STATE). $STATE must be either normal, pending or frozen.

Taler-AML-Officer-Signature: The client must provide Base-32 encoded EdDSA

signature with $OFFICER_PRIV, affirming the desire to obtain AML data. Note that this is merely a simple authentication mechanism, the details of the request are not protected by the signature.

Query Parameters:
  • limitOptional. takes value of the form N (-N), so that at most N values strictly older (younger) than start are returned. Defaults to -20 to return the last 20 entries (before start).

  • offsetOptional. Row number threshold, see delta for its interpretation. Defaults to INT64_MAX, namely the biggest row id possible in the database.


200 OK:

The responds will be an AmlRecords message.

204 No content:

There are no matching AML records.

403 Forbidden:

The signature is invalid.

404 Not found:

The designated AML account is not known.

409 Conflict:

The designated AML account is not enabled.


interface AmlRecords {

  // Array of AML records matching the query.
  records: AmlRecord[];
interface AmlRecord {

  // Which payto-address is this record about.
  // Identifies a GNU Taler wallet or an affected bank account.
  h_payto: PaytoHash;

  // What is the current AML state.
  current_state: Integer;

  // Monthly transaction threshold before a review will be triggered
  threshold: Amount;

  // Row ID of the record.  Used to filter by offset.
  rowid: Integer;

GET /aml/$OFFICER_PUB/decision/$H_PAYTO

Obtain deails about an AML decision.

Taler-AML-Officer-Signature: The client must provide a Base-32

encoded EdDSA signature with $OFFICER_PRIV, affirming the desire to obtain AML data. Note that this is merely a simple authentication mechanism, the details of the request are not protected by the signature.

Query Parameters:
  • historyOptional. If set to yes, we return all historic decisions for the account, and not only the last one.


200 OK:

The responds will be an AmlDecisionDetails message.

204 No content:

There are no matching AML records for the given payto://-URI.

403 Forbidden:

The signature is invalid.

404 Not found:

The designated AML account is not known.

409 Conflict:

The designated AML account is not enabled.


interface AmlDecisionDetails {

  // Array of AML decisions made for this account. Possibly
  // contains only the most recent decision if "history" was
  // not set to 'true'.
  aml_history: AmlDecisionDetail[];

  // Array of KYC attributes and outcomes obtained for this
  // account.
  kyc_attributes: KycDetail[];
interface AmlDecisionDetail {

  // What was the justification given?
  justification: string;

  // When does the outcome expire?
  expiration_time: Timestamp;

  // KYC rules to apply.  Note that this
  // overrides *all* of the default rules
  // until the expiration_time and specifies
  // the successor measure to apply after the
  // expiration time.
  new_rules: LegitimizationRules;

  // When was this decision made?
  decision_time: Timestamp;

  // Who made the decision?
  decider_pub: AmlOfficerPublicKeyP;

interface KycDetail {

  // Name of the configuration section that specifies the provider
  // which was used to collect the KYC details
  provider_section: string;

  // The collected KYC data.  NULL if the attribute data could not
  // be decrypted (internal error of the exchange, likely the
  // attribute key was changed).
  attributes?: Object;

  // Time when the KYC data was collected
  collection_time: Timestamp;

  // Outcome of the AML program.
  outcome: AmlProgramOutcome;

POST /aml/$OFFICER_PUB/decision

Make an AML decision. Triggers the respective action and records the justification.


The request must be an AmlDecision message.


204 No content:

The AML decision has been executed and recorded successfully.

403 Forbidden:

The signature is invalid.

404 Not found:

The address the decision was made upon is unknown to the exchange or the designated AML account is not known.

409 Conflict:

The designated AML account is not enabled or a more recent decision was already submitted.


interface AmlDecision {

  // Human-readable justification for the decision.
  justification: string;

  // Which payto-address is the decision about?
  // Identifies a GNU Taler wallet or an affected bank account.
  h_payto: PaytoHash;

  // When does the outcome expire?
  expiration_time: Timestamp;

  // What are the new rules?
  new_rules: LegitimizationRules;

  // When was the decision made?
  decision_time: Timestamp;

  // Signature by the AML officer over a TALER_AmlDecisionPS.
  // Must have purpose TALER_SIGNATURE_MASTER_AML_KEY.
  officer_sig: EddsaSignature;

} Modifications to existing endpoints

When withdrawing, the exchange checks if the KYC status is acceptable. If no KYC was done and if either the amount withdrawn over a particular timeframe exceeds the threshold or the reserve received received a P2P transfer, then a 451 Unavailable for Legal Reasons is returned which redirects the consumer to the new /kyc-check/ handler.

When depositing, the exchange aggregator (!) checks the KYC status and if negative, returns an additional information field via the aggregation_transient table which is returned via GET /deposts/ to the merchant. This way, the merchant learns the requirement_row needed to begin the KYC process (this is independent of the amount) at the new /kyc-check/ handler.

When merging into a reserve, the KYC status is checked and again the merge fails with 451 Unavailable for Legal Reasons to trigger the KYC process.

To allow the wallet to do the KYC check if it is about to exceed a set balance threshold, we modify the /keys response to add an optional array wallet_balance_limit_without_kyc of threshold amounts is returned. Whenever the wallet crosses one of these thresholds for the first time, it should trigger the KYC process. If this field is absent, there is no limit. If the field is provided, a correct wallet must create a long-term account-reserve key pair. This should be the same key that is also used to receive wallet-to-wallet payments. Then, before a wallet performs an operation that would cause it to exceed the balance threshold in terms of funds held from a particular exchange, it should first request the user to complete the KYC process. For that, the wallet should POST to the new /kyc-wallet endpoint, providing its long-term reserve-account public key and a signature requesting permission to exceed the account limit. Configuration of external KYC providers

For each KYC provider that could contribute to checks the configuration specifies a $PROVIDER_SECTION for each authentication procedure. For each (enabled) provider, the exchange has a logic plugin which (asynchronously) determines the redirect URL for a given wire target. See below for a description of the high-level process for different providers.


# Which plugin is responsible for this provider?

# Optional cost, useful if clients want to voluntarily
# trigger authentication procedures for attestation.
# Since **vATTEST**.

# Plus additional logic-specific options, e.g.:
AUTHORIZATION_TOKEN = superdupersecret

# Other logic-specific internal options (example):
FORM_ID = business_legi_form

# Name of a program to run on the output of the plugin
# to convert the result into the desired set of attributes.
# The converter must create a log for the system administrator
# if the provided inputs do not match expectations.
# Note that the converter will be expected to output the
# set of attributes listed under the respective ``[kyc-check-*]``
# sections. Calling the converter with ``--list-outputs``
# should generate a (newline-separated) list of attributes
# the converter promises to generate in its JSON output
# (when run regularly).
CONVERTER = taler-exchange-helper-$NAME Configuration of possible KYC/AML checks

The configuration specifies a set of possible KYC checks offered by external providers, one per configuration section:


# Which type of check is this? Also determines
# the SPA form to show to the user for this check.
# INFO: wait for staff or contact staff out-of band
#          (only information shown, no SPA action)
# FORM: SPA should show an inline (HTML) form
# LINK: SPA may start external KYC process or upload

# Optional. Set to YES to allow this check be
# done voluntarily by a client (they may then
# still have to pay for it). Used to offer the
# SPA to display checks even if they are
# not required. Default is NO.
# Since **vATTEST**.

# Provider id, present only if type is LINK.

# Name of the SPA form, if type is FORM
# "INFO" and "LINK" are reserved and must not be used.
# The exchange server and the SPA must agree on a list
# of supported forms and the resulting attributes.
# The SPA should include a JSON resource file
# "forms.json" mapping form names to arrays of
# attribute names each form provides.
FORM_NAME = name

# Descriptions to use in the SPA to display the check.
DESCRIPTION = "Upload your passport picture"
DESCRIPTION_I18N = "{"en":"Upload scan of your passport"}"

# ';'-separated list of fields that the CONTEXT must
# provided as inputs to this check. For example,
# for a FORM of type CHOICE, this might state
# ``choices: string[];``. The type after the ":"
# is for now purely for documentation and is
# not checked. However, it may be shown to AML staff
# when they configure measures.
REQUIRES = requirement;

# Description of the outputs provided by the check.
# Basically, the check's output is expected to
# provide the following fields as inputs into
# a subsequent AML program.
OUTPUTS = business_name street city country registration

# **original** measure to take if the check fails
# (for any reason, e.g. provider or form fail to
# satisfy constraints or provider signals user error)
# Usually should point to a measure that requests
# AML staff to investigate.  The fallback measure
# context always includes the reasons for the
# failure.

The list of possible FORM names is fixed in the SPA for a particular exchange release.

The outcome of any check should always be uploaded encrypted into the kyc_attributes table. It MUST include an expiration_time. Configuration of legitimization requirement triggers

The configuration also specifies a set of legitimization rules including the condition and the measure the condition triggers, one condition per configuration section:


# Operation that triggers this legitimization.

# Next measures to be performed. The SPA should
# display *all* of these measures to the user.
# (they have a choice of either which ones, or in
# which order they are to be performed).
# A special measure "verboten" is used if the
# threshold may never be crossed.

# Context for each of the above measures, optional.

# "yes" if all REQUIRED_MEASURES will eventually need
# to be satisfied, "no" if the user has a choice between
# them. Not actually enforced by the exchange, but
# primarily used to inform the user whether this is
# an "and" or "or". YES for "and".

# YES if the rule (specifically, operation type,
# threshold, timeframe) and the general nature of
# the next measure (verboten or approval required)
# should be exposed to the client.
# Defaults to NO if not set.

# Threshold amount above which the legitimization is
# triggered.  The total must be exceeded in the given
# timeframe. Can be 'forever'.

# Timeframe over which the amount to be compared to
# the  THRESHOLD is calculated.
# Ignored for WALLET-BALANCE.

# Enabled (default is NO)
ENABLED = NO AML programs

AML programs are helper programs that can:

  • Generate a list of required context field names for the helper (introspection!) using the “–required-context” command-line switch. The output should use the same syntax as the REQUIRES clause of [kyc-check-] configuration sections, except that new lines MUST be used to separate fields instead of “;”.

  • Generate a list of required attribute names for the helper (introspection!) using the “–required-attributes” command-line switch. The output should use the same list of names as the ATTRIBUTES in the [kyc-provider-] configuration section (but may also include FORM field names).

  • Process an input JSON object of type AmlProgramInput into a JSON object of type AmlProgramOutcome. This is the default behavior if no command-line switches are provided.

interface AmlProgramInput {

  // JSON object that was provided as
  // part of the *measure*.  This JSON object is
  // provided under "context" in the main JSON object
  // input to the AML program.  This "context" should
  // satify both the REQUIRES clause of the respective
  // check and the output of "--requires" from the
  // AML program's command-line option.
  context?: Object;

  // JSON object that captures the
  // output of a [kyc-provider-] or (HTML) FORM.
  // The keys in the JSON object will be the attribute
  // names and the values must be strings representing
  // the data. In the case of file uploads, the data
  // MUST be base64-encoded.
  attributes: Object;

  // JSON array with the results of historic
  // AML desisions about the account.
  aml_history: AmlDecisionDetail[];

  // JSON array with the results of historic
  // KYC data about the account.
  kyc_history: KycDetail[];

interface AmlProgramOutcome {

  // Should the client's account be investigated
  // by AML staff?
  // Defaults to false.
  to_investigate?: boolean;

  // Should the client's account be frozen?
  // Defaults to false.
  is_frozen?: boolean;

  // Was the client's account reported to the authorities?
  // Defaults to false.
  is_reported?: boolean;

  // Free-form properties about the account.
  // Can be used to store properties such as PEP,
  // risk category, type of business, hits on
  // sanctions lists, etc.
  properties?: AccountProperties;

  // Types of events to add to the KYC events table.
  // (for statistics).
  events?: string[];

  // When does the outcome expire?
  expiration_time: Timestamp;

  // KYC rules to apply.  Note that this
  // overrides *all* of the default rules
  // until the expiration_time and specifies
  // the successor measure to apply after the
  // expiration time.
  new_rules: LegitimizationRules;

interface KycRule {

  // Type of operation to which the rule applies.
  operation_type: string;

  // Measure to be taken if the given
  // threshold is crossed over the given timeframe.
  threshold: Amount;

  // Over which duration should the threshold be
  // computed.
  timeframe: RelativeTime;

  // Array of names of measures to apply.
  // Names listed can be original measures or
  // custom measures from the AmlProgramOutcome.
  // A special measure "verboten" is used if the
  // threshold may never be crossed.
  measures: string[];

  // True if the rule (specifically, operation_type,
  // threshold, timeframe) and the general nature of
  // the measures (verboten or approval required)
  // should be exposed to the client.
  // Defaults to "false" if not set.
  exposed?: boolean;

  // True if all the measures will eventually need to
  // be satisfied, false if any of the measures should
  // do.
  // Default (if missing) is false.
  is_and_combinator?: boolean;

If the AML program fails (exits with a failure code or does not provide well-formed JSON output) the AML/KYC process continues with the FALLBACK measure. This should usually be one that asks AML staff to contact the systems administrator.

AML programs are listed in the configuration file, one program per section:


# Program to run.
COMMAND = taler-helper-aml-pep

# Human-readable description of what this
# AML helper program will do. Used to show
# to the AML staff.
DESCRIPTION = "check if the customer is a PEP"

# True if this AML program is enabled (and thus can be
# used in measures and exposed to AML staff).
# Optional, default is NO.

# **original** measure to take if COMMAND fails
# Usually points to a measure that asks AML staff
# to contact the systems administrator. The fallback measure
# context always includes the reasons for the
# failure.
FALLBACK = MEASURE_NAME Configuration of measures

Finally, the configuration specifies a set of original measures one per configuration section:


# Possible check for this measure.  Optional.
# If not given, PROGRAM should be run immediately
# (on an empty set of attributes).

# Context for the check. The context can be
# just an empty JSON object if there is none.
CONTEXT = {"choices":["individual","business"]}

# Program to run on the context and check data to
# determine the outcome and next measure.
PROGRAM = taler-aml-program

If no CHECK_NAME is provided at all, the AML PROGRAM is to be run immediately. This is useful if no client-interaction is required to arrive at a decision.


The list of measures is not complete: AML staff may freely define new measures dynamically, usually by selecting checks, an AML program, and providing context. Sanity checking

On start-up, taler-exchange-httpd should sanity-check its configuration. Specifically, it should validate that for all AML programs the input requirements (attributes and context) are claimed to be satisfied by the respective checks that may trigger those programs, and similarly that for all checks the original measures satisfy the context requirements for their KYC checks.

As a result, any component (AML program, form or external check) is warranted to be always called with the declared required inputs. Furthermore, we can detect if a component fails to produce the required output and the configuration contains (presumably safe) FALLBACKs to address this case. The exchange MUST detect circular failures, like when a FALLBACK triggers a measure that itself immediately triggers again the same FALLBACK. Exchange database schema

We introduce a new wire_targets table into the exchange database. This table is referenced as the source or destination of payments (regular deposits and also P2P payments). A positive side-effect is that we reduce duplication in the reserves_in, wire_out and deposits tables as they can reference this table.

We introduce a new legitimization_processes table that tracks the status of a legitimization process at a provider, including the configuration section name, the user/account name at the provider, and some legitimization identifier for the process at the provider. In this table, we additionally store information related to the KYC status of the underlying payto://-URI, in particular when the KYC expires (0 if it was never done).

Finally, we introduce a new legitimization_requirements table that contains a list of checks required for a particular wire target. When KYC is triggered (say when some endpoint returns an HTTP status code of 451) a new requirement is first put into the requirements table. Then, when the client identifies as business or individual the specific legitimization process is started. When the taler-exchange-aggregator triggers a KYC check the merchant can observe this when a 202 (Accepted) status code is returned on GET /deposits/ with the respective legitimization requirement row.

CREATE TABLE wire_targets
  (wire_target_serial_id BIGSERIAL UNIQUE
  ,h_payto BYTEA NOT NULL CHECK (LENGTH(h_payto)=64),
  ,target_token BYTEA UNIQUE CHECK (LENGTH(target_token)=32)
  ,target_pub BYTEA CHECK (LENGTH(target_pub)=32)
  ,payto_uri STRING NOT NULL
  ,PRIMARY KEY (h_payto,target_pub)
  PARTITION BY HASH (h_payto);

COMMENT ON TABLE wire_targets
  IS 'All recipients of money via the exchange';
COMMENT ON COLUMN wire_targets.payto_uri
  IS 'Can be a regular bank account, or also be a URI identifying a reserve-account (for P2P payments)';
COMMENT ON COLUMN wire_targets.h_payto
  IS 'Unsalted hash of payto_uri';
COMMENT ON COLUMN wire_targets.target_token
  IS 'high-entropy random value that uniquely identifies the wire target and is used as a token to authorize access to the KYC process (without requiring a signature by target_priv); NULL if KYC is not allowed for the account (legacy)';
COMMENT ON COLUMN wire_targets.target_pub
  IS 'Public key (reserve_pub or merchant_pub) associated with the account; NULL if KYC is not allowed for the account (legacy)';

CREATE TABLE IF NOT EXISTS legitimization_measures
  (legitimization_measure_serial_id BIGINT GENERATED BY DEFAULT AS IDENTITY
  ,target_token BYTEA NOT NULL UNIQUE CHECK (LENGTH(target_token)=32)
   REFERENCES wire_targets (target_token)
  ,start_time INT8 NOT NULL
  ,jmeasures VARCHAR[] NOT NULL
  PARTITION BY HASH (h_payto);

COMMENT ON COLUMN legitimization_requirements.target_token
  IS 'Used to uniquely identify the account and as a symmetric access control mechanism for the SPA';
COMMENT ON COLUMN legitimization_requirements.start_time
  IS 'Time when the measure was triggered (by decision or rule)';
COMMENT ON COLUMN legitimization_requirements.jmeasures
  IS 'JSON object of type LegitimizationMeasures with KYC/AML measures for the account encoded';
COMMENT ON COLUMN legitimization_requirements.is_finished
  IS 'Set to TRUE if this set of measures was processed; used to avoid indexing measures that are done';

CREATE INDEX ON legitimization_measures (target_token)
  WHERE NOT finished;

CREATE TABLE legitimization_outcomes
  ,h_payto BYTEA CHECK (LENGTH(h_payto)=32)
   REFERENCES wire_targets (h_payto)
  ,decision_time INT8 NOT NULL DEFAULT(0)
  ,expiration_time INT8 NOT NULL DEFAULT(0)
  ,jproperties TEXT,
  ,to_investigate BOOL NOT NULL
  ,is_frozen BOOL NOT NULL
  ,is_reported BOOL NOT NULL
  ,jnew_rules NOT NULL TEXT
  PARTITION BY HASH (h_payto);

COMMENT ON TABLE legitimization_outcomes
  IS 'Outcomes can come from AML programs';
COMMENT ON COLUMN legitimization_outcomes.h_payto
  IS 'hash of the payto://-URI this outcome is about';
COMMENT ON COLUMN legitimization_outcomes.decision_time
  IS 'when was this outcome decided';
COMMENT ON COLUMN legitimization_outcomes.expiration_time
  IS 'time when the decision expires and the expiration new_rules should be applied';
COMMENT ON COLUMN legitimization_outcomes.jproperties
  IS 'JSON object of type AccountProperties, such as PEP status, business domain, risk assessment, etc.';
COMMENT ON COLUMN legitimization_outcomes.to_investigate
  IS 'AML staff should investigate the activity of this account';
COMMENT ON COLUMN legitimization_outcomes.is_frozen
  IS 'Transactions with this account should be held (until expiration data or AML staff action)';
COMMENT ON COLUMN legitimization_outcomes.is_reported
  IS 'Set to TRUE if the activity of the account was reported to authorities';
COMMENT ON COLUMN legitimization_outcomes.is_active
  IS 'TRUE if this is the current authoritative legitimization outcome';
COMMENT ON COLUMN legitimization_outcomes.jnew_rules
  IS 'JSON object of type LegitimizationRules with rules to apply to the various operation types for this account; all KYC checks should first check if active new rules for a given account exist in this table (and apply specified measures); if not, it should check the default rules to decide if a measure is required';

CREATE INDEX legitimization_outcomes_active
  ON legitimization_outcomes(h_payto)
  WHERE is_active;

CREATE TABLE kyc_setups
  (kyc_setup_serial_id BIGSERIAL UNIQUE
  ,h_payto BYTEA NOT NULL CHECK (LENGTH(h_payto)=64)
   REFERENCES wire_targets (h_payto)
  ,start_time INT8 NOT NULL
  ,expiration_time INT8 NOT NULL DEFAULT (0)
  ,legitimization_measure_serial_id BIGINT
   REFERENCES legitimization_measures (legitimization_measure_serial_id)
  ,measure_index INT8
  ,provider_section VARCHAR NOT NULL
  ,provider_user_id VARCHAR DEFAULT NULL
  ,provider_legitimization_id VARCHAR DEFAULT NULL
  ,redirect_url TEXT DEFAULT NULL
  PARTITION BY HASH (h_payto);

  IS 'here we track KYC processes we initiated with external providers; the main reason is so that we do not initiate a second process when an equivalent one is still active; note that h_payto, provider_section, jcontext must match and the process must not be finished or expired for an existing redirect_url to be re-used; given that clients may voluntarily initiate KYC processes, there may not always be a legitimization_measure that triggered the setup';
COMMENT ON COLUMN kyc_setups.h_payto
  IS 'foreign key linking the entry to the wire_targets table, NOT a primary key (multiple KYC setups are possible per wire target)';
COMMENT ON COLUMN kyc_setups.start_time
  IS 'when was the legitimization process initiated';
COMMENT ON COLUMN kyc_setups.expiration_time
  IS 'when does the process expire (and needs to be manually set up again)';
COMMENT ON COLUMN kyc_setups.measure_index
  IS 'index of the measure in legitimization_measures that was selected for this KYC setup; NULL if legitimization_measure_serial_id is NULL; enables determination of the context data provided to the external process';
COMMENT ON COLUMN kyc_setups.provider_section
  IS 'Configuration file section with details about this provider';
COMMENT ON COLUMN kyc_setups.provider_user_id
  IS 'Identifier for the user at the provider that was used for the legitimization. NULL if provider is unaware.';
COMMENT ON COLUMN kyc_setups.provider_legitimization_id
  IS 'Identifier for the specific legitimization process at the provider. NULL if legitimization was not started.';
COMMENT ON COLUMN kyc_setups.legitimization_measure_serial_id
  IS 'measure that enabled this setup, NULL if client voluntarily initiated the process';
COMMENT ON COLUMN kyc_setups.redirect_url
  IS 'Where the user should be redirected for this external KYC process';
COMMENT ON COLUMN kyc_setups.finished
  IS 'set to TRUE when the specific legitimization process is finished';

CREATE TABLE kyc_attributes
  ,h_payto BYTEA PRIMARY KEY CHECK (LENGTH(h_payto)=32)
   REFERENCES wire_targets (h_payto)
  ,kyc_prox BYTEA NOT NULL CHECK (LENGTH(kyc_prox)=32)
  ,kyc_setup_serial_id INT8 NOT NULL
   REFERENCES kyc_setups (kyc_setup_serial_id)
  ,collection_time INT8 NOT NULL
  ,expiration_time INT8 NOT NULL
  ,trigger_outcome_serial INT8 NOT NULL
   REFERENCES legitimization_outcomes(outcome_serial_id)
  ,encrypted_attributes BYTEA NOT NULL
  ) PARTITION BY HASH (h_payto);

COMMENT ON COLUMN kyc_attributes.h_payto
  IS 'identifies the account this is about';
COMMENT ON COLUMN kyc_attributes.kyc_prox
  IS 'for proximity search on encrypted data';
COMMENT ON COLUMN kyc_attributes.kyc_setup_serial_id
  IS 'serial ID of the KYC setup that resulted in these attributes';
COMMENT ON COLUMN kyc_attributes.collection_time
  IS 'when were these attributes collected';
COMMENT ON COLUMN kyc_attributes.expiration_time
  IS 'when are these attributes expected to expire';
COMMENT ON COLUMN kyc_attributes.trigger_outcome_serial
  IS 'ID of the outcome that was returned by the AML program based on the KYC data collected';
COMMENT ON COLUMN kyc_attributes.encrypted_attributes
  IS 'encrypted JSON object with the attribute data the check provided';

CREATE TABLE aml_history
  ,h_payto BYTEA CHECK (LENGTH(h_payto)=32)
   REFERENCES wire_targets (h_payto)
  ,legitimization_outcome INT8 NOT NULL
   REFERENCES legitimization_outcomes (outcome_serial_id)
  ,justification TEXT NOT NULL
  ,decider_pub BYTEA CHECK (LENGTH(decider_pub)=32)
  ,decider_sig BYTEA CHECK (LENGTH(decider_sig)=64);

COMMENT ON TABLE aml_history
  IS 'Records decisions by AML staff with the respective signature and free-form justification.';
COMMENT ON COLUMN aml_history.legitimization_outcome
  IS 'Actual outcome for the account (included in what decider_sig signs over)';
COMMENT ON COLUMN aml_history.decider_sig
  IS 'Signature key of the staff member affirming the AML decision; of type AML_DECISION';

CREATE TABLE kyc_events
  ,event_timestamp INT8 NOT NULL
  ,event_type TEXT NOT NULL);

  IS 'Records of key events for statistics. Populated via triggers.';
COMMENT ON COLUMN kyc_events.event_type
  IS 'Name of the event, such as account-open or sar-filed';

CREATE INDEX kyc_event_index
  ON kyc_events(event_type,event_timestamp);

The jmeasures JSON in the legitimization_measures table has is of type LegitimizationMeasures:

interface LegitimizationMeasures {

  // Array of legitimization measures that
  // are to be applied.
  measures: MeasureInformation[];

  // True if the client is expected to eventually satisfy all requirements.
  // Default (if missing) is false.
  is_and_combinator?: boolean;

The jnew_rules JSON in the legitimization_outcomes table has is of type LegitimizationRules:

interface LegitimizationRules {

  // Measure to apply when the expiration time is
  // reached.  If not set, we refer to the default
  // set of rules (and the default account state).
  successor_measure?: string;

  // Legitimization rules that are to be applied
  // to this account.
  rules: KycRule[];

  // Custom measures that KYC rules may refer to.
  custom_measures: { "name" : MeasureInformation };

The jproperties JSON in the legitimization_outcomes table has is of type AccountProperties. All fields in this object are optional. The actual properties collected depend fully on the discretion of the exchange operator; however, some common fields are standardized and thus described here.

interface AccountProperties {

  // True if this is a politically exposed account.
  // Rules for classifying accounts as politically
  // exposed are country-dependent.
  pep?: boolean;

  // True if this is a sanctioned account.
  // Rules for classifying accounts as sanctioned
  // are country-dependent.
  sanctioned?: boolean;

  // True if this is a high-risk account.
  // Rules for classifying accounts as at-risk
  // are exchange operator-dependent.
  high_risk?: boolean;

  // Business domain of the account owner.
  // The list of possible business domains is
  // operator- or country-dependent.
  business_domain?: string;

} KYC forms

The KYC SPA run by clients needs to support three TYPEs of checks. INFO is only about displaying the provided information, LINK is about setting up an exteral KYC check and redirecting there. FORM is about displaying a particular (HTML) form to the user and POSTing the entered information directly with the exchange. Here we describe the forms that must be supported:

  • CHOICE: Asks the client a multiple-choice question. The context must include “choices: string[]” with a list of choices to show. Used, for example, to ask a client if they are an individual or a business. The resulting HTML FORM field name must be “choice” and it must be mapped to strings from the choices list.

  • UPLOAD: Asks the client to upload a single file. The context may include “extensions: string[]” with a list of allowed file extensions the client’s file must end with (e.g. “png”, “pdf”, “gif”). In the absence of this context, any file may be uploaded. The context may also include “size_limit: Integer” with the maximum file size in bytes that can be uploaded. The resulting HTML FORM must have two fields, “filename” and “filedata”. “filename” must be set to the basename of the original file (to the extend that it is available), and “filedata” to the base64-encoding of the uploaded data.

As with other SPA checks, the KYC form should also show the description of the check. Merchant modifications

A new setting is required where the merchant backend can be configured for a business (default) or individual.

We introduce new kyc_ok, aml_decision, kyc_timestamp and exchange_kyc_serial fields into a new table merchant_kyc with primary keys exchange_url and account_serial. This status is updated whenever a deposit is created or tracked, or whenever the mechant backend receives a /kyc-check/ response from the exchange. Initially, exchange_kyc_serial is zero, indicating that the merchant has not yet made any deposits and thus does not have an account at the exchange.

A new private endpoint /kyc is introduced which allows frontends to request the /kyc status of any configured account (including with long polling). If the KYC status is negative or the kyc_timestamp not recent (say older than one month), the merchant backend will re-check the KYC status at the exchange (and update its cached status). The endpoint then returns either that the KYC is OK, or information (same as from the exchange endpoint) to begin the KYC process.

The merchant backend uses the new field to remember that a KYC is pending (after detection in taler-merchant-depositcheck) and the SPA then shows a notification whenever the staff is logged in to the system. The notification can be hidden for the current day (remembered in local storage).

The notification links to a (new) KYC status page. When opened, the KYC SPA first re-checks the KYC status with the exchange. If the KYC is still unfinished, that SPA will show forms, links or contact information to begin the KYC process (for example, redirecting to the OAuth 2.0 login page of the legitimization resource server), otherwise it shows that the KYC process is done. If the KYC is unfinished, the merchant SPA should use long-polling on the KYC status on this page to ensure it is always up-to-date, and change to KYC satisfied should the long-poller return with positive news.


Semi-related: The TMH_setup_wire_account() is changed to use
128-bit salt values (to keep ``deposits`` table small) and checks for salt
to be well-formed should be added "everywhere". Bank requirements

The exchange primarily requires a KYC provider to be operated by the bank that offers an endpoint for with an API implemented by one of the logic plugins (and the respective legitimization configuration). Logic plugins

The $PROVIDER_SECTION is based on the name of the configuration section, not on the name of the logic plugin (that we call $LOGIC). Using the configuration section, the exchange then determines the logic plugin to use.

This section describes the general API for all of the supported KYC providers, as well as some details of how this general API could be implemented by the logic for different APIs. General KYC Logic Plugin API

This section provides a sketch of the proposed API for the KYC logic plugins.

  • initiation of KYC check (kyc-check):

    • inputs: + provider_section (for additional configuration) + h_payto

    • outputs: + success/provider-failure + redirect URL (or NULL) + provider_user_id (or NULL) + provider_legitimization_id (or NULL)

  • KYC status check (kyc-proof):

    • inputs: + provider_section (for additional configuration) + h_payto + provider_user_id (or NULL) + provider_legitimization_id (or NULL)

    • outputs: + success/pending/user-aborted/user-failure/provider-failure status code + HTML response for end-user

  • Webhook notification handler (kyc-webhook):

    • inputs: + HTTP method (GET/POST) + rest of URL (after provider_section) + HTTP body (if applicable!)

    • outputs: + success/pending/user-aborted/user-failure/provider-failure status code + h_payto (for DB status update) + HTTP response to be returned to KYC provider

The plugins do not directly interact with the database, the caller sets the expiration on success and also updates provider_user_id and provider_legitimization_id in the tables as required.

For the webhook, we need a way to lookup h_payto by other data, so the KYC logic plugin API should be provided a method lookup with:

  • inputs: + provider_section + provider_legitimization_id

  • outputs: + h_payto + legitimization_process_row OAuth 2.0 specifics

In terms of configuration, the OAuth 2.0 logic requires the respective client credentials to be configured apriori to enable access to the legitimization service.

For the /kyc-check/ endpoint, the OAuth 2.0 logic may need to create and store a nonce to be used during /kyc-proof/, depending on the OAuth variant used. This may require another exchange table. The OAuth 2.0 process must then be set up to end at the new /kyc-proof/$PROVIDER_ID/ endpoint.

This /kyc-proof/oauth2/ endpoint must query the OAuth 2.0 server using the code argument provided as a query parameter. Based on the result, it then updates the KYC table of the exchange with the legitimization status and returns a human-readable KYC status page.

The /kyc-webhook/ is not applicable. Persona specifics

We would use the hosted flow. Endpoints return a request-id, which we should log for diagnosis.

For /kyc-check/:

  • Post to /api/v1/accounts using reference-id set to our h_payto. Returns id (account_id).

  • Create /verify endpoint using template-id (from configuration), and account_id (from previous step) and a reference-id (use the legitimization_serial_id for the new process). Set redirect-uri to /kyc-proof/$PROVIDER_ID/. However, we cannot rely on the user clicking this, so we must also configure a webhook. The request returns a ‘verification-id. That we store under the provider_legitimization_id in the database.

For /kyc-proof/:

  • Use the /api/v1/verifications endpoint to get the verification status. Requires the verification-id from the previous step. Results include: created/pending/completed/expired (aborted)/failed.

For /kyc-webhook/:

  • The webhook is authenticated using a shared secret, which should be in the configuration. So all we should have to do is parse the POSTed body to find the status and the verification-id to lookup h_payto and return the result. KYC AID specifics

For /kyc-check/:

  • Post to /applicants with a type (person or company) to obtain applicant_id. Store that under provider_user_id. ISSUE: we need to get the company_name, business_activity_id and registration_country before this somehow!

  • start with create form URL /forms/$FORM_ID/urls providing our h_payto as the external_applicant_id, using the applicant_id from above, and the /kyc-proof/$PROVIDER_ID for the redirect_url.

  • redirect customer to the form_url, store the verification_id under provider_legitimization_id in the database.

For /kyc-proof/:

  • Not needed, just return an error.

For /kyc-webhook/:

  • For security, we should probably simply trigger the GET on /verifications/{verification_id} to not trust an unsigned POST to tell us anything for sure. The result is then returned. Types of KYC events

The /aml/kyc-statistics endpoint exposes statistics for various KYC event types.

We will initially support the use of the following types of KYC events in the SPA (and have a dialog to show the total number of any of these for any specified time range):

  • account-open

  • account-closed

  • voluntary-sar

  • mandatory-sar

  • pep-started

  • pep-ended

  • risky-started

  • risky-ended

  • account-frozen

  • account-unfrozen

Based on these, the SPA should also be albe to show active statistics (for any given timestamp) on the total number of:

  • open accounts

  • frozen accounts

  • high-risk accounts

  • PEPs served


This can be done by simply running the queries with a start time of zero and subtracting.

12.23.5. Alternatives

We could also store the access token (returned by OAuth 2.0), but that seems slightly more dangerous and given the close business relationship is unnecessary. Furthermore, not all APIs offer this.

We could extend the KYC logic API to return key attributes about the user (such as legal name, phone number, address, etc.) which we could then sign and return to the user. This would be useful in P2P payments to identify the origin of an invoice. However, we might want to be careful to not disclose the key attributes via the API by accident. This could likely be done by limiting access to the respective endpoint to messages with a signature by the reserve private key (which is the only case where we care to certify things anyway).

12.23.6. Drawbacks

12.23.7. Discussion / Q&A

(This should be filled in with results from discussions on mailing lists / personal communication.)