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/global-p2p/sending-receiving-payments/sending-payments.mdx b/mintlify/global-p2p/sending-receiving-payments/sending-payments.mdx index d8e1b6b9d..3197247e0 100644 --- a/mintlify/global-p2p/sending-receiving-payments/sending-payments.mdx +++ b/mintlify/global-p2p/sending-receiving-payments/sending-payments.mdx @@ -27,4 +27,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 548095f84..689b824f8 100644 --- a/mintlify/payouts-and-b2b/payment-flow/send-payment.mdx +++ b/mintlify/payouts-and-b2b/payment-flow/send-payment.mdx @@ -362,6 +362,15 @@ 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) + +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..b786f531c --- /dev/null +++ b/mintlify/platform-overview/sca/login-and-sessions.mdx @@ -0,0 +1,103 @@ +--- +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. 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 /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 lift a lockout, rather than relying on +the customer simply logging in again. + diff --git a/mintlify/platform-overview/sca/overview.mdx b/mintlify/platform-overview/sca/overview.mdx new file mode 100644 index 000000000..eaa8f30fd --- /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: 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). 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..665cd4045 --- /dev/null +++ b/mintlify/platform-overview/sca/per-transaction-authorization.mdx @@ -0,0 +1,74 @@ +--- +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. 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 new file mode 100644 index 000000000..514956cfe --- /dev/null +++ b/mintlify/platform-overview/sca/trusted-beneficiaries.mdx @@ -0,0 +1,92 @@ +--- +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 SCA proof (`code` for `SMS_OTP` / `TOTP`, or `passkeyAssertion` + +`origin` for `PASSKEY`), echoing `challengeId` when start issued one. + +```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 SCA proof (`code` for `SMS_OTP` / `TOTP`, or `passkeyAssertion` + +`origin` for `PASSKEY`), echoing `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 d4a4c56b0..fd3b8ed79 100644 --- a/mintlify/ramps/conversion-flows/fiat-crypto-conversion.mdx +++ b/mintlify/ramps/conversion-flows/fiat-crypto-conversion.mdx @@ -112,6 +112,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 { @@ -233,6 +241,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..4ec734d80 --- /dev/null +++ b/mintlify/snippets/sca/strong-customer-authentication.mdx @@ -0,0 +1,104 @@ + +**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 + +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; 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) | + +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 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" }' +``` + + +**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. + + +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.