KYC Central — Elixir client
UK company KYC and AML risk assessment, from one API call.
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
- Authentication
- Quick start
- Working with an assessment
- Error handling
- Retries, timeouts and TLS
- Using a different HTTP client
- Calling from Erlang
- Rate limits and plans
- API coverage
- Compliance notes
- Contributing
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
:kind | Status | Usual cause |
|---|---|---|
:bad_request | 400 | Malformed request |
:authentication | 401 | Missing or invalid API key |
:permission_denied | 403 | Endpoint needs an active Professional subscription |
:not_found | 404 | No such company, officer, charge, rule or rule set |
:unprocessable_entity | 422 | Failed the API's validation — see :body |
:rate_limit | 429 | Rate limit or monthly free quota — see :retry_after |
:server_error / :service_unavailable | 5xx | API or an upstream dependency failed |
:connection / :timeout | — | Never reached the API |
:job_failed / :job_timeout | — | A queued assessment failed or outran :poll_timeout |
:invalid_argument | — | Caught 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
| Tier | Limit |
|---|---|
| Anonymous | 30 requests / minute, per IP |
| Authenticated | 120 requests / minute |
| Professional subscription | 60 assessments / minute, full endpoint access |
| Free plan | A 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
| Function | Endpoint |
|---|---|
search/3 | GET /companies/search |
search_officers/3 | GET /companies/search/officers |
advanced_search/2 | GET /companies/advanced-search |
get/2 | GET /companies/{n} |
dossier/2 | GET /companies/{n}/dossier |
officers/2 | GET /companies/{n}/officers |
pscs/2 | GET /companies/{n}/persons-with-significant-control |
psc_statements/2 | GET /companies/{n}/persons-with-significant-control-statements |
psc_chain_depth/2 | GET /companies/{n}/psc-chain-depth |
psc_chain_tree/2 | GET /companies/{n}/psc-chain-tree |
charges/2 | GET /companies/{n}/charges |
charge/3 | GET /companies/{n}/charges/{id} |
insolvency/2 | GET /companies/{n}/insolvency |
disqualifications/2 | GET /companies/{n}/disqualifications |
officer_disqualification/3 | GET /companies/{n}/officers/{id}/disqualification |
officer_appointments/3 | GET /companies/officers/{id}/appointments |
filing_history/3Professional | GET /companies/{n}/filing-history |
filing_extract/3Professional | GET /companies/{n}/filing-history/{tx}/extract |
statement_of_capital/2Professional | GET /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
| Function | Endpoint |
|---|---|
KYCCentral.KYC.assess/3 | GET /kyc/assess |
KYCCentral.RuleSets.list/1 | GET /rule-sets |
KYCCentral.Rules.list/1 | GET /rules |
KYCCentral.Rules.fields/1 | GET /rules/fields |
KYCCentral.Jobs.get/2 | GET /jobs/{id} |
Screening
| Function | Endpoint |
|---|---|
KYCCentral.Sanctions.status/1 | GET /sanctions/status |
KYCCentral.Sanctions.meta/1 | GET /sanctions/meta |
KYCCentral.Sanctions.screen/3 | GET /sanctions/screen |
KYCCentral.Sanctions.screen_names/2 | POST /sanctions/screen-names |
KYCCentral.Sanctions.entities/2 | GET /sanctions/entities |
KYCCentral.News.status/1 | GET /news/status |
KYCCentral.News.search_names/2Professional | POST /news/search-names |
KYCCentral.News.search_entities/2Professional | POST /news/search-entities |
KYCCentral.News.screen_company/2Professional | GET /news/screen-company |
KYCCentral.OffshoreLeaks.status/1 | GET /offshore-leaks/status |
KYCCentral.OffshoreLeaks.screen_names/2 | POST /offshore-leaks/screen-names |
KYCCentral.OffshoreLeaks.screen_company/2 | GET /offshore-leaks/screen-company |
KYCCentral.OffshoreLeaks.node/3 | GET /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
| Function | Endpoint |
|---|---|
KYCCentral.FCA.status/1 | GET /fca/status |
KYCCentral.FCA.search/2 | GET /fca/search |
KYCCentral.FCA.firm/2 | GET /fca/firm/{frn} |
KYCCentral.FCA.firm_names/2 | GET /fca/firm/{frn}/names |
KYCCentral.FCA.firm_individuals/2 | GET /fca/firm/{frn}/individuals |
KYCCentral.FCA.screen_individuals/2 | GET /fca/screen-individuals |
KYCCentral.FCA.check_individual/2 | GET /fca/check-individual |
KYCCentral.GLEIF.company/2 | GET /gleif/company |
KYCCentral.IndividualInsolvency.screen_company/2 | GET /individual-insolvency/screen-company |
KYCCentral.Charity.status/1 | GET /charity/status |
KYCCentral.Charity.search/2 | GET /charity/search |
KYCCentral.Charity.get/3 | GET /charity/charity/{regno} |
KYCCentral.Charity.trustees/2 | GET /charity/charity/{regno}/trustees |
KYCCentral.HMRCVat.status/1 | GET /hmrc-vat/status |
KYCCentral.HMRCVat.check/2 | GET /hmrc-vat/check |
KYCCentral.Jurisdictions.list/1 | GET /jurisdictions |
KYCCentral.Jurisdictions.check/2 | GET /jurisdictions/check |
KYCCentral.OffshoreJurisdictions.list/1 | GET /offshore-jurisdictions |
KYCCentral.OffshoreJurisdictions.check/2 | GET /offshore-jurisdictions/check |
FATF listings are refreshed after each plenary (roughly February, June and October).
AI analysis and health
| Function | Endpoint |
|---|---|
KYCCentral.Analysis.status/1 | GET /analysis/status |
KYCCentral.Analysis.company/3Professional | POST /analysis/company |
KYCCentral.Analysis.adverse_media_overview/3Professional | POST /analysis/adverse-media-overview |
KYCCentral.Analysis.filing_extract/4 | POST /analysis/filing-extract |
KYCCentral.Docs.ask/3 | POST /docs/ask |
KYCCentral.health/1 | GET /health |
KYCCentral.data_source_health/1 | GET /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.
- Sanctions and adverse media matching is approximate. Sanctions lists carry transliterated names, aliases and date-of-birth ranges. Every hit is a candidate for human review, not a determination.
- Unconfirmed matches are deliberately low-severity. Adverse media and Offshore Leaks
hits stay at
*_UNCONFIRMEDuntil an analyst confirms the specific article or match. Don't promote them programmatically. - There is no PEP screening. The platform ingests sanctions lists only. Nothing here identifies politically exposed persons.
- Check
Assessment.partial?/1before recording a clean result. See Partial results. - Registry data has a lag.
assessment.data_fetched_attells you how fresh the underlying Companies House data is.
Other languages
| Language | Package | Repository |
|---|---|---|
| Python | kyccentral | kyccentral-python |
| JavaScript / TypeScript | @kyccentral/sdk | kyccentral-js |
| Elixir / Erlang | kyccentral | kyccentral-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