Routes are served under the /api/v1 prefix; path references below are written
without it for brevity unless the full spelling is the point. The legacy root
and /api/… spellings still route to the same handlers (see the API versioning
section in ../README.md).
The canonical client contract lives in mergepay-web/src/lib/types.ts. This file
is the local reference for the parts of the contract that are defined by this
repository, so a change here can be mirrored there in one step.
Keep this file in sync whenever a response shape, status vocabulary, error code, or query convention changes.
Every error — validation, authorization, rate limiting, upstream — uses one shape:
{
"error": "NOT_FOUND",
"message": "Settlement not found",
"statusCode": 404,
"details": { "…": "optional, structured" },
"requestId": "01J…"
}error is a stable machine-readable code from ErrorCode in
../src/lib/errors.ts. Codes worth calling out:
| Code | Status | Meaning |
|---|---|---|
VALIDATION_ERROR |
400 | Request failed Zod validation; details lists the offending fields |
INVALID_CURSOR |
400 | Pagination cursor was not produced by this API |
INTENT_EXPIRED |
400 | The unsigned transaction's signing window has closed — request a new one |
XDR_MISMATCH |
400 | The signed envelope does not match the intent it was built for |
XDR_MALFORMED |
400 | The envelope could not be parsed at all |
INVALID_IDEMPOTENCY_KEY |
400 | Idempotency-Key is outside 1–255 characters of A–Z a–z 0–9 - _ . : |
MISSING_IDEMPOTENCY_KEY |
400 | The route requires an Idempotency-Key header and none was sent |
IDEMPOTENCY_CONFLICT |
409 | The key was already used with a different payload |
IDEMPOTENCY_IN_PROGRESS |
409 | The first request with this key is still running; retry shortly |
UNAUTHORIZED |
401 | Missing or invalid session |
TOKEN_EXPIRED |
401 | The session JWT's exp claim has passed — re-authenticate via SEP-10 or POST /auth/refresh; details carries a re-auth hint |
INVALID_TOKEN |
401 | The bearer token was rejected (malformed, wrong signature, wrong algorithm/issuer/audience) — re-authenticate via SEP-10; refresh cannot redeem it |
FORBIDDEN |
403 | Authenticated, but not permitted on this resource |
NOT_FOUND |
404 | The resource does not exist |
RATE_LIMITED |
429 | Per-route budget exhausted; the wait is in the Retry-After header |
DUPLICATE_RECORD |
409 | A unique constraint rejected the write; details.fields names the columns |
UPSTREAM_ERROR |
502 | Horizon or an anchor failed |
SERVICE_UNAVAILABLE |
503 | The database is unreachable, so the request could not be attempted |
404 versus 403 is deliberate and consistent: a resource that does not exist
is 404; one that exists but is not the caller's is 403. Clients need to
distinguish "gone" from "not yours" to render a useful state, and the difference
leaks only the existence of an opaque identifier — never any content.
A statement PostgreSQL rejects is translated centrally, so a write that breaks a constraint reads as what it is rather than as an outage. Defined in ../src/lib/prisma-error.ts.
| Database failure | Status | Code |
|---|---|---|
| Unique constraint (a value already exists) | 409 | DUPLICATE_RECORD |
| Foreign key, value too long, null or missing required field, failed check constraint | 400 | VALIDATION_ERROR |
| Required record not found | 404 | NOT_FOUND |
| Write conflict or deadlock | 409 | CONFLICT |
| Database unreachable, refused, or timed out | 503 | SERVICE_UNAVAILABLE |
| A malformed statement of our own | 500 | INTERNAL_ERROR |
DUPLICATE_RECORD carries details.fields — the columns whose unique index
rejected the write — so a client can point at the offending input. Prisma's own
message, the statement it was executing, and the Postgres constraint name are
never sent: the message is fixed prose, and the driver's text and the constraint
name go to the log instead.
Applies to GET /groups, /groups/:id, /groups/:id/expenses, /groups/:id/settlements,
/groups/:id/ledger, /groups/:id/treasury/history, /groups/:groupId/treasury/proposals,
/groups/:groupId/webhooks, /groups/:groupId/webhooks/:webhookId/deliveries,
/anchors/sessions, /api/v1/sep24/transactions, and /history.
Defined in ../src/lib/pagination.ts.
| Parameter | Type | Default | Notes |
|---|---|---|---|
limit |
integer 1–100 | 50 |
Out of range → VALIDATION_ERROR (never silently clamped) |
cursor |
opaque string | — | From a previous response's meta.nextCursor; malformed → INVALID_CURSOR |
order |
"desc" | "asc" |
"desc" |
Applies to the (createdAt, id) ordering |
interface PageMeta {
nextCursor: string | null; // null when there is no further page
hasMore: boolean;
limit: number; // the effective page size
order: "asc" | "desc";
}Guarantees:
- Deterministic ordering. Rows are ordered by the pair
(createdAt, id), so records sharing a timestamp have a defined order and can never appear on two pages or be skipped between them. - Bounded reads. Each query fetches
limit + 1rows — the page plus one lookahead row to computehasMore— so no endpoint loads a full result set. - Cursors carry no authority. A cursor contains only ordering coordinates, never a group id, user id, or permission. Scope comes from each query's own filter plus its membership check, so a cursor from one resource replayed against another can only move a page boundary inside data the caller may already read.
GET /history paginates two resources independently: cursor walks the expense
stream, settlementCursor walks the settlement stream, with metadata in meta
and settlementMeta respectively.
The group expense list extends the shared pagination parameters with filters (defined in ../src/services/expenses.ts). Filters compose with the cursor rather than replacing it, so a filtered scan pages exactly like an unfiltered one.
| Parameter | Type | Notes |
|---|---|---|
asset |
"XLM" | "USDC" |
Filter by asset code. Other codes are a VALIDATION_ERROR |
status |
"PENDING" | "SETTLED" |
Settlement state of the whole expense: SETTLED requires every share settled, PENDING at least one outstanding |
startDate / endDate |
ISO 8601 datetime | Inclusive createdAt bounds. A reversed range is INVALID_RANGE, not an empty page |
includeTotal |
"true" | "false" |
Opt-in meta.total count. Counting a filtered set is a second query over every matching row, so clients that need a total ask for it explicitly |
The count behind includeTotal covers the filters but not the cursor, so the
total stays stable while a client pages through the result.
Defined in ../src/services/settlement-history.ts. Same shape as the expense filters above, and the same composition with the cursor.
| Parameter | Type | Notes |
|---|---|---|
status |
one of the five public settlement statuses | Filters by the value GET /settlements/:id/status reports for the same row, not by the internal column |
asset |
"XLM" | "USDC" |
Filter by asset code. Other codes are a VALIDATION_ERROR |
startDate / endDate |
ISO 8601 datetime | Inclusive createdAt bounds. A reversed range is INVALID_RANGE |
includeTotal |
"true" | "false" |
Opt-in meta.total over the filtered set, ignoring the cursor |
Two of the five public statuses are derived from the signing deadline rather
than stored — a row at pending or submitted past its expiresAt reports as
expired — so the filter is written as the exact inverse of that mapping,
including its clock-skew tolerance. Consequences a client can rely on:
- the five values partition the history: every settlement matches exactly one, so no row is unreachable and none appears under two filters;
- a filtered row reports the same status when read individually through
GET /settlements/:id/status.
Defined in ../src/services/ledger.ts. The ledger interleaves expenses, settlements, and treasury transactions into one time-ordered feed.
| Parameter | Type | Notes |
|---|---|---|
type |
one or more of expense, settlement, treasury |
Comma-separated (?type=expense,settlement). Selects which tables are read at all, so one kind means one query instead of three. An unknown kind is a VALIDATION_ERROR |
startDate / endDate |
ISO 8601 datetime | Inclusive createdAt bounds, pushed into every table read. A reversed range is INVALID_RANGE |
Both filters are applied inside each table's query rather than to the merged
page. That is what keeps pages full: each table is read with its own
limit + 1 window, so dropping rows after the merge would return short pages
and make hasMore describe the unfiltered scan.
Defined in ../src/services/webhook-listing.ts.
| Parameter | Type | Notes |
|---|---|---|
status |
"pending" | "delivered" | "failed" |
Delivery state recorded by the worker |
event |
a webhook event type | Filter to one event type |
A delivery row also stores the exact signed request body and the receiver's
response text. Neither is returned by this endpoint: the payload is the group's
own data re-served through a different authorization path, and the response body
is third-party text. The list returns id, webhookId, eventType, status,
attempts, responseStatusCode, nextAttemptAt, lastAttemptAt, createdAt,
and updatedAt.
Defined in ../src/services/idempotency.ts. Mobile networks and wallet callbacks retry after timeouts, so every state-changing settlement request can be replayed safely.
| Header | Idempotency-Key |
| Length | 1–255 characters |
| Charset | A–Z, a–z, 0–9, -, _, ., : |
| Validation | Zod, before any database read — out of bounds is INVALID_IDEMPOTENCY_KEY (400) |
| Endpoint | Key |
|---|---|
POST /expenses/:id/settle |
optional, honoured when sent |
POST /groups/:id/settlements |
optional, honoured when sent |
POST /settlements/:id/confirm |
required — missing is MISSING_IDEMPOTENCY_KEY (400) |
POST /groups/:id/treasury/{deposit,withdraw}, POST /treasury-transactions/:id/confirm |
optional, honoured when sent |
Submission requires a key because it is the request that ends in money moving: a retry that slipped through unguarded could mean a second on-chain payment.
A key is stored against (authenticated user, operation scope, key) together
with a hash of the request's intent (scope, resource id, body).
- Replay — same user, same key, equivalent payload → the original response is returned verbatim. No second settlement record is created and no second envelope is handed to the worker for Horizon submission.
- Different payload — same user and key, different body →
409 IDEMPOTENCY_CONFLICT. The first request's result is untouched. - Different user — a key is invisible outside the account that used it. Another user sending the same string starts a fresh request; it never replays and never conflicts.
- Different endpoint — the scope is part of the identity, so the same string on an unrelated route is a separate key.
- Concurrency — the reservation row is written before the guarded operation
runs, and the unique constraint serializes racing retries. The loser gets
409 IDEMPOTENCY_IN_PROGRESSrather than a second execution, so concurrent requests produce exactly one durable outcome. - Failure — the guarded operation runs inside a database transaction, so a
failure rolls back completely and the key is marked
failed. The same key may then be retried and will run exactly once more. A settlement is never left looking complete without a confirmed transaction: the request path only records a signed envelope, and the status only advances pastsubmittedonce the worker has a Horizon result. - Retention — keys replay for 24 hours, then are swept.
Signed XDR validation happens before the key's operation begins, so an
invalid envelope is never recorded as an idempotent success — every retry with a
bad envelope gets the same fresh XDR_MISMATCH.
Defined in ../src/lib/time-bounds.ts.
Endpoints that return an unsigned XDR (POST /expenses/:id/settle,
POST /groups/:id/settlements, POST /groups/:id/treasury/deposit,
POST /groups/:id/treasury/withdraw) include:
{
xdr: string; // unsigned envelope for the wallet to sign
networkPassphrase: string;
expiresAt: string; // ISO 8601, server-controlled
expiresInSeconds: number;
}- The deadline is derived from the server clock and is also set as the
transaction's
maxTime, so the stored intent and the on-chain envelope describe the same moment. validitySeconds(optional, 30–300) requests a shorter window. A client can never extend one and never supplies an absolute timestamp; out-of-range values are aVALIDATION_ERROR.POST /settlements/:id/confirmandPOST /treasury-transactions/:id/confirmreject a lapsed intent withINTENT_EXPIRED. Submission additionally validates the signed envelope's own time bounds against the stored intent — an unbounded envelope, or one valid longer than its intent, is anXDR_MISMATCH.- Comparisons allow a bounded 30-second clock-skew tolerance.
- No expired transaction is ever submitted to Horizon or an anchor. The worker
marks such a settlement
expiredand releases its expense share.
Settlement and TreasuryTransaction payloads both carry
expiresAt: string | null (null on rows predating expiration tracking).
Defined in ../src/services/settlement-xdr.ts and ../src/services/stellar.ts.
POST /settlements/:id/confirm loads the settlement's stored intent — never
anything the client sent alongside the envelope — and validates the signed XDR
against it before the envelope is persisted and before anything reaches Horizon.
The worker repeats the same check at submission time.
Checked, in order:
| Property | Rejected when |
|---|---|
| Envelope | Unparseable, or a fee-bump wrapper (XDR_MALFORMED / XDR_MISMATCH) |
| Validity window | No expiry, valid longer than the intent, already lapsed, or not yet valid |
| Transaction source | Not the settlement's payer |
| Operation count | Anything other than exactly one operation |
| Fee | Below the network minimum or above the fee the API built (per operation) |
| Operation type | Not a payment |
| Operation source | Overridden to an account other than the payer |
| Destination | Not the settlement's recipient |
| Asset | Different code, or the same code with a different issuer |
| Amount | Differs at 7-decimal precision |
| Memo | Not the settlement's own short code |
| Signature | Missing, or not verifiable against the source account for the configured network passphrase |
A mismatch is a 400 with a stable code (XDR_MISMATCH, XDR_MALFORMED, or
INTENT_EXPIRED) and a message naming the field that diverged. The signed
envelope, its signatures, and any key material are never included in the
response or in logs. A rejected transaction is never submitted and never
advances the settlement's status — the settlement stays awaiting a signature and
the wallet can sign the correct envelope instead.
The same checks, in the same order, are available as a non-throwing
inspection in
../src/services/xdr-inspection.ts, for
callers that want the diverging field reported rather than raised —
inspectSignedTransaction returns { ok: true, tx, hash, source, sequence } or
{ ok: false, code, field, message }, where code is one of xdr_malformed,
xdr_mismatch, intent_expired, or xdr_unsigned, and field names the part
of the envelope that diverged. It adds no rules of its own: it composes the same
primitives the submission path uses, so there is exactly one description of what
"matches the intent" means. Signature verification is on by default and can only
be waived explicitly, for the paths where authorship is established elsewhere
(an unsigned intent read back, or a multisig proposal whose shared source
account never signs with its own key).
The single source of truth for a settlement's state after creating or signing it. Defined in ../src/services/settlement-status.ts.
Authentication: required. Authorization: any member of the settlement's
group, via the same requireMembership helper the mutating routes use.
:id accepts either the settlement's cuid or its human-facing shortCode (the
value that appears in the payment memo). Both are unique. Anything outside
[A-Za-z0-9_-]{4,64} is a VALIDATION_ERROR before any database read.
| Parameter | Type | Default | Notes |
|---|---|---|---|
refresh |
"true" | "false" |
"true" |
Whether to consult Horizon for on-chain confirmation |
interface SettlementStatusResponse {
settlement: Settlement; // the standard serialized settlement
status: SettlementStatus;
terminal: boolean; // whether `status` can still change
onChain: {
checked: boolean; // a Horizon lookup ran and answered in time
found: boolean; // Horizon has a record of the transaction
successful: boolean | null; // Horizon's own flag; null if not found
transactionHash: string | null;
};
// Present only when `status` is "failed"; null otherwise, including while a
// settlement is still being retried. `category` is the stable value to
// branch on; `reason` is scrubbed — no upstream text, XDR, or stack.
failure: {
category: SettlementFailureCategory;
reason: string;
} | null;
expiresAt: string | null; // ISO 8601
expiresInSeconds: number | null; // negative once lapsed
createdAt: string; // ISO 8601
updatedAt: string; // ISO 8601
checkedAt: string; // ISO 8601, when this answer was computed
}type SettlementStatus =
| "awaiting_signature"
| "submitted"
| "confirmed"
| "failed"
| "expired";| Status | Terminal | Meaning | Client action |
|---|---|---|---|
awaiting_signature |
no | Unsigned XDR issued; no signed envelope returned yet | Sign it before expiresAt |
submitted |
no | A signed envelope was accepted and is being submitted; not yet confirmed on-chain | Keep polling |
confirmed |
yes | The payment succeeded on-chain | Done |
failed |
yes | Submission was rejected, or the transaction failed on-chain | Branch on failure.category |
expired |
yes | The signing window closed before submission | Create a new settlement |
An unrecognised internal status maps to awaiting_signature — conservative, and
never reported as paid.
type SettlementFailureCategory =
| "validation"
| "insufficient_funds"
| "expired"
| "upstream"
| "ledger_rejected"
| "internal";| Category | Meaning | Client action |
|---|---|---|
validation |
The request or signed envelope was wrong (malformed XDR, intent mismatch, authorization) | Fix the request; retrying the same input fails identically |
insufficient_funds |
The payer cannot cover the amount, the network fee, or the account reserve, or lacks the trustline | Fund the account or add the trustline, then settle again |
expired |
The signing window closed before submission | Create a new settlement; do not retry this one |
upstream |
Horizon or the anchor was unreachable, timed out, or rate-limited | Retry later; nothing is wrong with the request |
ledger_rejected |
The network rejected the transaction, or it failed on-chain | Permanent for this envelope; create a new settlement |
internal |
An unclassified failure inside the API | Retry; contact support if it persists |
The set is closed and stable — a client may switch on it exhaustively. A
settlement that failed before this field existed reports internal rather than
omitting the category, so the shape is the same for every failed settlement.
The same categories are assigned by both the request path and the background worker, from one classifier, so a given failure is reported identically no matter which process observed it.
A transaction hash Horizon does not yet know about is the ordinary state for the
first seconds after submission, because Horizon only sees a transaction once it
is in a closed ledger. That case is reported as onChain.found: false with
status submitted. Only an explicitly successful Horizon record advances the
status to confirmed, and only an explicitly unsuccessful one to failed.
The Horizon lookup runs only when it could change the answer (there is a hash and
the status is not already terminal), is bounded by a 2.5s timeout, and degrades
to onChain.checked: false rather than failing the request if Horizon is slow or
erroring.
Signed or unsigned XDRs, private keys, anchor or session tokens, provider
credentials, upstream error text, and stack traces. failure.reason is limited
to the short, already-scrubbed message the worker recorded — capped at 300
characters, with bearer tokens, labelled secrets, bare Stellar secret seeds, and
bare transaction envelopes removed before it is ever persisted.
Every size limit is explicit and configurable, and each has its own error code
so a client can tell what to change. All are answered as 413 — the request was
well-formed, just too large — except a body that is not multipart at all, which
is 415.
| Limit | Environment variable | Default | Code when exceeded |
|---|---|---|---|
| JSON body (global) | JSON_BODY_LIMIT_BYTES |
1 MB | REQUEST_TOO_LARGE |
| JSON body (auth, settlement, treasury, anchor routes) | AUTH_BODY_LIMIT_BYTES |
256 KB | REQUEST_TOO_LARGE |
| Uploaded file size | MULTIPART_FILE_SIZE_BYTES |
5 MB | FILE_TOO_LARGE |
| Files per request | MULTIPART_MAX_FILES |
1 | TOO_MANY_FILES |
| Single form field size | MULTIPART_FIELD_SIZE_BYTES |
64 KB | FIELD_TOO_LARGE |
| Total multipart parts | MULTIPART_MAX_FIELDS |
20 | TOO_MANY_PARTS |
A malformed multipart body is 400 BAD_REQUEST with a fixed message; the
parser's own text is never echoed, because it can quote the malformed input.
Limits are applied at the narrowest scope that works. The multipart limits reach
only POST /uploads/receipt, the sole multipart route — the SEP-24 anchor flow
is JSON end to end — so ordinary JSON routes keep their own body limit and are
unaffected.
POST /uploads/receipt streams the file to a staging directory outside the
served path and renames it into place only once it has been fully received and
accepted. Nothing is buffered whole in memory, every rejection path removes the
partial file, and no URL is returned for a file that is not complete. A client
that aborts mid-upload leaves nothing behind.
Every route is covered by a global budget; SEP-10 authentication, signed submission, and anchor routes each get their own bucket. See the table in ../README.md and the policy definitions in ../src/lib/rate-limit.ts.
A 429 uses the standard error envelope with error.code / code set to
RATE_LIMITED, alongside the usual Retry-After and X-RateLimit-Limit /
X-RateLimit-Remaining / X-RateLimit-Reset headers. The body carries no
details: how long to wait is a property of the limiter, not of the failed
request, and repeating it in two places invites them to disagree. It also
reveals nothing about the caller's identity or whether a wallet account is
known to the API — /auth/* budgets are keyed by client IP precisely so that a
429 cannot be used as an account oracle.
Operational probes for deployments and load balancers. They require no authentication and are not subject to application business authorization — a monitoring probe must work without a session. See also ../HEALTH.md for the deployment-facing view.
| Endpoint | Type | Behavior |
|---|---|---|
GET /health · GET /health/live |
Liveness | 200 with { "status": "ok", "timestamp": "…" } while the process can accept requests. Contacts nothing external. |
GET /health/ready |
Readiness | 200 with { "status": "ok", "checks": {…} } when every required dependency is available; 503 with { "status": "not_ready", "checks": {…} } otherwise. |
GET /health/ready verifies the dependencies the API needs to submit and
inspect transactions, using only read-only requests — never a state-changing
call:
| Check | What it verifies | How |
|---|---|---|
stellar |
The configured Horizon endpoint answers a read-only fee-stats request, proving the Stellar network configuration is usable end to end | getFeeStats() in src/services/network.ts — the shared Horizon client built from config.HORIZON_URL, no duplicated configuration |
database |
Prisma can run SELECT 1 |
Read-only query on the shared Prisma client |
anchor |
The configured anchor's stellar.toml is reachable |
Read-only request; reported disabled when no anchor is configured |
Each check has its own 1.5-second timeout and results are cached for five
seconds, so an unavailable upstream never hangs a probe or thrashes Horizon.
A down status on any required check makes the whole response not_ready
with HTTP 503, which lets a load balancer drain the instance.
Responses contain only the status vocabulary (up/down/disabled,
ok/not_ready) and a timestamp — never connection strings, Horizon URLs,
credentials, tokens, or upstream error text.
Invalid Stellar configuration fails fast, before the API can report ready.
STELLAR_NETWORK and HORIZON_URL are validated together at process start in
src/config.ts: a testnet URL with a public network (or vice versa) exits the
process with a clear error instead of serving a readiness endpoint that could
only ever report a broken dependency.