Complete reference for every HTTP endpoint exposed by the StableRoute
backend (src/index.ts). The service is an in-memory Express API; state
resets on process restart.
Base URL (local): http://localhost:3001
All paths below are absolute. Versioned endpoints live under
/api/v1; /health is unversioned.
Every response carries an X-Request-Id header. If the caller sends an
X-Request-Id request header that passes both the charset and length
checks below, it is echoed back verbatim; otherwise a fresh UUID v4 is
generated. The same id appears in the requestId field of every error
body, so logs and error responses can be correlated.
Accepted format: 1–200 characters drawn exclusively from the
conservative token charset [A-Za-z0-9._-]. Values containing control
characters (including CR \r / LF \n), spaces, or any other
non-token bytes are not echoed — a generated UUID v4 is used
instead, closing the header-injection and log-injection surface while
preserving correlation.
All error responses share a single canonical JSON shape:
{
"error": "invalid_request",
"message": "human-readable explanation",
"requestId": "0f8c…-uuid"
}Some errors include extra fields (e.g. the 500 handler adds method
and path), but error, message, and requestId are always present.
| Code | HTTP | When it is emitted |
|---|---|---|
invalid_request |
400 | Request validation failed (missing/invalid params or body). |
not_found |
404 | Resource does not exist, or no route matches the method + path. |
payload_too_large |
413 | Request body exceeds the 100 KiB JSON limit. |
rate_limited |
429 | More than 60 requests per 60 s from one IP. Sets Retry-After: 60. Disabled when NODE_ENV=test. See Rate-limiter memory bounding for eviction behaviour. |
service_paused |
503 | Service is paused and a non-idempotent request was made (see Admin / pause). |
internal_error |
500 | Unhandled exception; message carries the error text plus method/path. |
not_acceptable |
406 | Accept header is present and excludes application/json (see Content negotiation). |
Pause behaviour: while paused, all non-
GET/HEAD/OPTIONSrequests return503 service_paused, exceptPOST /api/v1/admin/unpause, so an operator can always recover.
All versioned JSON API endpoints enforce HTTP content negotiation. If a
request carries an Accept header that neither includes application/json
nor any matching wildcard (*/* or application/*), the server responds with:
HTTP/1.1 406 Not Acceptable
Content-Type: application/json
{
"error": "not_acceptable",
"message": "This endpoint only produces application/json",
"requestId": "…"
}
Exempt routes — the following paths bypass the guard because they serve non-JSON content:
| Path | Content-Type |
|---|---|
GET /health |
application/json (monitoring-friendly, no restriction) |
GET /api/v1/metrics |
text/plain (Prometheus exposition format) |
Accepted Accept values:
| Value | Accepted? |
|---|---|
| (absent) | Yes — defaults to JSON |
application/json |
Yes |
*/* |
Yes |
application/* |
Yes |
text/html |
No → 406 |
text/csv |
No → 406 |
Every response sets X-Content-Type-Options: nosniff,
X-Frame-Options: DENY, Referrer-Policy: no-referrer, and
Strict-Transport-Security: max-age=31536000; includeSubDomains.
The per-IP sliding-window rate limiter stores request timestamps in the
rateBuckets map (src/stores.ts). Without eviction every distinct
source IP that ever makes a request keeps a map entry forever, enabling a
slow memory-exhaustion attack via rotating or spoofed addresses.
Two eviction strategies bound the map's memory footprint:
-
Idle eviction — after each request the limiter prunes stale timestamps from the IP's bucket. If the bucket is now empty (all timestamps have aged out of the 60 s window) the map key is deleted entirely rather than writing back an empty array.
-
IP-ceiling eviction — the map is hard-capped at
RATE_BUCKETS_MAX_IPS(50 000) tracked IPs. When a new IP would exceed the ceiling, the least-recently-active entry (insertion-order oldest) is evicted before the new key is inserted.
Both passes are implemented in the exported evictRateBuckets helper
(src/index.ts) so unit tests can exercise eviction logic directly,
independent of the Express middleware (which is disabled under
NODE_ENV=test).
Active clients are not affected: the 60-req / 60-s window and the
429 rate_limited + Retry-After: 60 response are unchanged.
Shallow liveness check.
- Response 200:
{ "status": "ok", "service": "stableroute-backend" }
Returns a minimal OpenAPI 3.0.3 document describing the available paths.
- Response 200: OpenAPI document (
{ openapi, info, paths }).
Lightweight build/version metadata. Unauthenticated and cheap — runs no health checks and exposes only build identity.
- Response 200:
{ "name": "stableroute-backend", "version": "0.1.0", "commit": "a1b2c3d", "buildTime": "2026-01-01T00:00:00Z", "node": "v20.0.0" }
name/version come from package.json; commit/buildTime come from the
GIT_COMMIT/BUILD_TIME env vars (each falling back to "unknown"); node is
process.version. No secrets are exposed.
curl http://localhost:3001/api/v1/versionDeep readiness probe. Runs synchronous storage and clock checks.
- Response 200 (all checks pass, not paused), with body:
{ "status": "ok", "uptimeSeconds": 42, "memory": { "rssMb": 70, "heapUsedMb": 20 }, "pid": 1234, "node": "v18.0.0", "checks": [ { "name": "storage", "status": "ok", "durationMs": 0 }, { "name": "clock", "status": "ok", "durationMs": 0 } ] } - Response 200 with
status: "paused"when the service is paused. - Response 503 with
status: "degraded"and the same body shape when any check reportsstatus: "fail".
A pair is a (source, destination) tuple of asset codes. Asset codes are
1–12 character strings (Stellar's max alphanumeric asset code).
List every registered pair. Supports conditional GET via a weak ETag.
- Response 200:
{ "pairs": [ { "source": "USDC", "destination": "EURC" }, … ] }with anETag: W/"<base64 sha1 slice>"header derived from the body. - Response 304: empty body, when the request's
If-None-Matchheader matches the current ETag.
Returns the same ETag, Content-Type, and Content-Length headers as
GET /api/v1/pairs but with no response body. A well-behaved HTTP
cache can issue this request to learn the current ETag and body size
without transferring the full pairs list.
The ETag is computed from the same serialized body as the GET handler
via a shared helper, so the two values are always byte-identical.
- Response 200: empty body. Headers set:
ETag,Content-Type: application/json,Content-Length(byte length of the equivalent GET body). - Response 304: empty body, when the request's
If-None-Matchheader matches the current ETag.
Register (or refresh) a pair.
- Body:
{ "source": "USDC", "destination": "EURC" } - Response 201: first registration —
{ source, destination, registered: true }. - Response 200: idempotent re-registration of an existing pair (same body).
- Errors:
400 invalid_requestifsource/destinationare not 1–12 char strings, if they are equal, or if either code begins with the reserved prefix__health(see Reserved probe namespace below).
The deep readiness probe (GET /api/v1/health/deep) uses a scratch entry in
the internal pairMeta store to verify read/write/delete round-trips. The
scratch key is a fixed sentinel (HEALTH_PROBE_KEY) prefixed with the NUL
control character (\x00), which is structurally impossible in any valid asset
code and therefore can never collide with operator data.
As an additional guard, POST /api/v1/pairs rejects any asset code that starts
with __health (case-sensitive). This prevents a caller from registering a pair
whose derived key could approximate the reserved namespace and be silently
deleted by a concurrent probe run.
Read a single registered pair.
- Response 200:
{ source, destination, registered: true }. - Errors:
404 not_foundif the pair is not registered.
Unregister a pair.
- Response 204: empty body on success.
- Errors:
404 not_foundif the pair is not registered.
Aggregate read of the pair's registration state plus all per-pair metadata in one round-trip. Returns defaults even for unregistered pairs.
- Response 200:
{ "source": "USDC", "destination": "EURC", "registered": true, "feeBps": 0, "minAmount": "0", "maxAmount": "0", "liquidity": "0" }
Set the pair fee in basis points.
- Body:
{ "feeBps": 30 }— integer in[0, 1000]. - Response 200:
{ source, destination, feeBps, minAmount, maxAmount, liquidity }. - Errors:
404 not_foundif the pair is not registered;400 invalid_requestiffeeBpsis not an integer in[0, 1000].
Set the minimum amount.
- Body:
{ "minAmount": "100" }— non-negative integer string (/^[0-9]{1,39}$/). - Response 200: the updated metadata object.
- Errors:
404 not_found(unregistered);400 invalid_request(bad value, orminAmountexceeds the pair's currentliquidity— see cross-field invariant below).
Set the maximum amount.
- Body:
{ "maxAmount": "1000000" }— positive integer string (/^[1-9][0-9]{0,38}$/). - Response 200: the updated metadata object.
- Errors:
404 not_found(unregistered);400 invalid_request(bad value).
Set available liquidity.
- Body:
{ "liquidity": "500000" }— non-negative integer string (/^[0-9]{1,39}$/). - Response 200: the updated metadata object.
- Errors:
404 not_found(unregistered);400 invalid_request(bad value, orliquiditywould fall below the pair's currentminAmount— see cross-field invariant below).
The backend enforces that a pair's minAmount never exceeds its liquidity.
Setting a minimum trade size larger than the available liquidity would produce a
pair that advertises a minimum it can never fill.
The invariant is checked using BigInt so that 39-digit base-unit strings (common
in stablecoin protocols) are compared exactly without Number precision loss.
Rule: minAmount > liquidity is rejected with 400 invalid_request on both
PATCH .../liquidity and PATCH .../min.
Unset carve-out: a liquidity of "0" means "not yet configured / unbounded".
Pairs that have never had their liquidity set are not retroactively invalidated
against their minAmount. The invariant is only enforced when liquidity is a
non-zero value.
| Scenario | liquidity |
minAmount |
Accepted? |
|---|---|---|---|
| Normal | "5000" |
"100" |
Yes — min < liquidity |
| Equal | "500" |
"500" |
Yes — min == liquidity |
| Violation | "100" |
"999" |
No — 400 returned |
| Unset liquidity | "0" |
"999" |
Yes — liquidity is unset |
Register many pairs in a single request. Each item is validated independently; one bad item never fails the whole batch.
- Body:
{ "pairs": [ { "source": "USDC", "destination": "EURC" }, … ] }— 1 toconfig.bulkMaxItemsentries (default 100). - Response 200:
{ "results": [ … ] }where each result is either:- success:
{ "index": 0, "ok": true, "source": "USDC", "destination": "EURC", "registered": true } - failure:
{ "index": 1, "ok": false, "error": "invalid_asset_code" | "same_asset" }
- success:
- Errors:
400 invalid_requestif thepairsarray is missing, empty, or exceedsconfig.bulkMaxItems. - Audit: emits
pair.registeredorpair.refreshedfor each successful item.
curl -X POST http://localhost:3001/api/v1/pairs/bulk \
-H 'Content-Type: application/json' \
-d '{"pairs":[{"source":"USDC","destination":"EURC"},{"source":"EURC","destination":"XLM"}]}'Reset all metadata for a registered pair back to factory defaults
(feeBps: 0, minAmount: "0", maxAmount: "0", liquidity: "0").
Use this to undo a misconfigured feeBps, maxAmount, or other field
without unregistering the pair (which would emit spurious lifecycle
events).
- Body: none required.
- Response 200:
{ "source": "USDC", "destination": "EURC", "feeBps": 0, "minAmount": "0", "maxAmount": "0", "liquidity": "0" } - Errors:
404 not_foundif the pair is not registered;503 service_pausedif the service is currently paused. - Audit: emits a
pair.meta.resetevent in the event log.
Get a single route quote. All three params are query-string params.
- Query:
source_asset(1–12 chars),dest_asset(1–12 chars),amount(positive integer string, no leading zero,/^[1-9][0-9]{0,38}$/). - Response 200:
{ "source_asset": "USDC", "dest_asset": "EURC", "amount": "10000", "estimated_rate": "1.0", "route": ["USDC", "EURC"], "feeBps": 30, "feeAmount": "30", "netAmount": "9970" } - Errors:
400 invalid_requestif any param is missing, if assets are not 1–12 char strings, ifsource_asset === dest_asset, or ifamountis not a valid positive integer string.
| Field | Type | Description |
|---|---|---|
feeBps |
number | The fee rate applied in basis points (100 bps = 1 %). Sourced from the registered pair's metadata; defaults to 0 if no metadata exists. |
feeAmount |
string | Absolute fee in base units as an integer string. Computed as floor(amount × feeBps / 10000). Fees are rounded down (in the gateway's favour). |
netAmount |
string | Amount the destination side receives after fees: amount - feeAmount. Always ≥ 0. |
All fee arithmetic uses BigInt internally, so precision is preserved for
amounts above Number.MAX_SAFE_INTEGER (~9 × 10¹⁵).
Pair USDC→EURC has feeBps: 30 (0.3 %). Quoting amount = "10000":
feeAmount = floor(10000 × 30 / 10000) = floor(3) = 3
netAmount = 10000 − 3 = 9997
Response:
{
"source_asset": "USDC",
"dest_asset": "EURC",
"amount": "10000",
"estimated_rate": "1.0",
"route": ["USDC", "EURC"],
"feeBps": 30,
"feeAmount": "3",
"netAmount": "9997"
}Note:
amount,estimated_rate, androuteare unchanged for backward compatibility. New integrations should usenetAmountas the authoritative receivable figure.
Reverse quote: given a desired output amount, compute the gross input needed
so the recipient receives exactly output base units after fees are deducted.
- Query:
source_asset(1–12 chars),dest_asset(1–12 chars),output(positive integer string, no leading zero,/^[1-9][0-9]{0,38}$/). - Response 200:
{ "source_asset": "USDC", "dest_asset": "EURC", "output": "9970", "requiredInput": "10000", "feeAmount": "30", "feeBps": 30, "route": ["USDC", "EURC"] } - Errors:
400 invalid_requestif any param is missing, if assets are invalid or equal, or ifoutputis not a valid positive integer string.
The forward fee formula is fee = floor(gross × feeBps / 10000), so
output = gross - fee. Inverting:
requiredInput = ceil(output × 10000 / (10000 − feeBps))
feeAmount = requiredInput − output
Ceiling division ensures the recipient always receives at least output base
units. A feeBps of 10000 (100%) is rejected with a RangeError.
curl 'http://localhost:3001/api/v1/quote/reverse?source_asset=USDC&dest_asset=EURC&output=9970'Quote up to 100 items in one request. Invalid items are reported per-item rather than failing the whole request.
- Body:
{ "items": [ { "source_asset": "USDC", "dest_asset": "EURC", "amount": "100" }, … ] }(1–config.bulkMaxItemsitems). - Response 200:
{ "results": [ … ] }where each result is either{ index, ok: true, source_asset, dest_asset, amount, estimated_rate }or{ index, ok: false, error: "invalid_item" }. - Errors:
400 invalid_requestifitemsis not an array of 1–config.bulkMaxItemsentries.
curl -X POST http://localhost:3001/api/v1/quote/bulk \
-H 'Content-Type: application/json' \
-d '{"items":[{"source_asset":"USDC","dest_asset":"EURC","amount":"1000"}]}'API keys are created server-side; the raw secret is returned only once at creation and never again. List/delete operate on the first 8 characters (the prefix).
Create a new API key. The raw key is returned once at creation and never again — store it securely.
- Body:
{ "label": "ci-runner", "scopes": ["pairs:write"] }label— required, 1–64 chars.scopes— optional string array. Known scopes:pairs:write,webhooks:write,keys:admin. Omitscopesto create a read-only key (empty scope set).
- Response 201:
{ "key": "srk_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "label": "ci-runner" } - Errors:
400 invalid_requestiflabelis missing or not 1–64 chars, or ifscopescontains unknown values.
curl -X POST http://localhost:3001/api/v1/api-keys \
-H 'Content-Type: application/json' \
-d '{"label":"ci-runner","scopes":["pairs:write"]}'List all API keys. The raw key value is never returned.
- Response 200:
Rotated predecessor records include a
{ "items": [ { "prefix": "srk_a1b2", "label": "ci-runner", "createdAt": 1700000000000 }, { "prefix": "srk_c3d4", "label": "old-key", "createdAt": 1699000000000, "rotatedAt": 1700000000000 } ] }rotatedAtfield.
curl http://localhost:3001/api/v1/api-keysDelete by the 8-character key prefix.
- Response 204: empty body on success.
- Errors:
404 not_foundif no key matches the prefix.
curl -X DELETE http://localhost:3001/api/v1/api-keys/srk_a1b2Rotate an API key without downtime. Mints a new srk_ successor key inheriting
the predecessor's label. The predecessor remains valid for a grace window
(ROTATION_GRACE_MS, default 1 hour) so callers can cut over without downtime.
- Path param:
:prefix— the 8-character prefix of the key to rotate. - Response 201:
The new raw key is returned exactly once.
{ "key": "srk_newkeyvalue0000000000000000000", "label": "ci-runner", "graceExpiresAt": 1700003600000 }graceExpiresAtis the epoch-ms deadline after which the predecessor key will stop working. - Errors:
404 not_foundif no key matches the prefix.
curl -X POST http://localhost:3001/api/v1/api-keys/srk_a1b2/rotate- Body:
{ "url": "https://example.com/hook", "events": ["pair.registered"] }.urlmust behttp(s)and ≤ 2048 chars;eventsmust be a non-empty array of strings. - Events limits:
- At most 20 entries per registration (
WEBHOOK_MAX_EVENTS). - Each event name must be ≤ 128 characters (
WEBHOOK_MAX_EVENT_LENGTH). - Event names must not be blank or whitespace-only.
- Event names must not start with a reserved prefix:
internal.,system., oradmin.(reserved for internal StableRoute use). - Duplicate event names are silently deduplicated before storage.
- At most 20 entries per registration (
- Response 201:
{ "id": "wh_<hex>", "url", "events" }. Theeventsarray in the response reflects the deduplicated list. - Errors:
400 invalid_requestifurlis invalid, or ifeventsviolates any of the rules above.
List all registered webhooks.
- Response 200:
{ "items": [ { "id": "wh_a1b2c3d4e5f6a7b8", "url": "https://example.com/hook", "events": ["pair.registered"], "createdAt": 1700000000000 } ] }
curl http://localhost:3001/api/v1/webhooksRead a single registered webhook by id.
- Response 200:
{ "id": "wh_a1b2c3d4e5f6a7b8", "url": "https://example.com/hook", "events": ["pair.registered"], "createdAt": 1700000000000 } - Errors:
404 not_foundif no webhook has that id.
curl http://localhost:3001/api/v1/webhooks/wh_a1b2c3d4e5f6a7b8Update the subscribed events list for an existing webhook in place.
The url is immutable on PATCH — to change the destination, delete and
recreate the webhook.
- Body:
{ "events": ["pair.registered", "pair.unregistered"] }— non-empty string array; duplicates are deduplicated before storage. - Response 200: the updated webhook object (
{ id, url, events, createdAt }). - Errors:
404 not_foundif the webhook does not exist;400 invalid_requestifeventsis missing, empty, or contains non-string values.
curl -X PATCH http://localhost:3001/api/v1/webhooks/wh_a1b2c3d4e5f6a7b8 \
-H 'Content-Type: application/json' \
-d '{"events":["pair.registered","pair.unregistered"]}'Delete a registered webhook.
- Response 204: empty body on success.
- Errors:
404 not_foundif no webhook has that id.
curl -X DELETE http://localhost:3001/api/v1/webhooks/wh_a1b2c3d4e5f6a7b8Pause the service. While paused, all non-idempotent (GET/HEAD/OPTIONS)
requests return 503 service_paused, except POST /api/v1/admin/unpause which
is always reachable so an operator can recover.
- Response 200:
{ "paused": true }. - Audit: emits an
admin.pausedevent.
curl -X POST http://localhost:3001/api/v1/admin/pauseResume the service. Always reachable even while paused.
- Response 200:
{ "paused": false }. - Audit: emits an
admin.unpausedevent.
curl -X POST http://localhost:3001/api/v1/admin/unpauseEnable read-only maintenance mode. Keeps reads and quotes flowing while
freezing all other mutations. Weaker than paused — if the service is also
paused, the pause guard (503 service_paused) takes precedence.
While read-only is active, allowed requests are:
- All
GET/HEAD/OPTIONSrequests. POST /api/v1/quote,POST /api/v1/quote/reverse,POST /api/v1/quote/bulk.POST /api/v1/admin/read-write(recovery path, always reachable).
All other mutating requests return 503 read_only_mode.
- Response 200:
{ "readOnly": true }.
curl -X POST http://localhost:3001/api/v1/admin/read-onlyDisable read-only maintenance mode. Always reachable even while read-only is active, so operators can never be locked out.
- Response 200:
{ "readOnly": false }.
curl -X POST http://localhost:3001/api/v1/admin/read-writeReturns the current operational flags.
- Response 200:
{ "paused": false, "readOnly": false }.
curl http://localhost:3001/api/v1/admin/statusAggregate statistics about the current service state.
- Response 200:
{ "totalPairs": 5, "paused": false, "totalApiKeys": 2, "totalWebhooks": 1, "totalEvents": 42, "pairsWithFee": 3, "distinctAssets": 4 }
| Field | Description |
|---|---|
totalPairs |
Number of currently registered pairs. |
paused |
Whether the service is currently paused. |
totalApiKeys |
Number of API keys in the store (including rotated predecessors). |
totalWebhooks |
Number of registered webhooks. |
totalEvents |
Current number of entries in the audit event log. |
pairsWithFee |
Count of pairs whose stored feeBps > 0. |
distinctAssets |
Number of unique asset codes appearing in any registered pair. |
curl http://localhost:3001/api/v1/statsPrometheus exposition format.
- Response 200:
text/plain; version=0.0.4body with the following gauges:
| Metric | Type | Description |
|---|---|---|
stableroute_pairs_total |
gauge | Number of currently registered pairs. |
stableroute_paused |
gauge | 1 if the service is paused, 0 otherwise. |
stableroute_events_total |
gauge | Current size of the in-memory audit event log. |
stableroute_events_by_type{type="…"} |
gauge | Count of events in the audit log for each known event type (pair.registered, pair.refreshed, pair.unregistered). |
Label values in stableroute_events_by_type are escaped per the Prometheus
text-exposition rules (backslash, double-quote, and newline characters are
escaped). The series set is stable across scrapes — all known types are always
emitted even when their count is zero.
Audit log (in-memory ring buffer). The maximum number of stored entries is
controlled by the eventLogCap config key (default 10 000; see
PATCH /api/v1/config below).
- Query:
since— epoch ms timestamp; only events withts >= sinceare included (default0).limit— maximum number of events to return, clamped to[1, 10 000](default100).type(optional) — filter results to events of exactly this type. Must be one of the canonicalEventTypevalues:pair.registered,pair.refreshed,pair.unregistered. When omitted all event types are returned.sinceandlimitare applied after the type filter.
- Response 200:
{ "items": [ { "id", "ts", "type", "payload" } ] }. - Errors:
400 invalid_requestiftypeis supplied but is not one of the known event types.
Example — fetch only pair.unregistered events in the last window:
curl 'http://localhost:3001/api/v1/events?type=pair.unregistered&limit=50'- Response 200:
{ "config": { "rateLimitPerWindow", "rateLimitWindowMs", "bulkMaxItems", "eventLogCap" } }.
Update mutable config values at runtime. The writable keys are:
| Key | Description | Constraints |
|---|---|---|
rateLimitPerWindow |
Maximum requests allowed per IP per rateLimitWindowMs. |
Positive integer. |
rateLimitWindowMs |
Sliding-window duration in milliseconds for the rate limiter. | Positive integer. |
bulkMaxItems |
Maximum number of items accepted by POST /api/v1/quote/bulk. |
Positive integer, ≤ 100 000. |
eventLogCap |
Maximum number of events kept in the in-memory ring buffer. | Positive integer, ≤ 1 000 000. |
eventLogCap behaviour:
-
The cap is enforced at write time in
recordEvent— every call torecordEventevicts the oldest entry if the buffer exceeds the configured value. -
When you lower
eventLogCapvia this endpoint the existing buffer is trimmed immediately (oldest-first) down to the new cap, so memory is released without waiting for the next write. -
Setting
eventLogCapabove1 000 000is rejected with400 invalid_requestto prevent unbounded memory allocation. -
Body: any subset of the writable keys, each a positive integer.
-
Response 200:
{ "config": { … } }with the merged config. -
Errors:
400 invalid_requestif a provided value is not a positive integer, or ifeventLogCapexceeds 1 000 000, or ifbulkMaxItemsexceeds its absolute maximum.
All examples use placeholder values — never put real keys in command history.
Register a pair:
curl -X POST http://localhost:3001/api/v1/pairs \
-H 'Content-Type: application/json' \
-d '{"source":"USDC","destination":"EURC"}'Set fee, min, max, and liquidity for a pair:
curl -X PATCH http://localhost:3001/api/v1/pairs/USDC/EURC/fee_bps \
-H 'Content-Type: application/json' -d '{"feeBps":30}'
curl -X PATCH http://localhost:3001/api/v1/pairs/USDC/EURC/min \
-H 'Content-Type: application/json' -d '{"minAmount":"100"}'
curl -X PATCH http://localhost:3001/api/v1/pairs/USDC/EURC/max \
-H 'Content-Type: application/json' -d '{"maxAmount":"1000000"}'
curl -X PATCH http://localhost:3001/api/v1/pairs/USDC/EURC/liquidity \
-H 'Content-Type: application/json' -d '{"liquidity":"500000"}'Get a quote:
curl 'http://localhost:3001/api/v1/quote?source_asset=USDC&dest_asset=EURC&amount=100'Create an API key (the key is shown only once — store it securely):
curl -X POST http://localhost:3001/api/v1/api-keys \
-H 'Content-Type: application/json' \
-d '{"label":"my-service"}'Register a webhook:
curl -X POST http://localhost:3001/api/v1/webhooks \
-H 'Content-Type: application/json' \
-d '{"url":"https://example.com/hook","events":["pair.registered"]}'Get a reverse quote (what input is needed to deliver exactly 9970 output units):
curl 'http://localhost:3001/api/v1/quote/reverse?source_asset=USDC&dest_asset=EURC&output=9970'Bulk register pairs:
curl -X POST http://localhost:3001/api/v1/pairs/bulk \
-H 'Content-Type: application/json' \
-d '{"pairs":[{"source":"USDC","destination":"EURC"},{"source":"EURC","destination":"XLM"}]}'Rotate an API key (use the 8-char prefix from GET /api/v1/api-keys):
curl -X POST http://localhost:3001/api/v1/api-keys/srk_a1b2/rotateEnable read-only mode and then re-enable writes:
curl -X POST http://localhost:3001/api/v1/admin/read-only
curl -X POST http://localhost:3001/api/v1/admin/read-writeCheck service operational status:
curl http://localhost:3001/api/v1/admin/status