KYC Central — Elixir client

UK company KYC and AML risk assessment, from one API call.

Hex.pmDocsCILicense: MIT

Website · API reference · Get an API key · Other clients


Screen a UK company against Companies House, the FCA Register, GLEIF, the Insolvency Service, OFAC / UN / UK / EU sanctions lists, adverse media and the ICIJ Offshore Leaks database — then run a configurable rule engine over the result and get back a structured list of risk flags.

client = KYCCentral.new() # reads KYCCENTRAL_API_KEY
{:ok, assessment} = KYCCentral.KYC.assess(client, "00445790")
IO.puts("#{assessment.company_name}#{assessment.risk_level}")
for flag <- KYCCentral.Assessment.flags_at_or_above(assessment, :high) do
IO.puts(" [#{flag.severity}] #{flag.code}: #{flag.description}")
end
TESCO PLC → medium
[high] ACCOUNTS_OVERDUE: Annual accounts are 42 days overdue.

One runtime dependency. The default transport is OTP's own :httpc, so adding this client pulls in Jason and nothing else — no HTTP stack, no supervision tree, nothing to start. Prefer Req, Finch or Tesla? Plug yours in.

Contents

Install

Add :kyccentral to your dependencies in mix.exs:

def deps do
[
{:kyccentral, "~> 0.1"}
]
end

Requires Elixir 1.15+ and OTP 25+.

Authentication

Generate a key in Account settings, then either set an environment variable:

export KYCCENTRAL_API_KEY="your_key"
client = KYCCentral.new()

…or pass it explicitly:

client = KYCCentral.new(api_key: "your_key")

A key is not always required. Reference and lookup endpoints — company search, sanctions screening, FATF jurisdictions, health — work anonymously at a lower rate limit, which makes the library easy to try before you sign up. Assessments (KYCCentral.KYC.assess/3) and the AI endpoints always need a key.

KYCCentral.new/1 returns a plain struct. It starts no processes and opens no connections, so there is nothing to supervise and nothing to close — build one and pass it around, or memoise it in your application's state.

Quick start

alias KYCCentral.Assessment
client = KYCCentral.new()
with {:ok, %{"items" => [%{"company_number" => number} | _]}} <-
KYCCentral.Companies.search(client, "tesco plc", items_per_page: 5),
{:ok, assessment} <- KYCCentral.KYC.assess(client, number) do
cond do
Assessment.flags_at(assessment, [:critical]) != [] ->
{:block, Assessment.flags_at(assessment, [:critical])}
assessment.risk_level == :low and not Assessment.partial?(assessment) ->
:clear
true ->
:refer
end
end

Working with an assessment

assess/3 returns a %KYCCentral.Assessment{}:

assessment.company_name # "TESCO PLC"
assessment.risk_level # :medium
assessment.flags # [%KYCCentral.RiskFlag{code: "ACCOUNTS_OVERDUE", ...}]
assessment.rule_results # every rule, including the ones that passed
assessment.checked_at # when this assessment ran
assessment.data_fetched_at # how fresh the underlying registry data is

Severities and statuses are atoms — :low, :medium, :high, :critical and :passed, :failed, :not_evaluated — so they pattern-match cleanly:

case assessment.risk_level do
:critical -> escalate(assessment)
level when level in [:high, :medium] -> refer(assessment)
:low -> approve(assessment)
end

Helper functions keep the common checks short:

alias KYCCentral.Assessment
Assessment.clear?(assessment) # no flags at all
Assessment.flags_at(assessment, [:critical]) # blockers
Assessment.flags_at_or_above(assessment, :high) # by severity
Assessment.has_flag?(assessment, "ACCOUNTS_OVERDUE") # by code
Assessment.flag(assessment, "PSC_CHAIN_TOO_DEEP") # -> RiskFlag.t() | nil
Assessment.rules_with_status(assessment, :failed)

The evidence each rule was judged against is on the *_summary fields — :officers_summary, :psc_summary, :sanctions_summary, :charges_summary and so on — and the untouched response body is always on assessment.raw, so a field this client version doesn't model yet is never lost.

Partial results are marked as partial

An assessment fans out to a dozen upstream sources. When one is slow or down, the API returns what it has and says so rather than silently reporting a clean result:

if Assessment.partial?(assessment) do
Logger.warning("Incomplete assessment",
timed_out: assessment.timed_out_services,
failed_rules: assessment.failed_rules
)
end

Treat partial?/1 as "not yet screened", not "clean". An absent flag from a source that timed out is not evidence of absence.

Confirming noisy matches

Adverse media and Offshore Leaks matching is fuzzy, so unconfirmed hits only ever raise a low-severity *_UNCONFIRMED flag. Once an analyst has confirmed a specific article or match, pass it back to promote it to full severity:

KYCCentral.KYC.assess(client, "00445790",
confirmed_media_urls: ["https://news.example/article"],
confirmed_leak_ids: ["icij-node-12345"]
)

Queued assessments are handled for you

