Payment Links Infrastructure on Stellar
Create hosted crypto checkout links, embed payment flows in your app, and receive instant on-chain confirmation.
Live App · API Reference · Quick Start · Security Model
- Overview
- Core Features
- Architecture
- How It Works
- Quick Start
- Project Structure
- API Endpoints
- Environment Variables
- Technology Stack
- Security Model
- Deployment
Link2Pay is a full-stack payment-link infrastructure platform built on Stellar. Builders create a payment link (payment intent), redirect users to hosted checkout, and track confirmation through API status polling. Payments settle in 3–5 seconds with near-zero network fees.
Traditional invoicing tools require:
- 2–3% payment processor fees with multi-day settlement delays
- Custodial services that hold funds on your behalf
- Complex exchange flows for multi-currency invoicing
- Trust in a third party for payment verification
Link2Pay solves this by settling payments directly on-chain using XLM, USDC, or EURC. Cryptographic proof of payment is stored permanently on Stellar's ledger — no third party required.
Create professional invoices with line items, tax rates, discounts, and due dates. Full support for XLM, USDC, and EURC.
Every invoice generates a unique public URL (/pay/:invoiceId). Clients open the link and pay directly — no account or registration required.
Merchants sign in with Freighter, a Privy embedded wallet (email / social login — no browser extension or seed phrase), or a wallet from Stellar Wallets Kit. Payers pay from any Stellar wallet, a QR / SEP-7 deep link, or Freighter — no account required. Link2Pay never holds funds; transactions are signed on the client and only the public key reaches the server.
Nonce-based ed25519 challenge-response authentication. Every authenticated request requires a server-issued nonce signed by the Freighter wallet — no passwords, no sessions, no JWTs.
3–5 second finality on the Stellar network. The payment watcher monitors Horizon in real time and marks invoices as PAID automatically upon on-chain confirmation.
Every invoice state transition (CREATED → SENT → PROCESSING → PAID) is recorded in InvoiceAuditLog with actor wallet and timestamp. The on-chain transaction hash provides independent, verifiable proof.
Live statistics — total invoices, revenue, pending amounts — scoped to the authenticated wallet. No data leakage across users.
Live XLM/USD equivalent shown at payment time via CoinGecko pricing, cached server-side to avoid rate limits.
Reuse client details across invoices with an optional favorites system and quick-fill at invoice creation.
Sign in with email or a social account and get a Stellar embedded wallet automatically — no extension, no seed phrase. Freighter and Stellar Wallets Kit remain fully supported for wallet-native users.
A dashboard Wallet page shows live on-chain balances (XLM / USDC / EURC), a deposit QR + address, activation status, and one-click USDC trustline setup — everything an embedded-wallet merchant needs to fund and receive.
Fixed-amount or open-amount links (the payer chooses how much to pay), optional no-expiration links, and the ability to cancel any open link — for both crypto and Bre-B (COP) payouts.
Direct-payment, business-invoice, and service-invoice types — with line items, tax, discounts, generated PDF documents, and reusable business-profile details.
Fiat (Bre-B) payout links require the merchant to clear KYC first (pluggable KycProvider; mock + Didit providers included). Crypto links are ungated.
The network is fixed per deployment: the primary domain runs Mainnet, and Testnet lives on its own testnet.* subdomain (a separate origin with isolated wallet/session state). Resolution order: VITE_STELLAR_NETWORK → a testnet. hostname → Mainnet default. There is no in-app network toggle.
A payer pays USDC on Stellar from any wallet, and the receiver gets local fiat (Colombian pesos) in their bank via the Bre-B rail — without Link2Pay ever holding funds. See Anchor off-ramp below.
flowchart TB
subgraph Browser["Client Browser"]
FE["React + Vite · TypeScript · TailwindCSS"]
FW["Freighter Wallet Extension"]
end
subgraph Backend["Backend API — Render"]
direction TB
EX["Express.js · Helmet · CORS · Rate Limiters"]
subgraph Services["Services"]
IS["InvoiceService"]
SS["StellarService"]
AS["AuthService"]
WS["WatcherService"]
end
MW["Zod Validation · requireWallet Middleware"]
EX --> MW --> Services
end
subgraph Data["Data Layer"]
PG[("PostgreSQL\n(Render / Supabase)")]
end
subgraph Stellar["Stellar Network"]
HZ["Horizon API\n(testnet / mainnet)"]
LDG["Stellar Ledger"]
end
Browser --"HTTPS / REST\nnonce + ed25519 sig"--> Backend
FW --"signs XDR\n(key never leaves device)"--> Browser
Backend --"Prisma ORM"--> PG
SS --"build & submit XDR"--> HZ
WS --"stream payments"--> HZ
HZ --> LDG
style Browser fill:#1e293b,color:#f8fafc
style Backend fill:#0f172a,color:#f8fafc
style Data fill:#1e293b,color:#f8fafc
style Stellar fill:#0f172a,color:#f8fafc
Trust Boundaries
| Boundary | Trust Level | Verification |
|---|---|---|
| Browser → Backend | Untrusted | Nonce + ed25519 signature on every request |
| Backend → Horizon | Trusted source, verified data | Transaction details re-verified independently |
| Backend → Database | Trusted (internal) | Prisma parameterized queries; no raw SQL |
| Watcher → Horizon | Trusted source, verified data | Invoice number in memo re-checked on confirm |
stateDiagram-v2
[*] --> DRAFT : POST /api/invoices
DRAFT --> PENDING : POST /invoices/{id}/send
DRAFT --> CANCELLED : DELETE /invoices/{id}
PENDING --> PROCESSING : POST /payments/{id}/pay-intent
PENDING --> EXPIRED : due date passed
PROCESSING --> PAID : Watcher detects on-chain payment
PAID --> [*] : immutable
CANCELLED --> [*]
EXPIRED --> [*]
note right of PAID
InvoiceAuditLog records:
actorWallet + transactionHash
Independent on-chain proof via Horizon
end note
sequenceDiagram
actor Freelancer
actor Client
participant FE as Frontend
participant BE as Backend API
participant HZ as Stellar Horizon
participant WS as Watcher Service
participant LDG as Stellar Ledger
Freelancer->>FE: Create invoice (items, currency, client)
FE->>BE: POST /api/invoices
BE-->>FE: Invoice created (status: DRAFT)
Freelancer->>FE: Send to client
FE->>BE: POST /api/invoices/:id/send
BE-->>FE: Status → PENDING
Freelancer->>Client: Share payment link /pay/:invoiceId
Client->>FE: Open payment link
FE->>BE: GET /api/invoices/:id (public view)
BE-->>FE: Invoice details (no wallet address exposed)
Client->>FE: Connect Freighter & click Pay
FE->>BE: POST /api/payments/:id/pay-intent
Note over BE: Amount read from DB — never from client
BE->>HZ: Fetch sequence number
HZ-->>BE: Account sequence
BE-->>FE: Unsigned XDR + invoice memo
FE->>FE: Freighter.signTransaction(xdr)
Note over FE: Private key never leaves device
FE->>BE: POST /api/payments/submit (signed XDR)
BE->>HZ: Submit transaction
HZ->>LDG: Execute & commit
LDG-->>HZ: Transaction confirmed
HZ-->>BE: tx hash
loop Every 5 seconds
WS->>HZ: Stream payments for watched address
HZ-->>WS: Payment event detected
WS->>BE: markAsPaid(invoiceId, txHash)
Note over BE: SERIALIZABLE isolation prevents double-pay
end
BE-->>FE: Status → PAID
FE-->>Client: Payment confirmed ✓
FE-->>Freelancer: Dashboard updated ✓
sequenceDiagram
participant FE as Frontend (auth.ts)
participant FW as Freighter Wallet
participant BE as Backend API
participant AS as AuthService
Note over FE: First authenticated request triggers auth flow
FE->>BE: POST /api/auth/nonce\n{ walletAddress }
BE->>AS: generateNonce(walletAddress)
AS-->>BE: { nonce, message, expiresAt }
Note over AS: Nonce stored with 5-min TTL\nSingle-use — consumed on verify
BE-->>FE: { nonce, message }
FE->>FW: signMessage(message)
FW-->>FE: hex-encoded ed25519 signature
Note over FW: Private key never leaves extension
Note over FE: Signature cached for 5 minutes\nAll requests include 3 headers:
FE->>BE: Any authenticated request\nx-wallet-address: G...\nx-auth-nonce: <nonce>\nx-auth-signature: <hex sig>
BE->>AS: verifySignature(address, nonce, signature)
Note over AS: 1. Verify nonce exists + not expired\n2. Verify ed25519 signature\n3. Consume nonce (mark used)
alt Valid signature
AS-->>BE: true
BE-->>FE: 200 OK + response
else Invalid / expired / replayed
AS-->>BE: false
BE-->>FE: 401 Unauthorized
Note over FE: Cache cleared → re-auth triggered
end
Built for the PULSO hackathon (Stellar / NearX, Colombia track). Link2Pay adds a non-custodial USDC→fiat off-ramp: a payer pays USDC on Stellar from any wallet, and the receiver gets Colombian pesos (COP) on their Bre-B llave — and Link2Pay never custodies funds or holds private keys.
- Anchor Platform — the SDF testnet reference anchor (
testanchor.stellar.org) the demo runs against, via SEP-10 (auth), SEP-38 (firm USDC→COP quote), and SEP-24 (interactive withdraw). This is the integration the off-ramp is built on; remove it and the feature does not function. - Abroad — the named Bre-B production off-ramp. The
AbroadAdapteris shipped as a documented, stubbed production path (Abroad has no public sandbox); the demo does not call it live.
All anchor interaction goes through one swappable AnchorAdapter interface, selected by the ANCHOR_PROVIDER env var:
ANCHOR_PROVIDER |
Adapter | What it does |
|---|---|---|
testnet |
TestAnchorAdapter |
Real SEP-10/38/24 against the SDF test anchor |
mock-breb |
MockBreBAdapter |
Demo hero rail — real on-chain USDC leg, simulated COP payout |
abroad |
AbroadAdapter |
Stubbed Abroad production path (requires real credentials) |
- ✅ Real on testnet: the SEP-10/38/24 anchor flow and the on-chain USDC payment (payer → anchor).
⚠️ Simulated: the Colombian peso payout in themock-brebdemo. It is labeled "Simulated Bre-B settlement (testnet demo)" everywhere it appears. No real pesos move.
1. Receiver creates a Bre-B invoice and enters their Bre-B llave (payout alias).
2. Receiver requests a firm USDC→COP quote (SEP-38) and initiates a SEP-24 withdraw,
which returns the anchor's deposit address + exact memo.
3. The payer opens the payment link and pays USDC straight to the ANCHOR's address
with that exact memo, signed by their own wallet (Freighter / Wallets Kit).
4. The anchor matches the payment by memo, pays out COP to the receiver's llave,
and the invoice advances to SETTLED_FIAT.
Link2Pay only orchestrates the quote, link, memo, status, and (optionally) an on-chain receipt — the payer always pays the anchor directly.
# backend/.env
ANCHOR_PROVIDER=mock-breb
ANCHOR_HOME_DOMAIN=testanchor.stellar.org- Create an invoice and choose Fiat off-ramp · Bre-B (COP), entering a llave alias.
- Open the invoice; in the Bre-B fiat off-ramp panel, get a quote → initiate. A payment link is generated.
- Open the payment link, connect a wallet, and pay USDC. Watch the invoice settle to COP (simulated, labeled).
| Variable | Example | Notes |
|---|---|---|
ANCHOR_PROVIDER |
testnet | mock-breb | abroad |
Selects the adapter (default testnet) |
ANCHOR_HOME_DOMAIN |
testanchor.stellar.org |
SEP-1 home domain for the anchor |
RECEIPT_CONTRACT_ID |
(optional) | Soroban receipt contract id, if deployed |
ABROAD_API_BASE / ABROAD_API_KEY |
(optional) | Production-only; do not set for the demo |
Hackathon project. Ready for demos and local deployments; production hardening is still in progress.
- Node.js v18+ (nodejs.org)
- Docker + Docker Compose (for local PostgreSQL)
- Freighter browser extension (freighter.app)
git clone https://github.com/Link2Pay/link2pay-app.git
cd link2pay-app
# Installs all dependencies, starts Docker Postgres,
# applies DB schema, and builds both services
./setup.sh./link2pay| Service | URL |
|---|---|
| Frontend | http://localhost:4173 |
| Backend API | http://localhost:3001 |
| Health check | http://localhost:3001/api/health |
# 1. Backend
cd backend
cp .env.example .env # fill in DATABASE_URL and other vars
npm install
npx prisma generate
npx prisma db push # or: npx prisma migrate deploy
npm run build
# 2. Frontend
cd ../frontend
cp .env.example .env # set VITE_API_URL=http://localhost:3001
npm install
npm run build
# 3. Start PostgreSQL
docker compose up -d postgres
# 4. Start backend (terminal 1)
cd backend && npm start
# 5. Start frontend (terminal 2)
cd frontend && npx vite preview --port 4173- Install Freighter browser extension
- Switch to Stellar Testnet in Freighter settings
- Fund your testnet account at friendbot.stellar.org
- Open http://localhost:4173 and connect wallet
link2pay-app/
├── backend/
│ ├── prisma/
│ │ └── schema.prisma # Invoice, Payment, Client, AuditLog models
│ └── src/
│ ├── index.ts # Express server, Helmet CSP, CORS, rate limiters
│ ├── config/
│ │ └── index.ts # Env var validation with Zod at startup
│ ├── routes/
│ │ ├── auth.ts # POST /auth/nonce — nonce issuance
│ │ ├── invoices.ts # Invoice CRUD + per-wallet rate limiter
│ │ ├── payments.ts # pay-intent, submit, confirm, status
│ │ ├── prices.ts # XLM/USD price feed (CoinGecko, cached)
│ │ └── clients.ts # Saved client book
│ ├── services/
│ │ ├── invoiceService.ts # Business logic + SERIALIZABLE transactions
│ │ ├── stellarService.ts # XDR construction, Horizon interaction
│ │ ├── watcherService.ts # Real-time Horizon payment stream watcher
│ │ └── authService.ts # Nonce generation + ed25519 verification
│ ├── middleware/
│ │ └── validation.ts # Zod request validation + requireWallet guard
│ ├── types/
│ │ └── index.ts # InvoicePublicView, InvoiceOwnerView, etc.
│ └── utils/
│ └── generators.ts # CUID / random invoice number generators
│
├── frontend/
│ └── src/
│ ├── App.tsx # Routes + context providers
│ ├── components/
│ │ ├── Layout.tsx # Sidebar + top navigation
│ │ ├── Invoice/ # InvoiceForm, InvoiceList, InvoiceDetail
│ │ ├── Wallet/ # Freighter connection flow
│ │ └── Payment/ # Public payment page (client-facing)
│ ├── services/
│ │ ├── api.ts # Typed backend API client
│ │ └── auth.ts # Nonce fetch + 5-min signature cache
│ ├── store/
│ │ └── walletStore.ts # Zustand wallet state + signMessage / signTx
│ ├── i18n/
│ │ └── translations.ts # EN / ES / PT translations
│ └── pages/
│ ├── Dashboard.tsx
│ └── CreateInvoice.tsx
│
├── SECURITY.md # STRIDE threat model + per-threat remediation status
├── docker-compose.yml # Local PostgreSQL on port 5433
├── setup.sh # One-command bootstrap script
├── link2pay # Launcher (Docker + backend + frontend)
└── README.md
- Freelancer connects Freighter wallet.
- Freelancer creates and shares an invoice link.
- Client opens
/pay/:invoiceId, connects wallet, signs payment. - Invoice status updates after on-chain confirmation.
All endpoints are prefixed with /api. Authenticated endpoints require three request headers:
x-wallet-address Stellar public key (G...)
x-auth-nonce Server-issued single-use nonce
x-auth-signature Hex-encoded ed25519 signature of the nonce message
| Method | Path | Description | Auth |
|---|---|---|---|
POST |
/auth/nonce |
Issue a single-use nonce (5-min TTL) | — |
| Method | Path | Description | Auth |
|---|---|---|---|
POST |
/invoices |
Create invoice (20/hr per wallet) | Wallet |
GET |
/invoices |
List authenticated wallet's invoices | Wallet |
GET |
/invoices/stats |
Dashboard statistics | Wallet |
GET |
/invoices/:id |
Public invoice view (no wallet address) | — |
GET |
/invoices/:id/owner |
Full invoice for owner | Wallet |
PATCH |
/invoices/:id |
Update DRAFT invoice | Wallet |
POST |
/invoices/:id/send |
Mark invoice PENDING | Wallet |
POST |
/invoices/:id/cancel |
Cancel an open invoice / link | Wallet |
DELETE |
/invoices/:id |
Soft-delete DRAFT invoice | Wallet |
- Linux
- Node.js
18+ - npm
- Docker Engine + Docker Compose plugin
| Method | Path | Description | Auth |
|---|---|---|---|
POST |
/payments/:id/pay-intent |
Build payment XDR (10 req / 5 min per IP) | — |
POST |
/payments/submit |
Submit signed XDR to Stellar | — |
POST |
/payments/confirm |
Manually confirm by transaction hash | — |
GET |
/payments/:id/status |
Poll payment status | — |
POST |
/payments/verify-tx |
Verify transaction on-chain | — |
| Method | Path | Description | Auth |
|---|---|---|---|
GET |
/prices/xlm |
XLM/USD rate (30 req / 60 s per IP) | — |
| Method | Path | Description | Auth |
|---|---|---|---|
GET |
/clients |
List saved clients | Wallet |
POST |
/clients |
Save a new client | Wallet |
PATCH |
/clients/:id/favorite |
Toggle favorite | Wallet |
DELETE |
/clients/:id |
Delete client | Wallet |
| Method | Path | Description | Auth |
|---|---|---|---|
GET |
/wallet/:publicKey/balances |
On-chain balances for any address (network via ?networkPassphrase=) |
— |
| Method | Path | Description | Auth |
|---|---|---|---|
GET |
/kyc/status |
Merchant KYC status | Wallet |
POST |
/kyc/start |
Begin verification | Wallet |
POST |
/kyc/mock/complete |
Complete mock verification (demo) | Wallet |
POST |
/kyc/webhook |
Provider webhook callback | — |
| Method | Path | Description | Auth |
|---|---|---|---|
POST |
/invoices/:id/offramp/quote |
Request a firm SEP-38 quote | Wallet |
POST |
/invoices/:id/offramp/initiate |
Initiate SEP-24 withdraw | Wallet |
POST |
/invoices/:id/offramp/set-amount |
Open-amount: payer sets amount → quote + initiate | — |
GET |
/invoices/:id/offramp/status |
Poll anchor settlement status | — |
POST |
/invoices/:id/offramp/pay-intent |
Build the USDC→anchor payment XDR | — |
POST |
/invoices/:id/offramp/submit |
Submit the signed off-ramp payment | — |
POST |
/invoices/:id/offramp/path-quote |
Preview a pay-in-any-asset route | — |
./setup.sh automates env file preparation, Docker PostgreSQL startup, dependency install, Prisma sync, and backend/frontend builds.
| Variable | Description | Default |
|---|---|---|
PORT |
Server port | 3001 |
DATABASE_URL |
PostgreSQL connection string | Required |
STELLAR_NETWORK |
testnet or mainnet |
testnet |
HORIZON_URL |
Stellar Horizon API URL | Testnet URL |
NETWORK_PASSPHRASE |
Stellar network passphrase | Testnet passphrase |
FRONTEND_URL |
Frontend origin for CORS allowlist | http://localhost:5173 |
WATCHER_POLL_INTERVAL_MS |
Payment watcher poll interval | 5000 |
NODE_ENV |
development or production |
development |
| Variable | Description | Default |
|---|---|---|
VITE_API_URL |
Backend API base URL | http://localhost:3001 |
VITE_STELLAR_NETWORK |
Stellar network | testnet |
VITE_HORIZON_URL |
Horizon API URL | Testnet URL |
VITE_NETWORK_PASSPHRASE |
Network passphrase | Testnet passphrase |
Stop local app with Ctrl+C.
| Layer | Technology |
|---|---|
| Framework | React 18, TypeScript, Vite |
| Styling | TailwindCSS |
| State Management | Zustand, React Query |
| Wallet | Freighter · Privy (embedded) · Stellar Wallets Kit |
| Internationalization | Custom (EN / ES / PT) |
| Layer | Technology |
|---|---|
| Runtime | Node.js 18+, TypeScript |
| Framework | Express.js |
| ORM | Prisma |
| Database | PostgreSQL |
| Logging | Winston (JSON in production) |
| Validation | Zod schemas on all request bodies |
| Layer | Technology |
|---|---|
| Network | Stellar (testnet / mainnet) |
| SDK | @stellar/stellar-sdk v15 |
| API | Horizon REST API |
| Wallet | Freighter · Privy (embedded) · Stellar Wallets Kit (ed25519 signing) |
| Supported Assets | XLM (native), USDC, EURC |
| Layer | Technology |
|---|---|
| HTTP Headers | Helmet.js — CSP, HSTS, X-Frame-Options |
| Authentication | Nonce + ed25519 challenge-response |
| Rate Limiting | express-rate-limit (global + per-endpoint + per-wallet) |
| Input Validation | Zod on all request bodies; Prisma parameterized queries |
| CORS | Restricted to known frontend origins |
Link2Pay applies STRIDE threat modeling (see SECURITY.md) and follows SDF Security Best Practices.
| Property | Implementation |
|---|---|
| Non-custodial | Private keys never leave Freighter. Server holds and verifies public keys only. |
| Cryptographic auth | Every authenticated request requires a server-issued nonce signed with the wallet's ed25519 key. |
| No address spoofing | requireWallet rejects requests missing x-auth-nonce or x-auth-signature. No legacy fallback. |
| Server-side amounts | Fixed-amount invoices read total from the DB. Open-amount links persist the payer's chosen amount server-side at pay time, and /payments/submit re-verifies the on-chain transaction actually pays the invoice (destination, asset, amount ≥ total) before marking PAID. |
| Non-sequential IDs | CUID for database IDs, random alphanumeric for invoice numbers — prevents enumeration. |
| Field-level access control | Public endpoints return InvoicePublicView (no wallet address). Owner endpoints require auth. |
| IDOR prevention | All owner queries filter by authenticated walletAddress at the database level. |
| Rate limiting | Global: 100 req/15 min · Pay-intent: 10 req/5 min · Invoice creation: 20/hr per wallet. |
| CSP + HSTS | Helmet configures Content-Security-Policy and Strict-Transport-Security on every response. |
| Audit trail | InvoiceAuditLog records every state transition with actor wallet and timestamp. |
| Race condition protection | markAsPaid uses SERIALIZABLE isolation to prevent double-payment on concurrent requests. |
| Attack Vector | Status | Mechanism |
|---|---|---|
| Wallet impersonation | ✅ Mitigated | ed25519 signature verification on every request |
| Nonce replay | ✅ Mitigated | Single-use nonces consumed on verify, 5-min TTL |
| Amount tampering | ✅ Mitigated | Fixed amounts from DB; open-amount payments re-verified on-chain at /payments/submit before PAID |
| XDR replay | ✅ Mitigated | 5-min transaction timeout + invoice memo binding |
| IDOR | ✅ Mitigated | DB-level wallet filter on all owner queries |
| ID enumeration | ✅ Mitigated | CUID + random alphanumeric invoice numbers |
| Wallet address disclosure | ✅ Mitigated | InvoicePublicView type excludes freelancerWallet |
| Invoice creation flood | ✅ Mitigated | 20 invoices/hr per wallet rate limit |
| Clickjacking | ✅ Mitigated | X-Frame-Options: DENY via Helmet |
| MIME sniffing | ✅ Mitigated | X-Content-Type-Options: nosniff via Helmet |
| Double payment | ✅ Mitigated | SERIALIZABLE DB transaction on markAsPaid |
| Variable | Required | Purpose |
|---|---|---|
DATABASE_URL |
Yes | PostgreSQL connection string |
PORT |
No | API port (default 3001) |
FRONTEND_URL |
No | CORS origin for frontend |
STELLAR_NETWORK |
No | testnet or mainnet |
HORIZON_URL |
No | Stellar Horizon endpoint |
NETWORK_PASSPHRASE |
No | Stellar network passphrase |
cd frontend
npm run build
vercel --prod
# vercel.json configures CSP, HSTS, Referrer-Policy, and cache headers automatically# Backend dev server (watch mode)
cd backend
npm run build
# Set all environment variables in the Render dashboard
# Build command: npm run build
# Start command: npm start
# On first deploy, run migrations:
npx prisma migrate deploySet DATABASE_URL from your provider's connection string, then apply the schema:
cd backend
DATABASE_URL="postgresql://..." npx prisma migrate deployBuilt on Stellar · Settled on-chain · Secured by ed25519