Directory structure
services/api/
api/ # HTTP endpoints (Vercel Functions)
anchors/
catalog.ts # List anchors in catalog
countries.ts # Get supported countries
diagnostics.ts # Anchor health diagnostics
ops.ts # Admin operations
compare-routes.ts # Main route comparison endpoint
execute-transfer.ts # Transfer execution (MVP)
generate-proof.ts # Proof of payment generation
health.ts # Health check
lib/ # Business logic and utilities
remittances/
compare/
service.ts # Route comparison orchestration
scoring.ts # Route scoring algorithm
fx.ts # Exchange rate lookup
schema.ts # Input validation
types.ts # TypeScript interfaces
stellar/ # Stellar protocol providers
sep1.ts # SEP-1: stellar.toml discovery
sep6.ts # SEP-6: Deposit/Withdrawal API
sep10.ts # SEP-10: Stellar Web Auth
sep24.ts # SEP-24: Hosted flows
capabilities.ts # Capability resolution
horizon.ts # Horizon API client
repositories/ # Database access layer
anchors-catalog.ts # Anchor catalog CRUD
anchor-events.ts # Event logging
http.ts # HTTP utilities
stellar.ts # Stellar config
supabase.ts # Supabase client
sql/ # Database migrations
001_anchors_catalog.sql
002_anchors_catalog_capabilities.sql
003_anchor_callback_events.sql
HTTP endpoints
/api/compare-routes (POST)
Main route comparison endpoint.
Request:
{
"origin": "US",
"destination": "MX",
"amount": 1000
}
{
"routes": [
{
"id": "route-abc123",
"originAnchor": {
"name": "MoneyGram Access",
"country": "US",
"currency": "USD"
},
"destinationAnchor": {
"name": "MoneyGram Access",
"country": "MX",
"currency": "MXN"
},
"feePercentage": 2.4,
"feeAmount": 24.00,
"estimatedTime": "8 min",
"exchangeRate": 18.5,
"receivedAmount": 18060,
"available": true,
"recommended": true
}
],
"meta": {
"origin": "US",
"destination": "MX",
"amount": 1000,
"queriedAt": "2026-03-03T15:30:00Z"
}
}
// api/compare-routes.ts
export default async function handler(
req: VercelRequest,
res: VercelResponse
) {
// 1. CORS handling
if (handleCorsPreflight(req, res, ["POST", "OPTIONS"])) return;
applyCors(req, res, ["POST", "OPTIONS"]);
// 2. Validate method
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
// 3. Parse and validate body
const parsed = readJsonBody(req);
if (!parsed.ok) {
return res.status(400).json({ error: "Invalid request body" });
}
const parsedInput = parseCompareRoutesInput(parsed.value);
if (!parsedInput.ok) {
return res.status(400).json({ error: parsedInput.error });
}
// 4. Call business logic
try {
const result = await compareRoutesWithAnchors(parsedInput.value);
return res.status(200).json(result);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return res.status(500).json({ error: message });
}
}
/api/anchors/countries (GET)
Returns list of countries with active anchors.
Response:
[
{ "code": "US", "name": "United States" },
{ "code": "MX", "name": "Mexico" },
{ "code": "BR", "name": "Brazil" }
]
/api/anchors/catalog (GET)
List all anchors with filtering.
Query parameters:
country: Filter by country (ISO 2-letter code)type: Filter by type (on-ramporoff-ramp)operationalOnly: Only return operational anchors
curl "https://api.payonproof.com/api/anchors/catalog?country=US&type=on-ramp&operationalOnly=true"
Business logic layer
Route comparison service
Location:lib/remittances/compare/service.ts
Core function:
export async function compareRoutesWithAnchors(input: CompareRoutesInput) {
// 1. Fetch anchors from database
const anchors = await getAnchorsForCorridor({
origin: input.origin,
destination: input.destination,
});
// 2. Resolve runtime capabilities (SEP endpoints, fees)
const runtimes = await Promise.all(anchors.map(resolveAnchorRuntime));
// 3. Filter operational anchors
const originAnchors = runtimes.filter(
(r) => r.catalog.type === "on-ramp" && r.operational
);
const destinationAnchors = runtimes.filter(
(r) => r.catalog.type === "off-ramp" && r.operational
);
// 4. Fetch exchange rate
const exchangeRate = await getFxRate(
originAnchors[0].catalog.currency,
destinationAnchors[0].catalog.currency
);
// 5. Build route matrix (origin × destination)
const routes: RemittanceRoute[] = [];
for (const originAnchor of originAnchors) {
for (const destinationAnchor of destinationAnchors) {
routes.push(buildRoute(input, originAnchor, destinationAnchor, exchangeRate));
}
}
// 6. Score and rank routes
const scored = scoreRoutes(routes).slice(0, MAX_ROUTES);
return { routes: scored, meta: { /* diagnostics */ } };
}
Anchor runtime resolution
Resolves live SEP capabilities for each anchor:async function resolveAnchorRuntime(anchor: AnchorCatalogEntry): Promise<AnchorRuntime> {
// Check if cached capabilities are fresh
const lastCheckedAtMs = Date.parse(anchor.capabilities.lastCheckedAt);
const shouldRefresh = Date.now() - lastCheckedAtMs > CAPABILITY_REFRESH_MS;
if (!shouldRefresh) {
// Use cached capabilities
return {
catalog: anchor,
sep: anchor.capabilities,
endpoints: { /* cached endpoints */ },
operational: anchor.capabilities.operational,
};
}
// Refresh from stellar.toml and SEP endpoints
try {
const resolved = await resolveAnchorCapabilities({
domain: anchor.domain,
assetCode: anchor.currency,
});
// Validate operational status
const operational = Boolean(
resolved.sep.sep10 &&
resolved.sep.sep24 &&
resolved.endpoints.webAuthEndpoint &&
resolved.endpoints.transferServerSep24
);
const runtime: AnchorRuntime = {
catalog: anchor,
sep: resolved.sep,
endpoints: resolved.endpoints,
operational,
diagnostics: resolved.diagnostics,
fees: resolved.fees,
};
// Update database with fresh capabilities
await updateAnchorCapabilities({
id: anchor.id,
...runtime.sep,
...runtime.endpoints,
operational,
lastCheckedAt: new Date().toISOString(),
});
return runtime;
} catch (error) {
// Mark as offline on error
return {
catalog: anchor,
sep: { sep24: false, sep6: false, sep31: false },
operational: false,
diagnostics: [`Capability resolution error: ${error.message}`],
};
}
}
Capabilities are cached for 10 minutes to reduce latency. After the cache expires, the next route comparison triggers a refresh.
Route scoring algorithm
Location:lib/remittances/compare/scoring.ts
export function scoreRoutes(routes: RemittanceRoute[]): RemittanceRoute[] {
const scored = routes.map((route) => {
let score = 100;
// Penalize higher fees (most important)
score -= route.feePercentage * 10;
// Reward faster routes
score += (30 - route.estimatedMinutes) * 0.5;
// Bonus for escrow protection
if (route.escrow) score += 5;
// Penalty for risks
score -= route.risks.length * 3;
return { ...route, score: Math.max(0, score) };
});
// Sort by score descending
scored.sort((a, b) => b.score - a.score);
// Mark top route as recommended
if (scored.length > 0) {
scored[0].recommended = true;
}
return scored;
}
Provider layer: Stellar integration
SEP-1: Anchor discovery
Location:lib/stellar/sep1.ts
export async function discoverAnchorFromDomain(
input: Sep1DiscoveryInput
): Promise<Sep1DiscoveryResult> {
const domain = normalizeDomain(input.domain);
const stellarTomlUrl = `https://${domain}/.well-known/stellar.toml`;
const response = await fetchWithTimeout(stellarTomlUrl, DEFAULT_TIMEOUT_MS);
if (!response.ok) {
throw new Error(`Failed to load stellar.toml (${response.status})`);
}
const text = await response.text();
const parsed = parseTomlFlat(text);
return {
domain,
stellarTomlUrl,
signingKey: parsed.SIGNING_KEY,
webAuthEndpoint: parsed.WEB_AUTH_ENDPOINT,
transferServerSep24: parsed.TRANSFER_SERVER_SEP0024,
transferServerSep6: parsed.TRANSFER_SERVER,
directPaymentServer: parsed.DIRECT_PAYMENT_SERVER,
kycServer: parsed.KYC_SERVER,
raw: parsed,
};
}
SEP-24: Hosted deposit/withdrawal
Location:lib/stellar/sep24.ts
export async function fetchSep24Info(input: Sep24InfoInput): Promise<{
infoUrl: string;
transferServerSep24: string;
info: unknown;
}> {
let transferServer = input.transferServerSep24;
// Auto-discover if only domain provided
if (!transferServer && input.domain) {
const discovered = await discoverAnchorFromDomain({ domain: input.domain });
transferServer = discovered.transferServerSep24;
}
if (!transferServer) {
throw new Error("TRANSFER_SERVER_SEP0024 not found");
}
const infoUrl = `${transferServer}/info`;
const response = await fetchWithTimeout(infoUrl, DEFAULT_TIMEOUT_MS);
if (!response.ok) {
throw new Error(`Failed to load SEP-24 /info (${response.status})`);
}
const info = await response.json();
return { infoUrl, transferServerSep24: transferServer, info };
}
Capability resolution
Location:lib/stellar/capabilities.ts
export async function resolveAnchorCapabilities(input: {
domain: string;
assetCode: string;
}): Promise<ResolvedAnchorCapabilities> {
const diagnostics: string[] = [];
// 1. Discover anchor from stellar.toml (SEP-1)
const sep1 = await discoverAnchorFromDomain({ domain: input.domain });
// 2. Check which SEPs are supported
const sep = {
sep24: Boolean(sep1.transferServerSep24),
sep6: Boolean(sep1.transferServerSep6),
sep31: Boolean(sep1.directPaymentServer),
sep10: Boolean(sep1.webAuthEndpoint),
};
// 3. Fetch SEP-24 /info for fees
let fees = { source: "default" as const };
if (sep1.transferServerSep24) {
try {
const r = await fetchSep24Info({ transferServerSep24: sep1.transferServerSep24 });
const extracted = extractFeeFromInfo(r.info, input.assetCode);
if (extracted) {
fees = { ...extracted, source: "sep24" };
}
} catch (error) {
diagnostics.push(`SEP-24 /info error: ${error.message}`);
}
}
// 4. Fallback to SEP-6 /info if needed
if (fees.source === "default" && sep1.transferServerSep6) {
try {
const r = await fetchSep6Info({ transferServerSep6: sep1.transferServerSep6 });
const extracted = extractFeeFromInfo(r.info, input.assetCode);
if (extracted) {
fees = { ...extracted, source: "sep6" };
}
} catch (error) {
diagnostics.push(`SEP-6 /info error: ${error.message}`);
}
}
return {
domain: sep1.domain,
sep,
endpoints: {
webAuthEndpoint: sep1.webAuthEndpoint,
transferServerSep24: sep1.transferServerSep24,
transferServerSep6: sep1.transferServerSep6,
directPaymentServer: sep1.directPaymentServer,
kycServer: sep1.kycServer,
},
fees,
diagnostics,
};
}
Repository layer: Database access
Anchors catalog repository
Location:lib/repositories/anchors-catalog.ts
export async function getAnchorsForCorridor(input: {
origin: string;
destination: string;
}): Promise<AnchorCatalogEntry[]> {
try {
const supabase = getSupabaseAdmin();
const { data, error } = await supabase
.from("anchors_catalog")
.select("*")
.eq("active", true)
.in("country", [input.origin, input.destination])
.in("type", ["on-ramp", "off-ramp"]);
if (error) {
throw new Error(`anchors_catalog query failed: ${error.message}`);
}
return filterAnchors(data.map(mapCatalogRow));
} catch {
// Fallback to local JSON export
const fallback = loadLocalFallbackAnchors();
return filterAnchors(
fallback.filter(
(anchor) => anchor.country === input.origin || anchor.country === input.destination
)
);
}
}
Local fallback ensures the API remains functional even if Supabase is temporarily unavailable.
Capability updates
export async function updateAnchorCapabilities(
input: CapabilityUpdateInput
): Promise<void> {
const supabase = getSupabaseAdmin();
const { error } = await supabase
.from("anchors_catalog")
.update({
sep24: input.sep24,
sep6: input.sep6,
sep31: input.sep31,
sep10: input.sep10,
operational: input.operational,
fee_fixed: input.feeFixed ?? null,
fee_percent: input.feePercent ?? null,
fee_source: input.feeSource ?? "default",
transfer_server_sep24: input.transferServerSep24 ?? null,
transfer_server_sep6: input.transferServerSep6 ?? null,
web_auth_endpoint: input.webAuthEndpoint ?? null,
diagnostics: input.diagnostics ?? [],
last_checked_at: input.lastCheckedAt,
updated_at: new Date().toISOString(),
})
.eq("id", input.id);
if (error) {
throw new Error(`anchors_catalog update failed: ${error.message}`);
}
}
Database schema
anchors_catalog table
CREATE TABLE anchors_catalog (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
domain TEXT NOT NULL,
country TEXT NOT NULL,
currency TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN ('on-ramp', 'off-ramp')),
active BOOLEAN DEFAULT true,
sep24 BOOLEAN DEFAULT false,
sep6 BOOLEAN DEFAULT false,
sep31 BOOLEAN DEFAULT false,
sep10 BOOLEAN DEFAULT false,
operational BOOLEAN DEFAULT false,
fee_fixed NUMERIC,
fee_percent NUMERIC,
fee_source TEXT,
transfer_server_sep24 TEXT,
transfer_server_sep6 TEXT,
web_auth_endpoint TEXT,
direct_payment_server TEXT,
kyc_server TEXT,
diagnostics JSONB DEFAULT '[]',
last_checked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
Environment configuration
# services/api/.env
# Stellar network
STELLAR_HORIZON_URL=https://horizon.stellar.org
STELLAR_NETWORK_PASSPHRASE="Public Global Stellar Network ; September 2015"
STELLAR_SIGNING_SECRET=S...
# Supabase
SUPABASE_URL=https://xxx.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJ...
# API configuration
ANCHOR_ALLOWED_ASSETS=USDC,XLM
ANCHOR_FALLBACK_FEE_PERCENT=1.5
MAX_COMPARE_ROUTES=12
Local development
cd services/api
npm install
npm run dev
http://localhost:3001.
Build process:
npm run build:local # Compiles TypeScript to .build/
node .build/local-server.js
Deployment
Vercel automatically deploys each function inapi/ as a serverless endpoint.
Production checklist:
- Set all environment variables in Vercel dashboard
- Run SQL migrations in Supabase
- Test
/api/healthendpoint - Verify CORS configuration for frontend domain
Next steps
Stellar integration
Deep dive into SEP protocols and blockchain interactions
Frontend architecture
Learn how the UI consumes these API endpoints