diff --git a/mintlify/openapi.yaml b/mintlify/openapi.yaml index edd1c5018..f037a2673 100644 --- a/mintlify/openapi.yaml +++ b/mintlify/openapi.yaml @@ -2580,12 +2580,16 @@ paths: asynchronously. The outcome is delivered via `EXTERNAL_ACCOUNT.STATUS_UPDATED` webhooks or by polling the account. - Calling this endpoint again abandons any in-flight challenge and issues a - new one with the requested method — use it to retry after a failed - attempt, to replace an expired challenge, or to switch methods. An - `UNVERIFIED` account returns to `PENDING_OWNERSHIP_VERIFICATION` when a - new challenge is issued. + While a `WALLET_SIGNATURE` challenge is outstanding, calling this endpoint + again returns that same challenge, with the same `messageToSign` and + `expiresAt`. Reloading a signing page therefore does not invalidate the + message the user is about to sign. You get a new challenge once the + outstanding one expires or its attempt fails. + Requesting `LIVENESS` always starts a liveness attempt, even while a + signature challenge is outstanding. + + An `UNVERIFIED` account stays `UNVERIFIED` until a retry succeeds. Completing ownership verification moves the account to `ACTIVE`. operationId: createExternalAccountOwnershipChallenge tags: @@ -2932,12 +2936,16 @@ paths: asynchronously. The outcome is delivered via `EXTERNAL_ACCOUNT.STATUS_UPDATED` webhooks or by polling the account. - Calling this endpoint again abandons any in-flight challenge and issues a - new one with the requested method — use it to retry after a failed - attempt, to replace an expired challenge, or to switch methods. An - `UNVERIFIED` account returns to `PENDING_OWNERSHIP_VERIFICATION` when a - new challenge is issued. + While a `WALLET_SIGNATURE` challenge is outstanding, calling this endpoint + again returns that same challenge, with the same `messageToSign` and + `expiresAt`. Reloading a signing page therefore does not invalidate the + message the user is about to sign. You get a new challenge once the + outstanding one expires or its attempt fails. + + Requesting `LIVENESS` always starts a liveness attempt, even while a + signature challenge is outstanding. + An `UNVERIFIED` account stays `UNVERIFIED` until a retry succeeds. Completing ownership verification moves the account to `ACTIVE`. operationId: createPlatformExternalAccountOwnershipChallenge tags: diff --git a/mintlify/platform-overview/core-concepts/account-model.mdx b/mintlify/platform-overview/core-concepts/account-model.mdx index c6559d1e1..242069a89 100644 --- a/mintlify/platform-overview/core-concepts/account-model.mdx +++ b/mintlify/platform-overview/core-concepts/account-model.mdx @@ -209,11 +209,63 @@ PENDING → ACTIVE - **PENDING**: Created, undergoing verification/screening - **ACTIVE**: Verified, ready for transactions +- **PENDING_OWNERSHIP_VERIFICATION**: The customer must prove they control this self-custody wallet +- **UNVERIFIED**: The last ownership attempt failed. Start a new challenge to retry - **INACTIVE**: Disabled (can be reactivated) - **UNDER_REVIEW**: Additional review required You'll receive `INTERNAL_ACCOUNT.BALANCE_UPDATED` webhooks as balance changes, and `INTERNAL_ACCOUNT.STATUS_UPDATED` webhooks when account status changes (e.g., ACTIVE to FROZEN). +### Ownership Verification + +Some regions require the owner of a self-custody wallet to prove they control it (e.g. the EU). Register the wallet with `ownershipType` set to `FIRST_PARTY` and it starts at `PENDING_OWNERSHIP_VERIFICATION`. + +The status is not a block: transfers below the regulatory threshold go through while a wallet is pending. Larger transfers need verification to be complete first. + +Start a challenge on the account, choosing how the owner will prove control: + +```bash +curl -X POST https://api.lightspark.com/grid/2025-10-13/customers/external-accounts/ExternalAccount:abc123/challenge \ + -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ + -H "Content-Type: application/json" \ + -d '{"method": "WALLET_SIGNATURE"}' +``` + +**`WALLET_SIGNATURE`** returns a message for the wallet to sign: + +```json +{ + "method": "WALLET_SIGNATURE", + "messageToSign": "I am verifying ownership of the wallet address 7Ub1FK9aE5MsQBYfEdxSXorWeCGq9xPgPygL9XjA3KZz as a361e175-69b4-4bfb-9ceb-acb6ea672cf0. This message was signed on 04/09/2026 to confirm my control over this wallet.", + "expiresAt": "2026-09-06T00:00:00Z" +} +``` + +Submit the signature to complete verification synchronously. The response is the external account, with `status` set to `ACTIVE` on success: + +```bash +curl -X POST https://api.lightspark.com/grid/2025-10-13/customers/external-accounts/ExternalAccount:abc123/verify \ + -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ + -H "Content-Type: application/json" \ + -d '{"signature": "5tCtPdm7CnT3YyYChEdiiEH5..."}' +``` + + + Sign `messageToSign` exactly as returned. It is matched character for character, and the date it carries is only accepted for yesterday, today, and tomorrow in UTC. Never rebuild the message yourself. + + +**`LIVENESS`** returns a hosted `verificationLink` (and an embed `token` where supported). The owner completes a biometric flow and verification finishes asynchronously, so watch for `EXTERNAL_ACCOUNT.STATUS_UPDATED` or poll the account. + + + Calling the challenge endpoint again while a signature challenge is outstanding returns that same challenge, so reloading a signing page does not invalidate the message the owner is about to sign. A refused signature moves the account to `UNVERIFIED`, where it stays until a retry succeeds. + + +Verification applies only to first-party self-custody wallets. A wallet held at an exchange is declared by setting `vaspName` inside `accountInfo`, and skips verification because the keys belong to the exchange. `THIRD_PARTY` wallets skip it too. + + + `vaspName` belongs inside `accountInfo`. At the top level it is ignored, and the wallet is treated as self-custody. + + ### Using External Accounts diff --git a/mintlify/ramps/accounts/external-accounts.mdx b/mintlify/ramps/accounts/external-accounts.mdx index a64ade151..a142e04f4 100644 --- a/mintlify/ramps/accounts/external-accounts.mdx +++ b/mintlify/ramps/accounts/external-accounts.mdx @@ -172,18 +172,30 @@ curl -X GET 'https://api.lightspark.com/grid/2025-10-13/customers/external-accou External accounts move through verification states: -| Status | Description | Can Use for Conversions | -| ---------- | ------------------------ | ----------------------- | -| `PENDING` | Verification in progress | ❌ | -| `ACTIVE` | Verified and ready | ✅ | -| `UNDER_REVIEW` | Additional review required | ❌ | -| `INACTIVE` | Manually disabled | ❌ | +| Status | Description | Can use for conversions | +| -------------------------------- | ----------------------------------------------------------------- | ------------------------------ | +| `PENDING` | Verification in progress | No | +| `ACTIVE` | Verified and ready | Yes | +| `PENDING_OWNERSHIP_VERIFICATION` | The customer must prove they control this self-custody wallet | Below the regulatory threshold | +| `UNVERIFIED` | The last ownership attempt failed. Start a new challenge to retry | Below the regulatory threshold | +| `UNDER_REVIEW` | Additional review required | No | +| `INACTIVE` | Manually disabled | No | Spark wallet accounts are immediately `ACTIVE`. Bank accounts may require verification (typically instant to a few hours). + + A self-custody wallet registered with `ownershipType` set to `FIRST_PARTY` + starts at `PENDING_OWNERSHIP_VERIFICATION` in regions that require the owner to + prove they control it (e.g. the EU). It is not blocked: conversions below the + regulatory threshold go through while the wallet is pending. To clear the + status, start a challenge with + `POST /customers/external-accounts/{externalAccountId}/challenge` and complete + it with either a wallet signature or a liveness check. + + ## Best practices for ramps diff --git a/mintlify/snippets/external-accounts.mdx b/mintlify/snippets/external-accounts.mdx index 107545b25..946b97af8 100644 --- a/mintlify/snippets/external-accounts.mdx +++ b/mintlify/snippets/external-accounts.mdx @@ -1970,14 +1970,18 @@ For business beneficiaries, the required fields vary by destination currency: ## Account status -Beneficiary data may be reviewed for risk and compliance. Only `ACTIVE` accounts can receive payments. Updates to account data may trigger account re-review. - -| Status | Description | -| -------------- | ----------------------------------- | -| `PENDING` | Created, awaiting verification | -| `ACTIVE` | Verified and ready for transactions | -| `UNDER_REVIEW` | Additional review required | -| `INACTIVE` | Disabled, cannot be used | +Beneficiary data may be reviewed for risk and compliance. Only `ACTIVE` accounts can receive payments, except that a self-custody wallet still owed ownership verification can receive transfers below the regulatory threshold. Updates to account data may trigger account re-review. + +| Status | Description | +| -------------------------------- | --------------------------------------------------------------------------------- | +| `PENDING` | Created, awaiting verification | +| `ACTIVE` | Verified and ready for transactions | +| `PENDING_OWNERSHIP_VERIFICATION` | The customer must prove they control this self-custody wallet | +| `UNVERIFIED` | The last ownership attempt failed. Start a new challenge to retry | +| `UNDER_REVIEW` | Additional review required | +| `INACTIVE` | Disabled, cannot be used | + +A wallet reaches `PENDING_OWNERSHIP_VERIFICATION` when you register it with `ownershipType` set to `FIRST_PARTY` in a region that requires the owner to prove they control it (e.g. the EU). To clear it, start a challenge with `POST /customers/external-accounts/{externalAccountId}/challenge` and complete it with either a wallet signature or a liveness check. Both statuses allow transfers below the regulatory threshold, so a pending wallet is not blocked outright. ## Listing external accounts diff --git a/openapi.yaml b/openapi.yaml index edd1c5018..f037a2673 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2580,12 +2580,16 @@ paths: asynchronously. The outcome is delivered via `EXTERNAL_ACCOUNT.STATUS_UPDATED` webhooks or by polling the account. - Calling this endpoint again abandons any in-flight challenge and issues a - new one with the requested method — use it to retry after a failed - attempt, to replace an expired challenge, or to switch methods. An - `UNVERIFIED` account returns to `PENDING_OWNERSHIP_VERIFICATION` when a - new challenge is issued. + While a `WALLET_SIGNATURE` challenge is outstanding, calling this endpoint + again returns that same challenge, with the same `messageToSign` and + `expiresAt`. Reloading a signing page therefore does not invalidate the + message the user is about to sign. You get a new challenge once the + outstanding one expires or its attempt fails. + Requesting `LIVENESS` always starts a liveness attempt, even while a + signature challenge is outstanding. + + An `UNVERIFIED` account stays `UNVERIFIED` until a retry succeeds. Completing ownership verification moves the account to `ACTIVE`. operationId: createExternalAccountOwnershipChallenge tags: @@ -2932,12 +2936,16 @@ paths: asynchronously. The outcome is delivered via `EXTERNAL_ACCOUNT.STATUS_UPDATED` webhooks or by polling the account. - Calling this endpoint again abandons any in-flight challenge and issues a - new one with the requested method — use it to retry after a failed - attempt, to replace an expired challenge, or to switch methods. An - `UNVERIFIED` account returns to `PENDING_OWNERSHIP_VERIFICATION` when a - new challenge is issued. + While a `WALLET_SIGNATURE` challenge is outstanding, calling this endpoint + again returns that same challenge, with the same `messageToSign` and + `expiresAt`. Reloading a signing page therefore does not invalidate the + message the user is about to sign. You get a new challenge once the + outstanding one expires or its attempt fails. + + Requesting `LIVENESS` always starts a liveness attempt, even while a + signature challenge is outstanding. + An `UNVERIFIED` account stays `UNVERIFIED` until a retry succeeds. Completing ownership verification moves the account to `ACTIVE`. operationId: createPlatformExternalAccountOwnershipChallenge tags: diff --git a/openapi/paths/customers/customers_external_accounts_{externalAccountId}_challenge.yaml b/openapi/paths/customers/customers_external_accounts_{externalAccountId}_challenge.yaml index abc37e643..364becae7 100644 --- a/openapi/paths/customers/customers_external_accounts_{externalAccountId}_challenge.yaml +++ b/openapi/paths/customers/customers_external_accounts_{externalAccountId}_challenge.yaml @@ -14,12 +14,16 @@ post: asynchronously. The outcome is delivered via `EXTERNAL_ACCOUNT.STATUS_UPDATED` webhooks or by polling the account. - Calling this endpoint again abandons any in-flight challenge and issues a - new one with the requested method — use it to retry after a failed - attempt, to replace an expired challenge, or to switch methods. An - `UNVERIFIED` account returns to `PENDING_OWNERSHIP_VERIFICATION` when a - new challenge is issued. + While a `WALLET_SIGNATURE` challenge is outstanding, calling this endpoint + again returns that same challenge, with the same `messageToSign` and + `expiresAt`. Reloading a signing page therefore does not invalidate the + message the user is about to sign. You get a new challenge once the + outstanding one expires or its attempt fails. + Requesting `LIVENESS` always starts a liveness attempt, even while a + signature challenge is outstanding. + + An `UNVERIFIED` account stays `UNVERIFIED` until a retry succeeds. Completing ownership verification moves the account to `ACTIVE`. operationId: createExternalAccountOwnershipChallenge tags: diff --git a/openapi/paths/platform/platform_external_accounts_{externalAccountId}_challenge.yaml b/openapi/paths/platform/platform_external_accounts_{externalAccountId}_challenge.yaml index 532decc60..a4ab543b2 100644 --- a/openapi/paths/platform/platform_external_accounts_{externalAccountId}_challenge.yaml +++ b/openapi/paths/platform/platform_external_accounts_{externalAccountId}_challenge.yaml @@ -14,12 +14,16 @@ post: asynchronously. The outcome is delivered via `EXTERNAL_ACCOUNT.STATUS_UPDATED` webhooks or by polling the account. - Calling this endpoint again abandons any in-flight challenge and issues a - new one with the requested method — use it to retry after a failed - attempt, to replace an expired challenge, or to switch methods. An - `UNVERIFIED` account returns to `PENDING_OWNERSHIP_VERIFICATION` when a - new challenge is issued. + While a `WALLET_SIGNATURE` challenge is outstanding, calling this endpoint + again returns that same challenge, with the same `messageToSign` and + `expiresAt`. Reloading a signing page therefore does not invalidate the + message the user is about to sign. You get a new challenge once the + outstanding one expires or its attempt fails. + Requesting `LIVENESS` always starts a liveness attempt, even while a + signature challenge is outstanding. + + An `UNVERIFIED` account stays `UNVERIFIED` until a retry succeeds. Completing ownership verification moves the account to `ACTIVE`. operationId: createPlatformExternalAccountOwnershipChallenge tags: