From f2d230ab2fee9b8d0859cf422df1714bbd1d8fbc Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Sun, 7 Jun 2026 23:52:31 -0700 Subject: [PATCH 1/7] feat(sca): add Strong Customer Authentication surface Adds the SCA surface required for EU (Striga) customers while keeping it invisible to every other caller: - New sca/ schemas: ScaFactor, ScaChallenge, ScaAuthorization. - readOnly scaChallenge on Quote and Transaction, present only while status is PENDING_AUTHORIZATION (new enum value on TransactionStatus and Quote.status); omitted entirely for providers that don't require SCA. - Resource-scoped authorize endpoints: POST /quotes/{quoteId}/authorize (returns Quote) and POST /transactions/{transactionId}/authorize (returns Transaction), plus an optional inline scaAuthorization on execute and transfer-out for one-shot completion. - Realtime-funding create-quote returns 202 and withholds paymentInstructions until the challenge is authorized. Design: webdev docs/plans/2026-06-06-striga-sca-design.md Co-Authored-By: Claude Opus 4.8 --- mintlify/openapi.yaml | 258 +++++++++++++++++- openapi.yaml | 258 +++++++++++++++++- .../schemas/quotes/ExecuteQuoteRequest.yaml | 15 + openapi/components/schemas/quotes/Quote.yaml | 16 +- .../schemas/sca/ScaAuthorization.yaml | 21 ++ .../components/schemas/sca/ScaChallenge.yaml | 47 ++++ openapi/components/schemas/sca/ScaFactor.yaml | 13 + .../schemas/transactions/Transaction.yaml | 8 + .../transactions/TransactionStatus.yaml | 2 + .../schemas/transfers/TransferOutRequest.yaml | 9 + openapi/openapi.yaml | 10 + openapi/paths/quotes/quotes.yaml | 12 + .../quotes/quotes_{quoteId}_authorize.yaml | 74 +++++ .../quotes/quotes_{quoteId}_execute.yaml | 17 +- ...ransactions_{transactionId}_authorize.yaml | 76 ++++++ 15 files changed, 827 insertions(+), 9 deletions(-) create mode 100644 openapi/components/schemas/quotes/ExecuteQuoteRequest.yaml create mode 100644 openapi/components/schemas/sca/ScaAuthorization.yaml create mode 100644 openapi/components/schemas/sca/ScaChallenge.yaml create mode 100644 openapi/components/schemas/sca/ScaFactor.yaml create mode 100644 openapi/paths/quotes/quotes_{quoteId}_authorize.yaml create mode 100644 openapi/paths/transactions/transactions_{transactionId}_authorize.yaml diff --git a/mintlify/openapi.yaml b/mintlify/openapi.yaml index 5c510eb9c..4c182b0c1 100644 --- a/mintlify/openapi.yaml +++ b/mintlify/openapi.yaml @@ -23,6 +23,8 @@ tags: description: Customer management endpoints for creating and updating customer information - name: Contact Verification description: Endpoints for verifying a customer's email and phone via one-time codes. Required only for customers whose payment provider mandates contact verification (e.g. EU customers); other providers return 409. + - name: Strong Customer Authentication + description: Endpoints for authorizing money-movement operations that require Strong Customer Authentication. Relevant only for customers whose payment provider mandates SCA (e.g. EU customers); other providers never surface an SCA challenge and these endpoints return 409. - name: KYC/KYB Verifications description: Endpoints for Know Your Customer (KYC) and Know Your Business (KYB) verification, including managing beneficial owners and triggering verification for customers. - name: Documents @@ -2761,6 +2763,13 @@ paths: exchangeRate: 0.92 feesIncluded: 10 transactionId: Transaction:019542f5-b3e7-1d02-0000-000000000005 + '202': + description: | + Quote created but awaiting Strong Customer Authentication. Returned only for customers whose provider requires SCA (e.g. EU) on a realtime-funding source: the quote has status `PENDING_AUTHORIZATION` and carries an `scaChallenge`, and `paymentInstructions` are **withheld** until the challenge is authorized via `POST /quotes/{quoteId}/authorize`. Once authorized, the quote's `paymentInstructions` are populated. + content: + application/json: + schema: + $ref: '#/components/schemas/Quote' '400': description: Bad request - Missing or invalid parameters content: @@ -2843,11 +2852,18 @@ paths: schema: type: string example: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9 + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/ExecuteQuoteRequest' responses: '200': description: | - Quote confirmed successfully. The transfer has been initiated and - the quote status has been updated. + Quote confirmed successfully. The transfer has been initiated and the quote status has been updated. + + For customers whose provider requires Strong Customer Authentication (e.g. EU), the quote may instead come back with status `PENDING_AUTHORIZATION` and an `scaChallenge` that must be authorized — inline via `scaAuthorization`, or by calling `POST /transactions/{transactionId}/authorize` for the quote's `transactionId` — before the transfer is released. content: application/json: schema: @@ -2882,6 +2898,79 @@ paths: application/json: schema: $ref: '#/components/schemas/Error500' + /quotes/{quoteId}/authorize: + parameters: + - name: quoteId + in: path + description: The unique identifier of the quote whose SCA challenge is being authorized. + required: true + schema: + type: string + example: Quote:019542f5-b3e7-1d02-0000-000000000006 + post: + summary: Authorize a quote's SCA challenge + description: | + Satisfy the Strong Customer Authentication challenge carried by a quote in + `PENDING_AUTHORIZATION` status by submitting an `ScaAuthorization` proof. + + This is used for realtime-funding quotes: the quote is returned with an + `scaChallenge` and **without** `paymentInstructions`; once authorized, the + quote advances and its `paymentInstructions` are populated so the customer + can fund the transfer. + + This endpoint is only meaningful for customers whose payment provider + requires SCA (e.g. EU customers). For customers whose provider has no such + requirement, this returns `409`. + + In sandbox, the SMS/TOTP code is always `123456`. + operationId: authorizeQuote + tags: + - Strong Customer Authentication + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ScaAuthorization' + responses: + '200': + description: Challenge authorized; the updated quote is returned. + content: + application/json: + schema: + $ref: '#/components/schemas/Quote' + '400': + description: Invalid or expired authorization proof + content: + application/json: + schema: + $ref: '#/components/schemas/Error400' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '404': + description: Quote not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error404' + '409': + description: The customer's payment provider does not require SCA, or the quote has no pending challenge to authorize. + content: + application/json: + schema: + $ref: '#/components/schemas/Error409' + '500': + description: Internal service error + content: + application/json: + schema: + $ref: '#/components/schemas/Error500' /transactions: get: summary: List transactions @@ -3229,6 +3318,81 @@ paths: application/json: schema: $ref: '#/components/schemas/Error500' + /transactions/{transactionId}/authorize: + parameters: + - name: transactionId + in: path + description: The unique identifier of the transaction whose SCA challenge is being authorized. + required: true + schema: + type: string + example: Transaction:019542f5-b3e7-1d02-0000-000000000005 + post: + summary: Authorize a transaction's SCA challenge + description: | + Satisfy the Strong Customer Authentication challenge carried by a + transaction in `PENDING_AUTHORIZATION` status by submitting an + `ScaAuthorization` proof. On success the transaction advances to + `PROCESSING` (or `COMPLETED`) and the underlying transfer is released. + + Transactions enter `PENDING_AUTHORIZATION` from `execute` (embedded-wallet + quotes) and `transfer-out` when the customer's provider requires SCA. The + same authorization can be supplied inline on those calls via their optional + `scaAuthorization` field instead of calling this endpoint. + + This endpoint is only meaningful for customers whose payment provider + requires SCA (e.g. EU customers). For customers whose provider has no such + requirement, this returns `409`. + + In sandbox, the SMS/TOTP code is always `123456`. + operationId: authorizeTransaction + tags: + - Strong Customer Authentication + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ScaAuthorization' + responses: + '200': + description: Challenge authorized; the updated transaction is returned. + content: + application/json: + schema: + $ref: '#/components/schemas/TransactionOneOf' + '400': + description: Invalid or expired authorization proof + content: + application/json: + schema: + $ref: '#/components/schemas/Error400' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '404': + description: Transaction not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error404' + '409': + description: The customer's payment provider does not require SCA, or the transaction has no pending challenge to authorize. + content: + application/json: + schema: + $ref: '#/components/schemas/Error409' + '500': + description: Internal service error + content: + application/json: + schema: + $ref: '#/components/schemas/Error500' /crypto/estimate-withdrawal-fee: post: summary: Estimate crypto withdrawal fee @@ -16800,6 +16964,7 @@ components: enum: - CREATED - PENDING + - PENDING_AUTHORIZATION - PROCESSING - COMPLETED - REJECTED @@ -16813,6 +16978,7 @@ components: |--------|-------------| | `CREATED` | Initial lookup has been created | | `PENDING` | Quote has been created | + | `PENDING_AUTHORIZATION` | Awaiting Strong Customer Authentication. Only occurs for customers whose provider requires SCA (e.g. EU); authorize the transaction's `scaChallenge` to proceed. | | `PROCESSING` | Funding has been received and payment initiated | | `COMPLETED` | Cross border payment has been received, converted and payment has been sent to the offramp network | | `REJECTED` | Receiving institution or wallet rejected payment, payment has been refunded | @@ -16896,6 +17062,57 @@ components: FULL_NAME: John Sender BIRTH_DATE: '1985-06-15' NATIONALITY: DE + ScaFactor: + type: string + enum: + - SMS_OTP + - TOTP + - PASSKEY + description: | + A Strong Customer Authentication factor. + + | Factor | Description | + |--------|-------------| + | `SMS_OTP` | One-time code sent by SMS to the customer's verified phone. Requires no prior enrollment. | + | `TOTP` | Time-based one-time code from an authenticator app. Requires enrollment. Not valid for per-transaction challenges (cannot carry dynamic linking). | + | `PASSKEY` | WebAuthn passkey assertion. Requires enrollment. | + ScaChallenge: + type: object + description: |- + A Strong Customer Authentication challenge that must be satisfied before a money-movement operation can complete. This object is **only present when the customer's payment provider requires SCA** (e.g. EU customers on an e-money provider); for customers whose provider has no such requirement it is omitted entirely and no action is needed. + + When present on a quote or transaction, authorize it by submitting an `ScaAuthorization` proof to `POST /quotes/{quoteId}/authorize` or `POST /transactions/{transactionId}/authorize`, or inline via the optional `scaAuthorization` field on the originating `execute` / `transfer-out` call. + required: + - id + - expiresAt + - factor + - availableFactors + properties: + id: + type: string + description: Unique identifier for this challenge, supplied back when authorizing. + example: ScaChallenge:019542f5-b3e7-1d02-0000-000000000007 + expiresAt: + type: string + format: date-time + description: Absolute UTC timestamp after which this challenge can no longer be authorized. + example: '2025-10-03T12:05:00Z' + factor: + $ref: '#/components/schemas/ScaFactor' + description: The factor this challenge was issued for. Defaults to `SMS_OTP`. + availableFactors: + type: array + description: The factors the customer may use to satisfy this challenge. + items: + $ref: '#/components/schemas/ScaFactor' + example: + - SMS_OTP + passkeyAssertionOptions: + type: + - object + - 'null' + additionalProperties: true + description: Opaque WebAuthn assertion request options, present only when `factor` is `PASSKEY`. Pass to the device's WebAuthn API to produce the assertion submitted back in `ScaAuthorization.passkeyAssertion`. Transaction: type: object required: @@ -16958,6 +17175,10 @@ components: example: 'Payment for invoice #1234' counterpartyInformation: $ref: '#/components/schemas/CounterpartyInformation' + scaChallenge: + $ref: '#/components/schemas/ScaChallenge' + readOnly: true + description: 'Present only while `status` is `PENDING_AUTHORIZATION`: the Strong Customer Authentication challenge to satisfy before this transaction can proceed. Omitted entirely for customers whose provider does not require SCA.' TransactionSourceType: type: string enum: @@ -17578,6 +17799,22 @@ components: description: The payment rail to use for the transfer. Must be one of the rails supported by the destination account. If not specified, the system will select a default rail. allOf: - $ref: '#/components/schemas/PaymentRail' + ScaAuthorization: + type: object + description: Proof that satisfies an `ScaChallenge`. Provide exactly one of `code` (for `SMS_OTP` / `TOTP`) or `passkeyAssertion` (for `PASSKEY`). + properties: + code: + type: + - string + - 'null' + description: The one-time code the customer received by SMS, or read from their authenticator app. In sandbox, the code is always `123456`. + example: '123456' + passkeyAssertion: + type: + - object + - 'null' + additionalProperties: true + description: Opaque WebAuthn assertion produced by the device from the challenge's `passkeyAssertionOptions`. Required when satisfying a `PASSKEY` challenge. TransferOutRequest: type: object required: @@ -17600,6 +17837,9 @@ components: maxLength: 80 description: 'Free-form information about the payment that travels with it to the recipient. The field this populates depends on the payment rail: for ACH it populates the Addenda record, for FedNow and RTP it populates the remittanceInformation field, and for wires it populates the OBI (Originator to Beneficiary Information) / beneficiary information.' example: '12345' + scaAuthorization: + $ref: '#/components/schemas/ScaAuthorization' + description: 'Optional inline Strong Customer Authentication proof. Only relevant for customers whose provider requires SCA (e.g. EU): supply this to satisfy the challenge in the same call once the customer has the code/assertion. Omit it on the first call to receive the transaction in `PENDING_AUTHORIZATION` with an `scaChallenge`, then authorize separately. Ignored for customers whose provider does not require SCA.' CurrencyPreference: type: object required: @@ -17859,11 +18099,12 @@ components: type: string enum: - PENDING + - PENDING_AUTHORIZATION - PROCESSING - COMPLETED - FAILED - EXPIRED - description: Current status of the quote + description: 'Current status of the quote. `PENDING_AUTHORIZATION` occurs only for customers whose provider requires Strong Customer Authentication (e.g. EU): the quote carries an `scaChallenge` that must be authorized before execution, and for realtime-funding sources `paymentInstructions` are withheld until it is satisfied.' example: PENDING createdAt: type: string @@ -17938,6 +18179,10 @@ components: rateDetails: $ref: '#/components/schemas/OutgoingRateDetails' description: Details about the rate and fees for the transaction. + scaChallenge: + $ref: '#/components/schemas/ScaChallenge' + readOnly: true + description: 'Present only while `status` is `PENDING_AUTHORIZATION`: the Strong Customer Authentication challenge to satisfy before this quote can be executed (or, for realtime-funding sources, before `paymentInstructions` are issued). Omitted for customers whose provider does not require SCA.' QuoteLockSide: type: string enum: @@ -18011,6 +18256,13 @@ components: example: FULL_NAME: Jane Receiver NATIONALITY: FR + ExecuteQuoteRequest: + type: object + description: Optional body for executing a quote. Only needed to supply an inline Strong Customer Authentication proof; omit the body entirely for quotes that do not require SCA. + properties: + scaAuthorization: + $ref: '#/components/schemas/ScaAuthorization' + description: 'Optional inline Strong Customer Authentication proof. Only relevant for customers whose provider requires SCA (e.g. EU): supply this to satisfy the challenge in the same call once the customer has the code/assertion. Omit it to receive the quote in `PENDING_AUTHORIZATION` with an `scaChallenge`, then authorize separately. Ignored for customers whose provider does not require SCA.' TransactionListResponse: type: object required: diff --git a/openapi.yaml b/openapi.yaml index 5c510eb9c..4c182b0c1 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -23,6 +23,8 @@ tags: description: Customer management endpoints for creating and updating customer information - name: Contact Verification description: Endpoints for verifying a customer's email and phone via one-time codes. Required only for customers whose payment provider mandates contact verification (e.g. EU customers); other providers return 409. + - name: Strong Customer Authentication + description: Endpoints for authorizing money-movement operations that require Strong Customer Authentication. Relevant only for customers whose payment provider mandates SCA (e.g. EU customers); other providers never surface an SCA challenge and these endpoints return 409. - name: KYC/KYB Verifications description: Endpoints for Know Your Customer (KYC) and Know Your Business (KYB) verification, including managing beneficial owners and triggering verification for customers. - name: Documents @@ -2761,6 +2763,13 @@ paths: exchangeRate: 0.92 feesIncluded: 10 transactionId: Transaction:019542f5-b3e7-1d02-0000-000000000005 + '202': + description: | + Quote created but awaiting Strong Customer Authentication. Returned only for customers whose provider requires SCA (e.g. EU) on a realtime-funding source: the quote has status `PENDING_AUTHORIZATION` and carries an `scaChallenge`, and `paymentInstructions` are **withheld** until the challenge is authorized via `POST /quotes/{quoteId}/authorize`. Once authorized, the quote's `paymentInstructions` are populated. + content: + application/json: + schema: + $ref: '#/components/schemas/Quote' '400': description: Bad request - Missing or invalid parameters content: @@ -2843,11 +2852,18 @@ paths: schema: type: string example: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9 + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/ExecuteQuoteRequest' responses: '200': description: | - Quote confirmed successfully. The transfer has been initiated and - the quote status has been updated. + Quote confirmed successfully. The transfer has been initiated and the quote status has been updated. + + For customers whose provider requires Strong Customer Authentication (e.g. EU), the quote may instead come back with status `PENDING_AUTHORIZATION` and an `scaChallenge` that must be authorized — inline via `scaAuthorization`, or by calling `POST /transactions/{transactionId}/authorize` for the quote's `transactionId` — before the transfer is released. content: application/json: schema: @@ -2882,6 +2898,79 @@ paths: application/json: schema: $ref: '#/components/schemas/Error500' + /quotes/{quoteId}/authorize: + parameters: + - name: quoteId + in: path + description: The unique identifier of the quote whose SCA challenge is being authorized. + required: true + schema: + type: string + example: Quote:019542f5-b3e7-1d02-0000-000000000006 + post: + summary: Authorize a quote's SCA challenge + description: | + Satisfy the Strong Customer Authentication challenge carried by a quote in + `PENDING_AUTHORIZATION` status by submitting an `ScaAuthorization` proof. + + This is used for realtime-funding quotes: the quote is returned with an + `scaChallenge` and **without** `paymentInstructions`; once authorized, the + quote advances and its `paymentInstructions` are populated so the customer + can fund the transfer. + + This endpoint is only meaningful for customers whose payment provider + requires SCA (e.g. EU customers). For customers whose provider has no such + requirement, this returns `409`. + + In sandbox, the SMS/TOTP code is always `123456`. + operationId: authorizeQuote + tags: + - Strong Customer Authentication + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ScaAuthorization' + responses: + '200': + description: Challenge authorized; the updated quote is returned. + content: + application/json: + schema: + $ref: '#/components/schemas/Quote' + '400': + description: Invalid or expired authorization proof + content: + application/json: + schema: + $ref: '#/components/schemas/Error400' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '404': + description: Quote not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error404' + '409': + description: The customer's payment provider does not require SCA, or the quote has no pending challenge to authorize. + content: + application/json: + schema: + $ref: '#/components/schemas/Error409' + '500': + description: Internal service error + content: + application/json: + schema: + $ref: '#/components/schemas/Error500' /transactions: get: summary: List transactions @@ -3229,6 +3318,81 @@ paths: application/json: schema: $ref: '#/components/schemas/Error500' + /transactions/{transactionId}/authorize: + parameters: + - name: transactionId + in: path + description: The unique identifier of the transaction whose SCA challenge is being authorized. + required: true + schema: + type: string + example: Transaction:019542f5-b3e7-1d02-0000-000000000005 + post: + summary: Authorize a transaction's SCA challenge + description: | + Satisfy the Strong Customer Authentication challenge carried by a + transaction in `PENDING_AUTHORIZATION` status by submitting an + `ScaAuthorization` proof. On success the transaction advances to + `PROCESSING` (or `COMPLETED`) and the underlying transfer is released. + + Transactions enter `PENDING_AUTHORIZATION` from `execute` (embedded-wallet + quotes) and `transfer-out` when the customer's provider requires SCA. The + same authorization can be supplied inline on those calls via their optional + `scaAuthorization` field instead of calling this endpoint. + + This endpoint is only meaningful for customers whose payment provider + requires SCA (e.g. EU customers). For customers whose provider has no such + requirement, this returns `409`. + + In sandbox, the SMS/TOTP code is always `123456`. + operationId: authorizeTransaction + tags: + - Strong Customer Authentication + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ScaAuthorization' + responses: + '200': + description: Challenge authorized; the updated transaction is returned. + content: + application/json: + schema: + $ref: '#/components/schemas/TransactionOneOf' + '400': + description: Invalid or expired authorization proof + content: + application/json: + schema: + $ref: '#/components/schemas/Error400' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '404': + description: Transaction not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error404' + '409': + description: The customer's payment provider does not require SCA, or the transaction has no pending challenge to authorize. + content: + application/json: + schema: + $ref: '#/components/schemas/Error409' + '500': + description: Internal service error + content: + application/json: + schema: + $ref: '#/components/schemas/Error500' /crypto/estimate-withdrawal-fee: post: summary: Estimate crypto withdrawal fee @@ -16800,6 +16964,7 @@ components: enum: - CREATED - PENDING + - PENDING_AUTHORIZATION - PROCESSING - COMPLETED - REJECTED @@ -16813,6 +16978,7 @@ components: |--------|-------------| | `CREATED` | Initial lookup has been created | | `PENDING` | Quote has been created | + | `PENDING_AUTHORIZATION` | Awaiting Strong Customer Authentication. Only occurs for customers whose provider requires SCA (e.g. EU); authorize the transaction's `scaChallenge` to proceed. | | `PROCESSING` | Funding has been received and payment initiated | | `COMPLETED` | Cross border payment has been received, converted and payment has been sent to the offramp network | | `REJECTED` | Receiving institution or wallet rejected payment, payment has been refunded | @@ -16896,6 +17062,57 @@ components: FULL_NAME: John Sender BIRTH_DATE: '1985-06-15' NATIONALITY: DE + ScaFactor: + type: string + enum: + - SMS_OTP + - TOTP + - PASSKEY + description: | + A Strong Customer Authentication factor. + + | Factor | Description | + |--------|-------------| + | `SMS_OTP` | One-time code sent by SMS to the customer's verified phone. Requires no prior enrollment. | + | `TOTP` | Time-based one-time code from an authenticator app. Requires enrollment. Not valid for per-transaction challenges (cannot carry dynamic linking). | + | `PASSKEY` | WebAuthn passkey assertion. Requires enrollment. | + ScaChallenge: + type: object + description: |- + A Strong Customer Authentication challenge that must be satisfied before a money-movement operation can complete. This object is **only present when the customer's payment provider requires SCA** (e.g. EU customers on an e-money provider); for customers whose provider has no such requirement it is omitted entirely and no action is needed. + + When present on a quote or transaction, authorize it by submitting an `ScaAuthorization` proof to `POST /quotes/{quoteId}/authorize` or `POST /transactions/{transactionId}/authorize`, or inline via the optional `scaAuthorization` field on the originating `execute` / `transfer-out` call. + required: + - id + - expiresAt + - factor + - availableFactors + properties: + id: + type: string + description: Unique identifier for this challenge, supplied back when authorizing. + example: ScaChallenge:019542f5-b3e7-1d02-0000-000000000007 + expiresAt: + type: string + format: date-time + description: Absolute UTC timestamp after which this challenge can no longer be authorized. + example: '2025-10-03T12:05:00Z' + factor: + $ref: '#/components/schemas/ScaFactor' + description: The factor this challenge was issued for. Defaults to `SMS_OTP`. + availableFactors: + type: array + description: The factors the customer may use to satisfy this challenge. + items: + $ref: '#/components/schemas/ScaFactor' + example: + - SMS_OTP + passkeyAssertionOptions: + type: + - object + - 'null' + additionalProperties: true + description: Opaque WebAuthn assertion request options, present only when `factor` is `PASSKEY`. Pass to the device's WebAuthn API to produce the assertion submitted back in `ScaAuthorization.passkeyAssertion`. Transaction: type: object required: @@ -16958,6 +17175,10 @@ components: example: 'Payment for invoice #1234' counterpartyInformation: $ref: '#/components/schemas/CounterpartyInformation' + scaChallenge: + $ref: '#/components/schemas/ScaChallenge' + readOnly: true + description: 'Present only while `status` is `PENDING_AUTHORIZATION`: the Strong Customer Authentication challenge to satisfy before this transaction can proceed. Omitted entirely for customers whose provider does not require SCA.' TransactionSourceType: type: string enum: @@ -17578,6 +17799,22 @@ components: description: The payment rail to use for the transfer. Must be one of the rails supported by the destination account. If not specified, the system will select a default rail. allOf: - $ref: '#/components/schemas/PaymentRail' + ScaAuthorization: + type: object + description: Proof that satisfies an `ScaChallenge`. Provide exactly one of `code` (for `SMS_OTP` / `TOTP`) or `passkeyAssertion` (for `PASSKEY`). + properties: + code: + type: + - string + - 'null' + description: The one-time code the customer received by SMS, or read from their authenticator app. In sandbox, the code is always `123456`. + example: '123456' + passkeyAssertion: + type: + - object + - 'null' + additionalProperties: true + description: Opaque WebAuthn assertion produced by the device from the challenge's `passkeyAssertionOptions`. Required when satisfying a `PASSKEY` challenge. TransferOutRequest: type: object required: @@ -17600,6 +17837,9 @@ components: maxLength: 80 description: 'Free-form information about the payment that travels with it to the recipient. The field this populates depends on the payment rail: for ACH it populates the Addenda record, for FedNow and RTP it populates the remittanceInformation field, and for wires it populates the OBI (Originator to Beneficiary Information) / beneficiary information.' example: '12345' + scaAuthorization: + $ref: '#/components/schemas/ScaAuthorization' + description: 'Optional inline Strong Customer Authentication proof. Only relevant for customers whose provider requires SCA (e.g. EU): supply this to satisfy the challenge in the same call once the customer has the code/assertion. Omit it on the first call to receive the transaction in `PENDING_AUTHORIZATION` with an `scaChallenge`, then authorize separately. Ignored for customers whose provider does not require SCA.' CurrencyPreference: type: object required: @@ -17859,11 +18099,12 @@ components: type: string enum: - PENDING + - PENDING_AUTHORIZATION - PROCESSING - COMPLETED - FAILED - EXPIRED - description: Current status of the quote + description: 'Current status of the quote. `PENDING_AUTHORIZATION` occurs only for customers whose provider requires Strong Customer Authentication (e.g. EU): the quote carries an `scaChallenge` that must be authorized before execution, and for realtime-funding sources `paymentInstructions` are withheld until it is satisfied.' example: PENDING createdAt: type: string @@ -17938,6 +18179,10 @@ components: rateDetails: $ref: '#/components/schemas/OutgoingRateDetails' description: Details about the rate and fees for the transaction. + scaChallenge: + $ref: '#/components/schemas/ScaChallenge' + readOnly: true + description: 'Present only while `status` is `PENDING_AUTHORIZATION`: the Strong Customer Authentication challenge to satisfy before this quote can be executed (or, for realtime-funding sources, before `paymentInstructions` are issued). Omitted for customers whose provider does not require SCA.' QuoteLockSide: type: string enum: @@ -18011,6 +18256,13 @@ components: example: FULL_NAME: Jane Receiver NATIONALITY: FR + ExecuteQuoteRequest: + type: object + description: Optional body for executing a quote. Only needed to supply an inline Strong Customer Authentication proof; omit the body entirely for quotes that do not require SCA. + properties: + scaAuthorization: + $ref: '#/components/schemas/ScaAuthorization' + description: 'Optional inline Strong Customer Authentication proof. Only relevant for customers whose provider requires SCA (e.g. EU): supply this to satisfy the challenge in the same call once the customer has the code/assertion. Omit it to receive the quote in `PENDING_AUTHORIZATION` with an `scaChallenge`, then authorize separately. Ignored for customers whose provider does not require SCA.' TransactionListResponse: type: object required: diff --git a/openapi/components/schemas/quotes/ExecuteQuoteRequest.yaml b/openapi/components/schemas/quotes/ExecuteQuoteRequest.yaml new file mode 100644 index 000000000..b21335027 --- /dev/null +++ b/openapi/components/schemas/quotes/ExecuteQuoteRequest.yaml @@ -0,0 +1,15 @@ +type: object +description: >- + Optional body for executing a quote. Only needed to supply an inline Strong + Customer Authentication proof; omit the body entirely for quotes that do not + require SCA. +properties: + scaAuthorization: + $ref: ../sca/ScaAuthorization.yaml + description: >- + Optional inline Strong Customer Authentication proof. Only relevant for + customers whose provider requires SCA (e.g. EU): supply this to satisfy + the challenge in the same call once the customer has the code/assertion. + Omit it to receive the quote in `PENDING_AUTHORIZATION` with an + `scaChallenge`, then authorize separately. Ignored for customers whose + provider does not require SCA. diff --git a/openapi/components/schemas/quotes/Quote.yaml b/openapi/components/schemas/quotes/Quote.yaml index aee147bbc..23b52f374 100644 --- a/openapi/components/schemas/quotes/Quote.yaml +++ b/openapi/components/schemas/quotes/Quote.yaml @@ -22,11 +22,17 @@ properties: type: string enum: - PENDING + - PENDING_AUTHORIZATION - PROCESSING - COMPLETED - FAILED - EXPIRED - description: Current status of the quote + description: >- + Current status of the quote. `PENDING_AUTHORIZATION` occurs only for + customers whose provider requires Strong Customer Authentication (e.g. + EU): the quote carries an `scaChallenge` that must be authorized before + execution, and for realtime-funding sources `paymentInstructions` are + withheld until it is satisfied. example: PENDING createdAt: type: string @@ -120,3 +126,11 @@ properties: rateDetails: $ref: ../transactions/OutgoingRateDetails.yaml description: Details about the rate and fees for the transaction. + scaChallenge: + $ref: ../sca/ScaChallenge.yaml + readOnly: true + description: >- + Present only while `status` is `PENDING_AUTHORIZATION`: the Strong + Customer Authentication challenge to satisfy before this quote can be + executed (or, for realtime-funding sources, before `paymentInstructions` + are issued). Omitted for customers whose provider does not require SCA. diff --git a/openapi/components/schemas/sca/ScaAuthorization.yaml b/openapi/components/schemas/sca/ScaAuthorization.yaml new file mode 100644 index 000000000..e51dbc1bd --- /dev/null +++ b/openapi/components/schemas/sca/ScaAuthorization.yaml @@ -0,0 +1,21 @@ +type: object +description: >- + Proof that satisfies an `ScaChallenge`. Provide exactly one of `code` (for + `SMS_OTP` / `TOTP`) or `passkeyAssertion` (for `PASSKEY`). +properties: + code: + type: + - string + - 'null' + description: >- + The one-time code the customer received by SMS, or read from their + authenticator app. In sandbox, the code is always `123456`. + example: '123456' + passkeyAssertion: + type: + - object + - 'null' + additionalProperties: true + description: >- + Opaque WebAuthn assertion produced by the device from the challenge's + `passkeyAssertionOptions`. Required when satisfying a `PASSKEY` challenge. diff --git a/openapi/components/schemas/sca/ScaChallenge.yaml b/openapi/components/schemas/sca/ScaChallenge.yaml new file mode 100644 index 000000000..dc822cd6f --- /dev/null +++ b/openapi/components/schemas/sca/ScaChallenge.yaml @@ -0,0 +1,47 @@ +type: object +description: >- + A Strong Customer Authentication challenge that must be satisfied before a + money-movement operation can complete. This object is **only present when the + customer's payment provider requires SCA** (e.g. EU customers on an e-money + provider); for customers whose provider has no such requirement it is omitted + entirely and no action is needed. + + + When present on a quote or transaction, authorize it by submitting an + `ScaAuthorization` proof to `POST /quotes/{quoteId}/authorize` or + `POST /transactions/{transactionId}/authorize`, or inline via the optional + `scaAuthorization` field on the originating `execute` / `transfer-out` call. +required: + - id + - expiresAt + - factor + - availableFactors +properties: + id: + type: string + description: Unique identifier for this challenge, supplied back when authorizing. + example: ScaChallenge:019542f5-b3e7-1d02-0000-000000000007 + expiresAt: + type: string + format: date-time + description: Absolute UTC timestamp after which this challenge can no longer be authorized. + example: '2025-10-03T12:05:00Z' + factor: + $ref: ./ScaFactor.yaml + description: The factor this challenge was issued for. Defaults to `SMS_OTP`. + availableFactors: + type: array + description: The factors the customer may use to satisfy this challenge. + items: + $ref: ./ScaFactor.yaml + example: + - SMS_OTP + passkeyAssertionOptions: + type: + - object + - 'null' + additionalProperties: true + description: >- + Opaque WebAuthn assertion request options, present only when `factor` is + `PASSKEY`. Pass to the device's WebAuthn API to produce the assertion + submitted back in `ScaAuthorization.passkeyAssertion`. diff --git a/openapi/components/schemas/sca/ScaFactor.yaml b/openapi/components/schemas/sca/ScaFactor.yaml new file mode 100644 index 000000000..2e48b92bf --- /dev/null +++ b/openapi/components/schemas/sca/ScaFactor.yaml @@ -0,0 +1,13 @@ +type: string +enum: + - SMS_OTP + - TOTP + - PASSKEY +description: | + A Strong Customer Authentication factor. + + | Factor | Description | + |--------|-------------| + | `SMS_OTP` | One-time code sent by SMS to the customer's verified phone. Requires no prior enrollment. | + | `TOTP` | Time-based one-time code from an authenticator app. Requires enrollment. Not valid for per-transaction challenges (cannot carry dynamic linking). | + | `PASSKEY` | WebAuthn passkey assertion. Requires enrollment. | diff --git a/openapi/components/schemas/transactions/Transaction.yaml b/openapi/components/schemas/transactions/Transaction.yaml index ebc3ce889..9ace65c0c 100644 --- a/openapi/components/schemas/transactions/Transaction.yaml +++ b/openapi/components/schemas/transactions/Transaction.yaml @@ -65,3 +65,11 @@ properties: example: 'Payment for invoice #1234' counterpartyInformation: $ref: ./CounterpartyInformation.yaml + scaChallenge: + $ref: ../sca/ScaChallenge.yaml + readOnly: true + description: >- + Present only while `status` is `PENDING_AUTHORIZATION`: the Strong + Customer Authentication challenge to satisfy before this transaction can + proceed. Omitted entirely for customers whose provider does not require + SCA. diff --git a/openapi/components/schemas/transactions/TransactionStatus.yaml b/openapi/components/schemas/transactions/TransactionStatus.yaml index e13b4bdb0..19377b812 100644 --- a/openapi/components/schemas/transactions/TransactionStatus.yaml +++ b/openapi/components/schemas/transactions/TransactionStatus.yaml @@ -2,6 +2,7 @@ type: string enum: - CREATED - PENDING + - PENDING_AUTHORIZATION - PROCESSING - COMPLETED - REJECTED @@ -15,6 +16,7 @@ description: | |--------|-------------| | `CREATED` | Initial lookup has been created | | `PENDING` | Quote has been created | + | `PENDING_AUTHORIZATION` | Awaiting Strong Customer Authentication. Only occurs for customers whose provider requires SCA (e.g. EU); authorize the transaction's `scaChallenge` to proceed. | | `PROCESSING` | Funding has been received and payment initiated | | `COMPLETED` | Cross border payment has been received, converted and payment has been sent to the offramp network | | `REJECTED` | Receiving institution or wallet rejected payment, payment has been refunded | diff --git a/openapi/components/schemas/transfers/TransferOutRequest.yaml b/openapi/components/schemas/transfers/TransferOutRequest.yaml index 1678b4070..eb5bb2f1a 100644 --- a/openapi/components/schemas/transfers/TransferOutRequest.yaml +++ b/openapi/components/schemas/transfers/TransferOutRequest.yaml @@ -26,3 +26,12 @@ properties: remittanceInformation field, and for wires it populates the OBI (Originator to Beneficiary Information) / beneficiary information. example: '12345' + scaAuthorization: + $ref: ../sca/ScaAuthorization.yaml + description: >- + Optional inline Strong Customer Authentication proof. Only relevant for + customers whose provider requires SCA (e.g. EU): supply this to satisfy + the challenge in the same call once the customer has the code/assertion. + Omit it on the first call to receive the transaction in + `PENDING_AUTHORIZATION` with an `scaChallenge`, then authorize separately. + Ignored for customers whose provider does not require SCA. diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 6f3cc6181..300544efc 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -23,6 +23,12 @@ tags: Endpoints for verifying a customer's email and phone via one-time codes. Required only for customers whose payment provider mandates contact verification (e.g. EU customers); other providers return 409. + - name: Strong Customer Authentication + description: >- + Endpoints for authorizing money-movement operations that require Strong + Customer Authentication. Relevant only for customers whose payment + provider mandates SCA (e.g. EU customers); other providers never surface + an SCA challenge and these endpoints return 409. - name: KYC/KYB Verifications description: >- Endpoints for Know Your Customer (KYC) and Know Your Business (KYB) @@ -187,6 +193,8 @@ paths: $ref: paths/quotes/quotes.yaml /quotes/{quoteId}/execute: $ref: paths/quotes/quotes_{quoteId}_execute.yaml + /quotes/{quoteId}/authorize: + $ref: paths/quotes/quotes_{quoteId}_authorize.yaml /transactions: $ref: paths/transactions/transactions.yaml /transactions/{transactionId}: @@ -197,6 +205,8 @@ paths: $ref: paths/transactions/transactions_{transactionId}_approve.yaml /transactions/{transactionId}/reject: $ref: paths/transactions/transactions_{transactionId}_reject.yaml + /transactions/{transactionId}/authorize: + $ref: paths/transactions/transactions_{transactionId}_authorize.yaml /crypto/estimate-withdrawal-fee: $ref: paths/crypto/crypto_estimate-withdrawal-fee.yaml /sandbox/webhooks/test: diff --git a/openapi/paths/quotes/quotes.yaml b/openapi/paths/quotes/quotes.yaml index 75322de32..651e5573e 100644 --- a/openapi/paths/quotes/quotes.yaml +++ b/openapi/paths/quotes/quotes.yaml @@ -115,6 +115,18 @@ post: exchangeRate: 0.92 feesIncluded: 10 transactionId: Transaction:019542f5-b3e7-1d02-0000-000000000005 + '202': + description: > + Quote created but awaiting Strong Customer Authentication. Returned only + for customers whose provider requires SCA (e.g. EU) on a realtime-funding + source: the quote has status `PENDING_AUTHORIZATION` and carries an + `scaChallenge`, and `paymentInstructions` are **withheld** until the + challenge is authorized via `POST /quotes/{quoteId}/authorize`. Once + authorized, the quote's `paymentInstructions` are populated. + content: + application/json: + schema: + $ref: ../../components/schemas/quotes/Quote.yaml '400': description: Bad request - Missing or invalid parameters content: diff --git a/openapi/paths/quotes/quotes_{quoteId}_authorize.yaml b/openapi/paths/quotes/quotes_{quoteId}_authorize.yaml new file mode 100644 index 000000000..d868f43c0 --- /dev/null +++ b/openapi/paths/quotes/quotes_{quoteId}_authorize.yaml @@ -0,0 +1,74 @@ +parameters: + - name: quoteId + in: path + description: The unique identifier of the quote whose SCA challenge is being authorized. + required: true + schema: + type: string + example: Quote:019542f5-b3e7-1d02-0000-000000000006 +post: + summary: Authorize a quote's SCA challenge + description: | + Satisfy the Strong Customer Authentication challenge carried by a quote in + `PENDING_AUTHORIZATION` status by submitting an `ScaAuthorization` proof. + + This is used for realtime-funding quotes: the quote is returned with an + `scaChallenge` and **without** `paymentInstructions`; once authorized, the + quote advances and its `paymentInstructions` are populated so the customer + can fund the transfer. + + This endpoint is only meaningful for customers whose payment provider + requires SCA (e.g. EU customers). For customers whose provider has no such + requirement, this returns `409`. + + In sandbox, the SMS/TOTP code is always `123456`. + operationId: authorizeQuote + tags: + - Strong Customer Authentication + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: ../../components/schemas/sca/ScaAuthorization.yaml + responses: + '200': + description: Challenge authorized; the updated quote is returned. + content: + application/json: + schema: + $ref: ../../components/schemas/quotes/Quote.yaml + '400': + description: Invalid or expired authorization proof + 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: Quote not found + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error404.yaml + '409': + description: >- + The customer's payment provider does not require SCA, or the quote has + no pending challenge to authorize. + 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/quotes/quotes_{quoteId}_execute.yaml b/openapi/paths/quotes/quotes_{quoteId}_execute.yaml index 27d1bb7a8..dfd714459 100644 --- a/openapi/paths/quotes/quotes_{quoteId}_execute.yaml +++ b/openapi/paths/quotes/quotes_{quoteId}_execute.yaml @@ -50,12 +50,25 @@ post: schema: type: string example: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9 + requestBody: + required: false + content: + application/json: + schema: + $ref: ../../components/schemas/quotes/ExecuteQuoteRequest.yaml responses: '200': description: > - Quote confirmed successfully. The transfer has been initiated and + Quote confirmed successfully. The transfer has been initiated and the + quote status has been updated. - the quote status has been updated. + + For customers whose provider requires Strong Customer Authentication + (e.g. EU), the quote may instead come back with status + `PENDING_AUTHORIZATION` and an `scaChallenge` that must be authorized — + inline via `scaAuthorization`, or by calling + `POST /transactions/{transactionId}/authorize` for the quote's + `transactionId` — before the transfer is released. content: application/json: schema: diff --git a/openapi/paths/transactions/transactions_{transactionId}_authorize.yaml b/openapi/paths/transactions/transactions_{transactionId}_authorize.yaml new file mode 100644 index 000000000..fa7ea394b --- /dev/null +++ b/openapi/paths/transactions/transactions_{transactionId}_authorize.yaml @@ -0,0 +1,76 @@ +parameters: + - name: transactionId + in: path + description: The unique identifier of the transaction whose SCA challenge is being authorized. + required: true + schema: + type: string + example: Transaction:019542f5-b3e7-1d02-0000-000000000005 +post: + summary: Authorize a transaction's SCA challenge + description: | + Satisfy the Strong Customer Authentication challenge carried by a + transaction in `PENDING_AUTHORIZATION` status by submitting an + `ScaAuthorization` proof. On success the transaction advances to + `PROCESSING` (or `COMPLETED`) and the underlying transfer is released. + + Transactions enter `PENDING_AUTHORIZATION` from `execute` (embedded-wallet + quotes) and `transfer-out` when the customer's provider requires SCA. The + same authorization can be supplied inline on those calls via their optional + `scaAuthorization` field instead of calling this endpoint. + + This endpoint is only meaningful for customers whose payment provider + requires SCA (e.g. EU customers). For customers whose provider has no such + requirement, this returns `409`. + + In sandbox, the SMS/TOTP code is always `123456`. + operationId: authorizeTransaction + tags: + - Strong Customer Authentication + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: ../../components/schemas/sca/ScaAuthorization.yaml + responses: + '200': + description: Challenge authorized; the updated transaction is returned. + content: + application/json: + schema: + $ref: ../../components/schemas/transactions/TransactionOneOf.yaml + '400': + description: Invalid or expired authorization proof + 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: Transaction not found + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error404.yaml + '409': + description: >- + The customer's payment provider does not require SCA, or the + transaction has no pending challenge to authorize. + 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 From a181371c6c20210115ab94e6b86b6952f0a82314 Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Mon, 8 Jun 2026 00:06:25 -0700 Subject: [PATCH 2/7] docs(sca): clarify ScaChallenge.id is informational, not supplied back Address Greptile review: the authorize endpoints are resource-scoped, so the server resolves the active challenge from the quote/transaction context. The id is informational, not a field the client passes back in ScaAuthorization. Co-Authored-By: Claude Opus 4.8 --- mintlify/openapi.yaml | 2 +- openapi.yaml | 2 +- openapi/components/schemas/sca/ScaChallenge.yaml | 6 +++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/mintlify/openapi.yaml b/mintlify/openapi.yaml index 4c182b0c1..53b7bca6d 100644 --- a/mintlify/openapi.yaml +++ b/mintlify/openapi.yaml @@ -17090,7 +17090,7 @@ components: properties: id: type: string - description: Unique identifier for this challenge, supplied back when authorizing. + description: Unique identifier for this challenge. The server resolves the active challenge from the quote or transaction being authorized, so this field need not be supplied back; it is informational (e.g. for logging or correlation). example: ScaChallenge:019542f5-b3e7-1d02-0000-000000000007 expiresAt: type: string diff --git a/openapi.yaml b/openapi.yaml index 4c182b0c1..53b7bca6d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -17090,7 +17090,7 @@ components: properties: id: type: string - description: Unique identifier for this challenge, supplied back when authorizing. + description: Unique identifier for this challenge. The server resolves the active challenge from the quote or transaction being authorized, so this field need not be supplied back; it is informational (e.g. for logging or correlation). example: ScaChallenge:019542f5-b3e7-1d02-0000-000000000007 expiresAt: type: string diff --git a/openapi/components/schemas/sca/ScaChallenge.yaml b/openapi/components/schemas/sca/ScaChallenge.yaml index dc822cd6f..a2529ad04 100644 --- a/openapi/components/schemas/sca/ScaChallenge.yaml +++ b/openapi/components/schemas/sca/ScaChallenge.yaml @@ -19,7 +19,11 @@ required: properties: id: type: string - description: Unique identifier for this challenge, supplied back when authorizing. + description: >- + Unique identifier for this challenge. The server resolves the active + challenge from the quote or transaction being authorized, so this field + need not be supplied back; it is informational (e.g. for logging or + correlation). example: ScaChallenge:019542f5-b3e7-1d02-0000-000000000007 expiresAt: type: string From b72ee9d75feed97ff7e90fc82e86bbe35e0d536c Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Mon, 8 Jun 2026 00:19:57 -0700 Subject: [PATCH 3/7] docs(sca): clarify inline scaAuthorization is for the initial execute only Address Greptile review: after a quote returns PENDING_AUTHORIZATION, the only resolution is POST /transactions/{transactionId}/authorize; re-calling execute 409s. scaAuthorization on ExecuteQuoteRequest applies only to the initial call (to avoid the pending state entirely). Co-Authored-By: Claude Opus 4.8 --- mintlify/openapi.yaml | 2 +- openapi.yaml | 2 +- openapi/paths/quotes/quotes_{quoteId}_execute.yaml | 12 +++++++----- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/mintlify/openapi.yaml b/mintlify/openapi.yaml index 53b7bca6d..060ca3d73 100644 --- a/mintlify/openapi.yaml +++ b/mintlify/openapi.yaml @@ -2863,7 +2863,7 @@ paths: description: | Quote confirmed successfully. The transfer has been initiated and the quote status has been updated. - For customers whose provider requires Strong Customer Authentication (e.g. EU), the quote may instead come back with status `PENDING_AUTHORIZATION` and an `scaChallenge` that must be authorized — inline via `scaAuthorization`, or by calling `POST /transactions/{transactionId}/authorize` for the quote's `transactionId` — before the transfer is released. + For customers whose provider requires Strong Customer Authentication (e.g. EU): supplying `scaAuthorization` **upfront on this call** satisfies the challenge in one shot and avoids the pending state. Without it, the quote comes back with status `PENDING_AUTHORIZATION` and an `scaChallenge`; from that point the **only** way to release the transfer is `POST /transactions/{transactionId}/authorize` for the quote's `transactionId` (re-calling `execute` returns 409). The inline `scaAuthorization` field is therefore for the initial call only. content: application/json: schema: diff --git a/openapi.yaml b/openapi.yaml index 53b7bca6d..060ca3d73 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2863,7 +2863,7 @@ paths: description: | Quote confirmed successfully. The transfer has been initiated and the quote status has been updated. - For customers whose provider requires Strong Customer Authentication (e.g. EU), the quote may instead come back with status `PENDING_AUTHORIZATION` and an `scaChallenge` that must be authorized — inline via `scaAuthorization`, or by calling `POST /transactions/{transactionId}/authorize` for the quote's `transactionId` — before the transfer is released. + For customers whose provider requires Strong Customer Authentication (e.g. EU): supplying `scaAuthorization` **upfront on this call** satisfies the challenge in one shot and avoids the pending state. Without it, the quote comes back with status `PENDING_AUTHORIZATION` and an `scaChallenge`; from that point the **only** way to release the transfer is `POST /transactions/{transactionId}/authorize` for the quote's `transactionId` (re-calling `execute` returns 409). The inline `scaAuthorization` field is therefore for the initial call only. content: application/json: schema: diff --git a/openapi/paths/quotes/quotes_{quoteId}_execute.yaml b/openapi/paths/quotes/quotes_{quoteId}_execute.yaml index dfd714459..dd47e7bc6 100644 --- a/openapi/paths/quotes/quotes_{quoteId}_execute.yaml +++ b/openapi/paths/quotes/quotes_{quoteId}_execute.yaml @@ -64,11 +64,13 @@ post: For customers whose provider requires Strong Customer Authentication - (e.g. EU), the quote may instead come back with status - `PENDING_AUTHORIZATION` and an `scaChallenge` that must be authorized — - inline via `scaAuthorization`, or by calling - `POST /transactions/{transactionId}/authorize` for the quote's - `transactionId` — before the transfer is released. + (e.g. EU): supplying `scaAuthorization` **upfront on this call** satisfies + the challenge in one shot and avoids the pending state. Without it, the + quote comes back with status `PENDING_AUTHORIZATION` and an + `scaChallenge`; from that point the **only** way to release the transfer + is `POST /transactions/{transactionId}/authorize` for the quote's + `transactionId` (re-calling `execute` returns 409). The inline + `scaAuthorization` field is therefore for the initial call only. content: application/json: schema: From 736ce4b43627ccd1030edbe95f88c7ca4a05bf2e Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Mon, 8 Jun 2026 00:26:30 -0700 Subject: [PATCH 4/7] docs(sca): document PENDING_AUTHORIZATION outcome on transfer-out 201 Address Greptile review: mirror the execute/create-quote treatment so a transfer-out client has a spec-level signal that the returned transaction may be PENDING_AUTHORIZATION and require POST /transactions/{transactionId}/authorize. Co-Authored-By: Claude Opus 4.8 --- mintlify/openapi.yaml | 5 ++++- openapi.yaml | 5 ++++- openapi/paths/transfers/transfer_out.yaml | 11 ++++++++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/mintlify/openapi.yaml b/mintlify/openapi.yaml index 060ca3d73..5d984ed05 100644 --- a/mintlify/openapi.yaml +++ b/mintlify/openapi.yaml @@ -2426,7 +2426,10 @@ paths: remittanceInformation: '12345' responses: '201': - description: Transfer-out request created successfully + description: | + Transfer-out request created successfully. + + For customers whose provider requires Strong Customer Authentication (e.g. EU): supplying `scaAuthorization` upfront in the request satisfies the challenge in one shot. Without it, the returned transaction comes back with status `PENDING_AUTHORIZATION` and an `scaChallenge`; release the transfer by calling `POST /transactions/{transactionId}/authorize`. For providers that don't require SCA the transaction proceeds as usual. content: application/json: schema: diff --git a/openapi.yaml b/openapi.yaml index 060ca3d73..5d984ed05 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2426,7 +2426,10 @@ paths: remittanceInformation: '12345' responses: '201': - description: Transfer-out request created successfully + description: | + Transfer-out request created successfully. + + For customers whose provider requires Strong Customer Authentication (e.g. EU): supplying `scaAuthorization` upfront in the request satisfies the challenge in one shot. Without it, the returned transaction comes back with status `PENDING_AUTHORIZATION` and an `scaChallenge`; release the transfer by calling `POST /transactions/{transactionId}/authorize`. For providers that don't require SCA the transaction proceeds as usual. content: application/json: schema: diff --git a/openapi/paths/transfers/transfer_out.yaml b/openapi/paths/transfers/transfer_out.yaml index 60b5b36c8..b72a39c36 100644 --- a/openapi/paths/transfers/transfer_out.yaml +++ b/openapi/paths/transfers/transfer_out.yaml @@ -36,7 +36,16 @@ post: remittanceInformation: '12345' responses: '201': - description: Transfer-out request created successfully + description: > + Transfer-out request created successfully. + + + For customers whose provider requires Strong Customer Authentication + (e.g. EU): supplying `scaAuthorization` upfront in the request satisfies + the challenge in one shot. Without it, the returned transaction comes + back with status `PENDING_AUTHORIZATION` and an `scaChallenge`; release + the transfer by calling `POST /transactions/{transactionId}/authorize`. + For providers that don't require SCA the transaction proceeds as usual. content: application/json: schema: From 0e2f4f168c0ccfe3d9ab4b7bbf08b39802460c73 Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Wed, 17 Jun 2026 14:06:28 -0700 Subject: [PATCH 5/7] feat(sca): complete the per-transaction SCA surface (passkey origin + resend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses an independent audit of the SCA surface against Striga's API: - ScaAuthorization gains `origin` — Striga's wallets/transaction/confirm requires the WebAuthn origin alongside passkeyAssertion, and the assertion is origin-bound. Without it a passkey-authorized debit could not be confirmed. - ScaChallenge surfaces `passkeyAllowedOrigins` (and documents that the relying party id lives in passkeyAssertionOptions) so the caller knows which origin to echo back. - New resend endpoints: POST /quotes/{quoteId}/authorize/resend and /transactions/{transactionId}/authorize/resend, mapping to Striga's resend-otp-for-transaction (SMS only; passkey 409). An SMS code that lapses no longer forces abandoning the challenge. - Cross-reference the realtime-funding quote authorize path and the resend endpoint from the execute 200 description. Co-Authored-By: Claude Opus 4.8 --- mintlify/openapi.yaml | 127 +++++++++++++++++- openapi.yaml | 127 +++++++++++++++++- .../schemas/sca/ScaAuthorization.yaml | 14 +- .../components/schemas/sca/ScaChallenge.yaml | 15 ++- openapi/openapi.yaml | 4 + .../quotes_{quoteId}_authorize_resend.yaml | 54 ++++++++ .../quotes/quotes_{quoteId}_execute.yaml | 6 +- ...ions_{transactionId}_authorize_resend.yaml | 56 ++++++++ 8 files changed, 394 insertions(+), 9 deletions(-) create mode 100644 openapi/paths/quotes/quotes_{quoteId}_authorize_resend.yaml create mode 100644 openapi/paths/transactions/transactions_{transactionId}_authorize_resend.yaml diff --git a/mintlify/openapi.yaml b/mintlify/openapi.yaml index 5d984ed05..1ec645947 100644 --- a/mintlify/openapi.yaml +++ b/mintlify/openapi.yaml @@ -2866,7 +2866,7 @@ paths: description: | Quote confirmed successfully. The transfer has been initiated and the quote status has been updated. - For customers whose provider requires Strong Customer Authentication (e.g. EU): supplying `scaAuthorization` **upfront on this call** satisfies the challenge in one shot and avoids the pending state. Without it, the quote comes back with status `PENDING_AUTHORIZATION` and an `scaChallenge`; from that point the **only** way to release the transfer is `POST /transactions/{transactionId}/authorize` for the quote's `transactionId` (re-calling `execute` returns 409). The inline `scaAuthorization` field is therefore for the initial call only. + For customers whose provider requires Strong Customer Authentication (e.g. EU): supplying `scaAuthorization` **upfront on this call** satisfies the challenge in one shot and avoids the pending state. Without it, the quote comes back with status `PENDING_AUTHORIZATION` and an `scaChallenge`; from that point the **only** way to release the transfer is `POST /transactions/{transactionId}/authorize` for the quote's `transactionId` (re-calling `execute` returns 409). The inline `scaAuthorization` field is therefore for the initial call only. (For a realtime-funding quote that returned 202 from `POST /quotes`, authorize the quote itself via `POST /quotes/{quoteId}/authorize` instead.) If an SMS code lapses, re-send it via `POST /transactions/{transactionId}/authorize/resend`. content: application/json: schema: @@ -2974,6 +2974,58 @@ paths: application/json: schema: $ref: '#/components/schemas/Error500' + /quotes/{quoteId}/authorize/resend: + parameters: + - name: quoteId + in: path + description: The unique identifier of the quote whose SCA challenge code should be re-sent. + required: true + schema: + type: string + example: Quote:019542f5-b3e7-1d02-0000-000000000006 + post: + summary: Resend a quote's SCA challenge code + description: | + Re-send the one-time code for a realtime-funding quote in + `PENDING_AUTHORIZATION` status whose `scaChallenge.factor` is `SMS_OTP`. The + existing challenge is reused — no new challenge is issued. + + Only meaningful for customers whose provider requires SCA (e.g. EU); other + providers return 409. `PASSKEY` challenges cannot be re-sent and return 409. + + In sandbox, the code is always `123456`. + operationId: resendQuoteScaCode + tags: + - Strong Customer Authentication + security: + - BasicAuth: [] + responses: + '204': + description: Code re-sent. + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '404': + description: Quote not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error404' + '409': + description: The customer's provider does not require SCA, the quote has no pending SMS challenge, or the challenge uses a factor whose code cannot be re-sent (e.g. passkey). + content: + application/json: + schema: + $ref: '#/components/schemas/Error409' + '500': + description: Internal service error + content: + application/json: + schema: + $ref: '#/components/schemas/Error500' /transactions: get: summary: List transactions @@ -3396,6 +3448,60 @@ paths: application/json: schema: $ref: '#/components/schemas/Error500' + /transactions/{transactionId}/authorize/resend: + parameters: + - name: transactionId + in: path + description: The unique identifier of the transaction whose SCA challenge code should be re-sent. + required: true + schema: + type: string + example: Transaction:019542f5-b3e7-1d02-0000-000000000005 + post: + summary: Resend a transaction's SCA challenge code + description: | + Re-send the one-time code for a transaction in `PENDING_AUTHORIZATION` + status whose `scaChallenge.factor` is `SMS_OTP` (e.g. the customer didn't + receive it, or it expired). The existing challenge is reused — no new + challenge is issued. + + Only meaningful for customers whose provider requires SCA (e.g. EU); other + providers return 409. `PASSKEY` challenges cannot be re-sent (there is no + code to deliver) and return 409. + + In sandbox, the code is always `123456`. + operationId: resendTransactionScaCode + tags: + - Strong Customer Authentication + security: + - BasicAuth: [] + responses: + '204': + description: Code re-sent. + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '404': + description: Transaction not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error404' + '409': + description: The customer's provider does not require SCA, the transaction has no pending SMS challenge, or the challenge uses a factor whose code cannot be re-sent (e.g. passkey). + content: + application/json: + schema: + $ref: '#/components/schemas/Error409' + '500': + description: Internal service error + content: + application/json: + schema: + $ref: '#/components/schemas/Error500' /crypto/estimate-withdrawal-fee: post: summary: Estimate crypto withdrawal fee @@ -17115,7 +17221,16 @@ components: - object - 'null' additionalProperties: true - description: Opaque WebAuthn assertion request options, present only when `factor` is `PASSKEY`. Pass to the device's WebAuthn API to produce the assertion submitted back in `ScaAuthorization.passkeyAssertion`. + description: Opaque WebAuthn assertion request options (including the relying-party id, challenge, and allowed credentials), present only when `factor` is `PASSKEY`. Pass to the device's WebAuthn API to produce the assertion submitted back in `ScaAuthorization.passkeyAssertion`. + passkeyAllowedOrigins: + type: + - array + - 'null' + items: + type: string + description: The origins the WebAuthn ceremony may run against, present only when `factor` is `PASSKEY`. The origin the assertion is produced against must be one of these and must be echoed back as `ScaAuthorization.origin`. + example: + - https://app.example.com Transaction: type: object required: @@ -17804,7 +17919,7 @@ components: - $ref: '#/components/schemas/PaymentRail' ScaAuthorization: type: object - description: Proof that satisfies an `ScaChallenge`. Provide exactly one of `code` (for `SMS_OTP` / `TOTP`) or `passkeyAssertion` (for `PASSKEY`). + description: Proof that satisfies an `ScaChallenge`. Provide exactly one of `code` (for `SMS_OTP` / `TOTP`) or `passkeyAssertion` (for `PASSKEY`). When supplying a `passkeyAssertion`, `origin` is **required** — the WebAuthn assertion is bound to the origin it was produced against, and the provider rejects a passkey confirmation without it. properties: code: type: @@ -17818,6 +17933,12 @@ components: - 'null' additionalProperties: true description: Opaque WebAuthn assertion produced by the device from the challenge's `passkeyAssertionOptions`. Required when satisfying a `PASSKEY` challenge. + origin: + type: + - string + - 'null' + description: The WebAuthn origin the `passkeyAssertion` was produced against (one of the challenge's `passkeyAllowedOrigins`). **Required** alongside `passkeyAssertion`; omit it for the `code` path. + example: https://app.example.com TransferOutRequest: type: object required: diff --git a/openapi.yaml b/openapi.yaml index 5d984ed05..1ec645947 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2866,7 +2866,7 @@ paths: description: | Quote confirmed successfully. The transfer has been initiated and the quote status has been updated. - For customers whose provider requires Strong Customer Authentication (e.g. EU): supplying `scaAuthorization` **upfront on this call** satisfies the challenge in one shot and avoids the pending state. Without it, the quote comes back with status `PENDING_AUTHORIZATION` and an `scaChallenge`; from that point the **only** way to release the transfer is `POST /transactions/{transactionId}/authorize` for the quote's `transactionId` (re-calling `execute` returns 409). The inline `scaAuthorization` field is therefore for the initial call only. + For customers whose provider requires Strong Customer Authentication (e.g. EU): supplying `scaAuthorization` **upfront on this call** satisfies the challenge in one shot and avoids the pending state. Without it, the quote comes back with status `PENDING_AUTHORIZATION` and an `scaChallenge`; from that point the **only** way to release the transfer is `POST /transactions/{transactionId}/authorize` for the quote's `transactionId` (re-calling `execute` returns 409). The inline `scaAuthorization` field is therefore for the initial call only. (For a realtime-funding quote that returned 202 from `POST /quotes`, authorize the quote itself via `POST /quotes/{quoteId}/authorize` instead.) If an SMS code lapses, re-send it via `POST /transactions/{transactionId}/authorize/resend`. content: application/json: schema: @@ -2974,6 +2974,58 @@ paths: application/json: schema: $ref: '#/components/schemas/Error500' + /quotes/{quoteId}/authorize/resend: + parameters: + - name: quoteId + in: path + description: The unique identifier of the quote whose SCA challenge code should be re-sent. + required: true + schema: + type: string + example: Quote:019542f5-b3e7-1d02-0000-000000000006 + post: + summary: Resend a quote's SCA challenge code + description: | + Re-send the one-time code for a realtime-funding quote in + `PENDING_AUTHORIZATION` status whose `scaChallenge.factor` is `SMS_OTP`. The + existing challenge is reused — no new challenge is issued. + + Only meaningful for customers whose provider requires SCA (e.g. EU); other + providers return 409. `PASSKEY` challenges cannot be re-sent and return 409. + + In sandbox, the code is always `123456`. + operationId: resendQuoteScaCode + tags: + - Strong Customer Authentication + security: + - BasicAuth: [] + responses: + '204': + description: Code re-sent. + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '404': + description: Quote not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error404' + '409': + description: The customer's provider does not require SCA, the quote has no pending SMS challenge, or the challenge uses a factor whose code cannot be re-sent (e.g. passkey). + content: + application/json: + schema: + $ref: '#/components/schemas/Error409' + '500': + description: Internal service error + content: + application/json: + schema: + $ref: '#/components/schemas/Error500' /transactions: get: summary: List transactions @@ -3396,6 +3448,60 @@ paths: application/json: schema: $ref: '#/components/schemas/Error500' + /transactions/{transactionId}/authorize/resend: + parameters: + - name: transactionId + in: path + description: The unique identifier of the transaction whose SCA challenge code should be re-sent. + required: true + schema: + type: string + example: Transaction:019542f5-b3e7-1d02-0000-000000000005 + post: + summary: Resend a transaction's SCA challenge code + description: | + Re-send the one-time code for a transaction in `PENDING_AUTHORIZATION` + status whose `scaChallenge.factor` is `SMS_OTP` (e.g. the customer didn't + receive it, or it expired). The existing challenge is reused — no new + challenge is issued. + + Only meaningful for customers whose provider requires SCA (e.g. EU); other + providers return 409. `PASSKEY` challenges cannot be re-sent (there is no + code to deliver) and return 409. + + In sandbox, the code is always `123456`. + operationId: resendTransactionScaCode + tags: + - Strong Customer Authentication + security: + - BasicAuth: [] + responses: + '204': + description: Code re-sent. + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '404': + description: Transaction not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error404' + '409': + description: The customer's provider does not require SCA, the transaction has no pending SMS challenge, or the challenge uses a factor whose code cannot be re-sent (e.g. passkey). + content: + application/json: + schema: + $ref: '#/components/schemas/Error409' + '500': + description: Internal service error + content: + application/json: + schema: + $ref: '#/components/schemas/Error500' /crypto/estimate-withdrawal-fee: post: summary: Estimate crypto withdrawal fee @@ -17115,7 +17221,16 @@ components: - object - 'null' additionalProperties: true - description: Opaque WebAuthn assertion request options, present only when `factor` is `PASSKEY`. Pass to the device's WebAuthn API to produce the assertion submitted back in `ScaAuthorization.passkeyAssertion`. + description: Opaque WebAuthn assertion request options (including the relying-party id, challenge, and allowed credentials), present only when `factor` is `PASSKEY`. Pass to the device's WebAuthn API to produce the assertion submitted back in `ScaAuthorization.passkeyAssertion`. + passkeyAllowedOrigins: + type: + - array + - 'null' + items: + type: string + description: The origins the WebAuthn ceremony may run against, present only when `factor` is `PASSKEY`. The origin the assertion is produced against must be one of these and must be echoed back as `ScaAuthorization.origin`. + example: + - https://app.example.com Transaction: type: object required: @@ -17804,7 +17919,7 @@ components: - $ref: '#/components/schemas/PaymentRail' ScaAuthorization: type: object - description: Proof that satisfies an `ScaChallenge`. Provide exactly one of `code` (for `SMS_OTP` / `TOTP`) or `passkeyAssertion` (for `PASSKEY`). + description: Proof that satisfies an `ScaChallenge`. Provide exactly one of `code` (for `SMS_OTP` / `TOTP`) or `passkeyAssertion` (for `PASSKEY`). When supplying a `passkeyAssertion`, `origin` is **required** — the WebAuthn assertion is bound to the origin it was produced against, and the provider rejects a passkey confirmation without it. properties: code: type: @@ -17818,6 +17933,12 @@ components: - 'null' additionalProperties: true description: Opaque WebAuthn assertion produced by the device from the challenge's `passkeyAssertionOptions`. Required when satisfying a `PASSKEY` challenge. + origin: + type: + - string + - 'null' + description: The WebAuthn origin the `passkeyAssertion` was produced against (one of the challenge's `passkeyAllowedOrigins`). **Required** alongside `passkeyAssertion`; omit it for the `code` path. + example: https://app.example.com TransferOutRequest: type: object required: diff --git a/openapi/components/schemas/sca/ScaAuthorization.yaml b/openapi/components/schemas/sca/ScaAuthorization.yaml index e51dbc1bd..ecf052865 100644 --- a/openapi/components/schemas/sca/ScaAuthorization.yaml +++ b/openapi/components/schemas/sca/ScaAuthorization.yaml @@ -1,7 +1,10 @@ type: object description: >- Proof that satisfies an `ScaChallenge`. Provide exactly one of `code` (for - `SMS_OTP` / `TOTP`) or `passkeyAssertion` (for `PASSKEY`). + `SMS_OTP` / `TOTP`) or `passkeyAssertion` (for `PASSKEY`). When supplying a + `passkeyAssertion`, `origin` is **required** — the WebAuthn assertion is bound + to the origin it was produced against, and the provider rejects a passkey + confirmation without it. properties: code: type: @@ -19,3 +22,12 @@ properties: description: >- Opaque WebAuthn assertion produced by the device from the challenge's `passkeyAssertionOptions`. Required when satisfying a `PASSKEY` challenge. + origin: + type: + - string + - 'null' + description: >- + The WebAuthn origin the `passkeyAssertion` was produced against (one of + the challenge's `passkeyAllowedOrigins`). **Required** alongside + `passkeyAssertion`; omit it for the `code` path. + example: https://app.example.com diff --git a/openapi/components/schemas/sca/ScaChallenge.yaml b/openapi/components/schemas/sca/ScaChallenge.yaml index a2529ad04..023e03a41 100644 --- a/openapi/components/schemas/sca/ScaChallenge.yaml +++ b/openapi/components/schemas/sca/ScaChallenge.yaml @@ -46,6 +46,19 @@ properties: - 'null' additionalProperties: true description: >- - Opaque WebAuthn assertion request options, present only when `factor` is + Opaque WebAuthn assertion request options (including the relying-party id, + challenge, and allowed credentials), present only when `factor` is `PASSKEY`. Pass to the device's WebAuthn API to produce the assertion submitted back in `ScaAuthorization.passkeyAssertion`. + passkeyAllowedOrigins: + type: + - array + - 'null' + items: + type: string + description: >- + The origins the WebAuthn ceremony may run against, present only when + `factor` is `PASSKEY`. The origin the assertion is produced against must + be one of these and must be echoed back as `ScaAuthorization.origin`. + example: + - https://app.example.com diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 300544efc..95a61c7e8 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -195,6 +195,8 @@ paths: $ref: paths/quotes/quotes_{quoteId}_execute.yaml /quotes/{quoteId}/authorize: $ref: paths/quotes/quotes_{quoteId}_authorize.yaml + /quotes/{quoteId}/authorize/resend: + $ref: paths/quotes/quotes_{quoteId}_authorize_resend.yaml /transactions: $ref: paths/transactions/transactions.yaml /transactions/{transactionId}: @@ -207,6 +209,8 @@ paths: $ref: paths/transactions/transactions_{transactionId}_reject.yaml /transactions/{transactionId}/authorize: $ref: paths/transactions/transactions_{transactionId}_authorize.yaml + /transactions/{transactionId}/authorize/resend: + $ref: paths/transactions/transactions_{transactionId}_authorize_resend.yaml /crypto/estimate-withdrawal-fee: $ref: paths/crypto/crypto_estimate-withdrawal-fee.yaml /sandbox/webhooks/test: diff --git a/openapi/paths/quotes/quotes_{quoteId}_authorize_resend.yaml b/openapi/paths/quotes/quotes_{quoteId}_authorize_resend.yaml new file mode 100644 index 000000000..ffd4dc564 --- /dev/null +++ b/openapi/paths/quotes/quotes_{quoteId}_authorize_resend.yaml @@ -0,0 +1,54 @@ +parameters: + - name: quoteId + in: path + description: The unique identifier of the quote whose SCA challenge code should be re-sent. + required: true + schema: + type: string + example: Quote:019542f5-b3e7-1d02-0000-000000000006 +post: + summary: Resend a quote's SCA challenge code + description: | + Re-send the one-time code for a realtime-funding quote in + `PENDING_AUTHORIZATION` status whose `scaChallenge.factor` is `SMS_OTP`. The + existing challenge is reused — no new challenge is issued. + + Only meaningful for customers whose provider requires SCA (e.g. EU); other + providers return 409. `PASSKEY` challenges cannot be re-sent and return 409. + + In sandbox, the code is always `123456`. + operationId: resendQuoteScaCode + tags: + - Strong Customer Authentication + security: + - BasicAuth: [] + responses: + '204': + description: Code re-sent. + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error401.yaml + '404': + description: Quote not found + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error404.yaml + '409': + description: >- + The customer's provider does not require SCA, the quote has no pending + SMS challenge, or the challenge uses a factor whose code cannot be + re-sent (e.g. passkey). + 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/quotes/quotes_{quoteId}_execute.yaml b/openapi/paths/quotes/quotes_{quoteId}_execute.yaml index dd47e7bc6..2c04ab7f1 100644 --- a/openapi/paths/quotes/quotes_{quoteId}_execute.yaml +++ b/openapi/paths/quotes/quotes_{quoteId}_execute.yaml @@ -70,7 +70,11 @@ post: `scaChallenge`; from that point the **only** way to release the transfer is `POST /transactions/{transactionId}/authorize` for the quote's `transactionId` (re-calling `execute` returns 409). The inline - `scaAuthorization` field is therefore for the initial call only. + `scaAuthorization` field is therefore for the initial call only. (For a + realtime-funding quote that returned 202 from `POST /quotes`, authorize + the quote itself via `POST /quotes/{quoteId}/authorize` instead.) If an + SMS code lapses, re-send it via + `POST /transactions/{transactionId}/authorize/resend`. content: application/json: schema: diff --git a/openapi/paths/transactions/transactions_{transactionId}_authorize_resend.yaml b/openapi/paths/transactions/transactions_{transactionId}_authorize_resend.yaml new file mode 100644 index 000000000..a97c9ce2e --- /dev/null +++ b/openapi/paths/transactions/transactions_{transactionId}_authorize_resend.yaml @@ -0,0 +1,56 @@ +parameters: + - name: transactionId + in: path + description: The unique identifier of the transaction whose SCA challenge code should be re-sent. + required: true + schema: + type: string + example: Transaction:019542f5-b3e7-1d02-0000-000000000005 +post: + summary: Resend a transaction's SCA challenge code + description: | + Re-send the one-time code for a transaction in `PENDING_AUTHORIZATION` + status whose `scaChallenge.factor` is `SMS_OTP` (e.g. the customer didn't + receive it, or it expired). The existing challenge is reused — no new + challenge is issued. + + Only meaningful for customers whose provider requires SCA (e.g. EU); other + providers return 409. `PASSKEY` challenges cannot be re-sent (there is no + code to deliver) and return 409. + + In sandbox, the code is always `123456`. + operationId: resendTransactionScaCode + tags: + - Strong Customer Authentication + security: + - BasicAuth: [] + responses: + '204': + description: Code re-sent. + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error401.yaml + '404': + description: Transaction not found + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error404.yaml + '409': + description: >- + The customer's provider does not require SCA, the transaction has no + pending SMS challenge, or the challenge uses a factor whose code cannot + be re-sent (e.g. passkey). + 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 From 3603269558bb32a47f5f6d39d02afb0758e01449 Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Wed, 8 Jul 2026 13:51:38 -0700 Subject: [PATCH 6/7] fix(sca): close design-review gaps in the per-transaction surface - Add PENDING_AUTHORIZATION to OutgoingTransactionStatus. SCA-gated debits are outgoing transactions, whose status is overridden by this enum; without the value, generated clients reject the very responses the feature produces. - Add the optional top-level scaFactor field to ExecuteQuoteRequest and TransferOutRequest. The backend already reads it to pick SMS_OTP (default) vs PASSKEY for the per-transaction challenge; it was undiscoverable in the spec. - Clarify where the passkey origin comes from for a per-transaction challenge: such challenges carry passkeyAssertionOptions but omit passkeyAllowedOrigins, so ScaAuthorization.origin and ScaChallenge.passkeyAllowedOrigins now explain the per-transaction case instead of pointing at an absent field. Co-Authored-By: Claude Opus 4.8 --- mintlify/openapi.yaml | 12 ++++++++++-- openapi.yaml | 12 ++++++++++-- .../schemas/quotes/ExecuteQuoteRequest.yaml | 8 ++++++++ openapi/components/schemas/sca/ScaAuthorization.yaml | 10 +++++++--- openapi/components/schemas/sca/ScaChallenge.yaml | 9 ++++++--- .../transactions/OutgoingTransactionStatus.yaml | 2 ++ .../schemas/transfers/TransferOutRequest.yaml | 8 ++++++++ 7 files changed, 51 insertions(+), 10 deletions(-) diff --git a/mintlify/openapi.yaml b/mintlify/openapi.yaml index 1ec645947..d269878cb 100644 --- a/mintlify/openapi.yaml +++ b/mintlify/openapi.yaml @@ -17228,7 +17228,7 @@ components: - 'null' items: type: string - description: The origins the WebAuthn ceremony may run against, present only when `factor` is `PASSKEY`. The origin the assertion is produced against must be one of these and must be echoed back as `ScaAuthorization.origin`. + description: The origins the WebAuthn ceremony may run against. Populated for enrollment and login passkey challenges; the origin the assertion is produced against must be one of these and echoed back as `ScaAuthorization.origin`. Per-transaction passkey challenges omit this (they carry `passkeyAssertionOptions` only) — see `ScaAuthorization.origin` for how to source the origin in that case. example: - https://app.example.com Transaction: @@ -17511,6 +17511,7 @@ components: type: string enum: - PENDING + - PENDING_AUTHORIZATION - EXPIRED - PROCESSING - COMPLETED @@ -17521,6 +17522,7 @@ components: | Status | Description | |--------|-------------| | `PENDING` | Quote is pending confirmation | + | `PENDING_AUTHORIZATION` | Awaiting Strong Customer Authentication. Only occurs for customers whose provider requires SCA (e.g. EU); authorize the transaction's `scaChallenge` to proceed. | | `EXPIRED` | Quote wasn't executed before expiry window | | `PROCESSING` | Executing the quote after receiving funds | | `COMPLETED` | Payout successfully reached the destination | @@ -17937,7 +17939,7 @@ components: type: - string - 'null' - description: The WebAuthn origin the `passkeyAssertion` was produced against (one of the challenge's `passkeyAllowedOrigins`). **Required** alongside `passkeyAssertion`; omit it for the `code` path. + description: The WebAuthn origin the `passkeyAssertion` was produced against. **Required** alongside `passkeyAssertion`; omit it for the `code` path. When the challenge lists `passkeyAllowedOrigins` (enrollment / login challenges), it must be one of those. A per-transaction passkey challenge carries `passkeyAssertionOptions` but may omit `passkeyAllowedOrigins`; in that case supply the origin your app invoked the WebAuthn API from, which must match the relying party in `passkeyAssertionOptions`. example: https://app.example.com TransferOutRequest: type: object @@ -17961,6 +17963,9 @@ components: maxLength: 80 description: 'Free-form information about the payment that travels with it to the recipient. The field this populates depends on the payment rail: for ACH it populates the Addenda record, for FedNow and RTP it populates the remittanceInformation field, and for wires it populates the OBI (Originator to Beneficiary Information) / beneficiary information.' example: '12345' + scaFactor: + $ref: '#/components/schemas/ScaFactor' + description: Optional preferred factor for the Strong Customer Authentication challenge this call issues. Only relevant for customers whose provider requires SCA (e.g. EU); ignored otherwise. Valid values for a per-transaction challenge are `SMS_OTP` (default) and `PASSKEY` — `TOTP` cannot carry the required dynamic linking and is rejected here. Omit to default to `SMS_OTP`. scaAuthorization: $ref: '#/components/schemas/ScaAuthorization' description: 'Optional inline Strong Customer Authentication proof. Only relevant for customers whose provider requires SCA (e.g. EU): supply this to satisfy the challenge in the same call once the customer has the code/assertion. Omit it on the first call to receive the transaction in `PENDING_AUTHORIZATION` with an `scaChallenge`, then authorize separately. Ignored for customers whose provider does not require SCA.' @@ -18384,6 +18389,9 @@ components: type: object description: Optional body for executing a quote. Only needed to supply an inline Strong Customer Authentication proof; omit the body entirely for quotes that do not require SCA. properties: + scaFactor: + $ref: '#/components/schemas/ScaFactor' + description: Optional preferred factor for the Strong Customer Authentication challenge this call issues. Only relevant for customers whose provider requires SCA (e.g. EU); ignored otherwise. Valid values for a per-transaction challenge are `SMS_OTP` (default) and `PASSKEY` — `TOTP` cannot carry the required dynamic linking and is rejected here. Omit to default to `SMS_OTP`. scaAuthorization: $ref: '#/components/schemas/ScaAuthorization' description: 'Optional inline Strong Customer Authentication proof. Only relevant for customers whose provider requires SCA (e.g. EU): supply this to satisfy the challenge in the same call once the customer has the code/assertion. Omit it to receive the quote in `PENDING_AUTHORIZATION` with an `scaChallenge`, then authorize separately. Ignored for customers whose provider does not require SCA.' diff --git a/openapi.yaml b/openapi.yaml index 1ec645947..d269878cb 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -17228,7 +17228,7 @@ components: - 'null' items: type: string - description: The origins the WebAuthn ceremony may run against, present only when `factor` is `PASSKEY`. The origin the assertion is produced against must be one of these and must be echoed back as `ScaAuthorization.origin`. + description: The origins the WebAuthn ceremony may run against. Populated for enrollment and login passkey challenges; the origin the assertion is produced against must be one of these and echoed back as `ScaAuthorization.origin`. Per-transaction passkey challenges omit this (they carry `passkeyAssertionOptions` only) — see `ScaAuthorization.origin` for how to source the origin in that case. example: - https://app.example.com Transaction: @@ -17511,6 +17511,7 @@ components: type: string enum: - PENDING + - PENDING_AUTHORIZATION - EXPIRED - PROCESSING - COMPLETED @@ -17521,6 +17522,7 @@ components: | Status | Description | |--------|-------------| | `PENDING` | Quote is pending confirmation | + | `PENDING_AUTHORIZATION` | Awaiting Strong Customer Authentication. Only occurs for customers whose provider requires SCA (e.g. EU); authorize the transaction's `scaChallenge` to proceed. | | `EXPIRED` | Quote wasn't executed before expiry window | | `PROCESSING` | Executing the quote after receiving funds | | `COMPLETED` | Payout successfully reached the destination | @@ -17937,7 +17939,7 @@ components: type: - string - 'null' - description: The WebAuthn origin the `passkeyAssertion` was produced against (one of the challenge's `passkeyAllowedOrigins`). **Required** alongside `passkeyAssertion`; omit it for the `code` path. + description: The WebAuthn origin the `passkeyAssertion` was produced against. **Required** alongside `passkeyAssertion`; omit it for the `code` path. When the challenge lists `passkeyAllowedOrigins` (enrollment / login challenges), it must be one of those. A per-transaction passkey challenge carries `passkeyAssertionOptions` but may omit `passkeyAllowedOrigins`; in that case supply the origin your app invoked the WebAuthn API from, which must match the relying party in `passkeyAssertionOptions`. example: https://app.example.com TransferOutRequest: type: object @@ -17961,6 +17963,9 @@ components: maxLength: 80 description: 'Free-form information about the payment that travels with it to the recipient. The field this populates depends on the payment rail: for ACH it populates the Addenda record, for FedNow and RTP it populates the remittanceInformation field, and for wires it populates the OBI (Originator to Beneficiary Information) / beneficiary information.' example: '12345' + scaFactor: + $ref: '#/components/schemas/ScaFactor' + description: Optional preferred factor for the Strong Customer Authentication challenge this call issues. Only relevant for customers whose provider requires SCA (e.g. EU); ignored otherwise. Valid values for a per-transaction challenge are `SMS_OTP` (default) and `PASSKEY` — `TOTP` cannot carry the required dynamic linking and is rejected here. Omit to default to `SMS_OTP`. scaAuthorization: $ref: '#/components/schemas/ScaAuthorization' description: 'Optional inline Strong Customer Authentication proof. Only relevant for customers whose provider requires SCA (e.g. EU): supply this to satisfy the challenge in the same call once the customer has the code/assertion. Omit it on the first call to receive the transaction in `PENDING_AUTHORIZATION` with an `scaChallenge`, then authorize separately. Ignored for customers whose provider does not require SCA.' @@ -18384,6 +18389,9 @@ components: type: object description: Optional body for executing a quote. Only needed to supply an inline Strong Customer Authentication proof; omit the body entirely for quotes that do not require SCA. properties: + scaFactor: + $ref: '#/components/schemas/ScaFactor' + description: Optional preferred factor for the Strong Customer Authentication challenge this call issues. Only relevant for customers whose provider requires SCA (e.g. EU); ignored otherwise. Valid values for a per-transaction challenge are `SMS_OTP` (default) and `PASSKEY` — `TOTP` cannot carry the required dynamic linking and is rejected here. Omit to default to `SMS_OTP`. scaAuthorization: $ref: '#/components/schemas/ScaAuthorization' description: 'Optional inline Strong Customer Authentication proof. Only relevant for customers whose provider requires SCA (e.g. EU): supply this to satisfy the challenge in the same call once the customer has the code/assertion. Omit it to receive the quote in `PENDING_AUTHORIZATION` with an `scaChallenge`, then authorize separately. Ignored for customers whose provider does not require SCA.' diff --git a/openapi/components/schemas/quotes/ExecuteQuoteRequest.yaml b/openapi/components/schemas/quotes/ExecuteQuoteRequest.yaml index b21335027..6f4564763 100644 --- a/openapi/components/schemas/quotes/ExecuteQuoteRequest.yaml +++ b/openapi/components/schemas/quotes/ExecuteQuoteRequest.yaml @@ -4,6 +4,14 @@ description: >- Customer Authentication proof; omit the body entirely for quotes that do not require SCA. properties: + scaFactor: + $ref: ../sca/ScaFactor.yaml + description: >- + Optional preferred factor for the Strong Customer Authentication challenge + this call issues. Only relevant for customers whose provider requires SCA + (e.g. EU); ignored otherwise. Valid values for a per-transaction challenge + are `SMS_OTP` (default) and `PASSKEY` — `TOTP` cannot carry the required + dynamic linking and is rejected here. Omit to default to `SMS_OTP`. scaAuthorization: $ref: ../sca/ScaAuthorization.yaml description: >- diff --git a/openapi/components/schemas/sca/ScaAuthorization.yaml b/openapi/components/schemas/sca/ScaAuthorization.yaml index ecf052865..0b70f75d5 100644 --- a/openapi/components/schemas/sca/ScaAuthorization.yaml +++ b/openapi/components/schemas/sca/ScaAuthorization.yaml @@ -27,7 +27,11 @@ properties: - string - 'null' description: >- - The WebAuthn origin the `passkeyAssertion` was produced against (one of - the challenge's `passkeyAllowedOrigins`). **Required** alongside - `passkeyAssertion`; omit it for the `code` path. + The WebAuthn origin the `passkeyAssertion` was produced against. + **Required** alongside `passkeyAssertion`; omit it for the `code` path. + When the challenge lists `passkeyAllowedOrigins` (enrollment / login + challenges), it must be one of those. A per-transaction passkey challenge + carries `passkeyAssertionOptions` but may omit `passkeyAllowedOrigins`; in + that case supply the origin your app invoked the WebAuthn API from, which + must match the relying party in `passkeyAssertionOptions`. example: https://app.example.com diff --git a/openapi/components/schemas/sca/ScaChallenge.yaml b/openapi/components/schemas/sca/ScaChallenge.yaml index 023e03a41..083d4f2f1 100644 --- a/openapi/components/schemas/sca/ScaChallenge.yaml +++ b/openapi/components/schemas/sca/ScaChallenge.yaml @@ -57,8 +57,11 @@ properties: items: type: string description: >- - The origins the WebAuthn ceremony may run against, present only when - `factor` is `PASSKEY`. The origin the assertion is produced against must - be one of these and must be echoed back as `ScaAuthorization.origin`. + The origins the WebAuthn ceremony may run against. Populated for + enrollment and login passkey challenges; the origin the assertion is + produced against must be one of these and echoed back as + `ScaAuthorization.origin`. Per-transaction passkey challenges omit this + (they carry `passkeyAssertionOptions` only) — see `ScaAuthorization.origin` + for how to source the origin in that case. example: - https://app.example.com diff --git a/openapi/components/schemas/transactions/OutgoingTransactionStatus.yaml b/openapi/components/schemas/transactions/OutgoingTransactionStatus.yaml index 426d67153..099d674c6 100644 --- a/openapi/components/schemas/transactions/OutgoingTransactionStatus.yaml +++ b/openapi/components/schemas/transactions/OutgoingTransactionStatus.yaml @@ -1,6 +1,7 @@ type: string enum: - PENDING + - PENDING_AUTHORIZATION - EXPIRED - PROCESSING - COMPLETED @@ -11,6 +12,7 @@ description: | | Status | Description | |--------|-------------| | `PENDING` | Quote is pending confirmation | + | `PENDING_AUTHORIZATION` | Awaiting Strong Customer Authentication. Only occurs for customers whose provider requires SCA (e.g. EU); authorize the transaction's `scaChallenge` to proceed. | | `EXPIRED` | Quote wasn't executed before expiry window | | `PROCESSING` | Executing the quote after receiving funds | | `COMPLETED` | Payout successfully reached the destination | diff --git a/openapi/components/schemas/transfers/TransferOutRequest.yaml b/openapi/components/schemas/transfers/TransferOutRequest.yaml index eb5bb2f1a..6cc777c0e 100644 --- a/openapi/components/schemas/transfers/TransferOutRequest.yaml +++ b/openapi/components/schemas/transfers/TransferOutRequest.yaml @@ -26,6 +26,14 @@ properties: remittanceInformation field, and for wires it populates the OBI (Originator to Beneficiary Information) / beneficiary information. example: '12345' + scaFactor: + $ref: ../sca/ScaFactor.yaml + description: >- + Optional preferred factor for the Strong Customer Authentication challenge + this call issues. Only relevant for customers whose provider requires SCA + (e.g. EU); ignored otherwise. Valid values for a per-transaction challenge + are `SMS_OTP` (default) and `PASSKEY` — `TOTP` cannot carry the required + dynamic linking and is rejected here. Omit to default to `SMS_OTP`. scaAuthorization: $ref: ../sca/ScaAuthorization.yaml description: >- From 1bf0bc39a20e549b57ceb161f5d0c88f8acc3aec Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Thu, 9 Jul 2026 00:19:53 -0700 Subject: [PATCH 7/7] docs(sca): full-fidelity quote SCA + rate-limit, expiry, and authorize-target clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address faraday review on #558: - Add scaFactor to QuoteRequest so a challenge issued at quote creation (realtime funding) can be SMS_OTP or PASSKEY, matching execute/transfer-out — it was previously unspecified, implying SMS-only. - Reword the execute 200 description, which contradicted itself (claimed the transfer was initiated while also covering the PENDING_AUTHORIZATION case); it now splits the two outcomes and states which authorize endpoint to use. - Document a 429 (RATE_LIMITED) on the authorize and resend endpoints — brute -force protection on the OTP and anti-SMS-spam on resend, mirroring the existing auth/credentials OTP endpoints. - Clarify that resend reuses the existing challenge and does not extend expiresAt (post-expiry recovery tracked as design follow-up F8). - Fix nit: sandbox code wording SMS/TOTP -> SMS (TOTP is invalid per-transaction). Co-Authored-By: Claude --- mintlify/openapi.yaml | 100 ++++++++++++------ openapi.yaml | 100 ++++++++++++------ .../schemas/quotes/QuoteRequest.yaml | 9 ++ .../quotes/quotes_{quoteId}_authorize.yaml | 13 ++- .../quotes_{quoteId}_authorize_resend.yaml | 14 ++- .../quotes/quotes_{quoteId}_execute.yaml | 29 ++--- ...ransactions_{transactionId}_authorize.yaml | 13 ++- ...ions_{transactionId}_authorize_resend.yaml | 15 ++- 8 files changed, 208 insertions(+), 85 deletions(-) diff --git a/mintlify/openapi.yaml b/mintlify/openapi.yaml index d269878cb..1c2a90288 100644 --- a/mintlify/openapi.yaml +++ b/mintlify/openapi.yaml @@ -2864,9 +2864,11 @@ paths: responses: '200': description: | - Quote confirmed successfully. The transfer has been initiated and the quote status has been updated. + Quote processed. The outcome depends on whether the customer's provider requires Strong Customer Authentication (SCA): - For customers whose provider requires Strong Customer Authentication (e.g. EU): supplying `scaAuthorization` **upfront on this call** satisfies the challenge in one shot and avoids the pending state. Without it, the quote comes back with status `PENDING_AUTHORIZATION` and an `scaChallenge`; from that point the **only** way to release the transfer is `POST /transactions/{transactionId}/authorize` for the quote's `transactionId` (re-calling `execute` returns 409). The inline `scaAuthorization` field is therefore for the initial call only. (For a realtime-funding quote that returned 202 from `POST /quotes`, authorize the quote itself via `POST /quotes/{quoteId}/authorize` instead.) If an SMS code lapses, re-send it via `POST /transactions/{transactionId}/authorize/resend`. + - **No SCA required, or a valid `scaAuthorization` is supplied inline on this call:** the transfer is initiated and the quote status advances (`PROCESSING` / `COMPLETED`). + + - **SCA required and no inline proof:** the transfer is **not** initiated yet. The quote is returned with status `PENDING_AUTHORIZATION` and an `scaChallenge`, and the **only** way to release the transfer is `POST /transactions/{transactionId}/authorize` for the quote's `transactionId` (re-calling `execute` returns 409). The inline `scaAuthorization` field is therefore for the initial call only. A client holding a `PENDING_AUTHORIZATION` quote from `execute` always authorizes via the **transaction**-scoped endpoint; the quote-scoped `POST /quotes/{quoteId}/authorize` is only for a realtime-funding quote whose challenge was issued at quote creation (the 202 from `POST /quotes`). If an SMS code lapses, re-send it via `POST /transactions/{transactionId}/authorize/resend`. content: application/json: schema: @@ -2925,7 +2927,7 @@ paths: requires SCA (e.g. EU customers). For customers whose provider has no such requirement, this returns `409`. - In sandbox, the SMS/TOTP code is always `123456`. + In sandbox, the SMS code is always `123456`. operationId: authorizeQuote tags: - Strong Customer Authentication @@ -2968,6 +2970,12 @@ paths: application/json: schema: $ref: '#/components/schemas/Error409' + '429': + description: Too many requests. Returned with `RATE_LIMITED` when authorization attempts for this challenge happen too frequently (for example, repeated bad codes brute-forcing the OTP). The provider may invalidate the challenge after too many failed attempts. Clients should back off and retry after the interval indicated by the `Retry-After` response header. + content: + application/json: + schema: + $ref: '#/components/schemas/Error429' '500': description: Internal service error content: @@ -2988,7 +2996,9 @@ paths: description: | Re-send the one-time code for a realtime-funding quote in `PENDING_AUTHORIZATION` status whose `scaChallenge.factor` is `SMS_OTP`. The - existing challenge is reused — no new challenge is issued. + existing challenge is reused — no new challenge is issued, and its + `scaChallenge.expiresAt` is **not** extended; once the challenge is past + `expiresAt` it can no longer be authorized. Only meaningful for customers whose provider requires SCA (e.g. EU); other providers return 409. `PASSKEY` challenges cannot be re-sent and return 409. @@ -3020,6 +3030,12 @@ paths: application/json: schema: $ref: '#/components/schemas/Error409' + '429': + description: Too many requests. Returned with `RATE_LIMITED` when codes are re-sent too frequently, to avoid spamming the customer's phone. Clients should back off and retry after the interval indicated by the `Retry-After` response header. + content: + application/json: + schema: + $ref: '#/components/schemas/Error429' '500': description: Internal service error content: @@ -3399,7 +3415,7 @@ paths: requires SCA (e.g. EU customers). For customers whose provider has no such requirement, this returns `409`. - In sandbox, the SMS/TOTP code is always `123456`. + In sandbox, the SMS code is always `123456`. operationId: authorizeTransaction tags: - Strong Customer Authentication @@ -3442,6 +3458,12 @@ paths: application/json: schema: $ref: '#/components/schemas/Error409' + '429': + description: Too many requests. Returned with `RATE_LIMITED` when authorization attempts for this challenge happen too frequently (for example, repeated bad codes brute-forcing the OTP). The provider may invalidate the challenge after too many failed attempts. Clients should back off and retry after the interval indicated by the `Retry-After` response header. + content: + application/json: + schema: + $ref: '#/components/schemas/Error429' '500': description: Internal service error content: @@ -3462,8 +3484,9 @@ paths: description: | Re-send the one-time code for a transaction in `PENDING_AUTHORIZATION` status whose `scaChallenge.factor` is `SMS_OTP` (e.g. the customer didn't - receive it, or it expired). The existing challenge is reused — no new - challenge is issued. + receive it). The existing challenge is reused — no new challenge is issued, + and its `scaChallenge.expiresAt` is **not** extended; once the challenge is + past `expiresAt` it can no longer be authorized. Only meaningful for customers whose provider requires SCA (e.g. EU); other providers return 409. `PASSKEY` challenges cannot be re-sent (there is no @@ -3496,6 +3519,12 @@ paths: application/json: schema: $ref: '#/components/schemas/Error409' + '429': + description: Too many requests. Returned with `RATE_LIMITED` when codes are re-sent too frequently, to avoid spamming the customer's phone. Clients should back off and retry after the interval indicated by the `Retry-After` response header. + content: + application/json: + schema: + $ref: '#/components/schemas/Error429' '500': description: Internal service error content: @@ -18376,6 +18405,9 @@ components: example: 'Invoice #1234 payment' purposeOfPayment: $ref: '#/components/schemas/PurposeOfPayment' + scaFactor: + $ref: '#/components/schemas/ScaFactor' + description: Optional preferred factor for a Strong Customer Authentication challenge issued at quote creation. Only relevant for a realtime-funding source whose provider requires SCA (e.g. EU); ignored otherwise. Valid values are `SMS_OTP` (default) and `PASSKEY` — `TOTP` cannot carry the required dynamic linking and is rejected. When the quote is returned in `PENDING_AUTHORIZATION`, authorize it via `POST /quotes/{quoteId}/authorize`. senderCustomerInfo: type: object additionalProperties: true @@ -18395,6 +18427,33 @@ components: scaAuthorization: $ref: '#/components/schemas/ScaAuthorization' description: 'Optional inline Strong Customer Authentication proof. Only relevant for customers whose provider requires SCA (e.g. EU): supply this to satisfy the challenge in the same call once the customer has the code/assertion. Omit it to receive the quote in `PENDING_AUTHORIZATION` with an `scaChallenge`, then authorize separately. Ignored for customers whose provider does not require SCA.' + Error429: + type: object + required: + - message + - status + - code + properties: + status: + type: integer + enum: + - 429 + description: HTTP status code + code: + type: string + description: | + | Error Code | Description | + |------------|-------------| + | RATE_LIMITED | Too many requests in a short window; retry after the interval indicated by the `Retry-After` response header | + enum: + - RATE_LIMITED + message: + type: string + description: Error message + details: + type: object + description: Additional error details + additionalProperties: true TransactionListResponse: type: object required: @@ -19367,33 +19426,6 @@ components: format: date-time description: Timestamp after which the session is no longer valid and the `encryptedSessionSigningKey` must not be used to sign further requests. example: '2026-04-09T15:30:01Z' - Error429: - type: object - required: - - message - - status - - code - properties: - status: - type: integer - enum: - - 429 - description: HTTP status code - code: - type: string - description: | - | Error Code | Description | - |------------|-------------| - | RATE_LIMITED | Too many requests in a short window; retry after the interval indicated by the `Retry-After` response header | - enum: - - RATE_LIMITED - message: - type: string - description: Error message - details: - type: object - description: Additional error details - additionalProperties: true AuthCredentialChallengeRequest: title: Auth Credential Challenge Request description: Request body for `POST /auth/credentials/{id}/challenge`. Required when re-challenging a `PASSKEY` credential — must carry `clientPublicKey` so Grid can bake it into the session-creation payload the returned challenge is computed from. Ignored for `EMAIL_OTP` and `SMS_OTP`, where the credential type alone is sufficient because the OTP is delivered out-of-band. OAuth credentials do not use this endpoint; authenticate or reauthenticate them with `POST /auth/credentials/{id}/verify`. diff --git a/openapi.yaml b/openapi.yaml index d269878cb..1c2a90288 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2864,9 +2864,11 @@ paths: responses: '200': description: | - Quote confirmed successfully. The transfer has been initiated and the quote status has been updated. + Quote processed. The outcome depends on whether the customer's provider requires Strong Customer Authentication (SCA): - For customers whose provider requires Strong Customer Authentication (e.g. EU): supplying `scaAuthorization` **upfront on this call** satisfies the challenge in one shot and avoids the pending state. Without it, the quote comes back with status `PENDING_AUTHORIZATION` and an `scaChallenge`; from that point the **only** way to release the transfer is `POST /transactions/{transactionId}/authorize` for the quote's `transactionId` (re-calling `execute` returns 409). The inline `scaAuthorization` field is therefore for the initial call only. (For a realtime-funding quote that returned 202 from `POST /quotes`, authorize the quote itself via `POST /quotes/{quoteId}/authorize` instead.) If an SMS code lapses, re-send it via `POST /transactions/{transactionId}/authorize/resend`. + - **No SCA required, or a valid `scaAuthorization` is supplied inline on this call:** the transfer is initiated and the quote status advances (`PROCESSING` / `COMPLETED`). + + - **SCA required and no inline proof:** the transfer is **not** initiated yet. The quote is returned with status `PENDING_AUTHORIZATION` and an `scaChallenge`, and the **only** way to release the transfer is `POST /transactions/{transactionId}/authorize` for the quote's `transactionId` (re-calling `execute` returns 409). The inline `scaAuthorization` field is therefore for the initial call only. A client holding a `PENDING_AUTHORIZATION` quote from `execute` always authorizes via the **transaction**-scoped endpoint; the quote-scoped `POST /quotes/{quoteId}/authorize` is only for a realtime-funding quote whose challenge was issued at quote creation (the 202 from `POST /quotes`). If an SMS code lapses, re-send it via `POST /transactions/{transactionId}/authorize/resend`. content: application/json: schema: @@ -2925,7 +2927,7 @@ paths: requires SCA (e.g. EU customers). For customers whose provider has no such requirement, this returns `409`. - In sandbox, the SMS/TOTP code is always `123456`. + In sandbox, the SMS code is always `123456`. operationId: authorizeQuote tags: - Strong Customer Authentication @@ -2968,6 +2970,12 @@ paths: application/json: schema: $ref: '#/components/schemas/Error409' + '429': + description: Too many requests. Returned with `RATE_LIMITED` when authorization attempts for this challenge happen too frequently (for example, repeated bad codes brute-forcing the OTP). The provider may invalidate the challenge after too many failed attempts. Clients should back off and retry after the interval indicated by the `Retry-After` response header. + content: + application/json: + schema: + $ref: '#/components/schemas/Error429' '500': description: Internal service error content: @@ -2988,7 +2996,9 @@ paths: description: | Re-send the one-time code for a realtime-funding quote in `PENDING_AUTHORIZATION` status whose `scaChallenge.factor` is `SMS_OTP`. The - existing challenge is reused — no new challenge is issued. + existing challenge is reused — no new challenge is issued, and its + `scaChallenge.expiresAt` is **not** extended; once the challenge is past + `expiresAt` it can no longer be authorized. Only meaningful for customers whose provider requires SCA (e.g. EU); other providers return 409. `PASSKEY` challenges cannot be re-sent and return 409. @@ -3020,6 +3030,12 @@ paths: application/json: schema: $ref: '#/components/schemas/Error409' + '429': + description: Too many requests. Returned with `RATE_LIMITED` when codes are re-sent too frequently, to avoid spamming the customer's phone. Clients should back off and retry after the interval indicated by the `Retry-After` response header. + content: + application/json: + schema: + $ref: '#/components/schemas/Error429' '500': description: Internal service error content: @@ -3399,7 +3415,7 @@ paths: requires SCA (e.g. EU customers). For customers whose provider has no such requirement, this returns `409`. - In sandbox, the SMS/TOTP code is always `123456`. + In sandbox, the SMS code is always `123456`. operationId: authorizeTransaction tags: - Strong Customer Authentication @@ -3442,6 +3458,12 @@ paths: application/json: schema: $ref: '#/components/schemas/Error409' + '429': + description: Too many requests. Returned with `RATE_LIMITED` when authorization attempts for this challenge happen too frequently (for example, repeated bad codes brute-forcing the OTP). The provider may invalidate the challenge after too many failed attempts. Clients should back off and retry after the interval indicated by the `Retry-After` response header. + content: + application/json: + schema: + $ref: '#/components/schemas/Error429' '500': description: Internal service error content: @@ -3462,8 +3484,9 @@ paths: description: | Re-send the one-time code for a transaction in `PENDING_AUTHORIZATION` status whose `scaChallenge.factor` is `SMS_OTP` (e.g. the customer didn't - receive it, or it expired). The existing challenge is reused — no new - challenge is issued. + receive it). The existing challenge is reused — no new challenge is issued, + and its `scaChallenge.expiresAt` is **not** extended; once the challenge is + past `expiresAt` it can no longer be authorized. Only meaningful for customers whose provider requires SCA (e.g. EU); other providers return 409. `PASSKEY` challenges cannot be re-sent (there is no @@ -3496,6 +3519,12 @@ paths: application/json: schema: $ref: '#/components/schemas/Error409' + '429': + description: Too many requests. Returned with `RATE_LIMITED` when codes are re-sent too frequently, to avoid spamming the customer's phone. Clients should back off and retry after the interval indicated by the `Retry-After` response header. + content: + application/json: + schema: + $ref: '#/components/schemas/Error429' '500': description: Internal service error content: @@ -18376,6 +18405,9 @@ components: example: 'Invoice #1234 payment' purposeOfPayment: $ref: '#/components/schemas/PurposeOfPayment' + scaFactor: + $ref: '#/components/schemas/ScaFactor' + description: Optional preferred factor for a Strong Customer Authentication challenge issued at quote creation. Only relevant for a realtime-funding source whose provider requires SCA (e.g. EU); ignored otherwise. Valid values are `SMS_OTP` (default) and `PASSKEY` — `TOTP` cannot carry the required dynamic linking and is rejected. When the quote is returned in `PENDING_AUTHORIZATION`, authorize it via `POST /quotes/{quoteId}/authorize`. senderCustomerInfo: type: object additionalProperties: true @@ -18395,6 +18427,33 @@ components: scaAuthorization: $ref: '#/components/schemas/ScaAuthorization' description: 'Optional inline Strong Customer Authentication proof. Only relevant for customers whose provider requires SCA (e.g. EU): supply this to satisfy the challenge in the same call once the customer has the code/assertion. Omit it to receive the quote in `PENDING_AUTHORIZATION` with an `scaChallenge`, then authorize separately. Ignored for customers whose provider does not require SCA.' + Error429: + type: object + required: + - message + - status + - code + properties: + status: + type: integer + enum: + - 429 + description: HTTP status code + code: + type: string + description: | + | Error Code | Description | + |------------|-------------| + | RATE_LIMITED | Too many requests in a short window; retry after the interval indicated by the `Retry-After` response header | + enum: + - RATE_LIMITED + message: + type: string + description: Error message + details: + type: object + description: Additional error details + additionalProperties: true TransactionListResponse: type: object required: @@ -19367,33 +19426,6 @@ components: format: date-time description: Timestamp after which the session is no longer valid and the `encryptedSessionSigningKey` must not be used to sign further requests. example: '2026-04-09T15:30:01Z' - Error429: - type: object - required: - - message - - status - - code - properties: - status: - type: integer - enum: - - 429 - description: HTTP status code - code: - type: string - description: | - | Error Code | Description | - |------------|-------------| - | RATE_LIMITED | Too many requests in a short window; retry after the interval indicated by the `Retry-After` response header | - enum: - - RATE_LIMITED - message: - type: string - description: Error message - details: - type: object - description: Additional error details - additionalProperties: true AuthCredentialChallengeRequest: title: Auth Credential Challenge Request description: Request body for `POST /auth/credentials/{id}/challenge`. Required when re-challenging a `PASSKEY` credential — must carry `clientPublicKey` so Grid can bake it into the session-creation payload the returned challenge is computed from. Ignored for `EMAIL_OTP` and `SMS_OTP`, where the credential type alone is sufficient because the OTP is delivered out-of-band. OAuth credentials do not use this endpoint; authenticate or reauthenticate them with `POST /auth/credentials/{id}/verify`. diff --git a/openapi/components/schemas/quotes/QuoteRequest.yaml b/openapi/components/schemas/quotes/QuoteRequest.yaml index f8a98c921..6faa0ef98 100644 --- a/openapi/components/schemas/quotes/QuoteRequest.yaml +++ b/openapi/components/schemas/quotes/QuoteRequest.yaml @@ -57,6 +57,15 @@ properties: example: 'Invoice #1234 payment' purposeOfPayment: $ref: ./PurposeOfPayment.yaml + scaFactor: + $ref: ../sca/ScaFactor.yaml + description: >- + Optional preferred factor for a Strong Customer Authentication challenge + issued at quote creation. Only relevant for a realtime-funding source + whose provider requires SCA (e.g. EU); ignored otherwise. Valid values are + `SMS_OTP` (default) and `PASSKEY` — `TOTP` cannot carry the required + dynamic linking and is rejected. When the quote is returned in + `PENDING_AUTHORIZATION`, authorize it via `POST /quotes/{quoteId}/authorize`. senderCustomerInfo: type: object additionalProperties: true diff --git a/openapi/paths/quotes/quotes_{quoteId}_authorize.yaml b/openapi/paths/quotes/quotes_{quoteId}_authorize.yaml index d868f43c0..3a1241f9c 100644 --- a/openapi/paths/quotes/quotes_{quoteId}_authorize.yaml +++ b/openapi/paths/quotes/quotes_{quoteId}_authorize.yaml @@ -21,7 +21,7 @@ post: requires SCA (e.g. EU customers). For customers whose provider has no such requirement, this returns `409`. - In sandbox, the SMS/TOTP code is always `123456`. + In sandbox, the SMS code is always `123456`. operationId: authorizeQuote tags: - Strong Customer Authentication @@ -66,6 +66,17 @@ post: application/json: schema: $ref: ../../components/schemas/errors/Error409.yaml + '429': + description: >- + Too many requests. Returned with `RATE_LIMITED` when authorization + attempts for this challenge happen too frequently (for example, repeated + bad codes brute-forcing the OTP). The provider may invalidate the + challenge after too many failed attempts. Clients should back off and + retry after the interval indicated by the `Retry-After` response header. + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error429.yaml '500': description: Internal service error content: diff --git a/openapi/paths/quotes/quotes_{quoteId}_authorize_resend.yaml b/openapi/paths/quotes/quotes_{quoteId}_authorize_resend.yaml index ffd4dc564..96e5edbdf 100644 --- a/openapi/paths/quotes/quotes_{quoteId}_authorize_resend.yaml +++ b/openapi/paths/quotes/quotes_{quoteId}_authorize_resend.yaml @@ -11,7 +11,9 @@ post: description: | Re-send the one-time code for a realtime-funding quote in `PENDING_AUTHORIZATION` status whose `scaChallenge.factor` is `SMS_OTP`. The - existing challenge is reused — no new challenge is issued. + existing challenge is reused — no new challenge is issued, and its + `scaChallenge.expiresAt` is **not** extended; once the challenge is past + `expiresAt` it can no longer be authorized. Only meaningful for customers whose provider requires SCA (e.g. EU); other providers return 409. `PASSKEY` challenges cannot be re-sent and return 409. @@ -46,6 +48,16 @@ post: application/json: schema: $ref: ../../components/schemas/errors/Error409.yaml + '429': + description: >- + Too many requests. Returned with `RATE_LIMITED` when codes are re-sent + too frequently, to avoid spamming the customer's phone. Clients should + back off and retry after the interval indicated by the `Retry-After` + response header. + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error429.yaml '500': description: Internal service error content: diff --git a/openapi/paths/quotes/quotes_{quoteId}_execute.yaml b/openapi/paths/quotes/quotes_{quoteId}_execute.yaml index 2c04ab7f1..4b53517e1 100644 --- a/openapi/paths/quotes/quotes_{quoteId}_execute.yaml +++ b/openapi/paths/quotes/quotes_{quoteId}_execute.yaml @@ -59,21 +59,26 @@ post: responses: '200': description: > - Quote confirmed successfully. The transfer has been initiated and the - quote status has been updated. + Quote processed. The outcome depends on whether the customer's provider + requires Strong Customer Authentication (SCA): - For customers whose provider requires Strong Customer Authentication - (e.g. EU): supplying `scaAuthorization` **upfront on this call** satisfies - the challenge in one shot and avoids the pending state. Without it, the - quote comes back with status `PENDING_AUTHORIZATION` and an - `scaChallenge`; from that point the **only** way to release the transfer - is `POST /transactions/{transactionId}/authorize` for the quote's + - **No SCA required, or a valid `scaAuthorization` is supplied inline on + this call:** the transfer is initiated and the quote status advances + (`PROCESSING` / `COMPLETED`). + + + - **SCA required and no inline proof:** the transfer is **not** initiated + yet. The quote is returned with status `PENDING_AUTHORIZATION` and an + `scaChallenge`, and the **only** way to release the transfer is + `POST /transactions/{transactionId}/authorize` for the quote's `transactionId` (re-calling `execute` returns 409). The inline - `scaAuthorization` field is therefore for the initial call only. (For a - realtime-funding quote that returned 202 from `POST /quotes`, authorize - the quote itself via `POST /quotes/{quoteId}/authorize` instead.) If an - SMS code lapses, re-send it via + `scaAuthorization` field is therefore for the initial call only. A + client holding a `PENDING_AUTHORIZATION` quote from `execute` always + authorizes via the **transaction**-scoped endpoint; the quote-scoped + `POST /quotes/{quoteId}/authorize` is only for a realtime-funding quote + whose challenge was issued at quote creation (the 202 from `POST + /quotes`). If an SMS code lapses, re-send it via `POST /transactions/{transactionId}/authorize/resend`. content: application/json: diff --git a/openapi/paths/transactions/transactions_{transactionId}_authorize.yaml b/openapi/paths/transactions/transactions_{transactionId}_authorize.yaml index fa7ea394b..0e4949da3 100644 --- a/openapi/paths/transactions/transactions_{transactionId}_authorize.yaml +++ b/openapi/paths/transactions/transactions_{transactionId}_authorize.yaml @@ -23,7 +23,7 @@ post: requires SCA (e.g. EU customers). For customers whose provider has no such requirement, this returns `409`. - In sandbox, the SMS/TOTP code is always `123456`. + In sandbox, the SMS code is always `123456`. operationId: authorizeTransaction tags: - Strong Customer Authentication @@ -68,6 +68,17 @@ post: application/json: schema: $ref: ../../components/schemas/errors/Error409.yaml + '429': + description: >- + Too many requests. Returned with `RATE_LIMITED` when authorization + attempts for this challenge happen too frequently (for example, repeated + bad codes brute-forcing the OTP). The provider may invalidate the + challenge after too many failed attempts. Clients should back off and + retry after the interval indicated by the `Retry-After` response header. + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error429.yaml '500': description: Internal service error content: diff --git a/openapi/paths/transactions/transactions_{transactionId}_authorize_resend.yaml b/openapi/paths/transactions/transactions_{transactionId}_authorize_resend.yaml index a97c9ce2e..c3d566f81 100644 --- a/openapi/paths/transactions/transactions_{transactionId}_authorize_resend.yaml +++ b/openapi/paths/transactions/transactions_{transactionId}_authorize_resend.yaml @@ -11,8 +11,9 @@ post: description: | Re-send the one-time code for a transaction in `PENDING_AUTHORIZATION` status whose `scaChallenge.factor` is `SMS_OTP` (e.g. the customer didn't - receive it, or it expired). The existing challenge is reused — no new - challenge is issued. + receive it). The existing challenge is reused — no new challenge is issued, + and its `scaChallenge.expiresAt` is **not** extended; once the challenge is + past `expiresAt` it can no longer be authorized. Only meaningful for customers whose provider requires SCA (e.g. EU); other providers return 409. `PASSKEY` challenges cannot be re-sent (there is no @@ -48,6 +49,16 @@ post: application/json: schema: $ref: ../../components/schemas/errors/Error409.yaml + '429': + description: >- + Too many requests. Returned with `RATE_LIMITED` when codes are re-sent + too frequently, to avoid spamming the customer's phone. Clients should + back off and retry after the interval indicated by the `Retry-After` + response header. + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error429.yaml '500': description: Internal service error content: