> For the complete documentation index, see [llms.txt](https://docs.ur.app/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.ur.app/developer-resources/api-reference-managed-custody-sdk-kyc.md).

# API reference: Managed Custody SDK KYC

This page documents the alternative onboarding path for Managed Custody Mode where the user completes KYC on their own device using UR's Sumsub tenant. Your backend exchanges the user's URID for a short-lived Sumsub access token, and the Sumsub mobile or web SDK drives the KYC workflow directly with the user.

Use this path when you do not run a Sumsub tenant of your own and you want UR to host the KYC vendor relationship end to end. For the alternative path where your platform completes KYC in your own Sumsub tenant and shares the applicant with UR through Sumsub reuse, see [API reference: Managed Custody Mode](/developer-resources/api-reference-managed-custody-mode.md).

{% hint style="info" %}
This onboarding path requires your `partnerId` to be configured for SDK mode in UR Nacos. Coordinate with your dedicated integration channel before pointing production traffic at it.
{% endhint %}

## 1. Where this fits

Managed Custody Mode supports two KYC onboarding paths. Both produce the same end state (the user's URID is minted, the UR-managed wallet is provisioned, and the bank account is activated). They differ only in who runs the Sumsub workflow.

|                          | Sumsub reuse (share token)                                                                                | Sumsub SDK in your app (this page)                                                                                                            |
| ------------------------ | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| Sumsub tenant            | Your platform                                                                                             | UR                                                                                                                                            |
| Who runs KYC UX          | Your platform                                                                                             | Sumsub SDK in your app, on the user device                                                                                                    |
| KYC data lands at UR via | Sumsub share token + `/kyc/check` polling                                                                 | Sumsub webhooks (server to server)                                                                                                            |
| Partner backend calls    | `/create-account` (with `applicantId`), `/kyc/check`, `/kyc/form-a-info`, `/kyc/sign-form`, `/kyc/submit` | `/create-account` (no `applicantId`), `/kyc/sumsub-access-token`, `/kyc/session/current`, `/kyc/form-a-info`, `/kyc/sign-form`, `/kyc/submit` |
| Onboarding state shape   | Coarse (`PartnerDataIngestion`, `IdentityVerification`, `SignFormA`, `Register`)                          | Fine-grained (eight steps; first is `ConfirmYourCountryOfResidence`, last before submit is `SignFormA`)                                       |
| Retry on KYC rejection   | Your platform restarts in your own Sumsub                                                                 | Restart by calling `/create-account` again. Identity reuse is supported.                                                                      |

The webhook contract (`fma.account.result`) and all post-onboarding banking APIs are identical to the Sumsub reuse path. Once the user reaches the `Live` state, your integration uses the same Managed Custody Mode endpoints regardless of which KYC path got them there.

## 2. End-to-end sequence

```mermaid
sequenceDiagram
    autonumber
    participant U as End user
    participant PA as Partner app
    participant PB as Partner backend
    participant UR as UR OpenAPI
    participant SS as Sumsub UR tenant

    rect rgb(240,250,255)
    note over PB,UR: Phase 1. Account and Sumsub token (synchronous)
    PB->>UR: POST /api/fma/v1/create-account
    note right of PB: body contains email, nationality, residency, dob, documentExpiry<br/>header X-External-User-Id carries the partner-side user id
    UR-->>PB: 200 sessionId + urId + evmAddress + state ConfirmYourCountryOfResidence

    PB->>UR: POST /api/fma/v1/kyc/sumsub-access-token
    note right of PB: empty body; urId from X-Ur-Id (FMAValidate middleware)
    UR->>SS: Issue access token bound to UR own Sumsub tenant (SumSubClient)
    SS-->>UR: token
    UR-->>PB: 200 token
    PB->>PA: relay token to partner app
    end

    rect rgb(255,250,235)
    note over U,SS: Phase 2. KYC on user device (asynchronous)
    PA->>SS: Launch Sumsub SDK with the token
    loop For each Sumsub level
        U->>SS: Submit Country, Address, ID scan, Liveness
        SS->>UR: Webhook applicantPending or applicantReviewed
    end
    U->>SS: Finish last step
    SS->>UR: Webhook applicantWorkflowCompleted GREEN
    note over UR: UR fetches the full applicant data, stores the encrypted snapshot,<br/>and advances the session to state SignFormA
    end

    rect rgb(245,255,245)
    note over PB,UR: Phase 3. Form A and submit (synchronous)
    PA-)PB: SDK completion signal (or your backend polls session/current)
    PB->>UR: GET /api/fma/v1/kyc/session/current
    UR-->>PB: 200 state SignFormA

    PB->>UR: GET /api/fma/v1/kyc/form-a-info with sessionId
    UR-->>PB: 200 formAVersion + text + textHash
    PB->>U: Display Form A and obtain explicit consent

    PB->>UR: POST /api/fma/v1/kyc/sign-form with sessionId + textHash
    UR-->>PB: 200 signature + signerAddress

    PB->>UR: POST /api/fma/v1/kyc/submit with sessionId
    UR-->>PB: 200 state Submitting
    end

    rect rgb(250,245,255)
    note over UR,PB: Phase 4. Activation (asynchronous)
    note over UR: UR generates KYC PDFs, sends them to the banking partner,<br/>polls until the account is approved, then mints the URID to Live status
    UR->>PB: POST partner webhook URL with fma.account.result status activated
    PB-->>UR: 200 ack
    end

    rect rgb(255,240,240)
    note over SS,PB: Failure. Sumsub returns RED at any level
    SS->>UR: Webhook applicantWorkflowCompleted RED
    note over UR: UR closes the session, releases the identity reservation,<br/>and dispatches a rejection webhook
    UR->>PB: POST partner webhook URL with fma.account.result status rejected
    note over PB: The user may retry by calling /create-account again
    end
```

## 3. Integration walkthrough

{% stepper %}
{% step %}

### Create the account

Call `POST /api/fma/v1/create-account` with the partner-side `X-External-User-Id` header and the user's onboarding data in the body. UR mints the URID, provisions the UR-managed wallet, and creates an onboarding session bound to `sessionId`.

Unlike the Sumsub reuse path, you do not send `applicantId`. UR's Sumsub tenant will assign the applicant id later, once the user actually starts the SDK.

Persist `sessionId`, `urId`, and `evmAddress` before showing onboarding progress to the user.
{% endstep %}

{% step %}

### Request a Sumsub access token

Call `POST /api/fma/v1/kyc/sumsub-access-token` with an empty body. The endpoint sits under the same `FMAValidate` middleware as the rest of `/api/fma/v1/kyc/*`; UR reads `urId` from the `X-Ur-Id` header you already sign on each request, resolves the active session server-side, and returns a Sumsub access token with a fixed 20 minute TTL.

If the user pauses before launching the SDK, request a new token; UR re-issues a fresh token bound to the same Sumsub applicant so the workflow resumes where the user left off.
{% endstep %}

{% step %}

### Launch the Sumsub SDK

Pass the token to your partner app and initialize the Sumsub mobile or web SDK. The user completes Country, Address, ID scan, and Liveness levels on their device. Your backend does not need to call UR during this phase; UR receives Sumsub webhooks server to server.

If the user navigates away mid-flow, refresh the token by calling `/api/fma/v1/kyc/sumsub-access-token` again and relaunch the SDK with the new token.
{% endstep %}

{% step %}

### Wait for KYC completion

Detect SDK completion using one of two patterns:

* Push from your partner app to your backend when the SDK reports done.
* Poll `GET /api/fma/v1/kyc/session/current` from your backend every one to five seconds for up to one minute after the SDK starts.

The session reaches state `SignFormA` once Sumsub delivers a `GREEN` `applicantWorkflowCompleted` webhook and UR has stored the verified KYC snapshot. Sumsub webhook delivery is typically subsecond but the 99th percentile can reach 30 seconds; treat the 60 second polling window as the practical timeout.

If Sumsub returns `RED`, UR fires `fma.account.result` with `status` set to `rejected` and `rejectCode` set to `SUMSUB_REJECTED`. You can offer the user a retry path by starting again at `POST /api/fma/v1/create-account` with the same `X-External-User-Id`.
{% endstep %}

{% step %}

### Render Form A and capture consent

Call `GET /api/fma/v1/kyc/form-a-info?sessionId=...` and display `data.text` to the user exactly as returned. The text includes the user's verified KYC identity and the banking terms; obtaining explicit user consent at this step is a regulatory requirement.

Store `data.textHash` for the next step. UR signs the same text on its side under a session lock to prevent any divergence between what the user saw and what UR signs.
{% endstep %}

{% step %}

### Sign Form A

After the user consents, call `POST /api/fma/v1/kyc/sign-form` with `sessionId` and the `textHash` from the previous step. UR re-renders Form A under a session lock and rejects the call with `FORMA_TEXT_MISMATCH` if the hash no longer matches; that signals the staging data changed underneath you. Recover by re-calling `form-a-info` and resigning with the new hash.

UR's TurnKey wallet signs the Form A text on the user's behalf. The returned `signerAddress` equals the `evmAddress` from the create-account response.
{% endstep %}

{% step %}

### Submit the session

Call `POST /api/fma/v1/kyc/submit` with `sessionId`. UR returns `state: Submitting` with `queued: false`. The session is now owned by UR's background scheduler.

`queued: false` is the success signal for this onboarding path; it indicates that UR can proceed with bank activation immediately without waiting for any further user action. The legacy `queued: true` response shape is reserved for an unrelated penny-transfer path that the SDK flow does not use.
{% endstep %}

{% step %}

### Receive the activation webhook

UR runs the bank activation asynchronously (typical latency 30 seconds to five minutes on testnet, 10 to 60 seconds on mainnet). When the URID reaches `Live` status on chain and the bank account is open, UR delivers `fma.account.result` with `status: activated` to your registered webhook URL.

Your webhook handler must verify the `X-Ur-Webhook-Signature` HMAC, deduplicate on `businessKey`, and ACK with HTTP 200. UR retries non-2xx responses with exponential backoff over a 30 minute window.
{% endstep %}
{% endstepper %}

## 4. API reference

All endpoints below sit under Partner Auth. See [Signature and verify](/developer-resources/signature-and-verify.md) for the EIP-191 signing scheme and the canonical-message construction rules.

| Environment    | Base URL                          |
| -------------- | --------------------------------- |
| **Production** | `https://openapi.ur.app`          |
| **Preview**    | `https://openapi-preview.ur.app`  |
| **Testnet**    | `https://uropenapi-qa.ur-inc.xyz` |

> Confirm the exact base URL set with UR before production rollout.

The examples in this section use the testnet base URL.

### 4.1 Create account

Create the user's URID and the onboarding session. UR provisions a UR-managed wallet and stores the session in state `ConfirmYourCountryOfResidence`.

| Item    | Value                                                          |
| ------- | -------------------------------------------------------------- |
| Method  | `POST`                                                         |
| Path    | `/api/fma/v1/create-account`                                   |
| Headers | Partner Auth headers + `X-External-User-Id` (no `X-Ur-Id` yet) |

Request body:

```json
{
  "email": "user@example.com",
  "nationality": "CHN",
  "residency": "CHN",
  "dob": "1994-01-06",
  "documentExpiry": "2036-05-05"
}
```

Request fields:

| Field            | Required | Description                                                                                                                          |
| ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `email`          | Yes      | The user's email address. Must be unique across UR partners; reusing the same email under a different partner returns `L1_CONFLICT`. |
| `nationality`    | No       | ISO 3166-1 alpha-3 country code. Sending it lets UR fail country gates before the URID is minted.                                    |
| `residency`      | No       | ISO 3166-1 alpha-3 country code of residence.                                                                                        |
| `dob`            | No       | Date of birth in `YYYY-MM-DD` format.                                                                                                |
| `documentExpiry` | No       | Passport or ID expiry date in `YYYY-MM-DD` format.                                                                                   |

Response:

```json
{
  "code": 0,
  "message": "",
  "data": {
    "sessionId": "223d567f-ea11-4629-ba2a-94664edc26f6",
    "urId": 5643568810,
    "evmAddress": "0x7c8173c8Fe47D55b9Ee848e89da1149A52632193",
    "state": "ConfirmYourCountryOfResidence",
    "idempotentReplay": false
  }
}
```

Response fields:

| Field              | Description                                                                                                                                                                                       |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sessionId`        | UUID for this onboarding attempt. Constant for the lifetime of the attempt. Use it in every subsequent KYC endpoint on this page.                                                                 |
| `urId`             | The user's URID, equal to the on-chain NFT token id. Stable for the user's lifetime across all retries.                                                                                           |
| `evmAddress`       | The user's UR-managed wallet address. Form A is signed by this address.                                                                                                                           |
| `state`            | Current session state. `ConfirmYourCountryOfResidence` is the entry state for this onboarding path.                                                                                               |
| `idempotentReplay` | `true` when an active onboarding session already exists for `(partnerId, X-External-User-Id)`. The response returns the existing session unchanged so repeated calls cannot mint duplicate URIDs. |

Rules:

* Repeated calls with the same `X-External-User-Id` while a session is active return the same `sessionId` and set `idempotentReplay` to `true`. The user's URID and wallet address never change across these retries.
* After the active session terminates (success, rejection, or timeout), the next call mints a fresh session with an incremented `retryLevel`. The URID is reused; only the onboarding session is new.

### 4.2 Create Sumsub access token

Issue a short-lived Sumsub access token for the user. The token authenticates the Sumsub SDK against UR's Sumsub tenant.

| Item    | Value                                 |
| ------- | ------------------------------------- |
| Method  | `POST`                                |
| Path    | `/api/fma/v1/kyc/sumsub-access-token` |
| Headers | Partner Auth headers + `X-Ur-Id`      |

Request body: empty (`{}`).

UR reads `urId` from the `X-Ur-Id` header (which you already sign as part of Partner Auth on every `/api/fma/v1/*` request) and resolves the active onboarding session server-side. No `tokenId` or `network` fields are needed.

Response:

```json
{
  "code": 0,
  "message": "ok",
  "data": {
    "token": "_act-jwt-eyJhbGciOiJub25lIn0...."
  }
}
```

Response fields:

| Field   | Description                                                                                                |
| ------- | ---------------------------------------------------------------------------------------------------------- |
| `token` | Sumsub access token. TTL is 20 minutes. Pass this string to the Sumsub SDK constructor on the user device. |

Server-side validation rules:

* `X-Ur-Id` must be present (else `INVALID_PARAM`).
* The user must have an active onboarding session for `(partnerId, urId)` (else `NO_ACTIVE_SESSION`).
* The session's data channel must be the SDK path (else `SESSION_WRONG_CHANNEL`; check your partner config).

### 4.3 Get current session

Read the current onboarding state. Useful for polling after the user starts the Sumsub SDK.

| Item    | Value                             |
| ------- | --------------------------------- |
| Method  | `GET`                             |
| Path    | `/api/fma/v1/kyc/session/current` |
| Headers | Partner Auth headers + `X-Ur-Id`  |

Request: no query parameters or body.

Response with an active session:

```json
{
  "code": 0,
  "message": "",
  "data": {
    "sessionId": "223d567f-ea11-4629-ba2a-94664edc26f6",
    "flowId": 11188,
    "retryLevel": 0,
    "state": "SignFormA",
    "dataChannel": "sdk",
    "createdAt": 1781000449,
    "lastUserActivityAt": 1781000789
  }
}
```

Response fields:

| Field                             | Description                                                                                                                                                            |
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `state`                           | Current session state. Sequence for this onboarding path: `ConfirmYourCountryOfResidence`, intermediate KYC steps, `SignFormA`, `Submitting`, `Completed` or `Failed`. |
| `dataChannel`                     | Always `sdk` for this onboarding path. If you see anything else, your partner config is wrong.                                                                         |
| `retryLevel`                      | Increments each time the user starts a fresh onboarding attempt after a previous one terminated.                                                                       |
| `createdAt`, `lastUserActivityAt` | Unix timestamps in seconds.                                                                                                                                            |

A user with no active session receives `NO_ACTIVE_SESSION` (`code: 30031`); treat that as the post-onboarding steady state.

### 4.4 Get Form A

Render Form A from the verified KYC snapshot.

| Item    | Value                            |
| ------- | -------------------------------- |
| Method  | `GET`                            |
| Path    | `/api/fma/v1/kyc/form-a-info`    |
| Headers | Partner Auth headers + `X-Ur-Id` |
| Query   | `sessionId`                      |

Callable only while `state` is `SignFormA`.

Response:

```json
{
  "code": 0,
  "message": "",
  "data": {
    "formAVersion": "v1",
    "text": "HANG ZHOU SHI, 09 June 2026\nMy name is XIAOLING KANG, born on 1990-09-10, ...",
    "textHash": "0x5aa556c0a1108e13eaed59014ec80d9c5a14dd659223aa8f3ac2291ca2e6c5bf"
  }
}
```

Response fields:

| Field          | Description                                                                                                                           |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `formAVersion` | Current Form A template version. Pin this in your audit trail.                                                                        |
| `text`         | The Markdown text the user must consent to. Render exactly as returned.                                                               |
| `textHash`     | Keccak-256 hash of the text bytes. Pass it back unchanged in `/kyc/sign-form` so UR can detect any tampering between render and sign. |

### 4.5 Sign Form A

UR's TurnKey wallet signs Form A on the user's behalf. The user authorized the text by consenting in your UI; you prove that authorization by echoing the `textHash`.

| Item    | Value                            |
| ------- | -------------------------------- |
| Method  | `POST`                           |
| Path    | `/api/fma/v1/kyc/sign-form`      |
| Headers | Partner Auth headers + `X-Ur-Id` |

Request body:

```json
{
  "sessionId": "223d567f-ea11-4629-ba2a-94664edc26f6",
  "textHash": "0x5aa556c0a1108e13eaed59014ec80d9c5a14dd659223aa8f3ac2291ca2e6c5bf"
}
```

Response:

```json
{
  "code": 0,
  "message": "",
  "data": {
    "state": "SignFormA",
    "signature": "0x73bdf33acf3cc17029687da6880694491900ed59a3f9832a355a05d3dacd7fe53cd001defcbc3ddedb5b697b481b892d0e0a448aa6b97aa606ed2fa26bdb7d5a1c",
    "signerAddress": "0xeEdCEC0bCa761c1ecC933E7a01ea34EaA543132a"
  }
}
```

The `signerAddress` matches the `evmAddress` from `/create-account`. The returned `state` is still `SignFormA`; the next call (`/kyc/submit`) flips the session to `Submitting`.

Common errors:

| Code  | Constant              | Meaning                                                          | Recovery                                                                    |
| ----- | --------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------- |
| 30050 | `FORMA_TEXT_MISMATCH` | The stored text changed between `/form-a-info` and `/sign-form`. | Re-call `/form-a-info`, show the new text, then sign with the new hash.     |
| 30060 | `FORMA_INCOMPLETE`    | The session is missing required fields.                          | Should not happen after a Sumsub `GREEN`; contact your integration channel. |
| 30070 | `TURNKEY_SIGN_FAILED` | UR's signing subsystem hit a transient failure.                  | Retry after a short backoff.                                                |

### 4.6 Submit

Hand the session off to UR's background activation scheduler.

| Item    | Value                            |
| ------- | -------------------------------- |
| Method  | `POST`                           |
| Path    | `/api/fma/v1/kyc/submit`         |
| Headers | Partner Auth headers + `X-Ur-Id` |

Request body:

```json
{ "sessionId": "223d567f-ea11-4629-ba2a-94664edc26f6" }
```

Response:

```json
{
  "code": 0,
  "message": "",
  "data": {
    "state": "Submitting",
    "queued": false,
    "awaitingPenny": false
  }
}
```

`queued: false` is the success signal for this onboarding path; UR's background scheduler picks the session up immediately. Wait for the `fma.account.result` webhook to confirm `activated`.

## 5. Webhook contract

UR delivers a single event type during onboarding: `fma.account.result`. Subscribe at your registered webhook URL and verify the `X-Ur-Webhook-Signature` HMAC before processing. See [Webhooks](/developer-resources/webhook.md) for the verification recipe.

### 5.1 Envelope

```http
POST https://your-webhook.example.com/ur/webhooks HTTP/1.1
Content-Type: application/json
X-Ur-Webhook-Signature: 0x...
X-Ur-Webhook-Timestamp: 1781000945

{
  "eventType": "fma.account.result",
  "businessKey": "223d567f-ea11-4629-ba2a-94664edc26f6:fma:result:activated",
  "occurredAt": 1781000945,
  "partnerId": "8209",
  "payload": { }
}
```

`businessKey` is the dedup key. UR retries on non-2xx responses with the same business key for roughly 30 minutes; your handler must be idempotent on this value.

### 5.2 Activated payload

```json
{
  "urId": 5643568810,
  "sessionId": "223d567f-ea11-4629-ba2a-94664edc26f6",
  "partnerId": "8209",
  "status": "activated",
  "occurredAt": 1781000945
}
```

### 5.3 Rejected payload

```json
{
  "urId": 5643568810,
  "sessionId": "223d567f-ea11-4629-ba2a-94664edc26f6",
  "partnerId": "8209",
  "status": "rejected",
  "occurredAt": 1781000945,
  "rejectCode": "SUMSUB_REJECTED",
  "rejectReason": "Sumsub workflow completed with RED at level Passport or National ID scan V5"
}
```

The most common `rejectCode` values for this onboarding path:

| rejectCode                                        | Source          | Meaning                                                      | Recommended user-facing action                                                                             |
| ------------------------------------------------- | --------------- | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- |
| `SUMSUB_REJECTED`                                 | Sumsub          | Vendor-side terminal rejection.                              | Allow retry. Suggest clearer document images.                                                              |
| `KYC_REJECTED`                                    | Banking partner | Downstream compliance rejected the user after Sumsub passed. | Surface a generic non-onboarded message; do not encourage immediate retry.                                 |
| `SESSION_EXPIRED`                                 | UR maintenance  | The user abandoned mid-flow for more than seven days.        | Allow restart.                                                                                             |
| `NATIONALITY_RESTRICTED`, `RESIDENCY_UNSUPPORTED` | UR gates        | Country gates failed late.                                   | Should normally surface at `/create-account`; if it arrives via webhook, contact your integration channel. |

## 6. Failure handling

### 6.1 Sumsub rejection

When Sumsub returns `RED`, UR closes the onboarding session and dispatches `fma.account.result` with `status: rejected, rejectCode: SUMSUB_REJECTED`. Your platform can offer a retry by calling `/create-account` again with the same `X-External-User-Id`; UR mints a fresh `sessionId` and increments the internal `retryLevel`. The user's URID is preserved.

The retry is safe to attempt immediately. UR's onboarding pipeline keeps an identity reservation so the same Sumsub-verified person cannot have two active onboarding attempts at once; that reservation is released automatically when the current attempt terminates, including on Sumsub rejection.

### 6.2 User abandons mid-flow

If the user starts onboarding but never finishes, UR's background sweep marks the session expired after seven days of inactivity and dispatches `fma.account.result` with `status: rejected, rejectCode: SESSION_EXPIRED`. Your platform can offer a restart any time; calling `/create-account` after the expiration mints a fresh session.

While the seven-day window is open, `/kyc/session/current` continues to return the in-progress state. Your UI can resume the user from whichever step the session is in.

### 6.3 Identity reuse

UR enforces an anti-fraud check so that the same identity (matching on name plus date of birth) cannot run two simultaneous onboarding attempts across any partner. If the second attempt collides with an already-active first attempt, UR rejects the collision. Sequential retries by the same identity are always allowed; the previous attempt's reservation is released when it terminates.

You do not need to implement client-side dedup. UR handles the check; partners only see a rejection if the collision is intentional fraud.

### 6.4 Token refresh

Sumsub access tokens expire 20 minutes after issuance. If the user pauses partway through the SDK, your backend re-issues a token via `POST /api/fma/v1/kyc/sumsub-access-token`; the Sumsub SDK exposes a callback (typically called `getNewAccessToken`) for in-session refresh. UR re-binds the new token to the same Sumsub applicant, so the SDK resumes where the user left off.

## 7. Reference: integration test

The Go integration tests below ship in the UR backend repository and run against the testnet environment. Use them as a port reference for your language. Full source: `tools/callurbankapi/fma_sdk_test.go` and `tools/callurbankapi/fma_kyc_onboarding_test.go`.

### 7.1 Phase 1 and 2: account plus Sumsub token

```go
// TestFMASdkAccessToken_HappyPath validates the synchronous chain:
//   /create-account -> /kyc/session/current -> /api/fma/v1/kyc/sumsub-access-token
//
// The Sumsub workflow itself runs on the user device and is out of scope here.
func TestFMASdkAccessToken_HappyPath(t *testing.T) {
    cfg := loadFMASdkCfg(t)
    suffix := uniqSuffix()
    externalUserId := "sdk-token-happy-" + suffix

    // Step 1: create the account.
    createBody := fmatypes.CreateAccountReq{
        Email:          "sdk-happy+" + suffix + "@example.com",
        Nationality:    "CHN",
        Residency:      "CHN",
        Dob:            "1994-01-06",
        DocumentExpiry: "2036-05-05",
    }
    raw, status, err := callFMAValidatePOST(t, cfg, "api/fma/v1/create-account",
        createBody, "", externalUserId)
    require.NoError(t, err)
    require.Equal(t, 200, status)

    var caOK fmatypes.CreateAccountResult
    code, msg := decodeFMAEnvelope(t, raw, &caOK)
    require.Zerof(t, code, "/create-account should succeed: code=%d msg=%s", code, msg)
    urIdStr := strconv.FormatInt(caOK.UrId, 10)

    // Step 2: confirm the data channel and state.
    raw, _, err = callFMAValidateGET(t, cfg, "api/fma/v1/kyc/session/current",
        url.Values{}, urIdStr, externalUserId)
    require.NoError(t, err)
    var cur sdkSessionCurrentResult
    curCode, curMsg := decodeFMAEnvelope(t, raw, &cur)
    require.Zerof(t, curCode, "/kyc/session/current should succeed: code=%d msg=%s", curCode, curMsg)
    assert.Equal(t, "sdk", cur.DataChannel)
    assert.Equal(t, "ConfirmYourCountryOfResidence", cur.State)

    // Step 3: request the Sumsub token.
    // Empty body; urId is read from X-Ur-Id (FMAValidate middleware), and
    // UR resolves the active session server-side.
    raw, status, err = callFMAValidatePOST(t, cfg, "api/fma/v1/kyc/sumsub-access-token",
        struct{}{}, urIdStr, externalUserId)
    require.NoError(t, err)
    require.Equal(t, 200, status)

    var tokenResp struct {
        Token string `json:"token"`
    }
    code, msg = decodeFMAEnvelope(t, raw, &tokenResp)
    require.Zerof(t, code, "access-token should succeed: code=%d msg=%s", code, msg)
    assert.NotEmpty(t, tokenResp.Token)
}
```

### 7.2 Phase 3: sign and submit

```go
// TestFMASdkSignFormAndSubmit covers the post-Sumsub chain. Sumsub must have
// already returned GREEN, so the session is at state SignFormA.
//
// Run after the user has completed Sumsub on a device, with:
//   FMA_SDK_SESSION_ID=<uuid> FMA_SDK_UR_ID=<int> FMA_SDK_EXTERNAL_USER_ID=<str> \
//     go test -v -run TestFMASdkSignFormAndSubmit ./tools/callurbankapi
func TestFMASdkSignFormAndSubmit(t *testing.T) {
    sessionId := strings.TrimSpace(os.Getenv("FMA_SDK_SESSION_ID"))
    urIdStr := strings.TrimSpace(os.Getenv("FMA_SDK_UR_ID"))
    externalUserId := strings.TrimSpace(os.Getenv("FMA_SDK_EXTERNAL_USER_ID"))
    if sessionId == "" || urIdStr == "" || externalUserId == "" {
        t.Skip("set FMA_SDK_SESSION_ID + FMA_SDK_UR_ID + FMA_SDK_EXTERNAL_USER_ID env vars")
    }
    cfg := loadFMASdkCfg(t)

    // 4a: confirm state is SignFormA.
    raw, _, err := callFMAValidateGET(t, cfg, "api/fma/v1/kyc/session/current",
        url.Values{}, urIdStr, externalUserId)
    require.NoError(t, err)
    var cur sdkSessionCurrentResult
    code, msg := decodeFMAEnvelope(t, raw, &cur)
    require.Zerof(t, code, "/kyc/session/current should succeed: code=%d msg=%s", code, msg)
    require.Equal(t, "SignFormA", cur.State)

    // 4b: get Form A textHash.
    qs := url.Values{}
    qs.Set("sessionId", sessionId)
    raw, _, err = callFMAValidateGET(t, cfg, "api/fma/v1/kyc/form-a-info",
        qs, urIdStr, externalUserId)
    require.NoError(t, err)
    var formA struct {
        FormAVersion string `json:"formAVersion"`
        Text         string `json:"text"`
        TextHash     string `json:"textHash"`
    }
    code, msg = decodeFMAEnvelope(t, raw, &formA)
    require.Zerof(t, code, "/kyc/form-a-info should succeed: code=%d msg=%s", code, msg)
    require.NotEmpty(t, formA.TextHash)

    // 4c: TurnKey-sign Form A.
    signBody := fmatypes.SignFormReq{SessionId: sessionId, TextHash: formA.TextHash}
    raw, _, err = callFMAValidatePOST(t, cfg, "api/fma/v1/kyc/sign-form",
        signBody, urIdStr, externalUserId)
    require.NoError(t, err)
    var signOK fmatypes.SignFormResult
    code, msg = decodeFMAEnvelope(t, raw, &signOK)
    require.Zerof(t, code, "/kyc/sign-form should succeed: code=%d msg=%s", code, msg)
    require.NotEmpty(t, signOK.Signature)
    require.NotEmpty(t, signOK.SignerAddress)

    // 5: hand off to the background scheduler.
    submitBody := fmatypes.SubmitReq{SessionId: sessionId}
    raw, _, err = callFMAValidatePOST(t, cfg, "api/fma/v1/kyc/submit",
        submitBody, urIdStr, externalUserId)
    require.NoError(t, err)
    var submitOK fmatypes.SubmitResult
    code, msg = decodeFMAEnvelope(t, raw, &submitOK)
    require.Zerof(t, code, "/kyc/submit should succeed: code=%d msg=%s", code, msg)
    assert.Equal(t, "Submitting", submitOK.State)
    assert.False(t, submitOK.Queued)
    assert.False(t, submitOK.AwaitingPenny)
}
```

### 7.3 Partner Auth signing helper

```go
// EIP-191 personal_sign over canonicalMessage + " " + deadline.
// See section §4 above for canonical-message construction rules.
func fmaSignHeaders(t *testing.T, cfg fmaCfg, canonicalMessage string, deadlineSeconds int64) map[string]string {
    if deadlineSeconds <= 0 {
        deadlineSeconds = 60
    }
    deadline := time.Now().Unix() + deadlineSeconds
    deadlineStr := strconv.FormatInt(deadline, 10)
    signedMessage := canonicalMessage + " " + deadlineStr

    pk, _ := crypto.HexToECDSA(strings.TrimPrefix(cfg.privateKey, "0x"))

    // EIP-191 personal_sign prefix.
    prefix := fmt.Sprintf("\x19Ethereum Signed Message:\n%d", len(signedMessage))
    hash := crypto.Keccak256Hash([]byte(prefix + signedMessage))

    sig, _ := crypto.Sign(hash.Bytes(), pk)
    return map[string]string{
        "X-Api-Signature": "0x" + hex.EncodeToString(sig),
        "X-Api-Deadline":  deadlineStr,
        "X-Api-PublicKey": cfg.pubKey,
        "Content-Type":    "application/json",
    }
}

// Canonical for FMA endpoints: {body}urId:{X-Ur-Id}externalUserId:{X-External-User-Id}
func fmaValidateSignHeaders(t *testing.T, cfg fmaCfg, body, urId, externalUserId string,
    deadlineSeconds int64) map[string]string {
    canonical := body +
        fmt.Sprintf("urId:%s", urId) +
        fmt.Sprintf("externalUserId:%s", externalUserId)
    headers := fmaSignHeaders(t, cfg, canonical, deadlineSeconds)
    if urId != "" {
        headers["X-Ur-Id"] = urId
    }
    if externalUserId != "" {
        headers["X-External-User-Id"] = externalUserId
    }
    return headers
}
```

## 8. Error codes

The table below covers the codes that surface during this onboarding path. Endpoint-specific error tables for the rest of the Managed Custody Mode surface live next to each endpoint in [API reference: Managed Custody Mode](/developer-resources/api-reference-managed-custody-mode.md).

| Code           | Constant                | Where it surfaces                                             | Meaning                                                                                          |
| -------------- | ----------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| 10002          | `PARTNER_NOT_ALLOWED`   | `/create-account`                                             | `partnerId` is not on UR's allowlist; contact your integration channel.                          |
| 10004          | `PARTNER_MODE_MISMATCH` | `/create-account`                                             | The partner is not configured for the SDK onboarding path.                                       |
| 30010 to 30013 | country gates           | `/create-account`                                             | Sent `nationality`, `residency`, `dob`, or `documentExpiry` fails a country or age gate.         |
| 30015          | `L1_CONFLICT`           | `/create-account`                                             | The email is already onboarded by another partner.                                               |
| 30031          | `NO_ACTIVE_SESSION`     | `/api/fma/v1/kyc/sumsub-access-token`, `/kyc/session/current` | The user has no active onboarding session. Call `/create-account` first.                         |
| 30032          | `SESSION_WRONG_CHANNEL` | `/api/fma/v1/kyc/sumsub-access-token`                         | The active session is on a different onboarding path; partner config issue.                      |
| 30050          | `FORMA_TEXT_MISMATCH`   | `/kyc/sign-form`                                              | `textHash` does not match the current render; re-call `/kyc/form-a-info`.                        |
| 30070          | `TURNKEY_SIGN_FAILED`   | `/kyc/sign-form`                                              | UR's signing subsystem hit a transient failure; retry.                                           |
| 50001          | `UPSTREAM_UNAVAILABLE`  | any                                                           | UR's upstream (banking partner, TurnKey, Sumsub) is temporarily unavailable. Retry with backoff. |
| 50002          | `INTERNAL_ERROR`        | any                                                           | Surface a generic message and contact your integration channel.                                  |


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.ur.app/developer-resources/api-reference-managed-custody-sdk-kyc.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