A cold assessment can take longer than a sensible HTTP timeout, so the API may answer 202 Accepted with a job id instead of holding the connection open. This client polls the job and returns the finished assessment either way:

KYCCentral.KYC.assess(client, "00445790") # blocks until done
KYCCentral.KYC.assess(client, "00445790", poll_timeout: 300_000) # allow longer
KYCCentral.KYC.assess(client, "00445790", wait: false) # %{"job_id" => ...}

Error handling

Every function returns {:ok, result} or {:error, %KYCCentral.Error{}}. Errors carry a :kind atom rather than being split across a tree of exception modules, which makes them pleasant to match on:

case KYCCentral.KYC.assess(client, "00445790") do
{:ok, assessment} ->
assessment
{:error, %KYCCentral.Error{kind: :not_found}} ->
:no_such_company
{:error, %KYCCentral.Error{kind: :permission_denied, detail: detail}} ->
{:upgrade_required, detail}
{:error, %KYCCentral.Error{kind: :rate_limit, retry_after: seconds}} ->
{:retry_in, seconds}
{:error, error} ->
raise error
end
:kindStatusUsual cause
:bad_request400Malformed request
:authentication401Missing or invalid API key
:permission_denied403Endpoint needs an active Professional subscription
:not_found404No such company, officer, charge, rule or rule set
:unprocessable_entity422Failed the API's validation — see :body
:rate_limit429Rate limit or monthly free quota — see :retry_after
:server_error / :service_unavailable5xxAPI or an upstream dependency failed
:connection / :timeoutNever reached the API
:job_failed / :job_timeoutA queued assessment failed or outran :poll_timeout
:invalid_argumentCaught before any request was made

KYCCentral.Error is an exception, so raise error works when you would rather not handle a failure locally.

Retries, timeouts and TLS

Timeouts, connection failures and retryable statuses (408, 429, 500, 502, 503, 504) are retried twice by default, with exponential backoff plus jitter, honouring Retry-After. Client errors like 401, 403, 404 and 422 are never retried — they will not become true on a second attempt.

KYCCentral.new(
receive_timeout: 60_000, # per-request, milliseconds
max_retries: 5 # 0 disables retries entirely
)

The default :httpc transport verifies TLS properly — verify_peer against the OS trust store, with hostname checking and TLS 1.2/1.3 only. :httpc does not do this by default, and a client that carries an API key must never talk to an unverified peer.

Using a different HTTP client

Pass :http — a one-argument function. This is also how the test suite runs offline:

# With Req
http = fn request ->
case Req.request(
method: request.method,
url: request.url,
headers: request.headers,
body: request.body,
receive_timeout: request.receive_timeout,
retry: false,
decode_body: false
) do
{:ok, resp} -> {:ok, %{status: resp.status, headers: resp.headers, body: resp.body}}
{:error, reason} -> {:error, reason}
end
end
client = KYCCentral.new(http: http)

The function receives %{method:, url:, headers:, body:, receive_timeout:} and must return {:ok, %{status:, headers:, body:}} or {:error, reason}. Bodies are decoded centrally, so returning the raw string is correct — this client's retry policy and error mapping then apply unchanged.

Calling from Erlang

Elixir modules are reachable from Erlang with an Elixir. prefix:

Client = 'Elixir.KYCCentral':new([{api_key, <<"your_key">>}]),
{ok, Assessment} = 'Elixir.KYCCentral.KYC':assess(Client, <<"00445790">>),
RiskLevel = maps:get(risk_level, Assessment),
Flags = maps:get(flags, Assessment).

Structs are maps with a '__struct__' key, so maps:get/2 reads any field. Add {kyccentral, "0.1.0"} to your rebar.config deps.

Rate limits and plans

TierLimit
Anonymous30 requests / minute, per IP
Authenticated120 requests / minute
Professional subscription60 assessments / minute, full endpoint access
Free planA fixed number of assessments per calendar month

Cached results and failed runs don't consume free-plan quota. Endpoints marked Professional below return {:error, %KYCCentral.Error{kind: :permission_denied}} without an active subscription.

Batch endpoints exist precisely to stay inside these limits — KYCCentral.Sanctions.screen_names/2 screens up to 500 names in a single request.

API coverage

Every documented endpoint is available. Each function takes the client as its first argument.

Companies — KYCCentral.Companies
FunctionEndpoint
search/3GET /companies/search
search_officers/3GET /companies/search/officers
advanced_search/2GET /companies/advanced-search
get/2GET /companies/{n}
dossier/2GET /companies/{n}/dossier
officers/2GET /companies/{n}/officers
pscs/2GET /companies/{n}/persons-with-significant-control
psc_statements/2GET /companies/{n}/persons-with-significant-control-statements
psc_chain_depth/2GET /companies/{n}/psc-chain-depth
psc_chain_tree/2GET /companies/{n}/psc-chain-tree
charges/2GET /companies/{n}/charges
charge/3GET /companies/{n}/charges/{id}
insolvency/2GET /companies/{n}/insolvency
disqualifications/2GET /companies/{n}/disqualifications
officer_disqualification/3GET /companies/{n}/officers/{id}/disqualification
officer_appointments/3GET /companies/officers/{id}/appointments
filing_history/3ProfessionalGET /companies/{n}/filing-history
filing_extract/3ProfessionalGET /companies/{n}/filing-history/{tx}/extract
statement_of_capital/2ProfessionalGET /companies/{n}/statement-of-capital

