Skip to content

Latest commit

 

History

History
554 lines (442 loc) · 26.9 KB

api-contract.md

File metadata and controls

554 lines (442 loc) · 26.9 KB

Mergepay API contract reference

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.


Error envelope

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.

Database failures

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.


Pagination

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.

Query parameters

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

Response metadata

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 + 1 rows — the page plus one lookahead row to compute hasMore — 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.

Expense list filters (GET /groups/:id/expenses)

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.

Settlement list filters (GET /groups/:id/settlements)

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.

Ledger filters (GET /groups/:id/ledger)

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.

Webhook delivery filters (GET /groups/:groupId/webhooks/:webhookId/deliveries)

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.


Idempotency

Defined in ../src/services/idempotency.ts. Mobile networks and wallet callbacks retry after timeouts, so every state-changing settlement request can be replayed safely.

The header

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.

Semantics

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_PROGRESS rather 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 past submitted once 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.


Transaction intents and expiration

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 a VALIDATION_ERROR.
  • POST /settlements/:id/confirm and POST /treasury-transactions/:id/confirm reject a lapsed intent with INTENT_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 an XDR_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 expired and releases its expense share.

Settlement and TreasuryTransaction payloads both carry expiresAt: string | null (null on rows predating expiration tracking).

Signed XDR validation

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).


GET /settlements/:id/status

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.

Path parameter

: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.

Query parameters

Parameter Type Default Notes
refresh "true" | "false" "true" Whether to consult Horizon for on-chain confirmation

Response

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
}

Status values

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.

Failure categories

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.

Pending is not confirmed

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.

Never returned

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.


Request size limits

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.

Uploads never leave partial state

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.


Rate limiting

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.


Health endpoints

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.

Readiness checks

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.