From 6a3156991a83bf4bf3d7dc31ccf034cc227456b8 Mon Sep 17 00:00:00 2001
From: xmrcheckout
Date: Thu, 8 Jan 2026 12:57:30 +0000
Subject: [PATCH] Add optional post-confirmation continue URL
---
api/app/routes.py | 56 ++++++++++++++++++-
api/app/schemas.py | 31 ++++++++++
ui/app/(app)/dashboard/actions.ts | 32 +++++++++++
ui/app/(marketing)/docs/integrations/page.tsx | 5 ++
ui/app/(marketing)/docs/page.tsx | 18 ++++++
.../(marketing)/invoice/[invoiceId]/page.tsx | 12 ++++
ui/app/i/[invoiceId]/page.tsx | 14 +++++
ui/components/create-invoice-card.tsx | 16 ++++++
ui/components/invoice-panel.tsx | 27 +++++++++
9 files changed, 210 insertions(+), 1 deletion(-)
diff --git a/api/app/routes.py b/api/app/routes.py
index cdbf088..deb0dad 100644
--- a/api/app/routes.py
+++ b/api/app/routes.py
@@ -7,7 +7,7 @@ import io
import json
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
-from fastapi.responses import StreamingResponse
+from fastapi.responses import RedirectResponse, StreamingResponse
import requests
from requests import RequestException
from sqlalchemy import func, or_
@@ -131,6 +131,20 @@ def _is_donation_invoice(invoice: Invoice) -> bool:
return isinstance(metadata, dict) and metadata.get("origin") == "donation"
+def _get_checkout_continue_url(invoice: Invoice) -> str | None:
+ metadata = invoice.metadata_json or {}
+ if not isinstance(metadata, dict):
+ return None
+ checkout = metadata.get("checkout")
+ if not isinstance(checkout, dict):
+ return None
+ value = checkout.get("continue_url")
+ if not isinstance(value, str):
+ return None
+ trimmed = value.strip()
+ return trimmed or None
+
+
def _ensure_webhook_secret(db: Session, user: User) -> str:
if user.webhook_secret_encrypted:
return decrypt_secret(user.webhook_secret_encrypted)
@@ -288,6 +302,10 @@ def _public_invoice_status_response(
update["qr_logo"] = logo
if logo == "custom" and isinstance(logo_data_url, str):
update["qr_logo_data_url"] = logo_data_url
+ continue_url = _get_checkout_continue_url(invoice)
+ update["checkout_continue_available"] = bool(
+ invoice.status == "confirmed" and continue_url
+ )
update["qr_url"] = _qr_url(invoice, request)
return response.model_copy(update=update)
@@ -455,6 +473,36 @@ def get_invoice_status(
return _public_invoice_status_response(db, invoice, request)
+@router.get(
+ "/api/core/public/invoice/{invoice_id}/continue",
+ response_class=RedirectResponse,
+)
+def continue_invoice_after_confirmation(
+ invoice_id: uuid.UUID,
+ db: Session = Depends(get_db),
+):
+ invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
+ if invoice is None or _is_donation_invoice(invoice):
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Invoice not found",
+ )
+ if invoice.status != "confirmed":
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Continue link not available",
+ )
+ continue_url = _get_checkout_continue_url(invoice)
+ if not continue_url:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Continue link not available",
+ )
+ response = RedirectResponse(url=continue_url, status_code=status.HTTP_302_FOUND)
+ response.headers["Cache-Control"] = "no-store"
+ return response
+
+
@router.get(
"/api/core/public/donation/{invoice_id}",
response_model=InvoiceStatusResponse,
@@ -933,6 +981,12 @@ def create_invoice_for_user(
metadata = {"qr": {"logo": user.default_qr_logo}}
if user.default_qr_logo == "custom" and user.default_qr_logo_data_url:
metadata["qr"]["logo_data_url"] = user.default_qr_logo_data_url
+ if payload.checkout_continue_url is not None:
+ checkout = metadata.get("checkout") if isinstance(metadata, dict) else None
+ if not isinstance(checkout, dict):
+ checkout = {}
+ checkout = {**checkout, "continue_url": str(payload.checkout_continue_url)}
+ metadata = {**(metadata if isinstance(metadata, dict) else {}), "checkout": checkout}
address, subaddress_index = create_subaddress_for_user(db, user=user)
invoice = Invoice(
user_id=user.id,
diff --git a/api/app/schemas.py b/api/app/schemas.py
index 85b6237..56aeb00 100644
--- a/api/app/schemas.py
+++ b/api/app/schemas.py
@@ -26,6 +26,10 @@ class InvoiceCreate(BaseModel):
amount_fiat: Decimal | None = Field(default=None, gt=0)
currency: str | None = None
confirmation_target: conint(ge=0, le=10) = 2
+ checkout_continue_url: AnyUrl | None = Field(
+ default=None,
+ description="Optional: after the invoice is confirmed, the hosted invoice page can offer a Continue button that navigates to this merchant URL.",
+ )
metadata: dict[str, Any] | None = None
expires_at: datetime | None = Field(
default=None,
@@ -49,6 +53,17 @@ class InvoiceCreate(BaseModel):
raise ValueError("currency is required when amount_fiat is provided")
return self
+ @field_validator("checkout_continue_url")
+ @classmethod
+ def _validate_checkout_continue_url(cls, value: AnyUrl | None) -> AnyUrl | None:
+ if value is None:
+ return None
+ if value.scheme == "https":
+ return value
+ if value.scheme == "http" and value.host in ("localhost", "127.0.0.1"):
+ return value
+ raise ValueError("checkout_continue_url must use https (http is allowed for localhost)")
+
class DonationCreate(BaseModel):
amount_xmr: Decimal = Field(gt=0)
@@ -90,6 +105,10 @@ class InvoiceCreateUser(BaseModel):
amount_fiat: Decimal | None = Field(default=None, gt=0)
currency: str | None = None
confirmation_target: conint(ge=0, le=10) = 2
+ checkout_continue_url: AnyUrl | None = Field(
+ default=None,
+ description="Optional: after the invoice is confirmed, the hosted invoice page can offer a Continue button that navigates to this merchant URL.",
+ )
metadata: dict[str, Any] | None = None
expires_at: datetime | None = Field(
default=None,
@@ -113,6 +132,17 @@ class InvoiceCreateUser(BaseModel):
raise ValueError("currency is required when amount_fiat is provided")
return self
+ @field_validator("checkout_continue_url")
+ @classmethod
+ def _validate_checkout_continue_url(cls, value: AnyUrl | None) -> AnyUrl | None:
+ if value is None:
+ return None
+ if value.scheme == "https":
+ return value
+ if value.scheme == "http" and value.host in ("localhost", "127.0.0.1"):
+ return value
+ raise ValueError("checkout_continue_url must use https (http is allowed for localhost)")
+
class InvoiceListResponse(BaseModel):
items: list[InvoiceResponse]
@@ -145,6 +175,7 @@ class InvoiceStatusResponse(BaseModel):
btcpay_redirect_automatically: bool | None = None
btcpay_order_id: str | None = None
btcpay_order_number: str | None = None
+ checkout_continue_available: bool = False
quote: dict[str, Any] | None = None
qr_url: str | None = None
diff --git a/ui/app/(app)/dashboard/actions.ts b/ui/app/(app)/dashboard/actions.ts
index dabcf1f..b967da3 100644
--- a/ui/app/(app)/dashboard/actions.ts
+++ b/ui/app/(app)/dashboard/actions.ts
@@ -125,6 +125,37 @@ export async function createInvoiceAction(
}
const recipientName = String(formData.get("recipient_name") ?? "").trim();
const description = String(formData.get("description") ?? "").trim();
+ const checkoutContinueUrlRaw = String(formData.get("checkout_continue_url") ?? "").trim();
+ if (checkoutContinueUrlRaw) {
+ try {
+ const parsed = new URL(checkoutContinueUrlRaw);
+ const isLocalhost =
+ parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLocalhost)) {
+ return {
+ error: "Continue URL must use https (http is allowed for localhost).",
+ invoiceId: null,
+ address: null,
+ amount: null,
+ recipientName: null,
+ description: null,
+ subaddressIndex: null,
+ warnings: null,
+ };
+ }
+ } catch {
+ return {
+ error: "Continue URL must be a valid URL.",
+ invoiceId: null,
+ address: null,
+ amount: null,
+ recipientName: null,
+ description: null,
+ subaddressIndex: null,
+ warnings: null,
+ };
+ }
+ }
const expiresAtRaw = String(formData.get("expires_at") ?? "").trim();
const expiresDate = String(formData.get("expires_date") ?? "").trim();
const expiresTime = String(formData.get("expires_time") ?? "").trim();
@@ -216,6 +247,7 @@ export async function createInvoiceAction(
currency: isFiat ? currency : null,
confirmation_target: confirmationTarget,
expires_at: expiresAt,
+ checkout_continue_url: checkoutContinueUrlRaw || null,
metadata:
Object.keys(metadata).length > 0 || qrPayload
? {
diff --git a/ui/app/(marketing)/docs/integrations/page.tsx b/ui/app/(marketing)/docs/integrations/page.tsx
index d01b54e..efad0a4 100644
--- a/ui/app/(marketing)/docs/integrations/page.tsx
+++ b/ui/app/(marketing)/docs/integrations/page.tsx
@@ -73,6 +73,11 @@ export default function IntegrationsPage() {
Create an invoice via{" "}
POST /api/core/invoices.
+
+ Optional: include{" "}
+ checkout_continue_url to show a
+ customer-facing Continue button after confirmation.
+
Redirect the customer to invoice_url.
diff --git a/ui/app/(marketing)/docs/page.tsx b/ui/app/(marketing)/docs/page.tsx
index 28682f9..2e0aeff 100644
--- a/ui/app/(marketing)/docs/page.tsx
+++ b/ui/app/(marketing)/docs/page.tsx
@@ -43,6 +43,11 @@ const endpointGroups = [
path: "/api/core/public/invoice/{invoice_id}",
description: "Read invoice status and payment details without auth.",
},
+ {
+ method: "GET",
+ path: "/api/core/public/invoice/{invoice_id}/continue",
+ description: "After confirmation, redirect to the merchant-provided Continue URL (if configured).",
+ },
],
},
{
@@ -241,6 +246,17 @@ const endpointRequirements = [
optional: [],
notes: ["Returns invoice status and payment details."],
},
+ {
+ method: "GET",
+ path: "/api/core/public/invoice/{invoice_id}/continue",
+ auth: "None",
+ required: ["invoice_id (path)"],
+ optional: [],
+ notes: [
+ "Only available after an invoice is confirmed.",
+ "Returns a 302 redirect to the merchant-provided checkout_continue_url (if configured).",
+ ],
+ },
{
method: "GET",
path: "/api/core/invoices",
@@ -352,6 +368,7 @@ const endpointRequirements = [
required: ["amount_xmr or amount_fiat + currency"],
optional: [
"confirmation_target (default 2, 0-10)",
+ "checkout_continue_url (https URL, optional post-confirmation Continue button)",
"metadata",
"expires_at (ISO 8601)",
],
@@ -614,6 +631,7 @@ Content-Type: application/json
"amount_fiat": "100.00",
"currency": "USD",
"confirmation_target": 2,
+ "checkout_continue_url": "https://merchant.example/thanks",
"metadata": { "order_id": "ORDER-1234" }
}`}
diff --git a/ui/app/(marketing)/invoice/[invoiceId]/page.tsx b/ui/app/(marketing)/invoice/[invoiceId]/page.tsx
index d1a09ac..c67cebe 100644
--- a/ui/app/(marketing)/invoice/[invoiceId]/page.tsx
+++ b/ui/app/(marketing)/invoice/[invoiceId]/page.tsx
@@ -29,6 +29,7 @@ type InvoiceStatusResponse = {
status: InvoiceStatus;
confirmation_target: number;
confirmations: number;
+ checkout_continue_available?: boolean;
qr_logo?: "monero" | "none" | "custom" | null;
qr_logo_data_url?: string | null;
created_at: string;
@@ -162,6 +163,8 @@ export default async function InvoiceStatusDetailPage({
const isInvalid = invoice.status === "invalid";
const hasDetectedPayment =
invoice.status === "payment_detected" || invoice.status === "confirmed";
+ const checkoutContinueAvailable =
+ invoice.status === "confirmed" && Boolean(invoice.checkout_continue_available);
const timelineItems: {
label: string;
timestamp: ReturnType;
@@ -326,6 +329,15 @@ export default async function InvoiceStatusDetailPage({
diff --git a/ui/app/i/[invoiceId]/page.tsx b/ui/app/i/[invoiceId]/page.tsx
index 9d2be33..a5a6016 100644
--- a/ui/app/i/[invoiceId]/page.tsx
+++ b/ui/app/i/[invoiceId]/page.tsx
@@ -30,6 +30,7 @@ type InvoiceStatusResponse = {
status: InvoiceStatus;
confirmation_target: number;
confirmations: number;
+ checkout_continue_available?: boolean;
created_at: string;
expires_at: string | null;
detected_at: string | null;
@@ -149,6 +150,10 @@ export default async function BtcpayModalInvoicePage({
const useClassicCheckout =
isBtcpayInvoice && invoice.btcpay_checkout_style === "btcpay_classic";
const shouldShowBtcpayActions = isBtcpayInvoice && invoice.status === "confirmed";
+ const checkoutContinueAvailable =
+ !isBtcpayInvoice &&
+ invoice.status === "confirmed" &&
+ Boolean(invoice.checkout_continue_available);
return (
@@ -241,6 +246,15 @@ export default async function BtcpayModalInvoicePage({
+ {checkoutContinueAvailable ? (
+
+ Continue to merchant
+
+ ) : null}
diff --git a/ui/components/create-invoice-card.tsx b/ui/components/create-invoice-card.tsx
index 19a011f..71f29dd 100644
--- a/ui/components/create-invoice-card.tsx
+++ b/ui/components/create-invoice-card.tsx
@@ -376,6 +376,22 @@ export default function CreateInvoiceCard() {
placeholder="Order 1042"
/>
+
+
+
+
+ After confirmation, the hosted invoice page shows a "Continue" button
+ that navigates to this URL.
+
+
+
+
+
setCheckoutContinueUrl(event.target.value)}
+ />
+
+ After confirmation, the hosted invoice page can show a Continue button.
+
+
) : null}