dossier/2 returns profile, officers, PSCs, charges, insolvency and filings in one request — cheaper than six separate calls.

Assessments and rules
FunctionEndpoint
KYCCentral.KYC.assess/3GET /kyc/assess
KYCCentral.RuleSets.list/1GET /rule-sets
KYCCentral.Rules.list/1GET /rules
KYCCentral.Rules.fields/1GET /rules/fields
KYCCentral.Jobs.get/2GET /jobs/{id}
Screening
FunctionEndpoint
KYCCentral.Sanctions.status/1GET /sanctions/status
KYCCentral.Sanctions.meta/1GET /sanctions/meta
KYCCentral.Sanctions.screen/3GET /sanctions/screen
KYCCentral.Sanctions.screen_names/2POST /sanctions/screen-names
KYCCentral.Sanctions.entities/2GET /sanctions/entities
KYCCentral.News.status/1GET /news/status
KYCCentral.News.search_names/2ProfessionalPOST /news/search-names
KYCCentral.News.search_entities/2ProfessionalPOST /news/search-entities
KYCCentral.News.screen_company/2ProfessionalGET /news/screen-company
KYCCentral.OffshoreLeaks.status/1GET /offshore-leaks/status
KYCCentral.OffshoreLeaks.screen_names/2POST /offshore-leaks/screen-names
KYCCentral.OffshoreLeaks.screen_company/2GET /offshore-leaks/screen-company
KYCCentral.OffshoreLeaks.node/3GET /offshore-leaks/node/{id}

Sanctions coverage: OFAC (US), UN Security Council, the UK Sanctions List and the EU Financial Sanctions Files.

Registries and reference data
FunctionEndpoint
KYCCentral.FCA.status/1GET /fca/status
KYCCentral.FCA.search/2GET /fca/search
KYCCentral.FCA.firm/2GET /fca/firm/{frn}
KYCCentral.FCA.firm_names/2GET /fca/firm/{frn}/names
KYCCentral.FCA.firm_individuals/2GET /fca/firm/{frn}/individuals
KYCCentral.FCA.screen_individuals/2GET /fca/screen-individuals
KYCCentral.FCA.check_individual/2GET /fca/check-individual
KYCCentral.GLEIF.company/2GET /gleif/company
KYCCentral.IndividualInsolvency.screen_company/2GET /individual-insolvency/screen-company
KYCCentral.Charity.status/1GET /charity/status
KYCCentral.Charity.search/2GET /charity/search
KYCCentral.Charity.get/3GET /charity/charity/{regno}
KYCCentral.Charity.trustees/2GET /charity/charity/{regno}/trustees
KYCCentral.HMRCVat.status/1GET /hmrc-vat/status
KYCCentral.HMRCVat.check/2GET /hmrc-vat/check
KYCCentral.Jurisdictions.list/1GET /jurisdictions
KYCCentral.Jurisdictions.check/2GET /jurisdictions/check
KYCCentral.OffshoreJurisdictions.list/1GET /offshore-jurisdictions
KYCCentral.OffshoreJurisdictions.check/2GET /offshore-jurisdictions/check

FATF listings are refreshed after each plenary (roughly February, June and October).

AI analysis and health
FunctionEndpoint
KYCCentral.Analysis.status/1GET /analysis/status
KYCCentral.Analysis.company/3ProfessionalPOST /analysis/company
KYCCentral.Analysis.adverse_media_overview/3ProfessionalPOST /analysis/adverse-media-overview
KYCCentral.Analysis.filing_extract/4POST /analysis/filing-extract
KYCCentral.Docs.ask/3POST /docs/ask
KYCCentral.health/1GET /health
KYCCentral.data_source_health/1GET /health/data-sources

Endpoints that proxy an upstream registry return the decoded JSON as a plain map with string keys, so new upstream fields reach you the day they ship instead of waiting on a client release. The assessment result — the one response shape this API owns — is a typed struct.

Compliance notes

This library is a client for a data API. It is not, and does not provide, regulatory advice, and using it does not by itself discharge any obligation under the Money Laundering Regulations.

Other languages

LanguagePackageRepository
Pythonkyccentralkyccentral-python
JavaScript / TypeScript@kyccentral/sdkkyccentral-js
Elixir / Erlangkyccentralkyccentral-elixir

Contributing

Contributions are welcome — see CONTRIBUTING.md.

git clone https://github.com/qualia91/kyccentral-elixir
cd kyccentral-elixir
mix deps.get
mix test

The test suite injects a stub HTTP function, so it runs offline and needs no API key.

Licence

MIT © KYC Central