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 | 最終實益擁有人:位於股權結構樹末端、最終擁有或控制該公司的個人。 |
