From 630c3d3c90778ceaf7bac8812b6d1800a024aec1 Mon Sep 17 00:00:00 2001 From: Peng Ying Date: Thu, 12 Feb 2026 14:11:43 -0800 Subject: [PATCH 1/8] docs: add design doc for Grid API sample application MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defines architecture for a Kotlin backend + shared Vite frontend sample demonstrating the payout flow (customer → external account → quote → execute → sandbox fund) with live webhook streaming via SSE. Co-Authored-By: Claude Opus 4.6 --- .../2026-02-12-grid-sample-app-design.md | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 docs/plans/2026-02-12-grid-sample-app-design.md diff --git a/docs/plans/2026-02-12-grid-sample-app-design.md b/docs/plans/2026-02-12-grid-sample-app-design.md new file mode 100644 index 0000000..2defeea --- /dev/null +++ b/docs/plans/2026-02-12-grid-sample-app-design.md @@ -0,0 +1,202 @@ +# Grid API Sample Application Design + +## Overview + +A sample application demonstrating the Grid API payout flow using the Grid Kotlin SDK. Consists of a shared Vite/React frontend and a Kotlin (Ktor) backend. The frontend is reusable with future backend implementations in other languages. + +## Architecture + +**Approach:** Thin backend proxy. The Kotlin backend holds API credentials, translates frontend JSON requests into Grid SDK builder calls, and returns raw JSON responses. The frontend orchestrates the step-by-step wizard flow. Webhooks stream from backend to frontend via SSE. + +## Directory Structure + +``` +samples/ +├── frontend/ # Shared Vite + React + Tailwind frontend +│ ├── package.json +│ ├── vite.config.ts # Proxies /api → localhost:8080 +│ ├── tsconfig.json +│ ├── index.html +│ └── src/ +│ ├── main.tsx +│ ├── App.tsx # Wizard flow + webhook panel +│ ├── components/ +│ │ ├── StepWizard.tsx # Step container with progress indicator +│ │ ├── JsonEditor.tsx # Editable JSON textarea +│ │ ├── ResponsePanel.tsx # Shows API response JSON +│ │ └── WebhookStream.tsx # SSE-connected live webhook feed +│ ├── steps/ +│ │ ├── CreateCustomer.tsx +│ │ ├── CreateExternalAccount.tsx +│ │ ├── CreateQuote.tsx +│ │ ├── ExecuteQuote.tsx +│ │ └── SandboxFund.tsx +│ └── lib/ +│ └── api.ts # fetch wrappers for /api/* endpoints +│ +├── kotlin/ # Kotlin backend sample +│ ├── README.md +│ ├── .env.example +│ ├── build.gradle.kts +│ ├── settings.gradle.kts +│ ├── gradle.properties +│ ├── gradlew / gradlew.bat +│ └── src/main/kotlin/com/grid/sample/ +│ ├── Application.kt +│ ├── Config.kt +│ ├── GridClientBuilder.kt +│ ├── Routing.kt +│ ├── WebhookStream.kt +│ ├── JsonUtils.kt +│ └── routes/ +│ ├── Customers.kt +│ ├── ExternalAccounts.kt +│ ├── Quotes.kt +│ ├── Sandbox.kt +│ ├── Webhooks.kt +│ └── Sse.kt +│ +└── README.md +``` + +## API Contract + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/api/customers` | Create an individual customer | +| `POST` | `/api/customers/{customerId}/external-accounts` | Create a USD external bank account | +| `POST` | `/api/quotes` | Create a quote (USDC internal → USD external) | +| `POST` | `/api/quotes/{quoteId}/execute` | Execute the quote to initiate payment | +| `POST` | `/api/sandbox/send-funds` | Simulate funding for the quote | +| `POST` | `/api/webhooks` | Receives webhooks from Grid (not called by frontend) | +| `GET` | `/api/sse` | SSE stream of webhook events to frontend | + +## Step-by-Step Flow + +### Step 1 — Create Customer + +```json +// POST /api/customers +{ + "customerType": "INDIVIDUAL", + "platformCustomerId": "sample-customer-001" +} +// Response includes `id` → used in Step 2 +``` + +### Step 2 — Create External Account + +```json +// POST /api/customers/{customerId}/external-accounts +{ + "currency": "USD", + "accountInfo": { + "accountType": "CHECKING", + "routingNumber": "021000021", + "accountNumber": "123456789" + } +} +// Response includes `id` → used in Step 3 +``` + +### Step 3 — Create Quote + +```json +// POST /api/quotes +{ + "source": { "internalAccountId": "" }, + "destination": { "externalAccountId": "" }, + "lockedCurrencyAmount": 1000, + "lockedCurrencySide": "SENDING" +} +// Response includes `quoteId` → used in Steps 4 and 5 +``` + +### Step 4 — Execute Quote + +```json +// POST /api/quotes/{quoteId}/execute +// No body needed +// Response: updated quote with status change +``` + +### Step 5 — Sandbox Fund + +```json +// POST /api/sandbox/send-funds +{ + "quoteId": "" +} +// Response: sandbox funding confirmation +``` + +## Frontend Design + +### Layout + +Two-panel layout: + +- **Left (60%):** Step wizard with vertical stepper. Active step shows editable JSON textarea and submit button. Response panel below. Completed steps collapse to summary. Future steps grayed out. +- **Right (40%):** Webhook stream panel. SSE connection on page load with auto-reconnect. Newest events at top. Each shows timestamp, event type badge, expandable raw JSON. + +### Tech Stack + +React 18, TypeScript, Vite 5, Tailwind CSS 4. No component libraries. + +### Data Flow Between Steps + +The frontend auto-populates IDs from previous responses into the next step's JSON template. Users can edit any value before submitting. + +## Backend Design (Kotlin) + +### Server + +Ktor 3.x with Netty engine. CORS enabled for all origins. SSE plugin installed. + +### Request Handling Pattern + +Each route handler: +1. Receives raw JSON string from request body +2. Parses with Jackson into `JsonNode` +3. Builds Grid SDK params using builder pattern +4. Calls Grid SDK +5. Returns SDK response as JSON + +### Key Components + +- **`Config.kt`** — Loads `GRID_API_TOKEN_ID`, `GRID_API_CLIENT_SECRET`, `GRID_WEBHOOK_PUBLIC_KEY` from `.env` or system env vars via dotenv-kotlin +- **`GridClientBuilder.kt`** — Lazy singleton `GridOkHttpClient` +- **`WebhookStream.kt`** — `MutableSharedFlow(replay = 10)` for broadcasting webhook events +- **`Webhooks.kt`** — Verifies P-256 ECDSA signature via `X-Grid-Signature` header, broadcasts to `WebhookStream` +- **`Sse.kt`** — Collects from `WebhookStream.eventFlow`, sends as `ServerSentEvent`. Heartbeat endpoint for keep-alive. + +### Dependencies + +- Grid Kotlin SDK (published Maven artifact from `com.grid:grid-kotlin`) +- Ktor 3.x (server-core, server-netty, server-cors, server-sse, server-content-negotiation) +- Jackson (kotlin module) +- dotenv-kotlin +- Logback + +### Error Handling + +Minimal. SDK exceptions caught and returned as JSON with appropriate HTTP status. + +## README Structure + +### `samples/README.md` + +Overview of the sample apps, directory structure, links to sub-READMEs. + +### `samples/kotlin/README.md` + +1. Overview of what the sample demonstrates +2. Prerequisites: Java 21+, Node.js 18+, Grid API sandbox credentials +3. Setup: copy `.env.example`, fill in credentials +4. Running: two terminals (backend `./gradlew run` on :8080, frontend `npm run dev` on :5173) +5. Webhook setup: ngrok for local dev, configure webhook URL in Grid dashboard +6. Walkthrough of each wizard step + +### `samples/frontend/README.md` + +How to run, how to configure proxy target for different backends. From e1e998d7017db077f7f02ce7e547113840b4ffc2 Mon Sep 17 00:00:00 2001 From: Peng Ying Date: Thu, 12 Feb 2026 14:18:28 -0800 Subject: [PATCH 2/8] docs: add implementation plan for Grid API sample app 12-task plan covering Kotlin backend scaffold, routes, frontend scaffold, components, wizard assembly, and READMEs. Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-02-12-grid-sample-app-plan.md | 2029 +++++++++++++++++ 1 file changed, 2029 insertions(+) create mode 100644 docs/plans/2026-02-12-grid-sample-app-plan.md diff --git a/docs/plans/2026-02-12-grid-sample-app-plan.md b/docs/plans/2026-02-12-grid-sample-app-plan.md new file mode 100644 index 0000000..ef4b098 --- /dev/null +++ b/docs/plans/2026-02-12-grid-sample-app-plan.md @@ -0,0 +1,2029 @@ +# Grid API Sample Application Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build a sample app (Kotlin backend + shared React frontend) demonstrating the Grid API payout flow: create customer → external account → quote → execute → sandbox fund, with live webhook streaming. + +**Architecture:** Thin backend proxy pattern. Ktor server translates JSON requests to Grid SDK builder calls. React frontend runs a step-by-step wizard, passing IDs between steps. Webhooks stream via SSE from backend to frontend. + +**Tech Stack:** Kotlin 2.1 + Ktor 3.x + Grid Kotlin SDK (backend), React 18 + TypeScript + Vite 5 + Tailwind CSS 4 (frontend) + +**Design doc:** `docs/plans/2026-02-12-grid-sample-app-design.md` + +--- + +### Task 1: Kotlin Backend — Gradle Project Scaffold + +**Files:** +- Create: `samples/kotlin/build.gradle.kts` +- Create: `samples/kotlin/settings.gradle.kts` +- Create: `samples/kotlin/gradle.properties` +- Create: `samples/kotlin/.env.example` +- Create: `samples/kotlin/src/main/resources/application.yaml` +- Create: `samples/kotlin/src/main/resources/logback.xml` + +**Step 1: Initialize Gradle wrapper** + +```bash +cd samples/kotlin +gradle wrapper --gradle-version 8.12 +``` + +If `gradle` is not installed locally, copy wrapper files from `/Users/pengying/Src/grid-api/sdks/grid-kotlin/`: + +```bash +mkdir -p samples/kotlin +cp -r sdks/grid-kotlin/gradle samples/kotlin/gradle +cp sdks/grid-kotlin/gradlew samples/kotlin/gradlew +cp sdks/grid-kotlin/gradlew.bat samples/kotlin/gradlew.bat +``` + +**Step 2: Create settings.gradle.kts** + +```kotlin +rootProject.name = "grid-sample" +``` + +**Step 3: Create gradle.properties** + +```properties +kotlin.code.style=official +org.gradle.jvmargs=-Xmx1024m +``` + +**Step 4: Create build.gradle.kts** + +```kotlin +plugins { + kotlin("jvm") version "2.1.21" + kotlin("plugin.serialization") version "2.1.21" + id("io.ktor.plugin") version "3.1.3" +} + +kotlin { + jvmToolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +group = "com.grid.sample" +version = "0.0.1" + +application { + mainClass = "io.ktor.server.netty.EngineMain" + applicationDefaultJvmArgs = listOf("-Dio.ktor.development=true") +} + +repositories { + mavenCentral() +} + +dependencies { + // Ktor server + implementation("io.ktor:ktor-server-core:3.1.3") + implementation("io.ktor:ktor-server-netty:3.1.3") + implementation("io.ktor:ktor-server-cors:3.1.3") + implementation("io.ktor:ktor-server-sse:3.1.3") + implementation("io.ktor:ktor-server-content-negotiation:3.1.3") + implementation("io.ktor:ktor-server-config-yaml:3.1.3") + + // Grid Kotlin SDK + implementation("com.lightspark.grid:lightspark-grid-kotlin:0.4.0") + + // JSON + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.18.2") + + // Environment + implementation("io.github.cdimascio:dotenv-kotlin:6.4.1") + + // Logging + implementation("ch.qos.logback:logback-classic:1.5.6") +} +``` + +**Step 5: Create .env.example** + +```bash +# Grid API Credentials (from https://app.lightspark.com) +GRID_API_TOKEN_ID=your_api_token_id +GRID_API_CLIENT_SECRET=your_api_client_secret + +# Webhook verification (P-256 public key, PEM format) +GRID_WEBHOOK_PUBLIC_KEY=your_webhook_public_key +``` + +**Step 6: Create application.yaml** + +```yaml +ktor: + application: + modules: + - com.grid.sample.ApplicationKt.module + deployment: + port: 8080 +``` + +**Step 7: Create logback.xml** + +```xml + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + +``` + +**Step 8: Verify compilation** + +```bash +cd samples/kotlin && ./gradlew build +``` + +Expected: BUILD SUCCESSFUL (may have warnings about no source files yet, that's fine) + +**Step 9: Commit** + +```bash +git add samples/kotlin/ +git commit -m "feat(samples): scaffold Kotlin backend Gradle project" +``` + +--- + +### Task 2: Kotlin Backend — Core Infrastructure + +**Files:** +- Create: `samples/kotlin/src/main/kotlin/com/grid/sample/Application.kt` +- Create: `samples/kotlin/src/main/kotlin/com/grid/sample/Config.kt` +- Create: `samples/kotlin/src/main/kotlin/com/grid/sample/GridClientBuilder.kt` +- Create: `samples/kotlin/src/main/kotlin/com/grid/sample/WebhookStream.kt` +- Create: `samples/kotlin/src/main/kotlin/com/grid/sample/JsonUtils.kt` +- Create: `samples/kotlin/src/main/kotlin/com/grid/sample/Routing.kt` + +**Step 1: Create Config.kt** + +```kotlin +package com.grid.sample + +import io.github.cdimascio.dotenv.dotenv + +object Config { + private val dotenv = dotenv { + directory = "./" + ignoreIfMalformed = true + ignoreIfMissing = true + } + + val apiTokenId: String = getEnvVar("GRID_API_TOKEN_ID") + val apiClientSecret: String = getEnvVar("GRID_API_CLIENT_SECRET") + val webhookPublicKey: String = getEnvVar("GRID_WEBHOOK_PUBLIC_KEY").replace("\\n", "\n") + + private fun getEnvVar(key: String): String = + System.getProperty(key) + ?: dotenv[key] + ?: System.getenv(key) + ?: throw IllegalStateException("$key environment variable not set") +} +``` + +**Step 2: Create GridClientBuilder.kt** + +```kotlin +package com.grid.sample + +import com.grid.api.client.GridClient +import com.grid.api.client.okhttp.GridOkHttpClient + +object GridClientBuilder { + val client: GridClient by lazy { + GridOkHttpClient.builder() + .username(Config.apiTokenId) + .password(Config.apiClientSecret) + .build() + } +} +``` + +**Step 3: Create WebhookStream.kt** + +```kotlin +package com.grid.sample + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +object WebhookStream { + private val _eventFlow = MutableSharedFlow(replay = 10) + val eventFlow: SharedFlow = _eventFlow.asSharedFlow() + + fun addEvent(event: String) { + println("Broadcasting webhook: $event") + _eventFlow.tryEmit(event) + } +} +``` + +**Step 4: Create JsonUtils.kt** + +```kotlin +package com.grid.sample + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper + +object JsonUtils { + val mapper: ObjectMapper = jacksonObjectMapper().apply { + enable(SerializationFeature.INDENT_OUTPUT) + } + + fun prettyPrint(obj: Any): String = + try { + mapper.writeValueAsString(obj) + } catch (e: Exception) { + """{"error": "Failed to serialize response: ${e.message}"}""" + } +} +``` + +**Step 5: Create Routing.kt** + +Minimal routing that just installs CORS and SSE — route modules added in later tasks. + +```kotlin +package com.grid.sample + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.plugins.cors.routing.* +import io.ktor.server.routing.* +import io.ktor.server.sse.* + +fun Application.module() { + install(CORS) { + allowMethod(HttpMethod.Options) + allowMethod(HttpMethod.Get) + allowMethod(HttpMethod.Post) + allowHeader(HttpHeaders.ContentType) + allowHeader(HttpHeaders.Authorization) + allowCredentials = true + anyHost() + } + install(SSE) + routing { + // Route modules will be added here + } +} +``` + +**Step 6: Create Application.kt** + +```kotlin +package com.grid.sample + +import io.ktor.server.application.* + +fun main(args: Array) { + io.ktor.server.netty.EngineMain.main(args) +} +``` + +**Step 7: Verify compilation** + +```bash +cd samples/kotlin && ./gradlew build +``` + +Expected: BUILD SUCCESSFUL + +**Step 8: Commit** + +```bash +git add samples/kotlin/src/ +git commit -m "feat(samples): add Kotlin backend core infrastructure" +``` + +--- + +### Task 3: Kotlin Backend — Customer Route + +**Files:** +- Create: `samples/kotlin/src/main/kotlin/com/grid/sample/routes/Customers.kt` +- Modify: `samples/kotlin/src/main/kotlin/com/grid/sample/Routing.kt` + +**Step 1: Create Customers.kt** + +```kotlin +package com.grid.sample.routes + +import com.fasterxml.jackson.databind.JsonNode +import com.grid.api.models.customers.CustomerCreateParams +import com.grid.api.models.customers.CustomerCreateParams.CreateCustomerRequest +import com.grid.api.models.customers.CustomerType +import com.grid.sample.GridClientBuilder +import com.grid.sample.JsonUtils +import io.ktor.http.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* + +fun Route.customerRoutes() { + route("/api/customers") { + post { + try { + val body = call.receiveText() + val json = JsonUtils.mapper.readTree(body) + + val individualRequest = CreateCustomerRequest + .IndividualCustomerCreateRequest.builder() + .customerType(CustomerType.INDIVIDUAL) + .apply { + json.optText("platformCustomerId")?.let { platformCustomerId(it) } + json.optText("fullName")?.let { fullName(it) } + json.optText("nationality")?.let { nationality(it) } + } + .build() + + val params = CustomerCreateParams.builder() + .createCustomerRequest( + CreateCustomerRequest.ofIndividualCustomerCreate(individualRequest) + ) + .build() + + val customer = GridClientBuilder.client.customers().create(params) + call.respondText( + JsonUtils.prettyPrint(customer), + ContentType.Application.Json, + HttpStatusCode.Created + ) + } catch (e: Exception) { + call.respondText( + """{"error": "${e.message}"}""", + ContentType.Application.Json, + HttpStatusCode.InternalServerError + ) + } + } + } +} + +private fun JsonNode.optText(field: String): String? = + if (has(field) && !get(field).isNull) get(field).asText() else null +``` + +**Step 2: Register route in Routing.kt** + +Add inside the `routing { }` block: + +```kotlin +import com.grid.sample.routes.customerRoutes + +// Inside routing { }: +customerRoutes() +``` + +**Step 3: Verify compilation** + +```bash +cd samples/kotlin && ./gradlew build +``` + +Expected: BUILD SUCCESSFUL + +**Step 4: Commit** + +```bash +git add samples/kotlin/src/ +git commit -m "feat(samples): add customer creation route" +``` + +--- + +### Task 4: Kotlin Backend — External Account Route + +**Files:** +- Create: `samples/kotlin/src/main/kotlin/com/grid/sample/routes/ExternalAccounts.kt` +- Modify: `samples/kotlin/src/main/kotlin/com/grid/sample/Routing.kt` + +**Step 1: Create ExternalAccounts.kt** + +```kotlin +package com.grid.sample.routes + +import com.fasterxml.jackson.databind.JsonNode +import com.grid.api.models.customers.externalaccounts.ExternalAccountCreate +import com.grid.api.models.customers.externalaccounts.ExternalAccountCreateParams +import com.grid.api.models.customers.externalaccounts.ExternalAccountInfoOneOf +import com.grid.api.models.customers.externalaccounts.IndividualBeneficiary +import com.grid.sample.GridClientBuilder +import com.grid.sample.JsonUtils +import io.ktor.http.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* + +fun Route.externalAccountRoutes() { + route("/api/customers/{customerId}/external-accounts") { + post { + try { + val customerId = call.parameters["customerId"] + ?: return@post call.respondText( + """{"error": "customerId is required"}""", + ContentType.Application.Json, + HttpStatusCode.BadRequest + ) + + val body = call.receiveText() + val json = JsonUtils.mapper.readTree(body) + val accountInfo = json.get("accountInfo") + + val usAccountInfo = ExternalAccountInfoOneOf + .UsAccountExternalAccountInfo.builder() + .accountNumber(accountInfo.get("accountNumber").asText()) + .routingNumber(accountInfo.get("routingNumber").asText()) + .accountType(accountInfo.optText("accountType") ?: "CHECKING") + .apply { + val beneficiaryNode = json.get("beneficiary") + if (beneficiaryNode != null && !beneficiaryNode.isNull) { + beneficiary( + IndividualBeneficiary.builder() + .firstName(beneficiaryNode.optText("firstName") ?: "") + .lastName(beneficiaryNode.optText("lastName") ?: "") + .build() + ) + } + } + .build() + + val externalAccountCreate = ExternalAccountCreate.builder() + .accountInfo( + ExternalAccountInfoOneOf.ofUsAccountExternalAccountInfo(usAccountInfo) + ) + .currency(json.optText("currency") ?: "USD") + .customerId(customerId) + .apply { + json.optText("platformAccountId")?.let { platformAccountId(it) } + } + .build() + + val params = ExternalAccountCreateParams.builder() + .externalAccountCreate(externalAccountCreate) + .build() + + val account = GridClientBuilder.client.customers().externalAccounts().create(params) + call.respondText( + JsonUtils.prettyPrint(account), + ContentType.Application.Json, + HttpStatusCode.Created + ) + } catch (e: Exception) { + call.respondText( + """{"error": "${e.message}"}""", + ContentType.Application.Json, + HttpStatusCode.InternalServerError + ) + } + } + } +} + +private fun JsonNode.optText(field: String): String? = + if (has(field) && !get(field).isNull) get(field).asText() else null +``` + +**Step 2: Register route in Routing.kt** + +```kotlin +import com.grid.sample.routes.externalAccountRoutes + +// Inside routing { }: +externalAccountRoutes() +``` + +**Step 3: Verify compilation** + +```bash +cd samples/kotlin && ./gradlew build +``` + +**Step 4: Commit** + +```bash +git add samples/kotlin/src/ +git commit -m "feat(samples): add external account creation route" +``` + +--- + +### Task 5: Kotlin Backend — Quote Routes (Create + Execute) + +**Files:** +- Create: `samples/kotlin/src/main/kotlin/com/grid/sample/routes/Quotes.kt` +- Modify: `samples/kotlin/src/main/kotlin/com/grid/sample/Routing.kt` + +**Step 1: Create Quotes.kt** + +```kotlin +package com.grid.sample.routes + +import com.fasterxml.jackson.databind.JsonNode +import com.grid.api.models.quotes.BaseDestination +import com.grid.api.models.quotes.BaseQuoteSource +import com.grid.api.models.quotes.QuoteCreateParams +import com.grid.api.models.quotes.QuoteSourceOneOf +import com.grid.api.models.quotes.QuoteDestinationOneOf +import com.grid.sample.GridClientBuilder +import com.grid.sample.JsonUtils +import io.ktor.http.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* + +fun Route.quoteRoutes() { + route("/api/quotes") { + post { + try { + val body = call.receiveText() + val json = JsonUtils.mapper.readTree(body) + + val sourceNode = json.get("source") + val source = buildQuoteSource(sourceNode) + + val destNode = json.get("destination") + val destination = buildQuoteDestination(destNode) + + val params = QuoteCreateParams.builder() + .source(source) + .destination(destination) + .lockedCurrencyAmount(json.get("lockedCurrencyAmount").asLong()) + .lockedCurrencySide( + when (json.optText("lockedCurrencySide")?.uppercase()) { + "RECEIVING" -> QuoteCreateParams.LockedCurrencySide.RECEIVING + else -> QuoteCreateParams.LockedCurrencySide.SENDING + } + ) + .apply { + json.optText("description")?.let { description(it) } + } + .build() + + val quote = GridClientBuilder.client.quotes().create(params) + call.respondText( + JsonUtils.prettyPrint(quote), + ContentType.Application.Json, + HttpStatusCode.Created + ) + } catch (e: Exception) { + call.respondText( + """{"error": "${e.message}"}""", + ContentType.Application.Json, + HttpStatusCode.InternalServerError + ) + } + } + + post("/{quoteId}/execute") { + try { + val quoteId = call.parameters["quoteId"] + ?: return@post call.respondText( + """{"error": "quoteId is required"}""", + ContentType.Application.Json, + HttpStatusCode.BadRequest + ) + + val quote = GridClientBuilder.client.quotes().execute(quoteId) + call.respondText( + JsonUtils.prettyPrint(quote), + ContentType.Application.Json, + HttpStatusCode.OK + ) + } catch (e: Exception) { + call.respondText( + """{"error": "${e.message}"}""", + ContentType.Application.Json, + HttpStatusCode.InternalServerError + ) + } + } + } +} + +private fun buildQuoteSource(sourceNode: JsonNode): QuoteSourceOneOf { + val sourceType = sourceNode.optText("sourceType") + + if (sourceType == "REALTIME_FUNDING" || sourceNode.has("currency")) { + return QuoteSourceOneOf.ofRealtimeFundingQuoteSource( + QuoteSourceOneOf.RealtimeFundingQuoteSource.builder() + .sourceType(BaseQuoteSource.SourceType.REALTIME_FUNDING) + .currency(sourceNode.get("currency").asText()) + .apply { + sourceNode.optText("customerId")?.let { customerId(it) } + } + .build() + ) + } + + return QuoteSourceOneOf.ofAccountQuoteSource( + QuoteSourceOneOf.AccountQuoteSource.builder() + .sourceType(BaseQuoteSource.SourceType.ACCOUNT) + .accountId(sourceNode.get("accountId").asText()) + .apply { + sourceNode.optText("customerId")?.let { customerId(it) } + } + .build() + ) +} + +private fun buildQuoteDestination(destNode: JsonNode): QuoteDestinationOneOf { + if (destNode.has("umaAddress")) { + return QuoteDestinationOneOf.ofUmaAddressDestination( + QuoteDestinationOneOf.UmaAddressDestination.builder() + .destinationType(BaseDestination.DestinationType.UMA_ADDRESS) + .umaAddress(destNode.get("umaAddress").asText()) + .build() + ) + } + + return QuoteDestinationOneOf.ofAccountDestination( + QuoteDestinationOneOf.AccountDestination.builder() + .destinationType(BaseDestination.DestinationType.ACCOUNT) + .accountId(destNode.get("accountId").asText()) + .build() + ) +} + +private fun JsonNode.optText(field: String): String? = + if (has(field) && !get(field).isNull) get(field).asText() else null +``` + +**Step 2: Register route in Routing.kt** + +```kotlin +import com.grid.sample.routes.quoteRoutes + +// Inside routing { }: +quoteRoutes() +``` + +**Step 3: Verify compilation** + +```bash +cd samples/kotlin && ./gradlew build +``` + +**Step 4: Commit** + +```bash +git add samples/kotlin/src/ +git commit -m "feat(samples): add quote creation and execution routes" +``` + +--- + +### Task 6: Kotlin Backend — Sandbox + Webhooks + SSE Routes + +**Files:** +- Create: `samples/kotlin/src/main/kotlin/com/grid/sample/routes/Sandbox.kt` +- Create: `samples/kotlin/src/main/kotlin/com/grid/sample/routes/Webhooks.kt` +- Create: `samples/kotlin/src/main/kotlin/com/grid/sample/routes/Sse.kt` +- Modify: `samples/kotlin/src/main/kotlin/com/grid/sample/Routing.kt` + +**Step 1: Create Sandbox.kt** + +```kotlin +package com.grid.sample.routes + +import com.grid.api.models.sandbox.SandboxSendFundsParams +import com.grid.sample.GridClientBuilder +import com.grid.sample.JsonUtils +import io.ktor.http.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* + +fun Route.sandboxRoutes() { + route("/api/sandbox") { + post("/send-funds") { + try { + val body = call.receiveText() + val json = JsonUtils.mapper.readTree(body) + + val params = SandboxSendFundsParams.builder() + .quoteId(json.get("quoteId").asText()) + .currencyCode(json.optText("currencyCode") ?: "USD") + .apply { + if (json.has("currencyAmount") && !json.get("currencyAmount").isNull) { + currencyAmount(json.get("currencyAmount").asLong()) + } + } + .build() + + val response = GridClientBuilder.client.sandbox().sendFunds(params) + call.respondText( + JsonUtils.prettyPrint(response), + ContentType.Application.Json, + HttpStatusCode.OK + ) + } catch (e: Exception) { + call.respondText( + """{"error": "${e.message}"}""", + ContentType.Application.Json, + HttpStatusCode.InternalServerError + ) + } + } + } +} + +private fun com.fasterxml.jackson.databind.JsonNode.optText(field: String): String? = + if (has(field) && !get(field).isNull) get(field).asText() else null +``` + +**Step 2: Create Webhooks.kt** + +```kotlin +package com.grid.sample.routes + +import com.grid.sample.Config +import com.grid.sample.WebhookStream +import io.ktor.http.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import java.security.KeyFactory +import java.security.Signature +import java.security.spec.X509EncodedKeySpec +import java.util.Base64 + +fun Route.webhookRoutes() { + route("/api/webhooks") { + post { + val rawBody = call.receiveText() + val signatureHeader = call.request.headers["X-Grid-Signature"] + + if (signatureHeader != null) { + val isValid = verifyWebhookSignature(rawBody, signatureHeader) + if (!isValid) { + call.respondText( + """{"error": "Invalid webhook signature"}""", + ContentType.Application.Json, + HttpStatusCode.Unauthorized + ) + return@post + } + } + + WebhookStream.addEvent(rawBody) + call.respond(HttpStatusCode.OK) + } + } +} + +private fun verifyWebhookSignature(body: String, signature: String): Boolean { + return try { + val publicKeyPem = Config.webhookPublicKey + .replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replace("\\s".toRegex(), "") + + val keyBytes = Base64.getDecoder().decode(publicKeyPem) + val keySpec = X509EncodedKeySpec(keyBytes) + val keyFactory = KeyFactory.getInstance("EC") + val publicKey = keyFactory.generatePublic(keySpec) + + val sig = Signature.getInstance("SHA256withECDSA") + sig.initVerify(publicKey) + sig.update(body.toByteArray()) + + val decodedSignature = Base64.getDecoder().decode(signature) + sig.verify(decodedSignature) + } catch (e: Exception) { + println("Webhook signature verification failed: ${e.message}") + false + } +} +``` + +**Step 3: Create Sse.kt** + +```kotlin +package com.grid.sample.routes + +import com.grid.sample.WebhookStream +import io.ktor.server.routing.* +import io.ktor.server.sse.* +import io.ktor.sse.* +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlin.time.Duration.Companion.seconds + +fun Route.sseRoutes() { + sse("/api/sse") { + val connected = """{"type":"connected","timestamp":${System.currentTimeMillis()}}""" + send(ServerSentEvent(connected)) + + WebhookStream.eventFlow + .onEach { event -> send(ServerSentEvent(event)) } + .catch { e -> println("SSE stream error: ${e.message}") } + .launchIn(this) + } + + sse("/api/sse/heartbeat") { + heartbeat { + period = 30.seconds + event = ServerSentEvent("heartbeat") + } + } +} +``` + +**Step 4: Update Routing.kt with all routes** + +Replace the full `Routing.kt` with: + +```kotlin +package com.grid.sample + +import com.grid.sample.routes.* +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.plugins.cors.routing.* +import io.ktor.server.routing.* +import io.ktor.server.sse.* + +fun Application.module() { + install(CORS) { + allowMethod(HttpMethod.Options) + allowMethod(HttpMethod.Get) + allowMethod(HttpMethod.Post) + allowHeader(HttpHeaders.ContentType) + allowHeader(HttpHeaders.Authorization) + allowCredentials = true + anyHost() + } + install(SSE) + routing { + customerRoutes() + externalAccountRoutes() + quoteRoutes() + sandboxRoutes() + webhookRoutes() + sseRoutes() + } +} +``` + +**Step 5: Verify compilation** + +```bash +cd samples/kotlin && ./gradlew build +``` + +Expected: BUILD SUCCESSFUL + +**Step 6: Commit** + +```bash +git add samples/kotlin/src/ +git commit -m "feat(samples): add sandbox, webhook, and SSE routes" +``` + +--- + +### Task 7: Frontend — Vite + React + Tailwind Scaffold + +**Files:** +- Create: `samples/frontend/package.json` +- Create: `samples/frontend/vite.config.ts` +- Create: `samples/frontend/tsconfig.json` +- Create: `samples/frontend/tsconfig.node.json` +- Create: `samples/frontend/index.html` +- Create: `samples/frontend/src/main.tsx` +- Create: `samples/frontend/src/index.css` + +**Step 1: Create package.json** + +```json +{ + "name": "grid-sample-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.10", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "tailwindcss": "^4.1.10", + "typescript": "^5.6.3", + "vite": "^6.0.0" + } +} +``` + +**Step 2: Create vite.config.ts** + +```typescript +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + secure: false, + } + } + } +}) +``` + +**Step 3: Create tsconfig.json** + +```json +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} +``` + +**Step 4: Create tsconfig.node.json** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} +``` + +**Step 5: Create index.html** + +```html + + + + + + Grid API Sample + + +
+ + + +``` + +**Step 6: Create src/index.css** + +```css +@import "tailwindcss"; +``` + +**Step 7: Create src/main.tsx** + +```tsx +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) +``` + +**Step 8: Create placeholder src/App.tsx** + +```tsx +export default function App() { + return ( +
+

Grid API Sample

+

Frontend scaffold — components coming next.

+
+ ) +} +``` + +**Step 9: Install dependencies and verify** + +```bash +cd samples/frontend && npm install && npm run build +``` + +Expected: Build succeeds, output in `dist/` + +**Step 10: Commit** + +```bash +git add samples/frontend/ +git commit -m "feat(samples): scaffold Vite + React + Tailwind frontend" +``` + +--- + +### Task 8: Frontend — API Client + Shared Components + +**Files:** +- Create: `samples/frontend/src/lib/api.ts` +- Create: `samples/frontend/src/components/JsonEditor.tsx` +- Create: `samples/frontend/src/components/ResponsePanel.tsx` + +**Step 1: Create api.ts** + +```typescript +export async function apiPost(path: string, body?: unknown): Promise { + const res = await fetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined, + }) + const text = await res.text() + let data: T + try { + data = JSON.parse(text) + } catch { + throw new Error(text) + } + if (!res.ok) { + throw new Error((data as Record).error ?? text) + } + return data +} +``` + +**Step 2: Create JsonEditor.tsx** + +```tsx +import { useState, useEffect } from 'react' + +interface JsonEditorProps { + value: string + onChange: (value: string) => void + disabled?: boolean +} + +export default function JsonEditor({ value, onChange, disabled }: JsonEditorProps) { + const [error, setError] = useState(null) + + useEffect(() => { + try { + JSON.parse(value) + setError(null) + } catch (e) { + setError((e as Error).message) + } + }, [value]) + + return ( +
+