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)
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)See .env.example. Key ones:
| Variable | Description |
|---|---|
DATABASE_URL |
PostgreSQL connection string |
JWT_SECRET |
Secret for signing session JWTs (12h expiry) |
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 (CORS + invite links) |
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 |
Every route is covered by a global default limit
(RATE_LIMIT_GLOBAL_MAX / RATE_LIMIT_GLOBAL_WINDOW_MS, default 100 per
minute), plus route-appropriate overrides for endpoints with different
traffic patterns or trust boundaries:
| Route(s) | Variables | Default |
|---|---|---|
POST /auth/challenge |
RATE_LIMIT_AUTH_CHALLENGE_MAX / _WINDOW_MS |
20 / 1 min |
POST /auth/verify |
RATE_LIMIT_AUTH_VERIFY_MAX / _WINDOW_MS |
10 / 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 |
RATE_LIMIT_SETTLEMENT_CONFIRM_MAX / _WINDOW_MS |
30 / 1 min |
POST /treasury-transactions/:id/confirm |
RATE_LIMIT_TREASURY_SUBMIT_MAX / _WINDOW_MS |
30 / 1 min |
POST /anchors/deposit, POST /anchors/withdraw, POST /anchors/sessions/:id/complete |
RATE_LIMIT_ANCHOR_INIT_MAX / _WINDOW_MS |
10 / 1 min |
GET /anchors, GET /anchors/sessions |
RATE_LIMIT_ANCHOR_POLL_MAX / _WINDOW_MS |
60 / 1 min |
POST /anchors/webhook |
RATE_LIMIT_ANCHOR_WEBHOOK_MAX / _WINDOW_MS |
60 / 1 min |
POST /groups |
RATE_LIMIT_GROUP |
30 / 1 min |
GET /history |
RATE_LIMIT_HISTORY |
60 / 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 capped at one hour; maximums must be
positive integers, 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.
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 internal id when available
(never a Stellar public key), or the resolved client IP otherwise —
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.
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 tracks job claims and retry attempts for settlement submissions and anchor polling. In the event of a worker crash or deployment interruption, stale jobs (claimed for longer than 15 minutes) are automatically recovered and re-eligible for processing. Job state is tracked via:
jobAttemptCount: Number of times the job has been claimed for processingjobClaimedAt: Timestamp when the job was last claimed (null if released)jobEligibleAt: Earliest time the job is eligible for the next retry attemptjobErrorSummary: Truncated error message from the last attempt (for operator review)
Terminal failures (exhausted retries, permanent errors) are marked with status
failed and retain an error summary for support investigation. Each job has
a maximum of 5 attempts with exponential backoff (5s, 30s, 5m, 30m, 60m).
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.
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.
| 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 |
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).
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.