The Stellar-native settlement engine behind Mergepay.
Authentication, group & expense logic, the settlement engine, Stellar integration, treasury multisig, anchor (SEP-24) flows, and background jobs.
| Maintainer | Role | GitHub |
|---|---|---|
| Fuhad (K1NGD4VID) | Maintainer | @K1NGD4VID |
Questions and contributions are welcome — open an issue or PR, or start a discussion.
Mergepay is a Stellar-native group settlement app that turns shared spending into
transparent, auditable, low-fee on-chain payments for friends, roommates, and
small communities. This is the backend; the frontend lives in
mergepay-web.
Built on Stellar. Every settlement is a real on-chain Stellar payment: login is SEP-10 wallet auth, payments carry a
MP:<code>memo linking them to an expense, balances settle in XLM or USDC over trustlines, shared treasuries use Stellar multisig, and fiat on/off-ramp goes through SEP-24 anchors. The server never holds user keys — it builds unsigned XDRs that the user's wallet signs.🌊 Open to contributors via Drips Wave (Stellar ecosystem). Scoped, bounty-ready issues live in DRIPS_WAVE.md; see CONTRIBUTING.md to get started.
- SEP-10 — wallet-based auth; the user's public key is their identity.
- Payments + memos — every settlement is an on-chain payment carrying a
MP:<code>memo that links it to a specific expense. - Trustlines — settle in native XLM or a stable asset (USDC by default).
- Multisig — shared treasuries can require multiple signers for withdrawals.
- SEP-24 — anchor deposit/withdraw bridges fiat and Stellar.
Private keys never touch the server. The API builds unsigned transaction envelopes; the user's wallet signs them; the API validates the signed XDR against the original intent and submits it to Horizon. The only key the server holds is its own SEP-10 signing key.
┌──────────────┐
wallet ────▶ │ mergepay-web│ (Next.js)
└──────┬───────┘
│ REST + Bearer JWT
┌──────▼───────┐ ┌──────────────┐
│ mergepay-api│◀────▶│ PostgreSQL │
│ (Fastify) │ └──────────────┘
└──┬────────┬──┘
build/submit│ │ poll status
┌──▼──┐ ┌──▼─────────┐
│Horizon│ │ worker │ (settlement + anchor reconciliation)
└──────┘ └────────────┘
▲
│ SEP-10 / SEP-24
┌────┴─────┐
│ Anchor │
└──────────┘
- Node.js 20+
- PostgreSQL 14+
- A Stellar SEP-10 signing keypair (generate one below)
For the quickest local dev path against Stellar testnet, see docs/LOCAL_SETUP.md.
git clone https://github.com/mergepay/mergepay-api.git
cd mergepay-api
npm install
cp .env.example .env
# Generate a server SEP-10 signing key and paste the secret into .env
npm run gen:sep10key
# Create the database schema
npm run prisma:generate
npm run prisma:migrate # creates tables (needs DATABASE_URL)
# (optional) demo data
npm run db:seed
# Run it
npm run dev # API on :4000
npm run worker # background reconciliation worker (separate shell)For local development, use PostgreSQL 14+ and Node.js 20+. Start PostgreSQL,
then create the mergepay database once:
createdb mergepayCopy .env.example to .env if you have not already, and set DATABASE_URL
to a connection string for that database, for example:
DATABASE_URL=postgresql://postgres:your-password@localhost:5432/mergepayGenerate the Prisma client and apply the migrations to initialize the schema:
npm run prisma:generate
npm run prisma:migrateTo add the local development seed data, run:
npm run db:seednpm run db:seed runs prisma/seed.ts and populates a
disposable demo dataset so API endpoints can be exercised immediately, without
manual bootstrapping. The script is idempotent: every row is written with
an upsert keyed by a deterministic id (or another natural unique key), so
running it again never throws a unique constraint violation and never
duplicates data. A re-run also restores any seed-owned row to its canonical
demo values. Rows left behind by older versions of the seed (random ids) are
untouched — delete them by hand if you want a clean slate.
| Row | Details |
|---|---|
| Users | Ada, Kola, Zo, Tunde — deterministic testnet keypairs |
| Groups | Lagos Trip (4 members) and Flat 12B (3 members, treasury enabled) |
| Expenses | Dinner (equal), Airport transfer (equal), Groceries (custom split), Wi-Fi subscription (equal) |
| Settlements | SEEDSETTLE confirmed, SEEDQUEUE2 pending signature, SEEDRETRY2 failed and retryable — each with status history |
| Treasury | confirmed deposit SEEDTREASR (100 XLM) and pending deposit SEEDGRANT2 (50 XLM) in Flat 12B |
| Invite | code SEEDCLUB for Lagos Trip (max 10 uses) |
The demo accounts are derived from public labels (mergepay:demo:…), so their
secret keys are recomputable by anyone: use them only in local or testnet
databases and never fund them with anything of value. To sign demo
transactions (for example in Stellar Laboratory), print the secret seeds with:
SEED_PRINT_SECRETS=1 npm run db:seedSeeded intents carry expiresAt = null, which the API reads as "no recorded
deadline", so demo rows stay actionable instead of expiring while the database
sits idle.
New to the codebase? The typing standards enforced across src/ are documented in TypeScript strict mode.
See .env.example. Key ones:
| Variable | Description |
|---|---|
DATABASE_URL |
PostgreSQL connection string |
JWT_SECRET |
Secret for signing session JWTs (default 15min expiry, configurable via ACCESS_TOKEN_TTL_SECONDS) |
JWT_ISSUER |
JWT issuer claim (default: mergepay-api) |
JWT_AUDIENCE |
JWT audience claim (default: mergepay-app) |
ACCESS_TOKEN_TTL_SECONDS |
Access token lifetime in seconds (default: 900 / 15 minutes) |
REFRESH_TOKEN_TTL_MS |
Refresh token lifetime in milliseconds (default: 30 days) |
STELLAR_NETWORK |
testnet or public |
HORIZON_URL |
Horizon server |
SEP10_SIGNING_SECRET |
Server's SEP-10 signing key (npm run gen:sep10key) |
WEB_URL |
Frontend origin allow-list for CORS + invite links (comma-separated; * for local dev) |
ANCHOR_HOME_DOMAIN |
SEP-24 anchor home domain (default SDF test anchor) |
ANCHOR_WEBHOOK_SECRET |
Shared secret for the anchor webhook |
STABLE_ASSET_CODE / STABLE_ASSET_ISSUER |
Stable asset for settlement |
Prisma is initialized in src/db.ts with explicit connection
resilience settings so a slow, saturated, or partitioned PostgreSQL fails
fast instead of hanging request workers indefinitely. The values below are
appended to DATABASE_URL as query parameters (buildDatasourceUrl) and
forwarded to the underlying driver; a per-query middleware adds a wall-clock
budget on top.
| Variable | Default | Description |
|---|---|---|
DATABASE_CONNECT_TIMEOUT_SECONDS |
10 | Max time to establish a socket to Postgres |
DATABASE_POOL_TIMEOUT_SECONDS |
10 | Max wait for a free pooled connection before erroring |
DATABASE_CONNECTION_LIMIT |
5 | Max pooled connections per instance |
DATABASE_QUERY_TIMEOUT_MS |
10000 | Per-query wall-clock budget enforced by middleware |
Parameters already present in DATABASE_URL are overridden by these values,
so the effective timeout policy is always the one configured here. When a
query exceeds DATABASE_QUERY_TIMEOUT_MS it rejects with Query timeout after Nms, the request fails promptly, and the health check
(checkDatabaseConnection, used by /health/ready) applies the same budget.
Cross-origin access for the frontend (mergepay-web) is configured entirely
from the environment: src/app.ts registers @fastify/cors with the options
built by src/lib/cors.ts. Preflights are answered 204 inside the plugin's
onRequest hook — ahead of authentication and rate limiting — because a
browser never sends an Authorization header on an OPTIONS probe.
| Variable | Default | Description |
|---|---|---|
WEB_URL |
"" (deny cross-origin) |
Origin allow-list, comma-separated; * reflects any origin and is for local development only (the shipped .env.example sets *) |
CORS_ALLOW_CREDENTIALS |
false |
Whether cross-origin requests may carry credentials; never enable alongside WEB_URL=* outside local development |
CORS_ALLOW_METHODS |
GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS |
Methods advertised on a preflight — restricted to this list, never echoed from the request |
CORS_ALLOW_HEADERS |
Content-Type,Authorization,X-Requested-With,Idempotency-Key |
Request headers a cross-origin request may send |
CORS_EXPOSE_HEADERS |
X-Request-ID,X-Correlation-ID,X-RateLimit-*,Retry-After |
Response headers made readable to the caller |
CORS_MAX_AGE |
86400 |
Preflight cache lifetime in seconds |
An empty WEB_URL denies every cross-origin request while leaving same-origin
and non-browser clients (no Origin header) to the routes' own
authentication. If WEB_URL names a *.vercel.app host, preview deployments
of the frontend (mergepay-web-*.vercel.app) are allowed too.
Read-only Horizon calls (currently fee statistics) retry transient failures — timeouts, connection resets, and selected 5xx responses — with bounded, configurable backoff so a temporary upstream blip does not immediately fail a recoverable read. Transaction submission is never transparently retried by this helper.
| Variable | Default | Description |
|---|---|---|
HORIZON_READ_RETRY_MAX_ATTEMPTS |
3 | Total attempts (including the first) for a transiently failing read |
HORIZON_READ_RETRY_INITIAL_DELAY_MS |
250 | Backoff before the first retry |
HORIZON_READ_RETRY_MAX_DELAY_MS |
2000 | Cap on the exponential backoff |
General retry configuration for safe Horizon and anchor reads (see src/services/retry.ts):
| Variable | Default | Description |
|---|---|---|
UPSTREAM_RETRY_MAX_ATTEMPTS |
3 | Total retry attempts for safe upstream calls |
UPSTREAM_RETRY_INITIAL_DELAY_MS |
200 | Initial delay before first retry |
UPSTREAM_RETRY_MAX_DELAY_MS |
2000 | Maximum delay cap for exponential backoff |
UPSTREAM_RETRY_JITTER_RATIO |
0.25 | Fraction of delay applied as random jitter |
HORIZON_RETRY_ON_RATE_LIMIT |
true | Horizon reads in src/services/stellar.ts also retry HTTP 429 (honouring Retry-After up to UPSTREAM_RETRY_MAX_DELAY_MS). Submissions are never retried. |
Idempotency for POST /api/settlements/execute prevents duplicate submissions:
| Variable | Default | Description |
|---|---|---|
IDEMPOTENCY_TTL_MS |
86400000 (24h) | How long a completed reservation replays its stored status |
IDEMPOTENCY_IN_PROGRESS_TIMEOUT_MS |
60000 (1m) | Timeout for in-progress reservations before they can be reclaimed |
Configuration for SEP-24 anchor callbacks (POST /api/webhooks/sep24):
| Variable | Default | Description |
|---|---|---|
SEP24_WEBHOOK_SECRETS |
"" | Per-anchor HMAC secrets as "anchorName:secret,anchorName:secret" |
SEP24_WEBHOOK_TOLERANCE_MS |
300000 (5m) | Maximum timestamp deviation allowed for anchor callbacks |
Every route is covered by a global default limit
(RATE_LIMIT_GLOBAL_MAX / RATE_LIMIT_GLOBAL_WINDOW_MS, default 100 per
minute). /health and /docs are exempt from it so probes and the API
reference stay reachable during an incident. Endpoints with a different
traffic pattern or trust boundary replace that default with their own bucket:
| Route(s) | Variables | Default |
|---|---|---|
POST /auth/challenge |
RATE_LIMIT_AUTH_CHALLENGE_MAX / _WINDOW_MS |
20 / 1 min |
POST /auth/verify, POST /auth/refresh |
RATE_LIMIT_AUTH_VERIFY_MAX / _WINDOW_MS |
10 / 1 min |
POST /groups/:id/expenses |
RATE_LIMIT_EXPENSE_CREATE_MAX / _WINDOW_MS |
30 / 1 min |
POST /expenses/:id/settle, POST /groups/:id/settlements, POST /groups/:id/treasury/deposit, POST /groups/:id/treasury/withdraw |
RATE_LIMIT_SETTLEMENT_CREATE_MAX / _WINDOW_MS |
20 / 1 min |
POST /settlements/:id/confirm, POST /withdraw/:id/confirm |
RATE_LIMIT_SETTLEMENT_CONFIRM_MAX / _WINDOW_MS |
20 / 1 min |
POST /api/settlements/execute |
RATE_LIMIT_SETTLEMENT_EXECUTE_MAX / _WINDOW_MS |
20 / 1 min |
POST /treasury-transactions/:id/confirm, POST /groups/:groupId/treasury/proposals/:proposalId/sign, POST /api/treasury/proposals/:id/signatures |
RATE_LIMIT_TREASURY_SUBMIT_MAX / _WINDOW_MS |
30 / 1 min |
POST /groups/:groupId/treasury/proposals, POST /api/treasury/proposals |
RATE_LIMIT_TREASURY_PROPOSE_MAX / _WINDOW_MS |
20 / 1 min |
POST /anchors/deposit, POST /anchors/withdraw, POST /anchors/sessions/:id/complete, POST /api/sep24/deposit, POST /api/sep24/withdraw, POST /withdraw |
RATE_LIMIT_ANCHOR_INIT_MAX / _WINDOW_MS |
10 / 1 min |
GET /anchors, GET /anchors/sessions, GET /anchors/sessions/:id |
RATE_LIMIT_ANCHOR_POLL_MAX / _WINDOW_MS |
60 / 1 min |
POST /anchors/webhook |
RATE_LIMIT_ANCHOR_WEBHOOK_MAX / _WINDOW_MS |
50 / 1 min |
POST /api/sep24/callback, POST /api/webhooks/sep24 |
SEP24_RATE_LIMIT_MAX / _WINDOW_MS |
10 / 1 min |
POST /groups |
RATE_LIMIT_GROUP / _WINDOW_MS |
10 / 1 min |
GET /history |
RATE_LIMIT_HISTORY / _WINDOW_MS |
30 / 1 min |
Tuning a deployment. Every value above is an environment variable with a
safe default, so a deployment overrides only what it needs — for example a
wallet integration that legitimately retries submissions can raise
RATE_LIMIT_SETTLEMENT_CONFIRM_MAX without loosening SEP-10 or anchor
budgets. Windows are milliseconds and are rejected at startup above one hour;
maximums must be positive integers. Both bounds exist so a typo cannot silently
disable limiting. The single source of truth for which route gets which policy
is the table in src/lib/rate-limit.ts; routes name a
policy rather than repeating numbers, and each policy has its own key prefix,
which is what makes the buckets independent. The registration of the limiter
itself — global limits, key strategy, counter store, and the 429 body — is in
src/plugins/rate-limit.ts.
Every route above has a bucket separate from ordinary authenticated reads, so exhausting a submission or anchor budget never blocks a client from reading its own groups, expenses, or settlement status.
Limit keys are the authenticated user's SEP-10 public key when the request
carries a session, and the resolved client IP otherwise. SEP-10 has no session
yet, so /auth/challenge, /auth/verify, and /auth/refresh are keyed by IP
alone: a public-key bucket there would make the 429 threshold depend on whether
an account is known to the API, turning the limiter into an account oracle.
req.ip does not trust X-Forwarded-For unless Fastify's trustProxy option
is explicitly enabled, which this app does not do by default. If you deploy
behind a reverse proxy or load balancer and want per-client (rather than
per-proxy) limiting, enable trustProxy in src/app.ts and make sure only your
proxy can reach the app directly.
The anchor webhook's rate limit is abuse protection only — it never
replaces the shared-secret (ANCHOR_WEBHOOK_SECRET) check, which remains
the actual authentication gate for that route.
By default (RATE_LIMIT_STORE=memory) counters live in each API process's
memory, which is fine for a single instance. Set RATE_LIMIT_STORE=database
to share counters across multiple instances via a small Postgres-backed
store (rate_limit_buckets table, see
src/services/rate-limit-store.ts). That store fails open: if a count
query errors (e.g. a transient database outage), the request is allowed
through rather than the whole API returning 500s — a degraded rate limiter
is preferable to a full outage. Every 429 response includes standard
Retry-After / X-RateLimit-* headers and the standard error envelope
({"error": ..., "code": "RATE_LIMITED", "message": ..., "requestId": ...}).
The API enforces explicit limits on JSON bodies and multipart uploads to prevent memory exhaustion and DoS attacks:
| Type | Variable | Default | Description |
|---|---|---|---|
| JSON body | JSON_BODY_LIMIT_BYTES |
256 KB | All JSON request bodies (auth, settlements, anchors) |
| Multipart file | MULTIPART_FILE_SIZE_BYTES |
5 MB | Max file size in multipart uploads (e.g. receipts) |
| Multipart files | MULTIPART_MAX_FILES |
1 | Maximum number of files per multipart request |
| Multipart fields | MULTIPART_MAX_FIELDS |
10 | Maximum number of form fields per multipart request |
Oversized requests are rejected with a 413 Payload Too Large or 400 Bad Request
response before expensive business logic or external Stellar calls run. Request
bodies are fully validated with Zod before use; the size limits ensure the
validator runs efficiently.
The background worker drives settlement submission and SEP-24 anchor polling
with the same claim-based rules (see src/worker/index.ts):
- Claim before work. A job is claimed with a conditional update that writes
a lease (
claimedBy,leaseExpiresAt). Two workers can never drive the same transition. A crashed process leaves its lease behind; at the top of every cyclerecoverStaleSettlements()/recoverStaleAnchorSessions()free expired leases (default lease timeout:WORKER_LEASE_TIMEOUT_MS, 60s), so a restart resumes work without duplicating it. - Classify before retrying. Every failure is categorised by
src/services/job-retry.tsintotransient(rate limits, outages — safe to retry),indeterminate(timeout, dropped socket — the worker checks Horizon by the envelope's deterministic hash before resubmitting, so an already applied transaction is never submitted twice), andpermanent(rejected transaction, expired intent, authorization — no retry, the job fails). - Persist the job state. Attempts, the next eligible time, the failure
category, and the final reason are columns on the row (
retryCount,nextAttemptAt,errorCategory,failureReason), never process memory. An exhausted job stops retrying and is markedfailedwith a sanitized reason; retries and failures are also recorded in the audit log / status history.
Retry budgets are exponential with jitter and fully configurable via env vars
(see .env.example):
WORKER_SETTLEMENT_MAX_ATTEMPTS(default 3),WORKER_SETTLEMENT_RETRY_INITIAL_DELAY_MS(default 1000),WORKER_SETTLEMENT_RETRY_MAX_DELAY_MS(default 30000),WORKER_SETTLEMENT_RETRY_JITTER_RATIO(default 0.25)WORKER_ANCHOR_MAX_ATTEMPTS(default 5),WORKER_ANCHOR_RETRY_INITIAL_DELAY_MS(default 5000),WORKER_ANCHOR_RETRY_MAX_DELAY_MS(default 120000),WORKER_ANCHOR_RETRY_JITTER_RATIO(default 0.25)WORKER_CYCLE_TASK_MAX_ATTEMPTS(default 3),WORKER_CYCLE_TASK_RETRY_INITIAL_DELAY_MS(default 500),WORKER_CYCLE_TASK_RETRY_MAX_DELAY_MS(default 10000) — retries for the cycle tasks themselves (issue #708). A sweep that throws a transient database or Horizon error is retried in-cycle with exponential backoff; permanent and indeterminate failures are left to the next cycle. A task whose budget is exhausted is dead-lettered as a critical log line for that cycle while its sibling tasks continue.WORKER_HEALTH_UNHEALTHY_THRESHOLD(default 3) — consecutive failed cycles before the per-cycleworker_healthheartbeat reportshealthy: falseand a critical health line is emitted.
POST /auth/challenge builds a challenge transaction signed by the server key.
The wallet signs it; POST /auth/verify validates the signature (handling
unfunded accounts via the master key), upserts the user, and returns a JWT.
Challenge transactions carry a strictly validated validity window: the
envelope's own minTime/maxTime are checked against server time with a
bounded 30-second clock-skew tolerance. A challenge whose maxTime has elapsed
is rejected with 401 CHALLENGE_EXPIRED (the remedy is to request and sign a
fresh one); one whose minTime has not been reached returns
CHALLENGE_NOT_YET_VALID, and a window longer than the 300s validity the
server issues returns CHALLENGE_WINDOW_TOO_LONG. All other verification
failures stay the generic 401 UNAUTHORIZED, so rejections cannot be probed
for which structural check failed. Challenges are single-use (durable replay
detection), and the worker's challenge cleanup purges replay records once
their window closes, keeping them for 24h forensics before deletion.
POST /expenses/:id/settle(orPOST /groups/:id/settlements) builds an unsigned payment XDR — correct source, destination, asset, amount, and aMP:<shortCode>memo — and records apendingsettlement.- The wallet signs the XDR.
POST /settlements/:id/confirmre-parses the signed XDR, validates it matches the stored intent exactly (source, single payment op, destination, asset, amount, memo, time bounds) and rejects mismatches withxdr_mismatch, then submits to Horizon, stores the tx hash, and marks the expense sharesettled.GET /settlements/:id/statusis the single source of truth from then on. It combines persisted state with a bounded Horizon lookup and reports one ofawaiting_signature,submitted,confirmed,failed, orexpired. A transaction Horizon has not indexed yet is reported assubmittedwithonChain.found: false— never as a confirmed payment. See docs/api-contract.md.
Every unsigned XDR the API builds carries a server-controlled deadline,
recorded on the row as expiresAt and set as the transaction's own maxTime so
the stored intent and the on-chain envelope describe the same moment. Creation
responses include expiresAt and expiresInSeconds.
- The deadline comes from the server clock. A client may request a shorter
window via
validitySeconds(30–300s); it can never extend one, and it never supplies an absolute timestamp. An out-of-range request is aVALIDATION_ERROR. - Signing and submission re-check the deadline.
POST /settlements/:id/confirmandPOST /treasury-transactions/:id/confirmreject a stale intent withINTENT_EXPIRED(400) — deliberately distinct fromXDR_MISMATCH(the envelope is wrong) andUNAUTHORIZED/FORBIDDEN(the caller is wrong), so a client knows to request a fresh transaction rather than to debug. - Submission also validates the signed envelope's own time bounds against the
stored intent: an unbounded envelope, or one valid longer than the intent it
was built for, is an
XDR_MISMATCH. No expired transaction is ever sent to Horizon or an anchor — the worker marks such a settlementexpiredand releases its expense share instead of retrying. - Comparisons allow a bounded 30-second clock-skew tolerance
(
CLOCK_SKEW_TOLERANCE_SECONDSin src/lib/time-bounds.ts), so a wallet whose clock is a few seconds off still works while a genuinely stale envelope is still rejected. It is a constant rather than a config knob because widening it weakens replay protection proportionally.
A group registers a Stellar account it created in a wallet (the API never holds
the key). Deposits are signed by the depositor; withdrawals are signed from the
treasury account and, when treasuryRequiredSigners > 1, returned in
awaiting_signatures for additional signers before submission.
POST /anchors/deposit|withdraw creates a session and fetches a SEP-10 challenge
from the anchor. The wallet signs it; POST /anchors/sessions/:id/complete
exchanges it for an anchor JWT and the interactive deposit/withdraw URL. A signed
POST /anchors/webhook updates session status; the worker also polls.
/api/sep24/deposit|withdraw are aliases of the same two routes and share the
same request contract. Both are validated by the Zod schemas in
src/validations/sep24.ts before the anchor is
contacted, so a malformed request never reaches an upstream call, the database,
or the audit log:
- The body is
.strict():assetCode(1–12 alphanumeric characters, upper-cased), an optionalassetIssuer,account/to/refundAddressas checksum-valid Stellar public keys, an optionalamount(required to withdraw) that must be a positive decimal string with at most 7 places, andmemo/refundMemobounded by theirmemoType(text≤ 28 UTF-8 bytes with no control characters,idan unsigned 64-bit integer,hasha base64-encoded 32-byte value).memoandmemoTypemust be supplied together, unknown keys are rejected, andextraMetadatais capped at 20 keys and 2 KB serialized. - The query string is validated too, and carries nothing but an optional
lang. A parameter the body does not define —?asset_code=XLM, the SEP-24 wire spelling of the body'sassetCode— is a 400 naming that parameter, not a silently dropped hint that the body then contradicts. - The asset is checked against the configured registry as a pair: an issuer Mergepay does not issue that asset under is rejected instead of being dropped in favour of the configured one.
- Every rejection is the shared
VALIDATION_ERRORenvelope with per-fielddetailsandissues. The routes document the same schemas throughopenApiBody(..., { enforce: false }), so Fastify's ajv cannot pre-empt the handler and answer in its own words — the Zod schema is the only validator.
Status tracking (src/services/anchor.ts, src/services/anchor-status.ts):
anchorService.getTransactionreadsGET /transactionand validates it with the Zod schema insrc/services/anchor-schemas.ts.id,kindandstatusare required, unknown fields are stripped, and amounts stay decimal strings (a malformed optional field is dropped and logged, never coerced). Failures raise typed errors fromsrc/services/anchor-errors.ts, all 502 over HTTP. Each attempt is bounded byANCHOR_POLL_TIMEOUT_MS, and transient failures are retried perUPSTREAM_RETRY_*.- Every status change goes through
applyAnchorSessionTransition: a conditional update on the current status plus astatus_historyrow and an audit row, all in one transaction. Re-delivering the same status is a no-op, terminal states (completed,refunded,expired,no_market,too_small,too_large;errormay still becomerefunded) are never walked back, and concurrent writers record the transition exactly once. - A status outside the SEP-24 set is logged and ignored. The session keeps its last known state and the worker keeps polling.
| Method | Path | Purpose |
|---|---|---|
| POST | /auth/challenge · /auth/verify · /auth/logout |
SEP-10 auth |
| GET/PATCH | /me |
Current user |
| POST/GET | /groups · /groups/:id |
Groups |
| POST | /groups/:id/invite · /groups/join · /groups/:id/leave · /groups/:id/archive |
Membership |
| POST/GET/PATCH/DELETE | /groups/:id/expenses · /expenses/:id |
Expenses |
| POST | /expenses/:id/settle · /groups/:id/settlements · /settlements/:id/confirm |
Settlement |
| GET | /settlements/:id/status |
Settlement state (see docs/api-contract.md) |
| GET | /groups/:id/balances · /groups/:id/ledger |
Balances & ledger |
| POST/GET | /groups/:id/treasury/* · /treasury-transactions/:id/confirm |
Treasury |
| GET/POST | /anchors · /anchors/deposit · /anchors/withdraw · /anchors/sessions/:id/complete · /anchors/sessions · /anchors/webhook |
Anchors |
| GET | /history |
Cross-group history |
| POST/GET | /uploads/receipt · /uploads/:file |
Receipts |
| GET | /health · /health/live · /health/ready |
Liveness & readiness probes (see HEALTH.md) |
All request bodies are validated with Zod; every group action checks membership
(and admin rights where required). The full contract — error envelope and codes,
pagination, intent expiration, and the settlement status endpoint — is documented
in docs/api-contract.md and mirrored in
mergepay-web/src/lib/types.ts.
Every list endpoint uses one cursor convention, defined and documented in src/lib/pagination.ts and mirrored in docs/api-contract.md.
| Parameter | Type | Default | Notes |
|---|---|---|---|
limit |
integer 1–100 | 50 | Outside the range → VALIDATION_ERROR, never a silent clamp |
cursor |
opaque string | — | From a previous response's meta.nextCursor; malformed → INVALID_CURSOR |
order |
desc | asc |
desc |
Applies to the (createdAt, id) ordering |
Every list response carries the same metadata:
{ "meta": { "nextCursor": "MTc2…", "hasMore": true, "limit": 50, "order": "desc" } }Ordering is always the pair (createdAt, id), so rows sharing a timestamp have
a defined order and can never appear on two pages or be skipped between them.
Queries fetch limit + 1 rows — the page plus one lookahead row to compute
hasMore — so no endpoint ever loads a full result set. Cursors carry only
ordering coordinates, never a group or user id: access is decided by each
query's own scope plus its membership check, so a cursor from one resource
replayed against another cannot widen what the caller can read.
Covers GET /groups, /groups/:id/expenses, /groups/:id/ledger,
/groups/:id/treasury/history, /anchors/sessions, and /history (which
paginates its expense and settlement streams independently, via cursor and
settlementCursor).
The whole of src/ compiles under TypeScript's strict flag — see tsconfig.json for the exact configuration. strict turns on every strict type-checking family at once (strictNullChecks, noImplicitAny, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitThis, alwaysStrict, and useUnknownInCatchVariables), so null/undefined flows, implicit anys, and unbound this are compile errors rather than production incidents.
| Setting | Value | Why |
|---|---|---|
strict |
true |
All strict checks on across src/ — no per-file opt-outs |
forceConsistentCasingInFileNames |
true |
Casing differences cannot break Linux CI builds |
noUnusedLocals |
false |
Deliberate: readability over lint-by-compiler (ESLint's no-unused-vars covers it) |
noUncheckedIndexedAccess |
false |
Deliberate: index accesses are guarded where they matter |
CI enforces it: npm run build must pass with zero TS errors before a PR merges. When contributing, keep new code strict-clean — narrow unknowns explicitly, annotate catch variables, and never suppress with any casts where a real type exists (see CONTRIBUTING.md).
npm testTests run without a database or network — Prisma and Horizon are mocked. They
cover the settlement engine (splits, net balances, greedy suggestions), money
math, SEP-10 challenge/verify, signed-XDR validation, and the auth & group routes
via app.inject.
docs/api.http is a committed request collection for the VS Code REST Client extension (also compatible with JetBrains HTTP Client). It walks the full happy path end-to-end:
- SEP-10 auth — challenge & verify (you sign the challenge with your Stellar secret key via Stellar Laboratory or the SDK)
- Create a group
- Add an expense (equal split)
- Attempt settlement (requires a second group member as payer)
- Fetch personal history
- Open
docs/api.httpin VS Code. - Run 1. Health Check to confirm the server is running.
- Generate a Stellar keypair — use the
Stellar Laboratory
or run:
node -e "console.log(require('@stellar/stellar-sdk').Keypair.random().secret())" - Replace
GDULW5...in 2. SEP-10 Challenge with your public key and send. - Copy the
transactionXDR from the response, sign it with your secret key (see instructions in the file), and paste the signed XDR into 3. SEP-10 Verify. - After a successful verify, copy the
tokenvalue and paste it into the@tokenvariable at the top of the file.
Subsequent requests use {{token}} automatically. Response variables
(@name / {{…}}) chain group and expense IDs for you.
Deploys to Render / Fly.io / Railway. Provision Postgres (Neon/Supabase/RDS),
set the env vars, run npm run prisma:deploy on release, start the API with
npm run start, and run the worker as a separate process (npm run worker:start).
Found a vulnerability? Please report it privately — see SECURITY.md. This is testnet, unaudited software; don't run it against mainnet with real funds without your own review.
See CONTRIBUTING.md. Open-source public good — issues and PRs welcome.
MIT © 2026 Mergepay contributors.