From 06294ab217ceda1d7a6257d3a684425aaef89034 Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Mon, 13 Jul 2026 11:30:46 -0700 Subject: [PATCH 1/7] docs(sca): document Strong Customer Authentication for EU customers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an EU-scoped SCA section to the money-movement guides where it's relevant — payouts send-payment, global-p2p sending-payments, and ramps fiat/crypto conversion — via a reusable snippet. Mirrors Striga's SCA docs, adapted to Grid's surface: when a transaction/quote comes back PENDING_AUTHORIZATION with an scaChallenge, satisfy it through the authorize endpoints in a status-driven loop (may be more than one), covering the SMS/passkey factors, inline scaAuthorization, resend, sandbox 123456, and trusted-beneficiary. Scoped so non-EU integrators have nothing to do. Co-Authored-By: Claude --- .../sending-payments.mdx | 6 + .../payment-flow/send-payment.mdx | 5 + .../fiat-crypto-conversion.mdx | 5 + .../sca/strong-customer-authentication.mdx | 108 ++++++++++++++++++ 4 files changed, 124 insertions(+) create mode 100644 mintlify/snippets/sca/strong-customer-authentication.mdx diff --git a/mintlify/global-p2p/sending-receiving-payments/sending-payments.mdx b/mintlify/global-p2p/sending-receiving-payments/sending-payments.mdx index d8e1b6b9d..3fcde6d7c 100644 --- a/mintlify/global-p2p/sending-receiving-payments/sending-payments.mdx +++ b/mintlify/global-p2p/sending-receiving-payments/sending-payments.mdx @@ -27,4 +27,10 @@ import SendUMA from '/snippets/sending/uma.mdx' +import StrongCustomerAuthentication from '/snippets/sca/strong-customer-authentication.mdx' + +## Strong Customer Authentication (EU customers) + + + diff --git a/mintlify/payouts-and-b2b/payment-flow/send-payment.mdx b/mintlify/payouts-and-b2b/payment-flow/send-payment.mdx index 548095f84..9f0e66bc2 100644 --- a/mintlify/payouts-and-b2b/payment-flow/send-payment.mdx +++ b/mintlify/payouts-and-b2b/payment-flow/send-payment.mdx @@ -6,6 +6,7 @@ icon: "/images/icons/paper-plane-top-right.svg" --- import { FeatureCard, FeatureCardGrid } from '/snippets/feature-card.mdx'; +import StrongCustomerAuthentication from '/snippets/sca/strong-customer-authentication.mdx'; Send payments from your customers' internal accounts to their external bank accounts or to other destinations. Grid supports both same-currency transfers and cross-currency transfers with automatic exchange rate handling. @@ -362,6 +363,10 @@ If a transaction fails, Grid initiates a refund automatically. You'll receive `O | `FAILED` | Transfer failed — refund initiated automatically (track via `refund` object) | | `EXPIRED` | Quote expired without execution | +## Strong Customer Authentication (EU customers) + + + ## Checking Payment Status Configure a webhook endpoint to receive real-time notifications when payment status changes: diff --git a/mintlify/ramps/conversion-flows/fiat-crypto-conversion.mdx b/mintlify/ramps/conversion-flows/fiat-crypto-conversion.mdx index d4a4c56b0..94095a789 100644 --- a/mintlify/ramps/conversion-flows/fiat-crypto-conversion.mdx +++ b/mintlify/ramps/conversion-flows/fiat-crypto-conversion.mdx @@ -6,6 +6,7 @@ icon: "/images/icons/repeat.svg" --- import { FeatureCard, FeatureCardGrid } from '/snippets/feature-card.mdx'; +import StrongCustomerAuthentication from '/snippets/sca/strong-customer-authentication.mdx'; ## Overview @@ -233,6 +234,10 @@ 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) + + + ## 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..979484412 --- /dev/null +++ b/mintlify/snippets/sca/strong-customer-authentication.mdx @@ -0,0 +1,108 @@ + +**This applies only to customers whose payment provider requires Strong Customer +Authentication — 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 +the provider's 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 + +Reads and non-EUR/USDC accounts are not gated this way. + +### 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 — code sent to the customer's verified phone | ✅ Default | +| `PASSKEY` | Required (WebAuthn credential) | ✅ | +| `TOTP` | Required (authenticator app) | ❌ Not permitted — a TOTP code can't be dynamically linked to the amount and payee | + +Request a specific factor per transaction with the optional top-level `scaFactor` +field on `execute` / `transfer-out` (`SMS_OTP` default, or `PASSKEY`). + +### Satisfying a challenge + +Submit an `ScaAuthorization` proof — exactly one of `code` (for `SMS_OTP`) or +`passkeyAssertion` + `origin` (for `PASSKEY`) — to the authorization endpoint for +the resource that carries the challenge: + +```bash +curl -X POST https://api.lightspark.com/grid/2025-10-13/transactions/{transactionId}/authorize \ + -u "$GRID_API_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ "code": "123456" }' +``` + + +**A single money movement may require more than one authorization, in sequence.** +Treat `scaChallenge` as the challenge to satisfy *now*, not the only one. After +each authorization, re-inspect the returned transaction: if it is still +`PENDING_AUTHORIZATION` it carries the **next** `scaChallenge` (a new `id`) — +authorize that one too, and repeat until the transaction leaves +`PENDING_AUTHORIZATION`. Don't assume one authorization releases the transfer. + +For example, a cross-currency send today authorizes the currency conversion and +then the payout as two separate challenges (the optional `scaChallenge.purpose` +labels each — e.g. `CURRENCY_CONVERSION`, `PAYOUT` — for display only). A client +written to loop on status handles any number of steps unchanged. + + +You have two ways to supply the first proof: + + + +Pass `scaAuthorization` in the body of the originating `execute` / `transfer-out` +call to satisfy the first challenge in the same request and skip the pending +state. Any subsequent challenges in the sequence still use the authorize +endpoint. + + +Omit it, receive `PENDING_AUTHORIZATION` + `scaChallenge`, then call the +authorize endpoint for the resource: +- After `execute` / `transfer-out`: `POST /transactions/{transactionId}/authorize` +- For a realtime-funding quote returned from `POST /quotes`: `POST /quotes/{quoteId}/authorize` + + + +If an SMS code lapses before it's used, re-send it (the existing challenge is +reused, and its `expiresAt` is not extended): + +```bash +curl -X POST https://api.lightspark.com/grid/2025-10-13/transactions/{transactionId}/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 whose provider doesn't require SCA + +The authorization endpoints return **`409`** for customers whose provider does +not require SCA, and no `scaChallenge` is ever attached to their transactions. +You don't need to branch on region — just handle `scaChallenge` when it's present +and treat its absence as "nothing to do." From 15852627219710d0475285f614ed0dcdf65eb2ca Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Mon, 13 Jul 2026 11:46:05 -0700 Subject: [PATCH 2/7] docs(sca): add quote-scoped resend + note withheld paymentInstructions Address Greptile review on #683: - Document the quote resend endpoint (POST /quotes/{quoteId}/authorize/resend) alongside the transaction one, since a realtime-funding quote's SMS challenge exists before any transaction. - Note that a realtime-funding 202 withholds paymentInstructions until the challenge is authorized, so integrators authorize first, then read them. Co-Authored-By: Claude --- .../sca/strong-customer-authentication.mdx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/mintlify/snippets/sca/strong-customer-authentication.mdx b/mintlify/snippets/sca/strong-customer-authentication.mdx index 979484412..114020771 100644 --- a/mintlify/snippets/sca/strong-customer-authentication.mdx +++ b/mintlify/snippets/sca/strong-customer-authentication.mdx @@ -82,8 +82,20 @@ authorize endpoint for the resource: + +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 — don't +read them off the initial pending response, or 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): +reused, and its `expiresAt` is not extended). Use the resend endpoint for the +resource that carries the challenge: + +- Transaction (after `execute` / `transfer-out`): `POST /transactions/{transactionId}/authorize/resend` +- Realtime-funding quote (from `POST /quotes`): `POST /quotes/{quoteId}/authorize/resend` ```bash curl -X POST https://api.lightspark.com/grid/2025-10-13/transactions/{transactionId}/authorize/resend \ From 09336a56fb2ac713ab40cedd2f1df0e64f843cf2 Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Mon, 13 Jul 2026 11:52:38 -0700 Subject: [PATCH 3/7] docs(sca): guard the fiat-to-crypto paymentInstructions example for SCA-pending quotes The runnable displayPaymentInstructions example dereferenced quote.paymentInstructions[0] unconditionally, which throws for an EU realtime-funding quote returned as PENDING_AUTHORIZATION (instructions withheld until the scaChallenge is authorized). Guard for the pending case and point at the SCA section. Addresses Greptile P1 on #683. Co-Authored-By: Claude --- .../ramps/conversion-flows/fiat-crypto-conversion.mdx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/mintlify/ramps/conversion-flows/fiat-crypto-conversion.mdx b/mintlify/ramps/conversion-flows/fiat-crypto-conversion.mdx index 94095a789..4e0d4864c 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 { From 3d32d1fd45814d918dba8830f6ef680da4feb6cf Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Mon, 13 Jul 2026 23:29:14 -0700 Subject: [PATCH 4/7] docs(sca): reframe SCA snippet by region, not provider Match the #558/#600 terminology fix in the embedded SCA docs section: 'customers in a region where SCA is required (EU)' / 'customers outside SCA-regulated regions' instead of 'whose payment provider requires SCA'. Co-Authored-By: Claude --- .../sca/strong-customer-authentication.mdx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/mintlify/snippets/sca/strong-customer-authentication.mdx b/mintlify/snippets/sca/strong-customer-authentication.mdx index 114020771..2e16a8917 100644 --- a/mintlify/snippets/sca/strong-customer-authentication.mdx +++ b/mintlify/snippets/sca/strong-customer-authentication.mdx @@ -1,6 +1,6 @@ -**This applies only to customers whose payment provider requires Strong Customer -Authentication — in practice, customers in the EU (EUR / USDC).** For every other +**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. @@ -8,8 +8,8 @@ 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 -the provider's SCA so you satisfy it through the same resources you already use — -there is no separate SCA product to integrate. +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 @@ -112,9 +112,9 @@ 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 whose provider doesn't require SCA +### Calling a customer outside SCA-regulated regions -The authorization endpoints return **`409`** for customers whose provider does -not require SCA, and no `scaChallenge` is ever attached to their transactions. +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 — just handle `scaChallenge` when it's present and treat its absence as "nothing to do." From 6be7905e874a66fa21d6cf978f719d52a66479a7 Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Tue, 14 Jul 2026 01:15:23 -0700 Subject: [PATCH 5/7] docs(sca): expand into a full multi-page SCA guide Grows the SCA docs from a single per-transaction snippet into a dedicated "Strong Customer Authentication" guide covering the whole EU surface the API exposes, mirroring the structure of the reference docs it wraps. - overview: region scope, a "what SCA covers" coverage table, factors + dynamic-linking, a Mermaid flow diagram, lifetimes/limits and error tables, and navigation cards. - per-transaction-authorization: reuses the existing snippet as the single source (flow pages unchanged). - factor-enrollment: TOTP + passkey enrollment, list, delete. - login-and-sessions: SCA login + the session it grants, plus account-security (record-event) signals and the lockout ladder. - trusted-beneficiaries: start / confirm / untrust walkthrough (keyed by externalAccountId). - two-factor-reset: liveness-gated reset (start -> poll -> complete). All EU-scoped; every endpoint 409s for non-SCA customers. --- mintlify/docs.json | 11 ++ .../sca/factor-enrollment.mdx | 122 ++++++++++++++++ .../sca/login-and-sessions.mdx | 82 +++++++++++ mintlify/platform-overview/sca/overview.mdx | 131 ++++++++++++++++++ .../sca/per-transaction-authorization.mdx | 15 ++ .../sca/trusted-beneficiaries.mdx | 73 ++++++++++ .../sca/two-factor-reset.mdx | 63 +++++++++ .../sca/strong-customer-authentication.mdx | 36 ++--- 8 files changed, 515 insertions(+), 18 deletions(-) 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 diff --git a/mintlify/docs.json b/mintlify/docs.json index f74c8476f..ab8eec45b 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/platform-overview/sca/factor-enrollment.mdx b/mintlify/platform-overview/sca/factor-enrollment.mdx new file mode 100644 index 000000000..0db9b6b2e --- /dev/null +++ b/mintlify/platform-overview/sca/factor-enrollment.mdx @@ -0,0 +1,122 @@ +--- +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)). + +All paths below are relative to `https://api.lightspark.com/grid/2025-10-13`. + +## Enroll a TOTP authenticator + + + +```bash +POST /customers/{customerId}/sca/factors/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 +{ + "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 /customers/{customerId}/sca/factors/totp/confirm + +{ "secret": "…", "code": "123456" } +``` + +```json +{ "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. + + + +```bash +POST /customers/{customerId}/sca/factors/passkey +``` + +```json +{ + "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 /customers/{customerId}/sca/factors/passkey/confirm + +{ "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 /customers/{customerId}/sca/factors +``` + +```json +{ + "factors": [ + { "factor": "TOTP", "name": "Authenticator app" }, + { "factor": "PASSKEY", "credentialId": "…", "name": "iPhone" } + ] +} +``` + +`credentialId` is populated only for `PASSKEY` factors. + +## Delete a passkey + +```bash +DELETE /customers/{customerId}/sca/factors/passkey/{credentialId} +``` + +Returns `204`. Use the `credentialId` from the factor list (or the confirm +response). 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..89b885ec4 --- /dev/null +++ b/mintlify/platform-overview/sca/login-and-sessions.mdx @@ -0,0 +1,82 @@ +--- +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). + +All paths below are relative to `https://api.lightspark.com/grid/2025-10-13`. + +## Logging in + + + +```bash +POST /customers/{customerId}/sca/login/start + +{ "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 /customers/{customerId}/sca/login/complete + +{ "factor": "SMS_OTP", "challengeId": "…", "code": "123456" } +``` + +```json +{ "status": "SUCCESS" } +``` + +A `status` of `SUCCESS` means the session is open. 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`.** + + + +## 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 /customers/{customerId}/sca/record-event + +{ "eventType": "FAILED_LOGIN_ATTEMPT" } +``` + +Returns `204`. `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`. diff --git a/mintlify/platform-overview/sca/overview.mdx b/mintlify/platform-overview/sca/overview.mdx new file mode 100644 index 000000000..336ece539 --- /dev/null +++ b/mintlify/platform-overview/sca/overview.mdx @@ -0,0 +1,131 @@ +--- +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. Reads and non-EUR/USDC accounts +are never gated. "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) | ❌ Not permitted (a TOTP code can't be dynamically linked to the amount and payee) | + +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, and sends to an already-trusted beneficiary. + +## 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 / transfer-out) + 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: inline versus after-the-fact proof, 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). After it, 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 | Too many failed authorizations may invalidate the challenge and return `429 RATE_LIMITED`, so honor `Retry-After`. | +| Login session | A completed [SCA login](/platform-overview/sca/login-and-sessions) grants a session that covers reads and account access beyond the per-transaction window; when it lapses the customer logs in again. | +| 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..aa41a40ca --- /dev/null +++ b/mintlify/platform-overview/sca/per-transaction-authorization.mdx @@ -0,0 +1,15 @@ +--- +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'; + + diff --git a/mintlify/platform-overview/sca/trusted-beneficiaries.mdx b/mintlify/platform-overview/sca/trusted-beneficiaries.mdx new file mode 100644 index 000000000..39418e619 --- /dev/null +++ b/mintlify/platform-overview/sca/trusted-beneficiaries.mdx @@ -0,0 +1,73 @@ +--- +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 (an IBAN or crypto address). 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/{customerId}/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 SCA proof (`code` for `SMS_OTP` / `TOTP`, or `passkeyAssertion` + +`origin` for `PASSKEY`), echoing `challengeId` when start issued one. + +```bash +POST /customers/{customerId}/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 + +Revoking trust is a single confirm call with the SCA proof, the same request +shape as trust/confirm. Returns `trusted: false`. + +```bash +POST /customers/{customerId}/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..21617b7aa --- /dev/null +++ b/mintlify/platform-overview/sca/two-factor-reset.mdx @@ -0,0 +1,63 @@ +--- +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 /customers/{customerId}/sca/factors/reset + +{ "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. + +```json +{ + "resetId": "…", + "livenessAccessToken": "…", + "verificationLink": "https://…", + "expiresAt": "2025-10-03T12:30:00Z" +} +``` + + +```bash +GET /customers/{customerId}/sca/factors/reset/{resetId} +``` + +```json +{ "status": "PENDING" } +``` + +Keep polling while `status` is non-terminal (e.g. `PENDING`). Once it reaches +`LIVENESS_PASSED`, proceed. The field is passed through verbatim, so treat any +value other than `LIVENESS_PASSED` as "not ready yet" rather than a failure. + + +```bash +POST /customers/{customerId}/sca/factors/reset/{resetId}/complete +``` + +Returns `204` and clears the lost factor. 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/snippets/sca/strong-customer-authentication.mdx b/mintlify/snippets/sca/strong-customer-authentication.mdx index 2e16a8917..dc57eaa1c 100644 --- a/mintlify/snippets/sca/strong-customer-authentication.mdx +++ b/mintlify/snippets/sca/strong-customer-authentication.mdx @@ -1,6 +1,6 @@ **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 +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. @@ -8,7 +8,7 @@ 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 +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 @@ -31,18 +31,18 @@ may use. `scaChallenge.factor` is the one in use (default `SMS_OTP`). | Factor | Enrollment | Per-transaction debit | |--------|-----------|-----------------------| -| `SMS_OTP` | None — code sent to the customer's verified phone | ✅ Default | +| `SMS_OTP` | None; a code is sent to the customer's verified phone | ✅ Default | | `PASSKEY` | Required (WebAuthn credential) | ✅ | -| `TOTP` | Required (authenticator app) | ❌ Not permitted — a TOTP code can't be dynamically linked to the amount and payee | +| `TOTP` | Required (authenticator app) | ❌ Not permitted (a TOTP code can't be dynamically linked to the amount and payee) | Request a specific factor per transaction with the optional top-level `scaFactor` field on `execute` / `transfer-out` (`SMS_OTP` default, or `PASSKEY`). ### Satisfying a challenge -Submit an `ScaAuthorization` proof — exactly one of `code` (for `SMS_OTP`) or -`passkeyAssertion` + `origin` (for `PASSKEY`) — to the authorization endpoint for -the resource that carries the challenge: +Submit an `ScaAuthorization` proof to the authorization endpoint for the resource +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/transactions/{transactionId}/authorize \ @@ -55,13 +55,13 @@ curl -X POST https://api.lightspark.com/grid/2025-10-13/transactions/{transactio **A single money movement may require more than one authorization, in sequence.** Treat `scaChallenge` as the challenge to satisfy *now*, not the only one. After each authorization, re-inspect the returned transaction: if it is still -`PENDING_AUTHORIZATION` it carries the **next** `scaChallenge` (a new `id`) — -authorize that one too, and repeat until the transaction leaves +`PENDING_AUTHORIZATION` it carries the **next** `scaChallenge` (a new `id`). +Authorize that one too, and repeat until the transaction leaves `PENDING_AUTHORIZATION`. Don't assume one authorization releases the transfer. For example, a cross-currency send today authorizes the currency conversion and -then the payout as two separate challenges (the optional `scaChallenge.purpose` -labels each — e.g. `CURRENCY_CONVERSION`, `PAYOUT` — for display only). A client +then the payout as two separate challenges. The optional `scaChallenge.purpose` +labels each one (`CURRENCY_CONVERSION`, `PAYOUT`) for display only. A client written to loop on status handles any number of steps unchanged. @@ -85,13 +85,13 @@ authorize endpoint for the resource: 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 — don't -read them off the initial pending response, or you'll show the customer nothing -to fund. +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 resend endpoint for the +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 resend endpoint for the resource that carries the challenge: - Transaction (after `execute` / `transfer-out`): `POST /transactions/{transactionId}/authorize/resend` @@ -116,5 +116,5 @@ payouts to known destinations rather than authorizing every send. 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 — just handle `scaChallenge` when it's present -and treat its absence as "nothing to do." +You don't need to branch on region. Handle `scaChallenge` when it's present and +treat its absence as nothing to do. From ac2883cfd71481821926e1530490ae2127e4ab2f Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Tue, 14 Jul 2026 10:05:33 -0700 Subject: [PATCH 6/7] docs(sca): address review on the SCA guide - two-factor-reset: give the poll loop a bounded stop condition (stop at expiresAt / a client timeout; start a new reset if the window closes before liveness passes) instead of implying an open-ended wait. - login-and-sessions: note the failed-login counter is cumulative and cleared only by RESET_PASSWORD_COMPLETED, not by a successful login. - trusted-beneficiaries: untrust has no start step, so document that it's confirmed with a self-produced proof (TOTP) and challengeId is omitted; SMS/passkey untrust would need a start endpoint that isn't in the surface yet. - fix MD031/MD040 markdownlint violations on the new fenced code blocks. --- .../platform-overview/sca/factor-enrollment.mdx | 1 + .../platform-overview/sca/login-and-sessions.mdx | 8 ++++++++ .../sca/trusted-beneficiaries.mdx | 14 +++++++++++--- .../platform-overview/sca/two-factor-reset.mdx | 15 ++++++++++++--- 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/mintlify/platform-overview/sca/factor-enrollment.mdx b/mintlify/platform-overview/sca/factor-enrollment.mdx index 0db9b6b2e..0a5409a63 100644 --- a/mintlify/platform-overview/sca/factor-enrollment.mdx +++ b/mintlify/platform-overview/sca/factor-enrollment.mdx @@ -37,6 +37,7 @@ add it to their authenticator app. "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. diff --git a/mintlify/platform-overview/sca/login-and-sessions.mdx b/mintlify/platform-overview/sca/login-and-sessions.mdx index 89b885ec4..d8205c23f 100644 --- a/mintlify/platform-overview/sca/login-and-sessions.mdx +++ b/mintlify/platform-overview/sca/login-and-sessions.mdx @@ -22,6 +22,7 @@ All paths below are relative to `https://api.lightspark.com/grid/2025-10-13`. + ```bash POST /customers/{customerId}/sca/login/start @@ -80,3 +81,10 @@ Returns `204`. `eventType` must be one of: 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 lift a lockout, rather than relying on +the customer simply logging in again. + diff --git a/mintlify/platform-overview/sca/trusted-beneficiaries.mdx b/mintlify/platform-overview/sca/trusted-beneficiaries.mdx index 39418e619..a0638a68b 100644 --- a/mintlify/platform-overview/sca/trusted-beneficiaries.mdx +++ b/mintlify/platform-overview/sca/trusted-beneficiaries.mdx @@ -60,14 +60,22 @@ An invalid or expired proof returns `400`. **In sandbox, the code is always ## Untrusting a beneficiary -Revoking trust is a single confirm call with the SCA proof, the same request -shape as trust/confirm. Returns `trusted: false`. +Untrust has no separate start step. You confirm it directly with an SCA proof, +the same request shape as trust/confirm, and it returns `trusted: false`. ```bash POST /customers/{customerId}/external-accounts/{externalAccountId}/untrust/confirm -{ "challengeId": "…", "code": "123456" } +{ "code": "123456" } ``` + +Because there is no untrust start step, nothing dispatches an SMS code or issues +passkey options for the untrust. The customer authenticates with a proof they can +produce on their own, a `TOTP` code, and `challengeId` is omitted. SMS and passkey +untrust would each need a start step to issue the challenge, which isn't part of +the current surface. + + 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 index 21617b7aa..401bab243 100644 --- a/mintlify/platform-overview/sca/two-factor-reset.mdx +++ b/mintlify/platform-overview/sca/two-factor-reset.mdx @@ -19,6 +19,7 @@ All paths below are relative to `https://api.lightspark.com/grid/2025-10-13`. + ```bash POST /customers/{customerId}/sca/factors/reset @@ -37,6 +38,7 @@ Returns **`201`** with a `resetId` and opaque liveness handles. Embed "expiresAt": "2025-10-03T12:30:00Z" } ``` + ```bash @@ -47,11 +49,18 @@ GET /customers/{customerId}/sca/factors/reset/{resetId} { "status": "PENDING" } ``` -Keep polling while `status` is non-terminal (e.g. `PENDING`). Once it reaches -`LIVENESS_PASSED`, proceed. The field is passed through verbatim, so treat any -value other than `LIVENESS_PASSED` as "not ready yet" rather than a failure. +Poll with a short backoff. Two things end the loop: `status` reaches +`LIVENESS_PASSED` (proceed to complete), or the reset's `expiresAt` passes. +`status` is passed through verbatim, so match `LIVENESS_PASSED` exactly and treat +every other value (including `PENDING`) as "not ready yet." + +To keep the loop bounded, stop at `expiresAt`, and apply your own timeout if the +start response didn't include one. If the window closes before liveness passes +(for example the customer abandons or fails the check), the reset is over: start +a new one rather than polling indefinitely. + ```bash POST /customers/{customerId}/sca/factors/reset/{resetId}/complete ``` From 4ea44c2e3121282804a3ef5b8f27a380f97e452a Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Tue, 14 Jul 2026 10:20:20 -0700 Subject: [PATCH 7/7] docs(sca): document the untrust start step Now that startBeneficiaryUntrust exists, untrust mirrors trust (start issues the challenge, confirm submits the proof), so drop the earlier note about untrust having no start step. --- .../sending-payments.mdx | 9 ++- .../payment-flow/send-payment.mdx | 8 ++- .../sca/factor-enrollment.mdx | 44 ++++++++++---- .../sca/login-and-sessions.mdx | 21 +++++-- mintlify/platform-overview/sca/overview.mdx | 4 +- .../sca/per-transaction-authorization.mdx | 59 +++++++++++++++++++ .../sca/trusted-beneficiaries.mdx | 45 ++++++++------ .../sca/two-factor-reset.mdx | 40 ++++++++----- .../fiat-crypto-conversion.mdx | 9 ++- .../sca/strong-customer-authentication.mdx | 58 +++++++----------- 10 files changed, 204 insertions(+), 93 deletions(-) diff --git a/mintlify/global-p2p/sending-receiving-payments/sending-payments.mdx b/mintlify/global-p2p/sending-receiving-payments/sending-payments.mdx index 3fcde6d7c..3197247e0 100644 --- a/mintlify/global-p2p/sending-receiving-payments/sending-payments.mdx +++ b/mintlify/global-p2p/sending-receiving-payments/sending-payments.mdx @@ -27,10 +27,13 @@ import SendUMA from '/snippets/sending/uma.mdx' -import StrongCustomerAuthentication from '/snippets/sca/strong-customer-authentication.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 9f0e66bc2..689b824f8 100644 --- a/mintlify/payouts-and-b2b/payment-flow/send-payment.mdx +++ b/mintlify/payouts-and-b2b/payment-flow/send-payment.mdx @@ -6,7 +6,6 @@ icon: "/images/icons/paper-plane-top-right.svg" --- import { FeatureCard, FeatureCardGrid } from '/snippets/feature-card.mdx'; -import StrongCustomerAuthentication from '/snippets/sca/strong-customer-authentication.mdx'; Send payments from your customers' internal accounts to their external bank accounts or to other destinations. Grid supports both same-currency transfers and cross-currency transfers with automatic exchange rate handling. @@ -365,7 +364,12 @@ If a transaction fails, Grid initiates a refund automatically. You'll receive `O ## 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 diff --git a/mintlify/platform-overview/sca/factor-enrollment.mdx b/mintlify/platform-overview/sca/factor-enrollment.mdx index 0a5409a63..a890e0395 100644 --- a/mintlify/platform-overview/sca/factor-enrollment.mdx +++ b/mintlify/platform-overview/sca/factor-enrollment.mdx @@ -16,14 +16,24 @@ 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 /customers/{customerId}/sca/factors/totp +POST /sca/factors?customerId={customerId} + +{ "type": "TOTP" } ``` Returns the shared secret and an `otpauth://` provisioning URI. Render `totpUri` @@ -32,6 +42,7 @@ add it to their authenticator app. ```json { + "type": "TOTP", "secret": "…", "secretBase32Encoded": "ABC123…", "totpUri": "otpauth://totp/Grid:customer@example.com?secret=ABC123&issuer=Grid" @@ -45,13 +56,13 @@ Grid returns one-time **recovery codes**; surface them to the customer once and don't store them server-side. ```bash -POST /customers/{customerId}/sca/factors/totp/confirm +POST /sca/factors/confirm?customerId={customerId} -{ "secret": "…", "code": "123456" } +{ "type": "TOTP", "secret": "…", "code": "123456" } ``` ```json -{ "recoveryCodes": ["ABCD-EFGH-IJKL", "MNOP-QRST-UVWX"] } +{ "type": "TOTP", "recoveryCodes": ["ABCD-EFGH-IJKL", "MNOP-QRST-UVWX"] } ``` A wrong or expired code returns `400`. **In sandbox, the code is always @@ -64,14 +75,24 @@ A wrong or expired code returns `400`. **In sandbox, the code is always 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 /customers/{customerId}/sca/factors/passkey +POST /sca/factors?customerId={customerId} + +{ "type": "PASSKEY" } ``` ```json { + "type": "PASSKEY", "options": { "…": "opaque WebAuthn PublicKeyCredentialCreationOptions" }, "allowedOrigins": ["https://app.example.com"], "relyingPartyId": "app.example.com" @@ -86,9 +107,9 @@ Pass `options` unmodified to the device's WebAuthn API Submit the credential the device produced and the `origin` it ran against. ```bash -POST /customers/{customerId}/sca/factors/passkey/confirm +POST /sca/factors/confirm?customerId={customerId} -{ "origin": "https://app.example.com", "credential": { "…": "opaque WebAuthn credential" } } +{ "type": "PASSKEY", "origin": "https://app.example.com", "credential": { "…": "opaque WebAuthn credential" } } ``` Returns the enrolled `factor` (an `ScaFactorView`, including the `credentialId` @@ -99,7 +120,7 @@ you'll use to delete it later). An invalid credential or origin returns `400`. ## List enrolled factors ```bash -GET /customers/{customerId}/sca/factors +GET /sca/factors?customerId={customerId} ``` ```json @@ -113,11 +134,12 @@ GET /customers/{customerId}/sca/factors `credentialId` is populated only for `PASSKEY` factors. -## Delete a passkey +## Delete a factor ```bash -DELETE /customers/{customerId}/sca/factors/passkey/{credentialId} +DELETE /sca/factors/{credentialId}?customerId={customerId} ``` Returns `204`. Use the `credentialId` from the factor list (or the confirm -response). +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 index d8205c23f..b786f531c 100644 --- a/mintlify/platform-overview/sca/login-and-sessions.mdx +++ b/mintlify/platform-overview/sca/login-and-sessions.mdx @@ -16,6 +16,15 @@ 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 @@ -24,7 +33,7 @@ All paths below are relative to `https://api.lightspark.com/grid/2025-10-13`. ```bash -POST /customers/{customerId}/sca/login/start +POST /sca/login/start?customerId={customerId} { "factor": "SMS_OTP" } ``` @@ -44,7 +53,7 @@ Submit the proof for the factor you started with: `code` for `SMS_OTP` / `TOTP` `PASSKEY`. ```bash -POST /customers/{customerId}/sca/login/complete +POST /sca/login/complete?customerId={customerId} { "factor": "SMS_OTP", "challengeId": "…", "code": "123456" } ``` @@ -67,12 +76,16 @@ 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 /customers/{customerId}/sca/record-event +POST /sca/record-event?customerId={customerId} { "eventType": "FAILED_LOGIN_ATTEMPT" } ``` -Returns `204`. `eventType` must be one of: +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 | |---|---| diff --git a/mintlify/platform-overview/sca/overview.mdx b/mintlify/platform-overview/sca/overview.mdx index 336ece539..eaa8f30fd 100644 --- a/mintlify/platform-overview/sca/overview.mdx +++ b/mintlify/platform-overview/sca/overview.mdx @@ -100,8 +100,8 @@ sequenceDiagram ``` [Per-transaction authorization](/platform-overview/sca/per-transaction-authorization) -covers the mechanics: inline versus after-the-fact proof, the multi-step loop, -resending an expired code, and the realtime-funding-quote nuance. +covers the mechanics: authorizing the quote, the multi-step loop, resending an +expired code, and the realtime-funding-quote nuance. ## Lifetimes & limits diff --git a/mintlify/platform-overview/sca/per-transaction-authorization.mdx b/mintlify/platform-overview/sca/per-transaction-authorization.mdx index aa41a40ca..665cd4045 100644 --- a/mintlify/platform-overview/sca/per-transaction-authorization.mdx +++ b/mintlify/platform-overview/sca/per-transaction-authorization.mdx @@ -13,3 +13,62 @@ 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. A pre-funded send is a single +challenge — one authorization releases it (the currency-conversion leg of a +cross-currency send is not separately gated). + + + + + +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, and a single authorization clears it. + + +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. `transfer-out` is being deprecated, 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 index a0638a68b..514956cfe 100644 --- a/mintlify/platform-overview/sca/trusted-beneficiaries.mdx +++ b/mintlify/platform-overview/sca/trusted-beneficiaries.mdx @@ -11,10 +11,10 @@ returns **`409`** for other customers. Trusting a beneficiary is a one-time, SCA-gated step that whitelists an external -account (an IBAN or crypto address). 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. +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 @@ -25,7 +25,7 @@ relative to `https://api.lightspark.com/grid/2025-10-13`. ```bash -POST /customers/{customerId}/external-accounts/{externalAccountId}/trust +POST /customers/external-accounts/{externalAccountId}/trust ``` Returns the `scaChallenge` to satisfy: @@ -44,7 +44,7 @@ Submit the SCA proof (`code` for `SMS_OTP` / `TOTP`, or `passkeyAssertion` + `origin` for `PASSKEY`), echoing `challengeId` when start issued one. ```bash -POST /customers/{customerId}/external-accounts/{externalAccountId}/trust/confirm +POST /customers/external-accounts/{externalAccountId}/trust/confirm { "challengeId": "…", "code": "123456" } ``` @@ -60,22 +60,33 @@ An invalid or expired proof returns `400`. **In sandbox, the code is always ## Untrusting a beneficiary -Untrust has no separate start step. You confirm it directly with an SCA proof, -the same request shape as trust/confirm, and it returns `trusted: false`. +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 SCA proof (`code` for `SMS_OTP` / `TOTP`, or `passkeyAssertion` + +`origin` for `PASSKEY`), echoing `challengeId` when start issued one. Returns +`trusted: false`. ```bash -POST /customers/{customerId}/external-accounts/{externalAccountId}/untrust/confirm +POST /customers/external-accounts/{externalAccountId}/untrust/confirm -{ "code": "123456" } +{ "challengeId": "…", "code": "123456" } ``` - -Because there is no untrust start step, nothing dispatches an SMS code or issues -passkey options for the untrust. The customer authenticates with a proof they can -produce on their own, a `TOTP` code, and `challengeId` is omitted. SMS and passkey -untrust would each need a start step to issue the challenge, which isn't part of -the current surface. - + + 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 index 401bab243..0a7be1861 100644 --- a/mintlify/platform-overview/sca/two-factor-reset.mdx +++ b/mintlify/platform-overview/sca/two-factor-reset.mdx @@ -21,14 +21,15 @@ All paths below are relative to `https://api.lightspark.com/grid/2025-10-13`. ```bash -POST /customers/{customerId}/sca/factors/reset +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. +`verificationLink`. `expiresAt` bounds the reset window. Reset initiation is +rate-limited to **5 per 24 hours** per customer; beyond that this returns `429`. ```json { @@ -42,31 +43,40 @@ Returns **`201`** with a `resetId` and opaque liveness handles. Embed ```bash -GET /customers/{customerId}/sca/factors/reset/{resetId} +GET /sca/factors/reset/{resetId}?customerId={customerId} ``` ```json -{ "status": "PENDING" } +{ "status": "INITIATED" } ``` -Poll with a short backoff. Two things end the loop: `status` reaches -`LIVENESS_PASSED` (proceed to complete), or the reset's `expiresAt` passes. -`status` is passed through verbatim, so match `LIVENESS_PASSED` exactly and treat -every other value (including `PENDING`) as "not ready yet." +Poll with a short backoff. `status` is one of `INITIATED`, `PENDING_REVIEW`, +`LIVENESS_PASSED`, `COMPLETED`, `REJECTED`, or `EXPIRED`: -To keep the loop bounded, stop at `expiresAt`, and apply your own timeout if the -start response didn't include one. If the window closes before liveness passes -(for example the customer abandons or fails the check), the reset is over: start -a new one rather than polling indefinitely. +- `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 /customers/{customerId}/sca/factors/reset/{resetId}/complete +POST /sca/factors/reset/{resetId}/complete?customerId={customerId} + +{ "mobile": { "countryCode": "+1", "number": "4155550123" } } ``` -Returns `204` and clears the lost factor. Calling it before liveness has passed -returns `400`. The customer can then re-enroll via +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 4e0d4864c..fd3b8ed79 100644 --- a/mintlify/ramps/conversion-flows/fiat-crypto-conversion.mdx +++ b/mintlify/ramps/conversion-flows/fiat-crypto-conversion.mdx @@ -6,7 +6,6 @@ icon: "/images/icons/repeat.svg" --- import { FeatureCard, FeatureCardGrid } from '/snippets/feature-card.mdx'; -import StrongCustomerAuthentication from '/snippets/sca/strong-customer-authentication.mdx'; ## Overview @@ -244,7 +243,13 @@ curl -X POST 'https://api.lightspark.com/grid/2025-10-13/quotes' \ ## 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 index dc57eaa1c..4ec734d80 100644 --- a/mintlify/snippets/sca/strong-customer-authentication.mdx +++ b/mintlify/snippets/sca/strong-customer-authentication.mdx @@ -40,47 +40,34 @@ field on `execute` / `transfer-out` (`SMS_OTP` default, or `PASSKEY`). ### Satisfying a challenge -Submit an `ScaAuthorization` proof to the authorization endpoint for the resource -that carries the challenge. Provide exactly one of `code` (for `SMS_OTP`) or -`passkeyAssertion` + `origin` (for `PASSKEY`): +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/transactions/{transactionId}/authorize \ +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" }' ``` -**A single money movement may require more than one authorization, in sequence.** -Treat `scaChallenge` as the challenge to satisfy *now*, not the only one. After -each authorization, re-inspect the returned transaction: if it is still -`PENDING_AUTHORIZATION` it carries the **next** `scaChallenge` (a new `id`). -Authorize that one too, and repeat until the transaction leaves -`PENDING_AUTHORIZATION`. Don't assume one authorization releases the transfer. - -For example, a cross-currency send today authorizes the currency conversion and -then the payout as two separate challenges. The optional `scaChallenge.purpose` -labels each one (`CURRENCY_CONVERSION`, `PAYOUT`) for display only. A client -written to loop on status handles any number of steps unchanged. +**A pre-funded send is a single authorization today — but write your client to +loop.** One `POST /quotes/{quoteId}/authorize` releases a pre-funded send; the +currency-conversion leg of a cross-currency send is not separately gated. Even +so, 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`. A +client written to loop on status stays correct if a future flow adds steps. -You have two ways to supply the first proof: - - - -Pass `scaAuthorization` in the body of the originating `execute` / `transfer-out` -call to satisfy the first challenge in the same request and skip the pending -state. Any subsequent challenges in the sequence still use the authorize -endpoint. - - -Omit it, receive `PENDING_AUTHORIZATION` + `scaChallenge`, then call the -authorize endpoint for the resource: -- After `execute` / `transfer-out`: `POST /transactions/{transactionId}/authorize` -- For a realtime-funding quote returned from `POST /quotes`: `POST /quotes/{quoteId}/authorize` - - +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 @@ -91,14 +78,11 @@ 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 resend endpoint for the -resource that carries the challenge: - -- Transaction (after `execute` / `transfer-out`): `POST /transactions/{transactionId}/authorize/resend` -- Realtime-funding quote (from `POST /quotes`): `POST /quotes/{quoteId}/authorize/resend` +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/transactions/{transactionId}/authorize/resend \ +curl -X POST https://api.lightspark.com/grid/2025-10-13/quotes/{quoteId}/authorize/resend \ -u "$GRID_API_TOKEN" ```