From 9fbc3dc10d025b89b0f548bca6f830b59e127e7e Mon Sep 17 00:00:00 2001 From: Peng Ying Date: Thu, 23 Apr 2026 16:43:23 -0700 Subject: [PATCH] docs(embedded-wallets): add guide for self-custodial wallets Add a new Embedded Wallets guide section under both Payouts & B2B and Global P2P tabs. Covers the 4-party architecture (client / integrator backend / Grid / Turnkey), the end-to-end quickstart from finding the auto-provisioned wallet through a signed withdrawal, P-256 client key management and HPKE session-key decryption on Web / iOS / Android, the three auth credential types (passkey, OAuth/OIDC, email OTP), the two-step signed-retry pattern for credential / session management and wallet export. Relies on the InternalAccount.type discriminator added in the parent PR to filter for Embedded Wallets via GET /internal-accounts?type=EMBEDDED_WALLET. --- mintlify/docs.json | 24 +- .../embedded-wallets/authentication.mdx | 9 + .../embedded-wallets/client-keys.mdx | 9 + .../embedded-wallets/exporting-wallet.mdx | 9 + .../embedded-wallets/managing-sessions.mdx | 9 + .../global-p2p/embedded-wallets/overview.mdx | 10 + .../platform-configuration.mdx | 2 +- .../configuring-customers.mdx | 0 .../invitations.mdx | 0 .../receiving-payments.mdx | 2 +- .../embedded-wallets/authentication.mdx | 9 + .../embedded-wallets/client-keys.mdx | 9 + .../embedded-wallets/exporting-wallet.mdx | 9 + .../embedded-wallets/managing-sessions.mdx | 9 + .../embedded-wallets/overview.mdx | 10 + .../embedded-wallets/authentication.mdx | 622 ++++++++++++++++++ .../snippets/embedded-wallets/client-keys.mdx | 305 +++++++++ .../embedded-wallets/exporting-wallet.mdx | 78 +++ .../embedded-wallets/managing-sessions.mdx | 78 +++ .../snippets/embedded-wallets/overview.mdx | 405 ++++++++++++ 20 files changed, 1604 insertions(+), 4 deletions(-) create mode 100644 mintlify/global-p2p/embedded-wallets/authentication.mdx create mode 100644 mintlify/global-p2p/embedded-wallets/client-keys.mdx create mode 100644 mintlify/global-p2p/embedded-wallets/exporting-wallet.mdx create mode 100644 mintlify/global-p2p/embedded-wallets/managing-sessions.mdx create mode 100644 mintlify/global-p2p/embedded-wallets/overview.mdx rename mintlify/global-p2p/{onboarding-customers => onboarding}/configuring-customers.mdx (100%) rename mintlify/global-p2p/{onboarding-customers => onboarding}/invitations.mdx (100%) create mode 100644 mintlify/payouts-and-b2b/embedded-wallets/authentication.mdx create mode 100644 mintlify/payouts-and-b2b/embedded-wallets/client-keys.mdx create mode 100644 mintlify/payouts-and-b2b/embedded-wallets/exporting-wallet.mdx create mode 100644 mintlify/payouts-and-b2b/embedded-wallets/managing-sessions.mdx create mode 100644 mintlify/payouts-and-b2b/embedded-wallets/overview.mdx create mode 100644 mintlify/snippets/embedded-wallets/authentication.mdx create mode 100644 mintlify/snippets/embedded-wallets/client-keys.mdx create mode 100644 mintlify/snippets/embedded-wallets/exporting-wallet.mdx create mode 100644 mintlify/snippets/embedded-wallets/managing-sessions.mdx create mode 100644 mintlify/snippets/embedded-wallets/overview.mdx diff --git a/mintlify/docs.json b/mintlify/docs.json index e5204a21..8edb1cf5 100644 --- a/mintlify/docs.json +++ b/mintlify/docs.json @@ -108,6 +108,16 @@ "payouts-and-b2b/payment-flow/error-handling" ] }, + { + "group": "Embedded Wallets", + "pages": [ + "payouts-and-b2b/embedded-wallets/overview", + "payouts-and-b2b/embedded-wallets/client-keys", + "payouts-and-b2b/embedded-wallets/authentication", + "payouts-and-b2b/embedded-wallets/managing-sessions", + "payouts-and-b2b/embedded-wallets/exporting-wallet" + ] + }, { "group": "Platform Tools", "pages": [ @@ -220,8 +230,8 @@ "group": "Onboarding", "pages": [ "global-p2p/getting-started/platform-configuration", - "global-p2p/onboarding-customers/configuring-customers", - "global-p2p/onboarding-customers/invitations" + "global-p2p/onboarding/configuring-customers", + "global-p2p/onboarding/invitations" ] }, { @@ -241,6 +251,16 @@ "global-p2p/sending-receiving-payments/error-handling" ] }, + { + "group": "Embedded Wallets", + "pages": [ + "global-p2p/embedded-wallets/overview", + "global-p2p/embedded-wallets/client-keys", + "global-p2p/embedded-wallets/authentication", + "global-p2p/embedded-wallets/managing-sessions", + "global-p2p/embedded-wallets/exporting-wallet" + ] + }, { "group": "Platform Tools", "pages": [ diff --git a/mintlify/global-p2p/embedded-wallets/authentication.mdx b/mintlify/global-p2p/embedded-wallets/authentication.mdx new file mode 100644 index 00000000..09e4b14d --- /dev/null +++ b/mintlify/global-p2p/embedded-wallets/authentication.mdx @@ -0,0 +1,9 @@ +--- +title: "Authentication" +description: "Register, reauthenticate, and manage Embedded Wallet credentials (passkey, OAuth, email OTP)" +icon: "/images/icons/shield.svg" +--- + +import Authentication from '/snippets/embedded-wallets/authentication.mdx'; + + diff --git a/mintlify/global-p2p/embedded-wallets/client-keys.mdx b/mintlify/global-p2p/embedded-wallets/client-keys.mdx new file mode 100644 index 00000000..9b4e034f --- /dev/null +++ b/mintlify/global-p2p/embedded-wallets/client-keys.mdx @@ -0,0 +1,9 @@ +--- +title: "Client keys & signing" +description: "Generate the P-256 client key pair, decrypt the session signing key, and sign wallet actions on Web, iOS, and Android" +icon: "/images/icons/key2.svg" +--- + +import ClientKeys from '/snippets/embedded-wallets/client-keys.mdx'; + + diff --git a/mintlify/global-p2p/embedded-wallets/exporting-wallet.mdx b/mintlify/global-p2p/embedded-wallets/exporting-wallet.mdx new file mode 100644 index 00000000..cce8dc29 --- /dev/null +++ b/mintlify/global-p2p/embedded-wallets/exporting-wallet.mdx @@ -0,0 +1,9 @@ +--- +title: "Exporting a wallet" +description: "Let a customer take the seed of their Embedded Wallet off Grid" +icon: "/images/icons/arrow-path-right.svg" +--- + +import ExportingWallet from '/snippets/embedded-wallets/exporting-wallet.mdx'; + + diff --git a/mintlify/global-p2p/embedded-wallets/managing-sessions.mdx b/mintlify/global-p2p/embedded-wallets/managing-sessions.mdx new file mode 100644 index 00000000..2e1e502c --- /dev/null +++ b/mintlify/global-p2p/embedded-wallets/managing-sessions.mdx @@ -0,0 +1,9 @@ +--- +title: "Sessions" +description: "List and revoke active sessions on an Embedded Wallet" +icon: "/images/icons/arrows-repeat-circle.svg" +--- + +import Sessions from '/snippets/embedded-wallets/managing-sessions.mdx'; + + diff --git a/mintlify/global-p2p/embedded-wallets/overview.mdx b/mintlify/global-p2p/embedded-wallets/overview.mdx new file mode 100644 index 00000000..0366f89c --- /dev/null +++ b/mintlify/global-p2p/embedded-wallets/overview.mdx @@ -0,0 +1,10 @@ +--- +title: "Embedded Wallets" +sidebarTitle: "Overview" +description: "Self-custodial stablecoin wallets that plug into Grid payment flows" +icon: "/images/icons/crypto-wallet.svg" +--- + +import Overview from '/snippets/embedded-wallets/overview.mdx'; + + diff --git a/mintlify/global-p2p/getting-started/platform-configuration.mdx b/mintlify/global-p2p/getting-started/platform-configuration.mdx index 45712339..1124e7cc 100644 --- a/mintlify/global-p2p/getting-started/platform-configuration.mdx +++ b/mintlify/global-p2p/getting-started/platform-configuration.mdx @@ -77,7 +77,7 @@ For regulated financial institutions, the `providerRequiredCustomerFields` array This list specifies which user information fields are mandated by the underlying UMA provider for *your own registered users* if they intend to send or receive payments in that particular currency. For example, to allow a user to transact in "USD", the UMA provider might require that the user has a `NATIONALITY` on record. -These fields must be supplied when creating or updating a user via the `POST /customers` or `PATCH /customers/{customerId}` endpoints if that user is expected to use the specified currency. Refer to the [Configuring Customers](/global-p2p/onboarding-customers/configuring-customers) guide for more details on how this impacts user setup. +These fields must be supplied when creating or updating a user via the `POST /customers` or `PATCH /customers/{customerId}` endpoints if that user is expected to use the specified currency. Refer to the [Configuring Customers](/global-p2p/onboarding/configuring-customers) guide for more details on how this impacts user setup. ## Manage configuration via API If you prefer to manage settings programmatically, use the `/config` endpoints. diff --git a/mintlify/global-p2p/onboarding-customers/configuring-customers.mdx b/mintlify/global-p2p/onboarding/configuring-customers.mdx similarity index 100% rename from mintlify/global-p2p/onboarding-customers/configuring-customers.mdx rename to mintlify/global-p2p/onboarding/configuring-customers.mdx diff --git a/mintlify/global-p2p/onboarding-customers/invitations.mdx b/mintlify/global-p2p/onboarding/invitations.mdx similarity index 100% rename from mintlify/global-p2p/onboarding-customers/invitations.mdx rename to mintlify/global-p2p/onboarding/invitations.mdx diff --git a/mintlify/global-p2p/sending-receiving-payments/receiving-payments.mdx b/mintlify/global-p2p/sending-receiving-payments/receiving-payments.mdx index cad485d5..4c1c6211 100644 --- a/mintlify/global-p2p/sending-receiving-payments/receiving-payments.mdx +++ b/mintlify/global-p2p/sending-receiving-payments/receiving-payments.mdx @@ -11,7 +11,7 @@ This guide explains how to enable your customers to receive UMA payments. When a Before you begin, make sure you: - Configure UMA, supported currencies, and required counterparty fields in Platform Configuration -- Create customers and capture any provider-required user fields in Creating Customers +- Create customers and capture any provider-required user fields in Creating Customers - Set up and verify webhooks in Webhooks diff --git a/mintlify/payouts-and-b2b/embedded-wallets/authentication.mdx b/mintlify/payouts-and-b2b/embedded-wallets/authentication.mdx new file mode 100644 index 00000000..09e4b14d --- /dev/null +++ b/mintlify/payouts-and-b2b/embedded-wallets/authentication.mdx @@ -0,0 +1,9 @@ +--- +title: "Authentication" +description: "Register, reauthenticate, and manage Embedded Wallet credentials (passkey, OAuth, email OTP)" +icon: "/images/icons/shield.svg" +--- + +import Authentication from '/snippets/embedded-wallets/authentication.mdx'; + + diff --git a/mintlify/payouts-and-b2b/embedded-wallets/client-keys.mdx b/mintlify/payouts-and-b2b/embedded-wallets/client-keys.mdx new file mode 100644 index 00000000..9b4e034f --- /dev/null +++ b/mintlify/payouts-and-b2b/embedded-wallets/client-keys.mdx @@ -0,0 +1,9 @@ +--- +title: "Client keys & signing" +description: "Generate the P-256 client key pair, decrypt the session signing key, and sign wallet actions on Web, iOS, and Android" +icon: "/images/icons/key2.svg" +--- + +import ClientKeys from '/snippets/embedded-wallets/client-keys.mdx'; + + diff --git a/mintlify/payouts-and-b2b/embedded-wallets/exporting-wallet.mdx b/mintlify/payouts-and-b2b/embedded-wallets/exporting-wallet.mdx new file mode 100644 index 00000000..cce8dc29 --- /dev/null +++ b/mintlify/payouts-and-b2b/embedded-wallets/exporting-wallet.mdx @@ -0,0 +1,9 @@ +--- +title: "Exporting a wallet" +description: "Let a customer take the seed of their Embedded Wallet off Grid" +icon: "/images/icons/arrow-path-right.svg" +--- + +import ExportingWallet from '/snippets/embedded-wallets/exporting-wallet.mdx'; + + diff --git a/mintlify/payouts-and-b2b/embedded-wallets/managing-sessions.mdx b/mintlify/payouts-and-b2b/embedded-wallets/managing-sessions.mdx new file mode 100644 index 00000000..2e1e502c --- /dev/null +++ b/mintlify/payouts-and-b2b/embedded-wallets/managing-sessions.mdx @@ -0,0 +1,9 @@ +--- +title: "Sessions" +description: "List and revoke active sessions on an Embedded Wallet" +icon: "/images/icons/arrows-repeat-circle.svg" +--- + +import Sessions from '/snippets/embedded-wallets/managing-sessions.mdx'; + + diff --git a/mintlify/payouts-and-b2b/embedded-wallets/overview.mdx b/mintlify/payouts-and-b2b/embedded-wallets/overview.mdx new file mode 100644 index 00000000..0366f89c --- /dev/null +++ b/mintlify/payouts-and-b2b/embedded-wallets/overview.mdx @@ -0,0 +1,10 @@ +--- +title: "Embedded Wallets" +sidebarTitle: "Overview" +description: "Self-custodial stablecoin wallets that plug into Grid payment flows" +icon: "/images/icons/crypto-wallet.svg" +--- + +import Overview from '/snippets/embedded-wallets/overview.mdx'; + + diff --git a/mintlify/snippets/embedded-wallets/authentication.mdx b/mintlify/snippets/embedded-wallets/authentication.mdx new file mode 100644 index 00000000..babee132 --- /dev/null +++ b/mintlify/snippets/embedded-wallets/authentication.mdx @@ -0,0 +1,622 @@ +Every Embedded Wallet action beyond receiving funds must be authorized by a session signing key. Sessions are issued by verifying one of three **credential types** on the wallet's internal account: + +| Type | When to use it | +|---|---| +| **`PASSKEY`** | Best default. Biometric, phishing-resistant, usable across the user's devices via iCloud Keychain / Google Password Manager. | +| **`OAUTH`** | Your platform already authenticates the user via OIDC (Google, Apple, your own IdP) and you want Grid to trust the same identity. | +| **`EMAIL_OTP`** | Lowest-friction option. Works on any device with email access — no biometric hardware, identity provider, or client SDK required beyond the code entry field. | + +A single internal account can hold one credential of each type concurrently. Only one `PASSKEY` and one `EMAIL_OTP` per account in v1. + +## Registration vs. verification + +Every credential type uses the same two-step shape: + +1. **`POST /auth/credentials`** creates the credential record. This triggers the out-of-band channel (OTP email sent, WebAuthn attestation stored) but does not yet issue a session. +2. **`POST /auth/credentials/{id}/verify`** completes activation by presenting proof of control (the OTP value, a fresh OIDC token, or a WebAuthn assertion) plus a `clientPublicKey`. The response carries the `encryptedSessionSigningKey`. + +Re-authentication after a session expires skips step 1 — you only call `verify` (with `challenge` first for passkey). Each credential type's re-auth path is covered in its section below. + +## Passkey + +### Passkey registration + +Passkey registration spans four parties: the **client** (browser or app), your **integrator backend**, **Grid**, and the platform authenticator (Touch ID / Face ID / Windows Hello / a security key). Your backend issues the WebAuthn registration challenge; Grid issues the subsequent authentication challenge used to prove that the passkey actually works end-to-end before a session is issued. + +```mermaid +sequenceDiagram + participant C as Client + participant IB as Integrator backend + participant G as Grid + participant A as Authenticator + + C->>IB: Start registration + IB->>IB: Generate random challenge (stored in session) + IB-->>C: { challenge, rpId, user } + C->>A: navigator.credentials.create({ challenge, … }) + A-->>C: attestation (credentialId, clientDataJson, attestationObject) + C->>IB: POST /my-backend/passkey/register (attestation) + IB->>G: POST /auth/credentials { type: PASSKEY, challenge, attestation, … } + G-->>IB: 201 PasskeyAuthChallenge { id, challenge, requestId, expiresAt } + IB-->>C: { credentialId, gridChallenge, requestId } + C->>C: generateClientKeyPair() + C->>A: navigator.credentials.get({ challenge: gridChallenge, … }) + A-->>C: assertion (credentialId, clientDataJson, authenticatorData, signature) + C->>IB: POST /my-backend/passkey/verify (assertion, clientPublicKey) + IB->>G: POST /auth/credentials/{id}/verify
Request-Id: requestId
{ type: PASSKEY, assertion, clientPublicKey } + G-->>IB: 200 AuthSession { encryptedSessionSigningKey, expiresAt } + IB-->>C: { encryptedSessionSigningKey, expiresAt } + C->>C: decrypt with private key, hold session signing key +``` + +The `challenge` on `POST /auth/credentials` is the one your backend issued. Grid rebinds to a fresh challenge for the *first* authentication and hands it back on the 201 response as `PasskeyAuthChallenge.challenge` with an accompanying `requestId`. + + + Passkeys are domain-bound. Before shipping, set up your `/.well-known/apple-app-site-association` and `/.well-known/assetlinks.json` entries so the platform authenticator binds the passkey to your origin and app bundles: + - Web: [passkeys.dev bootstrapping guide](https://passkeys.dev/docs/use-cases/bootstrapping/#opting-the-user-into-passkeys) + - Android: [Create passkeys on Android](https://developer.android.com/identity/passkeys/create-passkeys) + - iOS: [Supporting passkeys](https://developer.apple.com/documentation/authenticationservices/supporting-passkeys) + + +#### Client sample code + +The client never talks to Grid. It talks to your integrator backend — the snippets below simulate that call with `fetch('/my-backend/...')`, which your backend then relays to Grid. + + +```typescript Web (TypeScript) +// 1. Ask your backend for a registration challenge. +const startRes = await fetch("/my-backend/passkey/register/start", { + method: "POST", + credentials: "include", +}); +const { challenge, rpId, user } = await startRes.json(); + +// 2. Ask the authenticator to create a passkey. +const attestation = (await navigator.credentials.create({ + publicKey: { + challenge: base64urlToBytes(challenge), + rp: { id: rpId, name: "Acme Wallet" }, + user: { + id: base64urlToBytes(user.id), + name: user.email, + displayName: user.displayName, + }, + pubKeyCredParams: [{ type: "public-key", alg: -7 }], // ES256 + authenticatorSelection: { residentKey: "required", userVerification: "required" }, + timeout: 60_000, + }, +})) as PublicKeyCredential; +const att = attestation.response as AuthenticatorAttestationResponse; + +// 3. Send the attestation to your backend, which calls POST /auth/credentials. +const registerRes = await fetch("/my-backend/passkey/register", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + nickname: "This device", + credentialId: bytesToBase64url(new Uint8Array(attestation.rawId)), + clientDataJson: bytesToBase64url(new Uint8Array(att.clientDataJSON)), + attestationObject: bytesToBase64url(new Uint8Array(att.attestationObject)), + transports: att.getTransports?.() ?? [], + }), +}); +const { credentialId, gridChallenge, requestId } = await registerRes.json(); + +// 4. Generate the client key pair and run an assertion against the Grid-issued challenge. +const { keyPair, publicKeyHex } = await generateClientKeyPair(); +const assertion = (await navigator.credentials.get({ + publicKey: { + challenge: base64urlToBytes(gridChallenge), + rpId, + userVerification: "required", + allowCredentials: [{ type: "public-key", id: base64urlToBytes(credentialId) }], + }, +})) as PublicKeyCredential; +const asr = assertion.response as AuthenticatorAssertionResponse; + +// 5. Send the assertion + client public key to your backend; it relays to POST /verify. +const verifyRes = await fetch("/my-backend/passkey/verify", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + credentialId, + requestId, + assertion: { + credentialId: bytesToBase64url(new Uint8Array(assertion.rawId)), + clientDataJson: bytesToBase64url(new Uint8Array(asr.clientDataJSON)), + authenticatorData: bytesToBase64url(new Uint8Array(asr.authenticatorData)), + signature: bytesToBase64url(new Uint8Array(asr.signature)), + userHandle: asr.userHandle + ? bytesToBase64url(new Uint8Array(asr.userHandle)) + : null, + }, + clientPublicKey: publicKeyHex, + }), +}); +const { encryptedSessionSigningKey, expiresAt } = await verifyRes.json(); +// Decrypt and cache the session signing key — see client-keys.mdx. +``` + +```kotlin Android (Kotlin) +// Uses Jetpack Credential Manager. +// implementation("androidx.credentials:credentials:1.3.0") +// implementation("androidx.credentials:credentials-play-services-auth:1.3.0") +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.PublicKeyCredential + +suspend fun registerPasskey(context: Context, api: MyBackendApi): EmbeddedWalletSession { + // 1. Backend → WebAuthn registration options JSON (challenge, rp, user, ...). + val createOptionsJson = api.passkeyRegisterStart() + + val cm = CredentialManager.create(context) + + // 2. Authenticator creates the passkey. + val createResp = cm.createCredential( + context = context, + request = CreatePublicKeyCredentialRequest(createOptionsJson), + ) as androidx.credentials.CreatePublicKeyCredentialResponse + val attestationJson = createResp.registrationResponseJson + + // 3. Backend → POST /auth/credentials. Returns Grid-issued challenge + requestId. + val registerResp = api.passkeyRegister(attestationJson, nickname = "This device") + + // 4. Generate client key pair and run assertion against Grid-issued challenge. + val clientKeys = generateClientKeyPair(alias = "embedded-wallet-${registerResp.credentialId}") + val getOptionsJson = buildWebAuthnGetOptionsJson( + challenge = registerResp.gridChallenge, + rpId = registerResp.rpId, + credentialId = registerResp.credentialId, + ) + val getResp = cm.getCredential( + context = context, + request = GetCredentialRequest(listOf(GetPublicKeyCredentialOption(getOptionsJson))), + ).credential as PublicKeyCredential + val assertionJson = getResp.authenticationResponseJson + + // 5. Backend → POST /verify. Returns encryptedSessionSigningKey. + return api.passkeyVerify( + credentialId = registerResp.credentialId, + requestId = registerResp.requestId, + assertionJson = assertionJson, + clientPublicKeyHex = clientKeys.publicKeyHex, + ) +} +``` + +```swift iOS (Swift) +import AuthenticationServices + +final class PasskeyCoordinator: NSObject, ASAuthorizationControllerDelegate { + private let api: MyBackendApi + private var continuation: CheckedContinuation? + private var clientKeys: ClientKeyPair? + private var pendingCredentialId: String? + private var pendingRequestId: String? + + init(api: MyBackendApi) { self.api = api } + + func registerPasskey() async throws -> EmbeddedWalletSession { + // 1. Backend → WebAuthn registration options. + let options = try await api.passkeyRegisterStart() + + // 2. Authenticator creates the passkey. + let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( + relyingPartyIdentifier: options.rpId, + ) + let request = provider.createCredentialRegistrationRequest( + challenge: options.challenge, + name: options.user.name, + userID: options.user.id, + ) + let attestation = try await withCheckedThrowingContinuation { + (cont: CheckedContinuation< + ASAuthorizationPlatformPublicKeyCredentialRegistration, Error + >) in + let controller = ASAuthorizationController(authorizationRequests: [request]) + controller.delegate = AttestationDelegate(cont: cont) + controller.performRequests() + } + + // 3. Backend → POST /auth/credentials. + let registered = try await api.passkeyRegister( + credentialId: attestation.credentialID.base64URLEncoded, + clientDataJson: attestation.rawClientDataJSON.base64URLEncoded, + attestationObject: attestation.rawAttestationObject!.base64URLEncoded, + nickname: "This device", + ) + + // 4. Generate client key pair, run assertion against Grid challenge. + let keys = generateClientKeyPair() + let assertRequest = provider.createCredentialAssertionRequest( + challenge: registered.gridChallenge, + ) + assertRequest.allowedCredentials = [ + ASAuthorizationPlatformPublicKeyCredentialDescriptor( + credentialID: Data(base64URLEncoded: registered.credentialId)!, + ), + ] + let assertion = try await withCheckedThrowingContinuation { + (cont: CheckedContinuation< + ASAuthorizationPlatformPublicKeyCredentialAssertion, Error + >) in + let controller = ASAuthorizationController(authorizationRequests: [assertRequest]) + controller.delegate = AssertionDelegate(cont: cont) + controller.performRequests() + } + + // 5. Backend → POST /verify. + return try await api.passkeyVerify( + credentialId: registered.credentialId, + requestId: registered.requestId, + clientDataJson: assertion.rawClientDataJSON.base64URLEncoded, + authenticatorData: assertion.rawAuthenticatorData.base64URLEncoded, + signature: assertion.signature.base64URLEncoded, + userHandle: assertion.userID?.base64URLEncoded, + clientPublicKeyHex: keys.publicKeyHex, + ) + } +} +``` + + +#### WebAuthn → Grid parameter map + +These are the fields you need to pass through on each hop. + +**Registration (`/auth/credentials`):** + +| Browser (`credential`) | Your backend payload | Grid request body | +|---|---|---| +| `credential.rawId` | `credentialId` | `attestation.credentialId` | +| `response.clientDataJSON` | `clientDataJson` | `attestation.clientDataJson` | +| `response.attestationObject` | `attestationObject` | `attestation.attestationObject` | +| `response.getTransports()` | `transports` | `attestation.transports` | +| *(backend session state)* | `challenge` | `challenge` *(top-level, not under `attestation`)* | +| *(backend-chosen)* | `nickname` | `nickname` | +| *(Grid account id)* | — | `accountId` | + +**Assertion (`/auth/credentials/{id}/verify` and passkey reauthentication):** + +| Browser (`credential`) | Your backend payload | Grid request body | +|---|---|---| +| `credential.rawId` | `credentialId` | `assertion.credentialId` | +| `response.clientDataJSON` | `clientDataJson` | `assertion.clientDataJson` | +| `response.authenticatorData` | `authenticatorData` | `assertion.authenticatorData` | +| `response.signature` | `signature` | `assertion.signature` | +| `response.userHandle` | `userHandle` | `assertion.userHandle` *(optional)* | +| *(from 201/200 challenge response)* | `requestId` | `Request-Id` header | +| *(client-generated)* | `clientPublicKey` | `clientPublicKey` | + + + The WebAuthn spec names the field `clientDataJSON`. Grid spells it `clientDataJson` (lowercase `json`) for consistency with the rest of the API. The **bytes are identical** — only the field name changes. + + +### Passkey reauthentication + +When a session expires the client re-verifies without recreating the credential. Call `POST /auth/credentials/{id}/challenge` for a fresh Grid-issued challenge, run `navigator.credentials.get()`, then call `/verify` with the assertion and a new `clientPublicKey`. + +```mermaid +sequenceDiagram + participant C as Client + participant IB as Integrator backend + participant G as Grid + participant A as Authenticator + + C->>IB: session expired, re-authenticate + IB->>G: POST /auth/credentials/{id}/challenge + G-->>IB: 200 PasskeyAuthChallenge { challenge, requestId, expiresAt } + IB-->>C: { challenge, requestId } + C->>C: generateClientKeyPair() + C->>A: navigator.credentials.get({ challenge }) + A-->>C: assertion + C->>IB: POST /my-backend/passkey/reauth (assertion, clientPublicKey) + IB->>G: POST /auth/credentials/{id}/verify
Request-Id: requestId + G-->>IB: 200 AuthSession { encryptedSessionSigningKey, expiresAt } + IB-->>C: { encryptedSessionSigningKey, expiresAt } +``` + +## OAuth (OIDC) + +Use an OAuth credential when your platform already authenticates the user with an OpenID Connect identity provider (Google, Apple, your own IdP) and you want Grid to trust that same identity. + +### OAuth registration + +```mermaid +sequenceDiagram + participant C as Client + participant IB as Integrator backend + participant G as Grid + participant OP as OIDC provider + + C->>OP: Sign-in flow + OP-->>C: id_token (OIDC) + C->>IB: POST /my-backend/oauth/register { oidcToken } + IB->>G: POST /auth/credentials { type: OAUTH, oidcToken, accountId } + G->>OP: fetch .well-known/openid-configuration + jwks + G-->>IB: 201 AuthMethod + IB-->>C: { credentialId } + C->>C: generateClientKeyPair() + C->>OP: get fresh id_token (iat within 60s) + OP-->>C: id_token + C->>IB: POST /my-backend/oauth/verify { oidcToken, clientPublicKey } + IB->>G: POST /auth/credentials/{id}/verify { type: OAUTH, oidcToken, clientPublicKey } + G-->>IB: 200 AuthSession + IB-->>C: { encryptedSessionSigningKey, expiresAt } +``` + +Grid validates the OIDC token signature against the issuer's JWKS on every call and requires `iat` to be no more than **60 seconds** older than the request. Use a fresh token for each `verify` call; cached tokens will fail. + +```bash +curl -X POST "$GRID_BASE_URL/auth/credentials" \ + -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ + -H "Content-Type: application/json" \ + -d '{ + "type": "OAUTH", + "accountId": "EmbeddedWallet:019542f5-b3e7-1d02-0000-000000000002", + "oidcToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyIsInR5cCI6IkpXVCJ9..." + }' +``` + +**Response:** `201 AuthMethod` with `nickname` populated from the OIDC token's `email` claim. + +### OAuth verify / reauthentication + +`POST /auth/credentials/{id}/verify` is also the reauthentication path — call it with a fresh OIDC token whenever the session expires. + +```bash +curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001/verify" \ + -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ + -H "Content-Type: application/json" \ + -d '{ + "type": "OAUTH", + "oidcToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyIsInR5cCI6IkpXVCJ9...", + "clientPublicKey": "04f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31d4245acfb88c2ebd42b47628d63ecabf345484f0a9f665b63c54c897d5578be2" + }' +``` + +## Email OTP + +The lowest-friction credential type — works on any device with email access and requires no biometric hardware, identity provider, or client-side setup beyond an input field for the code. + +### Email OTP registration + +Creating the credential triggers an OTP email to the address you pass. The user reads the code off the email and submits it through your UI. + +```mermaid +sequenceDiagram + participant C as Client + participant IB as Integrator backend + participant G as Grid + participant E as Email + + C->>IB: POST /my-backend/otp/register { email } + IB->>G: POST /auth/credentials { type: EMAIL_OTP, email, accountId } + G->>E: deliver OTP email + G-->>IB: 201 AuthMethod + IB-->>C: { credentialId } + E-->>C: OTP code + C->>C: generateClientKeyPair() + C->>IB: POST /my-backend/otp/verify { otp, clientPublicKey } + IB->>G: POST /auth/credentials/{id}/verify { type: EMAIL_OTP, otp, clientPublicKey } + G-->>IB: 200 AuthSession + IB-->>C: { encryptedSessionSigningKey, expiresAt } +``` + +```bash +curl -X POST "$GRID_BASE_URL/auth/credentials" \ + -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ + -H "Content-Type: application/json" \ + -d '{ + "type": "EMAIL_OTP", + "accountId": "EmbeddedWallet:019542f5-b3e7-1d02-0000-000000000002", + "email": "jane@example.com" + }' +``` + +**Response (201):** + +```json +{ + "id": "AuthMethod:019542f5-b3e7-1d02-0000-000000000004", + "accountId": "EmbeddedWallet:019542f5-b3e7-1d02-0000-000000000002", + "type": "EMAIL_OTP", + "nickname": "jane@example.com", + "createdAt": "2026-04-19T12:00:00Z", + "updatedAt": "2026-04-19T12:00:00Z" +} +``` + +Then complete activation with the OTP value: + +```bash +curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000004/verify" \ + -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ + -H "Content-Type: application/json" \ + -d '{ + "type": "EMAIL_OTP", + "otp": "123456", + "clientPublicKey": "04f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31d4245acfb88c2ebd42b47628d63ecabf345484f0a9f665b63c54c897d5578be2" + }' +``` + +### Resending an OTP + +If the code expires or the email didn't arrive, re-issue the challenge with `POST /auth/credentials/{id}/challenge`. This sends a fresh OTP email and leaves the `AuthMethod` otherwise untouched. + +```bash +curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000004/challenge" \ + -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" +``` + + + Challenge re-issues are rate-limited. On `429`, back off for the duration returned in the `Retry-After` header before retrying. + + +### Email OTP reauthentication + +Same pattern as the first activation: call `/challenge` to send a new OTP, then `/verify` with the new code and a fresh `clientPublicKey`. + +## Managing credentials + +Every Embedded Wallet starts with a single credential — the one used in the quickstart. In production, encourage customers to register a second credential of a different type (e.g., an email OTP alongside a passkey) so the wallet is recoverable if their primary device is lost. Adding, revoking, and rotating credentials after the first all go through the same **two-step signed-retry** pattern. + +### List credentials + +```bash +curl -X GET "$GRID_BASE_URL/auth/credentials?accountId=EmbeddedWallet:019542f5-b3e7-1d02-0000-000000000002" \ + -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" +``` + +**Response (200):** + +```json +{ + "data": [ + { + "id": "AuthMethod:019542f5-b3e7-1d02-0000-000000000001", + "accountId": "EmbeddedWallet:019542f5-b3e7-1d02-0000-000000000002", + "type": "PASSKEY", + "nickname": "iPhone Face-ID", + "createdAt": "2026-04-08T15:30:01Z", + "updatedAt": "2026-04-08T15:30:01Z" + }, + { + "id": "AuthMethod:019542f5-b3e7-1d02-0000-000000000004", + "accountId": "EmbeddedWallet:019542f5-b3e7-1d02-0000-000000000002", + "type": "EMAIL_OTP", + "nickname": "jane@example.com", + "createdAt": "2026-04-09T10:15:00Z", + "updatedAt": "2026-04-09T10:15:00Z" + } + ] +} +``` + +The response is not paginated — each account holds a small, bounded number of credentials. + +### The signed-retry pattern + +Adding an additional credential, revoking a credential, revoking a session, and exporting a wallet all share the same shape: + +```mermaid +sequenceDiagram + participant C as Client + participant IB as Integrator backend + participant G as Grid + + IB->>G: Request (no headers) + G-->>IB: 202 { payloadToSign, requestId, expiresAt } + IB-->>C: { payloadToSign, requestId } + C->>C: sign(payloadToSign, sessionPrivateKey) + C->>IB: { signature } + IB->>G: Same request
Grid-Wallet-Signature: signature
Request-Id: requestId + G-->>IB: 2xx (terminal success) + IB-->>C: done +``` + +Key rules: + +- Always sign the `payloadToSign` **byte-for-byte as Grid returned it**. Do not re-parse, re-serialize, or modify whitespace. +- Sign with the **session private key** held on the client — never ship it back to your backend. +- The retry must reach Grid before `expiresAt` (typically 5 minutes from issue). +- The `requestId` is single-use; reusing one yields `401`. + +### Add an additional credential + +Requires an active session on an *existing* credential on the same account. The first call looks identical to the one used to create the first credential; Grid detects the pre-existing credential and responds `202` instead of `201`. + + + + ```bash + curl -X POST "$GRID_BASE_URL/auth/credentials" \ + -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ + -H "Content-Type: application/json" \ + -d '{ + "type": "EMAIL_OTP", + "accountId": "EmbeddedWallet:019542f5-b3e7-1d02-0000-000000000002", + "email": "jane@example.com" + }' + ``` + + **Response (202):** + + ```json + { + "type": "EMAIL_OTP", + "payloadToSign": "{\"requestId\":\"7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21\",\"type\":\"EMAIL_OTP\",\"accountId\":\"EmbeddedWallet:019542f5-b3e7-1d02-0000-000000000002\",\"expiresAt\":\"2026-04-08T15:35:00Z\"}", + "requestId": "7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21", + "expiresAt": "2026-04-08T15:35:00Z" + } + ``` + + + Send `payloadToSign` to the client. The client signs with the session signing key from the existing credential's active session — see signing payloads. + + + Re-run the same request with the signature and request id in headers: + + ```bash + curl -X POST "$GRID_BASE_URL/auth/credentials" \ + -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ + -H "Content-Type: application/json" \ + -H "Grid-Wallet-Signature: MEUCIQDx7k2N0aK4p8f3vR9J6yT5wL1mB0sXnG2hQ4vJ8zYkCgIgZ4rP9dT7eWfU3oM6KjR1qSpNvBwL0tXyA2iG8fH5dE=" \ + -H "Request-Id: 7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21" \ + -d '{ + "type": "EMAIL_OTP", + "accountId": "EmbeddedWallet:019542f5-b3e7-1d02-0000-000000000002", + "email": "jane@example.com" + }' + ``` + + **Response (201):** a plain `AuthMethod`. For `EMAIL_OTP`, Grid delivers the OTP email on this signed retry, not on the first call. + + + Call `POST /auth/credentials/{id}/verify` with the OTP (or the OIDC token / passkey assertion, depending on type) and a fresh `clientPublicKey` — same as activating any first credential. + + + + + Only one credential of each type (`EMAIL_OTP`, `PASSKEY`) is allowed per internal account in v1. Registering a second credential of the same type returns `400 EMAIL_OTP_CREDENTIAL_ALREADY_EXISTS` or `400 PASSKEY_CREDENTIAL_ALREADY_EXISTS`. + + +### Revoke a credential + +A credential is revoked by signing with a session from **a different credential on the same account**. This prevents a compromised credential from revoking itself to lock the legitimate owner out. An account must keep at least one credential — if only one exists, the revoke call returns `400`. + + + + ```bash + curl -X DELETE "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001" \ + -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" + ``` + + **Response (202):** + + ```json + { + "type": "PASSKEY", + "payloadToSign": "Y2hhbGxlbmdlLXBheWxvYWQtdG8tc2lnbg==", + "requestId": "9f7a2c10-5e88-4fb1-bd0e-1c3a8e7b2d45", + "expiresAt": "2026-04-08T15:35:00Z" + } + ``` + + + The client signs `payloadToSign` with the session signing key of an active session on any *other* credential (not the one being revoked). + + + ```bash + curl -X DELETE "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001" \ + -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ + -H "Grid-Wallet-Signature: MEUCIQDx7k2N0aK4p8f3vR9J6yT5wL1mB0sXnG2hQ4vJ8zYkCgIgZ4rP9dT7eWfU3oM6KjR1qSpNvBwL0tXyA2iG8fH5dE=" \ + -H "Request-Id: 9f7a2c10-5e88-4fb1-bd0e-1c3a8e7b2d45" + ``` + + **Response:** `204 No Content`. All active sessions issued by the revoked credential are also revoked. + + diff --git a/mintlify/snippets/embedded-wallets/client-keys.mdx b/mintlify/snippets/embedded-wallets/client-keys.mdx new file mode 100644 index 00000000..bb7950fa --- /dev/null +++ b/mintlify/snippets/embedded-wallets/client-keys.mdx @@ -0,0 +1,305 @@ +Every signed Embedded Wallet action uses two key pairs: + +| Key pair | Where it lives | What it does | +|---|---|---| +| **Client key pair** (P-256) | On the customer's device, generated fresh per verification request | Used as the HPKE recipient key so Grid can encrypt the session signing key to the client. Ephemeral — one pair per `POST /auth/credentials/{id}/verify` call. | +| **Session signing key** (P-256) | Issued by Grid, encrypted to the client public key, decrypted and held on the device | Signs every wallet action for the lifetime of the session (default 15 minutes). | + +This page covers generating the client key pair, sending the public key to your backend, decrypting the session signing key, and signing payloads. Everything here runs **on the client**; your integrator backend only relays opaque byte strings. + +## 1. Generate a client key pair + +Generate a fresh P-256 key pair for every `POST /auth/credentials/{id}/verify` call and for every wallet export. Keep the private key in device-local secure storage (browser `IndexedDB` gated by Web Crypto's non-extractable flag, iOS Keychain, Android Keystore). Send the public key hex-encoded — a 130-character string starting with `04` — to your integrator backend, which passes it to Grid as `clientPublicKey`. The Web Crypto, iOS, and Android APIs shown below all produce this format natively. + + + For local development, you can generate a P-256 key pair from the command line: + ```bash + # Private key (PKCS#8 PEM) + openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out private.pem + + # Public key (SPKI PEM) + openssl pkey -in private.pem -pubout -out public.pem + ``` + + + +```typescript Web (TypeScript) +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +// Generate a non-extractable P-256 key pair in the browser. +// The private key never leaves Web Crypto; only the public key is exported. +async function generateClientKeyPair(): Promise<{ + keyPair: CryptoKeyPair; + publicKeyHex: string; +}> { + const keyPair = await crypto.subtle.generateKey( + { name: "ECDH", namedCurve: "P-256" }, + false, // private key non-extractable + ["deriveBits"], + ); + + const raw = new Uint8Array( + await crypto.subtle.exportKey("raw", keyPair.publicKey), + ); + // exportKey("raw") returns the 65-byte uncompressed form (0x04 || X || Y). + const publicKeyHex = bytesToHex(raw); + + return { keyPair, publicKeyHex }; +} +``` + +```kotlin Android (Kotlin) +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.security.KeyPairGenerator +import java.security.interfaces.ECPublicKey +import java.security.spec.ECPoint + +data class ClientKeyPair(val alias: String, val publicKeyHex: String) + +private fun BigInteger.toFixed32(): ByteArray { + val bytes = toByteArray() + return ByteArray(32).also { out -> + val copyFrom = maxOf(0, bytes.size - 32) + val copyLength = bytes.size - copyFrom + System.arraycopy(bytes, copyFrom, out, 32 - copyLength, copyLength) + } +} + +fun generateClientKeyPair(alias: String): ClientKeyPair { + val generator = KeyPairGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_EC, + "AndroidKeyStore", + ) + generator.initialize( + KeyGenParameterSpec.Builder( + alias, + KeyProperties.PURPOSE_AGREE_KEY, + ) + .setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1")) + .build(), + ) + val keyPair = generator.generateKeyPair() + val point: ECPoint = (keyPair.public as ECPublicKey).w + val x = point.affineX.toFixed32() + val y = point.affineY.toFixed32() + val uncompressed = byteArrayOf(0x04) + x + y + val hex = uncompressed.joinToString("") { "%02x".format(it) } + return ClientKeyPair(alias = alias, publicKeyHex = hex) +} +``` + +```swift iOS (Swift) +import CryptoKit +import Security + +struct ClientKeyPair { + let privateKey: P256.KeyAgreement.PrivateKey + let publicKeyHex: String +} + +func generateClientKeyPair() -> ClientKeyPair { + let privateKey = P256.KeyAgreement.PrivateKey() + // x963Representation returns uncompressed SEC1 (65 bytes starting with 0x04). + let raw = privateKey.publicKey.x963Representation + let hex = raw.map { String(format: "%02x", $0) }.joined() + return ClientKeyPair(privateKey: privateKey, publicKeyHex: hex) +} +``` + + + + The private key **must not leave the device**. Your integrator backend only ever sees `publicKeyHex`. + + +## 2. Verify the credential and receive the encrypted session signing key + +Your client sends `publicKeyHex` to your integrator backend along with whatever the credential type requires (OTP value, OIDC token, or WebAuthn assertion — see Authentication). Your backend calls `POST /auth/credentials/{id}/verify` and returns the `encryptedSessionSigningKey` from Grid's response to the client. + +Grid encrypts the session signing key with **HPKE** (RFC 9180) using the suite: + +- KEM: DHKEM(P-256, HKDF-SHA256) +- KDF: HKDF-SHA256 +- AEAD: AES-256-GCM + +The wire format is a base58check string. Decoded, the payload is a 33-byte compressed P-256 encapsulated public key followed by AES-256-GCM ciphertext (ciphertext || 16-byte auth tag). + +## 3. Decrypt the session signing key + + +```typescript Web (TypeScript) +// npm i @hpke/core @hpke/dhkem-p256 bs58check +import { Aes256Gcm, CipherSuite, HkdfSha256 } from "@hpke/core"; +import { DhkemP256HkdfSha256 } from "@hpke/dhkem-p256"; +import bs58check from "bs58check"; + +async function decryptSessionSigningKey( + clientKeyPair: CryptoKeyPair, + encryptedSessionSigningKey: string, +): Promise { + const payload = bs58check.decode(encryptedSessionSigningKey); + const enc = payload.slice(0, 33); // compressed P-256 encapsulated public key + const ciphertext = payload.slice(33); + + const suite = new CipherSuite({ + kem: new DhkemP256HkdfSha256(), + kdf: new HkdfSha256(), + aead: new Aes256Gcm(), + }); + + const recipient = await suite.createRecipientContext({ + recipientKey: clientKeyPair.privateKey, + enc, + }); + const plaintext = await recipient.open(ciphertext); + return new Uint8Array(plaintext); // 32-byte P-256 session private key (scalar) +} +``` + +```kotlin Android (Kotlin) +// Uses BouncyCastle for HPKE. implementation("org.bouncycastle:bcprov-jdk18on:1.78.1") +// Decoded session signing key is a 32-byte P-256 private scalar. +import org.bouncycastle.crypto.hpke.HPKE +import org.bouncycastle.crypto.hpke.HPKEContextWithEncapsulation +import java.security.KeyStore + +fun decryptSessionSigningKey( + alias: String, + encryptedSessionSigningKey: String, // base58check +): ByteArray { + val payload = Base58Check.decode(encryptedSessionSigningKey) + val enc = payload.copyOfRange(0, 33) + val ciphertext = payload.copyOfRange(33, payload.size) + + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + val privateKey = keyStore.getKey(alias, null) as java.security.interfaces.ECPrivateKey + + val hpke = HPKE( + HPKE.mode_base, + HPKE.kem_P256_SHA256, + HPKE.kdf_HKDF_SHA256, + HPKE.aead_AES_GCM256, + ) + val recipient = hpke.setupBaseR(enc, privateKey, byteArrayOf()) + return recipient.open(byteArrayOf(), ciphertext) +} +``` + +```swift iOS (Swift) +// swift-crypto 3.x exposes HPKE via CryptoKit. +// The decoded session signing key is a 32-byte P-256 private scalar. +import CryptoKit +import Foundation + +func decryptSessionSigningKey( + clientPrivateKey: P256.KeyAgreement.PrivateKey, + encryptedSessionSigningKey: String, // base58check +) throws -> Data { + let payload = try Base58Check.decode(encryptedSessionSigningKey) + let enc = payload.prefix(33) + let ciphertext = payload.suffix(from: 33) + + var recipient = try HPKE.Recipient( + privateKey: clientPrivateKey, + ciphersuite: .P256_SHA256_AES_GCM_256, + info: Data(), + encapsulatedKey: Data(enc), + ) + return try recipient.open(Data(ciphertext), authenticating: Data()) +} +``` + + +The plaintext is a **32-byte P-256 private scalar**. Treat it as the session signing key for the rest of the session. + +## 4. Sign a `payloadToSign` + +Grid returns `payloadToSign` strings from several endpoints: + +- `POST /quotes` (when the source is an Embedded Wallet) — the quote's `paymentInstructions[].accountOrWalletInfo.payloadToSign`. +- `POST /auth/credentials` (adding an additional credential) — 202 response body. +- `DELETE /auth/credentials/{id}`, `DELETE /auth/sessions/{id}`, `POST /internal-accounts/{id}/export` — all 202 response bodies. + +Sign the payload **byte-for-byte as returned** (do not re-parse, re-serialize, or trim whitespace). The signature is ECDSA over SHA-256 using the session signing key, DER-encoded, then base64-encoded. Pass it as the `Grid-Wallet-Signature` header on the retry (and, for endpoints that use it, the `Request-Id` header echoed back from the 202). + + +```typescript Web (TypeScript) +// npm i @noble/curves @noble/hashes +import { p256 } from "@noble/curves/p256"; +import { sha256 } from "@noble/hashes/sha256"; + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +function signPayload( + sessionPrivateKeyBytes: Uint8Array, // 32 bytes, from decryptSessionSigningKey + payloadToSign: string, +): string { + const digest = sha256(new TextEncoder().encode(payloadToSign)); + const signature = p256.sign(digest, sessionPrivateKeyBytes); + return bytesToBase64(signature.toDERRawBytes()); +} +``` + +```kotlin Android (Kotlin) +import java.security.KeyFactory +import java.security.Signature +import java.security.spec.ECPrivateKeySpec +import java.security.spec.ECParameterSpec +import android.util.Base64 + +fun signPayload( + sessionPrivateScalar: ByteArray, // 32 bytes + payloadToSign: String, + p256Params: ECParameterSpec, +): String { + val s = java.math.BigInteger(1, sessionPrivateScalar) + val privateKey = KeyFactory.getInstance("EC") + .generatePrivate(ECPrivateKeySpec(s, p256Params)) + val signer = Signature.getInstance("SHA256withECDSA").apply { + initSign(privateKey) + update(payloadToSign.toByteArray(Charsets.UTF_8)) + } + val der = signer.sign() // JCE returns DER-encoded ECDSA + return Base64.encodeToString(der, Base64.NO_WRAP) +} +``` + +```swift iOS (Swift) +import CryptoKit +import Foundation + +func signPayload( + sessionPrivateScalar: Data, // 32 bytes + payloadToSign: String, +) throws -> String { + let signingKey = try P256.Signing.PrivateKey(rawRepresentation: sessionPrivateScalar) + let payload = Data(payloadToSign.utf8) + let signature = try signingKey.signature(for: payload) + return signature.derRepresentation.base64EncodedString() +} +``` + + +Your backend adds the signature to the retry request: + +```bash +curl -X POST "https://api.lightspark.com/grid/2025-10-13/quotes/Quote:019542f5-b3e7-1d02-0000-000000000006/execute" \ + -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ + -H "Grid-Wallet-Signature: MEUCIQDx7k2N0aK4p8f3vR9J6yT5wL1mB0sXnG2hQ4vJ8zYkCgIgZ4rP9dT7eWfU3oM6KjR1qSpNvBwL0tXyA2iG8fH5dE=" +``` + +## Session lifetime + +Sessions are valid for 15 minutes by default. The `AuthSession.expiresAt` field tells you exactly when the session signing key stops being accepted. After expiry, the client must re-verify the credential (see Authentication) to obtain a fresh session. + + + If the device is lost or compromised, the user should add a second credential from a trusted device and revoke the compromised one — see Managing credentials. To end the current browser or app session without touching credentials, see Sessions. + diff --git a/mintlify/snippets/embedded-wallets/exporting-wallet.mdx b/mintlify/snippets/embedded-wallets/exporting-wallet.mdx new file mode 100644 index 00000000..9624c18a --- /dev/null +++ b/mintlify/snippets/embedded-wallets/exporting-wallet.mdx @@ -0,0 +1,78 @@ +Exporting a wallet returns the wallet's mnemonic seed, encrypted to the client's public key. The customer decrypts it on their device and can then import the wallet into any compatible self-custody client. Grid never sees the plaintext seed leaving the system. + +Export uses the same signed-retry pattern as credential and session revocation — the initial `POST` returns a `payloadToSign`, and the signed retry returns the encrypted seed. + +Generate a fresh P-256 client key pair specifically for the export. Send its `clientPublicKey` on both export requests, then decrypt `encryptedWalletCredentials` with the matching private key after the signed retry succeeds. + +```mermaid +sequenceDiagram + participant C as Client + participant IB as Integrator backend + participant G as Grid + + IB->>G: POST /internal-accounts/{id}/export { clientPublicKey } + G-->>IB: 202 { payloadToSign, requestId, expiresAt } + IB-->>C: { payloadToSign, requestId } + C->>C: sign(payloadToSign, sessionPrivateKey) + C->>IB: { signature } + IB->>G: POST /internal-accounts/{id}/export { same clientPublicKey }
Grid-Wallet-Signature
Request-Id + G-->>IB: 200 { id, encryptedWalletCredentials } + IB-->>C: { encryptedWalletCredentials } + C->>C: decrypt with client private key
→ mnemonic +``` + + + + ```bash + curl -X POST "$GRID_BASE_URL/internal-accounts/InternalAccount:019542f5-b3e7-1d02-0000-000000000002/export" \ + -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" + -H "Content-Type: application/json" \ + -d '{ + "clientPublicKey": "04f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31d4245acfb88c2ebd42b47628d63ecabf345484f0a9f665b63c54c897d5578be2" + }' + ``` + + **Response (202):** + + ```json + { + "payloadToSign": "Y2hhbGxlbmdlLXBheWxvYWQtdG8tc2lnbg==", + "requestId": "c3f8a614-47e2-4a19-9f5d-2b0a91d47e08", + "expiresAt": "2026-04-19T12:10:00Z" + } + ``` + + + Sign `payloadToSign` with an active session signing key on the account. Keep the export private key on the client; Grid will use the matching `clientPublicKey` from step 1 to seal the wallet credentials. + + + ```bash + curl -X POST "$GRID_BASE_URL/internal-accounts/InternalAccount:019542f5-b3e7-1d02-0000-000000000002/export" \ + -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ + -H "Content-Type: application/json" \ + -H "Grid-Wallet-Signature: MEUCIQDx7k2N0aK4p8f3vR9J6yT5wL1mB0sXnG2hQ4vJ8zYkCgIgZ4rP9dT7eWfU3oM6KjR1qSpNvBwL0tXyA2iG8fH5dE=" \ + -H "Request-Id: c3f8a614-47e2-4a19-9f5d-2b0a91d47e08" \ + -d '{ + "clientPublicKey": "04f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31d4245acfb88c2ebd42b47628d63ecabf345484f0a9f665b63c54c897d5578be2" + }' + ``` + + **Response (200):** + + ```json + { + "id": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", + "encryptedWalletCredentials": "5KqM8nT3wJz2F9b6H1vRgLpXcA7eD4YuN0sBaE8kPyW5iVfG2xQoZ3MnK9LhU6jT1dS4rCyPbH7oVwX2AgE5uYsNq8fLzR3D7JeM1bVkWcHa9Tp" + } + ``` + + + `encryptedWalletCredentials` uses the same base58check/HPKE format as `encryptedSessionSigningKey`. Decrypt with the export private key that matches the `clientPublicKey` you sent on both export requests — see decrypt the session signing key for code. + + The plaintext is a BIP-39 mnemonic (the wallet's master seed). + + + + + The exported mnemonic is the master key of the self-custody wallet. After decryption the customer is the only custodian — if the mnemonic is lost, the funds are lost. Surface appropriate warnings in your UI before running an export. + diff --git a/mintlify/snippets/embedded-wallets/managing-sessions.mdx b/mintlify/snippets/embedded-wallets/managing-sessions.mdx new file mode 100644 index 00000000..4966b698 --- /dev/null +++ b/mintlify/snippets/embedded-wallets/managing-sessions.mdx @@ -0,0 +1,78 @@ +Every call to `POST /auth/credentials/{id}/verify` creates a new **session** — an authenticated signing context with a 15-minute lifetime by default. Sessions accumulate: a customer signed in on a laptop and a phone has two active sessions, each with its own session signing key held on that device. Use the session endpoints to show the customer their active sign-ins and to sign out of a specific device. + +## List active sessions + +```bash +curl -X GET "$GRID_BASE_URL/auth/sessions?accountId=InternalAccount:019542f5-b3e7-1d02-0000-000000000002" \ + -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" +``` + +**Response (200):** + +```json +{ + "data": [ + { + "id": "Session:019542f5-b3e7-1d02-0000-000000000003", + "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", + "type": "PASSKEY", + "nickname": "iPhone Face-ID", + "createdAt": "2026-04-19T12:00:02Z", + "updatedAt": "2026-04-19T12:00:02Z", + "expiresAt": "2026-04-19T12:15:02Z" + }, + { + "id": "Session:019542f5-b3e7-1d02-0000-000000000007", + "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", + "type": "EMAIL_OTP", + "nickname": "jane@example.com", + "createdAt": "2026-04-19T10:01:00Z", + "updatedAt": "2026-04-19T10:01:00Z", + "expiresAt": "2026-04-19T10:16:00Z" + } + ] +} +``` + +The list endpoint returns all **active** sessions; expired sessions are not included. `encryptedSessionSigningKey` is never returned here — it is delivered exactly once on the verify response and never again. + +## Revoke a session + +Session revocation uses the same signed-retry pattern as credential management. Unlike credential revocation, a session **can revoke itself** — this is how self-logout works: sign with the session key you are about to invalidate. + + + + ```bash + curl -X DELETE "$GRID_BASE_URL/auth/sessions/Session:019542f5-b3e7-1d02-0000-000000000003" \ + -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" + ``` + + **Response (202):** + + ```json + { + "type": "PASSKEY", + "payloadToSign": "Y2hhbGxlbmdlLXBheWxvYWQtdG8tc2lnbg==", + "requestId": "2b1e5a08-9c44-4e91-ae7f-6d0b3f8c1e22", + "expiresAt": "2026-04-19T12:10:00Z" + } + ``` + + + Sign `payloadToSign` with any active session signing key on the same account — either the session being revoked (self-logout) or another session (admin-style sign-out of a different device). + + + ```bash + curl -X DELETE "$GRID_BASE_URL/auth/sessions/Session:019542f5-b3e7-1d02-0000-000000000003" \ + -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ + -H "Grid-Wallet-Signature: MEUCIQDx7k2N0aK4p8f3vR9J6yT5wL1mB0sXnG2hQ4vJ8zYkCgIgZ4rP9dT7eWfU3oM6KjR1qSpNvBwL0tXyA2iG8fH5dE=" \ + -H "Request-Id: 2b1e5a08-9c44-4e91-ae7f-6d0b3f8c1e22" + ``` + + **Response:** `204 No Content`. + + + + + Revoking a session only invalidates the session signing key, not the credential that issued it. The next call to `POST /auth/credentials/{id}/verify` on that credential still works and issues a brand new session. + diff --git a/mintlify/snippets/embedded-wallets/overview.mdx b/mintlify/snippets/embedded-wallets/overview.mdx new file mode 100644 index 00000000..1df740d7 --- /dev/null +++ b/mintlify/snippets/embedded-wallets/overview.mdx @@ -0,0 +1,405 @@ +An Embedded Wallet is a self-custodial wallet Grid provisions for your customer that holds a USDB stablecoin balance and participates in the standard Grid payment flows. It behaves like any other internal account for *incoming* funds, but every outbound transfer must be authorized by the customer — a session signing key issued on their device signs each payment. + +## Why an Embedded Wallet? + +- **Self-custody.** Grid never has unilateral access to move funds. The customer's device is the only party that can authorize a transaction. +- **Stablecoin-denominated.** Balances are held as USDB. Use the standard `/quotes` API to convert in from fiat or out to any supported Grid bank-account rail (ACH, PIX, CLABE, UPI, IBAN, UMA, …). +- **Grid-native.** You reuse the customer, internal-account, quote, transaction, and webhook primitives you already integrated for Payouts or P2P. The only thing that's new is an auth + signing layer at the wallet. + +## Architecture + +Three parties participate in every signed action: + +| Party | Role | +|---|---| +| **Client** | The customer's device (browser, iOS app, or Android app). Generates the client key pair, runs WebAuthn, decrypts the session signing key, and signs outbound requests. | +| **Integrator backend** | Your server. Holds your Grid API credentials, brokers every call to Grid on behalf of the client, and issues WebAuthn challenges for initial passkey registration. | +| **Grid** | Verifies auth credentials, issues session signing keys (encrypted to the client's public key), and enforces that every wallet action is authorized. | + +The client **never** talks to Grid directly. Every request flows client → integrator backend → Grid. + +## Auth credentials, client keys, and session signing keys + +Three distinct pieces of crypto collaborate to authorize actions on the Embedded Wallet (withdrawals, credential changes, session revocations, and wallet exports): + +| Piece | Where it lives | How long it lives | What it proves | +|---|---|---|---| +| **Auth credential** — passkey, OIDC token, or email OTP | Registered on the wallet; the passkey itself lives on the authenticator, OIDC on your IdP, OTP in the user's inbox | Until the customer revokes it | *"I am the human who owns this wallet."* Used to authenticate the user at the start of each session. | +| **Client key pair** (P-256) | Generated on the client device for each verification request; private key stays in device-local secure storage | One verification request | Binds a given session signing key delivery to the exact device that asked for it — Grid encrypts the session to this public key, so only this device can decrypt. | +| **Session signing key** (P-256) | Issued by Grid, sealed to the client public key, decrypted and held on the device for the session's lifetime | 15 minutes (default) | *"This specific wallet action was approved on an authenticated device."* Signs the `payloadToSign` Grid returns on quotes, credential changes, session revocations, and wallet exports. | + +The flow is always the same: verify an auth credential → receive a short-lived session signing key → sign `payloadToSign` bytes on the client → pass the signature as the `Grid-Wallet-Signature` header on the request that actually moves funds or changes account state. This applies to withdrawals, adding or removing credentials, revoking sessions, and exporting the wallet seed. + +See Client keys & signing for the full key-management and signing reference, and Authentication for the per-credential-type flows. + +## Prerequisites + + + Customers who hold an Embedded Wallet must be KYC/KYB verified before any funds can move in or out. This quickstart picks up after KYC is complete. + + In sandbox, customers are automatically KYC approved on creation so you can skip straight to wallet setup. + + +You also need: + +- A platform configured with `USDB` in its supported currencies. In sandbox, USDB is enabled by default alongside `USD` and `USDC`. +- Sandbox or production API credentials with access to the `Embedded Wallet Auth` and `Internal Accounts` endpoints. + +```bash +export GRID_BASE_URL="https://api.lightspark.com/grid/2025-10-13" +export GRID_CLIENT_ID="YOUR_SANDBOX_CLIENT_ID" +export GRID_CLIENT_SECRET="YOUR_SANDBOX_CLIENT_SECRET" +``` + +## Quickstart + +The walkthrough below is the happy path: create a customer, find the auto-provisioned wallet, register a passkey, fund it, and withdraw to a bank account. Each step shows the HTTP request your integrator backend makes on behalf of the client. + +### 1. Create a customer + +Create the customer record. An Embedded Wallet is provisioned automatically whenever a customer is created on a platform that has `USDB` in its supported currencies — you don't need to pass it on the customer. + +```bash +curl -X POST "$GRID_BASE_URL/customers" \ + -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ + -H "Content-Type: application/json" \ + -d '{ + "customerType": "INDIVIDUAL", + "platformCustomerId": "ind-9f84e0c2", + "region": "US", + "email": "jane@example.com", + "fullName": "Jane Doe", + "birthDate": "1990-01-15", + "nationality": "US" + }' +``` + +**Response:** `201 Created` with the new `Customer:...` id. In sandbox, the customer is KYC-approved immediately; in production you would now run them through the [KYC / KYB flow](../onboarding/configuring-customers) before any funds can move. + +### 2. Find the Embedded Wallet + +When a customer is created on a USDB-enabled platform, Grid automatically provisions an Embedded Wallet alongside their other internal accounts. Fetch it by filtering the customer's internal accounts by `type=EMBEDDED_WALLET`. + +```bash +curl -X GET "$GRID_BASE_URL/internal-accounts?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001&type=EMBEDDED_WALLET" \ + -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" +``` + +**Response:** + +```json +{ + "data": [ + { + "id": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", + "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", + "type": "EMBEDDED_WALLET", + "balance": { + "amount": 0, + "currency": { + "code": "USDB", + "name": "USDB", + "decimals": 2 + } + }, + "fundingPaymentInstructions": [], + "createdAt": "2026-04-19T12:00:00Z", + "updatedAt": "2026-04-19T12:00:00Z" + } + ], + "hasMore": false, + "totalCount": 1 +} +``` + +Hold onto the `InternalAccount:...` id — every auth credential is scoped to it. + +### 3. Register a passkey credential + +Embedded Wallets support three authentication credential types: **passkey**, **OAuth (OIDC)**, and **email OTP**. A passkey is a user-friendly default: biometric, phishing-resistant, and usable across the user's devices. + +Registration only binds the passkey to the wallet — it doesn't issue a session. Sessions are created on-demand, when the customer initiates an action that needs a signature (step 7). The full flow with sequence diagram is documented in Authentication; the condensed version: + + + + Generate a random base64url `challenge`, store it short-lived in your session store, and return it to the client. + + + The browser or OS prompts the user for a biometric, returns an `attestation`. The client posts the attestation back to your backend. + + + ```bash + curl -X POST "$GRID_BASE_URL/auth/credentials" \ + -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ + -H "Content-Type: application/json" \ + -d '{ + "type": "PASSKEY", + "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", + "nickname": "iPhone Face-ID", + "challenge": "ArkQi2yAYHPlgnJNFBlneIwchQdWXBOTrdB-AmMUB21Lx", + "attestation": { + "credentialId": "AdKXJEch1aV5Wo7bj7qLHskVY4OoNaj9qu8TPdJ7kSAgUeRxWNngXlcNIGt4gexZGKVGcqZpqqWordXb_he1izY", + "clientDataJson": "eyJjaGFsbGVuZ2UiOiJBcktRaTJ5...", + "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10...", + "transports": ["internal", "hybrid"] + } + }' + ``` + + Grid verifies the attestation and replies `201` with the new `AuthMethod:...` id plus the first-authentication `challenge`, `requestId`, and `expiresAt`. Persist the auth method id against the customer — you'll pass it to `/challenge` and `/verify` whenever the customer needs to sign. + + + +The passkey is now bound to the wallet. You'll use it to authorize the withdrawal in step 7. + +### 4. Fund the wallet + +Embedded Wallets behave like any other internal account on the way in — incoming funds do not need the customer's signature. In sandbox, use the sandbox funding endpoint to skip straight to a funded state: + +```bash +curl -X POST "$GRID_BASE_URL/sandbox/internal-accounts/InternalAccount:019542f5-b3e7-1d02-0000-000000000002/fund" \ + -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ + -H "Content-Type: application/json" \ + -d '{ + "amount": 100000 + }' +``` + +You will receive an `INCOMING_PAYMENT` webhook when the balance updates. The wallet now holds 1,000.00 USDB. + + + To fund from another currency (USD ACH, USDC on-chain, etc.), create a quote with `destination.destinationType: "ACCOUNT"` pointing at the Embedded Wallet's `InternalAccount` id. The quote's `sourceCurrency` can be any supported platform currency; Grid will convert into USDB on execute. + + +### 5. Add an external bank account + +Add the destination the customer wants to withdraw to. This is a standard external account — nothing Embedded Wallet-specific. + +```bash +curl -X POST "$GRID_BASE_URL/customers/external-accounts" \ + -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ + -H "Content-Type: application/json" \ + -d '{ + "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", + "currency": "USD", + "platformAccountId": "jane_doe_checking", + "accountInfo": { + "accountType": "USD_ACCOUNT", + "accountNumber": "1234567890", + "routingNumber": "021000021", + "beneficiary": { + "beneficiaryType": "INDIVIDUAL", + "fullName": "Jane Doe", + "birthDate": "1990-01-15", + "nationality": "US", + "address": { + "line1": "123 Main Street", + "city": "San Francisco", + "state": "CA", + "postalCode": "94105", + "country": "US" + } + } + } + }' +``` + +**Response:** `201 Created` with the new `ExternalAccount:...` id. + +### 6. Create a withdrawal quote + +Create a quote with the Embedded Wallet as the source. Grid returns a `payloadToSign` in the quote's payment instructions — this is what the client will sign to authorize the transfer. + +```bash +curl -X POST "$GRID_BASE_URL/quotes" \ + -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ + -H "Content-Type: application/json" \ + -d '{ + "source": { + "sourceType": "ACCOUNT", + "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002" + }, + "destination": { + "destinationType": "ACCOUNT", + "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" + }, + "lockedCurrencySide": "SENDING", + "lockedCurrencyAmount": 10000, + "description": "Withdrawal to checking" + }' +``` + +**Response:** + +```json +{ + "id": "Quote:019542f5-b3e7-1d02-0000-000000000006", + "status": "PENDING", + "createdAt": "2026-04-19T12:05:00Z", + "expiresAt": "2026-04-19T12:10:00Z", + "source": { + "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", + "currency": "USDB" + }, + "destination": { + "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123", + "currency": "USD" + }, + "sendingCurrency": { "code": "USDB", "name": "USDB", "decimals": 2 }, + "receivingCurrency": { "code": "USD", "name": "United States Dollar", "symbol": "$", "decimals": 2 }, + "totalSendingAmount": 10000, + "totalReceivingAmount": 9975, + "exchangeRate": 1.0, + "feesIncluded": 25, + "transactionId": "Transaction:019542f5-b3e7-1d02-0000-000000000005", + "paymentInstructions": [ + { + "accountOrWalletInfo": { + "accountType": "EMBEDDED_WALLET", + "payloadToSign": "{\"type\":\"ACTIVITY_TYPE_SIGN_TRANSACTION_V2\",\"timestampMs\":\"1746736509954\",\"organizationId\":\"org_abc123\",\"parameters\":{\"signWith\":\"wallet_abc123def456\",\"unsignedTransaction\":\"ea69b4bf05f775209f26ff0a34a05569180f7936579d5c4af9377ae550194f72\",\"type\":\"TRANSACTION_TYPE_ETHEREUM\"},\"generateAppProofs\":true}" + }, + "instructionsNotes": "Sign the payloadToSign byte-for-byte and pass the signature as the Grid-Wallet-Signature header on execute" + } + ] +} +``` + +### 7. Authenticate and sign + +The customer has an outstanding quote with a `payloadToSign`. Now we need a session signing key to sign it with — this is when the passkey actually gets used. The flow is challenge → assertion → verify → decrypt → sign. + + + + ```bash + curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001/challenge" \ + -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" + ``` + + **Response (200):** + + ```json + { + "id": "AuthMethod:019542f5-b3e7-1d02-0000-000000000001", + "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", + "type": "PASSKEY", + "nickname": "iPhone Face-ID", + "createdAt": "2026-04-19T12:00:01Z", + "updatedAt": "2026-04-19T12:05:00Z", + "challenge": "VjZ6o8KfE9V3q3LkR2nH5eZ6dM8yA1xW", + "requestId": "7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21", + "expiresAt": "2026-04-19T12:10:00Z" + } + ``` + + Return `challenge` and `requestId` to the client. + + + The client generates a fresh P-256 client key pair, then prompts the authenticator with the Grid-issued challenge: + + ```js + const base64urlToBytes = (value) => { + const padded = value.replace(/-/g, "+").replace(/_/g, "/") + .padEnd(Math.ceil(value.length / 4) * 4, "="); + return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0)); + }; + + const assertion = await navigator.credentials.get({ + publicKey: { + challenge: base64urlToBytes(gridChallenge), + rpId: "yourapp.com", + userVerification: "required", + }, + }); + ``` + + Post the assertion plus `clientPublicKey` (uncompressed hex) back to your backend. + + + ```bash + curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001/verify" \ + -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ + -H "Content-Type: application/json" \ + -H "Request-Id: 7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21" \ + -d '{ + "type": "PASSKEY", + "assertion": { + "credentialId": "KEbWNCc7NgaYnUyrNeFGX9_3Y-8oJ3KwzjnaiD1d1LVTxR7v3CaKfCz2Vy_g_MHSh7yJ8yL0Pxg6jo_o0hYiew", + "clientDataJson": "eyJjaGFsbGVuZ2UiOiJWalo2bzhLZkU5VjNxM0xrUjJuSDVlWjZkTTh5QTF4VyIsIm9yaWdpbiI6Imh0dHBzOi8veW91cmFwcC5jb20iLCJ0eXBlIjoid2ViYXV0aG4uZ2V0In0", + "authenticatorData": "PdxHEOnAiLIp26idVjIguzn3Ipr_RlsKZWsa-5qK-KABAAAAkA", + "signature": "MEUCIQDYXBOpCWSWq2Ll4558GJKD2RoWg958lvJSB_GdeokxogIgWuEVQ7ee6AswQY0OsuQ6y8Ks6jhd45bDx92wjXKs900" + }, + "clientPublicKey": "04f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31d4245acfb88c2ebd42b47628d63ecabf345484f0a9f665b63c54c897d5578be2" + }' + ``` + + **Response (200):** + + ```json + { + "id": "Session:019542f5-b3e7-1d02-0000-000000000003", + "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", + "type": "PASSKEY", + "nickname": "iPhone Face-ID", + "encryptedSessionSigningKey": "w99a5xV6A75TfoAUkZn869fVyDYvgVsKrawMALZXmrauZd8hEv66EkPU1Z42CUaHESQjcA5bqd8dynTGBMLWB9ewtXWPEVbZvocB4Tw2K1vQVp7uwjf", + "createdAt": "2026-04-19T12:05:01Z", + "updatedAt": "2026-04-19T12:05:01Z", + "expiresAt": "2026-04-19T12:20:01Z" + } + ``` + + Return `encryptedSessionSigningKey` and `expiresAt` to the client. + + + The client decrypts `encryptedSessionSigningKey` with the matching client private key, then signs the quote's `payloadToSign` with the resulting session signing key. Return the base64 signature to your backend. + + + + + Sign the `payloadToSign` bytes exactly as Grid returned them. Do not parse, re-serialize, trim, or normalize the JSON — the signature must cover the same bytes Grid's verifier hashes. + + +The session signing key is now valid for 15 minutes, so subsequent wallet actions within that window (for example, a second withdrawal) can reuse it without another `/challenge` + `/verify` round-trip. + +### 8. Execute the quote + +Call `/execute` with the signature in the `Grid-Wallet-Signature` header. + +```bash +curl -X POST "$GRID_BASE_URL/quotes/Quote:019542f5-b3e7-1d02-0000-000000000006/execute" \ + -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: 7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21" \ + -H "Grid-Wallet-Signature: MEUCIQDx7k2N0aK4p8f3vR9J6yT5wL1mB0sXnG2hQ4vJ8zYkCgIgZ4rP9dT7eWfU3oM6KjR1qSpNvBwL0tXyA2iG8fH5dE=" +``` + +**Response:** + +```json +{ + "id": "Quote:019542f5-b3e7-1d02-0000-000000000006", + "status": "PROCESSING", + "transactionId": "Transaction:019542f5-b3e7-1d02-0000-000000000005", + "totalSendingAmount": 10000, + "totalReceivingAmount": 9975, + "feesIncluded": 25 +} +``` + +The transaction is on its way. You'll receive standard transaction webhooks (`OUTGOING_PAYMENT`) as it settles — see [Transaction lifecycle](/platform-overview/core-concepts/transaction-lifecycle). + +## Where to next + + + + Generate the P-256 key pair, decrypt the session signing key, and sign payloads on Web, iOS, and Android. + + + OAuth and Email OTP flows, passkey reauthentication, and the full WebAuthn parameter mapping. + + + List active sessions and revoke a session (sign-out). + + + Let a customer take their wallet seed off Grid. + +