Skip to main content
Onramp and Pay Links both hand off from a web page to a Lightning wallet on the user’s phone through a deep link. The three browser-side patterns the handoff needs: opening the wallet reliably, tracking the order without leaking your API key, and showing the user what stage their payment is in.

Live example

flashnet-onramp-example.vercel.app

Source code

github.com/flashnetxyz/pay-link-example

How do I open the Lightning app?

The Cash App payment URL is a deep link. It only works when the browser navigates to it. A fetch() call just downloads the HTML response and nothing visible happens. Wrong:
// This silently fetches HTML, so Cash App never opens
await fetch(cashAppPaymentUrl);
Right:
// This navigates the browser, which triggers the Cash App universal link
window.location.href = cashAppPaymentUrl;
The same rule applies to Strike, Wallet of Satoshi, and any other Lightning app that handles a universal link. Navigate to the URL; do not fetch it.

How do I track order status with SSE?

Use Server-Sent Events for live order tracking. Connect through your proxy so the API key stays server-side. Close the connection on terminal statuses (completed, failed, refunded). Fall back to polling every 3 seconds if SSE disconnects.
const es = new EventSource(`/api/proxy/v1/sse/operations/${orderId}`);

es.addEventListener("status", (e) => {
  const { status } = JSON.parse(e.data);
  updateUI(status);
  if (["completed", "failed", "refunded"].includes(status)) es.close();
});
Proxy SSE through your backend. The browser should never see your API key. Detect /v1/sse/ paths in your proxy and stream the response with text/event-stream headers.
"use client";

import { useEffect, useRef, useState } from "react";

const TERMINAL_STATUSES = new Set(["completed", "failed", "refunded"]);

interface UseOrderSSEParams {
  orderId: string;
  onStatus: (status: string) => void;
  enabled?: boolean;
}

export function useOrderSSE({
  orderId,
  onStatus,
  enabled = true,
}: UseOrderSSEParams): { connected: boolean } {
  const [connected, setConnected] = useState(false);
  const onStatusRef = useRef(onStatus);
  onStatusRef.current = onStatus;

  useEffect(() => {
    if (!enabled || !orderId) return;

    const url = `/api/proxy/v1/sse/operations/${encodeURIComponent(orderId)}`;
    const es = new EventSource(url);

    es.addEventListener("status", (e) => {
      try {
        const data = JSON.parse(e.data) as { status: string };
        onStatusRef.current(data.status);
        if (TERMINAL_STATUSES.has(data.status)) {
          es.close();
          setConnected(false);
        }
      } catch {
        // ignore malformed events
      }
    });

    es.onopen = () => setConnected(true);
    es.onerror = () => setConnected(false);

    return () => {
      es.close();
      setConnected(false);
    };
  }, [orderId, enabled]);

  return { connected };
}

How do I proxy the API key?

Keep the API key on the server. The browser calls your proxy, the proxy attaches the Authorization header, and the response streams back. For SSE paths, the proxy streams the upstream body as-is with text/event-stream headers so the browser keeps the connection open.
// SSE: stream the response back as-is
if (isSSE && upstream.body) {
  return new Response(upstream.body, {
    status: upstream.status,
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
    },
  });
}

How do I show pipeline progress?

Orders move through a sequence of statuses. Surface the current stage so the user knows the payment is working.
StatusMeaning
confirmingPayment detected, waiting for confirmation
swappingConverting BTC to stablecoin
bridgingSending to destination chain (if applicable)
completedFunds delivered
Use the SSE status events to animate transitions between stages. The live example shows a step-by-step pipeline visualization with a countdown timer.