SDK 事件与前端接入

本页面是 集成方案 中 方案二:API + SDK v2 集成 的前端接入补充说明。

方案二告诉您如何获取 SDK URL 并让用户完成 KYC;本页告诉您在接入方前端页面上:

  • 如何嵌入 SDK URL
  • 如何监听 SDK 发出的关闭请求,正确销毁 SDK 页面
  • 联调阶段可用的 URL 参数

集成方式

从 POST /kyc/individual/sdk/url 获取到 SDK URL(形如 https://otc.legendtrading.com/v2/kyc/s/{code})后,推荐通过以下两种方式在您的前端页面呈现:

方式一:iframe 嵌入

在您的页面用 <iframe> 承载 SDK URL,SDK 作为您页面的一部分展示(适合放在弹窗、抽屉、侧边栏里)。

<iframe
  src="https://otc.legendtrading.com/v2/kyc/s/{code}"
  allow="camera; geolocation"
  style="width: 100%; height: 100%; border: 0;"
></iframe>

方式二:打开链接

用 window.open() 打开 SDK URL,把 KYC 流程独立到新窗口完成(适合弹出窗口 / 新标签页场景)。

window.open("https://otc.legendtrading.com/v2/kyc/s/{code}")

App WebView 内:window.open() 需要宿主额外开发——宿主必须响应该新窗口请求(Android 用 onCreateWindow,iOS 用 createWebViewWithConfiguration),否则新窗口会白屏。详见下文 window.open() 也需要宿主实现。

💡

SDK 内部在流程结束、或用户主动退出时,需要关闭承载它的 iframe / 弹窗。但浏览器安全策略不允许 SDK 页面直接关闭您的宿主窗口,所以 SDK 会通过消息通知您、由您来完成关闭动作。详见下文 close-requested 事件章节。

在手机 App 的 WebView 中嵌入

如果用户是在您的手机 App 内(WebView / WKWebView,而非浏览器)完成 KYC,SDK 运行在该 WebView 中,您的 App 就是宿主。

与浏览器不同,嵌入式 WebView 没有外壳:没有地址栏、没有下载管理器、没有文件选择器、也没有「新建标签页」。因此浏览器免费提供的能力,需要由宿主 App 补齐。具体需要哪些,取决于嵌入方式而非平台——同一个要求在两个平台上都存在,只是 API 名称不同。

其中两项容易被遗漏,且失败都是静默的、不报错:

  • 文件上传(PoA 证件上传)——按钮点击无反应。
  • window.open()(集成方式 → 方式二)——新窗口白屏。

文件上传需要宿主提供文件选择器

当网页需要用户选择文件时,嵌入式 WebView 自身不带文件选择器界面,会把请求交给宿主 App,由 App 负责拉起选择器。

Android 上,如果宿主没有实现该回调,请求会被静默丢弃——按钮点了完全没反应,且不会抛出、记录或向用户展示任何错误。iOS 上使用系统选择器,因此无需宿主实现。

这是 Android 上「上传按钮点击无反应」类反馈最常见的成因。

KYC SDK 会触发该场景:地址证明(PoA)流程可能要求用户上传证件文件(见 联调辅助 中 GPS 失败 → POA 流程 一行),IDV 采集也可能经由文件选择框完成。

接入清单

#事项平台适用场景
1INTERNET 权限(Android:<uses-permission android:name="android.permission.INTERNET" />)Android始终需要
2声明 CAMERA 权限并在运行时申请(iOS:NSCameraUsageDescription;GPS PoA 另需 NSLocationWhenInUseUsageDescription)两端IDV / GPS PoA
3Android:实现 WebChromeClient.onShowFileChooser,并始终回传回调AndroidPoA 证件上传
4授权 SDK 页面内的摄像头 / 麦克风请求(Android:onPermissionRequest;iOS 15+:requestMediaCapturePermissionForOrigin)两端IDV 拍摄
5Android:setSupportMultipleWindows(true) 并实现 onCreateWindow · iOS:createWebViewWithConfiguration两端仅当您的页面用 window.open() 打开 SDK 时

第 3 项遗漏表现为**「点击没有任何反应」;第 5 项遗漏或配置不全表现为白屏**。两者都是静默失败,见下文。

window.open() 也需要宿主实现

如果您的页面调用 window.open(sdkUrl) 打开 SDK(集成方式 → 方式二),宿主必须处理它。WebView 不带「新建标签页」能力,因此 window.open() 会转为向宿主请求创建新窗口——宿主不响应、或响应不完整,新窗口都无法渲染出 SDK。

各平台 API 名称不同,但义务相同:

  • Android——交由 WebChromeClient.onCreateWindow 处理。
  • iOS——交由 WKUIDelegate 的 createWebViewWithConfiguration。WebKit 官方文档原文:若未实现该方法,WebView 会取消该次导航——因此在 iOS 上同样是宿主义务,并非 Android 独有。

Android 侧的默认行为在两方面对您不利:

  • WebSettings.setSupportMultipleWindows 默认为 false。关闭时,新窗口请求会被当作顶层导航、替换当前页面,onCreateWindow 根本不会被调用。
  • WebChromeClient.onCreateWindow 默认返回 false——AOSP 原文:「该方法的默认实现什么都不做,因此返回 false」。

因此,宿主完全不做处理时,窗口压根不会打开。更麻烦的是:宿主建出了窗口、但新 WebView 配置不全时,会得到白屏——这很容易被误判为「SDK 加载失败」。白屏的确切含义是窗口已建出、但其中的 WebView 无法渲染页面;SDK 是单页应用(SPA),若该新 WebView 未启用 JavaScript,就没有内容可渲染。

以下三种故障形态已在真机验证(Android)。iOS 上若弹窗异常,也值得按同样思路排查:

宿主行为用户看到的现象
onCreateWindow 返回 false(或未处理)点击无反应
建出了窗口,但新 WebView 未启用 JavaScript白屏
建出窗口,且新 WebView 配置完整SDK 正常加载

iOS 上新窗口若为白屏,排查方向相同:createWebViewWithConfiguration 返回的 WebView 必须用传入的 configuration 创建(WebKit 有此要求),且该 WebView 未禁用 JavaScript。

可用实现如下:

webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true
webView.settings.setSupportMultipleWindows(true)   // 必须开启,否则 onCreateWindow 不会被调用

webChromeClient = object : WebChromeClient() {
    override fun onCreateWindow(
        view: WebView, isDialog: Boolean, isUserGesture: Boolean, resultMsg: Message
    ): Boolean {
        val popup = WebView(view.context).apply {
            settings.javaScriptEnabled = true   // 必须开启——否则 SDK 白屏
            settings.domStorageEnabled = true
            webViewClient = WebViewClient()
        }
        container.addView(popup)                // 新 WebView 必须加入视图层级

        (resultMsg.obj as WebView.WebViewTransport).webView = popup
        resultMsg.sendToTarget()                // 必须回传
        return true                             // 必须返回 true
    }
}
⚠️

以下三点缺一不可,漏掉任意一点都会导致弹窗失败:返回 true、调用 resultMsg.sendToTarget()、把新 WebView 加入视图层级。AOSP 另注明:返回 false 却又调用 sendToTarget() 属未定义行为。

App 内更省事的替代方案。 既然 window.open() 需要这些额外的宿主开发,App 内接入若改为直接加载 SDK URL 或使用 iframe,会比 window.open() 更稳。方式一(iframe)除清单中已列项外没有额外要求。

Android 示例

以下示例涵盖清单中的 Android 回调——onShowFileChooser(文件上传)与 onCreateWindow(window.open() 路径)。

class KycActivity : AppCompatActivity() {

    private var filePathCallback: ValueCallback<Array<Uri>>? = null

    private val fileChooserLauncher = registerForActivityResult(
        ActivityResultContracts.StartActivityForResult()
    ) { result ->
        val callback = filePathCallback ?: return@registerForActivityResult
        filePathCallback = null
        // 返回 null 表示用户取消了选择 —— 也必须回传,
        // 否则该文件选择框将永久失效。
        callback.onReceiveValue(
            WebChromeClient.FileChooserParams.parseResult(result.resultCode, result.data)
        )
    }

    @SuppressLint("SetJavaScriptEnabled")
    private fun buildWebView(): WebView = WebView(this).apply {
        settings.javaScriptEnabled = true
        settings.domStorageEnabled = true

        webChromeClient = object : WebChromeClient() {
            // Android 上必须实现:否则 SDK 中的文件选择框点了不会有任何反应。
            override fun onShowFileChooser(
                webView: WebView,
                callback: ValueCallback<Array<Uri>>,
                params: FileChooserParams,
            ): Boolean {
                // 先清掉上一次未完成的回调,否则后续点击会失效。
                filePathCallback?.onReceiveValue(null)
                filePathCallback = callback
                return try {
                    fileChooserLauncher.launch(params.createIntent())
                    true
                } catch (e: ActivityNotFoundException) {
                    filePathCallback = null
                    callback.onReceiveValue(null)
                    false
                }
            }

            // 让 SDK 可以使用摄像头完成 IDV。
            override fun onPermissionRequest(request: PermissionRequest) {
                request.grant(request.resources)
            }
        }

        loadUrl("https://otc.legendtrading.com/v2/kyc/s/{code}?layout=mobile")
    }
}
⚠️

回调必须始终回传,包括用户取消的情况。若未调用 filePathCallback.onReceiveValue(...)(例如用户直接关掉了选择器),该文件选择框会在本次会话中永久失效,而不是只失效一次。

网页端无法替代或绕过

该要求属于原生宿主的义务,网页端没有任何方式可以替代:

  • 没有任何 HTML 属性或 JavaScript 可以把文件选择委托给 WebView——不存在这样的 API。
  • iframe 的 allow="camera; geolocation" 属性不覆盖文件选择。它对应的是 Permissions Policy,其 feature 列表中不包含 <input type="file">;allow 对它所声明的两个用途(IDV 拍摄与 GPS 定位)是正确的,但无法授予文件选择权限。

其他宿主环境

  • iOS:文件上传无需回调——WKWebView 会自行弹出系统选择器。但 IDV 与 GPS PoA 步骤仍需要配置 NSCameraUsageDescription / NSLocationWhenInUseUsageDescription;若使用 window.open(),则还需要实现 createWebViewWithConfiguration。
  • 封装框架:部分框架已替您实现这些回调——react-native-webview、Cordova / Capacitor 自带;早期版本的 flutter_webview 没有。若使用封装框架且某项能力不可用,请先确认该框架是否实现了对应回调,再排查其他方向。

URL 参数

在 SDK URL 后可以追加以下 query 参数,用于联调、布局适配、品牌定制、消息安全加固:

参数取值是否必填说明
modedev否切换到联调环境。不传 = 生产环境
remotesandboxapi否联调环境的后端子域,必须与 mode=dev 同时传
layoutmobile | desktop否SDK 布局形态。不传或其它值 = desktop
primary-color十六进制颜色 #RGB 或 #RRGGBB(URL 中 # 需转义为 %23,例:%23FF6600)否自定义 SDK 主题色(按钮、链接、进度等主色)。仅接受严格的十六进制格式,非法值会被忽略并回退到默认主题色
parentOrigin您页面的完整 origin(如 https://a.com)否(强烈建议填)收窄 SDK 向宿主 postMessage 时的 targetOrigin。不传时 SDK 会用 *,意味着任意 origin 都能收到消息

联调环境完整示例

https://otc.legendtrading.com/v2/kyc/s/{code}?mode=dev&remote=sandboxapi&layout=mobile&primary-color=%23FF6600&parentOrigin=https://a.com
  • mode=dev&remote=sandboxapi — 前后端都指向联调环境,不影响生产数据
  • layout=mobile — 使用移动端布局(适合嵌入手机 App WebView 或较窄的容器)
  • primary-color=%23FF6600 — 自定义 SDK 主题色为橙色 #FF6600(# 必须转义为 %23)
  • parentOrigin=https://a.com — 只允许 https://a.com 收到 SDK 发出的关闭请求消息

生产环境示例

生产环境不需要传 mode / remote,直接使用原始 SDK URL 即可。仍然强烈建议追加 parentOrigin 参数以提高安全性,并按需追加 layout / primary-color 完成布局与品牌色适配:

https://otc.legendtrading.com/v2/kyc/s/{code}?layout=mobile&primary-color=%23FF6600&parentOrigin=https://your-domain.com
  • layout=mobile — 使用移动端布局(不传或其它值 = 桌面布局)
  • primary-color=%23FF6600 — 自定义 SDK 主题色,与您的品牌色保持一致(# 必须转义为 %23)
  • parentOrigin=https://your-domain.com — 收窄 SDK postMessage 的 targetOrigin

监听 close-requested 事件

SDK 页面在流程结束或用户主动退出时,需要关闭承载它的 iframe / 弹窗。由于浏览器安全策略不允许 SDK 页面直接关闭您的宿主窗口,SDK 会通过 postMessage 向您的页面发一条 close-requested 消息,请您帮忙完成关闭动作。

您在宿主页面上监听这条消息,收到后销毁承载 SDK 的容器(并可以在这里衔接您自己的后续业务逻辑)。

消息格式

SDK 向宿主页面发送的 postMessage 消息统一如下:

{
  source: "legend-kyc-relay",   // 固定值,识别消息来源
  type: "close-requested",      // 固定值,当前只有这一种消息类型
  detail: {                     // 附加信息,可能为 null
    flow: "kyc" | "additional-kyc" | null
  }
}

字段说明:

  • source — 固定 "legend-kyc-relay",用于将 SDK 消息与页面其它 postMessage 区分
  • type — 目前只有 "close-requested" 一种
  • detail.flow — 触发关闭时用户所处的 KYC 流程;kyc = 普通 KYC,additional-kyc = 续填 KYC,null = 用户还未进入 KYC 主流程(例如在初始拦截页触发关闭)

安全边界

监听器必须校验 event.origin === "https://otc.legendtrading.com",否则任意第三方页面都能通过 postMessage 伪造消息、诱发意外关闭行为。

同时建议在 SDK URL 上追加 parentOrigin 参数收窄 SDK 端的 targetOrigin,双向加固。

场景一:iframe 嵌入

假设您用 modal 承载 iframe,用户在 SDK 内触发关闭时需要销毁 iframe / 关闭 modal:

<div id="kyc-modal" class="modal hidden">
  <iframe
    id="kyc-iframe"
    src="https://otc.legendtrading.com/v2/kyc/s/{code}?parentOrigin=https://a.com"
    allow="camera; geolocation"
  ></iframe>
</div>
window.addEventListener("message", (event) => {
  // 1. 校验消息来源
  if (event.origin !== "https://otc.legendtrading.com") return

  // 2. 校验消息格式
  const { source, type, detail } = event.data || {}
  if (source !== "legend-kyc-relay") return
  if (type !== "close-requested") return

  // 3. 销毁承载 SDK 的 iframe / 关闭 modal
  document.getElementById("kyc-iframe")?.remove()
  document.getElementById("kyc-modal")?.classList.add("hidden")

  // 4. 在这里衔接您自己的业务逻辑,例如:
  //    - 调用您的接口,重新拉取用户 KYC 状态
  //    - 跳转到"KYC 已提交"提示页
  //    - 更新前端 UI(按钮 disable / 显示 loading 等)
  //    - detail.flow 可以帮您区分是普通 KYC 还是续填 KYC
})

场景二:popup 弹窗

您用 window.open() 打开 SDK。popup 端 SDK 收到关闭请求时会自己调用 window.close(),宿主端只做善后:

// 打开 SDK popup
const popup = window.open(
  "https://otc.legendtrading.com/v2/kyc/s/{code}?parentOrigin=https://a.com"
)

// 监听 popup 发来的关闭请求
window.addEventListener("message", (event) => {
  if (event.origin !== "https://otc.legendtrading.com") return
  const { source, type, detail } = event.data || {}
  if (source !== "legend-kyc-relay" || type !== "close-requested") return

  // popup 端 SDK 自己会 window.close(),宿主端在这里做善后:
  //    - 调用您的接口,重新拉取用户 KYC 状态
  //    - 更新前端 UI / 跳转页面
  //    - detail.flow 可以帮您区分是普通 KYC 还是续填 KYC

  // 兜底:如果 popup 因浏览器差异没能自关,宿主端主动关一次
  if (popup && !popup.closed) popup.close()
})

联调辅助:模拟 Veriff 审核结果

在联调环境(mode=dev&remote=sandboxapi)下,用户在 SDK 内提交人脸识别(IDV)后,SDK 会停留在结果等待页并持续轮询审核结果。联调环境没有真实的 Veriff 审核,流程会一直停在这里——您需要通过 Legend 开发者平台(DevTools)手动模拟一次 Veriff 审核结果,SDK 才能继续往下走。

操作步骤

  1. 在 SDK 内正常提交人脸识别,SDK 进入结果等待页(开始轮询)
  2. 打开 DevTools:https://otcsb.legendtrading.com/integration/login,输入 Legend 提供的访问密码登录(仅联调环境可用)
  3. 进入「用户管理」,通过用户 ID、邮箱或手机号搜索到该测试用户,点击进入用户详情页
  4. 在「模拟 Veriff 操作」区域,按您要验证的流程点击对应按钮(三选一):
按钮模拟的审核结果SDK 后续走向
GPS 成功流程IDV 通过 + GPS 定位地址与用户填写地址一致GPS PoA 直接通过,无需上传地址证明
GPS 失败走 POA 流程IDV 通过 + GPS 地址不匹配SDK 转入文件 PoA,引导用户上传地址证明
POA 流程IDV 通过,不附带 GPS 地址不经 GPS 验证,SDK 直接进入文件 PoA
  1. 点击后稍等片刻,SDK 轮询到审核结果即会自动进入下一步
💡

三个按钮模拟的都是「Veriff 审核通过」,区别只在 PoA(地址证明)分支的走向。联调环境不会对证件真伪、人脸相似度做真实校验。

当流程要求用户上传地址证明时,可以改用预制模板文件代替真实证件——校验会自动通过。请到 Sumsub 官方的 Verification document templates 页面下载;接受与被拒绝的模板样式另见 PoA 上传接口。

注意事项

  • 必须先在 SDK 提交人脸识别:该操作等效于 Veriff 服务器向 Legend 发送的「审核通过」回调,要求测试用户已存在 Veriff 会话。若点击后提示「不存在 Veriff 记录」或「不存在 attempts 记录」,说明该用户还没有提交过人脸识别——请先回到 SDK 完成提交,再来点击按钮。
  • 仅联调环境可用:DevTools 及模拟功能仅部署在联调(sandbox)环境,生产环境无此入口,也不会影响生产数据。

常见问题

联调时用户提交人脸识别后,SDK 一直停在等待页不动?

联调环境没有真实的 Veriff 审核,SDK 会一直轮询等待审核结果。请到开发者平台(DevTools)的「用户管理 → 用户详情 → 模拟 Veriff 操作」手动触发一次审核结果,SDK 即会继续。详见上文 联调辅助:模拟 Veriff 审核结果。

收不到 close-requested 消息怎么排查?

  1. 确认 window.addEventListener("message", ...) 在 iframe / popup 打开之前就已挂上,避免消息比监听器早到
  2. 检查 event.origin 校验值 —— 生产、联调都是 https://otc.legendtrading.com(mode / remote 只切换后端 API,不影响 SDK 页面 origin)
  3. 检查 parentOrigin 参数拼接是否正确 —— 必须是完整 origin(如 https://a.com),不带路径、不带尾斜杠
  4. 打开浏览器 DevTools Console 查是否有 postMessage 相关报错

primary-color 传了没生效怎么排查?

  1. 确认 # 已在 URL 中转义为 %23 —— 直接写 # 会被浏览器当作 fragment 截断,SDK 收不到
  2. 确认取值符合 #RGB 或 #RRGGBB 格式(3 位或 6 位十六进制)—— 其它写法(rgb()、颜色名、8 位带透明度等)都会被安全校验拦截、回退到默认色
  3. 确认参数名是 primary-color(中划线),不是 primaryColor 或 primary_color

SDK URL 有效期是多久?

单个 SDK URL 有效期为 24 小时。过期后需要重新调用 POST /kyc/individual/sdk/url 获取新 URL。

用户中途关闭后再次打开同一个 SDK URL,进度还在吗?

在。同一个 URL 在 24 小时内可以重复打开,SDK 会自动恢复到用户上次的进度,无需您额外处理。

移动端 App 内嵌 WebView 需要注意什么?

  • WebView 需要开启 JavaScript 与 postMessage 支持(iOS WKWebView / Android WebView 默认已开启)
  • WebView 需要授予 摄像头 与 地理位置 权限,否则 IDV / GPS PoA 环节会失败
  • 嵌入式 WebView 没有浏览器外壳,部分能力需宿主 App 自行补齐。Android 上必须实现 WebChromeClient.onShowFileChooser,否则 PoA 证件上传会点击无反应——静默失败、没有任何报错。若您的页面用 window.open() 打开 SDK,宿主还必须响应新窗口请求(Android:onCreateWindow;iOS:createWebViewWithConfiguration),否则新窗口会白屏。完整清单与示例见 在手机 App 的 WebView 中嵌入。
  • 建议使用 ?layout=mobile URL 参数切换到移动端布局

如何知道用户 KYC 最终审核结果?

close-requested 只表示用户从 SDK 侧完成或退出,不代表 KYC 已经通过。KYC 审核结果需要通过 kyc-status Webhook 或 POST /app/kyc-status 轮询获取,详见 集成方案 → 实时获取 KYC 审核状态。