+
{stream.status === "Paused"
? "Withdrawals resume once the stream is active again."
: stream.status === "Completed"
diff --git a/frontend/src/components/ui/LiveValue.tsx b/frontend/src/components/ui/LiveValue.tsx
new file mode 100644
index 00000000..e4342868
--- /dev/null
+++ b/frontend/src/components/ui/LiveValue.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+
+interface LiveValueProps {
+ /**
+ * Text to announce. Typically the same formatted value the visible ticker
+ * renders (e.g. "1,234.5 USDC").
+ */
+ value: string;
+ /** Minimum interval in ms between announcements while the value is ticking. */
+ cadenceMs?: number;
+ /** Optional label prepended to the announcement, e.g. "Claimable amount". */
+ prefix?: string;
+}
+
+/**
+ * #1198 — Screen-reader live region for values that tick continuously.
+ *
+ * Continuously mutating text inside an `aria-live` region (or worse, on every
+ * `requestAnimationFrame`) floods assistive technology with announcements. This
+ * component keeps the region visually hidden and syncs its text from the latest
+ * rendered value at most once every `cadenceMs`, announcing on user query
+ * rather than per frame.
+ *
+ * Note: the visible ticking counter is left as normal document text so users
+ * navigating with a virtual cursor read the current value directly; this live
+ * region supplements it with throttled announcements.
+ */
+export function LiveValue({ value, cadenceMs = 1000, prefix }: LiveValueProps) {
+ const valueRef = useRef(value);
+ const [text, setText] = useState(() => value);
+
+ useEffect(() => {
+ valueRef.current = value;
+ }, [value]);
+
+ useEffect(() => {
+ const id = window.setInterval(() => {
+ const latest = valueRef.current;
+ setText((prev) => (prev === latest ? prev : latest));
+ }, cadenceMs);
+ return () => window.clearInterval(id);
+ }, [cadenceMs]);
+
+ if (!text) return null;
+
+ return (
+
+ {prefix ? `${prefix} ${text}` : text}
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/hooks/useStreamingAmount.ts b/frontend/src/hooks/useStreamingAmount.ts
index 10a5ca0f..bd69dc45 100644
--- a/frontend/src/hooks/useStreamingAmount.ts
+++ b/frontend/src/hooks/useStreamingAmount.ts
@@ -44,6 +44,10 @@ export function useStreamingAmount({
!isPaused &&
ratePerSecond > 0 &&
maxClaimable > 0;
+ const reduceMotion =
+ typeof window !== "undefined" &&
+ typeof window.matchMedia === "function" &&
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let lastFrameTime = performance.now();
const computeClaimable = () => {
@@ -97,7 +101,12 @@ export function useStreamingAmount({
}
};
- if (isStreaming) {
+ if (reduceMotion && isStreaming) {
+ // #1198 — Respect prefers-reduced-motion: compute the accrued value once
+ // and skip the per-frame animation loop entirely.
+ claimableRef.current = computeClaimable();
+ setClaimable(claimableRef.current);
+ } else if (isStreaming) {
rafId = requestAnimationFrame(tick);
}
From 0554f706c2e98ed1d2bd7cad437916956a70a3f7 Mon Sep 17 00:00:00 2001
From: dimka90
Date: Mon, 31 Aug 2026 00:10:08 +0100
Subject: [PATCH 2/3] feat(frontend): Playwright E2E suite for wallet, create,
and stream lifecycle (#1199)
Adds a Playwright E2E harness for the FlowFi frontend:
- Global setup generates self-signed TLS certs for the local Soroban RPC mock.
- A combined mock server (REST + SSE + Soroban RPC over HTTPS) supports the
wallet connection, stream creation, and stream detail endpoints.
- A window-level Freighter postMessage mock plus a persisted non-mocked wallet
session enables full create/withdraw flows against the mocked chain.
- Tests run against Turbopack dev (port 3101) with mocked API/RPC on 3100/3102
in chromium and firefox.
Also pins the Turbopack workspace root so stray package-lock files outside the
repo do not break the dev server.
---
frontend/.gitignore | 5 +
frontend/e2e/global-setup.ts | 25 ++
frontend/e2e/mocks/api-server.mjs | 394 +++++++++++++++++++++++++
frontend/e2e/stream-creation.spec.ts | 33 +++
frontend/e2e/stream-lifecycle.spec.ts | 40 +++
frontend/e2e/utils/freighter.ts | 121 ++++++++
frontend/e2e/wallet-connection.spec.ts | 26 ++
frontend/next.config.ts | 7 +
frontend/package.json | 3 +
frontend/playwright.config.ts | 48 +++
package-lock.json | 64 ++++
11 files changed, 766 insertions(+)
create mode 100644 frontend/e2e/global-setup.ts
create mode 100644 frontend/e2e/mocks/api-server.mjs
create mode 100644 frontend/e2e/stream-creation.spec.ts
create mode 100644 frontend/e2e/stream-lifecycle.spec.ts
create mode 100644 frontend/e2e/utils/freighter.ts
create mode 100644 frontend/e2e/wallet-connection.spec.ts
create mode 100644 frontend/playwright.config.ts
diff --git a/frontend/.gitignore b/frontend/.gitignore
index 49a317f1..66ba020b 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -44,3 +44,8 @@ next-env.d.ts
# manually-triggered API type codegen output (see src/lib/api-types.ts)
src/lib/api-types.generated.ts
+
+# playwright e2e artifacts
+test-results/
+playwright-report/
+e2e/.e2e-certs/
diff --git a/frontend/e2e/global-setup.ts b/frontend/e2e/global-setup.ts
new file mode 100644
index 00000000..c567d496
--- /dev/null
+++ b/frontend/e2e/global-setup.ts
@@ -0,0 +1,25 @@
+import { execSync } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+
+const CERT_DIR = path.join(__dirname, ".e2e-certs");
+const CERT_PATH = path.join(CERT_DIR, "cert.pem");
+const KEY_PATH = path.join(CERT_DIR, "key.pem");
+
+export default function globalSetup() {
+ if (fs.existsSync(CERT_PATH) && fs.existsSync(KEY_PATH)) {
+ return;
+ }
+ fs.mkdirSync(CERT_DIR, { recursive: true });
+ execSync(
+ [
+ "openssl req -x509 -newkey rsa:2048 -nodes",
+ `-keyout ${KEY_PATH}`,
+ `-out ${CERT_PATH}`,
+ "-days 3650",
+ '-subj "/CN=localhost"',
+ '-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"',
+ ].join(" "),
+ { stdio: "pipe" },
+ );
+}
\ No newline at end of file
diff --git a/frontend/e2e/mocks/api-server.mjs b/frontend/e2e/mocks/api-server.mjs
new file mode 100644
index 00000000..94a90774
--- /dev/null
+++ b/frontend/e2e/mocks/api-server.mjs
@@ -0,0 +1,394 @@
+import http from "node:http";
+import https from "node:https";
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { xdr, Keypair, TransactionBuilder, Networks } from "@stellar/stellar-sdk";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const CERT_DIR = path.join(__dirname, "..", ".e2e-certs");
+const CERT_PATH = path.join(CERT_DIR, "cert.pem");
+const KEY_PATH = path.join(CERT_DIR, "key.pem");
+
+const PORT = Number(process.env.MOCK_API_PORT || 3100);
+const RPC_PORT = Number(process.env.MOCK_RPC_PORT || 3102);
+const APP_ORIGIN = process.env.E2E_APP_ORIGIN || "http://localhost:3101";
+const SESSION_PUBLIC_KEY = "GB5P5GY25PGHPN4DG2XSQLWCUHTFUK2GDZ75IWB7KZV3RKBVH33GZ32U";
+
+const RECIPIENT_PUBLIC_KEY = "GDBX55OJUOXRSTWICUESBZAHSMJNFWZ57NEEPVN74BXKH7OGZV23RYCG";
+const USDC_ADDRESS = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
+
+const RATE_PER_SECOND = "10000000"; // 1 USDC / second (7 decimals)
+const DEPOSITED_AMOUNT = "100000000000"; // 10,000 USDC
+const WITHDRAW_BATCH = BigInt("100000000"); // 10 USDC per simulated withdrawal
+
+const accountSequence = ["1"];
+
+const nowSec = () => Math.floor(Date.now() / 1000);
+
+function createStream() {
+ return {
+ id: "42",
+ streamId: 42,
+ sender: SESSION_PUBLIC_KEY,
+ recipient: RECIPIENT_PUBLIC_KEY,
+ tokenAddress: USDC_ADDRESS,
+ ratePerSecond: RATE_PER_SECOND,
+ depositedAmount: DEPOSITED_AMOUNT,
+ withdrawnAmount: "0",
+ startTime: nowSec() - 15,
+ lastUpdateTime: nowSec() - 3,
+ endTime: null,
+ isActive: true,
+ isPaused: false,
+ status: "active",
+ pausedAt: null,
+ totalPausedDuration: 0,
+ createdAt: new Date(Date.now() - 3600000).toISOString(),
+ updatedAt: new Date().toISOString(),
+ };
+}
+
+let stream = createStream();
+const watchers = new Set();
+
+function broadcast(eventName, data) {
+ const payload = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
+ for (const res of watchers) {
+ try {
+ res.write(payload);
+ } catch {
+ watchers.delete(res);
+ }
+ }
+}
+
+function corsHeaders() {
+ return {
+ "Access-Control-Allow-Origin": APP_ORIGIN,
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
+ "Access-Control-Allow-Headers": "*",
+ };
+}
+
+const sendJson = (res, statusCode, body, extraHeaders = {}) => {
+ const headers = { "Content-Type": "application/json", ...corsHeaders(), ...extraHeaders };
+ res.writeHead(statusCode, headers);
+ res.end(JSON.stringify(body));
+};
+
+const readBody = (req) =>
+ new Promise((resolve, reject) => {
+ let raw = "";
+ req.on("data", (chunk) => (raw += chunk));
+ req.on("end", () => {
+ try {
+ resolve(raw ? JSON.parse(raw) : {});
+ } catch (err) {
+ reject(err);
+ }
+ });
+ req.on("error", reject);
+ });
+
+// ── XDR factories ────────────────────────────────────────────────────────────
+
+function accountEntryXdr(publicKey, seq) {
+ const kp = Keypair.fromPublicKey(publicKey);
+ const accountEntry = new xdr.AccountEntry({
+ accountId: kp.xdrAccountId(),
+ balance: xdr.Int64.fromString("0"),
+ seqNum: new xdr.SequenceNumber(xdr.Int64.fromString(String(seq))),
+ numSubEntries: 0,
+ flags: 0,
+ homeDomain: "",
+ thresholds: new Uint8Array(4),
+ signers: [],
+ ext: new xdr.AccountEntryExt(0),
+ });
+ return xdr.LedgerEntryData.account(accountEntry);
+}
+
+function ledgerKeyXdr(publicKey) {
+ const kp = Keypair.fromPublicKey(publicKey);
+ return xdr.LedgerKey.account(new xdr.LedgerKeyAccount({ accountId: kp.xdrPublicKey() }));
+}
+
+function sorobanTransactionDataBase64() {
+ const footprint = new xdr.LedgerFootprint({ readOnly: [], readWrite: [] });
+ const resources = new xdr.SorobanResources({
+ footprint,
+ instructions: 0,
+ diskReadBytes: 8,
+ writeBytes: 8,
+ });
+ const data = new xdr.SorobanTransactionData({
+ resources,
+ resourceFee: 0n,
+ ext: new xdr.SorobanTransactionDataExt(0),
+ });
+ return data.toXDR("base64");
+}
+
+function scValVoidBase64() {
+ return xdr.ScVal.scvVoid().toXDR("base64");
+}
+
+// ── REST / SSE handlers ──────────────────────────────────────────────────────
+
+function buildEvents() {
+ const events = [
+ {
+ id: "1",
+ streamId: 42,
+ eventType: "CREATED",
+ timestamp: nowSec() - 3600,
+ amount: DEPOSITED_AMOUNT,
+ },
+ ];
+ if (Number(stream.withdrawnAmount) > 0) {
+ events.push({
+ id: "2",
+ streamId: 42,
+ eventType: "WITHDRAWN",
+ timestamp: nowSec(),
+ amount: stream.withdrawnAmount,
+ });
+ }
+ return events;
+}
+
+function handleRest(req, res) {
+ if (req.method === "OPTIONS") {
+ res.writeHead(204, corsHeaders());
+ return res.end();
+ }
+
+ const url = new URL(req.url, `http://${req.headers.host}`);
+
+ if (req.method === "GET" && url.pathname === "/health") {
+ return sendJson(res, 200, { ok: true });
+ }
+
+ if (
+ req.method === "GET" &&
+ (url.pathname === "/v1/streams" || url.pathname === "/api/v1/streams")
+ ) {
+ return sendJson(res, 200, { data: [stream] });
+ }
+
+ const streamDetail = url.pathname.match(/^\/v1\/streams\/(\d+)$/);
+ if (req.method === "GET" && streamDetail) {
+ return sendJson(res, 200, stream);
+ }
+
+ const streamEvents = url.pathname.match(/^\/v1\/streams\/(\d+)\/events$/);
+ if (req.method === "GET" && streamEvents) {
+ const all = buildEvents();
+ return sendJson(res, 200, {
+ events: all,
+ total: all.length,
+ page: Number(url.searchParams.get("page") || 1),
+ limit: Number(url.searchParams.get("limit") || 20),
+ });
+ }
+
+ if (req.method === "GET" && url.pathname === "/v1/events/subscribe") {
+ res.writeHead(200, {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ Connection: "keep-alive",
+ "Access-Control-Allow-Origin": APP_ORIGIN,
+ });
+ res.write(`retry: 3000\n\n`);
+ watchers.add(res);
+ req.on("close", () => watchers.delete(res));
+ const heartbeat = setInterval(() => {
+ try {
+ res.write(": ping\n\n");
+ } catch {
+ clearInterval(heartbeat);
+ }
+ }, 15000);
+ res.on("close", () => clearInterval(heartbeat));
+ return;
+ }
+
+ const withdrawControl = url.pathname.match(/^\/__e2e\/stream\/(\d+)\/withdraw$/);
+ if (req.method === "POST" && withdrawControl) {
+ const current = Number(stream.withdrawnAmount) || 0;
+ stream = {
+ ...stream,
+ withdrawnAmount: (current + Number(WITHDRAW_BATCH)).toString(),
+ updatedAt: new Date().toISOString(),
+ lastUpdateTime: nowSec(),
+ };
+ broadcast("stream.withdrawn", { streamId: 42 });
+ return sendJson(res, 200, { ok: true, withdrawnAmount: stream.withdrawnAmount });
+ }
+
+ return sendJson(res, 404, { error: "not found" });
+}
+
+// ── Soroban RPC handlers ─────────────────────────────────────────────────────
+
+function rpcError(id, code, message) {
+ return JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } });
+}
+
+function rpcResult(id, result) {
+ return JSON.stringify({ jsonrpc: "2.0", id, result });
+}
+
+let submittedTransactionXdr = null;
+
+function successTxResultBase64() {
+ return new xdr.TransactionResult({
+ feeCharged: xdr.Int64.fromString("0"),
+ result: xdr.TransactionResultResult.txSuccess([]),
+ ext: new xdr.TransactionResultExt(0),
+ }).toXDR("base64");
+}
+
+function zeroTxMetaBase64() {
+ return new xdr.TransactionMeta(0, []).toXDR("base64");
+}
+
+function dummyEnvelopeBase64() {
+ return new TransactionBuilder(
+ Keypair.fromPublicKey(SESSION_PUBLIC_KEY),
+ { fee: "1", networkPassphrase: Networks.TESTNET },
+ )
+ .setTimeout(30)
+ .build()
+ .toXDR("base64");
+}
+
+async function handleRpc(req, res) {
+ res.setHeader("Content-Type", "application/json");
+ res.setHeader("Access-Control-Allow-Origin", APP_ORIGIN);
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
+ res.setHeader("Access-Control-Allow-Headers", "*");
+
+ if (req.method === "OPTIONS") {
+ res.writeHead(204);
+ return res.end();
+ }
+
+ if (req.method !== "POST") {
+ res.writeHead(405);
+ return res.end(rpcError(null, -32600, "method not allowed"));
+ }
+
+ let body;
+ try {
+ body = await readBody(req);
+ } catch {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ error: "invalid json" }));
+ }
+
+ const { id, method, params } = body;
+
+ try {
+ switch (method) {
+ case "getHealth":
+ return res.end(rpcResult(id, { status: "healthy" }));
+
+ case "getNetwork":
+ return res.end(
+ rpcResult(id, {
+ friendbotUrl: "https://friendbot-futurenet.stellar.org/",
+ passthroughUrls: {},
+ sorobanRpcUrl: "",
+ }),
+ );
+
+ case "getLatestLedger":
+ return res.end(
+ rpcResult(id, {
+ id: "0000000000000000000000000000000000000000000000000000000000000000",
+ protocolVersion: 22,
+ sequence: 1000,
+ }),
+ );
+
+ case "getLedgerEntries": {
+ const keys = Array.isArray(params?.keys) ? params.keys : [];
+ const entries = keys.length
+ ? keys.map((keyBase64) => ({
+ key: keyBase64,
+ xdr: accountEntryXdr(SESSION_PUBLIC_KEY, 1).toXDR("base64"),
+ lastModifiedLedgerSeq: 0,
+ }))
+ : [];
+ return res.end(rpcResult(id, { latestLedger: 1000, entries }));
+ }
+
+ case "simulateTransaction":
+ return res.end(
+ rpcResult(id, {
+ id: "sim-1",
+ latestLedger: 1000,
+ transactionData: sorobanTransactionDataBase64(),
+ minResourceFee: "0",
+ cost: { cpuInsns: "0", memBytes: "0" },
+ results: [{ auth: [], xdr: scValVoidBase64() }],
+ events: [],
+ }),
+ );
+
+ case "sendTransaction":
+ submittedTransactionXdr = params?.transaction ?? null;
+ return res.end(
+ rpcResult(id, {
+ status: "PENDING",
+ hash: "0000000000000000000000000000000000000000000000000000000000000000",
+ latestLedger: 1000,
+ latestLedgerCloseTime: 0,
+ }),
+ );
+
+ case "getTransaction":
+ return res.end(
+ rpcResult(id, {
+ status: "SUCCESS",
+ latestLedger: 1000,
+ latestLedgerCloseTime: 0,
+ ledger: 1000,
+ applicationOrder: 1,
+ feeBump: false,
+ envelopeXdr:
+ submittedTransactionXdr ?? dummyEnvelopeBase64(),
+ resultXdr: successTxResultBase64(),
+ resultMetaXdr: zeroTxMetaBase64(),
+ }),
+ );
+
+ default:
+ return res.end(rpcError(id, -32601, `method not found: ${method}`));
+ }
+ } catch (err) {
+ res.end(rpcError(id, -32603, err.message));
+ }
+}
+
+// ── Bootstrap ────────────────────────────────────────────────────────────────
+
+const server = http.createServer(handleRest);
+server.listen(PORT, "0.0.0.0", () => {
+ console.log(`[mock-api] rest+sse listening on http://localhost:${PORT}`);
+});
+
+if (!fs.existsSync(CERT_PATH) || !fs.existsSync(KEY_PATH)) {
+ console.error("[mock-api] missing TLS certs — run the pw global-setup first (playwright install)");
+ process.exit(1);
+}
+
+const rpcServer = https.createServer(
+ { cert: fs.readFileSync(CERT_PATH), key: fs.readFileSync(KEY_PATH) },
+ handleRpc,
+);
+rpcServer.listen(RPC_PORT, "0.0.0.0", () => {
+ console.log(`[mock-api] soroban rpc listening on https://localhost:${RPC_PORT}`);
+});
\ No newline at end of file
diff --git a/frontend/e2e/stream-creation.spec.ts b/frontend/e2e/stream-creation.spec.ts
new file mode 100644
index 00000000..4209b7e3
--- /dev/null
+++ b/frontend/e2e/stream-creation.spec.ts
@@ -0,0 +1,33 @@
+import { test, expect } from "@playwright/test";
+import { mockConnectedWallet, RECIPIENT_PUBLIC_KEY } from "./utils/freighter";
+
+test("single-screen /streams/create form validates input and submits a stream", async ({
+ page,
+}) => {
+ await mockConnectedWallet(page);
+ await page.goto("/streams/create");
+
+ await expect(page.getByRole("heading", { name: "Create New Stream" })).toBeVisible({
+ timeout: 30_000,
+ });
+ await expect(page.locator(".wallet-chip").first()).toBeVisible({ timeout: 30_000 });
+
+ const amount = page.locator("#create-stream-amount");
+
+ await amount.fill("0");
+ await expect(page.getByText("Amount must be greater than 0")).toBeVisible();
+
+ await page.locator("#recipient").fill(RECIPIENT_PUBLIC_KEY);
+ await page.locator("#create-stream-token").selectOption("USDC");
+ await amount.fill("10");
+ await page.locator("#create-stream-duration").fill("7");
+
+ await expect(page.getByText("0.00001653 USDC/sec")).toBeVisible();
+
+ await page.getByRole("button", { name: "Start Streaming" }).click();
+
+ await expect(page.getByText("Stream created successfully!")).toBeVisible({
+ timeout: 20_000,
+ });
+ await expect(page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
+});
\ No newline at end of file
diff --git a/frontend/e2e/stream-lifecycle.spec.ts b/frontend/e2e/stream-lifecycle.spec.ts
new file mode 100644
index 00000000..495c5792
--- /dev/null
+++ b/frontend/e2e/stream-lifecycle.spec.ts
@@ -0,0 +1,40 @@
+import { test, expect } from "@playwright/test";
+import { mockConnectedWallet, RECIPIENT_PUBLIC_KEY } from "./utils/freighter";
+
+const MOCK_API_URL = "http://localhost:3100";
+
+test("stream detail page shows stream state and reflects a withdrawal via mock events", async ({
+ page,
+}) => {
+ // Connect as the stream RECIPIENT so the Withdraw action is available.
+ await mockConnectedWallet(page, RECIPIENT_PUBLIC_KEY);
+
+ await page.goto("/streams/42");
+
+ const withdrawnCard = page.locator(".glass-card", { hasText: "Withdrawn" }).first();
+ const claimableCard = page.locator(".glass-card", { hasText: "Claimable" }).first();
+
+ await expect(withdrawnCard).toContainText("0 USDC", { timeout: 30_000 });
+ // Live claimable is capped at the deposited amount by the dashboard contract.
+ await expect(claimableCard).toContainText("10000 USDC", { timeout: 30_000 });
+
+ const bump = await page.request.post(`${MOCK_API_URL}/__e2e/stream/42/withdraw`);
+ expect(bump.ok()).toBeTruthy();
+
+ await expect(withdrawnCard).toContainText("10 USDC", { timeout: 20_000 });
+
+ const eventRow = page
+ .locator("div.flex.items-center.gap-4.py-3", { hasText: "Withdrawn" })
+ .first();
+ await expect(eventRow).toBeVisible({ timeout: 20_000 });
+
+ const withdrawnButton = page
+ .getByRole("button", { name: /Withdraw/, exact: false })
+ .first();
+ await expect(withdrawnButton).toBeEnabled();
+ await withdrawnButton.click();
+
+ await expect(page.getByText("Withdrawal successful!")).toBeVisible({
+ timeout: 30_000,
+ });
+});
\ No newline at end of file
diff --git a/frontend/e2e/utils/freighter.ts b/frontend/e2e/utils/freighter.ts
new file mode 100644
index 00000000..330278a6
--- /dev/null
+++ b/frontend/e2e/utils/freighter.ts
@@ -0,0 +1,121 @@
+import type { Page } from "@playwright/test";
+
+export const WALLET_PUBLIC_KEY =
+ "GB5P5GY25PGHPN4DG2XSQLWCUHTFUK2GDZ75IWB7KZV3RKBVH33GZ32U";
+export const RECIPIENT_PUBLIC_KEY =
+ "GDBX55OJUOXRSTWICUESBZAHSMJNFWZ57NEEPVN74BXKH7OGZV23RYCG";
+export const SESSION_STORAGE_KEY = "flowfi.wallet.session.v1";
+
+/**
+ * Injects a window-level mock for the Freighter browser extension using the
+ * postMessage protocol implemented by @stellar/freighter-api v6
+ * (FREIGHTER_EXTERNAL_MSG_REQUEST / FREIGHTER_EXTERNAL_MSG_RESPONSE).
+ */
+export function freighterInitScript(address: string): string {
+ return `
+ (() => {
+ const address = ${JSON.stringify(address)};
+
+ window.freighter = { version: "mock" };
+
+ const respond = (messageId, payload) => {
+ window.postMessage(
+ {
+ source: "FREIGHTER_EXTERNAL_MSG_RESPONSE",
+ messagedId: messageId,
+ extensionName: "FREIGHTER",
+ apiVersion: 1,
+ ...payload,
+ },
+ window.location.origin,
+ );
+ };
+
+ window.addEventListener("message", (event) => {
+ if (event.source !== window) return;
+ const data = event.data || {};
+ if (data.source !== "FREIGHTER_EXTERNAL_MSG_REQUEST") return;
+
+ switch (data.type) {
+ case "REQUEST_ACCESS":
+ case "REQUEST_PUBLIC_KEY":
+ respond(data.messageId, { publicKey: address, error: undefined });
+ break;
+ case "REQUEST_CONNECTION_STATUS":
+ respond(data.messageId, { isConnected: true });
+ break;
+ case "REQUEST_ALLOWED_STATUS":
+ case "SET_ALLOWED_STATUS":
+ respond(data.messageId, { isAllowed: true });
+ break;
+ case "REQUEST_NETWORK_DETAILS":
+ respond(data.messageId, {
+ networkDetails: {
+ network: "TESTNET",
+ networkName: "SDF Test Network",
+ networkUrl: "https://horizon-testnet.stellar.org",
+ networkPassphrase: "Test SDF Network ; September 2015",
+ sorobanRpcUrl: "https://soroban-testnet.stellar.org",
+ },
+ error: undefined,
+ });
+ break;
+ case "SUBMIT_TRANSACTION":
+ respond(data.messageId, {
+ signedTransaction: data.transactionXdr,
+ signerAddress: address,
+ error: undefined,
+ });
+ break;
+ default:
+ respond(data.messageId, {
+ error: { code: -2, message: "Unsupported mock request: " + data.type },
+ });
+ break;
+ }
+ });
+ })();
+ `;
+}
+
+export async function installFreighterMock(
+ page: Page,
+ address: string = WALLET_PUBLIC_KEY,
+): Promise {
+ await page.addInitScript(freighterInitScript(address));
+}
+
+/**
+ * Seeds a persisted, non-mocked wallet session so pages hydrate straight into
+ * the connected state without opening the connect modal.
+ */
+export async function seedWalletSession(
+ page: Page,
+ address: string = WALLET_PUBLIC_KEY,
+): Promise {
+ await page.addInitScript(
+ ({ key, storageKey }) => {
+ window.localStorage.setItem(
+ storageKey,
+ JSON.stringify({
+ walletId: "freighter",
+ walletName: "Freighter",
+ publicKey: key,
+ connectedAt: new Date().toISOString(),
+ network: "Testnet",
+ mocked: false,
+ }),
+ );
+ },
+ { key: address, storageKey: SESSION_STORAGE_KEY },
+ );
+}
+
+/** Sets up a mocked Freighter extension AND a persisted connected session. */
+export async function mockConnectedWallet(
+ page: Page,
+ address: string = WALLET_PUBLIC_KEY,
+): Promise {
+ await installFreighterMock(page, address);
+ await seedWalletSession(page, address);
+}
\ No newline at end of file
diff --git a/frontend/e2e/wallet-connection.spec.ts b/frontend/e2e/wallet-connection.spec.ts
new file mode 100644
index 00000000..27557db0
--- /dev/null
+++ b/frontend/e2e/wallet-connection.spec.ts
@@ -0,0 +1,26 @@
+import { test, expect } from "@playwright/test";
+import { installFreighterMock, WALLET_PUBLIC_KEY } from "./utils/freighter";
+
+test("connects a Freighter wallet, shows the account badge, and disconnects", async ({
+ page,
+}) => {
+ await installFreighterMock(page);
+
+ await page.goto("/");
+
+ const connectButton = page.locator(".wallet-connect-btn").first();
+ await expect(connectButton).toBeVisible({ timeout: 30_000 });
+ await connectButton.click();
+
+ const dialog = page.getByRole("dialog", { name: "Connect a wallet" });
+ await expect(dialog).toBeVisible();
+ await dialog.getByRole("button", { name: "Connect Freighter" }).click();
+
+ const chip = page.locator(".wallet-chip").first();
+ await expect(chip).toBeVisible({ timeout: 15_000 });
+ await expect(chip).toContainText(WALLET_PUBLIC_KEY.slice(0, 4));
+
+ await chip.click();
+ await page.getByRole("menuitem", { name: "Disconnect" }).click();
+ await expect(page.locator(".wallet-connect-btn").first()).toBeVisible();
+});
\ No newline at end of file
diff --git a/frontend/next.config.ts b/frontend/next.config.ts
index 31c5ed1c..c90d14b9 100644
--- a/frontend/next.config.ts
+++ b/frontend/next.config.ts
@@ -1,6 +1,13 @@
+import path from "node:path";
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
+ // The workspace root lives one level above this directory. Pinning it here
+ // prevents Turbopack from inferring a wrong root when stray package-lock
+ // files exist outside the repo (e.g. ~/package-lock.json).
+ turbopack: {
+ root: path.join(path.dirname(new URL(import.meta.url).pathname), ".."),
+ },
// Enable tree-shaking for icon/utility libraries to reduce per-route
// bundle sizes (Issue #1254).
experimental: {
diff --git a/frontend/package.json b/frontend/package.json
index 02bb9898..524b1063 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,6 +10,8 @@
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
+ "test:e2e": "playwright test",
+ "test:e2e:headed": "playwright test --headed",
"codegen:api-types": "openapi-typescript http://localhost:3001/api-docs.json -o src/lib/api-types.generated.ts"
},
"dependencies": {
@@ -41,6 +43,7 @@
"happy-dom": "^20.10.3",
"jsdom": "^27.0.1",
"openapi-typescript": "^7.13.0",
+ "@playwright/test": "^1.55.0",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^3.2.7"
diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts
new file mode 100644
index 00000000..3f75ee65
--- /dev/null
+++ b/frontend/playwright.config.ts
@@ -0,0 +1,48 @@
+import { defineConfig, devices } from "@playwright/test";
+
+const API_PORT = Number(process.env.MOCK_API_PORT || 3100);
+const APP_PORT = Number(process.env.E2E_APP_PORT || 3101);
+const RPC_PORT = Number(process.env.MOCK_RPC_PORT || 3102);
+
+export default defineConfig({
+ testDir: "./e2e",
+ globalSetup: "./e2e/global-setup.ts",
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ workers: process.env.CI ? 1 : undefined,
+ reporter: process.env.CI
+ ? [["list"], ["html", { open: "never" }]]
+ : [["list"], ["html", { open: "never" }]],
+ use: {
+ baseURL: `http://localhost:${APP_PORT}`,
+ ignoreHTTPSErrors: true,
+ trace: "on-first-retry",
+ screenshot: "only-on-failure",
+ },
+ projects: [
+ { name: "chromium", use: { ...devices["Desktop Chrome"] } },
+ { name: "firefox", use: { ...devices["Desktop Firefox"] } },
+ ],
+ webServer: [
+ {
+ command: "node ./e2e/mocks/api-server.mjs",
+ url: `http://localhost:${API_PORT}/health`,
+ reuseExistingServer: !process.env.CI,
+ timeout: 60_000,
+ },
+ {
+ command: "npm run dev -- -p " + APP_PORT,
+ url: `http://localhost:${APP_PORT}/`,
+ reuseExistingServer: !process.env.CI,
+ timeout: 180_000,
+ env: {
+ NEXT_PUBLIC_API_URL: `http://localhost:${API_PORT}`,
+ NEXT_PUBLIC_STELLAR_NETWORK: "TESTNET",
+ NEXT_PUBLIC_SOROBAN_RPC_URL: `https://localhost:${RPC_PORT}/soroban`,
+ NEXT_PUBLIC_NETWORK_PASSPHRASE: "Test SDF Network ; September 2015",
+ NEXT_PUBLIC_STREAM_CONTRACT_ID: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4",
+ },
+ },
+ ],
+});
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 904392f2..abf30dcf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -92,6 +92,7 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
+ "@playwright/test": "^1.55.0",
"@tailwindcss/postcss": "^4.3.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
@@ -2306,6 +2307,22 @@
"node": ">=14"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
+ "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.62.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/@prisma/adapter-pg": {
"version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.9.1.tgz",
@@ -10337,6 +10354,53 @@
"pathe": "^2.0.3"
}
},
+ "node_modules/playwright": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
+ "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.62.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
+ "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/pluralize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
From b0fb531290fc4f741bc43fe07405d4c9a67c3b55 Mon Sep 17 00:00:00 2001
From: dimka90
Date: Mon, 31 Aug 2026 00:36:20 +0100
Subject: [PATCH 3/3] feat(api): complete OpenAPI 3.1 spec, SDK generator, and
Soroban deploy CI (#1200, #1201)
---
.github/workflows/ci.yml | 9 +
.github/workflows/deploy-contracts.yml | 146 +
backend/package.json | 3 +-
backend/scripts/export-openapi.mts | 19 +
backend/src/config/swagger.ts | 231 +-
backend/src/routes/health.routes.ts | 72 +-
backend/src/routes/v1/admin.routes.ts | 172 +
backend/src/routes/v1/auth.routes.ts | 34 +
backend/src/routes/v1/events.routes.ts | 75 +-
backend/src/routes/v1/stream.routes.ts | 493 ++-
backend/src/routes/v1/streams/withdraw.ts | 15 +-
backend/src/routes/v1/user.routes.ts | 106 +-
backend/src/routes/v1/webhook.routes.ts | 100 +-
backend/swagger/flowfi.openapi.json | 3507 +++++++++++++++++++++
contracts/stream_contract/README.md | 28 +
frontend/.gitignore | 3 -
frontend/package.json | 2 +-
frontend/src/lib/api-types.generated.ts | 2813 +++++++++++++++++
packages/flowfi-sdk/README.md | 37 +
packages/flowfi-sdk/package.json | 25 +
scripts/generate-sdk.sh | 33 +
21 files changed, 7735 insertions(+), 188 deletions(-)
create mode 100644 .github/workflows/deploy-contracts.yml
create mode 100644 backend/scripts/export-openapi.mts
create mode 100644 backend/swagger/flowfi.openapi.json
create mode 100644 frontend/src/lib/api-types.generated.ts
create mode 100644 packages/flowfi-sdk/README.md
create mode 100644 packages/flowfi-sdk/package.json
create mode 100755 scripts/generate-sdk.sh
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index eaaac563..ae634399 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -90,6 +90,15 @@ jobs:
run: npm run build
working-directory: backend
+ - name: OpenAPI spec & API types drift check
+ run: |
+ cd backend
+ npm run codegen:openapi
+ cd ../frontend
+ npm run codegen:api-types
+ cd ..
+ git diff --exit-code -- backend/swagger/flowfi.openapi.json frontend/src/lib/api-types.generated.ts
+
- name: Install Rollup Native Binding
run: npm install @rollup/rollup-linux-x64-gnu --no-save
diff --git a/.github/workflows/deploy-contracts.yml b/.github/workflows/deploy-contracts.yml
new file mode 100644
index 00000000..9fbe97d0
--- /dev/null
+++ b/.github/workflows/deploy-contracts.yml
@@ -0,0 +1,146 @@
+# Contract Deployment Workflow for FlowFi
+#
+# Compiles the Soroban stream contract to optimized WASM, runs contract tests,
+# and deploys + initializes the contract on Stellar Testnet on demand (or on
+# Mainnet for release tags). The resulting contract ID is surfaced in the job
+# summary and published as a release artifact.
+name: Deploy Soroban Contracts
+
+on:
+ release:
+ types: [published]
+ workflow_dispatch:
+ inputs:
+ network:
+ description: "Target network (testnet|mainnet)"
+ required: true
+ default: "testnet"
+ type: choice
+ options:
+ - testnet
+ - mainnet
+
+concurrency:
+ group: ${{ github.workflow }}-${{ inputs.network || github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: write
+
+jobs:
+ deploy:
+ name: Build & Deploy stream_contract
+ runs-on: ubuntu-latest
+ environment: ${{ github.event_name == 'release' && 'production' || 'staging' }}
+
+ env:
+ NETWORK: ${{ inputs.network || (github.event_name == 'release' && 'mainnet' || 'testnet') }}
+ DEPLOYER_SECRET: ${{ secrets.DEPLOYER_SECRET }}
+ ADMIN_ADDRESS: ${{ secrets.ADMIN_ADDRESS }}
+ TREASURY_ADDRESS: ${{ secrets.TREASURY_ADDRESS }}
+ FEE_RATE_BPS: ${{ secrets.FEE_RATE_BPS }}
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Rust toolchain
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ toolchain: stable
+ targets: wasm32-unknown-unknown
+ components: rustfmt, clippy
+
+ - name: Rust Cache
+ uses: Swatinem/rust-cache@v2
+ with:
+ workspace: "contracts -> target"
+
+ - name: Install Stellar CLI
+ run: |
+ curl -fsSL https://github.com/stellar/stellar-cli/raw/main/install.sh | sh -s -- --install-deps
+ echo "$HOME/.stellar-cli/bin" >> $GITHUB_PATH
+
+ - name: Run Contract Tests
+ run: cargo test --package stream_contract
+ working-directory: contracts
+
+ - name: Build & Optimize WASM
+ run: |
+ set -euo pipefail
+ cd contracts
+ cargo build --target wasm32-unknown-unknown --release
+ RELEASE_DIR="target/wasm32-unknown-unknown/release"
+ for w in "$RELEASE_DIR"/stream_contract.wasm; do
+ stellar contract optimize --wasm "$w" --wasm-out "$RELEASE_DIR/stream_contract.optimized.wasm"
+ done
+ ls -la "$RELEASE_DIR"/*.wasm
+
+ - name: Inspect Contract Interface & WASM Size
+ run: |
+ set -euo pipefail
+ WASM=contracts/target/wasm32-unknown-unknown/release/stream_contract.optimized.wasm
+ stellar contract inspect --wasm "$WASM"
+ SIZE=$(stat -c%s "$WASM")
+ echo "Optimized WASM size: $SIZE bytes"
+ if [ "$SIZE" -ge 65536 ]; then
+ echo "ERROR: optimized WASM exceeds 64KB budget ($SIZE bytes)"
+ exit 1
+ fi
+ echo "WASM_WASM_PATH=$WASM" >> $GITHUB_ENV
+
+ - name: Deploy & Initialize Contract
+ run: ./scripts/deploy.sh --network "$NETWORK"
+
+ - name: Read Deployed Contract ID
+ id: contract
+ run: |
+ set -euo pipefail
+ CONTRACT_ID=$(jq -r --arg net "$NETWORK" '.[$net].contractId' deployment-info.json)
+ echo "contract_id=$CONTRACT_ID" >> $GITHUB_OUTPUT
+ echo "deployment-json=$(jq -c . deployment-info.json)" >> $GITHUB_OUTPUT
+
+ - name: Emit Deployment Summary
+ if: always()
+ run: |
+ {
+ echo "## Deployment Summary"
+ echo ""
+ echo "- **Network**: \`$NETWORK\`"
+ echo "- **Contract ID**: \`${{ steps.contract.outputs.contract_id }}\`"
+ echo "- **WASM**: \`${{ env.WASM_WASM_PATH }}\`"
+ echo "- **Deployment info**: "
+ echo '```json'
+ echo "${{ steps.contract.outputs.deployment-json }}"
+ echo '```'
+ } >> "$GITHUB_STEP_SUMMARY"
+
+ - name: Upload Optimized WASM Artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: stream-contract-${{ env.NETWORK }}
+ path: contracts/target/wasm32-unknown-unknown/optimized/*.wasm
+ if-no-files-found: error
+
+ - name: Upload Deployment Info
+ uses: actions/upload-artifact@v4
+ with:
+ name: deployment-info-${{ env.NETWORK }}
+ path: deployment-info.json
+ if-no-files-found: error
+
+ - name: Commit Deployment Info
+ if: github.event_name == 'release'
+ env:
+ NETWORK: ${{ env.NETWORK }}
+ run: |
+ set -euo pipefail
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add deployment-info.json
+ if git diff --cached --quiet; then
+ echo "No deployment-info.json changes to commit"
+ exit 0
+ fi
+ git commit -m "chore(contracts): record $NETWORK contract deployment"
+ git push
\ No newline at end of file
diff --git a/backend/package.json b/backend/package.json
index 38478593..2b66f802 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -18,7 +18,8 @@
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy",
"prisma:seed": "prisma db seed",
- "prisma:studio": "prisma studio"
+ "prisma:studio": "prisma studio",
+ "codegen:openapi": "tsx scripts/export-openapi.mts"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
diff --git a/backend/scripts/export-openapi.mts b/backend/scripts/export-openapi.mts
new file mode 100644
index 00000000..5bf84b5a
--- /dev/null
+++ b/backend/scripts/export-openapi.mts
@@ -0,0 +1,19 @@
+/**
+ * Export the OpenAPI spec to a committed JSON file so it can be consumed
+ * without booting the API server (e.g. by `openapi-typescript` codegen and the
+ * CI drift check).
+ *
+ * npm run codegen:openapi
+ */
+import { mkdirSync, writeFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { swaggerSpec } from '../src/config/swagger.js';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const outDir = join(here, '..', 'swagger');
+const outFile = join(outDir, 'flowfi.openapi.json');
+
+mkdirSync(outDir, { recursive: true });
+writeFileSync(outFile, `${JSON.stringify(swaggerSpec, null, 2)}\n`);
+console.log(`OpenAPI spec written to ${outFile}`);
\ No newline at end of file
diff --git a/backend/src/config/swagger.ts b/backend/src/config/swagger.ts
index 6aea103c..19c8f0b9 100644
--- a/backend/src/config/swagger.ts
+++ b/backend/src/config/swagger.ts
@@ -2,7 +2,7 @@ import swaggerJsdoc from 'swagger-jsdoc';
const options: swaggerJsdoc.Options = {
definition: {
- openapi: '3.0.0',
+ openapi: '3.1.0',
info: {
title: 'FlowFi API',
version: '1.0.0',
@@ -259,6 +259,224 @@ See [Sandbox Mode Documentation](../docs/SANDBOX_MODE.md) for details.`,
},
},
},
+ StreamListResponse: {
+ type: 'object',
+ required: ['data', 'total', 'hasMore', 'limit', 'offset'],
+ properties: {
+ data: {
+ type: 'array',
+ description: 'Streams matching the filter, sorted and paginated',
+ items: { $ref: '#/components/schemas/Stream' },
+ },
+ total: { type: 'integer', description: 'Total number of streams matching the filter' },
+ hasMore: { type: 'boolean', description: 'Whether more results are available past this page' },
+ limit: { type: 'integer', description: 'Page size applied (capped at MAX_STREAM_PAGE_SIZE)' },
+ offset: { type: 'integer', description: 'Number of results skipped' },
+ },
+ },
+ StreamEventListResponse: {
+ type: 'object',
+ required: ['data', 'total', 'hasMore'],
+ properties: {
+ data: {
+ type: 'array',
+ description: 'Events for the stream, sorted by timestamp (tie-broken by id)',
+ items: { $ref: '#/components/schemas/StreamEvent' },
+ },
+ total: { type: 'integer', description: 'Total number of events matching the filter' },
+ hasMore: { type: 'boolean', description: 'Whether more results are available past this page' },
+ },
+ },
+ EventListResponse: {
+ type: 'object',
+ required: ['events', 'total', 'limit', 'offset', 'hasMore'],
+ properties: {
+ events: {
+ type: 'array',
+ description: 'Reverse-chronological stream events for the wallet',
+ items: { $ref: '#/components/schemas/StreamEvent' },
+ },
+ total: { type: 'integer', description: 'Total number of matching events' },
+ limit: { type: 'integer', description: 'Page size applied (capped at 200)' },
+ offset: { type: 'integer', description: 'Number of events skipped' },
+ hasMore: { type: 'boolean', description: 'Whether more results are available past this page' },
+ },
+ },
+ UserEventListResponse: {
+ type: 'object',
+ required: ['data', 'total', 'hasMore', 'limit', 'offset'],
+ properties: {
+ data: {
+ type: 'array',
+ description: 'Events associated with the user, newest first',
+ items: { $ref: '#/components/schemas/StreamEvent' },
+ },
+ total: { type: 'integer', description: 'Total number of matching events' },
+ hasMore: { type: 'boolean', description: 'Whether more results are available past this page' },
+ limit: { type: 'integer', description: 'Page size applied (capped at 200)' },
+ offset: { type: 'integer', description: 'Number of events skipped' },
+ },
+ },
+ UserStreamSummary: {
+ type: 'object',
+ required: [
+ 'address',
+ 'totalStreamsCreated',
+ 'totalStreamedOut',
+ 'totalStreamedIn',
+ 'currentClaimable',
+ 'activeOutgoingCount',
+ 'activeIncomingCount',
+ ],
+ properties: {
+ address: { type: 'string', description: 'Stellar public key' },
+ totalStreamsCreated: { type: 'integer', description: 'Number of streams this wallet sent' },
+ totalStreamedOut: { type: 'string', description: 'Sum of withdrawn amounts on outgoing streams (i128 as string)' },
+ totalStreamedIn: { type: 'string', description: 'Sum of withdrawn amounts on incoming streams (i128 as string)' },
+ currentClaimable: { type: 'string', description: 'Total currently claimable across active incoming streams (i128 as string)' },
+ activeOutgoingCount: { type: 'integer' },
+ activeIncomingCount: { type: 'integer' },
+ truncated: { type: 'boolean', description: 'True when the number of streams was capped at MAX_USER_STREAMS per direction', example: false },
+ },
+ },
+ ClaimableResponse: {
+ type: 'object',
+ required: ['claimableAmount', 'actionable', 'calculatedAt'],
+ properties: {
+ streamId: { type: 'integer', description: 'On-chain stream ID' },
+ ratePerSecond: { type: 'string', description: 'Payment rate per second (i128 as string)' },
+ depositedAmount: { type: 'string', description: 'Total deposited amount (i128 as string)' },
+ withdrawnAmount: { type: 'string', description: 'Total withdrawn amount (i128 as string)' },
+ startTime: { type: 'integer', description: 'Stream start time (Unix timestamp)' },
+ lastUpdateTime: { type: 'integer', description: 'Last state update time (Unix timestamp)' },
+ claimableAmount: { type: 'string', description: 'Amount claimable at the requested time (i128 as string)' },
+ actionable: { type: 'boolean', description: 'Whether the claimable amount is positive' },
+ calculatedAt: { type: 'integer', description: 'Unix timestamp of the calculation' },
+ cached: { type: 'boolean', description: 'Whether the value came from cache or a fresh computation' },
+ source: { type: 'string', enum: ['db', 'chain'], description: 'Where the value was computed from' },
+ },
+ },
+ PauseResumeResponse: {
+ type: 'object',
+ required: ['success', 'streamId', 'txHash'],
+ properties: {
+ success: { type: 'boolean', example: true },
+ streamId: { type: 'integer' },
+ txHash: { type: 'string', description: 'Stellar transaction hash of the pause/resume simulation' },
+ stream: { $ref: '#/components/schemas/Stream' },
+ },
+ },
+ TopUpResponse: {
+ type: 'object',
+ required: ['streamId', 'txHash', 'depositedAmount'],
+ properties: {
+ streamId: { type: 'integer' },
+ txHash: { type: 'string', description: 'Stellar transaction hash' },
+ depositedAmount: { type: 'string', description: 'New total deposited amount after the top-up (i128 as string)' },
+ },
+ },
+ WithdrawResponse: {
+ type: 'object',
+ required: ['success', 'streamId', 'txHash', 'amount'],
+ properties: {
+ success: { type: 'boolean', example: true },
+ streamId: { type: 'integer' },
+ txHash: { type: 'string', description: 'Stellar transaction hash of the withdrawal' },
+ amount: { type: 'string', description: 'Amount withdrawn (i128 as string)' },
+ stream: { $ref: '#/components/schemas/Stream' },
+ },
+ },
+ CancelResponse: {
+ type: 'object',
+ required: ['txHash', 'status'],
+ properties: {
+ txHash: { type: 'string', description: 'Stellar transaction hash of the cancel' },
+ status: { type: 'string', enum: ['CANCELLED'], example: 'CANCELLED' },
+ },
+ },
+ AuthChallengeResponse: {
+ type: 'object',
+ required: ['nonce', 'expiresAt'],
+ properties: {
+ nonce: { type: 'string', description: 'Hex-encoded nonce to sign via a Stellar manage_data operation' },
+ expiresAt: { type: 'integer', description: 'Unix timestamp (ms) when the challenge expires (60s)' },
+ },
+ },
+ AuthVerifyResponse: {
+ type: 'object',
+ required: ['token', 'expiresIn'],
+ properties: {
+ token: { type: 'string', description: 'JWT to use in the Authorization: Bearer header' },
+ expiresIn: { type: 'integer', description: 'Token lifetime in seconds (3600)' },
+ },
+ },
+ SseStats: {
+ type: 'object',
+ required: ['activeConnections', 'activeIps', 'perIpPeakConnections', 'maxConnections', 'timestamp'],
+ properties: {
+ activeConnections: { type: 'integer', example: 42 },
+ activeIps: { type: 'integer', example: 8 },
+ perIpPeakConnections: { type: 'integer', example: 5 },
+ maxConnections: { type: 'integer', example: 10000 },
+ timestamp: { type: 'string', format: 'date-time' },
+ },
+ },
+ WebhookSubscription: {
+ type: 'object',
+ required: ['id', 'userAddress', 'targetUrl', 'eventTypes', 'active', 'createdAt'],
+ properties: {
+ id: { type: 'string', description: 'Webhook subscription id' },
+ userAddress: { type: 'string', description: 'Stellar public key the subscription belongs to' },
+ targetUrl: { type: 'string', description: 'HTTPS endpoint receiving the events' },
+ eventTypes: {
+ type: 'array',
+ items: { type: 'string', enum: ['CREATED', 'TOPPED_UP', 'WITHDRAWN', 'CANCELLED', 'COMPLETED', 'PAUSED', 'RESUMED', 'FEE_COLLECTED'] },
+ },
+ active: { type: 'boolean' },
+ createdAt: { type: 'string', format: 'date-time' },
+ },
+ },
+ HealthResponse: {
+ type: 'object',
+ required: ['status', 'db', 'indexerEnabled', 'uptime', 'checks'],
+ properties: {
+ status: { type: 'string', enum: ['ok', 'degraded'], example: 'ok' },
+ db: { type: 'string', enum: ['connected', 'disconnected'], example: 'connected' },
+ indexerEnabled: { type: 'boolean', description: 'Whether the event indexer is configured' },
+ indexerLag: { type: 'integer', nullable: true, description: 'Seconds since last indexer update, or null when no state row exists yet' },
+ eventsProcessed: { type: 'integer', description: 'Lifetime count of successfully processed indexer events' },
+ eventsFailed: { type: 'integer', description: 'Lifetime count of indexer events that threw during processing' },
+ lastErrorAt: { type: 'string', format: 'date-time', nullable: true, description: 'Most recent per-event processing failure' },
+ indexerDegraded: { type: 'boolean', description: 'True when recent event-processing failure rate spikes' },
+ uptime: { type: 'number', description: 'Server uptime in seconds' },
+ checks: {
+ type: 'object',
+ description: 'Per-subsystem status breakdown',
+ properties: {
+ database: {
+ type: 'object',
+ properties: { status: { type: 'string', enum: ['ok', 'down'] } },
+ },
+ indexer: {
+ type: 'object',
+ properties: {
+ status: { type: 'string', enum: ['ok', 'degraded', 'disabled'] },
+ enabled: { type: 'boolean' },
+ lagSeconds: { type: 'integer', nullable: true },
+ },
+ },
+ redis: {
+ type: 'object',
+ properties: { status: { type: 'string', enum: ['ok', 'unavailable', 'not_configured'] } },
+ },
+ sorobanRpc: {
+ type: 'object',
+ properties: { status: { type: 'string', enum: ['ok', 'down'] } },
+ },
+ },
+ },
+ },
+ },
Error: {
type: 'object',
properties: {
@@ -272,6 +490,17 @@ See [Sandbox Mode Documentation](../docs/SANDBOX_MODE.md) for details.`,
description: 'Error code',
example: 'NOT_FOUND',
},
+ message: {
+ type: 'string',
+ nullable: true,
+ description: 'Human-readable detail (present on many error responses)',
+ },
+ details: {
+ type: 'array',
+ nullable: true,
+ description: 'Structured validation issues (zod) when the error is a 400',
+ items: { type: 'object' },
+ },
},
},
},
diff --git a/backend/src/routes/health.routes.ts b/backend/src/routes/health.routes.ts
index 42b4f32f..9de798cb 100644
--- a/backend/src/routes/health.routes.ts
+++ b/backend/src/routes/health.routes.ts
@@ -33,75 +33,13 @@ const router = Router();
* content:
* application/json:
* schema:
- * type: object
- * properties:
- * status:
- * type: string
- * example: ok
- * db:
- * type: string
- * example: connected
- * indexerEnabled:
- * type: boolean
- * description: Whether the event indexer is configured
- * example: true
- * indexerLag:
- * type: integer
- * nullable: true
- * description: Seconds since last indexer update, or null when no state row exists yet
- * example: 5
- * eventsProcessed:
- * type: integer
- * description: Lifetime count of successfully processed indexer events
- * eventsFailed:
- * type: integer
- * description: Lifetime count of indexer events that threw during processing
- * lastErrorAt:
- * type: string
- * nullable: true
- * description: ISO timestamp of the most recent per-event processing failure
- * indexerDegraded:
- * type: boolean
- * description: True when recent event-processing failure rate indicates a spike
- * uptime:
- * type: number
- * description: Server uptime in seconds
- * example: 3600
- * checks:
- * type: object
- * description: Per-subsystem status breakdown, so callers can tell "DB unreachable" apart from "indexer lagging" instead of inferring it from the top-level status alone.
- * properties:
- * database:
- * type: object
- * properties:
- * status:
- * type: string
- * enum: [ok, down]
- * indexer:
- * type: object
- * properties:
- * status:
- * type: string
- * enum: [ok, degraded, disabled]
- * enabled:
- * type: boolean
- * lagSeconds:
- * type: integer
- * nullable: true
- * redis:
- * type: object
- * properties:
- * status:
- * type: string
- * enum: [ok, unavailable, not_configured]
- * sorobanRpc:
- * type: object
- * properties:
- * status:
- * type: string
- * enum: [ok, down]
+ * $ref: '#/components/schemas/HealthResponse'
* 503:
* description: Service is degraded or unhealthy
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/HealthResponse'
*/
router.get('/', async (_req: Request, res: Response) => {
let dbStatus = 'connected';
diff --git a/backend/src/routes/v1/admin.routes.ts b/backend/src/routes/v1/admin.routes.ts
index 93b1a817..8799eea3 100644
--- a/backend/src/routes/v1/admin.routes.ts
+++ b/backend/src/routes/v1/admin.routes.ts
@@ -32,6 +32,92 @@ router.use(adminRateLimiter);
* responses:
* 200:
* description: Protocol health metrics
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * total_streams:
+ * type: integer
+ * active_streams:
+ * type: integer
+ * paused_streams:
+ * type: integer
+ * completed_streams:
+ * type: integer
+ * cancelled_streams:
+ * type: integer
+ * total_volume_streamed:
+ * type: string
+ * description: Sum of withdrawn amounts (i128 as string)
+ * streams:
+ * type: object
+ * properties:
+ * active: { type: integer }
+ * paused: { type: integer }
+ * total: { type: integer }
+ * byStatus:
+ * type: object
+ * additionalProperties: { type: integer }
+ * events:
+ * type: object
+ * properties:
+ * last24h: { type: integer }
+ * fees:
+ * type: object
+ * properties:
+ * totalFeesCollectedByToken:
+ * type: object
+ * additionalProperties: { type: string }
+ * feesLast24h:
+ * type: object
+ * additionalProperties: { type: string }
+ * sse:
+ * type: object
+ * properties:
+ * activeConnections: { type: integer }
+ * indexer:
+ * type: object
+ * properties:
+ * lastLedger: { type: integer }
+ * lagSeconds: { type: integer, nullable: true }
+ * lastUpdated: { type: string, format: date-time, nullable: true }
+ * eventsProcessed: { type: integer }
+ * eventsFailed: { type: integer }
+ * lastErrorAt: { type: string, nullable: true }
+ * degraded: { type: boolean }
+ * cache:
+ * type: object
+ * additionalProperties: true
+ * pgPool:
+ * type: object
+ * additionalProperties: true
+ * uptime:
+ * type: number
+ * timestamp:
+ * type: string
+ * format: date-time
+ * calculatedAt:
+ * type: string
+ * format: date-time
+ * 401:
+ * description: Unauthorized - missing or invalid authentication token
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 403:
+ * description: Forbidden - admin access required
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 500:
+ * description: Internal server error
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
*/
const ADMIN_METRICS_CACHE_KEY = 'admin:metrics';
const ADMIN_METRICS_CACHE_TTL_SECONDS = 60;
@@ -216,6 +302,29 @@ router.get('/metrics', async (_req: Request, res: Response) => {
* responses:
* 200:
* description: Indexer status
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * additionalProperties: true
+ * 401:
+ * description: Unauthorized - missing or invalid authentication token
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 403:
+ * description: Forbidden - admin access required
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 500:
+ * description: Internal server error
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
*/
router.get('/indexer/status', async (req: Request, res: Response) => {
try {
@@ -246,6 +355,37 @@ router.get('/indexer/status', async (req: Request, res: Response) => {
* responses:
* 200:
* description: Reset successful
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * ok: { type: boolean, example: true }
+ * lastLedger: { type: integer }
+ * 400:
+ * description: Invalid ledger value
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 401:
+ * description: Unauthorized - missing or invalid authentication token
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 403:
+ * description: Forbidden - admin access required
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 500:
+ * description: Internal server error
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
*/
router.post('/indexer/reset', async (req: Request, res: Response) => {
const ledger = Number(req.body?.ledger);
@@ -277,6 +417,38 @@ router.post('/indexer/reset', async (req: Request, res: Response) => {
* responses:
* 202:
* description: Replay started
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * ok: { type: boolean, example: true }
+ * replayingFrom: { type: integer }
+ * requestId: { type: string }
+ * 400:
+ * description: Invalid from_ledger value
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 401:
+ * description: Unauthorized - missing or invalid authentication token
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 403:
+ * description: Forbidden - admin access required
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 500:
+ * description: Internal server error
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
*/
router.post('/indexer/replay', async (req: Request, res: Response) => {
const fromLedger = Number(req.query.from_ledger);
diff --git a/backend/src/routes/v1/auth.routes.ts b/backend/src/routes/v1/auth.routes.ts
index 4d0d25d3..00f1f3b7 100644
--- a/backend/src/routes/v1/auth.routes.ts
+++ b/backend/src/routes/v1/auth.routes.ts
@@ -23,8 +23,22 @@ const router = Router();
* responses:
* 200:
* description: Challenge nonce issued
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/AuthChallengeResponse'
* 400:
* description: Invalid publicKey
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 429:
+ * description: Too many requests
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
*/
router.post('/challenge', issueChallenge);
@@ -50,8 +64,28 @@ router.post('/challenge', issueChallenge);
* responses:
* 200:
* description: JWT token
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/AuthVerifyResponse'
+ * 400:
+ * description: Missing publicKey or signedTransaction
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
* 401:
* description: Invalid signature or expired challenge
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 429:
+ * description: Too many requests
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
*/
router.post('/verify', verifyChallenge);
diff --git a/backend/src/routes/v1/events.routes.ts b/backend/src/routes/v1/events.routes.ts
index a29927bb..637c7aaf 100644
--- a/backend/src/routes/v1/events.routes.ts
+++ b/backend/src/routes/v1/events.routes.ts
@@ -48,7 +48,7 @@ export const DEFAULT_EVENTS_PAGE_SIZE = 50;
* description: |
* Comma-separated list of event types to include. Allowed values:
* CREATED, TOPPED_UP, WITHDRAWN, CANCELLED, COMPLETED, PAUSED,
- * RESUMED, FEE_COLLECTED.
+ * RESUMED, FEE_COLLECTED, FEE_CONFIG_UPDATED, ADMIN_TRANSFERRED.
* - in: query
* name: limit
* required: false
@@ -65,6 +65,34 @@ export const DEFAULT_EVENTS_PAGE_SIZE = 50;
* responses:
* 200:
* description: Paginated event list
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/EventListResponse'
+ * 400:
+ * description: Missing/invalid `address` or invalid `type` filter
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 401:
+ * description: Unauthorized - missing or invalid authentication token
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 403:
+ * description: Forbidden - `address` must match the authenticated wallet
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 500:
+ * description: Internal server error
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
*/
router.get('/', requireAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
@@ -205,13 +233,16 @@ router.get('/', requireAuth, async (req: Request, res: Response, next: NextFunct
* example: false
* responses:
* 200:
- * description: SSE connection established
+ * description: SSE connection established. Events are emitted as `data:` frames of type stream.created, stream.topped_up, stream.withdrawn, stream.cancelled, stream.completed, stream.paused, stream.resumed, fee.collected.
* content:
* text/event-stream:
* schema:
* type: string
+ * description: Server-Sent Events stream; each event carries a JSON payload matching the StreamEvent schema
* 400:
* description: Invalid subscription parameters
+ * 401:
+ * description: Unauthorized - missing or invalid authentication token
*/
router.get('/subscribe', requireAuth, subscribe);
@@ -222,30 +253,34 @@ router.get('/subscribe', requireAuth, subscribe);
* tags:
* - Events
* summary: Get SSE connection statistics
- * description: Returns current SSE connection metrics for monitoring
+ * description: Returns current SSE connection metrics for monitoring (admin only)
+ * security:
+ * - adminAuth: []
* responses:
* 200:
* description: Connection statistics
* content:
* application/json:
* schema:
- * type: object
- * properties:
- * activeConnections:
- * type: number
- * example: 42
- * activeIps:
- * type: number
- * example: 8
- * perIpPeakConnections:
- * type: number
- * example: 5
- * maxConnections:
- * type: number
- * example: 10000
- * timestamp:
- * type: string
- * format: date-time
+ * $ref: '#/components/schemas/SseStats'
+ * 401:
+ * description: Unauthorized - missing or invalid authentication token
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 403:
+ * description: Forbidden - admin access required
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 500:
+ * description: Internal server error
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
*/
router.get('/stats', requireAdmin, (req: Request, res: Response) => {
res.json({
diff --git a/backend/src/routes/v1/stream.routes.ts b/backend/src/routes/v1/stream.routes.ts
index cb6c39eb..cfba0252 100644
--- a/backend/src/routes/v1/stream.routes.ts
+++ b/backend/src/routes/v1/stream.routes.ts
@@ -24,18 +24,82 @@ const router = Router();
* tags:
* - Streams
* summary: Create a new payment stream
- * description: Creates a new payment stream on the Stellar network.
+ * description: Creates or reactivates a payment stream record for the authenticated wallet. The authenticated wallet must be the stream sender.
* security:
* - BearerAuth: []
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required: [streamId, sender, recipient, tokenAddress, ratePerSecond, depositedAmount, startTime]
+ * properties:
+ * streamId:
+ * type: integer
+ * description: On-chain stream ID
+ * example: 1
+ * sender:
+ * type: string
+ * description: Sender Stellar public key — must match the authenticated wallet
+ * example: "GABC123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA"
+ * recipient:
+ * type: string
+ * description: Recipient Stellar public key
+ * example: "GDEF456ABC789GHI012JKL345MNO678PQR901STU234VWX567YZA123BCD"
+ * tokenAddress:
+ * type: string
+ * description: Token contract address
+ * example: "CBCD789EFG012HIJ345KLM678NOP901QRS234TUV567WXY890ZAB123CDE"
+ * ratePerSecond:
+ * type: string
+ * description: Payment rate per second (i128 as string)
+ * example: "100"
+ * depositedAmount:
+ * type: string
+ * description: Total deposited amount (i128 as string)
+ * example: "10000"
+ * startTime:
+ * type: integer
+ * description: Stream start time (Unix timestamp)
+ * example: 1708531200
* responses:
* 201:
* description: Stream created successfully
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Stream'
* 400:
* description: Invalid input data
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized - missing or invalid authentication token
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 403:
+ * description: Forbidden - sender does not match the authenticated wallet, or stream is owned by another wallet
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
* 429:
* description: Too Many Requests - rate limit exceeded (10 requests per minute)
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 500:
+ * description: Internal server error
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
*/
router.post('/', requireAuth, streamCreationRateLimiter, createStream);
@@ -46,7 +110,74 @@ router.post('/', requireAuth, streamCreationRateLimiter, createStream);
* tags:
* - Streams
* summary: List payment streams
- * description: Retrieve a list of payment streams with optional filtering.
+ * description: Retrieve a list of payment streams with optional filtering, sorting, and pagination.
+ * parameters:
+ * - in: query
+ * name: sender
+ * schema: { type: string }
+ * description: Filter by sender public key
+ * - in: query
+ * name: recipient
+ * schema: { type: string }
+ * description: Filter by recipient public key
+ * - in: query
+ * name: status
+ * schema:
+ * type: string
+ * enum: [active, cancelled, completed, paused]
+ * description: Filter by stream status
+ * - in: query
+ * name: token
+ * schema: { type: string }
+ * description: Filter by token contract address
+ * - in: query
+ * name: sort
+ * schema:
+ * type: string
+ * enum: [createdAt, startTime, lastUpdateTime, depositedAmount, endTime]
+ * default: createdAt
+ * description: Sort field
+ * - in: query
+ * name: order
+ * schema:
+ * type: string
+ * enum: [asc, desc]
+ * default: desc
+ * description: Sort order
+ * - in: query
+ * name: limit
+ * schema:
+ * type: integer
+ * default: 20
+ * minimum: 1
+ * maximum: 100
+ * description: Max results per page (capped at 100)
+ * - in: query
+ * name: offset
+ * schema:
+ * type: integer
+ * default: 0
+ * minimum: 0
+ * description: Number of results to skip
+ * responses:
+ * 200:
+ * description: Paginated list of streams
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/StreamListResponse'
+ * 400:
+ * description: Invalid status or pagination parameters
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 500:
+ * description: Internal server error
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
*/
router.get('/', listStreams);
@@ -57,6 +188,32 @@ router.get('/', listStreams);
* tags:
* - Streams
* summary: Get user stream summary
+ * description: Aggregate dashboard/profile summary for a wallet address. Cached for 30 seconds.
+ * parameters:
+ * - in: path
+ * name: address
+ * required: true
+ * schema: { type: string }
+ * description: Stellar public key
+ * responses:
+ * 200:
+ * description: User stream summary
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/UserStreamSummary'
+ * 400:
+ * description: Address is required
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 500:
+ * description: Internal server error
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
*/
router.get('/summary/:address', getUserStreamSummary);
@@ -67,6 +224,39 @@ router.get('/summary/:address', getUserStreamSummary);
* tags:
* - Streams
* summary: Get stream details
+ * description: Returns a single stream. Falls back to live on-chain data when the DB record is missing or stale.
+ * parameters:
+ * - in: path
+ * name: streamId
+ * required: true
+ * schema:
+ * type: integer
+ * description: On-chain stream ID
+ * responses:
+ * 200:
+ * description: Stream details
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Stream'
+ * 400:
+ * description: Invalid streamId parameter
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 404:
+ * description: Stream not found
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 500:
+ * description: Internal server error
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
*/
router.get('/:streamId', getStream);
@@ -104,9 +294,21 @@ router.get('/:streamId', getStream);
* name: eventType
* schema:
* type: string
- * enum: [CREATED, TOPPED_UP, WITHDRAWN, CANCELLED, COMPLETED, PAUSED, RESUMED, FEE_COLLECTED]
+ * enum: [CREATED, TOPPED_UP, WITHDRAWN, CANCELLED, COMPLETED, PAUSED, RESUMED, FEE_COLLECTED, FEE_CONFIG_UPDATED, ADMIN_TRANSFERRED]
* description: Filter events by type
* - in: query
+ * name: page
+ * schema:
+ * type: integer
+ * default: 1
+ * minimum: 1
+ * description: 1-based page index (offset based). Ignored when `cursor` is set.
+ * - in: query
+ * name: cursor
+ * schema:
+ * type: string
+ * description: Event id cursor for stable pagination (hasMore-aware). Ignored when `offset` is set.
+ * - in: query
* name: order
* schema:
* type: string
@@ -115,46 +317,29 @@ router.get('/:streamId', getStream);
* description: "Sort order by timestamp (default: desc)"
* responses:
* 200:
- * description: Stream events retrieved successfully
- * content:
- * application/json:
- * schema:
- * type: object
- * properties:
- * data:
- * type: array
- * items:
- * type: object
- * properties:
- * id:
- * type: integer
- * streamId:
- * type: integer
- * eventType:
- * type: string
- * transactionHash:
- * type: string
- * ledgerSequence:
- * type: integer
- * timestamp:
- * type: integer
- * metadata:
- * type: string
- * createdAt:
- * type: string
- * format: date-time
- * total:
- * type: integer
- * description: Total number of events matching the filter
- * hasMore:
- * type: boolean
- * description: Whether there are more events available
+ * description: Paginated stream events
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/StreamEventListResponse'
* 400:
* description: Invalid request parameters
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
* 404:
* description: Stream not found
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
* 500:
* description: Internal server error
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
*/
router.get('/:streamId/events', getStreamEvents);
@@ -165,6 +350,45 @@ router.get('/:streamId/events', getStreamEvents);
* tags:
* - Streams
* summary: Get actionable claimable amount for a stream
+ * description: Returns the amount claimable right now (or at an optional timestamp). Uses a 5s-cached computation, with an on-chain fallback when the record is missing or stale.
+ * parameters:
+ * - in: path
+ * name: streamId
+ * required: true
+ * schema:
+ * type: integer
+ * description: On-chain stream ID
+ * - in: query
+ * name: at
+ * schema:
+ * type: integer
+ * minimum: 0
+ * description: Optional Unix timestamp (seconds) to compute the claimable amount at
+ * responses:
+ * 200:
+ * description: Claimable amount
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/ClaimableResponse'
+ * 400:
+ * description: Invalid streamId or `at` parameter
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 404:
+ * description: Stream not found
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 500:
+ * description: Internal server error
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
*/
router.get('/:streamId/claimable', getStreamClaimableAmount);
@@ -188,14 +412,46 @@ router.get('/:streamId/claimable', getStreamClaimableAmount);
* responses:
* 200:
* description: Stream paused successfully
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/PauseResumeResponse'
+ * 400:
+ * description: Invalid streamId, or on-chain pause simulation failed
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized - missing or invalid authentication
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
* 403:
* description: Forbidden - caller is not the stream sender
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
* 404:
* description: Stream not found
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
* 409:
* description: Conflict - stream already paused or inactive
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 500:
+ * description: Internal server error
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
*/
router.post('/:streamId/pause', requireAuth, pauseStream);
@@ -219,14 +475,46 @@ router.post('/:streamId/pause', requireAuth, pauseStream);
* responses:
* 200:
* description: Stream resumed successfully
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/PauseResumeResponse'
+ * 400:
+ * description: Invalid streamId, or on-chain resume simulation failed
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized - missing or invalid authentication
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
* 403:
* description: Forbidden - caller is not the stream sender
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
* 404:
* description: Stream not found
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
* 409:
* description: Conflict - stream not paused or inactive
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 500:
+ * description: Internal server error
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
*/
router.post('/:streamId/resume', requireAuth, resumeStream);
@@ -250,14 +538,46 @@ router.post('/:streamId/resume', requireAuth, resumeStream);
* responses:
* 200:
* description: Withdrawal submitted successfully
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/WithdrawResponse'
+ * 400:
+ * description: Invalid streamId or contract revert
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized - missing or invalid authentication
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
* 403:
* description: Forbidden - caller is not the stream recipient
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
* 404:
* description: Stream not found
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
* 409:
* description: Conflict - no claimable balance available
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 500:
+ * description: Internal server error
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
*/
router.post('/:streamId/withdraw', requireAuth, withdrawHandler as any);
@@ -297,24 +617,107 @@ router.post('/:streamId/withdraw', requireAuth, withdrawHandler as any);
* content:
* application/json:
* schema:
- * type: object
- * properties:
- * txHash:
- * type: string
- * streamId:
- * type: integer
- * newDepositedAmount:
- * type: string
+ * $ref: '#/components/schemas/TopUpResponse'
* 400:
* description: Invalid request — amount missing or not a positive integer string
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized - missing or invalid authentication token
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
* 403:
* description: Forbidden - caller is not the stream sender
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
* 404:
* description: Stream not found
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 409:
+ * description: Conflict - stream inactive or paused
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 500:
+ * description: Internal server error
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
*/
router.post('/:streamId/top-up', requireAuth, topUpStreamHandler);
+
+/**
+ * @openapi
+ * /v1/streams/{streamId}/cancel:
+ * post:
+ * tags:
+ * - Streams
+ * summary: Cancel an active payment stream
+ * description: Cancels an active payment stream. Only the sender can cancel; accrued tokens go to the recipient and the remainder is refunded to the sender.
+ * security:
+ * - bearerAuth: []
+ * parameters:
+ * - in: path
+ * name: streamId
+ * required: true
+ * schema:
+ * type: integer
+ * description: On-chain stream ID
+ * responses:
+ * 200:
+ * description: Stream cancelled successfully
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/CancelResponse'
+ * 400:
+ * description: Invalid streamId or transaction simulation failed
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 401:
+ * description: Unauthorized - missing or invalid authentication
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 403:
+ * description: Forbidden - only the sender can cancel
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 404:
+ * description: Stream not found
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 409:
+ * description: Stream already cancelled or completed
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 500:
+ * description: Internal server error
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ */
router.post('/:streamId/cancel', requireAuth, cancelStreamHandler as any);
export default router;
diff --git a/backend/src/routes/v1/streams/withdraw.ts b/backend/src/routes/v1/streams/withdraw.ts
index b0d6d943..45801099 100644
--- a/backend/src/routes/v1/streams/withdraw.ts
+++ b/backend/src/routes/v1/streams/withdraw.ts
@@ -29,18 +29,7 @@ import { parseStreamId } from '../../../lib/stream-id.js';
* content:
* application/json:
* schema:
- * type: object
- * properties:
- * success:
- * type: boolean
- * streamId:
- * type: integer
- * txHash:
- * type: string
- * amount:
- * type: string
- * stream:
- * $ref: '#/components/schemas/Stream'
+ * $ref: '#/components/schemas/WithdrawResponse'
* 400:
* description: Invalid streamId or contract revert
* 401:
@@ -51,6 +40,8 @@ import { parseStreamId } from '../../../lib/stream-id.js';
* description: Stream not found
* 409:
* description: Conflict - no claimable balance available
+ * 500:
+ * description: Internal server error
*/
export const withdrawHandler = async (req: AuthenticatedRequest, res: Response) => {
try {
diff --git a/backend/src/routes/v1/user.routes.ts b/backend/src/routes/v1/user.routes.ts
index 0bb63d66..8c61fcec 100644
--- a/backend/src/routes/v1/user.routes.ts
+++ b/backend/src/routes/v1/user.routes.ts
@@ -47,6 +47,16 @@ const router = Router();
* $ref: '#/components/schemas/User'
* 400:
* description: Invalid request body
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 500:
+ * description: Internal server error
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
*
* /v1/users/{publicKey}:
* get:
@@ -70,6 +80,16 @@ const router = Router();
* $ref: '#/components/schemas/User'
* 404:
* description: User not found
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 500:
+ * description: Internal server error
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
*
* /v1/users/me:
* get:
@@ -88,6 +108,16 @@ const router = Router();
* $ref: '#/components/schemas/User'
* 401:
* description: Unauthorized - invalid or missing token
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 500:
+ * description: Internal server error
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
*/
router.post("/", registerUser);
router.get("/me", requireAuth, getCurrentUser);
@@ -117,22 +147,19 @@ router.get("/me", requireAuth, getCurrentUser);
* content:
* application/json:
* schema:
- * type: object
- * properties:
- * address:
- * type: string
- * totalStreamsCreated:
- * type: integer
- * totalStreamedOut:
- * type: string
- * totalStreamedIn:
- * type: string
- * currentClaimable:
- * type: string
- * activeOutgoingCount:
- * type: integer
- * activeIncomingCount:
- * type: integer
+ * $ref: '#/components/schemas/UserStreamSummary'
+ * 400:
+ * description: Address is required
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 500:
+ * description: Internal server error
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
*/
router.get("/:address/summary", getUserStreamSummary);
router.get("/:publicKey", getUser);
@@ -171,22 +198,25 @@ router.get("/:publicKey", getUser);
* content:
* application/json:
* schema:
- * type: object
- * properties:
- * data:
- * type: array
- * items:
- * $ref: '#/components/schemas/StreamEvent'
- * total:
- * type: integer
- * hasMore:
- * type: boolean
- * limit:
- * type: integer
- * offset:
- * type: integer
+ * $ref: '#/components/schemas/UserEventListResponse'
+ * 400:
+ * description: Invalid pagination parameters
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
* 404:
* description: User not found
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 500:
+ * description: Internal server error
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
*/
router.get("/:publicKey/events", getUserEvents);
@@ -251,5 +281,21 @@ export default router;
* type: object
* 400:
* description: Invalid parameters
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 404:
+ * description: User not found
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 500:
+ * description: Internal server error
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
*/
router.get("/:address/export", exportTransactions);
diff --git a/backend/src/routes/v1/webhook.routes.ts b/backend/src/routes/v1/webhook.routes.ts
index 9aa88bd3..1a4b6cb3 100644
--- a/backend/src/routes/v1/webhook.routes.ts
+++ b/backend/src/routes/v1/webhook.routes.ts
@@ -7,10 +7,11 @@ import * as webhookController from "../../controllers/webhook.controller.js";
const router = Router();
/**
- * @swagger
- * /api/v1/webhooks:
+ * @openapi
+ * /v1/webhooks:
* post:
* summary: Register a new webhook subscription
+ * description: Creates a webhook subscription. The returned `secretKey` is only shown once.
* tags: [Webhooks]
* requestBody:
* required: true
@@ -25,21 +26,48 @@ const router = Router();
* properties:
* userAddress:
* type: string
+ * description: Stellar public key
* targetUrl:
* type: string
+ * format: uri
* eventTypes:
* type: array
* items:
* type: string
+ * enum: [CREATED, TOPPED_UP, WITHDRAWN, CANCELLED, COMPLETED, PAUSED, RESUMED, FEE_COLLECTED]
* responses:
* 201:
* description: Webhook created successfully
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * subscription:
+ * $ref: '#/components/schemas/WebhookSubscription'
+ * secretKey:
+ * type: string
+ * description: Webhook signing secret (returned only once)
+ * message:
+ * type: string
+ * 400:
+ * description: Missing required fields
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 500:
+ * description: Internal server error
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
*/
router.post("/", webhookController.createWebhook);
/**
- * @swagger
- * /api/v1/webhooks:
+ * @openapi
+ * /v1/webhooks:
* get:
* summary: List all webhooks for authenticated user
* tags: [Webhooks]
@@ -52,12 +80,33 @@ router.post("/", webhookController.createWebhook);
* responses:
* 200:
* description: List of webhook subscriptions
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * subscriptions:
+ * type: array
+ * items:
+ * $ref: '#/components/schemas/WebhookSubscription'
+ * 400:
+ * description: Missing userAddress
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 500:
+ * description: Internal server error
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
*/
router.get("/", webhookController.listWebhooks);
/**
- * @swagger
- * /api/v1/webhooks/{id}:
+ * @openapi
+ * /v1/webhooks/{id}:
* delete:
* summary: Delete a webhook subscription
* tags: [Webhooks]
@@ -75,12 +124,24 @@ router.get("/", webhookController.listWebhooks);
* responses:
* 204:
* description: Webhook deleted successfully
+ * 400:
+ * description: Invalid id or missing userAddress
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 500:
+ * description: Internal server error
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
*/
router.delete("/:id", webhookController.deleteWebhook);
/**
- * @swagger
- * /api/v1/webhooks/{id}/test:
+ * @openapi
+ * /v1/webhooks/{id}/test:
* post:
* summary: Send a test ping to a webhook
* tags: [Webhooks]
@@ -104,6 +165,29 @@ router.delete("/:id", webhookController.deleteWebhook);
* responses:
* 200:
* description: Test webhook sent
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * message:
+ * type: string
+ * example: Test webhook sent
+ * result:
+ * type: object
+ * additionalProperties: true
+ * 400:
+ * description: Missing id or userAddress
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 500:
+ * description: Internal server error
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
*/
router.post("/:id/test", webhookController.testWebhook);
diff --git a/backend/swagger/flowfi.openapi.json b/backend/swagger/flowfi.openapi.json
new file mode 100644
index 00000000..245d7961
--- /dev/null
+++ b/backend/swagger/flowfi.openapi.json
@@ -0,0 +1,3507 @@
+{
+ "openapi": "3.1.0",
+ "info": {
+ "title": "FlowFi API",
+ "version": "1.0.0",
+ "description": "API documentation for FlowFi - Real-time payment streaming on Stellar\n\n## Performance & Caching\nThe API implements caching for frequently accessed endpoints, such as claimable amount calculations. \n- **Claimable Cache TTL**: 5 seconds\n- **Invalidation**: Automatically cleared when a withdrawal event occurs.\n\n## Sandbox Mode\n\nFlowFi API supports sandbox mode for testing without affecting production data.\n\n**Enable Sandbox Mode:**\n- Header: `X-Sandbox-Mode: true`\n- Query Parameter: `?sandbox=true`\n\n**Sandbox Features:**\n- Isolated database (separate from production)\n- All responses include `_sandbox` metadata\n- Response headers include `X-Sandbox-Mode: true`\n- Safe for testing and development\n\nSee [Sandbox Mode Documentation](../docs/SANDBOX_MODE.md) for details.",
+ "contact": {
+ "name": "FlowFi Team",
+ "url": "https://github.com/LabsCrypt/flowfi"
+ },
+ "license": {
+ "name": "MIT",
+ "url": "https://opensource.org/licenses/MIT"
+ }
+ },
+ "servers": [
+ {
+ "url": "http://localhost:3001/v1",
+ "description": "Development server (v1)"
+ },
+ {
+ "url": "https://api.flowfi.io/v1",
+ "description": "Production server (v1)"
+ }
+ ],
+ "tags": [
+ {
+ "name": "Health",
+ "description": "Health check endpoints"
+ },
+ {
+ "name": "Users",
+ "description": "User management endpoints"
+ },
+ {
+ "name": "Streams",
+ "description": "Payment stream management endpoints"
+ },
+ {
+ "name": "Events",
+ "description": "Stream event tracking endpoints"
+ },
+ {
+ "name": "Admin",
+ "description": "Administrative and monitoring endpoints"
+ }
+ ],
+ "components": {
+ "securitySchemes": {
+ "BearerAuth": {
+ "type": "http",
+ "scheme": "bearer",
+ "bearerFormat": "JWT",
+ "description": "JSON Web Token issued by /v1/auth/verify after completing the SEP-10 challenge flow."
+ },
+ "bearerAuth": {
+ "type": "http",
+ "scheme": "bearer",
+ "bearerFormat": "JWT",
+ "description": "Alias for BearerAuth — used by route-level security annotations."
+ },
+ "adminAuth": {
+ "type": "http",
+ "scheme": "bearer",
+ "bearerFormat": "JWT",
+ "description": "Admin JWT — the token subject must match ADMIN_PUBLIC_KEY."
+ }
+ },
+ "schemas": {
+ "User": {
+ "type": "object",
+ "required": [
+ "id",
+ "publicKey"
+ ],
+ "properties": {
+ "id": {
+ "type": "string",
+ "format": "uuid",
+ "description": "Unique identifier for the user",
+ "example": "550e8400-e29b-41d4-a716-446655440000"
+ },
+ "publicKey": {
+ "type": "string",
+ "description": "Stellar public key (G...)",
+ "example": "GABC123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA"
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "User creation timestamp"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "Last update timestamp"
+ }
+ }
+ },
+ "Stream": {
+ "type": "object",
+ "required": [
+ "id",
+ "streamId",
+ "sender",
+ "recipient",
+ "tokenAddress",
+ "ratePerSecond"
+ ],
+ "properties": {
+ "id": {
+ "type": "string",
+ "format": "uuid",
+ "description": "Database UUID"
+ },
+ "streamId": {
+ "type": "integer",
+ "description": "On-chain stream ID",
+ "example": 1
+ },
+ "sender": {
+ "type": "string",
+ "description": "Sender Stellar public key",
+ "example": "GABC123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA"
+ },
+ "recipient": {
+ "type": "string",
+ "description": "Recipient Stellar public key",
+ "example": "GDEF456ABC789GHI012JKL345MNO678PQR901STU234VWX567YZA123BCD"
+ },
+ "tokenAddress": {
+ "type": "string",
+ "description": "Token contract address",
+ "example": "CBCD789EFG012HIJ345KLM678NOP901QRS234TUV567WXY890ZAB123CDE"
+ },
+ "ratePerSecond": {
+ "type": "string",
+ "description": "Payment rate per second (i128 as string)",
+ "example": "100"
+ },
+ "depositedAmount": {
+ "type": "string",
+ "description": "Total deposited amount (i128 as string)",
+ "example": "10000"
+ },
+ "withdrawnAmount": {
+ "type": "string",
+ "description": "Total withdrawn amount (i128 as string)",
+ "example": "2500"
+ },
+ "startTime": {
+ "type": "integer",
+ "description": "Stream start time (Unix timestamp)",
+ "example": 1708531200
+ },
+ "lastUpdateTime": {
+ "type": "integer",
+ "description": "Last update time (Unix timestamp)",
+ "example": 1708534800
+ },
+ "isActive": {
+ "type": "boolean",
+ "description": "Stream active status",
+ "example": true
+ },
+ "isPaused": {
+ "type": "boolean",
+ "description": "Whether the stream is currently paused",
+ "example": false
+ },
+ "pausedAt": {
+ "type": "integer",
+ "nullable": true,
+ "description": "Ledger timestamp when the stream was last paused (Unix), null if not paused",
+ "example": null
+ },
+ "totalPausedDuration": {
+ "type": "integer",
+ "description": "Cumulative seconds the stream has spent paused",
+ "example": 0
+ },
+ "endTime": {
+ "type": "integer",
+ "nullable": true,
+ "description": "Ledger timestamp when the stream ended (Unix), null if still active",
+ "example": null
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "updatedAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
+ "StreamEvent": {
+ "type": "object",
+ "required": [
+ "id",
+ "streamId",
+ "eventType",
+ "transactionHash",
+ "ledgerSequence",
+ "timestamp"
+ ],
+ "properties": {
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "streamId": {
+ "type": "integer",
+ "description": "Reference to stream ID"
+ },
+ "eventType": {
+ "type": "string",
+ "enum": [
+ "CREATED",
+ "TOPPED_UP",
+ "WITHDRAWN",
+ "CANCELLED",
+ "COMPLETED",
+ "PAUSED",
+ "RESUMED",
+ "FEE_COLLECTED"
+ ],
+ "description": "Type of stream event",
+ "example": "TOPPED_UP"
+ },
+ "amount": {
+ "type": "string",
+ "nullable": true,
+ "description": "Amount involved in event (i128 as string)",
+ "example": "5000"
+ },
+ "transactionHash": {
+ "type": "string",
+ "description": "Stellar transaction hash",
+ "example": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6"
+ },
+ "ledgerSequence": {
+ "type": "integer",
+ "description": "Ledger sequence number",
+ "example": 12345678
+ },
+ "timestamp": {
+ "type": "integer",
+ "description": "Event timestamp (Unix)",
+ "example": 1708531200
+ },
+ "metadata": {
+ "type": "string",
+ "nullable": true,
+ "description": "Additional event data (JSON string)"
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
+ "StreamListResponse": {
+ "type": "object",
+ "required": [
+ "data",
+ "total",
+ "hasMore",
+ "limit",
+ "offset"
+ ],
+ "properties": {
+ "data": {
+ "type": "array",
+ "description": "Streams matching the filter, sorted and paginated",
+ "items": {
+ "$ref": "#/components/schemas/Stream"
+ }
+ },
+ "total": {
+ "type": "integer",
+ "description": "Total number of streams matching the filter"
+ },
+ "hasMore": {
+ "type": "boolean",
+ "description": "Whether more results are available past this page"
+ },
+ "limit": {
+ "type": "integer",
+ "description": "Page size applied (capped at MAX_STREAM_PAGE_SIZE)"
+ },
+ "offset": {
+ "type": "integer",
+ "description": "Number of results skipped"
+ }
+ }
+ },
+ "StreamEventListResponse": {
+ "type": "object",
+ "required": [
+ "data",
+ "total",
+ "hasMore"
+ ],
+ "properties": {
+ "data": {
+ "type": "array",
+ "description": "Events for the stream, sorted by timestamp (tie-broken by id)",
+ "items": {
+ "$ref": "#/components/schemas/StreamEvent"
+ }
+ },
+ "total": {
+ "type": "integer",
+ "description": "Total number of events matching the filter"
+ },
+ "hasMore": {
+ "type": "boolean",
+ "description": "Whether more results are available past this page"
+ }
+ }
+ },
+ "EventListResponse": {
+ "type": "object",
+ "required": [
+ "events",
+ "total",
+ "limit",
+ "offset",
+ "hasMore"
+ ],
+ "properties": {
+ "events": {
+ "type": "array",
+ "description": "Reverse-chronological stream events for the wallet",
+ "items": {
+ "$ref": "#/components/schemas/StreamEvent"
+ }
+ },
+ "total": {
+ "type": "integer",
+ "description": "Total number of matching events"
+ },
+ "limit": {
+ "type": "integer",
+ "description": "Page size applied (capped at 200)"
+ },
+ "offset": {
+ "type": "integer",
+ "description": "Number of events skipped"
+ },
+ "hasMore": {
+ "type": "boolean",
+ "description": "Whether more results are available past this page"
+ }
+ }
+ },
+ "UserEventListResponse": {
+ "type": "object",
+ "required": [
+ "data",
+ "total",
+ "hasMore",
+ "limit",
+ "offset"
+ ],
+ "properties": {
+ "data": {
+ "type": "array",
+ "description": "Events associated with the user, newest first",
+ "items": {
+ "$ref": "#/components/schemas/StreamEvent"
+ }
+ },
+ "total": {
+ "type": "integer",
+ "description": "Total number of matching events"
+ },
+ "hasMore": {
+ "type": "boolean",
+ "description": "Whether more results are available past this page"
+ },
+ "limit": {
+ "type": "integer",
+ "description": "Page size applied (capped at 200)"
+ },
+ "offset": {
+ "type": "integer",
+ "description": "Number of events skipped"
+ }
+ }
+ },
+ "UserStreamSummary": {
+ "type": "object",
+ "required": [
+ "address",
+ "totalStreamsCreated",
+ "totalStreamedOut",
+ "totalStreamedIn",
+ "currentClaimable",
+ "activeOutgoingCount",
+ "activeIncomingCount"
+ ],
+ "properties": {
+ "address": {
+ "type": "string",
+ "description": "Stellar public key"
+ },
+ "totalStreamsCreated": {
+ "type": "integer",
+ "description": "Number of streams this wallet sent"
+ },
+ "totalStreamedOut": {
+ "type": "string",
+ "description": "Sum of withdrawn amounts on outgoing streams (i128 as string)"
+ },
+ "totalStreamedIn": {
+ "type": "string",
+ "description": "Sum of withdrawn amounts on incoming streams (i128 as string)"
+ },
+ "currentClaimable": {
+ "type": "string",
+ "description": "Total currently claimable across active incoming streams (i128 as string)"
+ },
+ "activeOutgoingCount": {
+ "type": "integer"
+ },
+ "activeIncomingCount": {
+ "type": "integer"
+ },
+ "truncated": {
+ "type": "boolean",
+ "description": "True when the number of streams was capped at MAX_USER_STREAMS per direction",
+ "example": false
+ }
+ }
+ },
+ "ClaimableResponse": {
+ "type": "object",
+ "required": [
+ "claimableAmount",
+ "actionable",
+ "calculatedAt"
+ ],
+ "properties": {
+ "streamId": {
+ "type": "integer",
+ "description": "On-chain stream ID"
+ },
+ "ratePerSecond": {
+ "type": "string",
+ "description": "Payment rate per second (i128 as string)"
+ },
+ "depositedAmount": {
+ "type": "string",
+ "description": "Total deposited amount (i128 as string)"
+ },
+ "withdrawnAmount": {
+ "type": "string",
+ "description": "Total withdrawn amount (i128 as string)"
+ },
+ "startTime": {
+ "type": "integer",
+ "description": "Stream start time (Unix timestamp)"
+ },
+ "lastUpdateTime": {
+ "type": "integer",
+ "description": "Last state update time (Unix timestamp)"
+ },
+ "claimableAmount": {
+ "type": "string",
+ "description": "Amount claimable at the requested time (i128 as string)"
+ },
+ "actionable": {
+ "type": "boolean",
+ "description": "Whether the claimable amount is positive"
+ },
+ "calculatedAt": {
+ "type": "integer",
+ "description": "Unix timestamp of the calculation"
+ },
+ "cached": {
+ "type": "boolean",
+ "description": "Whether the value came from cache or a fresh computation"
+ },
+ "source": {
+ "type": "string",
+ "enum": [
+ "db",
+ "chain"
+ ],
+ "description": "Where the value was computed from"
+ }
+ }
+ },
+ "PauseResumeResponse": {
+ "type": "object",
+ "required": [
+ "success",
+ "streamId",
+ "txHash"
+ ],
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "example": true
+ },
+ "streamId": {
+ "type": "integer"
+ },
+ "txHash": {
+ "type": "string",
+ "description": "Stellar transaction hash of the pause/resume simulation"
+ },
+ "stream": {
+ "$ref": "#/components/schemas/Stream"
+ }
+ }
+ },
+ "TopUpResponse": {
+ "type": "object",
+ "required": [
+ "streamId",
+ "txHash",
+ "depositedAmount"
+ ],
+ "properties": {
+ "streamId": {
+ "type": "integer"
+ },
+ "txHash": {
+ "type": "string",
+ "description": "Stellar transaction hash"
+ },
+ "depositedAmount": {
+ "type": "string",
+ "description": "New total deposited amount after the top-up (i128 as string)"
+ }
+ }
+ },
+ "WithdrawResponse": {
+ "type": "object",
+ "required": [
+ "success",
+ "streamId",
+ "txHash",
+ "amount"
+ ],
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "example": true
+ },
+ "streamId": {
+ "type": "integer"
+ },
+ "txHash": {
+ "type": "string",
+ "description": "Stellar transaction hash of the withdrawal"
+ },
+ "amount": {
+ "type": "string",
+ "description": "Amount withdrawn (i128 as string)"
+ },
+ "stream": {
+ "$ref": "#/components/schemas/Stream"
+ }
+ }
+ },
+ "CancelResponse": {
+ "type": "object",
+ "required": [
+ "txHash",
+ "status"
+ ],
+ "properties": {
+ "txHash": {
+ "type": "string",
+ "description": "Stellar transaction hash of the cancel"
+ },
+ "status": {
+ "type": "string",
+ "enum": [
+ "CANCELLED"
+ ],
+ "example": "CANCELLED"
+ }
+ }
+ },
+ "AuthChallengeResponse": {
+ "type": "object",
+ "required": [
+ "nonce",
+ "expiresAt"
+ ],
+ "properties": {
+ "nonce": {
+ "type": "string",
+ "description": "Hex-encoded nonce to sign via a Stellar manage_data operation"
+ },
+ "expiresAt": {
+ "type": "integer",
+ "description": "Unix timestamp (ms) when the challenge expires (60s)"
+ }
+ }
+ },
+ "AuthVerifyResponse": {
+ "type": "object",
+ "required": [
+ "token",
+ "expiresIn"
+ ],
+ "properties": {
+ "token": {
+ "type": "string",
+ "description": "JWT to use in the Authorization: Bearer header"
+ },
+ "expiresIn": {
+ "type": "integer",
+ "description": "Token lifetime in seconds (3600)"
+ }
+ }
+ },
+ "SseStats": {
+ "type": "object",
+ "required": [
+ "activeConnections",
+ "activeIps",
+ "perIpPeakConnections",
+ "maxConnections",
+ "timestamp"
+ ],
+ "properties": {
+ "activeConnections": {
+ "type": "integer",
+ "example": 42
+ },
+ "activeIps": {
+ "type": "integer",
+ "example": 8
+ },
+ "perIpPeakConnections": {
+ "type": "integer",
+ "example": 5
+ },
+ "maxConnections": {
+ "type": "integer",
+ "example": 10000
+ },
+ "timestamp": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
+ "WebhookSubscription": {
+ "type": "object",
+ "required": [
+ "id",
+ "userAddress",
+ "targetUrl",
+ "eventTypes",
+ "active",
+ "createdAt"
+ ],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Webhook subscription id"
+ },
+ "userAddress": {
+ "type": "string",
+ "description": "Stellar public key the subscription belongs to"
+ },
+ "targetUrl": {
+ "type": "string",
+ "description": "HTTPS endpoint receiving the events"
+ },
+ "eventTypes": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": [
+ "CREATED",
+ "TOPPED_UP",
+ "WITHDRAWN",
+ "CANCELLED",
+ "COMPLETED",
+ "PAUSED",
+ "RESUMED",
+ "FEE_COLLECTED"
+ ]
+ }
+ },
+ "active": {
+ "type": "boolean"
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
+ "HealthResponse": {
+ "type": "object",
+ "required": [
+ "status",
+ "db",
+ "indexerEnabled",
+ "uptime",
+ "checks"
+ ],
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": [
+ "ok",
+ "degraded"
+ ],
+ "example": "ok"
+ },
+ "db": {
+ "type": "string",
+ "enum": [
+ "connected",
+ "disconnected"
+ ],
+ "example": "connected"
+ },
+ "indexerEnabled": {
+ "type": "boolean",
+ "description": "Whether the event indexer is configured"
+ },
+ "indexerLag": {
+ "type": "integer",
+ "nullable": true,
+ "description": "Seconds since last indexer update, or null when no state row exists yet"
+ },
+ "eventsProcessed": {
+ "type": "integer",
+ "description": "Lifetime count of successfully processed indexer events"
+ },
+ "eventsFailed": {
+ "type": "integer",
+ "description": "Lifetime count of indexer events that threw during processing"
+ },
+ "lastErrorAt": {
+ "type": "string",
+ "format": "date-time",
+ "nullable": true,
+ "description": "Most recent per-event processing failure"
+ },
+ "indexerDegraded": {
+ "type": "boolean",
+ "description": "True when recent event-processing failure rate spikes"
+ },
+ "uptime": {
+ "type": "number",
+ "description": "Server uptime in seconds"
+ },
+ "checks": {
+ "type": "object",
+ "description": "Per-subsystem status breakdown",
+ "properties": {
+ "database": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": [
+ "ok",
+ "down"
+ ]
+ }
+ }
+ },
+ "indexer": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": [
+ "ok",
+ "degraded",
+ "disabled"
+ ]
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "lagSeconds": {
+ "type": "integer",
+ "nullable": true
+ }
+ }
+ },
+ "redis": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": [
+ "ok",
+ "unavailable",
+ "not_configured"
+ ]
+ }
+ }
+ },
+ "sorobanRpc": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "enum": [
+ "ok",
+ "down"
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "Error": {
+ "type": "object",
+ "properties": {
+ "error": {
+ "type": "string",
+ "description": "Error message",
+ "example": "Resource not found"
+ },
+ "code": {
+ "type": "string",
+ "description": "Error code",
+ "example": "NOT_FOUND"
+ },
+ "message": {
+ "type": "string",
+ "nullable": true,
+ "description": "Human-readable detail (present on many error responses)"
+ },
+ "details": {
+ "type": "array",
+ "nullable": true,
+ "description": "Structured validation issues (zod) when the error is a 400",
+ "items": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ }
+ },
+ "paths": {
+ "/": {
+ "get": {
+ "tags": [
+ "Health"
+ ],
+ "summary": "Simple health check",
+ "description": "Returns a simple message to verify the API is running",
+ "responses": {
+ "200": {
+ "description": "API is running successfully"
+ }
+ }
+ }
+ },
+ "/health": {
+ "get": {
+ "tags": [
+ "Health"
+ ],
+ "summary": "Detailed health check",
+ "description": "Returns liveness and readiness information.\n**Liveness** (200 vs 503) is determined by DB reachability alone.\n**Indexer lag** is reported in the body for observability but only\nforces a 503 when the indexer is actually enabled\n(`STREAM_CONTRACT_ID` env var set) and its state row is stale\n(lag > 60 s). A cold-started instance with no state row yet, or a\ndeployment with the indexer intentionally disabled, always returns 200\nas long as the DB is reachable.\n**Event-processing failures** are also reported. When the indexer is\nenabled and recent per-event failures spike (≥50% of attempts in the\nlast 5 minutes, with ≥3 samples), the endpoint returns 503 even if\nlag looks healthy (the IndexerState upsert bumps updatedAt every poll).\n",
+ "responses": {
+ "200": {
+ "description": "Service is healthy",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HealthResponse"
+ }
+ }
+ }
+ },
+ "503": {
+ "description": "Service is degraded or unhealthy",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HealthResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/webhooks": {
+ "post": {
+ "summary": "Register a new webhook subscription",
+ "description": "Creates a webhook subscription. The returned `secretKey` is only shown once.",
+ "tags": [
+ "Webhooks"
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "userAddress",
+ "targetUrl",
+ "eventTypes"
+ ],
+ "properties": {
+ "userAddress": {
+ "type": "string",
+ "description": "Stellar public key"
+ },
+ "targetUrl": {
+ "type": "string",
+ "format": "uri"
+ },
+ "eventTypes": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": [
+ "CREATED",
+ "TOPPED_UP",
+ "WITHDRAWN",
+ "CANCELLED",
+ "COMPLETED",
+ "PAUSED",
+ "RESUMED",
+ "FEE_COLLECTED"
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Webhook created successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "subscription": {
+ "$ref": "#/components/schemas/WebhookSubscription"
+ },
+ "secretKey": {
+ "type": "string",
+ "description": "Webhook signing secret (returned only once)"
+ },
+ "message": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Missing required fields",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ },
+ "get": {
+ "summary": "List all webhooks for authenticated user",
+ "tags": [
+ "Webhooks"
+ ],
+ "parameters": [
+ {
+ "in": "query",
+ "name": "userAddress",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "List of webhook subscriptions",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "subscriptions": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/WebhookSubscription"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Missing userAddress",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/webhooks/{id}": {
+ "delete": {
+ "summary": "Delete a webhook subscription",
+ "tags": [
+ "Webhooks"
+ ],
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "userAddress",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "Webhook deleted successfully"
+ },
+ "400": {
+ "description": "Invalid id or missing userAddress",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/webhooks/{id}/test": {
+ "post": {
+ "summary": "Send a test ping to a webhook",
+ "tags": [
+ "Webhooks"
+ ],
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "userAddress"
+ ],
+ "properties": {
+ "userAddress": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Test webhook sent",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "message": {
+ "type": "string",
+ "example": "Test webhook sent"
+ },
+ "result": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Missing id or userAddress",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/users": {
+ "post": {
+ "tags": [
+ "Users"
+ ],
+ "summary": "Register a wallet public key",
+ "description": "Registers a new Stellar wallet public key or returns the existing user if already registered.",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "publicKey"
+ ],
+ "properties": {
+ "publicKey": {
+ "type": "string",
+ "description": "Stellar public key (G...)",
+ "example": "GABC123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "User already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/User"
+ }
+ }
+ }
+ },
+ "201": {
+ "description": "User registered successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/User"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid request body",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/users/{publicKey}": {
+ "get": {
+ "tags": [
+ "Users"
+ ],
+ "summary": "Fetch a user by public key",
+ "description": "Returns user details along with recent sent and received streams.",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "publicKey",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Stellar public key"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "User found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/User"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "User not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/users/me": {
+ "get": {
+ "tags": [
+ "Users"
+ ],
+ "summary": "Get current authenticated user",
+ "description": "Returns the currently authenticated user's details (protected endpoint)",
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Current user details",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/User"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - invalid or missing token",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/users/{address}/summary": {
+ "get": {
+ "tags": [
+ "Users"
+ ],
+ "summary": "Get aggregate stream summary for a user",
+ "description": "Returns dashboard/profile summary data for a wallet address:\ntotal created streams, total streamed out/in, current claimable across\nactive incoming streams, and active stream counts.\n\nResponse is cached for 30 seconds to reduce DB load.\n",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "address",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Stellar public key address"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "User stream summary",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UserStreamSummary"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Address is required",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/users/{publicKey}/events": {
+ "get": {
+ "tags": [
+ "Users"
+ ],
+ "summary": "Fetch user activity history",
+ "description": "Returns a paginated chronological history of all stream events associated with the user.",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "publicKey",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Stellar public key"
+ },
+ {
+ "in": "query",
+ "name": "limit",
+ "schema": {
+ "type": "integer",
+ "default": 50,
+ "maximum": 200
+ },
+ "description": "Maximum number of events to return"
+ },
+ {
+ "in": "query",
+ "name": "offset",
+ "schema": {
+ "type": "integer",
+ "default": 0
+ },
+ "description": "Number of events to skip for pagination"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Paginated list of user events",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UserEventListResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid pagination parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "User not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/users/{address}/export": {
+ "get": {
+ "tags": [
+ "Users"
+ ],
+ "summary": "Export transaction history for tax and accounting",
+ "description": "Generates CSV or JSON export of stream transactions for QuickBooks, Xero, CoinTracker, etc.",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "address",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Stellar public key"
+ },
+ {
+ "in": "query",
+ "name": "format",
+ "schema": {
+ "type": "string",
+ "enum": [
+ "csv",
+ "json"
+ ],
+ "default": "csv"
+ },
+ "description": "Export format"
+ },
+ {
+ "in": "query",
+ "name": "direction",
+ "schema": {
+ "type": "string",
+ "enum": [
+ "incoming",
+ "outgoing",
+ "all"
+ ],
+ "default": "all"
+ },
+ "description": "Filter by transaction direction"
+ },
+ {
+ "in": "query",
+ "name": "startDate",
+ "schema": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "description": "Start date (ISO 8601 or Unix timestamp)"
+ },
+ {
+ "in": "query",
+ "name": "endDate",
+ "schema": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "description": "End date (ISO 8601 or Unix timestamp)"
+ },
+ {
+ "in": "query",
+ "name": "tokenAddress",
+ "schema": {
+ "type": "string"
+ },
+ "description": "Filter by specific token contract"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Transaction export file",
+ "content": {
+ "text/csv": {
+ "schema": {
+ "type": "string",
+ "format": "binary"
+ }
+ },
+ "application/json": {
+ "schema": {
+ "type": "object"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "User not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/streams": {
+ "post": {
+ "tags": [
+ "Streams"
+ ],
+ "summary": "Create a new payment stream",
+ "description": "Creates or reactivates a payment stream record for the authenticated wallet. The authenticated wallet must be the stream sender.",
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "streamId",
+ "sender",
+ "recipient",
+ "tokenAddress",
+ "ratePerSecond",
+ "depositedAmount",
+ "startTime"
+ ],
+ "properties": {
+ "streamId": {
+ "type": "integer",
+ "description": "On-chain stream ID",
+ "example": 1
+ },
+ "sender": {
+ "type": "string",
+ "description": "Sender Stellar public key — must match the authenticated wallet",
+ "example": "GABC123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA"
+ },
+ "recipient": {
+ "type": "string",
+ "description": "Recipient Stellar public key",
+ "example": "GDEF456ABC789GHI012JKL345MNO678PQR901STU234VWX567YZA123BCD"
+ },
+ "tokenAddress": {
+ "type": "string",
+ "description": "Token contract address",
+ "example": "CBCD789EFG012HIJ345KLM678NOP901QRS234TUV567WXY890ZAB123CDE"
+ },
+ "ratePerSecond": {
+ "type": "string",
+ "description": "Payment rate per second (i128 as string)",
+ "example": "100"
+ },
+ "depositedAmount": {
+ "type": "string",
+ "description": "Total deposited amount (i128 as string)",
+ "example": "10000"
+ },
+ "startTime": {
+ "type": "integer",
+ "description": "Stream start time (Unix timestamp)",
+ "example": 1708531200
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Stream created successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Stream"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid input data",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid authentication token",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - sender does not match the authenticated wallet, or stream is owned by another wallet",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Too Many Requests - rate limit exceeded (10 requests per minute)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ },
+ "get": {
+ "tags": [
+ "Streams"
+ ],
+ "summary": "List payment streams",
+ "description": "Retrieve a list of payment streams with optional filtering, sorting, and pagination.",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "sender",
+ "schema": {
+ "type": "string"
+ },
+ "description": "Filter by sender public key"
+ },
+ {
+ "in": "query",
+ "name": "recipient",
+ "schema": {
+ "type": "string"
+ },
+ "description": "Filter by recipient public key"
+ },
+ {
+ "in": "query",
+ "name": "status",
+ "schema": {
+ "type": "string",
+ "enum": [
+ "active",
+ "cancelled",
+ "completed",
+ "paused"
+ ]
+ },
+ "description": "Filter by stream status"
+ },
+ {
+ "in": "query",
+ "name": "token",
+ "schema": {
+ "type": "string"
+ },
+ "description": "Filter by token contract address"
+ },
+ {
+ "in": "query",
+ "name": "sort",
+ "schema": {
+ "type": "string",
+ "enum": [
+ "createdAt",
+ "startTime",
+ "lastUpdateTime",
+ "depositedAmount",
+ "endTime"
+ ],
+ "default": "createdAt"
+ },
+ "description": "Sort field"
+ },
+ {
+ "in": "query",
+ "name": "order",
+ "schema": {
+ "type": "string",
+ "enum": [
+ "asc",
+ "desc"
+ ],
+ "default": "desc"
+ },
+ "description": "Sort order"
+ },
+ {
+ "in": "query",
+ "name": "limit",
+ "schema": {
+ "type": "integer",
+ "default": 20,
+ "minimum": 1,
+ "maximum": 100
+ },
+ "description": "Max results per page (capped at 100)"
+ },
+ {
+ "in": "query",
+ "name": "offset",
+ "schema": {
+ "type": "integer",
+ "default": 0,
+ "minimum": 0
+ },
+ "description": "Number of results to skip"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Paginated list of streams",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/StreamListResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid status or pagination parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/streams/summary/{address}": {
+ "get": {
+ "tags": [
+ "Streams"
+ ],
+ "summary": "Get user stream summary",
+ "description": "Aggregate dashboard/profile summary for a wallet address. Cached for 30 seconds.",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "address",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Stellar public key"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "User stream summary",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/UserStreamSummary"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Address is required",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/streams/{streamId}": {
+ "get": {
+ "tags": [
+ "Streams"
+ ],
+ "summary": "Get stream details",
+ "description": "Returns a single stream. Falls back to live on-chain data when the DB record is missing or stale.",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "streamId",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "On-chain stream ID"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Stream details",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Stream"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid streamId parameter",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Stream not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/streams/{streamId}/events": {
+ "get": {
+ "tags": [
+ "Streams"
+ ],
+ "summary": "Get stream events",
+ "description": "Retrieve events for a specific stream with pagination, filtering, and sorting.",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "streamId",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "On-chain stream ID"
+ },
+ {
+ "in": "query",
+ "name": "limit",
+ "schema": {
+ "type": "integer",
+ "default": 50,
+ "minimum": 1,
+ "maximum": 200
+ },
+ "description": "Number of events to return per page (default: 50, max: 200)"
+ },
+ {
+ "in": "query",
+ "name": "offset",
+ "schema": {
+ "type": "integer",
+ "default": 0,
+ "minimum": 0
+ },
+ "description": "Number of events to skip (default: 0)"
+ },
+ {
+ "in": "query",
+ "name": "eventType",
+ "schema": {
+ "type": "string",
+ "enum": [
+ "CREATED",
+ "TOPPED_UP",
+ "WITHDRAWN",
+ "CANCELLED",
+ "COMPLETED",
+ "PAUSED",
+ "RESUMED",
+ "FEE_COLLECTED",
+ "FEE_CONFIG_UPDATED",
+ "ADMIN_TRANSFERRED"
+ ]
+ },
+ "description": "Filter events by type"
+ },
+ {
+ "in": "query",
+ "name": "page",
+ "schema": {
+ "type": "integer",
+ "default": 1,
+ "minimum": 1
+ },
+ "description": "1-based page index (offset based). Ignored when `cursor` is set."
+ },
+ {
+ "in": "query",
+ "name": "cursor",
+ "schema": {
+ "type": "string"
+ },
+ "description": "Event id cursor for stable pagination (hasMore-aware). Ignored when `offset` is set."
+ },
+ {
+ "in": "query",
+ "name": "order",
+ "schema": {
+ "type": "string",
+ "enum": [
+ "asc",
+ "desc"
+ ],
+ "default": "desc"
+ },
+ "description": "Sort order by timestamp (default: desc)"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Paginated stream events",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/StreamEventListResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid request parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Stream not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/streams/{streamId}/claimable": {
+ "get": {
+ "tags": [
+ "Streams"
+ ],
+ "summary": "Get actionable claimable amount for a stream",
+ "description": "Returns the amount claimable right now (or at an optional timestamp). Uses a 5s-cached computation, with an on-chain fallback when the record is missing or stale.",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "streamId",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "On-chain stream ID"
+ },
+ {
+ "in": "query",
+ "name": "at",
+ "schema": {
+ "type": "integer",
+ "minimum": 0
+ },
+ "description": "Optional Unix timestamp (seconds) to compute the claimable amount at"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Claimable amount",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ClaimableResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid streamId or `at` parameter",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Stream not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/streams/{streamId}/pause": {
+ "post": {
+ "tags": [
+ "Streams"
+ ],
+ "summary": "Pause a payment stream",
+ "description": "Pause an active stream. Only the sender can pause their own stream.",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "streamId",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "On-chain stream ID"
+ }
+ ],
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Stream paused successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PauseResumeResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid streamId, or on-chain pause simulation failed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid authentication",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - caller is not the stream sender",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Stream not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - stream already paused or inactive",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/streams/{streamId}/resume": {
+ "post": {
+ "tags": [
+ "Streams"
+ ],
+ "summary": "Resume a paused payment stream",
+ "description": "Resume a paused stream. Only the sender can resume their own stream.",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "streamId",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "On-chain stream ID"
+ }
+ ],
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Stream resumed successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PauseResumeResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid streamId, or on-chain resume simulation failed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid authentication",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - caller is not the stream sender",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Stream not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - stream not paused or inactive",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/streams/{streamId}/withdraw": {
+ "post": {
+ "tags": [
+ "Streams"
+ ],
+ "summary": "Withdraw claimable balance from a payment stream",
+ "description": "Withdraws the currently claimable amount. Only the recipient can withdraw.",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "streamId",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "On-chain stream ID"
+ }
+ ],
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Withdrawal submitted successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WithdrawResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid streamId or contract revert",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid authentication",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - caller is not the stream recipient",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Stream not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - no claimable balance available",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/streams/{streamId}/top-up": {
+ "post": {
+ "tags": [
+ "Streams"
+ ],
+ "summary": "Top up a payment stream",
+ "description": "Adds additional funds to an existing active stream. Only the original sender can top up.",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "streamId",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "On-chain stream ID"
+ }
+ ],
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "amount"
+ ],
+ "properties": {
+ "amount": {
+ "type": "string",
+ "description": "Amount to add to the stream deposit (i128 as string)",
+ "example": "5000"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Stream topped up successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TopUpResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid request — amount missing or not a positive integer string",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid authentication token",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - caller is not the stream sender",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Stream not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Conflict - stream inactive or paused",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/streams/{streamId}/cancel": {
+ "post": {
+ "tags": [
+ "Streams"
+ ],
+ "summary": "Cancel an active payment stream",
+ "description": "Cancels an active payment stream on the Stellar network.\nOnly the original sender can cancel the stream.\nAccrued tokens are sent to the recipient, and the remainder is refunded to the sender.\n",
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "parameters": [
+ {
+ "in": "path",
+ "name": "streamId",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ },
+ "description": "On-chain stream ID"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Stream cancelled successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CancelResponse",
+ "type": "object",
+ "properties": {
+ "txHash": {
+ "type": "string"
+ },
+ "status": {
+ "type": "string",
+ "example": "CANCELLED"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid streamId or transaction simulation failed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid authentication",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - only sender can cancel",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Stream not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Stream already cancelled or completed",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/events": {
+ "get": {
+ "tags": [
+ "Events"
+ ],
+ "summary": "List stream events for a wallet (paginated, filterable)",
+ "description": "Returns a reverse-chronological list of stream events where the wallet\nwas either the sender or recipient. Supports event-type filtering and\nlimit/offset pagination — used by the frontend activity timeline.\n",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "address",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Stellar public key (G...)"
+ },
+ {
+ "in": "query",
+ "name": "type",
+ "required": false,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Comma-separated list of event types to include. Allowed values:\nCREATED, TOPPED_UP, WITHDRAWN, CANCELLED, COMPLETED, PAUSED,\nRESUMED, FEE_COLLECTED, FEE_CONFIG_UPDATED, ADMIN_TRANSFERRED.\n"
+ },
+ {
+ "in": "query",
+ "name": "limit",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "default": 50,
+ "maximum": 200
+ }
+ },
+ {
+ "in": "query",
+ "name": "offset",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "default": 0
+ }
+ },
+ {
+ "in": "query",
+ "name": "page",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "default": 1
+ },
+ "description": "Optional 1-based page index. Ignored when offset is set."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Paginated event list",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/EventListResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Missing/invalid `address` or invalid `type` filter",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid authentication token",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - `address` must match the authenticated wallet",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/events/subscribe": {
+ "get": {
+ "tags": [
+ "Events"
+ ],
+ "summary": "Subscribe to real-time stream events",
+ "description": "Establishes a Server-Sent Events (SSE) connection for real-time updates.\n\n**Reconnection Strategy:**\n- Browser automatically reconnects with exponential backoff\n- Initial retry: 1s, max: 30s\n- Client should implement custom reconnection logic for production\n\n**Event Types:**\n- `stream.created` - New stream created\n- `stream.topped_up` - Stream received additional funds\n- `stream.withdrawn` - Funds withdrawn from stream\n- `stream.cancelled` - Stream cancelled\n- `stream.completed` - Stream completed\n\n**Sandbox Mode:**\n- Add header `X-Sandbox-Mode: true` or query parameter `?sandbox=true`\n- Sandbox events are clearly marked with `_sandbox` metadata\n- Sandbox events are isolated from production events\n",
+ "parameters": [
+ {
+ "in": "header",
+ "name": "X-Sandbox-Mode",
+ "schema": {
+ "type": "string",
+ "enum": [
+ "true",
+ "1"
+ ]
+ },
+ "description": "Enable sandbox mode for testing",
+ "required": false
+ },
+ {
+ "in": "query",
+ "name": "sandbox",
+ "schema": {
+ "type": "string",
+ "enum": [
+ "true",
+ "1"
+ ]
+ },
+ "description": "Enable sandbox mode via query parameter",
+ "required": false
+ },
+ {
+ "in": "query",
+ "name": "streams",
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "description": "Array of stream IDs to subscribe to",
+ "example": [
+ "1",
+ "2"
+ ]
+ },
+ {
+ "in": "query",
+ "name": "users",
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "description": "Array of user public keys to subscribe to",
+ "example": [
+ "GABC...",
+ "GDEF..."
+ ]
+ },
+ {
+ "in": "query",
+ "name": "all",
+ "schema": {
+ "type": "boolean"
+ },
+ "description": "Subscribe to all events",
+ "example": false
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "SSE connection established. Events are emitted as `data:` frames of type stream.created, stream.topped_up, stream.withdrawn, stream.cancelled, stream.completed, stream.paused, stream.resumed, fee.collected.",
+ "content": {
+ "text/event-stream": {
+ "schema": {
+ "type": "string",
+ "description": "Server-Sent Events stream; each event carries a JSON payload matching the StreamEvent schema"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid subscription parameters"
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid authentication token"
+ }
+ }
+ }
+ },
+ "/v1/events/stats": {
+ "get": {
+ "tags": [
+ "Events"
+ ],
+ "summary": "Get SSE connection statistics",
+ "description": "Returns current SSE connection metrics for monitoring (admin only)",
+ "security": [
+ {
+ "adminAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Connection statistics",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SseStats"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid authentication token",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - admin access required",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/auth/challenge": {
+ "post": {
+ "tags": [
+ "Auth"
+ ],
+ "summary": "Request a sign challenge for wallet authentication",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "publicKey"
+ ],
+ "properties": {
+ "publicKey": {
+ "type": "string",
+ "example": "GABC..."
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Challenge nonce issued",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AuthChallengeResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid publicKey",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Too many requests",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/auth/verify": {
+ "post": {
+ "tags": [
+ "Auth"
+ ],
+ "summary": "Verify signed challenge and receive JWT",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "publicKey",
+ "signedTransaction"
+ ],
+ "properties": {
+ "publicKey": {
+ "type": "string"
+ },
+ "signedTransaction": {
+ "type": "string",
+ "description": "Base64-encoded XDR signed transaction containing the nonce"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "JWT token",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AuthVerifyResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Missing publicKey or signedTransaction",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Invalid signature or expired challenge",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Too many requests",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/admin/metrics": {
+ "get": {
+ "tags": [
+ "Admin"
+ ],
+ "summary": "Protocol health metrics",
+ "security": [
+ {
+ "adminAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Protocol health metrics",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "total_streams": {
+ "type": "integer"
+ },
+ "active_streams": {
+ "type": "integer"
+ },
+ "paused_streams": {
+ "type": "integer"
+ },
+ "completed_streams": {
+ "type": "integer"
+ },
+ "cancelled_streams": {
+ "type": "integer"
+ },
+ "total_volume_streamed": {
+ "type": "string",
+ "description": "Sum of withdrawn amounts (i128 as string)"
+ },
+ "streams": {
+ "type": "object",
+ "properties": {
+ "active": {
+ "type": "integer"
+ },
+ "paused": {
+ "type": "integer"
+ },
+ "total": {
+ "type": "integer"
+ },
+ "byStatus": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "integer"
+ }
+ }
+ }
+ },
+ "events": {
+ "type": "object",
+ "properties": {
+ "last24h": {
+ "type": "integer"
+ }
+ }
+ },
+ "fees": {
+ "type": "object",
+ "properties": {
+ "totalFeesCollectedByToken": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "feesLast24h": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "sse": {
+ "type": "object",
+ "properties": {
+ "activeConnections": {
+ "type": "integer"
+ }
+ }
+ },
+ "indexer": {
+ "type": "object",
+ "properties": {
+ "lastLedger": {
+ "type": "integer"
+ },
+ "lagSeconds": {
+ "type": "integer",
+ "nullable": true
+ },
+ "lastUpdated": {
+ "type": "string",
+ "format": "date-time",
+ "nullable": true
+ },
+ "eventsProcessed": {
+ "type": "integer"
+ },
+ "eventsFailed": {
+ "type": "integer"
+ },
+ "lastErrorAt": {
+ "type": "string",
+ "nullable": true
+ },
+ "degraded": {
+ "type": "boolean"
+ }
+ }
+ },
+ "cache": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "pgPool": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "uptime": {
+ "type": "number"
+ },
+ "timestamp": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "calculatedAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid authentication token",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - admin access required",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/admin/indexer/status": {
+ "get": {
+ "tags": [
+ "Admin"
+ ],
+ "summary": "Get indexer status",
+ "security": [
+ {
+ "adminAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Indexer status",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid authentication token",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - admin access required",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/admin/indexer/reset": {
+ "post": {
+ "tags": [
+ "Admin"
+ ],
+ "summary": "Reset indexer lastProcessedLedger",
+ "security": [
+ {
+ "adminAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ledger"
+ ],
+ "properties": {
+ "ledger": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Reset successful",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "ok": {
+ "type": "boolean",
+ "example": true
+ },
+ "lastLedger": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid ledger value",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid authentication token",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - admin access required",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/admin/indexer/replay": {
+ "post": {
+ "tags": [
+ "Admin"
+ ],
+ "summary": "Replay events from a given ledger (StreamEvent rows deduplicated; stream mutations not idempotent — see indexerService.ts JSDoc)",
+ "security": [
+ {
+ "adminAuth": []
+ }
+ ],
+ "parameters": [
+ {
+ "in": "query",
+ "name": "from_ledger",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "responses": {
+ "202": {
+ "description": "Replay started",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "ok": {
+ "type": "boolean",
+ "example": true
+ },
+ "replayingFrom": {
+ "type": "integer"
+ },
+ "requestId": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid from_ledger value",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized - missing or invalid authentication token",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden - admin access required",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/contracts/stream_contract/README.md b/contracts/stream_contract/README.md
index c3f05421..d97dba86 100644
--- a/contracts/stream_contract/README.md
+++ b/contracts/stream_contract/README.md
@@ -119,3 +119,31 @@ The Soroban test runner can generate storage snapshots under `test_snapshots/` w
3. Sender may call `top_up_stream`, `pause_stream`, `resume_stream`, or `cancel_stream`.
4. Recipient calls `withdraw` over time until fully drained.
5. Final withdrawal emits `stream_completed`.
+
+## Automated deployment
+
+Deployment to Stellar Testnet/Mainnet is automated via the
+[`deploy-contracts`](../../.github/workflows/deploy-contracts.yml) GitHub Actions workflow:
+
+- **Triggers**
+ - `release` tags (`v*`) published via the GitHub Releases UI → deploys to **Mainnet**.
+ - Manual `workflow_dispatch` runs (Actions → "Deploy Soroban Contracts" → "Run workflow") → **Testnet** by default.
+- **Steps**: installs the Rust `wasm32-unknown-unknown` target and Stellar CLI, runs
+ `cargo test --package stream_contract`, builds + optimizes the WASM, asserts the
+ optimized WASM stays under the **64 KB budget** (`stellar contract inspect`),
+ then deploys and calls `initialize(admin, treasury, fee_rate_bps)` via
+ [`scripts/deploy.sh`](../../scripts/deploy.sh).
+- **Secrets** (repo → Settings → Secrets and variables → Actions):
+
+ | Secret | Description |
+ |---|---|
+ | `DEPLOYER_SECRET` | Secret key of the deployer account |
+ | `ADMIN_ADDRESS` | Admin address passed to `initialize` |
+ | `TREASURY_ADDRESS` | Fee treasury address passed to `initialize` |
+ | `FEE_RATE_BPS` | Fee rate in basis points (e.g. `25` = 0.25%) |
+
+- **Outputs**: the deployed `STREAM_CONTRACT_ID` and deployment details (network,
+ tx hash, admin, treasury, fee rate) are written to `deployment-info.json`, emitted
+ in the job summary, uploaded as GitHub artifacts, and committed to the repo on
+ release deploys so the backend/frontend can pick up `STREAM_CONTRACT_ID`.
+ The optimized `.wasm` is uploaded as a build artifact.
diff --git a/frontend/.gitignore b/frontend/.gitignore
index 66ba020b..6592fe7f 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -42,9 +42,6 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
-# manually-triggered API type codegen output (see src/lib/api-types.ts)
-src/lib/api-types.generated.ts
-
# playwright e2e artifacts
test-results/
playwright-report/
diff --git a/frontend/package.json b/frontend/package.json
index 524b1063..07067e46 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -12,7 +12,7 @@
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
- "codegen:api-types": "openapi-typescript http://localhost:3001/api-docs.json -o src/lib/api-types.generated.ts"
+ "codegen:api-types": "openapi-typescript ../backend/swagger/flowfi.openapi.json -o src/lib/api-types.generated.ts"
},
"dependencies": {
"@stellar/freighter-api": "^6.0.1",
diff --git a/frontend/src/lib/api-types.generated.ts b/frontend/src/lib/api-types.generated.ts
new file mode 100644
index 00000000..226b3529
--- /dev/null
+++ b/frontend/src/lib/api-types.generated.ts
@@ -0,0 +1,2813 @@
+/**
+ * This file was auto-generated by openapi-typescript.
+ * Do not make direct changes to the file.
+ */
+
+export interface paths {
+ "/": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Simple health check
+ * @description Returns a simple message to verify the API is running
+ */
+ get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description API is running successfully */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/health": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Detailed health check
+ * @description Returns liveness and readiness information.
+ * **Liveness** (200 vs 503) is determined by DB reachability alone.
+ * **Indexer lag** is reported in the body for observability but only
+ * forces a 503 when the indexer is actually enabled
+ * (`STREAM_CONTRACT_ID` env var set) and its state row is stale
+ * (lag > 60 s). A cold-started instance with no state row yet, or a
+ * deployment with the indexer intentionally disabled, always returns 200
+ * as long as the DB is reachable.
+ * **Event-processing failures** are also reported. When the indexer is
+ * enabled and recent per-event failures spike (≥50% of attempts in the
+ * last 5 minutes, with ≥3 samples), the endpoint returns 503 even if
+ * lag looks healthy (the IndexerState upsert bumps updatedAt every poll).
+ */
+ get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Service is healthy */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HealthResponse"];
+ };
+ };
+ /** @description Service is degraded or unhealthy */
+ 503: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HealthResponse"];
+ };
+ };
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/webhooks": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** List all webhooks for authenticated user */
+ get: {
+ parameters: {
+ query: {
+ userAddress: string;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description List of webhook subscriptions */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ subscriptions?: components["schemas"]["WebhookSubscription"][];
+ };
+ };
+ };
+ /** @description Missing userAddress */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ put?: never;
+ /**
+ * Register a new webhook subscription
+ * @description Creates a webhook subscription. The returned `secretKey` is only shown once.
+ */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /** @description Stellar public key */
+ userAddress: string;
+ /** Format: uri */
+ targetUrl: string;
+ eventTypes: ("CREATED" | "TOPPED_UP" | "WITHDRAWN" | "CANCELLED" | "COMPLETED" | "PAUSED" | "RESUMED" | "FEE_COLLECTED")[];
+ };
+ };
+ };
+ responses: {
+ /** @description Webhook created successfully */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ subscription?: components["schemas"]["WebhookSubscription"];
+ /** @description Webhook signing secret (returned only once) */
+ secretKey?: string;
+ message?: string;
+ };
+ };
+ };
+ /** @description Missing required fields */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/webhooks/{id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ /** Delete a webhook subscription */
+ delete: {
+ parameters: {
+ query: {
+ userAddress: string;
+ };
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Webhook deleted successfully */
+ 204: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Invalid id or missing userAddress */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/webhooks/{id}/test": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Send a test ping to a webhook */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ userAddress: string;
+ };
+ };
+ };
+ responses: {
+ /** @description Test webhook sent */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ /** @example Test webhook sent */
+ message?: string;
+ result?: {
+ [key: string]: unknown;
+ };
+ };
+ };
+ };
+ /** @description Missing id or userAddress */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/users": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Register a wallet public key
+ * @description Registers a new Stellar wallet public key or returns the existing user if already registered.
+ */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /**
+ * @description Stellar public key (G...)
+ * @example GABC123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA
+ */
+ publicKey: string;
+ };
+ };
+ };
+ responses: {
+ /** @description User already exists */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["User"];
+ };
+ };
+ /** @description User registered successfully */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["User"];
+ };
+ };
+ /** @description Invalid request body */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/users/{publicKey}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Fetch a user by public key
+ * @description Returns user details along with recent sent and received streams.
+ */
+ get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Stellar public key */
+ publicKey: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description User found */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["User"];
+ };
+ };
+ /** @description User not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/users/me": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get current authenticated user
+ * @description Returns the currently authenticated user's details (protected endpoint)
+ */
+ get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Current user details */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["User"];
+ };
+ };
+ /** @description Unauthorized - invalid or missing token */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/users/{address}/summary": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get aggregate stream summary for a user
+ * @description Returns dashboard/profile summary data for a wallet address:
+ * total created streams, total streamed out/in, current claimable across
+ * active incoming streams, and active stream counts.
+ *
+ * Response is cached for 30 seconds to reduce DB load.
+ */
+ get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Stellar public key address */
+ address: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description User stream summary */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UserStreamSummary"];
+ };
+ };
+ /** @description Address is required */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/users/{publicKey}/events": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Fetch user activity history
+ * @description Returns a paginated chronological history of all stream events associated with the user.
+ */
+ get: {
+ parameters: {
+ query?: {
+ /** @description Maximum number of events to return */
+ limit?: number;
+ /** @description Number of events to skip for pagination */
+ offset?: number;
+ };
+ header?: never;
+ path: {
+ /** @description Stellar public key */
+ publicKey: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Paginated list of user events */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UserEventListResponse"];
+ };
+ };
+ /** @description Invalid pagination parameters */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description User not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/users/{address}/export": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Export transaction history for tax and accounting
+ * @description Generates CSV or JSON export of stream transactions for QuickBooks, Xero, CoinTracker, etc.
+ */
+ get: {
+ parameters: {
+ query?: {
+ /** @description Export format */
+ format?: "csv" | "json";
+ /** @description Filter by transaction direction */
+ direction?: "incoming" | "outgoing" | "all";
+ /** @description Start date (ISO 8601 or Unix timestamp) */
+ startDate?: string;
+ /** @description End date (ISO 8601 or Unix timestamp) */
+ endDate?: string;
+ /** @description Filter by specific token contract */
+ tokenAddress?: string;
+ };
+ header?: never;
+ path: {
+ /** @description Stellar public key */
+ address: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Transaction export file */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "text/csv": string;
+ "application/json": Record;
+ };
+ };
+ /** @description Invalid parameters */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description User not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/streams": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * List payment streams
+ * @description Retrieve a list of payment streams with optional filtering, sorting, and pagination.
+ */
+ get: {
+ parameters: {
+ query?: {
+ /** @description Filter by sender public key */
+ sender?: string;
+ /** @description Filter by recipient public key */
+ recipient?: string;
+ /** @description Filter by stream status */
+ status?: "active" | "cancelled" | "completed" | "paused";
+ /** @description Filter by token contract address */
+ token?: string;
+ /** @description Sort field */
+ sort?: "createdAt" | "startTime" | "lastUpdateTime" | "depositedAmount" | "endTime";
+ /** @description Sort order */
+ order?: "asc" | "desc";
+ /** @description Max results per page (capped at 100) */
+ limit?: number;
+ /** @description Number of results to skip */
+ offset?: number;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Paginated list of streams */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["StreamListResponse"];
+ };
+ };
+ /** @description Invalid status or pagination parameters */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ put?: never;
+ /**
+ * Create a new payment stream
+ * @description Creates or reactivates a payment stream record for the authenticated wallet. The authenticated wallet must be the stream sender.
+ */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /**
+ * @description On-chain stream ID
+ * @example 1
+ */
+ streamId: number;
+ /**
+ * @description Sender Stellar public key — must match the authenticated wallet
+ * @example GABC123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA
+ */
+ sender: string;
+ /**
+ * @description Recipient Stellar public key
+ * @example GDEF456ABC789GHI012JKL345MNO678PQR901STU234VWX567YZA123BCD
+ */
+ recipient: string;
+ /**
+ * @description Token contract address
+ * @example CBCD789EFG012HIJ345KLM678NOP901QRS234TUV567WXY890ZAB123CDE
+ */
+ tokenAddress: string;
+ /**
+ * @description Payment rate per second (i128 as string)
+ * @example 100
+ */
+ ratePerSecond: string;
+ /**
+ * @description Total deposited amount (i128 as string)
+ * @example 10000
+ */
+ depositedAmount: string;
+ /**
+ * @description Stream start time (Unix timestamp)
+ * @example 1708531200
+ */
+ startTime: number;
+ };
+ };
+ };
+ responses: {
+ /** @description Stream created successfully */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Stream"];
+ };
+ };
+ /** @description Invalid input data */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Unauthorized - missing or invalid authentication token */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Forbidden - sender does not match the authenticated wallet, or stream is owned by another wallet */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Too Many Requests - rate limit exceeded (10 requests per minute) */
+ 429: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/streams/summary/{address}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get user stream summary
+ * @description Aggregate dashboard/profile summary for a wallet address. Cached for 30 seconds.
+ */
+ get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description Stellar public key */
+ address: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description User stream summary */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UserStreamSummary"];
+ };
+ };
+ /** @description Address is required */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/streams/{streamId}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get stream details
+ * @description Returns a single stream. Falls back to live on-chain data when the DB record is missing or stale.
+ */
+ get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description On-chain stream ID */
+ streamId: number;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Stream details */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Stream"];
+ };
+ };
+ /** @description Invalid streamId parameter */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Stream not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/streams/{streamId}/events": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get stream events
+ * @description Retrieve events for a specific stream with pagination, filtering, and sorting.
+ */
+ get: {
+ parameters: {
+ query?: {
+ /** @description Number of events to return per page (default: 50, max: 200) */
+ limit?: number;
+ /** @description Number of events to skip (default: 0) */
+ offset?: number;
+ /** @description Filter events by type */
+ eventType?: "CREATED" | "TOPPED_UP" | "WITHDRAWN" | "CANCELLED" | "COMPLETED" | "PAUSED" | "RESUMED" | "FEE_COLLECTED" | "FEE_CONFIG_UPDATED" | "ADMIN_TRANSFERRED";
+ /** @description 1-based page index (offset based). Ignored when `cursor` is set. */
+ page?: number;
+ /** @description Event id cursor for stable pagination (hasMore-aware). Ignored when `offset` is set. */
+ cursor?: string;
+ /** @description Sort order by timestamp (default: desc) */
+ order?: "asc" | "desc";
+ };
+ header?: never;
+ path: {
+ /** @description On-chain stream ID */
+ streamId: number;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Paginated stream events */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["StreamEventListResponse"];
+ };
+ };
+ /** @description Invalid request parameters */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Stream not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/streams/{streamId}/claimable": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get actionable claimable amount for a stream
+ * @description Returns the amount claimable right now (or at an optional timestamp). Uses a 5s-cached computation, with an on-chain fallback when the record is missing or stale.
+ */
+ get: {
+ parameters: {
+ query?: {
+ /** @description Optional Unix timestamp (seconds) to compute the claimable amount at */
+ at?: number;
+ };
+ header?: never;
+ path: {
+ /** @description On-chain stream ID */
+ streamId: number;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Claimable amount */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ClaimableResponse"];
+ };
+ };
+ /** @description Invalid streamId or `at` parameter */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Stream not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/streams/{streamId}/pause": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Pause a payment stream
+ * @description Pause an active stream. Only the sender can pause their own stream.
+ */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description On-chain stream ID */
+ streamId: number;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Stream paused successfully */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["PauseResumeResponse"];
+ };
+ };
+ /** @description Invalid streamId, or on-chain pause simulation failed */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Unauthorized - missing or invalid authentication */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Forbidden - caller is not the stream sender */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Stream not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Conflict - stream already paused or inactive */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/streams/{streamId}/resume": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Resume a paused payment stream
+ * @description Resume a paused stream. Only the sender can resume their own stream.
+ */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description On-chain stream ID */
+ streamId: number;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Stream resumed successfully */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["PauseResumeResponse"];
+ };
+ };
+ /** @description Invalid streamId, or on-chain resume simulation failed */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Unauthorized - missing or invalid authentication */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Forbidden - caller is not the stream sender */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Stream not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Conflict - stream not paused or inactive */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/streams/{streamId}/withdraw": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Withdraw claimable balance from a payment stream
+ * @description Withdraws the currently claimable amount. Only the recipient can withdraw.
+ */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description On-chain stream ID */
+ streamId: number;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Withdrawal submitted successfully */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["WithdrawResponse"];
+ };
+ };
+ /** @description Invalid streamId or contract revert */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Unauthorized - missing or invalid authentication */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Forbidden - caller is not the stream recipient */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Stream not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Conflict - no claimable balance available */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/streams/{streamId}/top-up": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Top up a payment stream
+ * @description Adds additional funds to an existing active stream. Only the original sender can top up.
+ */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description On-chain stream ID */
+ streamId: number;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /**
+ * @description Amount to add to the stream deposit (i128 as string)
+ * @example 5000
+ */
+ amount: string;
+ };
+ };
+ };
+ responses: {
+ /** @description Stream topped up successfully */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["TopUpResponse"];
+ };
+ };
+ /** @description Invalid request — amount missing or not a positive integer string */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Unauthorized - missing or invalid authentication token */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Forbidden - caller is not the stream sender */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Stream not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Conflict - stream inactive or paused */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/streams/{streamId}/cancel": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Cancel an active payment stream
+ * @description Cancels an active payment stream on the Stellar network.
+ * Only the original sender can cancel the stream.
+ * Accrued tokens are sent to the recipient, and the remainder is refunded to the sender.
+ */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description On-chain stream ID */
+ streamId: number;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Stream cancelled successfully */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["CancelResponse"];
+ };
+ };
+ /** @description Invalid streamId or transaction simulation failed */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Unauthorized - missing or invalid authentication */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Forbidden - only sender can cancel */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Stream not found */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Stream already cancelled or completed */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/events": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * List stream events for a wallet (paginated, filterable)
+ * @description Returns a reverse-chronological list of stream events where the wallet
+ * was either the sender or recipient. Supports event-type filtering and
+ * limit/offset pagination — used by the frontend activity timeline.
+ */
+ get: {
+ parameters: {
+ query: {
+ /** @description Stellar public key (G...) */
+ address: string;
+ /**
+ * @description Comma-separated list of event types to include. Allowed values:
+ * CREATED, TOPPED_UP, WITHDRAWN, CANCELLED, COMPLETED, PAUSED,
+ * RESUMED, FEE_COLLECTED, FEE_CONFIG_UPDATED, ADMIN_TRANSFERRED.
+ */
+ type?: string;
+ limit?: number;
+ offset?: number;
+ /** @description Optional 1-based page index. Ignored when offset is set. */
+ page?: number;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Paginated event list */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["EventListResponse"];
+ };
+ };
+ /** @description Missing/invalid `address` or invalid `type` filter */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Unauthorized - missing or invalid authentication token */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Forbidden - `address` must match the authenticated wallet */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/events/subscribe": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Subscribe to real-time stream events
+ * @description Establishes a Server-Sent Events (SSE) connection for real-time updates.
+ *
+ * **Reconnection Strategy:**
+ * - Browser automatically reconnects with exponential backoff
+ * - Initial retry: 1s, max: 30s
+ * - Client should implement custom reconnection logic for production
+ *
+ * **Event Types:**
+ * - `stream.created` - New stream created
+ * - `stream.topped_up` - Stream received additional funds
+ * - `stream.withdrawn` - Funds withdrawn from stream
+ * - `stream.cancelled` - Stream cancelled
+ * - `stream.completed` - Stream completed
+ *
+ * **Sandbox Mode:**
+ * - Add header `X-Sandbox-Mode: true` or query parameter `?sandbox=true`
+ * - Sandbox events are clearly marked with `_sandbox` metadata
+ * - Sandbox events are isolated from production events
+ */
+ get: {
+ parameters: {
+ query?: {
+ /** @description Enable sandbox mode via query parameter */
+ sandbox?: "true" | "1";
+ /**
+ * @description Array of stream IDs to subscribe to
+ * @example [
+ * "1",
+ * "2"
+ * ]
+ */
+ streams?: string[];
+ /**
+ * @description Array of user public keys to subscribe to
+ * @example [
+ * "GABC...",
+ * "GDEF..."
+ * ]
+ */
+ users?: string[];
+ /**
+ * @description Subscribe to all events
+ * @example false
+ */
+ all?: boolean;
+ };
+ header?: {
+ /** @description Enable sandbox mode for testing */
+ "X-Sandbox-Mode"?: "true" | "1";
+ };
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description SSE connection established. Events are emitted as `data:` frames of type stream.created, stream.topped_up, stream.withdrawn, stream.cancelled, stream.completed, stream.paused, stream.resumed, fee.collected. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "text/event-stream": string;
+ };
+ };
+ /** @description Invalid subscription parameters */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Unauthorized - missing or invalid authentication token */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/events/stats": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get SSE connection statistics
+ * @description Returns current SSE connection metrics for monitoring (admin only)
+ */
+ get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Connection statistics */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["SseStats"];
+ };
+ };
+ /** @description Unauthorized - missing or invalid authentication token */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Forbidden - admin access required */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/auth/challenge": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Request a sign challenge for wallet authentication */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /** @example GABC... */
+ publicKey: string;
+ };
+ };
+ };
+ responses: {
+ /** @description Challenge nonce issued */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["AuthChallengeResponse"];
+ };
+ };
+ /** @description Invalid publicKey */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Too many requests */
+ 429: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/auth/verify": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Verify signed challenge and receive JWT */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ publicKey: string;
+ /** @description Base64-encoded XDR signed transaction containing the nonce */
+ signedTransaction: string;
+ };
+ };
+ };
+ responses: {
+ /** @description JWT token */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["AuthVerifyResponse"];
+ };
+ };
+ /** @description Missing publicKey or signedTransaction */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Invalid signature or expired challenge */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Too many requests */
+ 429: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/admin/metrics": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Protocol health metrics */
+ get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Protocol health metrics */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ total_streams?: number;
+ active_streams?: number;
+ paused_streams?: number;
+ completed_streams?: number;
+ cancelled_streams?: number;
+ /** @description Sum of withdrawn amounts (i128 as string) */
+ total_volume_streamed?: string;
+ streams?: {
+ active?: number;
+ paused?: number;
+ total?: number;
+ byStatus?: {
+ [key: string]: number;
+ };
+ };
+ events?: {
+ last24h?: number;
+ };
+ fees?: {
+ totalFeesCollectedByToken?: {
+ [key: string]: string;
+ };
+ feesLast24h?: {
+ [key: string]: string;
+ };
+ };
+ sse?: {
+ activeConnections?: number;
+ };
+ indexer?: {
+ lastLedger?: number;
+ lagSeconds?: number | null;
+ /** Format: date-time */
+ lastUpdated?: string | null;
+ eventsProcessed?: number;
+ eventsFailed?: number;
+ lastErrorAt?: string | null;
+ degraded?: boolean;
+ };
+ cache?: {
+ [key: string]: unknown;
+ };
+ pgPool?: {
+ [key: string]: unknown;
+ };
+ uptime?: number;
+ /** Format: date-time */
+ timestamp?: string;
+ /** Format: date-time */
+ calculatedAt?: string;
+ };
+ };
+ };
+ /** @description Unauthorized - missing or invalid authentication token */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Forbidden - admin access required */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/admin/indexer/status": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Get indexer status */
+ get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Indexer status */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ [key: string]: unknown;
+ };
+ };
+ };
+ /** @description Unauthorized - missing or invalid authentication token */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Forbidden - admin access required */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/admin/indexer/reset": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Reset indexer lastProcessedLedger */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ ledger: number;
+ };
+ };
+ };
+ responses: {
+ /** @description Reset successful */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ /** @example true */
+ ok?: boolean;
+ lastLedger?: number;
+ };
+ };
+ };
+ /** @description Invalid ledger value */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Unauthorized - missing or invalid authentication token */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Forbidden - admin access required */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/admin/indexer/replay": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Replay events from a given ledger (StreamEvent rows deduplicated; stream mutations not idempotent — see indexerService.ts JSDoc) */
+ post: {
+ parameters: {
+ query: {
+ from_ledger: number;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Replay started */
+ 202: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ /** @example true */
+ ok?: boolean;
+ replayingFrom?: number;
+ requestId?: string;
+ };
+ };
+ };
+ /** @description Invalid from_ledger value */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Unauthorized - missing or invalid authentication token */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Forbidden - admin access required */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+}
+export type webhooks = Record;
+export interface components {
+ schemas: {
+ User: {
+ /**
+ * Format: uuid
+ * @description Unique identifier for the user
+ * @example 550e8400-e29b-41d4-a716-446655440000
+ */
+ id: string;
+ /**
+ * @description Stellar public key (G...)
+ * @example GABC123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA
+ */
+ publicKey: string;
+ /**
+ * Format: date-time
+ * @description User creation timestamp
+ */
+ createdAt?: string;
+ /**
+ * Format: date-time
+ * @description Last update timestamp
+ */
+ updatedAt?: string;
+ };
+ Stream: {
+ /**
+ * Format: uuid
+ * @description Database UUID
+ */
+ id: string;
+ /**
+ * @description On-chain stream ID
+ * @example 1
+ */
+ streamId: number;
+ /**
+ * @description Sender Stellar public key
+ * @example GABC123XYZ456DEF789GHI012JKL345MNO678PQR901STU234VWX567YZA
+ */
+ sender: string;
+ /**
+ * @description Recipient Stellar public key
+ * @example GDEF456ABC789GHI012JKL345MNO678PQR901STU234VWX567YZA123BCD
+ */
+ recipient: string;
+ /**
+ * @description Token contract address
+ * @example CBCD789EFG012HIJ345KLM678NOP901QRS234TUV567WXY890ZAB123CDE
+ */
+ tokenAddress: string;
+ /**
+ * @description Payment rate per second (i128 as string)
+ * @example 100
+ */
+ ratePerSecond: string;
+ /**
+ * @description Total deposited amount (i128 as string)
+ * @example 10000
+ */
+ depositedAmount?: string;
+ /**
+ * @description Total withdrawn amount (i128 as string)
+ * @example 2500
+ */
+ withdrawnAmount?: string;
+ /**
+ * @description Stream start time (Unix timestamp)
+ * @example 1708531200
+ */
+ startTime?: number;
+ /**
+ * @description Last update time (Unix timestamp)
+ * @example 1708534800
+ */
+ lastUpdateTime?: number;
+ /**
+ * @description Stream active status
+ * @example true
+ */
+ isActive?: boolean;
+ /**
+ * @description Whether the stream is currently paused
+ * @example false
+ */
+ isPaused?: boolean;
+ /**
+ * @description Ledger timestamp when the stream was last paused (Unix), null if not paused
+ * @example null
+ */
+ pausedAt?: number | null;
+ /**
+ * @description Cumulative seconds the stream has spent paused
+ * @example 0
+ */
+ totalPausedDuration?: number;
+ /**
+ * @description Ledger timestamp when the stream ended (Unix), null if still active
+ * @example null
+ */
+ endTime?: number | null;
+ /** Format: date-time */
+ createdAt?: string;
+ /** Format: date-time */
+ updatedAt?: string;
+ };
+ StreamEvent: {
+ /** Format: uuid */
+ id: string;
+ /** @description Reference to stream ID */
+ streamId: number;
+ /**
+ * @description Type of stream event
+ * @example TOPPED_UP
+ * @enum {string}
+ */
+ eventType: "CREATED" | "TOPPED_UP" | "WITHDRAWN" | "CANCELLED" | "COMPLETED" | "PAUSED" | "RESUMED" | "FEE_COLLECTED";
+ /**
+ * @description Amount involved in event (i128 as string)
+ * @example 5000
+ */
+ amount?: string | null;
+ /**
+ * @description Stellar transaction hash
+ * @example a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6
+ */
+ transactionHash: string;
+ /**
+ * @description Ledger sequence number
+ * @example 12345678
+ */
+ ledgerSequence: number;
+ /**
+ * @description Event timestamp (Unix)
+ * @example 1708531200
+ */
+ timestamp: number;
+ /** @description Additional event data (JSON string) */
+ metadata?: string | null;
+ /** Format: date-time */
+ createdAt?: string;
+ };
+ StreamListResponse: {
+ /** @description Streams matching the filter, sorted and paginated */
+ data: components["schemas"]["Stream"][];
+ /** @description Total number of streams matching the filter */
+ total: number;
+ /** @description Whether more results are available past this page */
+ hasMore: boolean;
+ /** @description Page size applied (capped at MAX_STREAM_PAGE_SIZE) */
+ limit: number;
+ /** @description Number of results skipped */
+ offset: number;
+ };
+ StreamEventListResponse: {
+ /** @description Events for the stream, sorted by timestamp (tie-broken by id) */
+ data: components["schemas"]["StreamEvent"][];
+ /** @description Total number of events matching the filter */
+ total: number;
+ /** @description Whether more results are available past this page */
+ hasMore: boolean;
+ };
+ EventListResponse: {
+ /** @description Reverse-chronological stream events for the wallet */
+ events: components["schemas"]["StreamEvent"][];
+ /** @description Total number of matching events */
+ total: number;
+ /** @description Page size applied (capped at 200) */
+ limit: number;
+ /** @description Number of events skipped */
+ offset: number;
+ /** @description Whether more results are available past this page */
+ hasMore: boolean;
+ };
+ UserEventListResponse: {
+ /** @description Events associated with the user, newest first */
+ data: components["schemas"]["StreamEvent"][];
+ /** @description Total number of matching events */
+ total: number;
+ /** @description Whether more results are available past this page */
+ hasMore: boolean;
+ /** @description Page size applied (capped at 200) */
+ limit: number;
+ /** @description Number of events skipped */
+ offset: number;
+ };
+ UserStreamSummary: {
+ /** @description Stellar public key */
+ address: string;
+ /** @description Number of streams this wallet sent */
+ totalStreamsCreated: number;
+ /** @description Sum of withdrawn amounts on outgoing streams (i128 as string) */
+ totalStreamedOut: string;
+ /** @description Sum of withdrawn amounts on incoming streams (i128 as string) */
+ totalStreamedIn: string;
+ /** @description Total currently claimable across active incoming streams (i128 as string) */
+ currentClaimable: string;
+ activeOutgoingCount: number;
+ activeIncomingCount: number;
+ /**
+ * @description True when the number of streams was capped at MAX_USER_STREAMS per direction
+ * @example false
+ */
+ truncated?: boolean;
+ };
+ ClaimableResponse: {
+ /** @description On-chain stream ID */
+ streamId?: number;
+ /** @description Payment rate per second (i128 as string) */
+ ratePerSecond?: string;
+ /** @description Total deposited amount (i128 as string) */
+ depositedAmount?: string;
+ /** @description Total withdrawn amount (i128 as string) */
+ withdrawnAmount?: string;
+ /** @description Stream start time (Unix timestamp) */
+ startTime?: number;
+ /** @description Last state update time (Unix timestamp) */
+ lastUpdateTime?: number;
+ /** @description Amount claimable at the requested time (i128 as string) */
+ claimableAmount: string;
+ /** @description Whether the claimable amount is positive */
+ actionable: boolean;
+ /** @description Unix timestamp of the calculation */
+ calculatedAt: number;
+ /** @description Whether the value came from cache or a fresh computation */
+ cached?: boolean;
+ /**
+ * @description Where the value was computed from
+ * @enum {string}
+ */
+ source?: "db" | "chain";
+ };
+ PauseResumeResponse: {
+ /** @example true */
+ success: boolean;
+ streamId: number;
+ /** @description Stellar transaction hash of the pause/resume simulation */
+ txHash: string;
+ stream?: components["schemas"]["Stream"];
+ };
+ TopUpResponse: {
+ streamId: number;
+ /** @description Stellar transaction hash */
+ txHash: string;
+ /** @description New total deposited amount after the top-up (i128 as string) */
+ depositedAmount: string;
+ };
+ WithdrawResponse: {
+ /** @example true */
+ success: boolean;
+ streamId: number;
+ /** @description Stellar transaction hash of the withdrawal */
+ txHash: string;
+ /** @description Amount withdrawn (i128 as string) */
+ amount: string;
+ stream?: components["schemas"]["Stream"];
+ };
+ CancelResponse: {
+ /** @description Stellar transaction hash of the cancel */
+ txHash: string;
+ /**
+ * @example CANCELLED
+ * @enum {string}
+ */
+ status: "CANCELLED";
+ };
+ AuthChallengeResponse: {
+ /** @description Hex-encoded nonce to sign via a Stellar manage_data operation */
+ nonce: string;
+ /** @description Unix timestamp (ms) when the challenge expires (60s) */
+ expiresAt: number;
+ };
+ AuthVerifyResponse: {
+ /** @description JWT to use in the Authorization: Bearer header */
+ token: string;
+ /** @description Token lifetime in seconds (3600) */
+ expiresIn: number;
+ };
+ SseStats: {
+ /** @example 42 */
+ activeConnections: number;
+ /** @example 8 */
+ activeIps: number;
+ /** @example 5 */
+ perIpPeakConnections: number;
+ /** @example 10000 */
+ maxConnections: number;
+ /** Format: date-time */
+ timestamp: string;
+ };
+ WebhookSubscription: {
+ /** @description Webhook subscription id */
+ id: string;
+ /** @description Stellar public key the subscription belongs to */
+ userAddress: string;
+ /** @description HTTPS endpoint receiving the events */
+ targetUrl: string;
+ eventTypes: ("CREATED" | "TOPPED_UP" | "WITHDRAWN" | "CANCELLED" | "COMPLETED" | "PAUSED" | "RESUMED" | "FEE_COLLECTED")[];
+ active: boolean;
+ /** Format: date-time */
+ createdAt: string;
+ };
+ HealthResponse: {
+ /**
+ * @example ok
+ * @enum {string}
+ */
+ status: "ok" | "degraded";
+ /**
+ * @example connected
+ * @enum {string}
+ */
+ db: "connected" | "disconnected";
+ /** @description Whether the event indexer is configured */
+ indexerEnabled: boolean;
+ /** @description Seconds since last indexer update, or null when no state row exists yet */
+ indexerLag?: number | null;
+ /** @description Lifetime count of successfully processed indexer events */
+ eventsProcessed?: number;
+ /** @description Lifetime count of indexer events that threw during processing */
+ eventsFailed?: number;
+ /**
+ * Format: date-time
+ * @description Most recent per-event processing failure
+ */
+ lastErrorAt?: string | null;
+ /** @description True when recent event-processing failure rate spikes */
+ indexerDegraded?: boolean;
+ /** @description Server uptime in seconds */
+ uptime: number;
+ /** @description Per-subsystem status breakdown */
+ checks: {
+ database?: {
+ /** @enum {string} */
+ status?: "ok" | "down";
+ };
+ indexer?: {
+ /** @enum {string} */
+ status?: "ok" | "degraded" | "disabled";
+ enabled?: boolean;
+ lagSeconds?: number | null;
+ };
+ redis?: {
+ /** @enum {string} */
+ status?: "ok" | "unavailable" | "not_configured";
+ };
+ sorobanRpc?: {
+ /** @enum {string} */
+ status?: "ok" | "down";
+ };
+ };
+ };
+ Error: {
+ /**
+ * @description Error message
+ * @example Resource not found
+ */
+ error?: string;
+ /**
+ * @description Error code
+ * @example NOT_FOUND
+ */
+ code?: string;
+ /** @description Human-readable detail (present on many error responses) */
+ message?: string | null;
+ /** @description Structured validation issues (zod) when the error is a 400 */
+ details?: Record[] | null;
+ };
+ };
+ responses: never;
+ parameters: never;
+ requestBodies: never;
+ headers: never;
+ pathItems: never;
+}
+export type $defs = Record;
+export type operations = Record;
diff --git a/packages/flowfi-sdk/README.md b/packages/flowfi-sdk/README.md
new file mode 100644
index 00000000..914c1cf7
--- /dev/null
+++ b/packages/flowfi-sdk/README.md
@@ -0,0 +1,37 @@
+# `@flowfi/sdk`
+
+Typed TypeScript client for the FlowFi API, generated straight from the committed
+OpenAPI 3.1 spec.
+
+## Regenerating
+
+The SDK is generated by a standalone script — it does not require the API server
+to be running:
+
+```bash
+./scripts/generate-sdk.sh
+```
+
+What it does:
+
+1. Reads `backend/swagger/flowfi.openapi.json` (the committed spec export).
+2. Runs [openapi-generator]'s `typescript-fetch` generator via
+ `@openapitools/openapi-generator-cli` (Java required on first run).
+3. Writes the typed client into `packages/flowfi-sdk/`.
+
+Keep the spec fresh before regenerating:
+
+```bash
+cd backend && npm run codegen:openapi
+```
+
+## Using the SDK
+
+```ts
+import { StreamsApi, Configuration } from '@flowfi/sdk';
+
+const api = new StreamsApi(new Configuration({ basePath: 'https://api.flowfi.io/v1' }));
+const { data } = await api.getStream(1);
+```
+
+[openapi-generator]: https://openapi-generator.tech/
\ No newline at end of file
diff --git a/packages/flowfi-sdk/package.json b/packages/flowfi-sdk/package.json
new file mode 100644
index 00000000..c7f38aff
--- /dev/null
+++ b/packages/flowfi-sdk/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "@flowfi/sdk",
+ "version": "0.1.0",
+ "description": "Typed TypeScript client for the FlowFi API, generated from the OpenAPI spec via scripts/generate-sdk.sh",
+ "private": true,
+ "type": "module",
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "tsc",
+ "codegen": "../../scripts/generate-sdk.sh"
+ },
+ "devDependencies": {
+ "typescript": "^5.6.0"
+ }
+}
\ No newline at end of file
diff --git a/scripts/generate-sdk.sh b/scripts/generate-sdk.sh
new file mode 100755
index 00000000..c28f54eb
--- /dev/null
+++ b/scripts/generate-sdk.sh
@@ -0,0 +1,33 @@
+#!/usr/bin/env bash
+#
+# Generate the FlowFi typed SDK client (packages/flowfi-sdk) from the committed
+# OpenAPI spec (backend/swagger/flowfi.openapi.json) using openapi-generator.
+#
+# ./scripts/generate-sdk.sh
+#
+# Requires Java 8+ (or a JRE available in PATH) on the first run so it can
+# launch the OpenAPI Generator. npx will download a pinned generator JAR
+# automatically.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+SPEC_PATH="${SPEC_PATH:-$ROOT/backend/swagger/flowfi.openapi.json}"
+OUT_DIR="$ROOT/packages/flowfi-sdk"
+GENERATOR_VERSION="${OPENAPI_GENERATOR_VERSION:-7.11.0}"
+
+if [[ ! -f "$SPEC_PATH" ]]; then
+ echo "Error: OpenAPI spec not found at $SPEC_PATH"
+ echo "Re-export it first with: cd backend && npm run codegen:openapi"
+ exit 1
+fi
+
+mkdir -p "$OUT_DIR"
+echo "Generating typed SDK from $SPEC_PATH into $OUT_DIR ..."
+npx --yes "@openapitools/openapi-generator-cli@v${GENERATOR_VERSION}" generate \
+ -i "$SPEC_PATH" \
+ -g typescript-fetch \
+ -o "$OUT_DIR" \
+ --additional-properties=useSingleRequestParameter=true,supportsES6=true,modelPropertyNaming=original,enumPropertyNaming=original,withSeparateModelsAndApi=true,apiPackage=api,modelPackage=models
+
+echo "SDK regeneration complete -> $OUT_DIR"
+echo "Next: cd packages/flowfi-sdk && npm install && npm run build"
\ No newline at end of file