diff --git a/backend/src/app.js b/backend/src/app.js
index 542caf8a..69fb5be0 100644
--- a/backend/src/app.js
+++ b/backend/src/app.js
@@ -19,9 +19,8 @@ import x402Router from "./routes/x402.js";
import authRouter from "./routes/auth.js";
import { requireApiKeyAuth } from "./lib/auth.js";
-import { isHorizonReachable } from "./lib/stellar.js";
-import { supabase } from "./lib/supabase.js";
import { pool } from "./lib/db.js";
+import { probeHealth } from "./lib/health.js";
import { x402Middleware } from "./middleware/x402.js";
import { idempotencyMiddleware } from "./lib/idempotency.js";
@@ -203,18 +202,15 @@ export async function createApp({ redisClient }) {
* type: string
* horizon_reachable:
* type: boolean
- */
+ */
app.get("/health", async (req, res) => {
- const [dbResult, horizonReachable] = await Promise.allSettled([
- supabase.from("merchants").select("id").limit(1),
- isHorizonReachable(),
- ]);
-
- const dbOk = dbResult.status === "fulfilled" && !dbResult.value?.error;
- if (!dbOk) {
- console.error("Health DB error:", JSON.stringify(dbResult.reason || dbResult.value?.error));
+ const { database, horizon } = await probeHealth();
+
+ if (!database.ok) {
+ console.error("Health DB error:", database.error);
}
- const horizonOk = horizonReachable.status === "fulfilled" && horizonReachable.value === true;
+ const dbOk = database.ok;
+ const horizonOk = horizon.ok;
const status = dbOk && horizonOk ? 200 : 503;
diff --git a/backend/src/index.js b/backend/src/index.js
index 5a817cbb..b8bd009c 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -13,12 +13,11 @@ import metricsRouter from "./routes/metrics.js";
import authRouter from "./routes/auth.js";
import auditRouter from "./routes/audit.js";
import { requireApiKeyAuth } from "./lib/auth.js";
-import { isHorizonReachable } from "./lib/stellar.js";
-import { supabase } from "./lib/supabase.js";
import { pool, closePool } from "./lib/db.js";
import { validateEnvironmentVariables } from "./lib/env-validation.js";
import { formatZodError } from "./lib/request-schemas.js";
import { idempotencyMiddleware } from "./lib/idempotency.js";
+import { probeHealth } from "./lib/health.js";
import { closeRedisClient, connectRedisClient } from "./lib/redis.js";
import {
createRedisRateLimitStore,
@@ -82,45 +81,33 @@ app.use(express.json({ limit: "1mb" }));
app.use(morgan(":request-id :method :url :status :response-time ms"));
app.get("/health", async (req, res) => {
- try {
- const [dbResult, horizonReachable] = await Promise.all([
- supabase.from("merchants").select("id").limit(1),
- isHorizonReachable(),
- ]);
-
- const { error } = dbResult;
-
- if (error) {
- return res.status(503).json({
- ok: false,
- service: "stellar-payment-api",
- error: "Database unavailable",
- horizon_reachable: horizonReachable,
- });
- }
-
- if (!horizonReachable) {
- return res.status(503).json({
- ok: false,
- service: "stellar-payment-api",
- error: "Horizon unavailable",
- horizon_reachable: false,
- });
- }
-
- res.json({
- ok: true,
+ const { database, horizon } = await probeHealth();
+
+ if (!database.ok) {
+ res.status(503).json({
+ ok: false,
service: "stellar-payment-api",
- horizon_reachable: true,
+ error: "Database unavailable",
+ horizon_reachable: horizon.ok,
});
- } catch {
+ return;
+ }
+
+ if (!horizon.ok) {
res.status(503).json({
ok: false,
service: "stellar-payment-api",
- error: "Health check failed",
+ error: "Horizon unavailable",
horizon_reachable: false,
});
+ return;
}
+
+ res.json({
+ ok: true,
+ service: "stellar-payment-api",
+ horizon_reachable: true,
+ });
});
app.use("/api/create-payment", requireApiKeyAuth());
diff --git a/backend/src/lib/health.js b/backend/src/lib/health.js
new file mode 100644
index 00000000..c36a1afb
--- /dev/null
+++ b/backend/src/lib/health.js
@@ -0,0 +1,20 @@
+import { pool } from "./db.js";
+import { isHorizonReachable } from "./stellar.js";
+
+export async function probeHealth({ db = pool, horizonProbe = isHorizonReachable } = {}) {
+ const [dbResult, horizonResult] = await Promise.allSettled([
+ db.query("SELECT 1"),
+ horizonProbe(),
+ ]);
+
+ return {
+ database: {
+ ok: dbResult.status === "fulfilled",
+ error: dbResult.status === "rejected" ? dbResult.reason : null,
+ },
+ horizon: {
+ ok: horizonResult.status === "fulfilled" && horizonResult.value === true,
+ error: horizonResult.status === "rejected" ? horizonResult.reason : null,
+ },
+ };
+}
diff --git a/backend/src/lib/health.test.js b/backend/src/lib/health.test.js
new file mode 100644
index 00000000..84be7bb9
--- /dev/null
+++ b/backend/src/lib/health.test.js
@@ -0,0 +1,45 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const query = vi.fn();
+const isHorizonReachable = vi.fn();
+
+vi.mock("./db.js", () => ({
+ pool: { query },
+}));
+
+vi.mock("./stellar.js", () => ({
+ isHorizonReachable,
+}));
+
+describe("probeHealth", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('runs a "SELECT 1" database probe and reports both services healthy', async () => {
+ query.mockResolvedValue({ rows: [{ "?column?": 1 }] });
+ isHorizonReachable.mockResolvedValue(true);
+
+ const { probeHealth } = await import("./health.js");
+ const result = await probeHealth();
+
+ expect(query).toHaveBeenCalledWith("SELECT 1");
+ expect(isHorizonReachable).toHaveBeenCalledTimes(1);
+ expect(result).toEqual({
+ database: { ok: true, error: null },
+ horizon: { ok: true, error: null },
+ });
+ });
+
+ it("reports the database as unavailable when the SELECT 1 query fails", async () => {
+ const dbError = new Error("db down");
+ query.mockRejectedValue(dbError);
+ isHorizonReachable.mockResolvedValue(true);
+
+ const { probeHealth } = await import("./health.js");
+ const result = await probeHealth();
+
+ expect(result.database).toEqual({ ok: false, error: dbError });
+ expect(result.horizon).toEqual({ ok: true, error: null });
+ });
+});
diff --git a/backend/tests/integration/health.test.js b/backend/tests/integration/health.test.js
index 1a1c50f7..4a99d723 100644
--- a/backend/tests/integration/health.test.js
+++ b/backend/tests/integration/health.test.js
@@ -18,6 +18,13 @@ vi.mock("../../src/lib/stellar.js", () => ({
isHorizonReachable: vi.fn(async () => true),
}));
+vi.mock("../../src/lib/health.js", () => ({
+ probeHealth: vi.fn(async () => ({
+ database: { ok: true, error: null },
+ horizon: { ok: true, error: null },
+ })),
+}));
+
vi.mock("../../src/lib/supabase.js", () => {
return {
supabase: {
diff --git a/frontend/src/app/(authenticated)/dashboard/page.tsx b/frontend/src/app/(authenticated)/dashboard/page.tsx
index ba29dcb4..c74cda2d 100644
--- a/frontend/src/app/(authenticated)/dashboard/page.tsx
+++ b/frontend/src/app/(authenticated)/dashboard/page.tsx
@@ -13,14 +13,11 @@ import {
} from "@/lib/merchant-store";
import { useTranslations } from "next-intl";
import FirstApiKeyModal from "@/components/FirstApiKeyModal";
-import PaymentMetrics from "@/components/PaymentMetrics";
-import RecentPayments from "@/components/RecentPayments";
-import WithdrawModal from "@/components/WithdrawModal";
+import FirstPaymentCelebration from "@/components/FirstPaymentCelebration";
export default function DashboardPage() {
const t = useTranslations("dashboardPage");
const [isFirstKeyModalOpen, setIsFirstKeyModalOpen] = useState(false);
- const [isWithdrawOpen, setIsWithdrawOpen] = useState(false);
const hydrated = useMerchantHydrated();
const apiKey = useMerchantApiKey();
const merchant = useMerchantMetadata();
@@ -44,100 +41,127 @@ export default function DashboardPage() {
if (!hydrated || loading) return
- Overview of your Stellar payment ecosystem and performance. -
-Overview
+{t("description")}
+{t("apiKeysTip")}
+{t("webhookLogsTip")}
+https://pluto-api.up.railway.app/api
+
+ Use this base URL for all subscription and x402 API requests.
+ Build pay-per-request flows with the production x402 setup guide. +
+