This document explains how the LiquiFact frontend communicates with the Express backend, outlines the relationship between the two, describes the current mocked state of the application, and serves as the official integration contract for future backend development.
The frontend determines the backend API URL using the NEXT_PUBLIC_API_URL environment variable.
Example configuration in local development:
NEXT_PUBLIC_API_URL=http://localhost:3001In the codebase, this is typically read as follows:
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
fetch(`${API_URL}/health`);This variable should be overridden in staging and production environments to point to the respective backend deployment URLs.
For details regarding query parameter conventions and filter controls implementation, please reference the FILTER_CONTRACTS.md document.
This document focuses on the HTTP payloads, endpoint contracts, and data models for frontend-backend communication.
The endpoints listed in this section reflect the existing code paths that are actively used in the frontend application today.
Status: Implemented Today
Method: GET /health
Purpose: Validate backend availability. This is currently invoked on the homepage to display the API status.
Example request:
GET /healthExample success response:
{
"status": "ok"
}Status: Implemented Today
Method: POST /invoices
Purpose: Upload and queue an invoice file for tokenization. This is actively used in the UploadZone component.
Request:
Content-Type: multipart/form-dataFields:
invoice:<invoice document (PDF)>
Example using curl:
curl -X POST -F "invoice=@invoice.pdf" http://localhost:3001/invoicesExample success response:
{
"message": "Upload successful",
"tokenizationDelay": 1500
}The frontend currently uses mock invoice data during development. All mock invoice
fixtures are defined exclusively in app/invest/lib.js — this is the single source
of truth until the real backend /invoices endpoint is wired up (see Retrieve Invoices
below).
- Fixture source:
app/invest/lib.js— exportsMOCK_INVOICES(the canonical array),loadMockInvoices(the async loader used as the defaultloadInvoicesprop inInvestMarketplace), andgetInvoiceById(used by the detail routeapp/invest/[id]). Do not redeclare these fixtures inline in any other module. - Health helper:
lib/api/health.jsprovides a real fetch wrapper for the health check.
loadMockInvoices checks window.__TEST_MOCK_INVOICES__ before returning the canonical
array. Playwright and Jest tests can override the fixture at runtime by setting this
global before the component mounts. The override is ignored in non-browser (SSR)
environments and has no effect in production builds.
app/invest/page.jsimportsloadMockInvoicesfrom./liband passes it as the default value for theloadInvoicesprop ofInvestMarketplace.app/invest/[id]/page.jscallsgetInvoiceByIdfrom./libfor the detail view.- Tests import
MOCK_INVOICES,loadMockInvoices, andgetInvoiceByIdfrom./libto assert against the shared fixture directly.
When the backend is ready, replace the default prop in InvestMarketplace:
// Before (mock)
import { loadMockInvoices } from "./lib";
export function InvestMarketplace({ loadInvoices = loadMockInvoices }) { … }
// After (real API)
import { fetchInvestableInvoices } from "../../lib/api/invoices";
export function InvestMarketplace({ loadInvoices = fetchInvestableInvoices }) { … }No other files need to change — the injectable loadInvoices prop keeps the mock and
production paths fully interchangeable.
The canonical invoice object used in the frontend UI requires the following shape to render correctly in the investment marketplace.
Canonical object example:
{
"id": "inv-001",
"issuer": "Acme Supplies Ltd",
"amount": "12,500",
"currency": "USD",
"dueDate": "2026-06-15",
"yield": "8.2%",
"status": "Open"
}Field Details:
| Field | Type | Required | Description |
|---|---|---|---|
id |
String | Yes | Unique identifier for the invoice |
issuer |
String | Yes | Name of the issuing entity |
amount |
String/Numeric | Yes | The invoice amount (formatted string with commas in mock, can adapt to numeric in future) |
currency |
String | Yes | ISO currency code (e.g., USD, EUR) |
dueDate |
String | Yes | Maturity date in ISO-8601 format (YYYY-MM-DD) |
yield |
String/Numeric | Yes | Estimated yield percentage |
status |
String | Yes | Current state of the invoice. Documented value in UI mock: "Open" |
Note: The frontend currently renders the amount and yield as strings, but a robust API integration should ideally provide these as numbers (e.g. amount: 12500, yield: 8.2) leaving formatting to the frontend presentation layer. For now, strings are shown based on the current mocked state.
fetchInvestableInvoices (lib/api/invoices.js) is the single boundary that
maps a raw backend payload onto the canonical shape above. Its guarantees,
pinned by lib/api/invoices.test.ts:
- Every field is always present. Each missing field — and every field of a
null/non-object entry — is defaulted tonull. - Only contract fields survive. Unknown extra keys in the raw payload are dropped, so malformed payloads cannot inject unexpected fields downstream.
yieldis mapped through verbatim to the UIyieldfield.- Error paths throw documented messages:
Failed to fetch invoices: …(not OK),Response is not valid JSON(bad body), andInvoice payload is not an array(non-array body).
These endpoints are not fully wired into the frontend today but represent the expected integration contract for the marketplace and invoice listings.
Status: Planned Future Integration
Method: GET /invoices
Purpose: Retrieve invoice listings for the investment marketplace.
Query params: Reference FILTER_CONTRACTS.md for supported filter and sort parameters.
Example request:
GET /invoices?status=Open&page=1Example success response:
{
"data": [
{
"id": "inv-001",
"issuer": "Acme Supplies Ltd",
"amount": "12,500",
"currency": "USD",
"dueDate": "2026-06-15",
"yield": "8.2%",
"status": "Open"
}
],
"meta": {
"page": 1,
"total": 1
}
}Status: Planned Future Integration
Method: GET /invoices/:id
Purpose: Retrieve details for a single invoice.
Example request:
GET /invoices/inv-001Example success response:
{
"data": {
"id": "inv-001",
"issuer": "Acme Supplies Ltd",
"amount": "12,500",
"currency": "USD",
"dueDate": "2026-06-15",
"yield": "8.2%",
"status": "Open"
}
}The frontend relies on standard error responses and HTTP status codes to surface issues to users appropriately.
APIs should return error responses matching the following structure:
{
"error": {
"code": "INVOICE_NOT_FOUND",
"message": "Invoice not found"
}
}Optional Validation Error Shape:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request payload",
"details": {
"field": "invoice"
}
}
}(Alternatively, simple {"message": "Upload failed"} is supported for compatibility with current UploadZone logic.)
| HTTP Status | Meaning | Frontend Behavior |
|---|---|---|
400 |
Bad Request / Validation | Show validation details or standard error message |
401 |
Unauthorized | Prompt login or wallet connection |
403 |
Forbidden | Show access denied message |
404 |
Not Found | Show not found state |
422 |
Unprocessable Entity | Highlight invalid fields (e.g., in upload forms) |
429 |
Too Many Requests | Show rate limit warning, encourage retry later |
500 |
Internal Server Error | Show generic failure message |
503 |
Service Unavailable | Show temporary outage message |
The application uses two distinct components to display errors to users:
- ErrorBanner: Use for persistent page-level failures. Network failures, failed page initialization (like an inability to load the invoice marketplace), or when the backend is entirely unavailable should use
ErrorBanner. - ToastProvider: Use for transient user actions and mutation outcomes. If an upload succeeded or failed, or for brief retry notifications, use
ToastProvidervia theuseToasthook.
fetchInvestableInvoices (in lib/api/invoices.js) protects the Invest marketplace against hung backends by aborting the request after a configurable deadline using the browser's AbortController API.
The default is 10 000 ms (10 s). Pass timeoutMs to override it per call-site:
import { fetchInvestableInvoices } from "@/lib/api/invoices";
// Use the default 10-second timeout
const invoices = await fetchInvestableInvoices();
// Custom 5-second timeout for a latency-sensitive context
const invoices = await fetchInvestableInvoices({ timeoutMs: 5000 });Callers may supply their own AbortSignal (e.g. tied to a React useEffect cleanup) alongside the timeout. Both are honoured simultaneously — whichever fires first cancels the request:
const controller = new AbortController();
// In a React component:
useEffect(() => {
fetchInvestableInvoices({ signal: controller.signal })
.then(setInvoices)
.catch(handleError);
return () => controller.abort(); // fires on unmount
}, []);When the timeout fires the function throws an InvoiceTimeoutError (a named subclass of Error). When the caller's signal fires it re-throws the original AbortError so the caller can tell the two apart:
import { fetchInvestableInvoices, InvoiceTimeoutError } from "@/lib/api/invoices";
try {
const invoices = await fetchInvestableInvoices({ signal, timeoutMs: 8000 });
} catch (err) {
if (err instanceof InvoiceTimeoutError) {
// Show retryable banner — backend did not respond in time.
} else if (err?.name === "AbortError") {
// Component unmounted or caller cancelled — suppress the error.
} else {
// Network / status / payload error — surface to the user.
}
}The InvoiceTimeoutError is retryable. The marketplace's error state already exposes a retry action and should treat this error class the same way it treats generic network failures.
The internal AbortController is created fresh per call. The timeout handle is always cleared in a finally block, so no timer or listener persists after the function resolves, rejects, or is aborted.
Handling network-level and unpredictable failures is critical to maintaining a robust user experience:
- Timeout / Offline: If a request times out,
fetchInvestableInvoicesthrows anInvoiceTimeoutError. Callers should show a retryableErrorBannerwith a prompt to retry or check their connection. See Request Timeout Handling above. - Invalid JSON: If the backend returns malformed JSON or an unexpected HTML response (such as from a proxy error), the frontend parser will fail. These should be caught as generic parsing errors and handled as persistent page-level failures (
ErrorBanner). - Unreachable Backend: Triggered when the server is down or
NEXT_PUBLIC_API_URLpoints to an invalid host. TheCheck API Healthfeature will catch this gracefully, but data-fetching pages like the marketplace will need to render a fallback error state (ErrorBanner). - Missing
NEXT_PUBLIC_API_URL: The app defaults tohttp://localhost:3001if this variable is unset. If the local backend is not running, it will result in an "Unreachable Backend" scenario.
To prevent client-side performance degradation or Denial of Service (DoS) attacks from oversized or deeply nested backend payloads, the frontend uses safety guards in the lib/format/safeJson utility module.
These utilities clean, depth-limit, and truncate data before rendering or parsing:
truncateString(value, maxLength): Limits the character length of a coerced string (default: 2000 characters). If the string exceeds the limit, it is sliced and appended with a…(truncated)marker.limitDepth(obj, maxDepth): Traverses an object or array and replaces any node nesting deeper thanmaxDepth(default: 5) with"[Depth limit reached]". It also detects circular references and replaces them with"[Circular]"to prevent serialization crashes.extractKnownFields(obj, fields): Filters an object to only include specified allowed keys (default:['status', 'message', 'version']), ignoring other fields.safeJsonStringify(obj, options): Wraps the depth limitation, standard serialization, and truncation workflows into a single helper. In case of unexpected serialization errors (e.g. nested BigInts), it gracefully falls back to a plain string representation of the object.
Version: v1.2 Last updated: 2026-06-29
This contract reflects the mocked frontend state as of today and sets the baseline for the upcoming full backend integration.
- All mock invoice fixtures are sourced from
app/invest/lib.js. - Please do not define duplicate mock arrays in UI components.