API 概览
本指南说明如何整合 Know Your Customer Limited 的 Public API v2:先介绍背后的概念,再说明您将执行的各项工作,并全程附上可实际执行的范例。第一次阅读时建议由头到尾读完,之后可作为参考资料使用。
如果您想在十分钟内完成第一次呼叫,请由快速入门开始,之后再回到本页深入了解。
简介
Know Your Customer Public API 让您可直接在产品中建立 KYB(企业尽职调查)功能。系统透过与 147 个司法管辖区的官方公司登记处即时连接来验证公司资料,涵盖范围为业界最广,覆盖每个已连接司法管辖区内 100% 合法注册的实体。同时包含对公司拥有人及董事的 KYC(客户尽职调查),让您可在同一流程中验证企业背后的个人。这是一套 REST API,供银行、虚拟银行、支付公司及其他众多需要即时、权威的公司及股权资料的机构使用。
单一 API 即涵盖完整的 KYB 及持续监控生命周期:
- 在验证当下即时对照官方登记处验证公司资料,并读取结构化的实体资料:名称、注册编号、状态、地址及高级职员。
- 取得最新的相关登记文件(登记摘录、注册证书、周年申报表等)。
- 解析谁最终拥有及控制该公司(实益拥有权及递回式组织架构图)。
- 针对制裁名单、政治敏感人物(PEP)名单及负面新闻名单,筛查该实体及相关个人。
- 对公司的最终受益所有人(UBO)及董事进行 KYC:收集并核对其身分文件(作为附加功能,采用第三方身分验证(IDV))。
- 产生可供稽核的报告。
- 客户开户后持续监控,并就警示采取行动。
以上所有工作均透过单一资源完成,即案例,以及一组简明可预测的端点。凡需时较长的工作,API 均采用非同步方式:您建立案例,再进行轮询直至完成。
沙盒与正式环境
Know Your Customer 提供免费的沙盒,网址为https://api.knowyourcustomer.dev。这是标准 KYC 平台的高拟真、规格完全一致的复制版本:相同的规格、相同的回应结构、相同的状态流程,并预先载入真实公开登记处公司及合成个人资料。您可免费在沙盒环境中开发,无需支付即时登记费用,之后只需切换基础网址及凭证即可转往正式环境。详见转往正式环境及沙盒页面。
当您准备好动手撰写程式码时,可直接前往快速入门或端对端公司验证。
核心概念
本节提供整体概念架构。本指南其余部分的工作内容均建基于这些概念,因此即使日后只是略读,也建议先完整阅读一次本节。
案例
一个案例是工作的基本单位。您所执行的一切操作均在案例之内进行。
案例分为两种:
- 一个企业(公司)案例:即您正在验证的企业客户(KYB)。
- 一个个人案例:即您正在验证的个人(KYC)。
每个案例均有一个唯一识别码,即caseCommonId,于建立案例时返回。您会使用该识别码caseCommonId进行往后所有与该案例相关的呼叫:读取其状态、列出其成员、上传文件、撷取其报告。
公司案例的内容相当丰富:建立时,Know Your Customer 会撷取该公司的登记记录、找出其拥有人、建立股权架构树,并进行筛查。个人案例则较为简单,主要围绕身分文件及反洗钱筛查。
案例生命周期与状态
案例是以非同步方式建立的。当您建立公司案例时,API 会启动一个背景程序,撷取登记记录、执行核对,并组合股权结构。视乎司法管辖区不同,此过程可能需时数秒至数分钟不等。建立案例的回应中并不会直接提供完成后的案例,您需要轮询该案例直至完成。
案例会经过一系列数字状态。典型的流程如下:
0 Initializing
50 Queued for build
51 Fetching registry record
then a varying subset of:
53 (build sub-step)
54 (build sub-step)
9 Google search
100 AML checks
107 Building
3 Ready并非每个案例都会经过所有中间状态。介乎 51 与 3 之间所经过的子集,会因司法管辖区及案例所需而有所不同。切勿在程式中硬性假设某个状态(例如状态 100)必定会出现。请将中间状态视为资讯性参考,并以最终状态作为程式逻辑的判断依据。
就绪指状态为3 且,同时案例结构已填入资料(成员及步骤均已存在)。请持续轮询,直至看到状态为3,然后再读取结果。完整对照表载于参考附录。
实际操作中可能出现的完整状态集合为{0, 3, 9, 50, 51, 53, 54, 100, 107}。
司法管辖区与登记处
公司案例是依据该公司所属的本地登记处建立的。登记处(因而连带资料内容及处理时间)会因司法管辖区而异。部分司法管辖区可于数秒内返回结果,部分则需数分钟,少数更需时更久。请据此设计您的轮询逻辑(详见轮询直至完成)。
您可在codeiso31662栏位中以 ISO 3166-2 国家代码标明司法管辖区,例如GB、SG或HK。在建立案例时提供国家资料,有助 Know Your Customer 准确导向正确的登记处。
成员与股权结构(UBO)
公司甚少由单一个人拥有。API 会解析公司的控制实体及个人,并以controllingEntitiesAndIndividuals的形式呈现于案例之中。
股权结构亦会以递回式的组织架构图呈现:即一个根公司节点,内嵌巢状的shareholders。股东本身亦可以是一间公司,而该公司再由其他公司或个人拥有。这正是 API 表达多层实益拥有权结构的方式:企业母公司位于其他企业之上,个人(即最终受益所有人,UBO)则位于架构的末端节点。
每个成员均带有一个memberType栏位,该栏位为一个字串,其值为"Individual"或"Company"。每个个人成员均有其专属的caseCommonId。您可将该个人以独立案例的形式,于/v2/Individuals/{caseCommonId}指定并处理,例如收集其身分文件。
多层结构的实例可参考CROPWELL BISHOP CREAMERY LIMITED,详见公司验证流程说明。
步骤
一个案例由步骤组成。每个步骤均为一项验证工作单位:例如撷取登记记录、收集文件、执行反洗钱筛查、执行 Google 搜寻、建立结构等。每个步骤均有一个caseStepId。
部分步骤会被停用(isDeactivated = true)。当某个步骤不需要进行完整验证时,该步骤便会被停用,例如持股量低于您所设定门槛的少数股东,或已通过反洗钱及制裁名单筛查并显示清白的实体。已停用的步骤其实仍已完成筛查,只是不需要完整的 KYC 程序。若您确实需要对其进行完整验证,可将该启用已停用的步骤。详见实益拥有权与个人。
文件与预先验证
每个案例均有其必要文件:
- 一个公司案例需要登记文件。
- 一个个人案例则需要
photoid、selfie及poa(住址证明)。
您可透过 multipart 请求上传文件,并可预先验证:API 会检查上传的档案,并回传讯息告知该文件是否符合要求。举例来说,若身分文件无法辨识或怀疑伪造,系统便会回传类似以下的预先验证讯息:IdentityDocumentNotRecognized。请将身分文件归入个人案例,将登记或董事会文件归入公司案例。完整规格请见文件。
反洗钱筛查
反洗钱筛查(制裁名单、政治敏感人物及负面新闻)为案例流程的一部分,其结果可供读取。您可对每个结果进行覆核,剔除误判项目,并重新计算筛查结果。开户完成后,即时监控会持续筛查该案例,并在有需要时发出警示,供您列出、查看及处理。详见反洗钱与持续监控。
报告
案例完成后,您可取得一份可供稽核的 PDF 报告。该报告记录已验证的结构、已执行的核对项目及其结果,适合用作稽核纪录。在沙盒环境中,报告仅供评估之用。详见报告。
即时监控
即时监控是客户开户后的持续筛查机制。若出现新的制裁名单、政治敏感人物或负面新闻比对结果,便会发出警示。您可列出所有警示、撷取个别警示详情并加以处理。您亦可设定覆核日期,为案例安排定期覆核。详见反洗钱与持续监控。
取得存取权限与验证
申请存取权限
如要使用沙盒环境,请申请存取权限。您需签署沙盒测试协议,并会收到沙盒客户端凭证:一组client_id与一组client_secret,以及权杖网址及基础网址。正式环境凭证则于商业合作洽谈过程中另行发放。
OAuth2 client-credentials(主要方式)
验证采用 OAuth2 client-credentials 授权方式。您需以您的client_id及client_secret换取一个短期有效的持有人权杖,并于每次呼叫 API 时传送该权杖。
透过向权杖端点发送 POST 请求以取得权杖:
grant_type=client_credentialsclient_id=<your client id>client_secret=<your client secret>scope=PublicApi
回应中会包含一个access_token,有效期约为十分钟。请于每次请求中透过Authorization标头传送:
Authorization: Bearer <access_token>当权杖过期时,您将收到401。请重新取得权杖并再次尝试。稳健的用户端应快取该权杖,并在十分钟有效期即将届满前更新,而非每次呼叫都重新取得。
# Exchange client credentials for a bearer token (~10 min TTL)
TOKEN=$(curl -fsS -X POST "$BASE_URL/connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "scope=PublicApi" | jq -r '.access_token')
# Send it on every request:
# -H "Authorization: Bearer $TOKEN"import requests
BASE_URL = "https://api.knowyourcustomer.dev" # free Sandbox
resp = requests.post(
f"{BASE_URL}/connect/token",
data={
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"scope": "PublicApi",
},
timeout=30,
)
resp.raise_for_status()
token = resp.json()["access_token"] # ~10 min TTL
headers = {"Authorization": f"Bearer {token}"}const BASE_URL = "https://api.knowyourcustomer.dev"; // free Sandbox
const body = new URLSearchParams({
grant_type: "client_credentials",
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
scope: "PublicApi",
});
const res = await fetch(`${BASE_URL}/connect/token`, { method: "POST", body });
if (!res.ok) throw new Error(`token failed: ${res.status}`);
const { access_token } = await res.json(); // ~10 min TTL
const headers = { Authorization: `Bearer ${access_token}` };Customer API Key(旧有、次要方式)
另有一种旧有的静态Customer API Key标头,每位客户均拥有独一无二的一组。此方式支援旧有的整合方案。新的整合应采用 OAuth2 client-credentials,此为主要且建议采用的验证方式。若您确实使用 API 金钥,请以客户 API 金钥标头传送,而非持有人权杖。
基础网址
| 环境 | 基础网址 |
|---|---|
| 沙盒 | https://api.knowyourcustomer.dev |
| 正式环境(欧洲) | https://api.knowyourcustomer.com |
| 正式环境(亚洲) | https://api-asia.knowyourcustomer.com |
三者的规格完全相同。由沙盒环境转往正式环境时,您只需变更基础网址及凭证;请求与回应的结构保持不变。
端对端公司验证(KYB)
这是最具代表性的完整流程说明:搜寻公司、建立案例、轮询直至完成,并读取包括股权结构在内的已验证结果。此流程的完整多语言程式码载于下方。
#!/usr/bin/env bash
#
# KYC Public API v2: full onboarding journey (curl + bash)
#
# Journey: token -> search -> create -> poll to Ready -> members + org-chart
# -> get an individual member -> upload document (good vs forged)
# -> AML view -> close (Approved) -> download report PDF
#
# Requires: bash, curl, jq
# Usage: CLIENT_ID=... CLIENT_SECRET=... ./journey.sh
#
set -euo pipefail
BASE_URL="${BASE_URL:-https://api.knowyourcustomer.dev}" # free Sandbox
CLIENT_ID="${CLIENT_ID:-YOUR_CLIENT_ID}"
CLIENT_SECRET="${CLIENT_SECRET:-YOUR_CLIENT_SECRET}"
# What we want to onboard
ISO="GB"
QUERY="CROPWELL BISHOP"
# --- 1. Get an access token -------------------------------------------------
echo "==> Requesting access token"
TOKEN=$(curl -fsS -X POST "$BASE_URL/connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "scope=PublicApi" | jq -r '.access_token')
auth=(-H "Authorization: Bearer $TOKEN")
# --- 2. Search the registry -------------------------------------------------
echo "==> Searching registry for '$QUERY' in $ISO"
RAWNAME=$(curl -fsS -X POST "$BASE_URL/v2/Companies/search" "${auth[@]}" \
-H "Content-Type: application/json" \
-d "{\"codeiso31662\":\"$ISO\",\"query\":\"$QUERY\"}" \
| jq -r '.companySearch.searchResults[0].rawname')
echo " matched: $RAWNAME"
# --- 3. Create the company case ---------------------------------------------
echo "==> Creating company case"
CASE_ID=$(curl -fsS -X POST "$BASE_URL/v2/Companies" "${auth[@]}" \
-H "Content-Type: application/json" \
-d "{\"rawname\":\"$RAWNAME\",\"codeiso31662\":\"$ISO\"}" \
| jq -r '.caseDetail.details.common.caseCommonId')
echo " caseCommonId: $CASE_ID"
# --- 4. Poll until Ready (statusId == 3) ------------------------------------
echo "==> Polling for Ready"
for i in $(seq 1 60); do
STATUS=$(curl -fsS "$BASE_URL/v2/Companies/$CASE_ID" "${auth[@]}" \
| jq -r '.caseDetail.details.common.statusId')
echo " statusId=$STATUS"
if [ "$STATUS" = "3" ]; then break; fi
if [ "$STATUS" = "8" ]; then echo " case expired/failed"; exit 1; fi
sleep 5
done
# --- 5. Members + org-chart -------------------------------------------------
echo "==> Fetching members"
MEMBERS=$(curl -fsS "$BASE_URL/v2/Companies/$CASE_ID/members" "${auth[@]}")
echo "$MEMBERS" | jq '{controlling: (.controllingEntitiesAndIndividuals | length),
shareholders: (.shareholdersAndBeneficialOwners | length)}'
echo "==> Fetching org-chart (recursive ownership tree)"
curl -fsS "$BASE_URL/v2/Companies/$CASE_ID/org-chart" "${auth[@]}" \
| jq '{root: .name, shareholders: [.shareholders[]?.name]}'
# Pick the first individual controlling party (memberType == "Individual")
INDIV_ID=$(echo "$MEMBERS" \
| jq -r 'first(.controllingEntitiesAndIndividuals[] | select(.memberType=="Individual") | .member.caseCommonId)')
echo " individual member caseCommonId: $INDIV_ID"
# --- 6. Get the individual + its mandatory docs -----------------------------
echo "==> Fetching individual member $INDIV_ID"
curl -fsS "$BASE_URL/v2/Individuals/$INDIV_ID" "${auth[@]}" \
| jq '.caseDetail.details.common | {caseCommonId, statusId, statusName}'
echo "==> Mandatory documents for the individual"
curl -fsS "$BASE_URL/v2/Individuals/$INDIV_ID/documents/mandatory" "${auth[@]}" | jq .
# typically: ["photoid","selfie","poa"]
# --- 7. Upload a document (multipart): good then forged ---------------------
# Replace ./passport.jpg / ./forged.jpg with real files when running.
echo "==> Uploading a (good) identity document"
curl -fsS -X POST "$BASE_URL/v2/Individuals/$INDIV_ID/documents/upload" "${auth[@]}" \
-F "file=@./passport.jpg" \
-F "name=Passport" \
-F "fileCat=photoid" \
-F "caseCommonId=$INDIV_ID" \
-F "isCompany=false" \
| jq '{caseDocumentId, category, prevalidationMessages}'
# Good document -> prevalidationMessages: []
echo "==> Uploading a (forged) identity document, expect a prevalidation message"
curl -fsS -X POST "$BASE_URL/v2/Individuals/$INDIV_ID/documents/upload" "${auth[@]}" \
-F "file=@./forged.jpg" \
-F "name=Passport (forged)" \
-F "fileCat=photoid" \
-F "caseCommonId=$INDIV_ID" \
-F "isCompany=false" \
| jq '.prevalidationMessages'
# Forged document -> [{ "key": "IdentityDocumentNotRecognized", ... }]
# --- 8. AML view ------------------------------------------------------------
echo "==> AML checks for the company"
curl -fsS "$BASE_URL/v2/Companies/$CASE_ID/amlchecks" "${auth[@]}" \
| jq '{worldChecks: (.worldChecks | length), lexisNexisChecks: (.lexisNexisChecks | length)}'
# --- 9. Close the case with a decision --------------------------------------
echo "==> Recording decision: Approved"
curl -fsS -X PATCH "$BASE_URL/v2/Companies/$CASE_ID/status" "${auth[@]}" \
-H "Content-Type: application/json" \
-d '{"status":"Approved"}' | jq .
# --- 10. Download the KYC report PDF ----------------------------------------
echo "==> Downloading KYC report PDF"
curl -fsS "$BASE_URL/v2/Companies/$CASE_ID/report?language=en" "${auth[@]}" \
-o "kyc-report-$CASE_ID.pdf"
echo " saved kyc-report-$CASE_ID.pdf"
echo "==> Done."
#!/usr/bin/env python3
"""
KYC Public API v2 - full onboarding journey (Python + requests)
Journey: token -> search -> create -> poll to Ready -> members + org-chart
-> get an individual member -> upload document (good vs forged)
-> AML view -> close (Approved) -> download report PDF
Requires: pip install requests
Usage: CLIENT_ID=... CLIENT_SECRET=... python journey.py
"""
import os
import sys
import time
import requests
BASE_URL = os.environ.get("BASE_URL", "https://api.knowyourcustomer.dev") # free Sandbox
CLIENT_ID = os.environ.get("CLIENT_ID", "YOUR_CLIENT_ID")
CLIENT_SECRET = os.environ.get("CLIENT_SECRET", "YOUR_CLIENT_SECRET")
ISO = "GB"
QUERY = "CROPWELL BISHOP"
class KycClient:
"""Minimal client with an in-memory token cache and refresh-on-401."""
def __init__(self, base_url, client_id, client_secret):
self.base_url = base_url.rstrip("/")
self.client_id = client_id
self.client_secret = client_secret
self._token = None
self._expires_at = 0.0
def _token_value(self):
if self._token and time.time() < self._expires_at - 30:
return self._token
resp = requests.post(
f"{self.base_url}/connect/token",
data={
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret,
"scope": "PublicApi",
},
timeout=30,
)
resp.raise_for_status()
body = resp.json()
self._token = body["access_token"]
self._expires_at = time.time() + int(body.get("expires_in", 600))
return self._token
def request(self, method, path, **kwargs):
url = f"{self.base_url}{path}"
headers = kwargs.pop("headers", {})
headers["Authorization"] = f"Bearer {self._token_value()}"
resp = requests.request(method, url, headers=headers, timeout=120, **kwargs)
if resp.status_code == 401: # token may have expired mid-journey - refresh once
self._token = None
headers["Authorization"] = f"Bearer {self._token_value()}"
resp = requests.request(method, url, headers=headers, timeout=120, **kwargs)
return resp
def main():
client = KycClient(BASE_URL, CLIENT_ID, CLIENT_SECRET)
# 2. Search the registry
print(f"==> Searching registry for {QUERY!r} in {ISO}")
r = client.request("POST", "/v2/Companies/search",
json={"codeiso31662": ISO, "query": QUERY})
r.raise_for_status()
results = r.json()["companySearch"]["searchResults"]
if not results:
sys.exit("No registry results")
rawname = results[0]["rawname"]
print(f" matched: {rawname}")
# 3. Create the company case
print("==> Creating company case")
r = client.request("POST", "/v2/Companies",
json={"rawname": rawname, "codeiso31662": ISO})
r.raise_for_status()
case_id = r.json()["caseDetail"]["details"]["common"]["caseCommonId"]
print(f" caseCommonId: {case_id}")
# 4. Poll until Ready (statusId == 3)
print("==> Polling for Ready")
for _ in range(60):
r = client.request("GET", f"/v2/Companies/{case_id}")
r.raise_for_status()
status = r.json()["caseDetail"]["details"]["common"]["statusId"]
print(f" statusId={status}")
if status == 3:
break
if status == 8:
sys.exit("Case expired/failed")
time.sleep(5)
# 5. Members + org-chart
print("==> Fetching members")
members = client.request("GET", f"/v2/Companies/{case_id}/members").json()
controlling = members.get("controllingEntitiesAndIndividuals", [])
print(f" controlling parties: {len(controlling)}, "
f"shareholders: {len(members.get('shareholdersAndBeneficialOwners', []))}")
print("==> Fetching org-chart (recursive ownership tree)")
org = client.request("GET", f"/v2/Companies/{case_id}/org-chart").json()
def walk(node, depth=0):
print(" " + " " * depth + f"- {node.get('name')} "
f"({node.get('effectivePercentage')}%)")
for child in (node.get("shareholders") or []):
walk(child, depth + 1)
for child in (node.get("officers") or []):
walk(child, depth + 1)
walk(org)
# memberType is a STRING ("Individual" / "Company")
individual = next((m for m in controlling if m.get("memberType") == "Individual"), None)
if not individual:
sys.exit("No individual controlling party found")
indiv_id = individual["member"]["caseCommonId"]
print(f" individual member caseCommonId: {indiv_id}")
# 6. Get the individual + mandatory docs
print(f"==> Fetching individual member {indiv_id}")
client.request("GET", f"/v2/Individuals/{indiv_id}").raise_for_status()
mandatory = client.request(
"GET", f"/v2/Individuals/{indiv_id}/documents/mandatory").json()
print(f" mandatory docs: {mandatory}") # typically ["photoid","selfie","poa"]
# 7. Upload a document (multipart) - good then forged
def upload(file_path, name, label):
with open(file_path, "rb") as fh:
r = client.request(
"POST", f"/v2/Individuals/{indiv_id}/documents/upload",
data={
"name": name,
"fileCat": "photoid",
"caseCommonId": str(indiv_id),
"isCompany": "false",
},
files={"file": (os.path.basename(file_path), fh,
"application/octet-stream")},
)
r.raise_for_status()
msgs = r.json().get("prevalidationMessages", [])
print(f" {label}: prevalidationMessages={msgs}")
print("==> Uploading a (good) identity document")
upload("./passport.jpg", "Passport", "good") # -> []
print("==> Uploading a (forged) identity document")
upload("./forged.jpg", "Passport (forged)", "forged") # -> [{key: IdentityDocumentNotRecognized}]
# 8. AML view
print("==> AML checks for the company")
aml = client.request("GET", f"/v2/Companies/{case_id}/amlchecks").json()
print(f" worldChecks={len(aml.get('worldChecks', []))}, "
f"lexisNexisChecks={len(aml.get('lexisNexisChecks', []))}")
# 9. Close the case with a decision
print("==> Recording decision: Approved")
r = client.request("PATCH", f"/v2/Companies/{case_id}/status",
json={"status": "Approved"})
r.raise_for_status()
print(f" {r.json()}")
# 10. Download the KYC report PDF
print("==> Downloading KYC report PDF")
r = client.request("GET", f"/v2/Companies/{case_id}/report",
params={"language": "en"})
if r.status_code == 409:
sys.exit("Report not ready yet")
r.raise_for_status()
out = f"kyc-report-{case_id}.pdf"
with open(out, "wb") as fh:
fh.write(r.content)
print(f" saved {out}")
print("==> Done.")
if __name__ == "__main__":
main()
/**
* KYC Public API v2 - full onboarding journey (Node / TypeScript, fetch)
*
* Journey: token -> search -> create -> poll to Ready -> members + org-chart
* -> get an individual member -> upload document (good vs forged)
* -> AML view -> close (Approved) -> download report PDF
*
* Requires: Node 18+ (built-in fetch, FormData, Blob).
* Run: CLIENT_ID=... CLIENT_SECRET=... npx tsx journey.ts
* (or compile with `tsc` and run with node)
*/
import { readFile, writeFile } from "node:fs/promises";
const BASE_URL = process.env.BASE_URL ?? "https://api.knowyourcustomer.dev"; // free Sandbox
const CLIENT_ID = process.env.CLIENT_ID ?? "YOUR_CLIENT_ID";
const CLIENT_SECRET = process.env.CLIENT_SECRET ?? "YOUR_CLIENT_SECRET";
const ISO = "GB";
const QUERY = "CROPWELL BISHOP";
/** Minimal client with an in-memory token cache and refresh-on-401. */
class KycClient {
private token: string | null = null;
private expiresAt = 0;
constructor(
private baseUrl: string,
private clientId: string,
private clientSecret: string,
) {}
private async tokenValue(): Promise<string> {
if (this.token && Date.now() < this.expiresAt - 30_000) return this.token;
const body = new URLSearchParams({
grant_type: "client_credentials",
client_id: this.clientId,
client_secret: this.clientSecret,
scope: "PublicApi",
});
const res = await fetch(`${this.baseUrl}/connect/token`, { method: "POST", body });
if (!res.ok) throw new Error(`token failed: ${res.status} ${await res.text()}`);
const json = (await res.json()) as { access_token: string; expires_in?: number };
this.token = json.access_token;
this.expiresAt = Date.now() + (json.expires_in ?? 600) * 1000;
return this.token;
}
async request(method: string, path: string, init: RequestInit = {}): Promise<Response> {
const headers = new Headers(init.headers);
headers.set("Authorization", `Bearer ${await this.tokenValue()}`);
let res = await fetch(`${this.baseUrl}${path}`, { ...init, method, headers });
if (res.status === 401) {
// token may have expired mid-journey - refresh once
this.token = null;
headers.set("Authorization", `Bearer ${await this.tokenValue()}`);
res = await fetch(`${this.baseUrl}${path}`, { ...init, method, headers });
}
return res;
}
async json<T = any>(method: string, path: string, init: RequestInit = {}): Promise<T> {
const res = await this.request(method, path, init);
if (!res.ok) throw new Error(`${method} ${path} -> ${res.status} ${await res.text()}`);
return (await res.json()) as T;
}
}
interface OrgNode {
name?: string;
effectivePercentage?: number | null;
shareholders?: OrgNode[] | null;
officers?: OrgNode[] | null;
others?: OrgNode[] | null;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function main(): Promise<void> {
const client = new KycClient(BASE_URL, CLIENT_ID, CLIENT_SECRET);
// 2. Search the registry
console.log(`==> Searching registry for '${QUERY}' in ${ISO}`);
const search = await client.json("POST", "/v2/Companies/search", {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ codeiso31662: ISO, query: QUERY }),
});
const rawname: string = search.companySearch.searchResults[0].rawname;
console.log(` matched: ${rawname}`);
// 3. Create the company case
console.log("==> Creating company case");
const created = await client.json("POST", "/v2/Companies", {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rawname, codeiso31662: ISO }),
});
const caseId: number = created.caseDetail.details.common.caseCommonId;
console.log(` caseCommonId: ${caseId}`);
// 4. Poll until Ready (statusId === 3)
console.log("==> Polling for Ready");
for (let i = 0; i < 60; i++) {
const c = await client.json("GET", `/v2/Companies/${caseId}`);
const status: number = c.caseDetail.details.common.statusId;
console.log(` statusId=${status}`);
if (status === 3) break;
if (status === 8) throw new Error("Case expired/failed");
await sleep(5000);
}
// 5. Members + org-chart
console.log("==> Fetching members");
const members = await client.json("GET", `/v2/Companies/${caseId}/members`);
const controlling: any[] = members.controllingEntitiesAndIndividuals ?? [];
console.log(
` controlling parties: ${controlling.length}, ` +
`shareholders: ${(members.shareholdersAndBeneficialOwners ?? []).length}`,
);
console.log("==> Fetching org-chart (recursive ownership tree)");
const org = await client.json<OrgNode>("GET", `/v2/Companies/${caseId}/org-chart`);
const walk = (node: OrgNode, depth = 0): void => {
console.log(` ${" ".repeat(depth)}- ${node.name} (${node.effectivePercentage}%)`);
for (const child of node.shareholders ?? []) walk(child, depth + 1);
for (const child of node.officers ?? []) walk(child, depth + 1);
};
walk(org);
// memberType is a STRING ("Individual" / "Company")
const individual = controlling.find((m) => m.memberType === "Individual");
if (!individual) throw new Error("No individual controlling party found");
const indivId: number = individual.member.caseCommonId;
console.log(` individual member caseCommonId: ${indivId}`);
// 6. Get the individual + mandatory docs
console.log(`==> Fetching individual member ${indivId}`);
await client.json("GET", `/v2/Individuals/${indivId}`);
const mandatory = await client.json("GET", `/v2/Individuals/${indivId}/documents/mandatory`);
console.log(` mandatory docs: ${JSON.stringify(mandatory)}`); // ["photoid","selfie","poa"]
// 7. Upload a document (multipart) - good then forged
const upload = async (filePath: string, name: string, label: string): Promise<void> => {
const bytes = await readFile(filePath);
const form = new FormData();
form.set("file", new Blob([bytes]), filePath.split("/").pop());
form.set("name", name);
form.set("fileCat", "photoid");
form.set("caseCommonId", String(indivId));
form.set("isCompany", "false");
const res = await client.json("POST", `/v2/Individuals/${indivId}/documents/upload`, {
body: form,
});
console.log(` ${label}: prevalidationMessages=${JSON.stringify(res.prevalidationMessages)}`);
};
console.log("==> Uploading a (good) identity document");
await upload("./passport.jpg", "Passport", "good"); // -> []
console.log("==> Uploading a (forged) identity document");
await upload("./forged.jpg", "Passport (forged)", "forged"); // -> [{key:"IdentityDocumentNotRecognized"}]
// 8. AML view
console.log("==> AML checks for the company");
const aml = await client.json("GET", `/v2/Companies/${caseId}/amlchecks`);
console.log(
` worldChecks=${(aml.worldChecks ?? []).length}, ` +
`lexisNexisChecks=${(aml.lexisNexisChecks ?? []).length}`,
);
// 9. Close the case with a decision
console.log("==> Recording decision: Approved");
const closed = await client.json("PATCH", `/v2/Companies/${caseId}/status`, {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "Approved" }),
});
console.log(` ${JSON.stringify(closed)}`);
// 10. Download the KYC report PDF
console.log("==> Downloading KYC report PDF");
const res = await client.request("GET", `/v2/Companies/${caseId}/report?language=en`);
if (res.status === 409) throw new Error("Report not ready yet");
if (!res.ok) throw new Error(`report -> ${res.status}`);
const pdf = Buffer.from(await res.arrayBuffer());
const out = `kyc-report-${caseId}.pdf`;
await writeFile(out, pdf);
console.log(` saved ${out}`);
console.log("==> Done.");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
以下将逐一说明各步骤,让您了解每次呼叫的作用。
步骤一:搜寻公司
搜寻功能会依名称或注册编号返回登记处中相符的结果。搜寻的目的是让您选取准确的记录,因为建立案例的呼叫必须使用与搜寻结果相符的名称。
POST /v2/Companies/search
可依名称的部分字串或注册编号搜寻,并可选择性地按国家(codeiso31662)缩小范围。例如,搜寻cropwell bishop于GB会返回 CROPWELL BISHOP CREAMERY LIMITED,其注册编号为00364890。
选取您所需的结果,并记下其准确的rawname及注册编号,供下一步骤使用。若多间公司名称相似,注册编号是可靠的区分依据。
步骤二:建立案例
POST /v2/Companies
| 栏位 | 是否必要 | 备注 |
|---|---|---|
rawname | 是 | 必须与所选取的搜寻结果相符。 |
codeiso31662 | 可选 | ISO 国家代码,例如GB。有助准确导向正确的登记处。 |
externalCode | 可选,建议提供 | 注册编号(例如00364890)。如有此资料,建议提供以确保准确比对登记记录。 |
回应中会包含新建立的caseCommonId。案例现正于背景中建立。此回应中并不会包含结构资料,请直接进行轮询。
{
"caseCommonId": "a1b2c3d4-...."
}步骤三:轮询直至完成
GET /v2/Companies/{caseCommonId}
轮询该案例并读取其status。请持续轮询,直至status为3(就绪),且结构已填入资料。
建议的轮询方式:每隔 3 至 5 秒轮询一次,并设定合理的整体逾时时间。由于部分司法管辖区可能需时数分钟,建议采用递增等待间隔(例如由 3 秒逐步增加至 15 秒),并依司法管辖区设定总等待时间上限。切勿以密集回圈方式轮询。除3以外的状态均应视为进行中,并持续等待。
若案例在逾时后仍未完成,请保留该caseCommonId,稍后再重新轮询,而非重新建立案例。
如您不想进行轮询,可注册一个webhook,用于CaseReady事件:一旦案例建立完成,API 便会立即向您的端点推送讯息,即使您从未进行轮询亦然。轮询适合用于短时间执行的程式码;Webhook 则较适合大规模运作或长期监控大量案例的情境。
步骤四:读取结果
案例就绪后,请读取其属性、成员及组织架构图。
GET /v2/Companies/{caseCommonId}会返回案例的属性(名称、注册编号、司法管辖区、状态)及股权资料。
成员会以controllingEntitiesAndIndividuals的形式呈现。组织架构图则是递回式结构:一个根公司节点,内嵌巢状的shareholders,每个节点均带有一个memberType,其值为"Company"或"Individual"。
CROPWELL BISHOP CREAMERY LIMITED 是一个良好的多层结构范例。其结构显示企业母公司位于个人股东之上,因此组织架构图包含多于一层:根公司、其下的控股公司,以及再下一层的个人(UBO)。请走访shareholders阵列以递回方式建立完整的结构树,并读取每个节点的memberType,以判断该节点是需要进一步展开的公司,还是需要验证的个人。
如要验证个人成员,请取用其caseCommonId,并于/v2/Individuals/{caseCommonId}。详见实益拥有权与个人。
实益拥有权与个人
成员与组织架构图的分别
股权结构提供两种检视方式,分别回答不同的问题:
controllingEntitiesAndIndividuals是控制该公司各方的扁平清单。当您只需要控制方名单而不需要层级关系时,可使用此方式。- 组织架构图则是递回式的结构树(根公司,内嵌巢状的
shareholders)。当您需要了解结构本身——即跨层级的拥有关系——时,可使用此方式。
递回结构
股权结构可能巢状多层。一间公司可由另一间公司拥有,而该公司再由个人拥有。若要找出最终受益所有人,请走访整个结构树:就每个节点检查memberType;若其为"Company",则进入其shareholders;若其为"Individual",即代表已到达末端拥有人。
门槛与已停用的少数股权步骤
并非每位股东均需进行完整验证。持股量低于您所设定门槛的少数股东,会以停用步骤表示(isDeactivated = true)。这些股东已通过反洗钱及制裁名单筛查并显示清白,符合简化尽职调查的资格。已筛查清白的实体同样会被停用。此机制有助案例聚焦于真正重要的各方。
指定个人成员
每个个人成员均有其专属的caseCommonId。可将该人士以个人案例的形式指定:
GET /v2/Individuals/{caseCommonId}
由此您可收集并核对其身分文件(详见文件),并读取其反洗钱结果。
启用步骤以进行完整验证
若您确实需要对某个步骤已被停用的一方进行完整验证,可启用该步骤。启用后,该步骤会被纳入启用中的集合,并进行完整验证,而非依简化尽职调查方式处理。请以其caseStepId识别该步骤(可列出案例的所有步骤以找出该步骤)并将其启用。启用后,即可依一般方式收集文件并完成该方的验证。
文件
各案例类型的必要文件
| 案例类型 | 必要文件 |
|---|---|
| 公司 | 登记文件 |
| 个人 | photoid、selfie、poa |
请将文件归入正确的案例:身分文件(photoid、selfie、poa)应归入个人的案例;登记及董事会文件则应归入公司案例。
预先验证
您可先对文件进行预先验证,让 API 在您依赖该文件之前先行检查。预先验证的讯息会于上传回应的prevalidationMessages栏位中返回。合格的文件不会出现任何阻挡性讯息。若身分文件无法辨识或怀疑伪造,则会返回类似以下的讯息:IdentityDocumentNotRecognized。请将预先验证讯息视为判断文件是否符合要求的依据。
上传(multipart 规格)
文件上传为一个multipart/form-data请求,包含以下栏位:
| 栏位 | 说明 |
|---|---|
file | 文件档案(二进位格式)。 |
fileCat | 文件类别(例如photoid、selfie、poa,或登记文件类别)。 |
name | 文件名称。 |
caseCommonId | 文件所属的案例。 |
isCompany | 此案例是否为公司案例(true)或个人案例(false)。 |
createNewStep | 是否为此文件建立新的步骤。 |
回应内容如下:
{
"caseDocumentId": "....",
"caseStepId": "....",
"category": "photoid",
"link": "https://....",
"prevalidationMessages": []
}caseDocumentId用以识别已储存的文件;caseStepId为该文件所附加的步骤;category会回传所提供的文件类别;link为撷取该文件的位置;prevalidationMessages则带有任何验证结果,例如IdentityDocumentNotRecognized。
请设定isCompany以符合文件所属的案例。若以isCompany=false及个人的caseCommonId上传身分文件,该文件便会归入该人士;若以isCompany=true及公司的caseCommonId上传登记文件,该文件便会归入该公司。
下载文件
请使用上传回应中返回的link以撷取已储存的文件。该连结会指向该案例中特定的已储存文件。
反洗钱与持续监控
查看案例的反洗钱结果
反洗钱筛查(制裁名单、政治敏感人物及负面新闻)是案例的一部分。请读取案例的反洗钱结果,以查看任何比对结果,当中包含所比对的名单及比对详情。若无任何比对结果,即代表该案例清白。
覆核、剔除、重新计算
就每项比对结果,您可执行以下操作:
- 覆核:记录您对该比对结果属真实还是误判的评估。
- 剔除:将误判项目剔除,使其不再计入该案例,并记录剔除原因以供稽核纪录使用。
- 重新计算筛查:重新对案例执行反洗钱筛查,例如在取得新资讯后,或需依最新名单重新筛查时。
即时监控警示
开户完成后,即时监控会重新筛查该案例,并在出现新的比对结果时发出警示。
- 列出警示:撷取警示清单,以便分类处理。
- 警示详情:撷取单一警示,以查看相符方及其发出原因。
- 处理警示:解决该警示,并记录结果(例如确认或驳回该比对结果)。处理后即会以已稽核的决定关闭该警示。
覆核日期
设定一个覆核日期于案例上,以安排定期覆核。覆核日期可用以规划您的持续尽职调查节奏,例如更频密地覆核高风险客户。您可于案例上设定、更新及读取覆核日期。
报告
案例完成后,您可取得其报告,即该已验证案例的可稽核 PDF 文件。
可依caseCommonId取得案例的报告。报告内容涵盖案例结果:已验证的公司或个人详情、公司的股权结构解析结果、已收集的文件、反洗钱筛查结果,以及已执行的核对项目,整合汇编以供您的稽核档案使用。
在沙盒中,报告仅供评估之用而产生。它们展示报告的格式及内容,但不可用于正式合规用途。正式环境所产生的报告则依即时登记处及筛查资料生成,属于可供稽核的正式纪录。
案例管理
除了建立及读取案例外,您亦可于案例的整个生命周期中对其进行管理。
- 步骤:列出案例的所有步骤,以查看其由哪些验证工作组成,并找出
caseStepId。读取步骤详情以查看其状态及结果,并可于验证过程中更新步骤(例如标记为通过或不通过)。 - 步骤备注:于步骤附加备注,以记录相关背景或决定,供稽核纪录使用。
- 将案例指派予使用者:将案例指派予指定使用者,让负责人在您的工作流程中清晰可见。
- 稽核纪录:案例上的每项重大操作均会被记录。读取案例的稽核纪录,即可查看何人于何时执行了何种操作。
- 更新案例资讯及状态:更新案例资讯,并在案例进展或结束时更新其状态(例如接受或拒绝)。
- 结束及删除:验证完成后结束案例;并可于适当情况下删除案例。在沙盒环境中,删除功能有助您重设测试资料。
转往正式环境
有何变化
规格不会改变。沙盒环境与正式环境共用相同的端点、请求内容及回应结构。转往正式环境代表:
- 基础网址:由
https://api.knowyourcustomer.dev切换至https://api.knowyourcustomer.com(欧洲)或https://api-asia.knowyourcustomer.com(亚洲)。 - 凭证:使用您于商业合作洽谈过程中发放的正式环境
client_id及client_secret,并对接正式环境的权杖端点。 - 资料:正式环境呼叫的是真实的登记处及真实的筛查名单。登记处查询会产生费用,延迟时间反映真实登记处的实际情况,而报告则属于可供稽核的正式纪录。沙盒环境不会进行任何即时登记处查询,并使用合成的个人资料。
取得正式环境凭证
正式环境凭证会于商业合作洽谈过程中另行发放,与沙盒存取权限分开处理。请联络 Know Your Customer 以展开相关程序(详见下方支援资讯)。
速率限制与公平使用
此 API 设有速率限制并须遵守公平使用原则。建议您的用户端快取持有人权杖,以递增等待间隔方式轮询而非密集回圈,并在遇到暂时性错误时以递增等待间隔方式重试。若收到429回应,即代表您已被限速:请稍作等待后重试。
支援、状态与更新记录
如需协助,请联络help@knowyourcustomer.com。任何重大变更均会以版本方式标示;v2 规格稳定不变。请留意 API 参考文件及更新记录,以掌握新增内容。
参考附录
状态码对照表
| 状态 | 意义 |
|---|---|
| 0 | 初始化中 |
| 50 | 已排入建立伫列 |
| 51 | 正在撷取登记记录 |
| 53 | 建立子步骤 |
| 54 | 建立子步骤 |
| 9 | Google 搜寻 |
| 100 | 反洗钱核对 |
| 107 | 建立中 |
| 3 | 就绪 |
案例会依序经过0 -> 50 -> 51 ->,实际经过的子集依情况而异,涵盖{53, 54, 9, 100, 107} -> 3。就绪状态即为状态3且结构已填入资料。中间状态的集合会因案例及司法管辖区而异;请以3而非任何单一中间状态作为程式逻辑的判断依据。实际操作中可能出现的完整集合为{0, 3, 9, 50, 51, 53, 54, 100, 107}。
错误模型
此 API 采用标准 HTTP 状态码。
| 代码 | 意义 | 应对方式 |
|---|---|---|
| 200 / 201 | 成功 | 可继续进行。 |
| 400 | 请求错误 | 请修正请求内容或参数(例如rawname与搜寻结果不符)。 |
| 401 | 未经授权 | 权杖缺失或已过期。请重新取得权杖并再次尝试。 |
| 403 | 禁止存取 | 您的凭证并无存取该资源的权限。 |
| 404 | 找不到资源 | 请检查caseCommonId或caseStepId。 |
| 409 | 冲突 | 该资源目前的状态不允许执行此操作。 |
| 429 | 请求次数过多 | 您已被限速。请稍作等待后重试。 |
| 500 | 伺服器错误 | 此为暂时性错误。请以递增等待间隔方式重试;若情况持续,请联络支援团队。 |
错误回应会附带说明问题原因的讯息。诊断问题时请记录完整的回应内容。
词汇表
| 词汇 | 意义 |
|---|---|
| 完整 KYC 案例 | 指已建立完整股权结构、已取得必要文件并已执行反洗钱筛查的企业案例;或已取得并验证必要文件、并已执行反洗钱筛查的个人案例。 |
| 人工案例 | 指为未注册实体所建立的案例,或并无即时登记处连接的案例。 |
| 纯反洗钱案例 | 指仅完成反洗钱核对的个人或企业案例。 |
| caseCommonId | 案例的唯一识别码。 |
| caseStepId | 案例内某个步骤的唯一识别码。 |
| 已停用步骤 | 指某个步骤(isDeactivated = true)不需要进行完整验证:例如持股量低于门槛的少数股东,或已筛查清白的一方。此步骤已完成反洗钱及制裁名单筛查,并可于有需要时启用以进行完整验证。 |
| 成员 | 指公司股权结构中的一方(公司或个人)。memberType为"Company"或"Individual"。 |
| UBO | 最终受益所有人:位于股权结构树末端、最终拥有或控制该公司的个人。 |
