diff --git a/.stainless/stainless.yml b/.stainless/stainless.yml index 49100e04c..781afc91c 100644 --- a/.stainless/stainless.yml +++ b/.stainless/stainless.yml @@ -210,6 +210,11 @@ resources: external_account_create: '#/components/schemas/ExternalAccountCreateRequest' external_account_info_one_of: "#/components/schemas/ExternalAccountInfoOneOf" business_beneficiary: "#/components/schemas/BusinessBeneficiary" + # Ownership verification (challenge/verify) + ownership_verification_method: "#/components/schemas/OwnershipVerificationMethod" + ownership_challenge_request: "#/components/schemas/OwnershipChallengeRequest" + ownership_challenge: "#/components/schemas/OwnershipChallenge" + ownership_verify_request: "#/components/schemas/OwnershipVerifyRequest" address: "#/components/schemas/Address" beneficiary_verified_data: "#/components/schemas/BeneficiaryVerifiedData" # List response @@ -219,6 +224,8 @@ resources: create: post /customers/external-accounts retrieve: get /customers/external-accounts/{externalAccountId} delete: delete /customers/external-accounts/{externalAccountId} + challenge: post /customers/external-accounts/{externalAccountId}/challenge + verify: post /customers/external-accounts/{externalAccountId}/verify bulk: methods: upload_csv: post /customers/bulk/csv @@ -238,6 +245,8 @@ resources: create: post /platform/external-accounts retrieve: get /platform/external-accounts/{externalAccountId} delete: delete /platform/external-accounts/{externalAccountId} + challenge: post /platform/external-accounts/{externalAccountId}/challenge + verify: post /platform/external-accounts/{externalAccountId}/verify models: platform_external_account_create_request: "#/components/schemas/PlatformExternalAccountCreateRequest" aed_account_info: "#/components/schemas/AedAccountInfo" diff --git a/mintlify/openapi.yaml b/mintlify/openapi.yaml index 6e4523f11..7a72ab91d 100644 --- a/mintlify/openapi.yaml +++ b/mintlify/openapi.yaml @@ -2429,6 +2429,154 @@ paths: application/json: schema: $ref: '#/components/schemas/Error500' + /customers/external-accounts/{externalAccountId}/challenge: + post: + summary: Start an ownership verification challenge + description: | + Start (or restart) ownership verification for a `FIRST_PARTY` self-custody + crypto wallet external account in `PENDING_OWNERSHIP_VERIFICATION` or + `UNVERIFIED` status. The response carries the method-specific challenge + material: + + - `WALLET_SIGNATURE` — a `messageToSign`; have the wallet sign it exactly + and submit the result to the verify endpoint to complete verification + synchronously. + - `LIVENESS` — a hosted `verificationLink` (and possibly an embed + `token`); the user completes a biometric flow and verification completes + 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. + + Completing ownership verification moves the account to `ACTIVE`. + operationId: createExternalAccountOwnershipChallenge + tags: + - External Accounts + security: + - BasicAuth: [] + parameters: + - name: externalAccountId + in: path + description: External account ID + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OwnershipChallengeRequest' + responses: + '201': + description: Challenge created; the method-specific material is returned. + content: + application/json: + schema: + $ref: '#/components/schemas/OwnershipChallenge' + '400': + description: Bad request - Invalid parameters + content: + application/json: + schema: + $ref: '#/components/schemas/Error400' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '404': + description: External account not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error404' + '409': + description: Ownership verification is not applicable to this external account (not a self-custody crypto wallet, not `FIRST_PARTY`, or already verified). + content: + application/json: + schema: + $ref: '#/components/schemas/Error409' + '500': + description: Internal service error + content: + application/json: + schema: + $ref: '#/components/schemas/Error500' + /customers/external-accounts/{externalAccountId}/verify: + post: + summary: Verify ownership with a wallet signature + description: | + Complete a `WALLET_SIGNATURE` challenge by submitting the signature the + wallet produced for the challenge's `messageToSign`. The message must be + signed exactly as returned, and the signature must be submitted before + the challenge's `expiresAt` — after expiry, start a new challenge. + + On success the account moves to `ACTIVE`; on an invalid signature it + moves to `UNVERIFIED` (start a new challenge to retry). `LIVENESS` + challenges complete asynchronously and never use this endpoint — their + outcome is delivered via `EXTERNAL_ACCOUNT.STATUS_UPDATED` webhooks or by + polling the account. + operationId: verifyExternalAccountOwnership + tags: + - External Accounts + security: + - BasicAuth: [] + parameters: + - name: externalAccountId + in: path + description: External account ID + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OwnershipVerifyRequest' + responses: + '200': + description: 'Signature valid; the updated external account is returned with `status: ACTIVE`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ExternalAccount' + '400': + description: Invalid or expired signature. The account moves to `UNVERIFIED`; start a new challenge to retry. + content: + application/json: + schema: + $ref: '#/components/schemas/Error400' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '404': + description: External account not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error404' + '409': + description: No `WALLET_SIGNATURE` challenge is outstanding for this external account. + content: + application/json: + schema: + $ref: '#/components/schemas/Error409' + '500': + description: Internal service error + content: + application/json: + schema: + $ref: '#/components/schemas/Error500' /platform/external-accounts: get: summary: List platform external accounts @@ -2633,6 +2781,154 @@ paths: application/json: schema: $ref: '#/components/schemas/Error500' + /platform/external-accounts/{externalAccountId}/challenge: + post: + summary: Start an ownership verification challenge + description: | + Start (or restart) ownership verification for a `FIRST_PARTY` self-custody + crypto wallet external account in `PENDING_OWNERSHIP_VERIFICATION` or + `UNVERIFIED` status. The response carries the method-specific challenge + material: + + - `WALLET_SIGNATURE` — a `messageToSign`; have the wallet sign it exactly + and submit the result to the verify endpoint to complete verification + synchronously. + - `LIVENESS` — a hosted `verificationLink` (and possibly an embed + `token`); the user completes a biometric flow and verification completes + 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. + + Completing ownership verification moves the account to `ACTIVE`. + operationId: createPlatformExternalAccountOwnershipChallenge + tags: + - External Accounts + security: + - BasicAuth: [] + parameters: + - name: externalAccountId + in: path + description: External account ID + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OwnershipChallengeRequest' + responses: + '201': + description: Challenge created; the method-specific material is returned. + content: + application/json: + schema: + $ref: '#/components/schemas/OwnershipChallenge' + '400': + description: Bad request - Invalid parameters + content: + application/json: + schema: + $ref: '#/components/schemas/Error400' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '404': + description: External account not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error404' + '409': + description: Ownership verification is not applicable to this external account (not a self-custody crypto wallet, not `FIRST_PARTY`, or already verified). + content: + application/json: + schema: + $ref: '#/components/schemas/Error409' + '500': + description: Internal service error + content: + application/json: + schema: + $ref: '#/components/schemas/Error500' + /platform/external-accounts/{externalAccountId}/verify: + post: + summary: Verify ownership with a wallet signature + description: | + Complete a `WALLET_SIGNATURE` challenge by submitting the signature the + wallet produced for the challenge's `messageToSign`. The message must be + signed exactly as returned, and the signature must be submitted before + the challenge's `expiresAt` — after expiry, start a new challenge. + + On success the account moves to `ACTIVE`; on an invalid signature it + moves to `UNVERIFIED` (start a new challenge to retry). `LIVENESS` + challenges complete asynchronously and never use this endpoint — their + outcome is delivered via `EXTERNAL_ACCOUNT.STATUS_UPDATED` webhooks or by + polling the account. + operationId: verifyPlatformExternalAccountOwnership + tags: + - External Accounts + security: + - BasicAuth: [] + parameters: + - name: externalAccountId + in: path + description: External account ID + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OwnershipVerifyRequest' + responses: + '200': + description: 'Signature valid; the updated external account is returned with `status: ACTIVE`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ExternalAccount' + '400': + description: Invalid or expired signature. The account moves to `UNVERIFIED`; start a new challenge to retry. + content: + application/json: + schema: + $ref: '#/components/schemas/Error400' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '404': + description: External account not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error404' + '409': + description: No `WALLET_SIGNATURE` challenge is outstanding for this external account. + content: + application/json: + schema: + $ref: '#/components/schemas/Error409' + '500': + description: Internal service error + content: + application/json: + schema: + $ref: '#/components/schemas/Error500' /beneficial-owners: post: summary: Create a beneficial owner @@ -10743,6 +11039,89 @@ webhooks: application/json: schema: $ref: '#/components/schemas/Error409' + external-account-status: + post: + summary: External account status webhook + description: | + Webhook that is called whenever the status of an external account changes, for any transition between statuses (`PENDING`, `ACTIVE`, `UNDER_REVIEW`, `INACTIVE`, `PENDING_OWNERSHIP_VERIFICATION`, `UNVERIFIED`) — for example when an account under review becomes active, when ownership verification completes (`PENDING_OWNERSHIP_VERIFICATION` → `ACTIVE`), or when a verification attempt fails (`PENDING_OWNERSHIP_VERIFICATION` → `UNVERIFIED`). + This endpoint should be implemented by clients of the Grid API. + + ### Authentication + The webhook includes a signature in the `X-Grid-Signature` header that allows you to verify that the webhook was sent by Grid. + To verify the signature: + 1. Get the Grid public key provided to you during integration + 2. Decode the base64 signature from the header + 3. Create a SHA-256 hash of the request body + 4. Verify the signature using the public key and the hash + + If the signature verification succeeds, the webhook is authentic. If not, it should be rejected. + + ### Event types + - `EXTERNAL_ACCOUNT.STATUS_UPDATED` — Fired when the status of an external account changes. The `data` payload contains the full external account object. + operationId: externalAccountStatusWebhook + tags: + - Webhooks + security: + - WebhookSignature: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExternalAccountStatusWebhook' + examples: + verificationSucceeded: + summary: A wallet account became active after ownership verification + value: + id: Webhook:019542f5-b3e7-1d02-0000-000000000042 + type: EXTERNAL_ACCOUNT.STATUS_UPDATED + timestamp: '2025-08-15T14:32:00Z' + data: + id: ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965 + customerId: Customer:da459a29-1fb7-41ce-a4cb-eb3a3c9fd7a7 + status: ACTIVE + currency: USDC + ownershipType: FIRST_PARTY + accountInfo: + accountType: ETHEREUM_WALLET + address: '0xAbCDEF1234567890aBCdEf1234567890ABcDef12' + verificationFailed: + summary: A liveness check failed; a new challenge is needed to retry + value: + id: Webhook:019542f5-b3e7-1d02-0000-000000000043 + type: EXTERNAL_ACCOUNT.STATUS_UPDATED + timestamp: '2025-08-15T14:45:00Z' + data: + id: ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965 + customerId: Customer:da459a29-1fb7-41ce-a4cb-eb3a3c9fd7a7 + status: UNVERIFIED + currency: USDC + ownershipType: FIRST_PARTY + accountInfo: + accountType: ETHEREUM_WALLET + address: '0xAbCDEF1234567890aBCdEf1234567890ABcDef12' + responses: + '200': + description: | + Webhook received successfully + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/Error400' + '401': + description: Unauthorized - Signature validation failed + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '409': + description: Conflict - Webhook has already been processed (duplicate id) + content: + application/json: + schema: + $ref: '#/components/schemas/Error409' verification-update: post: summary: Verification status change @@ -11835,6 +12214,7 @@ components: | STABLECOIN_PROVIDER_ACCOUNT_SELECTION_REQUIRED | Multiple active provider account links exist; pass `stablecoinProviderAccountId` to select one | | CARDHOLDER_KYC_NOT_APPROVED | The cardholder's KYC status is not `APPROVED`, so a card cannot be issued | | TRANSACTION_SIZE_LIMIT_EXCEEDED | The requested amount exceeds the configured maximum single-transaction amount for this trade corridor or withdrawal currency | + | EXTERNAL_ACCOUNT_VERIFICATION_REQUIRED | The destination account's ownership must be verified before this transfer can proceed | enum: - INVALID_INPUT - END_USER_TERMS_VERSION_NOT_FOUND @@ -11878,6 +12258,7 @@ components: - STABLECOIN_PROVIDER_ACCOUNT_SELECTION_REQUIRED - CARDHOLDER_KYC_NOT_APPROVED - TRANSACTION_SIZE_LIMIT_EXCEEDED + - EXTERNAL_ACCOUNT_VERIFICATION_REQUIRED message: type: string description: Error message @@ -16848,15 +17229,24 @@ components: enum: - PENDING - ACTIVE + - PENDING_OWNERSHIP_VERIFICATION + - UNVERIFIED - UNDER_REVIEW - INACTIVE - description: Status of an external account + description: | + Status of an external account. + + `PENDING_OWNERSHIP_VERIFICATION`: the wallet's ownership must be verified + (e.g. under the EU Travel Rule) before unrestricted use; transfers below + regulatory thresholds are still allowed. `UNVERIFIED`: the most recent + verification attempt failed; start a new challenge to retry. Successful + verification moves the account to `ACTIVE`. OwnershipType: type: string enum: - FIRST_PARTY - THIRD_PARTY - description: Whether the external account belongs to the customer themselves (first party) or to someone else (third party) + description: Whether the external account belongs to the customer themselves (`FIRST_PARTY`) or to someone else (`THIRD_PARTY`). Required when creating self-custody crypto wallet external accounts on platforms subject to counterparty requirements — for example, under the EU Travel Rule or similar requirements in other regions; recommended for all other accounts, where providing it can unlock additional capabilities and smoother compliance handling. example: FIRST_PARTY BeneficiaryVerificationStatus: type: string @@ -20150,6 +20540,108 @@ components: default: false accountInfo: $ref: '#/components/schemas/ExternalAccountCreateInfoOneOf' + OwnershipVerificationMethod: + type: string + enum: + - WALLET_SIGNATURE + - LIVENESS + description: | + The method used to verify ownership of a self-custody crypto wallet. + + | Method | Description | + |--------|-------------| + | `WALLET_SIGNATURE` | Prove control of the wallet by signing a message with the wallet's key | + | `LIVENESS` | Prove identity via a hosted biometric verification flow | + example: WALLET_SIGNATURE + OwnershipChallengeRequest: + type: object + description: Starts (or restarts) an ownership verification challenge for a crypto wallet external account. + required: + - method + properties: + method: + $ref: '#/components/schemas/OwnershipVerificationMethod' + description: The verification method to use for this challenge. + WalletSignatureChallenge: + title: Wallet Signature Challenge + type: object + description: A challenge to prove ownership of the wallet by signing a message with the wallet's key. + required: + - method + - messageToSign + - expiresAt + properties: + method: + type: string + enum: + - WALLET_SIGNATURE + description: The verification method. Always `WALLET_SIGNATURE` for this shape. + example: WALLET_SIGNATURE + messageToSign: + type: string + description: The exact message the wallet must sign, character-for-character. Submit the resulting signature via the verify endpoint. + example: 'I verify that I control this wallet. Nonce: 019542f5-b3e7-1d02' + expiresAt: + type: string + format: date-time + description: When this challenge expires. Prompt the user promptly; after expiry, start a new challenge. + example: '2025-08-15T15:32:00Z' + LivenessChallenge: + title: Liveness Challenge + type: object + description: A challenge to prove ownership through a hosted biometric verification flow. Completes asynchronously — the outcome is delivered via `EXTERNAL_ACCOUNT.STATUS_UPDATED` webhooks or by polling the external account. + required: + - method + - verificationLink + - expiresAt + properties: + method: + type: string + enum: + - LIVENESS + description: The verification method. Always `LIVENESS` for this shape. + example: LIVENESS + verificationLink: + type: string + format: uri + description: Hosted verification URL to present to the user. + example: https://verify.example.com/session/019542f5-b3e7-1d02 + token: + type: string + description: Provider-specific token that can be used in place of `verificationLink` — for example, to embed the provider's SDK directly in your application. Only returned for providers that support direct SDK integration. Whether to use the hosted URL or the embedded SDK is up to you; both flows result in the same verification outcome. + example: eyJhbGciOiJIUzI1NiJ9.example + expiresAt: + type: string + format: date-time + description: When this challenge expires. Prompt the user promptly; after expiry, start a new challenge. + example: '2025-08-15T15:32:00Z' + OwnershipChallenge: + description: An ownership verification challenge for a crypto wallet external account. The shape is determined by the challenge `method`. + oneOf: + - $ref: '#/components/schemas/WalletSignatureChallenge' + - $ref: '#/components/schemas/LivenessChallenge' + discriminator: + propertyName: method + mapping: + WALLET_SIGNATURE: '#/components/schemas/WalletSignatureChallenge' + LIVENESS: '#/components/schemas/LivenessChallenge' + OwnershipVerifyRequest: + type: object + description: Completes a `WALLET_SIGNATURE` challenge by submitting the signature the wallet produced for the challenge's `messageToSign`. + required: + - signature + properties: + signature: + type: string + description: The signature produced over the exact `messageToSign` — EIP-191 hex for EVM chains, base64 for Bitcoin, base58-encoded Ed25519 for Solana. + example: '0x52d75f01c9e7b8b2ce2fbcbd21bfeeee7bcd1a2f01ce6b8ad9a67a45e83a8f5d1c' + signatureScheme: + type: string + enum: + - bip137 + - electrum + default: bip137 + description: Bitcoin message-signing format. Defaults to `bip137`; use `electrum` for Electrum/Sparrow wallets. Ignored for non-Bitcoin chains. PlatformExternalAccountCreateRequest: type: object required: @@ -24718,6 +25210,7 @@ components: - VERIFICATION.IN_PROGRESS - VERIFICATION.PENDING_MANUAL_REVIEW - VERIFICATION.READY_FOR_VERIFICATION + - EXTERNAL_ACCOUNT.STATUS_UPDATED - INTERNAL_ACCOUNT.BALANCE_UPDATED - INTERNAL_ACCOUNT.STATUS_UPDATED - INVITATION.CLAIMED @@ -24912,6 +25405,19 @@ components: enum: - INTERNAL_ACCOUNT.BALANCE_UPDATED - INTERNAL_ACCOUNT.STATUS_UPDATED + ExternalAccountStatusWebhook: + allOf: + - $ref: '#/components/schemas/BaseWebhook' + - type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/ExternalAccount' + type: + type: string + enum: + - EXTERNAL_ACCOUNT.STATUS_UPDATED VerificationWebhook: allOf: - $ref: '#/components/schemas/BaseWebhook' diff --git a/openapi.yaml b/openapi.yaml index 6e4523f11..7a72ab91d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2429,6 +2429,154 @@ paths: application/json: schema: $ref: '#/components/schemas/Error500' + /customers/external-accounts/{externalAccountId}/challenge: + post: + summary: Start an ownership verification challenge + description: | + Start (or restart) ownership verification for a `FIRST_PARTY` self-custody + crypto wallet external account in `PENDING_OWNERSHIP_VERIFICATION` or + `UNVERIFIED` status. The response carries the method-specific challenge + material: + + - `WALLET_SIGNATURE` — a `messageToSign`; have the wallet sign it exactly + and submit the result to the verify endpoint to complete verification + synchronously. + - `LIVENESS` — a hosted `verificationLink` (and possibly an embed + `token`); the user completes a biometric flow and verification completes + 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. + + Completing ownership verification moves the account to `ACTIVE`. + operationId: createExternalAccountOwnershipChallenge + tags: + - External Accounts + security: + - BasicAuth: [] + parameters: + - name: externalAccountId + in: path + description: External account ID + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OwnershipChallengeRequest' + responses: + '201': + description: Challenge created; the method-specific material is returned. + content: + application/json: + schema: + $ref: '#/components/schemas/OwnershipChallenge' + '400': + description: Bad request - Invalid parameters + content: + application/json: + schema: + $ref: '#/components/schemas/Error400' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '404': + description: External account not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error404' + '409': + description: Ownership verification is not applicable to this external account (not a self-custody crypto wallet, not `FIRST_PARTY`, or already verified). + content: + application/json: + schema: + $ref: '#/components/schemas/Error409' + '500': + description: Internal service error + content: + application/json: + schema: + $ref: '#/components/schemas/Error500' + /customers/external-accounts/{externalAccountId}/verify: + post: + summary: Verify ownership with a wallet signature + description: | + Complete a `WALLET_SIGNATURE` challenge by submitting the signature the + wallet produced for the challenge's `messageToSign`. The message must be + signed exactly as returned, and the signature must be submitted before + the challenge's `expiresAt` — after expiry, start a new challenge. + + On success the account moves to `ACTIVE`; on an invalid signature it + moves to `UNVERIFIED` (start a new challenge to retry). `LIVENESS` + challenges complete asynchronously and never use this endpoint — their + outcome is delivered via `EXTERNAL_ACCOUNT.STATUS_UPDATED` webhooks or by + polling the account. + operationId: verifyExternalAccountOwnership + tags: + - External Accounts + security: + - BasicAuth: [] + parameters: + - name: externalAccountId + in: path + description: External account ID + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OwnershipVerifyRequest' + responses: + '200': + description: 'Signature valid; the updated external account is returned with `status: ACTIVE`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ExternalAccount' + '400': + description: Invalid or expired signature. The account moves to `UNVERIFIED`; start a new challenge to retry. + content: + application/json: + schema: + $ref: '#/components/schemas/Error400' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '404': + description: External account not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error404' + '409': + description: No `WALLET_SIGNATURE` challenge is outstanding for this external account. + content: + application/json: + schema: + $ref: '#/components/schemas/Error409' + '500': + description: Internal service error + content: + application/json: + schema: + $ref: '#/components/schemas/Error500' /platform/external-accounts: get: summary: List platform external accounts @@ -2633,6 +2781,154 @@ paths: application/json: schema: $ref: '#/components/schemas/Error500' + /platform/external-accounts/{externalAccountId}/challenge: + post: + summary: Start an ownership verification challenge + description: | + Start (or restart) ownership verification for a `FIRST_PARTY` self-custody + crypto wallet external account in `PENDING_OWNERSHIP_VERIFICATION` or + `UNVERIFIED` status. The response carries the method-specific challenge + material: + + - `WALLET_SIGNATURE` — a `messageToSign`; have the wallet sign it exactly + and submit the result to the verify endpoint to complete verification + synchronously. + - `LIVENESS` — a hosted `verificationLink` (and possibly an embed + `token`); the user completes a biometric flow and verification completes + 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. + + Completing ownership verification moves the account to `ACTIVE`. + operationId: createPlatformExternalAccountOwnershipChallenge + tags: + - External Accounts + security: + - BasicAuth: [] + parameters: + - name: externalAccountId + in: path + description: External account ID + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OwnershipChallengeRequest' + responses: + '201': + description: Challenge created; the method-specific material is returned. + content: + application/json: + schema: + $ref: '#/components/schemas/OwnershipChallenge' + '400': + description: Bad request - Invalid parameters + content: + application/json: + schema: + $ref: '#/components/schemas/Error400' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '404': + description: External account not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error404' + '409': + description: Ownership verification is not applicable to this external account (not a self-custody crypto wallet, not `FIRST_PARTY`, or already verified). + content: + application/json: + schema: + $ref: '#/components/schemas/Error409' + '500': + description: Internal service error + content: + application/json: + schema: + $ref: '#/components/schemas/Error500' + /platform/external-accounts/{externalAccountId}/verify: + post: + summary: Verify ownership with a wallet signature + description: | + Complete a `WALLET_SIGNATURE` challenge by submitting the signature the + wallet produced for the challenge's `messageToSign`. The message must be + signed exactly as returned, and the signature must be submitted before + the challenge's `expiresAt` — after expiry, start a new challenge. + + On success the account moves to `ACTIVE`; on an invalid signature it + moves to `UNVERIFIED` (start a new challenge to retry). `LIVENESS` + challenges complete asynchronously and never use this endpoint — their + outcome is delivered via `EXTERNAL_ACCOUNT.STATUS_UPDATED` webhooks or by + polling the account. + operationId: verifyPlatformExternalAccountOwnership + tags: + - External Accounts + security: + - BasicAuth: [] + parameters: + - name: externalAccountId + in: path + description: External account ID + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OwnershipVerifyRequest' + responses: + '200': + description: 'Signature valid; the updated external account is returned with `status: ACTIVE`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ExternalAccount' + '400': + description: Invalid or expired signature. The account moves to `UNVERIFIED`; start a new challenge to retry. + content: + application/json: + schema: + $ref: '#/components/schemas/Error400' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '404': + description: External account not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error404' + '409': + description: No `WALLET_SIGNATURE` challenge is outstanding for this external account. + content: + application/json: + schema: + $ref: '#/components/schemas/Error409' + '500': + description: Internal service error + content: + application/json: + schema: + $ref: '#/components/schemas/Error500' /beneficial-owners: post: summary: Create a beneficial owner @@ -10743,6 +11039,89 @@ webhooks: application/json: schema: $ref: '#/components/schemas/Error409' + external-account-status: + post: + summary: External account status webhook + description: | + Webhook that is called whenever the status of an external account changes, for any transition between statuses (`PENDING`, `ACTIVE`, `UNDER_REVIEW`, `INACTIVE`, `PENDING_OWNERSHIP_VERIFICATION`, `UNVERIFIED`) — for example when an account under review becomes active, when ownership verification completes (`PENDING_OWNERSHIP_VERIFICATION` → `ACTIVE`), or when a verification attempt fails (`PENDING_OWNERSHIP_VERIFICATION` → `UNVERIFIED`). + This endpoint should be implemented by clients of the Grid API. + + ### Authentication + The webhook includes a signature in the `X-Grid-Signature` header that allows you to verify that the webhook was sent by Grid. + To verify the signature: + 1. Get the Grid public key provided to you during integration + 2. Decode the base64 signature from the header + 3. Create a SHA-256 hash of the request body + 4. Verify the signature using the public key and the hash + + If the signature verification succeeds, the webhook is authentic. If not, it should be rejected. + + ### Event types + - `EXTERNAL_ACCOUNT.STATUS_UPDATED` — Fired when the status of an external account changes. The `data` payload contains the full external account object. + operationId: externalAccountStatusWebhook + tags: + - Webhooks + security: + - WebhookSignature: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ExternalAccountStatusWebhook' + examples: + verificationSucceeded: + summary: A wallet account became active after ownership verification + value: + id: Webhook:019542f5-b3e7-1d02-0000-000000000042 + type: EXTERNAL_ACCOUNT.STATUS_UPDATED + timestamp: '2025-08-15T14:32:00Z' + data: + id: ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965 + customerId: Customer:da459a29-1fb7-41ce-a4cb-eb3a3c9fd7a7 + status: ACTIVE + currency: USDC + ownershipType: FIRST_PARTY + accountInfo: + accountType: ETHEREUM_WALLET + address: '0xAbCDEF1234567890aBCdEf1234567890ABcDef12' + verificationFailed: + summary: A liveness check failed; a new challenge is needed to retry + value: + id: Webhook:019542f5-b3e7-1d02-0000-000000000043 + type: EXTERNAL_ACCOUNT.STATUS_UPDATED + timestamp: '2025-08-15T14:45:00Z' + data: + id: ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965 + customerId: Customer:da459a29-1fb7-41ce-a4cb-eb3a3c9fd7a7 + status: UNVERIFIED + currency: USDC + ownershipType: FIRST_PARTY + accountInfo: + accountType: ETHEREUM_WALLET + address: '0xAbCDEF1234567890aBCdEf1234567890ABcDef12' + responses: + '200': + description: | + Webhook received successfully + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/Error400' + '401': + description: Unauthorized - Signature validation failed + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '409': + description: Conflict - Webhook has already been processed (duplicate id) + content: + application/json: + schema: + $ref: '#/components/schemas/Error409' verification-update: post: summary: Verification status change @@ -11835,6 +12214,7 @@ components: | STABLECOIN_PROVIDER_ACCOUNT_SELECTION_REQUIRED | Multiple active provider account links exist; pass `stablecoinProviderAccountId` to select one | | CARDHOLDER_KYC_NOT_APPROVED | The cardholder's KYC status is not `APPROVED`, so a card cannot be issued | | TRANSACTION_SIZE_LIMIT_EXCEEDED | The requested amount exceeds the configured maximum single-transaction amount for this trade corridor or withdrawal currency | + | EXTERNAL_ACCOUNT_VERIFICATION_REQUIRED | The destination account's ownership must be verified before this transfer can proceed | enum: - INVALID_INPUT - END_USER_TERMS_VERSION_NOT_FOUND @@ -11878,6 +12258,7 @@ components: - STABLECOIN_PROVIDER_ACCOUNT_SELECTION_REQUIRED - CARDHOLDER_KYC_NOT_APPROVED - TRANSACTION_SIZE_LIMIT_EXCEEDED + - EXTERNAL_ACCOUNT_VERIFICATION_REQUIRED message: type: string description: Error message @@ -16848,15 +17229,24 @@ components: enum: - PENDING - ACTIVE + - PENDING_OWNERSHIP_VERIFICATION + - UNVERIFIED - UNDER_REVIEW - INACTIVE - description: Status of an external account + description: | + Status of an external account. + + `PENDING_OWNERSHIP_VERIFICATION`: the wallet's ownership must be verified + (e.g. under the EU Travel Rule) before unrestricted use; transfers below + regulatory thresholds are still allowed. `UNVERIFIED`: the most recent + verification attempt failed; start a new challenge to retry. Successful + verification moves the account to `ACTIVE`. OwnershipType: type: string enum: - FIRST_PARTY - THIRD_PARTY - description: Whether the external account belongs to the customer themselves (first party) or to someone else (third party) + description: Whether the external account belongs to the customer themselves (`FIRST_PARTY`) or to someone else (`THIRD_PARTY`). Required when creating self-custody crypto wallet external accounts on platforms subject to counterparty requirements — for example, under the EU Travel Rule or similar requirements in other regions; recommended for all other accounts, where providing it can unlock additional capabilities and smoother compliance handling. example: FIRST_PARTY BeneficiaryVerificationStatus: type: string @@ -20150,6 +20540,108 @@ components: default: false accountInfo: $ref: '#/components/schemas/ExternalAccountCreateInfoOneOf' + OwnershipVerificationMethod: + type: string + enum: + - WALLET_SIGNATURE + - LIVENESS + description: | + The method used to verify ownership of a self-custody crypto wallet. + + | Method | Description | + |--------|-------------| + | `WALLET_SIGNATURE` | Prove control of the wallet by signing a message with the wallet's key | + | `LIVENESS` | Prove identity via a hosted biometric verification flow | + example: WALLET_SIGNATURE + OwnershipChallengeRequest: + type: object + description: Starts (or restarts) an ownership verification challenge for a crypto wallet external account. + required: + - method + properties: + method: + $ref: '#/components/schemas/OwnershipVerificationMethod' + description: The verification method to use for this challenge. + WalletSignatureChallenge: + title: Wallet Signature Challenge + type: object + description: A challenge to prove ownership of the wallet by signing a message with the wallet's key. + required: + - method + - messageToSign + - expiresAt + properties: + method: + type: string + enum: + - WALLET_SIGNATURE + description: The verification method. Always `WALLET_SIGNATURE` for this shape. + example: WALLET_SIGNATURE + messageToSign: + type: string + description: The exact message the wallet must sign, character-for-character. Submit the resulting signature via the verify endpoint. + example: 'I verify that I control this wallet. Nonce: 019542f5-b3e7-1d02' + expiresAt: + type: string + format: date-time + description: When this challenge expires. Prompt the user promptly; after expiry, start a new challenge. + example: '2025-08-15T15:32:00Z' + LivenessChallenge: + title: Liveness Challenge + type: object + description: A challenge to prove ownership through a hosted biometric verification flow. Completes asynchronously — the outcome is delivered via `EXTERNAL_ACCOUNT.STATUS_UPDATED` webhooks or by polling the external account. + required: + - method + - verificationLink + - expiresAt + properties: + method: + type: string + enum: + - LIVENESS + description: The verification method. Always `LIVENESS` for this shape. + example: LIVENESS + verificationLink: + type: string + format: uri + description: Hosted verification URL to present to the user. + example: https://verify.example.com/session/019542f5-b3e7-1d02 + token: + type: string + description: Provider-specific token that can be used in place of `verificationLink` — for example, to embed the provider's SDK directly in your application. Only returned for providers that support direct SDK integration. Whether to use the hosted URL or the embedded SDK is up to you; both flows result in the same verification outcome. + example: eyJhbGciOiJIUzI1NiJ9.example + expiresAt: + type: string + format: date-time + description: When this challenge expires. Prompt the user promptly; after expiry, start a new challenge. + example: '2025-08-15T15:32:00Z' + OwnershipChallenge: + description: An ownership verification challenge for a crypto wallet external account. The shape is determined by the challenge `method`. + oneOf: + - $ref: '#/components/schemas/WalletSignatureChallenge' + - $ref: '#/components/schemas/LivenessChallenge' + discriminator: + propertyName: method + mapping: + WALLET_SIGNATURE: '#/components/schemas/WalletSignatureChallenge' + LIVENESS: '#/components/schemas/LivenessChallenge' + OwnershipVerifyRequest: + type: object + description: Completes a `WALLET_SIGNATURE` challenge by submitting the signature the wallet produced for the challenge's `messageToSign`. + required: + - signature + properties: + signature: + type: string + description: The signature produced over the exact `messageToSign` — EIP-191 hex for EVM chains, base64 for Bitcoin, base58-encoded Ed25519 for Solana. + example: '0x52d75f01c9e7b8b2ce2fbcbd21bfeeee7bcd1a2f01ce6b8ad9a67a45e83a8f5d1c' + signatureScheme: + type: string + enum: + - bip137 + - electrum + default: bip137 + description: Bitcoin message-signing format. Defaults to `bip137`; use `electrum` for Electrum/Sparrow wallets. Ignored for non-Bitcoin chains. PlatformExternalAccountCreateRequest: type: object required: @@ -24718,6 +25210,7 @@ components: - VERIFICATION.IN_PROGRESS - VERIFICATION.PENDING_MANUAL_REVIEW - VERIFICATION.READY_FOR_VERIFICATION + - EXTERNAL_ACCOUNT.STATUS_UPDATED - INTERNAL_ACCOUNT.BALANCE_UPDATED - INTERNAL_ACCOUNT.STATUS_UPDATED - INVITATION.CLAIMED @@ -24912,6 +25405,19 @@ components: enum: - INTERNAL_ACCOUNT.BALANCE_UPDATED - INTERNAL_ACCOUNT.STATUS_UPDATED + ExternalAccountStatusWebhook: + allOf: + - $ref: '#/components/schemas/BaseWebhook' + - type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/ExternalAccount' + type: + type: string + enum: + - EXTERNAL_ACCOUNT.STATUS_UPDATED VerificationWebhook: allOf: - $ref: '#/components/schemas/BaseWebhook' diff --git a/openapi/components/schemas/errors/Error400.yaml b/openapi/components/schemas/errors/Error400.yaml index bbb12f63f..8af97d754 100644 --- a/openapi/components/schemas/errors/Error400.yaml +++ b/openapi/components/schemas/errors/Error400.yaml @@ -56,6 +56,7 @@ properties: | STABLECOIN_PROVIDER_ACCOUNT_SELECTION_REQUIRED | Multiple active provider account links exist; pass `stablecoinProviderAccountId` to select one | | CARDHOLDER_KYC_NOT_APPROVED | The cardholder's KYC status is not `APPROVED`, so a card cannot be issued | | TRANSACTION_SIZE_LIMIT_EXCEEDED | The requested amount exceeds the configured maximum single-transaction amount for this trade corridor or withdrawal currency | + | EXTERNAL_ACCOUNT_VERIFICATION_REQUIRED | The destination account's ownership must be verified before this transfer can proceed | enum: - INVALID_INPUT - END_USER_TERMS_VERSION_NOT_FOUND @@ -99,6 +100,7 @@ properties: - STABLECOIN_PROVIDER_ACCOUNT_SELECTION_REQUIRED - CARDHOLDER_KYC_NOT_APPROVED - TRANSACTION_SIZE_LIMIT_EXCEEDED + - EXTERNAL_ACCOUNT_VERIFICATION_REQUIRED message: type: string description: Error message diff --git a/openapi/components/schemas/external_accounts/ExternalAccountStatus.yaml b/openapi/components/schemas/external_accounts/ExternalAccountStatus.yaml index bc3495857..d4a2bb3a6 100644 --- a/openapi/components/schemas/external_accounts/ExternalAccountStatus.yaml +++ b/openapi/components/schemas/external_accounts/ExternalAccountStatus.yaml @@ -2,6 +2,15 @@ type: string enum: - PENDING - ACTIVE + - PENDING_OWNERSHIP_VERIFICATION + - UNVERIFIED - UNDER_REVIEW - INACTIVE -description: Status of an external account +description: | + Status of an external account. + + `PENDING_OWNERSHIP_VERIFICATION`: the wallet's ownership must be verified + (e.g. under the EU Travel Rule) before unrestricted use; transfers below + regulatory thresholds are still allowed. `UNVERIFIED`: the most recent + verification attempt failed; start a new challenge to retry. Successful + verification moves the account to `ACTIVE`. diff --git a/openapi/components/schemas/external_accounts/LivenessChallenge.yaml b/openapi/components/schemas/external_accounts/LivenessChallenge.yaml new file mode 100644 index 000000000..359a118fb --- /dev/null +++ b/openapi/components/schemas/external_accounts/LivenessChallenge.yaml @@ -0,0 +1,39 @@ +title: Liveness Challenge +type: object +description: >- + A challenge to prove ownership through a hosted biometric verification + flow. Completes asynchronously — the outcome is delivered via + `EXTERNAL_ACCOUNT.STATUS_UPDATED` webhooks or by polling the external + account. +required: + - method + - verificationLink + - expiresAt +properties: + method: + type: string + enum: + - LIVENESS + description: The verification method. Always `LIVENESS` for this shape. + example: LIVENESS + verificationLink: + type: string + format: uri + description: Hosted verification URL to present to the user. + example: https://verify.example.com/session/019542f5-b3e7-1d02 + token: + type: string + description: >- + Provider-specific token that can be used in place of `verificationLink` — + for example, to embed the provider's SDK directly in your application. + Only returned for providers that support direct SDK integration. Whether + to use the hosted URL or the embedded SDK is up to you; both flows result + in the same verification outcome. + example: eyJhbGciOiJIUzI1NiJ9.example + expiresAt: + type: string + format: date-time + description: >- + When this challenge expires. Prompt the user promptly; after expiry, + start a new challenge. + example: '2025-08-15T15:32:00Z' diff --git a/openapi/components/schemas/external_accounts/OwnershipChallenge.yaml b/openapi/components/schemas/external_accounts/OwnershipChallenge.yaml new file mode 100644 index 000000000..534b7b6df --- /dev/null +++ b/openapi/components/schemas/external_accounts/OwnershipChallenge.yaml @@ -0,0 +1,11 @@ +description: >- + An ownership verification challenge for a crypto wallet external account. + The shape is determined by the challenge `method`. +oneOf: + - $ref: ./WalletSignatureChallenge.yaml + - $ref: ./LivenessChallenge.yaml +discriminator: + propertyName: method + mapping: + WALLET_SIGNATURE: ./WalletSignatureChallenge.yaml + LIVENESS: ./LivenessChallenge.yaml diff --git a/openapi/components/schemas/external_accounts/OwnershipChallengeRequest.yaml b/openapi/components/schemas/external_accounts/OwnershipChallengeRequest.yaml new file mode 100644 index 000000000..60b349f2c --- /dev/null +++ b/openapi/components/schemas/external_accounts/OwnershipChallengeRequest.yaml @@ -0,0 +1,10 @@ +type: object +description: >- + Starts (or restarts) an ownership verification challenge for a crypto + wallet external account. +required: + - method +properties: + method: + $ref: ./OwnershipVerificationMethod.yaml + description: The verification method to use for this challenge. diff --git a/openapi/components/schemas/external_accounts/OwnershipType.yaml b/openapi/components/schemas/external_accounts/OwnershipType.yaml index e2b6bce03..41be40af2 100644 --- a/openapi/components/schemas/external_accounts/OwnershipType.yaml +++ b/openapi/components/schemas/external_accounts/OwnershipType.yaml @@ -3,6 +3,11 @@ enum: - FIRST_PARTY - THIRD_PARTY description: >- - Whether the external account belongs to the customer themselves (first party) - or to someone else (third party) + Whether the external account belongs to the customer themselves + (`FIRST_PARTY`) or to someone else (`THIRD_PARTY`). Required when creating + self-custody crypto wallet external accounts on platforms subject to + counterparty requirements — for example, under the EU Travel Rule or + similar requirements in other regions; recommended for all other accounts, + where providing it can unlock additional capabilities and smoother + compliance handling. example: FIRST_PARTY diff --git a/openapi/components/schemas/external_accounts/OwnershipVerificationMethod.yaml b/openapi/components/schemas/external_accounts/OwnershipVerificationMethod.yaml new file mode 100644 index 000000000..6211b5928 --- /dev/null +++ b/openapi/components/schemas/external_accounts/OwnershipVerificationMethod.yaml @@ -0,0 +1,12 @@ +type: string +enum: + - WALLET_SIGNATURE + - LIVENESS +description: | + The method used to verify ownership of a self-custody crypto wallet. + + | Method | Description | + |--------|-------------| + | `WALLET_SIGNATURE` | Prove control of the wallet by signing a message with the wallet's key | + | `LIVENESS` | Prove identity via a hosted biometric verification flow | +example: WALLET_SIGNATURE diff --git a/openapi/components/schemas/external_accounts/OwnershipVerifyRequest.yaml b/openapi/components/schemas/external_accounts/OwnershipVerifyRequest.yaml new file mode 100644 index 000000000..44d48abeb --- /dev/null +++ b/openapi/components/schemas/external_accounts/OwnershipVerifyRequest.yaml @@ -0,0 +1,22 @@ +type: object +description: >- + Completes a `WALLET_SIGNATURE` challenge by submitting the signature the + wallet produced for the challenge's `messageToSign`. +required: + - signature +properties: + signature: + type: string + description: >- + The signature produced over the exact `messageToSign` — EIP-191 hex for + EVM chains, base64 for Bitcoin, base58-encoded Ed25519 for Solana. + example: '0x52d75f01c9e7b8b2ce2fbcbd21bfeeee7bcd1a2f01ce6b8ad9a67a45e83a8f5d1c' + signatureScheme: + type: string + enum: + - bip137 + - electrum + default: bip137 + description: >- + Bitcoin message-signing format. Defaults to `bip137`; use `electrum` for + Electrum/Sparrow wallets. Ignored for non-Bitcoin chains. diff --git a/openapi/components/schemas/external_accounts/WalletSignatureChallenge.yaml b/openapi/components/schemas/external_accounts/WalletSignatureChallenge.yaml new file mode 100644 index 000000000..d6fd7dbe6 --- /dev/null +++ b/openapi/components/schemas/external_accounts/WalletSignatureChallenge.yaml @@ -0,0 +1,29 @@ +title: Wallet Signature Challenge +type: object +description: >- + A challenge to prove ownership of the wallet by signing a message with the + wallet's key. +required: + - method + - messageToSign + - expiresAt +properties: + method: + type: string + enum: + - WALLET_SIGNATURE + description: The verification method. Always `WALLET_SIGNATURE` for this shape. + example: WALLET_SIGNATURE + messageToSign: + type: string + description: >- + The exact message the wallet must sign, character-for-character. Submit + the resulting signature via the verify endpoint. + example: 'I verify that I control this wallet. Nonce: 019542f5-b3e7-1d02' + expiresAt: + type: string + format: date-time + description: >- + When this challenge expires. Prompt the user promptly; after expiry, + start a new challenge. + example: '2025-08-15T15:32:00Z' diff --git a/openapi/components/schemas/webhooks/ExternalAccountStatusWebhook.yaml b/openapi/components/schemas/webhooks/ExternalAccountStatusWebhook.yaml new file mode 100644 index 000000000..6b90db8eb --- /dev/null +++ b/openapi/components/schemas/webhooks/ExternalAccountStatusWebhook.yaml @@ -0,0 +1,12 @@ +allOf: + - $ref: ./BaseWebhook.yaml + - type: object + required: + - data + properties: + data: + $ref: ../external_accounts/ExternalAccount.yaml + type: + type: string + enum: + - EXTERNAL_ACCOUNT.STATUS_UPDATED diff --git a/openapi/components/schemas/webhooks/WebhookType.yaml b/openapi/components/schemas/webhooks/WebhookType.yaml index fab72c524..d27f1b6e2 100644 --- a/openapi/components/schemas/webhooks/WebhookType.yaml +++ b/openapi/components/schemas/webhooks/WebhookType.yaml @@ -26,6 +26,7 @@ enum: - VERIFICATION.IN_PROGRESS - VERIFICATION.PENDING_MANUAL_REVIEW - VERIFICATION.READY_FOR_VERIFICATION + - EXTERNAL_ACCOUNT.STATUS_UPDATED - INTERNAL_ACCOUNT.BALANCE_UPDATED - INTERNAL_ACCOUNT.STATUS_UPDATED - INVITATION.CLAIMED diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index b6143351f..c3dc1e690 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -189,10 +189,18 @@ paths: $ref: paths/customers/customers_external_accounts.yaml /customers/external-accounts/{externalAccountId}: $ref: paths/customers/customers_external_accounts_{externalAccountId}.yaml + /customers/external-accounts/{externalAccountId}/challenge: + $ref: paths/customers/customers_external_accounts_{externalAccountId}_challenge.yaml + /customers/external-accounts/{externalAccountId}/verify: + $ref: paths/customers/customers_external_accounts_{externalAccountId}_verify.yaml /platform/external-accounts: $ref: paths/platform/platform_external_accounts.yaml /platform/external-accounts/{externalAccountId}: $ref: paths/platform/platform_external_accounts_{externalAccountId}.yaml + /platform/external-accounts/{externalAccountId}/challenge: + $ref: paths/platform/platform_external_accounts_{externalAccountId}_challenge.yaml + /platform/external-accounts/{externalAccountId}/verify: + $ref: paths/platform/platform_external_accounts_{externalAccountId}_verify.yaml /beneficial-owners: $ref: paths/beneficial-owners/beneficial_owners.yaml /beneficial-owners/{beneficialOwnerId}: @@ -389,6 +397,8 @@ webhooks: $ref: webhooks/customer-update.yaml internal-account-status: $ref: webhooks/internal-account-status.yaml + external-account-status: + $ref: webhooks/external-account-status.yaml verification-update: $ref: webhooks/verification-update.yaml card-state-change: diff --git a/openapi/paths/customers/customers_external_accounts_{externalAccountId}_challenge.yaml b/openapi/paths/customers/customers_external_accounts_{externalAccountId}_challenge.yaml new file mode 100644 index 000000000..abc37e643 --- /dev/null +++ b/openapi/paths/customers/customers_external_accounts_{externalAccountId}_challenge.yaml @@ -0,0 +1,81 @@ +post: + summary: Start an ownership verification challenge + description: | + Start (or restart) ownership verification for a `FIRST_PARTY` self-custody + crypto wallet external account in `PENDING_OWNERSHIP_VERIFICATION` or + `UNVERIFIED` status. The response carries the method-specific challenge + material: + + - `WALLET_SIGNATURE` — a `messageToSign`; have the wallet sign it exactly + and submit the result to the verify endpoint to complete verification + synchronously. + - `LIVENESS` — a hosted `verificationLink` (and possibly an embed + `token`); the user completes a biometric flow and verification completes + 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. + + Completing ownership verification moves the account to `ACTIVE`. + operationId: createExternalAccountOwnershipChallenge + tags: + - External Accounts + security: + - BasicAuth: [] + parameters: + - name: externalAccountId + in: path + description: External account ID + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: ../../components/schemas/external_accounts/OwnershipChallengeRequest.yaml + responses: + '201': + description: Challenge created; the method-specific material is returned. + content: + application/json: + schema: + $ref: ../../components/schemas/external_accounts/OwnershipChallenge.yaml + '400': + description: Bad request - Invalid parameters + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error400.yaml + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error401.yaml + '404': + description: External account not found + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error404.yaml + '409': + description: >- + Ownership verification is not applicable to this external account + (not a self-custody crypto wallet, not `FIRST_PARTY`, or already + verified). + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error409.yaml + '500': + description: Internal service error + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error500.yaml diff --git a/openapi/paths/customers/customers_external_accounts_{externalAccountId}_verify.yaml b/openapi/paths/customers/customers_external_accounts_{externalAccountId}_verify.yaml new file mode 100644 index 000000000..445e2832f --- /dev/null +++ b/openapi/paths/customers/customers_external_accounts_{externalAccountId}_verify.yaml @@ -0,0 +1,74 @@ +post: + summary: Verify ownership with a wallet signature + description: | + Complete a `WALLET_SIGNATURE` challenge by submitting the signature the + wallet produced for the challenge's `messageToSign`. The message must be + signed exactly as returned, and the signature must be submitted before + the challenge's `expiresAt` — after expiry, start a new challenge. + + On success the account moves to `ACTIVE`; on an invalid signature it + moves to `UNVERIFIED` (start a new challenge to retry). `LIVENESS` + challenges complete asynchronously and never use this endpoint — their + outcome is delivered via `EXTERNAL_ACCOUNT.STATUS_UPDATED` webhooks or by + polling the account. + operationId: verifyExternalAccountOwnership + tags: + - External Accounts + security: + - BasicAuth: [] + parameters: + - name: externalAccountId + in: path + description: External account ID + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: ../../components/schemas/external_accounts/OwnershipVerifyRequest.yaml + responses: + '200': + description: >- + Signature valid; the updated external account is returned with + `status: ACTIVE`. + content: + application/json: + schema: + $ref: ../../components/schemas/external_accounts/ExternalAccount.yaml + '400': + description: >- + Invalid or expired signature. The account moves to `UNVERIFIED`; + start a new challenge to retry. + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error400.yaml + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error401.yaml + '404': + description: External account not found + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error404.yaml + '409': + description: >- + No `WALLET_SIGNATURE` challenge is outstanding for this external + account. + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error409.yaml + '500': + description: Internal service error + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error500.yaml diff --git a/openapi/paths/platform/platform_external_accounts_{externalAccountId}_challenge.yaml b/openapi/paths/platform/platform_external_accounts_{externalAccountId}_challenge.yaml new file mode 100644 index 000000000..532decc60 --- /dev/null +++ b/openapi/paths/platform/platform_external_accounts_{externalAccountId}_challenge.yaml @@ -0,0 +1,81 @@ +post: + summary: Start an ownership verification challenge + description: | + Start (or restart) ownership verification for a `FIRST_PARTY` self-custody + crypto wallet external account in `PENDING_OWNERSHIP_VERIFICATION` or + `UNVERIFIED` status. The response carries the method-specific challenge + material: + + - `WALLET_SIGNATURE` — a `messageToSign`; have the wallet sign it exactly + and submit the result to the verify endpoint to complete verification + synchronously. + - `LIVENESS` — a hosted `verificationLink` (and possibly an embed + `token`); the user completes a biometric flow and verification completes + 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. + + Completing ownership verification moves the account to `ACTIVE`. + operationId: createPlatformExternalAccountOwnershipChallenge + tags: + - External Accounts + security: + - BasicAuth: [] + parameters: + - name: externalAccountId + in: path + description: External account ID + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: ../../components/schemas/external_accounts/OwnershipChallengeRequest.yaml + responses: + '201': + description: Challenge created; the method-specific material is returned. + content: + application/json: + schema: + $ref: ../../components/schemas/external_accounts/OwnershipChallenge.yaml + '400': + description: Bad request - Invalid parameters + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error400.yaml + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error401.yaml + '404': + description: External account not found + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error404.yaml + '409': + description: >- + Ownership verification is not applicable to this external account + (not a self-custody crypto wallet, not `FIRST_PARTY`, or already + verified). + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error409.yaml + '500': + description: Internal service error + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error500.yaml diff --git a/openapi/paths/platform/platform_external_accounts_{externalAccountId}_verify.yaml b/openapi/paths/platform/platform_external_accounts_{externalAccountId}_verify.yaml new file mode 100644 index 000000000..6d151d79e --- /dev/null +++ b/openapi/paths/platform/platform_external_accounts_{externalAccountId}_verify.yaml @@ -0,0 +1,74 @@ +post: + summary: Verify ownership with a wallet signature + description: | + Complete a `WALLET_SIGNATURE` challenge by submitting the signature the + wallet produced for the challenge's `messageToSign`. The message must be + signed exactly as returned, and the signature must be submitted before + the challenge's `expiresAt` — after expiry, start a new challenge. + + On success the account moves to `ACTIVE`; on an invalid signature it + moves to `UNVERIFIED` (start a new challenge to retry). `LIVENESS` + challenges complete asynchronously and never use this endpoint — their + outcome is delivered via `EXTERNAL_ACCOUNT.STATUS_UPDATED` webhooks or by + polling the account. + operationId: verifyPlatformExternalAccountOwnership + tags: + - External Accounts + security: + - BasicAuth: [] + parameters: + - name: externalAccountId + in: path + description: External account ID + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: ../../components/schemas/external_accounts/OwnershipVerifyRequest.yaml + responses: + '200': + description: >- + Signature valid; the updated external account is returned with + `status: ACTIVE`. + content: + application/json: + schema: + $ref: ../../components/schemas/external_accounts/ExternalAccount.yaml + '400': + description: >- + Invalid or expired signature. The account moves to `UNVERIFIED`; + start a new challenge to retry. + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error400.yaml + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error401.yaml + '404': + description: External account not found + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error404.yaml + '409': + description: >- + No `WALLET_SIGNATURE` challenge is outstanding for this external + account. + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error409.yaml + '500': + description: Internal service error + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error500.yaml diff --git a/openapi/webhooks/external-account-status.yaml b/openapi/webhooks/external-account-status.yaml new file mode 100644 index 000000000..d528ec031 --- /dev/null +++ b/openapi/webhooks/external-account-status.yaml @@ -0,0 +1,105 @@ +post: + summary: External account status webhook + description: > + Webhook that is called whenever the status of an external account changes, + for any transition between statuses (`PENDING`, `ACTIVE`, `UNDER_REVIEW`, + `INACTIVE`, `PENDING_OWNERSHIP_VERIFICATION`, `UNVERIFIED`) — for example + when an account under review becomes active, when ownership verification + completes (`PENDING_OWNERSHIP_VERIFICATION` → `ACTIVE`), or when a + verification attempt fails (`PENDING_OWNERSHIP_VERIFICATION` → + `UNVERIFIED`). + + This endpoint should be implemented by clients of the Grid API. + + + ### Authentication + + The webhook includes a signature in the `X-Grid-Signature` header that + allows you to verify that the webhook was sent by Grid. + + To verify the signature: + + 1. Get the Grid public key provided to you during integration + + 2. Decode the base64 signature from the header + + 3. Create a SHA-256 hash of the request body + + 4. Verify the signature using the public key and the hash + + + If the signature verification succeeds, the webhook is authentic. If not, it + should be rejected. + + + ### Event types + + - `EXTERNAL_ACCOUNT.STATUS_UPDATED` — Fired when the status of an external + account changes. The `data` payload contains the full external account + object. + + + operationId: externalAccountStatusWebhook + tags: + - Webhooks + security: + - WebhookSignature: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: ../components/schemas/webhooks/ExternalAccountStatusWebhook.yaml + examples: + verificationSucceeded: + summary: A wallet account became active after ownership verification + value: + id: Webhook:019542f5-b3e7-1d02-0000-000000000042 + type: EXTERNAL_ACCOUNT.STATUS_UPDATED + timestamp: '2025-08-15T14:32:00Z' + data: + id: ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965 + customerId: Customer:da459a29-1fb7-41ce-a4cb-eb3a3c9fd7a7 + status: ACTIVE + currency: USDC + ownershipType: FIRST_PARTY + accountInfo: + accountType: ETHEREUM_WALLET + address: '0xAbCDEF1234567890aBCdEf1234567890ABcDef12' + verificationFailed: + summary: A liveness check failed; a new challenge is needed to retry + value: + id: Webhook:019542f5-b3e7-1d02-0000-000000000043 + type: EXTERNAL_ACCOUNT.STATUS_UPDATED + timestamp: '2025-08-15T14:45:00Z' + data: + id: ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965 + customerId: Customer:da459a29-1fb7-41ce-a4cb-eb3a3c9fd7a7 + status: UNVERIFIED + currency: USDC + ownershipType: FIRST_PARTY + accountInfo: + accountType: ETHEREUM_WALLET + address: '0xAbCDEF1234567890aBCdEf1234567890ABcDef12' + responses: + '200': + description: > + Webhook received successfully + '400': + description: Bad request + content: + application/json: + schema: + $ref: ../components/schemas/errors/Error400.yaml + '401': + description: Unauthorized - Signature validation failed + content: + application/json: + schema: + $ref: ../components/schemas/errors/Error401.yaml + '409': + description: Conflict - Webhook has already been processed (duplicate id) + content: + application/json: + schema: + $ref: ../components/schemas/errors/Error409.yaml