Add optional post-confirmation continue URL

This commit is contained in:
xmrcheckout
2026-01-08 16:43:11 +00:00
parent 6eb8d381c9
commit 6a3156991a
9 changed files with 210 additions and 1 deletions
+32
View File
@@ -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
? {
@@ -73,6 +73,11 @@ export default function IntegrationsPage() {
Create an invoice via{" "}
<span className="font-mono text-ink">POST /api/core/invoices</span>.
</li>
<li>
Optional: include{" "}
<span className="font-mono text-ink">checkout_continue_url</span> to show a
customer-facing Continue button after confirmation.
</li>
<li>
Redirect the customer to <span className="font-mono text-ink">invoice_url</span>.
</li>
+18
View File
@@ -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" }
}`}</code>
</pre>
@@ -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<typeof formatTimestamp>;
@@ -326,6 +329,15 @@ export default async function InvoiceStatusDetailPage({
</section>
<section className="mt-6 grid gap-4">
{checkoutContinueAvailable ? (
<a
className="inline-flex w-fit items-center justify-center rounded-full bg-ink px-5 py-2.5 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
href={`/api/core/public/invoice/${encodeURIComponent(invoiceId)}/continue`}
rel="noreferrer"
>
Continue to merchant
</a>
) : null}
<InvoiceStatusActions />
</section>
</main>
+14
View File
@@ -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 (
<main className="min-h-screen bg-cream px-6 py-6 text-ink">
@@ -241,6 +246,15 @@ export default async function BtcpayModalInvoicePage({
</p>
</div>
</div>
{checkoutContinueAvailable ? (
<a
className="mt-5 inline-flex w-fit items-center justify-center rounded-full bg-ink px-5 py-2 text-sm font-semibold text-cream shadow-[0_16px_30px_rgba(16,18,23,0.18)] transition hover:-translate-y-0.5"
href={`/api/core/public/invoice/${encodeURIComponent(invoiceId)}/continue`}
rel="noreferrer"
>
Continue to merchant
</a>
) : null}
</div>
<div className="mt-5">
+16
View File
@@ -376,6 +376,22 @@ export default function CreateInvoiceCard() {
placeholder="Order 1042"
/>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="checkout_continue_url">
Continue URL (optional)
</label>
<input
className={inputClass}
id="checkout_continue_url"
name="checkout_continue_url"
type="url"
placeholder="https://merchant.example/thanks"
/>
<p className="text-sm text-ink-soft">
After confirmation, the hosted invoice page shows a &quot;Continue&quot; button
that navigates to this URL.
</p>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="qr_color">
QR accent color
+27
View File
@@ -114,6 +114,7 @@ export default function InvoicePanel({
const [expiresTime, setExpiresTime] = useState("");
const [recipientName, setRecipientName] = useState("");
const [description, setDescription] = useState("");
const [checkoutContinueUrl, setCheckoutContinueUrl] = useState("");
const [archiveModalInvoiceId, setArchiveModalInvoiceId] = useState<string | null>(null);
const [searchInput, setSearchInput] = useState(searchQuery ?? "");
const [qrLogoMode, setQrLogoMode] = useState<
@@ -260,6 +261,7 @@ export default function InvoicePanel({
setExpiresTime("");
setRecipientName("");
setDescription("");
setCheckoutContinueUrl("");
setQrLogoMode("account_default");
setQrLogoDataUrl(null);
};
@@ -1044,6 +1046,23 @@ export default function InvoicePanel({
onChange={(event) => setDescription(event.target.value)}
/>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="wizard_checkout_continue_url">
Continue URL (optional)
</label>
<input
className={inputClass}
id="wizard_checkout_continue_url"
name="checkout_continue_url"
type="url"
placeholder="https://merchant.example/thanks"
value={checkoutContinueUrl}
onChange={(event) => setCheckoutContinueUrl(event.target.value)}
/>
<p className="text-sm text-ink-soft">
After confirmation, the hosted invoice page can show a Continue button.
</p>
</div>
<div className="grid gap-2">
<label className={labelClass} htmlFor="wizard_qr_logo_mode">
QR logo
@@ -1118,6 +1137,11 @@ export default function InvoicePanel({
<input type="hidden" name="expires_at" value={expiryValue} />
<input type="hidden" name="recipient_name" value={recipientName} />
<input type="hidden" name="description" value={description} />
<input
type="hidden"
name="checkout_continue_url"
value={checkoutContinueUrl}
/>
<input type="hidden" name="qr_logo_mode" value={qrLogoMode} />
<input
type="hidden"
@@ -1177,6 +1201,9 @@ export default function InvoicePanel({
<p className="mt-2 text-sm text-ink">
Description: <strong>{description || "-"}</strong>
</p>
<p className="mt-2 text-sm text-ink">
Continue URL: <strong>{checkoutContinueUrl || "-"}</strong>
</p>
</div>
</div>
) : null}