diff --git a/README.md b/README.md
index cf79681..f59c3fc 100644
--- a/README.md
+++ b/README.md
@@ -147,6 +147,48 @@ If you’re running the bundled `monerod` service:
docker compose --profile local-daemon up --build -d
```
+### Troubleshooting first payment detection
+
+If an invoice stays at `0` confirmations or never moves out of `Awaiting funds`, check these in order:
+
+1. Open the dashboard overview and confirm:
+- `Wallet RPC` shows `ok`
+- `Daemon` shows `connected`
+- `Current daemon height` is visible and keeps increasing
+- `Last successful reconcile` is recent
+
+2. Open the public invoice page and confirm the same health indicators are present there. If block height is unavailable, the node path is broken before invoice detection can work.
+
+3. If the bundled wallet-rpc containers keep restarting, inspect their logs:
+
+```
+docker compose logs wallet-rpc-reconciler-1
+docker compose logs wallet-rpc-reconciler-2
+docker compose logs wallet-rpc-reconciler-3
+```
+
+Common causes:
+- wrong `MONERO_WALLET_RPC_WALLET_PASSWORD`
+- wallet cache file does not match the `.keys` file
+- daemon URL is unreachable from the wallet-rpc containers
+
+4. If you are running the bundled `monerod`, check whether it is synced:
+
+```
+docker compose logs monerod
+```
+
+Initial sync can take a long time. Payment detection is not reliable until the daemon is caught up.
+
+5. If the reconciler is not reporting a recent successful scan, inspect the API and reconciler logs:
+
+```
+docker compose logs api
+docker compose logs reconciler
+```
+
+The reconciler is what updates invoice detection and confirmations. If it is down, invoices will not advance even when wallet-rpc and the daemon are healthy.
+
### Optional: Postgres backups (disabled by default)
This repository includes an optional `db-backup` service that runs `pg_dump` hourly and writes backups to `./backups/postgres` on the host.
diff --git a/ui/app/(app)/dashboard/page.tsx b/ui/app/(app)/dashboard/page.tsx
index 78ad4a1..256b473 100644
--- a/ui/app/(app)/dashboard/page.tsx
+++ b/ui/app/(app)/dashboard/page.tsx
@@ -8,6 +8,7 @@ import BtcpayCheckoutStyleSection from "../../../components/btcpay-checkout-styl
import DefaultConfirmationTargetSection from "../../../components/default-confirmation-target-section";
import DefaultQrLogoSection from "../../../components/default-qr-logo-section";
import InvoicePanel from "../../../components/invoice-panel";
+import { formatRelativeTime } from "../../../components/relative-time";
import WebhookHistoryPanel from "../../../components/webhook-history-panel";
import WebhookSecretSection from "../../../components/webhook-secret-section";
import WebhookSection from "../../../components/webhook-section";
@@ -75,6 +76,15 @@ type ProfileSummary = {
btcpay_checkout_style?: "standard" | "btcpay_classic";
created_at: string;
};
+type SystemStatusSummary = {
+ wallet_rpc: "ok" | "unreachable";
+ daemon: "ok" | "unreachable" | "unknown";
+ daemon_height: number | null;
+ invoice_reconcile_interval_seconds: number;
+ last_reconcile_started_at: string | null;
+ last_reconcile_completed_at: string | null;
+ last_reconcile_error: string | null;
+};
export default async function DashboardPage({
searchParams,
}: {
@@ -205,6 +215,7 @@ export default async function DashboardPage({
const allInvoices = invoicesData?.items ?? [];
let profileData: ProfileSummary | null = null;
+ let systemStatus: SystemStatusSummary | null = null;
if (activeTab === "overview" || activeTab === "profile" || activeTab === "invoices") {
const response = await fetch(`${apiBaseUrl}/api/core/profile`, {
@@ -220,6 +231,14 @@ export default async function DashboardPage({
profileData = (await response.json()) as ProfileSummary;
}
}
+ if (activeTab === "overview") {
+ const response = await fetch(`${apiBaseUrl}/api/core/public/system/status`, {
+ cache: "no-store",
+ });
+ if (response.ok) {
+ systemStatus = (await response.json()) as SystemStatusSummary;
+ }
+ }
const isToday = (value: string) => {
const date = new Date(value);
@@ -240,7 +259,12 @@ export default async function DashboardPage({
const confirmedCount = allInvoices.filter(
(invoice) => invoice.status === "confirmed"
).length;
-
+ const lastReconcileCompleted = systemStatus?.last_reconcile_completed_at
+ ? formatRelativeTime(systemStatus.last_reconcile_completed_at)
+ : null;
+ const lastReconcileStarted = systemStatus?.last_reconcile_started_at
+ ? formatRelativeTime(systemStatus.last_reconcile_started_at)
+ : null;
const tabBaseClass =
"inline-flex items-center rounded-xl border border-stroke bg-white/70 px-4 py-2 text-xs font-semibold uppercase tracking-[0.12em] text-ink-soft transition hover:-translate-y-0.5";
const tabActiveClass =
@@ -346,6 +370,83 @@ export default async function DashboardPage({
)}
+
+
+
+ Monero connectivity
+
+
+
+ Wallet RPC {systemStatus?.wallet_rpc === "ok" ? "ok" : "down"}
+
+
+ Daemon{" "}
+ {systemStatus?.daemon === "ok"
+ ? "connected"
+ : systemStatus?.daemon === "unknown"
+ ? "not configured"
+ : "down"}
+
+
+
+
+
+
+ Current daemon height
+
+
+ {systemStatus?.daemon_height?.toLocaleString() ?? "Unavailable"}
+
+
+
+
+ Detection poll interval
+
+
+ {systemStatus?.invoice_reconcile_interval_seconds ?? 30}s
+
+
+
+
+ Last successful reconcile
+
+
+ {lastReconcileCompleted ?? "Not yet recorded"}
+
+
+
+
+ Last reconcile start
+
+
+ {lastReconcileStarted ?? "Not yet recorded"}
+
+
+
+
+ If payments stay at 0 confirmations, check that the daemon is connected
+ and that the height keeps advancing.
+
+ {systemStatus?.last_reconcile_error ? (
+
+ Last reconciler error: {systemStatus.last_reconcile_error}
+
+ ) : null}
+
We never hold funds. All payments move from the customer to your
wallet.
diff --git a/ui/app/(marketing)/docs/page.tsx b/ui/app/(marketing)/docs/page.tsx
index 95cafb9..d01f16f 100644
--- a/ui/app/(marketing)/docs/page.tsx
+++ b/ui/app/(marketing)/docs/page.tsx
@@ -48,6 +48,11 @@ const endpointGroups = [
path: "/api/core/public/invoice/{invoice_id}/continue",
description: "After confirmation, redirect to the merchant-provided Continue URL (if configured).",
},
+ {
+ method: "GET",
+ path: "/api/core/public/system/status",
+ description: "Read daemon, wallet-rpc, block height, and reconciler heartbeat without auth.",
+ },
],
},
{
@@ -257,6 +262,17 @@ const endpointRequirements = [
"Returns a 302 redirect to the merchant-provided checkout_continue_url (if configured).",
],
},
+ {
+ method: "GET",
+ path: "/api/core/public/system/status",
+ auth: "None",
+ required: [],
+ optional: [],
+ notes: [
+ "Returns wallet-rpc reachability, daemon reachability, current daemon height, and reconciler heartbeat timestamps.",
+ "Use this endpoint to debug setup issues before sending a real payment.",
+ ],
+ },
{
method: "GET",
path: "/api/core/invoices",
diff --git a/ui/app/(marketing)/invoice/[invoiceId]/page.tsx b/ui/app/(marketing)/invoice/[invoiceId]/page.tsx
index c67cebe..3bd7cdb 100644
--- a/ui/app/(marketing)/invoice/[invoiceId]/page.tsx
+++ b/ui/app/(marketing)/invoice/[invoiceId]/page.tsx
@@ -37,6 +37,15 @@ type InvoiceStatusResponse = {
detected_at: string | null;
confirmed_at: string | null;
};
+type SystemStatusResponse = {
+ wallet_rpc: "ok" | "unreachable";
+ daemon: "ok" | "unreachable" | "unknown";
+ daemon_height: number | null;
+ invoice_reconcile_interval_seconds: number;
+ last_reconcile_started_at: string | null;
+ last_reconcile_completed_at: string | null;
+ last_reconcile_error: string | null;
+};
const formatStatus = (status: InvoiceStatus) => {
if (status === "payment_detected") {
@@ -98,10 +107,12 @@ export default async function InvoiceStatusDetailPage({
params: Promise<{ invoiceId: string }>;
}) {
const { invoiceId } = await params;
- const response = await fetch(
- `${apiBaseUrl}/api/core/public/invoice/${encodeURIComponent(invoiceId)}`,
- { cache: "no-store" }
- );
+ const [response, systemStatusResponse] = await Promise.all([
+ fetch(`${apiBaseUrl}/api/core/public/invoice/${encodeURIComponent(invoiceId)}`, {
+ cache: "no-store",
+ }),
+ fetch(`${apiBaseUrl}/api/core/public/system/status`, { cache: "no-store" }),
+ ]);
if (response.status === 404) {
return (
@@ -157,6 +168,9 @@ export default async function InvoiceStatusDetailPage({
}
const invoice = (await response.json()) as InvoiceStatusResponse;
+ const systemStatus = systemStatusResponse.ok
+ ? ((await systemStatusResponse.json()) as SystemStatusResponse)
+ : null;
const statusLabel = formatStatus(invoice.status);
const confirmations = Math.max(0, invoice.confirmations ?? 0);
const confirmationTarget = Math.max(0, invoice.confirmation_target);
@@ -204,6 +218,7 @@ export default async function InvoiceStatusDetailPage({
const showConfirmedStamp = invoice.status === "confirmed";
const createdTimestamp = formatTimestamp(invoice.created_at);
const expiresTimestamp = formatTimestamp(invoice.expires_at);
+ const lastReconcileCompleted = formatTimestamp(systemStatus?.last_reconcile_completed_at ?? null);
return (
@@ -230,6 +245,9 @@ export default async function InvoiceStatusDetailPage({
Invoice ID: {invoiceId}
+
+ Current block height: {systemStatus?.daemon_height?.toLocaleString() ?? "Unavailable"}
+
This status page is public. Anyone with the link can view the current
invoice state.
@@ -259,6 +277,74 @@ export default async function InvoiceStatusDetailPage({
+
+
+
+ Monero node
+
+
+
+ Wallet RPC {systemStatus?.wallet_rpc === "ok" ? "ok" : "down"}
+
+
+ Daemon{" "}
+ {systemStatus?.daemon === "ok"
+ ? "connected"
+ : systemStatus?.daemon === "unknown"
+ ? "not configured"
+ : "down"}
+
+
+
+
+
+
+ Current block height
+
+
+ {systemStatus?.daemon_height?.toLocaleString() ?? "Unavailable"}
+
+
+
+
+ Detection poll
+
+
+ {systemStatus?.invoice_reconcile_interval_seconds ?? 30}s
+
+
+
+
+ Last successful scan
+
+
+ {lastReconcileCompleted.label}
+
+
+
+ {systemStatus?.last_reconcile_error ? (
+
+ Reconciler error: {systemStatus.last_reconcile_error}
+
+ ) : null}
+
diff --git a/ui/app/i/[invoiceId]/page.tsx b/ui/app/i/[invoiceId]/page.tsx
index a5a6016..eedba79 100644
--- a/ui/app/i/[invoiceId]/page.tsx
+++ b/ui/app/i/[invoiceId]/page.tsx
@@ -52,6 +52,15 @@ type InvoiceStatusResponse = {
quoted_at: string;
} | null;
};
+type SystemStatusResponse = {
+ wallet_rpc: "ok" | "unreachable";
+ daemon: "ok" | "unreachable" | "unknown";
+ daemon_height: number | null;
+ invoice_reconcile_interval_seconds: number;
+ last_reconcile_started_at: string | null;
+ last_reconcile_completed_at: string | null;
+ last_reconcile_error: string | null;
+};
const formatStatus = (status: InvoiceStatus) => {
if (status === "payment_detected") {
@@ -93,10 +102,12 @@ export default async function BtcpayModalInvoicePage({
params: Promise<{ invoiceId: string }>;
}) {
const { invoiceId } = await params;
- const response = await fetch(
- `${apiBaseUrl}/api/core/public/invoice/${encodeURIComponent(invoiceId)}`,
- { cache: "no-store" }
- );
+ const [response, systemStatusResponse] = await Promise.all([
+ fetch(`${apiBaseUrl}/api/core/public/invoice/${encodeURIComponent(invoiceId)}`, {
+ cache: "no-store",
+ }),
+ fetch(`${apiBaseUrl}/api/core/public/system/status`, { cache: "no-store" }),
+ ]);
if (response.status === 404) {
return (
@@ -139,6 +150,9 @@ export default async function BtcpayModalInvoicePage({
}
const invoice = (await response.json()) as InvoiceStatusResponse;
+ const systemStatus = systemStatusResponse.ok
+ ? ((await systemStatusResponse.json()) as SystemStatusResponse)
+ : null;
const statusLabel = formatStatus(invoice.status);
const confirmations = Math.max(0, invoice.confirmations ?? 0);
const confirmationTarget = Math.max(0, invoice.confirmation_target);
@@ -146,6 +160,7 @@ export default async function BtcpayModalInvoicePage({
invoice.status === "payment_detected" || invoice.status === "confirmed";
const createdTimestamp = formatTimestamp(invoice.created_at);
const expiresTimestamp = formatTimestamp(invoice.expires_at);
+ const lastReconcileCompleted = formatTimestamp(systemStatus?.last_reconcile_completed_at ?? null);
const isBtcpayInvoice = Boolean(invoice.btcpay_amount && invoice.btcpay_currency);
const useClassicCheckout =
isBtcpayInvoice && invoice.btcpay_checkout_style === "btcpay_classic";
@@ -246,6 +261,74 @@ export default async function BtcpayModalInvoicePage({
+
+
+
+ Monero node
+
+
+
+ Wallet RPC {systemStatus?.wallet_rpc === "ok" ? "ok" : "down"}
+
+
+ Daemon{" "}
+ {systemStatus?.daemon === "ok"
+ ? "connected"
+ : systemStatus?.daemon === "unknown"
+ ? "not configured"
+ : "down"}
+
+
+
+
+
+
+ Current block height
+
+
+ {systemStatus?.daemon_height?.toLocaleString() ?? "Unavailable"}
+
+
+
+
+ Detection poll
+
+
+ {systemStatus?.invoice_reconcile_interval_seconds ?? 30}s
+
+
+
+
+ Last successful scan
+
+
+ {lastReconcileCompleted.label}
+
+
+
+ {systemStatus?.last_reconcile_error ? (
+
+ Reconciler error: {systemStatus.last_reconcile_error}
+
+ ) : null}
+
{checkoutContinueAvailable ? (