From dcb923a7469927a33bea41237551375caa9cd9fe Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Fri, 28 Aug 2026 07:39:48 +0000 Subject: [PATCH 1/2] docs(sca): document Strong Customer Authentication for EU customers A dedicated SCA guide section under the Get started tab covering the whole SCA surface: overview, per-transaction authorization, factor enrollment, login and sessions, trusted beneficiaries, and two-factor reset, plus a shared snippet imported by the money-movement flow pages. --- mintlify/docs.json | 11 ++ .../sending-payments.mdx | 9 ++ .../payment-flow/send-payment.mdx | 9 ++ .../sca/factor-enrollment.mdx | 145 ++++++++++++++++++ .../sca/login-and-sessions.mdx | 123 +++++++++++++++ mintlify/platform-overview/sca/overview.mdx | 135 ++++++++++++++++ .../sca/per-transaction-authorization.mdx | 75 +++++++++ .../sca/trusted-beneficiaries.mdx | 94 ++++++++++++ .../sca/two-factor-reset.mdx | 82 ++++++++++ .../fiat-crypto-conversion.mdx | 18 +++ .../sca/strong-customer-authentication.mdx | 106 +++++++++++++ 11 files changed, 807 insertions(+) create mode 100644 mintlify/platform-overview/sca/factor-enrollment.mdx create mode 100644 mintlify/platform-overview/sca/login-and-sessions.mdx create mode 100644 mintlify/platform-overview/sca/overview.mdx create mode 100644 mintlify/platform-overview/sca/per-transaction-authorization.mdx create mode 100644 mintlify/platform-overview/sca/trusted-beneficiaries.mdx create mode 100644 mintlify/platform-overview/sca/two-factor-reset.mdx create mode 100644 mintlify/snippets/sca/strong-customer-authentication.mdx diff --git a/mintlify/docs.json b/mintlify/docs.json index 927785e65..6286b5ef3 100644 --- a/mintlify/docs.json +++ b/mintlify/docs.json @@ -58,6 +58,17 @@ "platform-overview/core-concepts/currencies-and-rails", "platform-overview/configuration" ] + }, + { + "group": "Strong Customer Authentication", + "pages": [ + "platform-overview/sca/overview", + "platform-overview/sca/per-transaction-authorization", + "platform-overview/sca/factor-enrollment", + "platform-overview/sca/login-and-sessions", + "platform-overview/sca/trusted-beneficiaries", + "platform-overview/sca/two-factor-reset" + ] } ] }, diff --git a/mintlify/global-p2p/sending-receiving-payments/sending-payments.mdx b/mintlify/global-p2p/sending-receiving-payments/sending-payments.mdx index 450228c03..37297fee5 100644 --- a/mintlify/global-p2p/sending-receiving-payments/sending-payments.mdx +++ b/mintlify/global-p2p/sending-receiving-payments/sending-payments.mdx @@ -25,4 +25,13 @@ import SendUMA from '/snippets/sending/uma.mdx' +## Strong Customer Authentication (EU customers) + +Customers in SCA-regulated regions (in practice the EU: EUR / USDC) must confirm +payments with Strong Customer Authentication. When it applies, the payment comes +back `PENDING_AUTHORIZATION` with an `scaChallenge` that you authorize before the +transfer is released; for every other customer nothing changes. See +[Per-transaction authorization](/platform-overview/sca/per-transaction-authorization) +for the full walkthrough. + diff --git a/mintlify/payouts-and-b2b/payment-flow/send-payment.mdx b/mintlify/payouts-and-b2b/payment-flow/send-payment.mdx index 0d786286a..74ebeb5b6 100644 --- a/mintlify/payouts-and-b2b/payment-flow/send-payment.mdx +++ b/mintlify/payouts-and-b2b/payment-flow/send-payment.mdx @@ -264,6 +264,15 @@ end-to-end times measured from quote execution: of these. +## Strong Customer Authentication (EU customers) + +Customers in SCA-regulated regions (in practice the EU: EUR / USDC) must confirm +payments with Strong Customer Authentication. When it applies, the payment comes +back `PENDING_AUTHORIZATION` with an `scaChallenge` that you authorize before the +transfer is released; for every other customer nothing changes. See +[Per-transaction authorization](/platform-overview/sca/per-transaction-authorization) +for the full walkthrough. + ## Checking Payment Status Configure a webhook endpoint to receive real-time notifications when payment status changes: diff --git a/mintlify/platform-overview/sca/factor-enrollment.mdx b/mintlify/platform-overview/sca/factor-enrollment.mdx new file mode 100644 index 000000000..a890e0395 --- /dev/null +++ b/mintlify/platform-overview/sca/factor-enrollment.mdx @@ -0,0 +1,145 @@ +--- +icon: "/images/icons/key2.svg" +title: "Factor enrollment" +description: "Enroll and manage a customer's TOTP and passkey factors" +"og:image": "/images/og/og-get-started.png" +--- + + +Applies only to customers in an SCA-required region (EU). Every endpoint here +returns **`409`** for other customers. + + +`SMS_OTP` needs no enrollment; a code is sent to the customer's verified phone. +`TOTP` and `PASSKEY` must be enrolled before a customer can authenticate with +them. Enrolled factors then appear in `scaChallenge.availableFactors` and can be +requested per transaction (see +[per-transaction authorization](/platform-overview/sca/per-transaction-authorization)). + +Enrollment is two calls, both discriminated by a `type` field (`TOTP` or +`PASSKEY`) — the same shape the [login and session](/platform-overview/sca/login-and-sessions) +endpoints use: + +- `POST /sca/factors` — start enrollment; returns the factor-specific material. +- `POST /sca/factors/confirm` — finish enrollment with the factor-specific proof. + +All paths below are relative to `https://api.lightspark.com/grid/2025-10-13`. + +## Enroll a TOTP authenticator + + + + +```bash +POST /sca/factors?customerId={customerId} + +{ "type": "TOTP" } +``` + +Returns the shared secret and an `otpauth://` provisioning URI. Render `totpUri` +as a QR code (or show `secretBase32Encoded` for manual entry) so the customer can +add it to their authenticator app. + +```json +{ + "type": "TOTP", + "secret": "…", + "secretBase32Encoded": "ABC123…", + "totpUri": "otpauth://totp/Grid:customer@example.com?secret=ABC123&issuer=Grid" +} +``` + + + +Submit the `secret` from the start call plus the first code the app produces. +Grid returns one-time **recovery codes**; surface them to the customer once and +don't store them server-side. + +```bash +POST /sca/factors/confirm?customerId={customerId} + +{ "type": "TOTP", "secret": "…", "code": "123456" } +``` + +```json +{ "type": "TOTP", "recoveryCodes": ["ABCD-EFGH-IJKL", "MNOP-QRST-UVWX"] } +``` + +A wrong or expired code returns `400`. **In sandbox, the code is always +`123456`.** + + + +## Enroll a passkey + +Passkey enrollment is a standard WebAuthn registration ceremony. Grid issues the +options, the customer's device produces the credential, and you hand it back. + + +A customer may have **only one passkey**. If one is already enrolled, starting +another returns `409` (`PASSKEY_ALREADY_ENROLLED`) — delete the existing passkey +first (see below). `GET /sca/factors` therefore lists at most one passkey. + + + + + +```bash +POST /sca/factors?customerId={customerId} + +{ "type": "PASSKEY" } +``` + +```json +{ + "type": "PASSKEY", + "options": { "…": "opaque WebAuthn PublicKeyCredentialCreationOptions" }, + "allowedOrigins": ["https://app.example.com"], + "relyingPartyId": "app.example.com" +} +``` + +Pass `options` unmodified to the device's WebAuthn API +(`navigator.credentials.create`). The ceremony must run against one of +`allowedOrigins`. + + +Submit the credential the device produced and the `origin` it ran against. + +```bash +POST /sca/factors/confirm?customerId={customerId} + +{ "type": "PASSKEY", "origin": "https://app.example.com", "credential": { "…": "opaque WebAuthn credential" } } +``` + +Returns the enrolled `factor` (an `ScaFactorView`, including the `credentialId` +you'll use to delete it later). An invalid credential or origin returns `400`. + + + +## List enrolled factors + +```bash +GET /sca/factors?customerId={customerId} +``` + +```json +{ + "factors": [ + { "factor": "TOTP", "name": "Authenticator app" }, + { "factor": "PASSKEY", "credentialId": "…", "name": "iPhone" } + ] +} +``` + +`credentialId` is populated only for `PASSKEY` factors. + +## Delete a factor + +```bash +DELETE /sca/factors/{credentialId}?customerId={customerId} +``` + +Returns `204`. Use the `credentialId` from the factor list (or the confirm +response). Today only passkeys carry a `credentialId`, so this is how you remove +an enrolled passkey. diff --git a/mintlify/platform-overview/sca/login-and-sessions.mdx b/mintlify/platform-overview/sca/login-and-sessions.mdx new file mode 100644 index 000000000..65e466ec1 --- /dev/null +++ b/mintlify/platform-overview/sca/login-and-sessions.mdx @@ -0,0 +1,123 @@ +--- +icon: "/images/icons/shield.svg" +title: "Login & sessions" +description: "The end-user SCA login and the session it grants" +"og:image": "/images/og/og-get-started.png" +--- + + +Applies only to customers in an SCA-required region (EU). Every endpoint here +returns **`409`** for other customers. + + +Per-transaction authorization gates individual debits. The **SCA login** is +separate: it authenticates the end user to open a longer-lived session that +covers reads and account access beyond the per-transaction window. Grid provides +the login plumbing; your application decides when to drive it (for example, when +a customer opens their account and the previous session has lapsed). + + +**A customer's EUR / USDC accounts aren't provisioned until their first SCA +login after KYC approval.** Provisioning is deferred from KYC-approval time to +the first login that opens a valid SCA session, so a freshly KYC-approved +customer's EUR / USDC accounts won't appear in `GET /customers/internal-accounts` +until then. Expect those accounts to be unavailable, and drive the SCA login +once KYC is approved, before relying on them. + + +All paths below are relative to `https://api.lightspark.com/grid/2025-10-13`. + +## Logging in + + + + +```bash +POST /sca/login/start?customerId={customerId} + +{ "factor": "SMS_OTP" } +``` + +The response carries only what the chosen factor needs: + +- **`SMS_OTP`**: a code is dispatched; you get back `challengeId` and `expiresAt`. +- **`TOTP`**: nothing extra; the customer reads the code from their app. +- **`PASSKEY`**: WebAuthn `passkeyOptions` (with `allowedOrigins` and `relyingPartyId`) to pass to the device. + +The factor must already be enrolled (or, for `SMS_OTP`, the phone verified). See +[factor enrollment](/platform-overview/sca/factor-enrollment). + + +Submit the proof for the factor you started with: `code` for `SMS_OTP` / `TOTP` +(echoing `challengeId` for `SMS_OTP`), or `passkeyAssertion` + `origin` for +`PASSKEY`. + +```bash +POST /sca/login/complete?customerId={customerId} + +{ "factor": "SMS_OTP", "challengeId": "…", "code": "123456" } +``` + +```json +{ "status": "SUCCESS" } +``` + +A `status` of `SUCCESS` means the session is open for 180 days and revokes any +previous SCA session for that customer. Any other value means the +login did not complete; the field is passed through verbatim, so treat only +`SUCCESS` as success. An invalid or expired proof returns `400`. **In sandbox, +the code is always `123456`.** + + + +## Session scope and fresh authentication + +An active SCA login session covers EUR / USDC account reads for 180 days. A +request for transaction history older than 90 days requires fresh SCA, even when +the broader session has not expired. When Grid indicates that a session is +missing, expired, or too old for the requested history, restart the login flow +before retrying the read. + +## Account-security signals + +Grid runs an adaptive-authentication risk engine that maintains each customer's +login-security state. Because your application owns the customer's login, you +report the security-relevant events it sees so the engine can act on them: + +```bash +POST /sca/record-event?customerId={customerId} + +{ "eventType": "FAILED_LOGIN_ATTEMPT" } +``` + +Returns the customer's resulting login-security state so you can surface a +lockout — `{ eventType, suspended, lockedUntil, failedAttempts }`. When the +customer is locked out, this (and `POST /sca/login/complete`) returns `423` with +`details.lockedUntil` (when they may retry) and `details.failedAttempts`. +`eventType` must be one of: + +| `eventType` | Effect | +|---|---| +| `FAILED_LOGIN_ATTEMPT` | Increments the failed-login counter and escalates a lockout: **5 → 15 min, 6 → 30 min, 7 → 1 hour, 8 → 24 hours, 9 or more → suspension.** | +| `RESET_PASSWORD_COMPLETED` | Revokes every active SCA session for the customer and clears the failed-login counter. | + +Report `FAILED_LOGIN_ATTEMPT` on each failed sign-in and `RESET_PASSWORD_COMPLETED` +once a password recovery finishes. Any other value returns `400`. + + +The failed-login counter is cumulative and is **not** reset by a successful +login; only `RESET_PASSWORD_COMPLETED` clears it. Record that event after a +password recovery to zero the counter and clear a time-bounded lockout, rather +than relying on the customer simply logging in again. A suspended customer +(9 or more failed attempts) requires support intervention; password recovery +does not unsuspend the account. + + +## Your responsibilities + +Grid provides the SCA endpoints and risk decisions; your application owns the +end-user login and session experience. Report every failed sign-in and completed +password recovery through `record-event`, enforce any returned lockout before +offering another login attempt, and do not store or reuse a customer's TOTP +secret or passkey material. Treat TOTP secrets and WebAuthn ceremony data as +end-user credentials, not platform credentials. diff --git a/mintlify/platform-overview/sca/overview.mdx b/mintlify/platform-overview/sca/overview.mdx new file mode 100644 index 000000000..76654d56a --- /dev/null +++ b/mintlify/platform-overview/sca/overview.mdx @@ -0,0 +1,135 @@ +--- +icon: "/images/icons/shield.svg" +title: "Strong Customer Authentication" +description: "How Grid satisfies PSD2 SCA for EU customers" +"og:image": "/images/og/og-get-started.png" +--- + + +**This applies only to customers in a region where Strong Customer Authentication +is required, in practice customers in the EU (EUR / USDC).** For every other +customer none of this appears: money-movement calls complete as usual, no +`scaChallenge` is returned, the authentication endpoints return `409`, and you +can skip this section. + + +Under PSD2, EU e-money and e-money-token (EUR / USDC) money movement must be +confirmed by the end user with Strong Customer Authentication (SCA). Grid wraps +SCA so you satisfy it through the same resources you already use. There is no +separate product to integrate, and the same request shapes work for every +customer whether or not SCA applies. + +This section covers the whole surface: + + + + Authorize a money movement that came back `PENDING_AUTHORIZATION`. This is the flow you hit most often. + + + Enroll and manage a customer's TOTP and passkey factors. + + + The end-user SCA login and the session it grants for reads. + + + Whitelist a payee once so future sends to it skip the per-transaction ceremony. + + + Recover a customer who has lost their factors, gated by an identity (liveness) check. + + + +## What SCA covers + +SCA gates **debits** on EU-regulated balances. EUR / USDC reads are covered by +an active [SCA login session](/platform-overview/sca/login-and-sessions), while +non-EUR/USDC accounts do not require SCA. "Dynamic linking" means the authorization is cryptographically +bound to the transaction's amount and payee (PSD2 Article 97(2)); it forces a +fresh, transaction-specific challenge, and it's the reason some flows can't use +TOTP (see [Authentication factors](#authentication-factors)). + +| Operation | SCA required? | Dynamically linked? | +|---|---|---| +| Send EUR / USDC (SEPA + intra-ledger) | Yes | Yes | +| Convert **from** EUR / USDC (the swap leg) | Yes | Yes | +| On-chain / Lightning withdrawal | Yes | No | +| Trust / untrust a beneficiary | Yes | No | +| Send to an **already-trusted** beneficiary | Lighter, no dynamic linking | No | +| Reading balances / history | Covered by the login session | n/a | +| Non-EUR/USDC accounts (e.g. USD) | No | n/a | + +## Authentication factors + +The `scaChallenge.availableFactors` field tells you which factors a customer may +use; `scaChallenge.factor` is the one in use (default `SMS_OTP`). + +| Factor | Enrollment | Per-transaction debit | +|--------|-----------|-----------------------| +| `SMS_OTP` | None; a code is sent to the customer's verified phone | ✅ Default | +| `PASSKEY` | Required (WebAuthn credential) | ✅ | +| `TOTP` | Required (authenticator app) | Available only where the challenge is not dynamically linked; never assume it is available for a particular debit | + +TOTP is barred from dynamically-linked debits because an authenticator code is +derived only from a clock and a shared secret, so it can't be bound to *this* +amount and payee. It stays valid for flows that don't require dynamic linking: +login, trusting a beneficiary, sends to an already-trusted beneficiary, and +eligible on-chain or Lightning withdrawals. Always use the factors in +`scaChallenge.availableFactors`; that field, not the operation name, is the +source of truth for the challenge Grid issued. + +## The authorization flow + +For an SCA-required customer, a money-movement call that would otherwise complete +instead returns the resource in status **`PENDING_AUTHORIZATION`** carrying an +**`scaChallenge`**, and the transfer is not released until the challenge is +satisfied. A single money movement can require **more than one** challenge in +sequence, so loop on status rather than assuming one authorization releases the +transfer. + +```mermaid +sequenceDiagram + participant P as Your platform + participant G as Grid + participant U as End user + + P->>G: Initiate money movement (execute a quote) + G-->>P: PENDING_AUTHORIZATION + scaChallenge + G->>U: Deliver challenge (e.g. SMS OTP) + loop while status == PENDING_AUTHORIZATION + U-->>P: Provide proof (code / passkey assertion) + P->>G: POST .../authorize (scaChallenge.id + proof) + G-->>P: Updated resource + next scaChallenge (if any) + end + G-->>P: Resource leaves PENDING_AUTHORIZATION, transfer released +``` + +[Per-transaction authorization](/platform-overview/sca/per-transaction-authorization) +covers the mechanics: authorizing the quote, the multi-step loop, resending an +expired code, and the realtime-funding-quote nuance. + +## Lifetimes & limits + +| Aspect | Behavior | +|---|---| +| Challenge expiry | Each challenge carries an absolute `scaChallenge.expiresAt` (UTC). SMS codes expire after 5 minutes; TOTP and passkey challenges expire after 10 minutes. After expiry, the challenge can no longer be authorized. | +| Resend | `SMS_OTP` only. Resending reuses the existing challenge and does not extend `expiresAt`; `PASSKEY` (and `TOTP`) codes can't be resent. | +| Repeated failures | A challenge permits at most 5 attempts. Too many failed authorizations may invalidate it and return `429 RATE_LIMITED`, so honor `Retry-After`. | +| Login session | A completed [SCA login](/platform-overview/sca/login-and-sessions) grants a 180-day session. EUR / USDC history older than 90 days requires fresh SCA even during that session. | +| Account lockout | Repeated `FAILED_LOGIN_ATTEMPT` signals escalate a lockout (5 → 15 min, 6 → 30 min, 7 → 1 hour, 8 → 24 hours, 9+ → suspension). See [account-security signals](/platform-overview/sca/login-and-sessions#account-security-signals). | +| 2FA reset window | A started reset carries its own `expiresAt`; complete it before then. | + +## Errors you'll encounter + +| Status | Meaning | What to do | +|---|---|---| +| `400` | Invalid or expired proof: wrong code, expired challenge, or a factor that can't satisfy this challenge (e.g. `TOTP` on a dynamically-linked debit). | Re-collect the proof. If the code lapsed, resend (`SMS_OTP`) or start over. | +| `409` | SCA isn't required for this customer (non-EU), there's no pending challenge, or the factor's code can't be resent (e.g. `PASSKEY`). | Don't retry the same call. Treat a non-EU `409` as nothing to authorize. | +| `429` | `RATE_LIMITED`: too many attempts or resends, and the challenge may now be invalidated. | Honor `Retry-After`; you may need to restart the flow. | +| `404` | The customer, transaction, quote, external account, or reset wasn't found. | Check the id. | + +## Calling a customer outside SCA-regulated regions + +Every authentication endpoint returns **`409`** for customers outside +SCA-regulated regions (non-EU), and no `scaChallenge` is ever attached to their +transactions. You don't need to branch on region. Handle `scaChallenge` when it's +present and treat its absence as nothing to do. diff --git a/mintlify/platform-overview/sca/per-transaction-authorization.mdx b/mintlify/platform-overview/sca/per-transaction-authorization.mdx new file mode 100644 index 000000000..ec55da5ff --- /dev/null +++ b/mintlify/platform-overview/sca/per-transaction-authorization.mdx @@ -0,0 +1,75 @@ +--- +icon: "/images/icons/lock.svg" +title: "Per-transaction authorization" +description: "Authorize an SCA-gated money movement" +"og:image": "/images/og/og-get-started.png" +--- + +This is the flow you hit most often: an SCA-required customer initiates a money +movement, it comes back `PENDING_AUTHORIZATION` with an `scaChallenge`, and you +authorize it before the transfer is released. For where this sits in the wider +SCA surface, see the [overview](/platform-overview/sca/overview). + +import StrongCustomerAuthentication from '/snippets/sca/strong-customer-authentication.mdx'; + + + +## Walkthrough by flow + +The mechanics above are the same everywhere: inspect for an `scaChallenge`, +submit an `ScaAuthorization`, and repeat until the resource leaves +`PENDING_AUTHORIZATION`. What differs between flows is *which* call first returns +the challenge and *which* resource you authorize. Here is each one end to end. + + + +The common case — lock a quote, execute it, authorize the quote. + + + +`POST /quotes` returns a quote as usual. A standard (prefunded) send carries no +challenge at quote time. + + +`POST /quotes/{quoteId}/execute` returns the **quote** in `PENDING_AUTHORIZATION` +with an `scaChallenge`. + + +`POST /quotes/{quoteId}/authorize` with the proof. Re-inspect the returned quote: +if it remains `PENDING_AUTHORIZATION`, authorize its next `scaChallenge`. Do not +assume a fixed number of challenges for a cross-currency or other multi-step +send. + + + + + +Here the challenge is issued at *quote* time and `paymentInstructions` are +withheld until you clear it — you authorize before you fund. + + + +`POST /quotes` for a realtime-funded send returns `202` / +`PENDING_AUTHORIZATION` with an `scaChallenge`. `paymentInstructions` are +**omitted** from this response. + + +`POST /quotes/{quoteId}/authorize` with the proof — the challenge is carried by +the quote. Re-inspect the returned quote and continue the authorization loop if +it remains `PENDING_AUTHORIZATION`. + + +Read `paymentInstructions` from the returned (advanced) quote and fund the +transfer. Reading them off the initial pending response would show the customer +nothing to fund. + + + + + +`transfer-out` has no associated quote, and per-transaction SCA is authorized +only on the quote resource, so SCA-gated EU debits are **not** offered on this +endpoint — use the quote + `execute` flow above for EU customers. For customers +outside SCA-regulated regions, `transfer-out` proceeds as usual. + + diff --git a/mintlify/platform-overview/sca/trusted-beneficiaries.mdx b/mintlify/platform-overview/sca/trusted-beneficiaries.mdx new file mode 100644 index 000000000..0e59b32aa --- /dev/null +++ b/mintlify/platform-overview/sca/trusted-beneficiaries.mdx @@ -0,0 +1,94 @@ +--- +icon: "/images/icons/checkmark1.svg" +title: "Trusted beneficiaries" +description: "Whitelist a payee so future sends skip the per-transaction ceremony" +"og:image": "/images/og/og-get-started.png" +--- + + +Applies only to customers in an SCA-required region (EU). Every endpoint here +returns **`409`** for other customers. + + +Trusting a beneficiary is a one-time, SCA-gated step that whitelists an external +account. Only **USDC addresses** can be trusted today. Once trusted, future sends +to that payee are no longer dynamically linked; they drop to a lighter +authentication instead of a full per-transaction challenge. Use it for recurring +payouts to known destinations. + +The beneficiary is identified end-to-end by its **`externalAccountId`** in the +path, so there is no separate whitelist handle to track. All paths below are +relative to `https://api.lightspark.com/grid/2025-10-13`. + +## Trusting a beneficiary + + + +```bash +POST /customers/external-accounts/{externalAccountId}/trust +``` + +Returns the `scaChallenge` to satisfy: + +```json +{ "scaChallenge": { "id": "…", "factor": "SMS_OTP", "expiresAt": "2025-10-03T12:05:00Z", "availableFactors": ["SMS_OTP"] } } +``` + + +`scaChallenge` may be **omitted** when no challenge is issued. In that case, +confirm directly without a `challengeId`. + + + +Submit the proof for the factor Grid returned in `scaChallenge`: `code` for +`SMS_OTP` / `TOTP`, or `passkeyAssertion` + `origin` for `PASSKEY`. Echo +`challengeId` when start issued one. Do not assume a factor is available unless +it appears in `scaChallenge.availableFactors`. + +```bash +POST /customers/external-accounts/{externalAccountId}/trust/confirm + +{ "challengeId": "…", "code": "123456" } +``` + +```json +{ "trusted": true } +``` + +An invalid or expired proof returns `400`. **In sandbox, the code is always +`123456`.** + + + +## Untrusting a beneficiary + +Untrusting mirrors trusting: a start call issues the challenge, then confirm +submits the proof. + + + + +```bash +POST /customers/external-accounts/{externalAccountId}/untrust +``` + +Returns the `scaChallenge` to satisfy, omitted when no challenge is issued (the +caller then confirms without a `challengeId`). + + + +Submit the proof for the factor Grid returned in `scaChallenge`: `code` for +`SMS_OTP` / `TOTP`, or `passkeyAssertion` + `origin` for `PASSKEY`. Echo +`challengeId` when start issued one. Returns `trusted: false`. + +```bash +POST /customers/external-accounts/{externalAccountId}/untrust/confirm + +{ "challengeId": "…", "code": "123456" } +``` + + + + +Once untrusted, sends to that beneficiary are dynamically linked again and each +one requires a full per-transaction challenge. diff --git a/mintlify/platform-overview/sca/two-factor-reset.mdx b/mintlify/platform-overview/sca/two-factor-reset.mdx new file mode 100644 index 000000000..0a7be1861 --- /dev/null +++ b/mintlify/platform-overview/sca/two-factor-reset.mdx @@ -0,0 +1,82 @@ +--- +icon: "/images/icons/key2.svg" +title: "Two-factor reset" +description: "Recover a customer who has lost their SCA factor" +"og:image": "/images/og/og-get-started.png" +--- + + +Applies only to customers in an SCA-required region (EU). Every endpoint here +returns **`409`** for other customers. + + +When a customer loses an enrolled factor (a new phone, a deleted authenticator), +they recover it with a **2FA reset**: an identity (liveness) check that, once +passed, clears the lost factor so they can re-enroll it. It's a poll-based flow: +start, poll until liveness passes, then complete. + +All paths below are relative to `https://api.lightspark.com/grid/2025-10-13`. + + + + +```bash +POST /sca/factors/reset?customerId={customerId} + +{ "factor": "TOTP" } +``` + +Returns **`201`** with a `resetId` and opaque liveness handles. Embed +`livenessAccessToken` in the verification SDK, or send the customer to +`verificationLink`. `expiresAt` bounds the reset window. Reset initiation is +rate-limited to **5 per 24 hours** per customer; beyond that this returns `429`. + +```json +{ + "resetId": "…", + "livenessAccessToken": "…", + "verificationLink": "https://…", + "expiresAt": "2025-10-03T12:30:00Z" +} +``` + + + +```bash +GET /sca/factors/reset/{resetId}?customerId={customerId} +``` + +```json +{ "status": "INITIATED" } +``` + +Poll with a short backoff. `status` is one of `INITIATED`, `PENDING_REVIEW`, +`LIVENESS_PASSED`, `COMPLETED`, `REJECTED`, or `EXPIRED`: + +- `INITIATED` — reset started, liveness not yet submitted; keep polling. +- `PENDING_REVIEW` — liveness submitted and under review; keep polling. +- `LIVENESS_PASSED` — proceed to complete. +- `COMPLETED` (reset finished, factor cleared), `REJECTED` (liveness failed), and + `EXPIRED` (window closed) are **terminal** — stop polling. On `REJECTED` or + `EXPIRED`, start a new reset. + +The response also carries `factor`, `enrollmentStatus` (`PENDING` until the +replacement factor is re-enrolled, then `COMPLETED`; `null` for an `SMS_OTP` +reset), `expiresAt` (the window bound), and `completedAt`. Stop at any terminal +status or once `expiresAt` passes; never poll indefinitely. + + + +```bash +POST /sca/factors/reset/{resetId}/complete?customerId={customerId} + +{ "mobile": { "countryCode": "+1", "number": "4155550123" } } +``` + +Returns `204` and clears the lost factor. For an `SMS_OTP` reset, include the new +`mobile` number in the body — it's enrolled as the customer completes the reset; +other factors need no body. Calling it before liveness has passed returns `400`. +The customer can then re-enroll via +[factor enrollment](/platform-overview/sca/factor-enrollment). + + diff --git a/mintlify/ramps/conversion-flows/fiat-crypto-conversion.mdx b/mintlify/ramps/conversion-flows/fiat-crypto-conversion.mdx index 1baf0243a..16bac969a 100644 --- a/mintlify/ramps/conversion-flows/fiat-crypto-conversion.mdx +++ b/mintlify/ramps/conversion-flows/fiat-crypto-conversion.mdx @@ -113,6 +113,14 @@ curl -X POST 'https://api.lightspark.com/grid/2025-10-13/quotes' \ ```javascript function displayPaymentInstructions(quote) { + // EU customers only: an SCA-gated realtime-funding quote comes back + // PENDING_AUTHORIZATION with paymentInstructions withheld until its + // scaChallenge is authorized (see "Strong Customer Authentication" below). + // Authorize first, then display — don't read paymentInstructions while pending. + if (!quote.paymentInstructions?.length) { + return { pendingAuthorization: true, scaChallenge: quote.scaChallenge }; + } + const instructions = quote.paymentInstructions[0]; return { @@ -234,6 +242,16 @@ curl -X POST 'https://api.lightspark.com/grid/2025-10-13/quotes' \ `immediatelyExecute` can only be used with sources that are either internal accounts or external accounts with direct pull functionality (e.g., ACH pull). +## Strong Customer Authentication (EU customers) + +Customers in SCA-regulated regions (in practice the EU: EUR / USDC) must confirm +money movements with Strong Customer Authentication, and a conversion out of +EUR / USDC is gated on its swap leg. When it applies, the transaction comes back +`PENDING_AUTHORIZATION` with an `scaChallenge` that you authorize before the +conversion is released; for every other customer nothing changes. See +[Per-transaction authorization](/platform-overview/sca/per-transaction-authorization) +for the full walkthrough. + ## Best practices diff --git a/mintlify/snippets/sca/strong-customer-authentication.mdx b/mintlify/snippets/sca/strong-customer-authentication.mdx new file mode 100644 index 000000000..1b0e3ef16 --- /dev/null +++ b/mintlify/snippets/sca/strong-customer-authentication.mdx @@ -0,0 +1,106 @@ + +**This applies only to customers in a region where Strong Customer Authentication +is required, in practice customers in the EU (EUR / USDC).** For every other +customer, none of this appears: money-movement calls complete as usual, no +`scaChallenge` is returned, and the authorization endpoints are not used. If you +don't serve EU customers you can skip this section. + + +Under PSD2, EU e-money and e-money-token (EUR / USDC) money movement must be +confirmed by the end user with Strong Customer Authentication (SCA). Grid wraps +SCA so you satisfy it through the same resources you already use. There is no +separate SCA product to integrate. + +### When you'll encounter it + +For an SCA-required customer, a money-movement call that would otherwise complete +instead returns the transaction (or quote) in status **`PENDING_AUTHORIZATION`** +with an **`scaChallenge`** object, and the transfer is **not** released until the +challenge is satisfied. This affects debits such as: + +- Sending EUR / USDC (SEPA and intra-ledger transfers) +- Cross-currency conversions from EUR / USDC (the swap leg) +- On-chain and Lightning withdrawals + +EUR / USDC reads are covered by an active SCA login session; non-EUR/USDC +accounts do not require SCA. + +### Authentication factors + +The `scaChallenge.availableFactors` field tells you which factors the customer +may use. `scaChallenge.factor` is the one in use (default `SMS_OTP`). + +| Factor | Enrollment | Per-transaction debit | +|--------|-----------|-----------------------| +| `SMS_OTP` | None; a code is sent to the customer's verified phone | ✅ Default | +| `PASSKEY` | Required (WebAuthn credential) | ✅ | +| `TOTP` | Required (authenticator app) | Available only where the challenge is not dynamically linked | + +TOTP cannot satisfy a dynamically linked debit because its code cannot be bound +to the amount and payee. For a non-dynamically-linked challenge, use TOTP only +when it appears in `scaChallenge.availableFactors`; that field is authoritative. + +Request a specific factor per transaction with the optional top-level `scaFactor` +field on `execute` (`SMS_OTP` default, or `PASSKEY`). + +### Satisfying a challenge + +Submit an `ScaAuthorization` proof to `POST /quotes/{quoteId}/authorize` for the +quote that carries the challenge. Provide exactly one of `code` (for `SMS_OTP`) +or `passkeyAssertion` + `origin` (for `PASSKEY`): + +```bash +curl -X POST https://api.lightspark.com/grid/2025-10-13/quotes/{quoteId}/authorize \ + -u "$GRID_API_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ "code": "123456" }' +``` + + +**Write your client to loop on status, not on a fixed challenge count.** Treat +`scaChallenge` as the challenge to satisfy *now*, not necessarily the only one: +after authorizing, re-inspect the returned quote, and if it is still +`PENDING_AUTHORIZATION` it carries the **next** `scaChallenge` (a new `id`) — +authorize that one too and repeat until it leaves `PENDING_AUTHORIZATION`. + + +Once the quote is in `PENDING_AUTHORIZATION`, authorize it: +`POST /quotes/{quoteId}/authorize`. This is the single authorize path for both +`execute` (pre-funded) and realtime-funding quotes. The challenge — and the SMS +code or passkey assertion that satisfies it — only exists after the challenge is +issued, so the proof is always supplied on this follow-up call, never on the +originating request. + + +For a **realtime-funding quote**, the `202` / `PENDING_AUTHORIZATION` response +**withholds `paymentInstructions`** until the challenge is authorized. Authorize +first, then read `paymentInstructions` from the returned (advanced) quote. If you +read them off the initial pending response you'll show the customer nothing to +fund. + + +If an SMS code lapses before it's used, re-send it. The existing challenge is +reused, and its `expiresAt` is not extended. Use the quote resend endpoint: +`POST /quotes/{quoteId}/authorize/resend`. + +```bash +curl -X POST https://api.lightspark.com/grid/2025-10-13/quotes/{quoteId}/authorize/resend \ + -u "$GRID_API_TOKEN" +``` + + +In **sandbox**, the SMS code is always `123456`. + + +### Reducing prompts for repeat payees + +Trusting a beneficiary (a one-time SCA-gated whitelisting step) lets subsequent +sends to that payee skip the per-transaction challenge. Use this for recurring +payouts to known destinations rather than authorizing every send. + +### Calling a customer outside SCA-regulated regions + +The authorization endpoints return **`409`** for customers outside SCA-regulated +regions (non-EU), and no `scaChallenge` is ever attached to their transactions. +You don't need to branch on region. Handle `scaChallenge` when it's present and +treat its absence as nothing to do. From 133b813dd94c04fdc9d6ebbf794786b3a23c371a Mon Sep 17 00:00:00 2001 From: Jeremy Date: Fri, 28 Aug 2026 07:42:51 +0000 Subject: [PATCH 2/2] docs(sca): refresh the guide for API drift since the branch point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SCA surface moved after this guide was written; bring it current: - SCA login complete now requires `endUserIpAddress` and returns `sessionExpiresAt` (#780); the session-scope guidance now tells integrators to prompt re-login ahead of it. - Quote authorize documents `409 SCA_SESSION_REQUIRED` and `423 ACCOUNT_LOCKED` (#761); both join the error tables, and the snippet notes authorizing requires an active login session. - A challenge left to expire unsatisfied now fails the transaction with `failureReason: SCA_NOT_COMPLETED` and no funds moved (#762). - Trusted external accounts refuse `DELETE` with `409 BENEFICIARY_TRUSTED`; untrust first (#770). - The challenge lives on the quote, not the transaction — webhook consumers route via the transaction's `quoteId` (#701). - `POST /transfer-out` is deprecated in favor of `POST /quotes` with `immediatelyExecute: true` (#856); the transfer-out tab now says so. --- .../sending-payments.mdx | 8 ++-- .../payment-flow/send-payment.mdx | 6 +-- .../sca/factor-enrollment.mdx | 3 +- .../sca/login-and-sessions.mdx | 42 +++++++++++-------- mintlify/platform-overview/sca/overview.mdx | 19 ++++++--- .../sca/per-transaction-authorization.mdx | 20 +++++---- .../sca/trusted-beneficiaries.mdx | 8 ++++ .../fiat-crypto-conversion.mdx | 6 +-- .../sca/strong-customer-authentication.mdx | 23 ++++++++-- 9 files changed, 87 insertions(+), 48 deletions(-) diff --git a/mintlify/global-p2p/sending-receiving-payments/sending-payments.mdx b/mintlify/global-p2p/sending-receiving-payments/sending-payments.mdx index 37297fee5..9b34a71cd 100644 --- a/mintlify/global-p2p/sending-receiving-payments/sending-payments.mdx +++ b/mintlify/global-p2p/sending-receiving-payments/sending-payments.mdx @@ -28,10 +28,8 @@ import SendUMA from '/snippets/sending/uma.mdx' ## Strong Customer Authentication (EU customers) Customers in SCA-regulated regions (in practice the EU: EUR / USDC) must confirm -payments with Strong Customer Authentication. When it applies, the payment comes -back `PENDING_AUTHORIZATION` with an `scaChallenge` that you authorize before the -transfer is released; for every other customer nothing changes. See +payments with Strong Customer Authentication. When it applies, the quote comes +back `PENDING_AUTHORIZATION` carrying an `scaChallenge` that you authorize +before the transfer is released; for every other customer nothing changes. See [Per-transaction authorization](/platform-overview/sca/per-transaction-authorization) for the full walkthrough. - - diff --git a/mintlify/payouts-and-b2b/payment-flow/send-payment.mdx b/mintlify/payouts-and-b2b/payment-flow/send-payment.mdx index 74ebeb5b6..c6c25feca 100644 --- a/mintlify/payouts-and-b2b/payment-flow/send-payment.mdx +++ b/mintlify/payouts-and-b2b/payment-flow/send-payment.mdx @@ -267,9 +267,9 @@ end-to-end times measured from quote execution: ## Strong Customer Authentication (EU customers) Customers in SCA-regulated regions (in practice the EU: EUR / USDC) must confirm -payments with Strong Customer Authentication. When it applies, the payment comes -back `PENDING_AUTHORIZATION` with an `scaChallenge` that you authorize before the -transfer is released; for every other customer nothing changes. See +payments with Strong Customer Authentication. When it applies, the quote comes +back `PENDING_AUTHORIZATION` carrying an `scaChallenge` that you authorize +before the transfer is released; for every other customer nothing changes. See [Per-transaction authorization](/platform-overview/sca/per-transaction-authorization) for the full walkthrough. diff --git a/mintlify/platform-overview/sca/factor-enrollment.mdx b/mintlify/platform-overview/sca/factor-enrollment.mdx index a890e0395..31a5989f0 100644 --- a/mintlify/platform-overview/sca/factor-enrollment.mdx +++ b/mintlify/platform-overview/sca/factor-enrollment.mdx @@ -17,8 +17,7 @@ requested per transaction (see [per-transaction authorization](/platform-overview/sca/per-transaction-authorization)). Enrollment is two calls, both discriminated by a `type` field (`TOTP` or -`PASSKEY`) — the same shape the [login and session](/platform-overview/sca/login-and-sessions) -endpoints use: +`PASSKEY`): - `POST /sca/factors` — start enrollment; returns the factor-specific material. - `POST /sca/factors/confirm` — finish enrollment with the factor-specific proof. diff --git a/mintlify/platform-overview/sca/login-and-sessions.mdx b/mintlify/platform-overview/sca/login-and-sessions.mdx index 65e466ec1..fe6707bc4 100644 --- a/mintlify/platform-overview/sca/login-and-sessions.mdx +++ b/mintlify/platform-overview/sca/login-and-sessions.mdx @@ -50,33 +50,39 @@ The factor must already be enrolled (or, for `SMS_OTP`, the phone verified). See Submit the proof for the factor you started with: `code` for `SMS_OTP` / `TOTP` (echoing `challengeId` for `SMS_OTP`), or `passkeyAssertion` + `origin` for -`PASSKEY`. +`PASSKEY`. Every completion also requires **`endUserIpAddress`** — the IP of the +end user's device the login is performed from, recorded against the login event +and fed into risk assessment. Supply the customer's address, not your server's. ```bash POST /sca/login/complete?customerId={customerId} -{ "factor": "SMS_OTP", "challengeId": "…", "code": "123456" } +{ "factor": "SMS_OTP", "challengeId": "…", "code": "123456", "endUserIpAddress": "203.0.113.42" } ``` ```json -{ "status": "SUCCESS" } +{ "status": "SUCCESS", "sessionExpiresAt": "2026-01-29T12:00:00Z" } ``` -A `status` of `SUCCESS` means the session is open for 180 days and revokes any -previous SCA session for that customer. Any other value means the -login did not complete; the field is passed through verbatim, so treat only -`SUCCESS` as success. An invalid or expired proof returns `400`. **In sandbox, -the code is always `123456`.** +A `status` of `SUCCESS` opens the session and revokes any previous SCA session +for that customer, and `sessionExpiresAt` gives its absolute expiry — prompt a +re-login ahead of it rather than waiting for a call to fail. Any other `status` +value means the login did not complete; treat only `SUCCESS` as success. An +invalid or expired proof — or a missing or malformed `endUserIpAddress` — +returns `400`. **In sandbox, the code is always `123456`.** ## Session scope and fresh authentication -An active SCA login session covers EUR / USDC account reads for 180 days. A -request for transaction history older than 90 days requires fresh SCA, even when -the broader session has not expired. When Grid indicates that a session is -missing, expired, or too old for the requested history, restart the login flow -before retrying the read. +An active SCA login session covers EUR / USDC account reads for 180 days; the +login-complete response's `sessionExpiresAt` gives the exact timestamp it +lapses. A request for transaction history older than 90 days requires fresh SCA, +even when the broader session has not expired. Money movement in SCA-regulated +currencies is refused with **`409` `SCA_SESSION_REQUIRED`** once the session +passes, so track `sessionExpiresAt` and drive a re-login before it does. When +Grid indicates that a session is missing, expired, or too old for the requested +history, restart the login flow before retrying the read. ## Account-security signals @@ -92,8 +98,10 @@ POST /sca/record-event?customerId={customerId} Returns the customer's resulting login-security state so you can surface a lockout — `{ eventType, suspended, lockedUntil, failedAttempts }`. When the -customer is locked out, this (and `POST /sca/login/complete`) returns `423` with -`details.lockedUntil` (when they may retry) and `details.failedAttempts`. +customer is locked out, this endpoint, `POST /sca/login/complete`, and +[quote authorization](/platform-overview/sca/per-transaction-authorization) +return `423` with `details.lockedUntil` (when they may retry) and +`details.failedAttempts`. `eventType` must be one of: | `eventType` | Effect | @@ -109,8 +117,8 @@ The failed-login counter is cumulative and is **not** reset by a successful login; only `RESET_PASSWORD_COMPLETED` clears it. Record that event after a password recovery to zero the counter and clear a time-bounded lockout, rather than relying on the customer simply logging in again. A suspended customer -(9 or more failed attempts) requires support intervention; password recovery -does not unsuspend the account. +(9 or more failed attempts, locked with no automatic expiry) clears the +suspension the same way — a completed password reset. ## Your responsibilities diff --git a/mintlify/platform-overview/sca/overview.mdx b/mintlify/platform-overview/sca/overview.mdx index 76654d56a..e9d681694 100644 --- a/mintlify/platform-overview/sca/overview.mdx +++ b/mintlify/platform-overview/sca/overview.mdx @@ -80,7 +80,7 @@ source of truth for the challenge Grid issued. ## The authorization flow For an SCA-required customer, a money-movement call that would otherwise complete -instead returns the resource in status **`PENDING_AUTHORIZATION`** carrying an +instead returns the quote in status **`PENDING_AUTHORIZATION`** carrying an **`scaChallenge`**, and the transfer is not released until the challenge is satisfied. A single money movement can require **more than one** challenge in sequence, so loop on status rather than assuming one authorization releases the @@ -97,10 +97,10 @@ sequenceDiagram G->>U: Deliver challenge (e.g. SMS OTP) loop while status == PENDING_AUTHORIZATION U-->>P: Provide proof (code / passkey assertion) - P->>G: POST .../authorize (scaChallenge.id + proof) - G-->>P: Updated resource + next scaChallenge (if any) + P->>G: POST /quotes/{quoteId}/authorize (proof) + G-->>P: Updated quote + next scaChallenge (if any) end - G-->>P: Resource leaves PENDING_AUTHORIZATION, transfer released + G-->>P: Quote leaves PENDING_AUTHORIZATION, transfer released ``` [Per-transaction authorization](/platform-overview/sca/per-transaction-authorization) @@ -111,10 +111,10 @@ expired code, and the realtime-funding-quote nuance. | Aspect | Behavior | |---|---| -| Challenge expiry | Each challenge carries an absolute `scaChallenge.expiresAt` (UTC). SMS codes expire after 5 minutes; TOTP and passkey challenges expire after 10 minutes. After expiry, the challenge can no longer be authorized. | +| Challenge expiry | Each challenge carries an absolute `scaChallenge.expiresAt` (UTC). SMS codes expire after 5 minutes; TOTP and passkey challenges expire after 10 minutes. After expiry, the challenge can no longer be authorized — if it lapses unsatisfied, the transaction fails with `failureReason: SCA_NOT_COMPLETED` and no funds move; create a new quote and authorize while the new challenge is live. | | Resend | `SMS_OTP` only. Resending reuses the existing challenge and does not extend `expiresAt`; `PASSKEY` (and `TOTP`) codes can't be resent. | | Repeated failures | A challenge permits at most 5 attempts. Too many failed authorizations may invalidate it and return `429 RATE_LIMITED`, so honor `Retry-After`. | -| Login session | A completed [SCA login](/platform-overview/sca/login-and-sessions) grants a 180-day session. EUR / USDC history older than 90 days requires fresh SCA even during that session. | +| Login session | A completed [SCA login](/platform-overview/sca/login-and-sessions) grants a 180-day session whose exact expiry is returned as `sessionExpiresAt`. EUR / USDC history older than 90 days requires fresh SCA even during that session, and money movement is refused with `SCA_SESSION_REQUIRED` once it passes — prompt a re-login ahead of `sessionExpiresAt`. | | Account lockout | Repeated `FAILED_LOGIN_ATTEMPT` signals escalate a lockout (5 → 15 min, 6 → 30 min, 7 → 1 hour, 8 → 24 hours, 9+ → suspension). See [account-security signals](/platform-overview/sca/login-and-sessions#account-security-signals). | | 2FA reset window | A started reset carries its own `expiresAt`; complete it before then. | @@ -124,9 +124,16 @@ expired code, and the realtime-funding-quote nuance. |---|---|---| | `400` | Invalid or expired proof: wrong code, expired challenge, or a factor that can't satisfy this challenge (e.g. `TOTP` on a dynamically-linked debit). | Re-collect the proof. If the code lapsed, resend (`SMS_OTP`) or start over. | | `409` | SCA isn't required for this customer (non-EU), there's no pending challenge, or the factor's code can't be resent (e.g. `PASSKEY`). | Don't retry the same call. Treat a non-EU `409` as nothing to authorize. | +| `409` `SCA_SESSION_REQUIRED` | The customer's SCA login session is missing or expired, so the money movement or authorization is refused. | Complete a fresh [SCA login](/platform-overview/sca/login-and-sessions), then retry the call. | +| `423` `ACCOUNT_LOCKED` | The customer is locked out after repeated failed attempts. | Wait out `details.lockedUntil`; see [account-security signals](/platform-overview/sca/login-and-sessions#account-security-signals). | | `429` | `RATE_LIMITED`: too many attempts or resends, and the challenge may now be invalidated. | Honor `Retry-After`; you may need to restart the flow. | | `404` | The customer, transaction, quote, external account, or reset wasn't found. | Check the id. | +A challenge left unsatisfied until it expires fails the transaction with +`failureReason: SCA_NOT_COMPLETED` rather than an HTTP error — no funds moved, +so create a new quote and have the customer authorize while the challenge is +live. + ## Calling a customer outside SCA-regulated regions Every authentication endpoint returns **`409`** for customers outside diff --git a/mintlify/platform-overview/sca/per-transaction-authorization.mdx b/mintlify/platform-overview/sca/per-transaction-authorization.mdx index ec55da5ff..b94f339b6 100644 --- a/mintlify/platform-overview/sca/per-transaction-authorization.mdx +++ b/mintlify/platform-overview/sca/per-transaction-authorization.mdx @@ -6,9 +6,10 @@ description: "Authorize an SCA-gated money movement" --- This is the flow you hit most often: an SCA-required customer initiates a money -movement, it comes back `PENDING_AUTHORIZATION` with an `scaChallenge`, and you -authorize it before the transfer is released. For where this sits in the wider -SCA surface, see the [overview](/platform-overview/sca/overview). +movement, the quote comes back `PENDING_AUTHORIZATION` carrying an +`scaChallenge`, and you authorize it before the transfer is released. For where +this sits in the wider SCA surface, see the +[overview](/platform-overview/sca/overview). import StrongCustomerAuthentication from '/snippets/sca/strong-customer-authentication.mdx'; @@ -66,10 +67,13 @@ nothing to fund. - -`transfer-out` has no associated quote, and per-transaction SCA is authorized -only on the quote resource, so SCA-gated EU debits are **not** offered on this -endpoint — use the quote + `execute` flow above for EU customers. For customers -outside SCA-regulated regions, `transfer-out` proceeds as usual. + +`POST /transfer-out` is **deprecated** — create a quote with an internal account +source, an external account destination, and `immediatelyExecute: true` for the +same single-request send, then follow the quote + `execute` flow above (see the +[quote system](/platform-overview/core-concepts/quote-system) guide). The legacy +endpoint has no associated quote, and per-transaction SCA is authorized only on +the quote resource, so SCA-gated EU debits are not offered on it. For customers +outside SCA-regulated regions it continues to work until removal. diff --git a/mintlify/platform-overview/sca/trusted-beneficiaries.mdx b/mintlify/platform-overview/sca/trusted-beneficiaries.mdx index 0e59b32aa..6145c02ff 100644 --- a/mintlify/platform-overview/sca/trusted-beneficiaries.mdx +++ b/mintlify/platform-overview/sca/trusted-beneficiaries.mdx @@ -92,3 +92,11 @@ POST /customers/external-accounts/{externalAccountId}/untrust/confirm Once untrusted, sends to that beneficiary are dynamically linked again and each one requires a full per-transaction challenge. + +## Deleting a trusted account + +A trusted external account can't be deleted while the trust is in place: +`DELETE /customers/external-accounts/{externalAccountId}` returns +**`409` `BENEFICIARY_TRUSTED`**. Untrust first (the SCA-gated flow above), then +delete. Trust is never revoked as a side effect of a delete — untrusting +requires the customer's SCA challenge, which a `DELETE` has no way to carry. diff --git a/mintlify/ramps/conversion-flows/fiat-crypto-conversion.mdx b/mintlify/ramps/conversion-flows/fiat-crypto-conversion.mdx index 16bac969a..03c159b0b 100644 --- a/mintlify/ramps/conversion-flows/fiat-crypto-conversion.mdx +++ b/mintlify/ramps/conversion-flows/fiat-crypto-conversion.mdx @@ -117,7 +117,7 @@ function displayPaymentInstructions(quote) { // PENDING_AUTHORIZATION with paymentInstructions withheld until its // scaChallenge is authorized (see "Strong Customer Authentication" below). // Authorize first, then display — don't read paymentInstructions while pending. - if (!quote.paymentInstructions?.length) { + if (quote.status === "PENDING_AUTHORIZATION") { return { pendingAuthorization: true, scaChallenge: quote.scaChallenge }; } @@ -246,8 +246,8 @@ curl -X POST 'https://api.lightspark.com/grid/2025-10-13/quotes' \ Customers in SCA-regulated regions (in practice the EU: EUR / USDC) must confirm money movements with Strong Customer Authentication, and a conversion out of -EUR / USDC is gated on its swap leg. When it applies, the transaction comes back -`PENDING_AUTHORIZATION` with an `scaChallenge` that you authorize before the +EUR / USDC is gated on its swap leg. When it applies, the quote comes back +`PENDING_AUTHORIZATION` carrying an `scaChallenge` that you authorize before the conversion is released; for every other customer nothing changes. See [Per-transaction authorization](/platform-overview/sca/per-transaction-authorization) for the full walkthrough. diff --git a/mintlify/snippets/sca/strong-customer-authentication.mdx b/mintlify/snippets/sca/strong-customer-authentication.mdx index 1b0e3ef16..99bf6f7e8 100644 --- a/mintlify/snippets/sca/strong-customer-authentication.mdx +++ b/mintlify/snippets/sca/strong-customer-authentication.mdx @@ -14,9 +14,12 @@ separate SCA product to integrate. ### When you'll encounter it For an SCA-required customer, a money-movement call that would otherwise complete -instead returns the transaction (or quote) in status **`PENDING_AUTHORIZATION`** -with an **`scaChallenge`** object, and the transfer is **not** released until the -challenge is satisfied. This affects debits such as: +instead returns the **quote** in status **`PENDING_AUTHORIZATION`** carrying an +**`scaChallenge`** object, and the transfer is **not** released until the +challenge is satisfied. The resulting transaction's webhooks also report +`PENDING_AUTHORIZATION`, but the challenge lives on the quote — fetch it with +`GET /quotes/{quoteId}` using the transaction's `quoteId`. This affects debits +such as: - Sending EUR / USDC (SEPA and intra-ledger transfers) - Cross-currency conversions from EUR / USDC (the swap leg) @@ -53,9 +56,17 @@ or `passkeyAssertion` + `origin` (for `PASSKEY`): curl -X POST https://api.lightspark.com/grid/2025-10-13/quotes/{quoteId}/authorize \ -u "$GRID_API_TOKEN" \ -H "Content-Type: application/json" \ - -d '{ "code": "123456" }' + -d '{ "code": "123456", "endUserIpAddress": "203.0.113.42" }' ``` +The optional `endUserIpAddress` is the IP of the end user's device authorizing +the operation; it feeds the SCA provider's risk assessment, so supply the +customer's address, not your server's. + +Authorizing requires an active [SCA login session](/platform-overview/sca/login-and-sessions): +if it has lapsed, the call returns `409` `SCA_SESSION_REQUIRED` — complete a +fresh login, then authorize again. + **Write your client to loop on status, not on a fixed challenge count.** Treat `scaChallenge` as the challenge to satisfy *now*, not necessarily the only one: @@ -88,6 +99,10 @@ curl -X POST https://api.lightspark.com/grid/2025-10-13/quotes/{quoteId}/authori -u "$GRID_API_TOKEN" ``` +If the challenge expires unsatisfied, the transaction fails with +`failureReason: SCA_NOT_COMPLETED` and no funds move. Create a new quote and +have the customer authorize while the new challenge is live. + In **sandbox**, the SMS code is always `123456`.