English · Español · Português · Deutsch · Français · हिन्दी · 简体中文
Payments microservice built with NestJS 12 + Prisma 7 (PostgreSQL).
It is a separate application from the Cosmos developer platform (paydev). The
dev platform only issues APISIX access tokens (consumers + key-auth
credentials) for downstream services. This service is one of those downstream
services: it sits behind APISIX, which load-balances and authenticates every
request before forwarding it here. The service therefore never sees raw API keys
— it only trusts what the gateway forwards.
A request is accepted only when both conditions hold (see
src/common/guards/apisix.guard.ts):
- Gateway shared secret. The request carries
X-Gateway-Secret, compared in constant time againstAPISIX_GATEWAY_SECRET. APISIX injects this header on every proxied request and strips any client-supplied copy, so a correct value can only originate from the gateway. (Defense in depth — pair it with network isolation so the service is not directly reachable.) - Authenticated consumer. APISIX's
key-authplugin, after validating the caller's API key, forwardsX-Consumer-Username(andX-Credential-Identifier). The guard requires the consumer header to be present, proving the key was authenticated upstream.
Routes can opt out with @Public() (used by the health probes the orchestrator
hits directly). Enforcement is always on — there is no opt-out flag. For local
development, run behind APISIX or send X-Gateway-Secret + the X-Consumer-*
headers yourself.
/v1/admin is cross-tenant, so AdminGuard also requires X-Cosmos-Internal.
APISIX removes that header from everything it proxies, so only a backend
calling the service directly with the gateway secret can send it — the developer
platform, which decides whether the signed-in account is an owner or admin. There
is no separate admin credential: the gateway secret, network isolation and the
header remove list in the gateway route are what protect cross-tenant data.
The pipeline:
request → ApisixContextMiddleware (reads consumer headers → req.gatewayConsumer)
→ ApisixGuard (APP_GUARD) (verifies secret + consumer, or @Public bypass)
→ ValidationPipe (DTO validation)
→ Controller / Service (@CurrentConsumer() gives the consumer)
src/
main.ts bootstrap: helmet, URI versioning (/v1), swagger
app.module.ts wires config, prisma, guard (global) and middleware
config/
configuration.ts typed config
env.validation.ts fail-fast env validation (secret required when enforcing)
*-whitelist.ts KYC redirect allow-list
prisma/ PrismaModule + PrismaService (global)
common/
guards/apisix.guard.ts THE gateway gate
guards/public-key.guard.ts confines the SHARED public key to @AllowPublicKey routes
guards/console-only.guard.ts confines a route to the platform console (alias recovery start)
middleware/apisix-context... extracts consumer identity from gateway headers
decorators/ @Public(), @CurrentConsumer(), @AllowPublicKey()
filters/ consistent error responses
interceptors/ structured access logging
interfaces/ GatewayConsumer + Express Request augmentation
validators/ IsStellarAddress (StrKey-based)
errors/api-error.ts ApiError + machine-readable ApiErrorCode
services/advisory-lock... cluster-wide lock for the background timers
stellar/ per-network Horizon servers (bounded timeout), account loader,
SEP-7 links, signed-envelope relay, settlement repository
payment-intents/ Stellar payment intents (controller, service, DTO) — emits events
swaps/ Stellar native swaps (path payments): quote, build XDR, submit
liquidity-pools/ AMM deposit/withdraw, pool + position reads, cost basis + commission on gain
observer/ background reconciler: swaps + LP ops against Horizon, one adapter per table
webhooks/ webhook endpoints CRUD + dispatcher (HMAC-signed, retried)
blindpay/ BlindPay core: HTTP client, Svix verify, sync + inbound webhook
kyc/ receivers (KYC/KYB), wallets, bank accounts, doc upload
onramp/ fiat → stablecoin: payin quotes, payins, virtual accounts
offramp/ stablecoin → fiat: payout quotes, payouts (client-signed)
products/ merchant catalogue
customers/ payer records derived from intents
aliases/ claimable payment handles: signed claims, resolution, email recovery
assets/ curated asset registry: the (code, issuer) pairs vouched for, per network
analytics/ summary, balances, API logs, webhook logs
activity/ client telemetry ingest + feed (wallet, dashboard)
admin/ cross-tenant platform admin (console-only), audited
audit/ audit-trail writer, called inside other modules' transactions
health/ liveness/readiness probes (@Public)
prisma/schema.prisma Consumer, PaymentIntent, Swap, LiquidityPoolOperation,
WebhookEndpoint/Delivery/EmittedEvent, BlindpayReceiver,
Blockchain/BankAccount/VirtualAccount, BlindpayQuote,
BlindpayWebhookEvent, Payin, Payout, RequestLog,
ActivityEvent,
AdminAuditLog, Alias, AliasAddress,
AliasChallenge, AliasRecovery
test/ e2e suites: gateway gate, admin + alias console gates,
payment intents, swaps, liquidity pools, KYC, webhooks
scripts/ OpenAPI generator, README check, operator scripts
deploy/authentik/ Authentik blueprint: the wallet sign-in and sign-up
docs/i18n/ this README in es, pt, de, fr, hi, zh
All routes are versioned under /v1 (URI versioning).
Every route is listed in the route index below, with its scope.
Request and response schemas live in the generated OpenAPI contract, which is
regenerated from the controllers and DTOs on every CI run
(npm run openapi:check fails the build if it drifts):
openapi/openapi.json/openapi/openapi.yaml— committed, reviewable in a diff/docs— Swagger UI, whenSWAGGER_ENABLED=true/docs/json,/docs/yaml— the same spec served live
| Area | Base path | What it does |
|---|---|---|
| Payment intents | /v1/payment-intents |
SEP-7 tx / pay intents, validation, on-chain observer |
| Swaps | /v1/swaps |
Path-payment quote, build unsigned XDR, submit signed |
| Liquidity pools | /v1/liquidity-pools |
AMM deposit / withdraw, positions, commission on gain |
| Webhooks | /v1/webhooks |
Endpoint CRUD, secret rotation, deliveries, redelivery |
| KYC | /v1/kyc |
Receivers (KYC/KYB), wallets, bank accounts, doc upload |
| Onramp | /v1/onramp |
Payin quotes, payins, virtual accounts |
| Offramp | /v1/offramp |
Payout quotes, authorize, payouts (client-signed) |
| Products | /v1/products |
Merchant catalogue |
| Customers | /v1/customers |
Payer records derived from intents |
| Aliases | /v1/aliases |
Claimable payment handles: claim, resolve, recover |
| Wallet sign-in | /v1/wallet |
Google / GitHub / email code, and the encrypted seed backup |
| Assets | /v1/assets |
Curated asset registry per network |
| Analytics | /v1/summary, /v1/balances, /v1/logs |
Dashboard aggregates and logs |
| Activity | /v1/activity |
Client-reported events: ingest, feed, rollup |
| Admin | /v1/admin |
Cross-tenant reads/writes — platform console only, audited |
| Health | /v1/health |
Liveness / readiness (@Public) |
Every route this service serves. Scope is what the API key must hold — one
of means any of the listed scopes is enough, and — means any authenticated key.
Public key marks the routes the shared public key may call (see
The shared public API key). A route marked
platform console takes no API key at all; only the console backend reaches it.
Paths use the OpenAPI {param} form.
| Method | Path | Scope | Public key |
|---|---|---|---|
| GET | /v1/activity/events |
activity:read |
|
| POST | /v1/activity/events |
activity:write |
✓ |
| GET | /v1/activity/summary |
activity:read |
|
| GET | /v1/admin/audit-logs |
platform console | |
| GET | /v1/admin/consumers |
platform console | |
| GET | /v1/admin/customers |
platform console | |
| GET | /v1/admin/payins |
platform console | |
| GET | /v1/admin/payment-intents |
platform console | |
| GET | /v1/admin/payouts |
platform console | |
| GET | /v1/admin/products |
platform console | |
| GET | /v1/admin/receivers |
platform console | |
| PATCH | /v1/admin/receivers/{id}/access |
platform console | |
| POST | /v1/admin/receivers/{id}/approve |
platform console | |
| POST | /v1/admin/receivers/{id}/enable |
platform console | |
| POST | /v1/admin/receivers/{id}/tos |
platform console | |
| GET | /v1/admin/summary |
platform console | |
| GET | /v1/admin/swaps |
platform console | |
| GET | /v1/aliases |
payments:read |
|
| POST | /v1/aliases |
payments:write |
|
| GET | /v1/aliases/availability/{name} |
payments:read |
✓ |
| GET | /v1/aliases/by-address/{address} |
payments:read |
✓ |
| POST | /v1/aliases/challenges |
payments:write |
|
| GET | /v1/aliases/resolve/{name} |
payments:read |
✓ |
| DELETE | /v1/aliases/{name} |
payments:write |
|
| POST | /v1/aliases/{name}/addresses |
payments:write |
|
| DELETE | /v1/aliases/{name}/addresses/{addressId} |
payments:write |
|
| POST | /v1/aliases/{name}/recovery |
platform console | |
| POST | /v1/aliases/{name}/recovery/complete |
payments:write |
|
| GET | /v1/assets |
— | ✓ |
| GET | /v1/balances |
payments:read |
|
| POST | /v1/blindpay/webhooks |
none — @Public(), Svix signature |
|
| GET | /v1/customers |
customers:read |
|
| POST | /v1/customers |
customers:write |
|
| GET | /v1/customers/{id} |
customers:read |
|
| PATCH | /v1/customers/{id} |
customers:write |
|
| DELETE | /v1/customers/{id} |
customers:write |
|
| GET | /v1/health/liveness |
none — @Public() |
|
| GET | /v1/health/readiness |
none — @Public() |
|
| GET | /v1/kyc/bank-details |
kyc:read |
|
| GET | /v1/kyc/rails |
kyc:read |
|
| GET | /v1/kyc/receivers |
kyc:read |
|
| POST | /v1/kyc/receivers |
kyc:write |
|
| GET | /v1/kyc/receivers/{id} |
kyc:read |
|
| PATCH | /v1/kyc/receivers/{id} |
kyc:write |
|
| DELETE | /v1/kyc/receivers/{id} |
kyc:write |
|
| PATCH | /v1/kyc/receivers/{id}/access |
kyc:write |
|
| POST | /v1/kyc/receivers/{id}/approve |
kyc:write |
|
| POST | /v1/kyc/receivers/{id}/enable |
kyc:write |
|
| POST | /v1/kyc/receivers/{id}/tos |
kyc:write |
|
| GET | /v1/kyc/receivers/{receiverId}/bank-accounts |
kyc:read |
|
| POST | /v1/kyc/receivers/{receiverId}/bank-accounts |
kyc:write |
|
| DELETE | /v1/kyc/receivers/{receiverId}/bank-accounts/{id} |
kyc:write |
|
| GET | /v1/kyc/receivers/{receiverId}/wallets |
kyc:read |
|
| POST | /v1/kyc/receivers/{receiverId}/wallets |
kyc:write |
|
| GET | /v1/kyc/receivers/{receiverId}/wallets/sign-message |
kyc:read |
|
| DELETE | /v1/kyc/receivers/{receiverId}/wallets/{id} |
kyc:write |
|
| POST | /v1/kyc/terms-of-service |
kyc:write |
|
| POST | /v1/kyc/upload |
kyc:write |
|
| GET | /v1/liquidity-pools |
one of liquidity:read, swaps:read |
✓ |
| POST | /v1/liquidity-pools/deposit |
one of liquidity:write, swaps:write |
✓ |
| GET | /v1/liquidity-pools/operations |
one of liquidity:read, swaps:read |
|
| GET | /v1/liquidity-pools/operations/{id} |
one of liquidity:read, swaps:read |
|
| POST | /v1/liquidity-pools/operations/{id}/submit |
one of liquidity:write, swaps:write |
✓ |
| GET | /v1/liquidity-pools/positions |
one of liquidity:read, swaps:read |
✓ |
| POST | /v1/liquidity-pools/withdraw |
one of liquidity:write, swaps:write |
✓ |
| GET | /v1/liquidity-pools/{poolId} |
one of liquidity:read, swaps:read |
✓ |
| GET | /v1/defindex/vaults |
one of liquidity:read, swaps:read |
✓ |
| GET | /v1/defindex/vaults/{vault} |
one of liquidity:read, swaps:read |
✓ |
| GET | /v1/defindex/vaults/{vault}/balance |
one of liquidity:read, swaps:read |
✓ |
| POST | /v1/defindex/vaults/{vault}/deposit |
one of liquidity:write, swaps:write |
✓ |
| POST | /v1/defindex/vaults/{vault}/withdraw |
one of liquidity:write, swaps:write |
✓ |
| POST | /v1/defindex/submit |
one of liquidity:write, swaps:write |
✓ |
| GET | /v1/logs |
payments:read |
|
| GET | /v1/logs/webhooks |
webhooks:read |
|
| GET | /v1/offramp/payouts |
offramp:read |
|
| POST | /v1/offramp/payouts |
offramp:write |
|
| POST | /v1/offramp/payouts/authorize |
offramp:write |
|
| GET | /v1/offramp/payouts/{id} |
offramp:read |
|
| POST | /v1/offramp/payouts/{id}/documents |
offramp:write |
|
| POST | /v1/offramp/quotes |
offramp:write |
|
| GET | /v1/onramp/payins |
onramp:read |
|
| POST | /v1/onramp/payins |
onramp:write |
|
| GET | /v1/onramp/payins/{id} |
onramp:read |
|
| POST | /v1/onramp/quotes |
onramp:write |
|
| GET | /v1/onramp/receivers/{receiverId}/virtual-accounts |
onramp:read |
|
| POST | /v1/onramp/receivers/{receiverId}/virtual-accounts |
onramp:write |
|
| POST | /v1/onramp/trustline |
onramp:write |
|
| GET | /v1/payment-intents |
payments:read |
|
| POST | /v1/payment-intents/pay |
payments:write |
✓ |
| POST | /v1/payment-intents/tx |
payments:write |
✓ |
| GET | /v1/payment-intents/{id} |
payments:read |
|
| PATCH | /v1/payment-intents/{id} |
payments:write |
|
| DELETE | /v1/payment-intents/{id} |
payments:write |
|
| GET | /v1/payment-intents/{id}/transitions |
payments:read |
|
| POST | /v1/payment-intents/{id}/validate |
payments:write |
|
| GET | /v1/products |
products:read |
|
| POST | /v1/products |
products:write |
|
| GET | /v1/products/{id} |
products:read |
|
| PATCH | /v1/products/{id} |
products:write |
|
| DELETE | /v1/products/{id} |
products:write |
|
| GET | /.well-known/stellar.toml |
none — @Public(), SEP-1 discovery (recovery servers only) |
|
| GET | /v1/sep10/auth |
none — @Public(); SEP-10/SEP-30, recovery servers only |
|
| POST | /v1/sep10/auth |
none — @Public(); SEP-10/SEP-30, recovery servers only |
|
| POST | /v1/sep30/identity |
none — @Public(); SEP-10/SEP-30, recovery servers only |
|
| POST | /v1/sep30/identity/email/start |
none — @Public(); SEP-10/SEP-30, recovery servers only |
|
| POST | /v1/sep30/identity/email/verify |
none — @Public(); SEP-10/SEP-30, recovery servers only |
|
| GET | /v1/sep30/accounts |
none — @Public(); SEP-10/SEP-30, recovery servers only |
|
| POST | /v1/sep30/accounts/{address} |
none — @Public(); SEP-10/SEP-30, recovery servers only |
|
| PUT | /v1/sep30/accounts/{address} |
none — @Public(); SEP-10/SEP-30, recovery servers only |
|
| GET | /v1/sep30/accounts/{address} |
none — @Public(); SEP-10/SEP-30, recovery servers only |
|
| DELETE | /v1/sep30/accounts/{address} |
none — @Public(); SEP-10/SEP-30, recovery servers only |
|
| POST | /v1/sep30/accounts/{address}/sign/{signer} |
none — @Public(); SEP-10/SEP-30, recovery servers only |
|
| GET | /v1/summary |
payments:read |
|
| GET | /v1/swaps |
swaps:read |
|
| POST | /v1/swaps |
swaps:write |
✓ |
| POST | /v1/swaps/quote |
swaps:read |
✓ |
| GET | /v1/swaps/{id} |
swaps:read |
|
| POST | /v1/swaps/{id}/submit |
swaps:write |
✓ |
| GET | /v1/wallet/auth/providers |
payments:read |
✓ |
| POST | /v1/wallet/auth/oauth/authorize |
payments:write |
✓ |
| GET | /v1/wallet/auth/oauth/callback/{provider} |
none — @Public(), a browser redirect |
|
| GET | /v1/wallet/auth/oauth/session/{state} |
payments:read |
✓ |
| POST | /v1/wallet/auth/oauth/claim |
payments:write |
✓ |
| POST | /v1/wallet/auth/email/start |
payments:write |
✓ |
| POST | /v1/wallet/auth/email/verify |
payments:write |
✓ |
| POST | /v1/wallet/auth/finish |
payments:write |
✓ |
| PUT | /v1/wallet/backup |
payments:write |
✓ |
| POST | /v1/wallet/recovery/setup |
payments:write |
✓ |
| GET | /v1/webhooks |
webhooks:read |
|
| POST | /v1/webhooks |
webhooks:write |
|
| GET | /v1/webhooks/{id} |
webhooks:read |
|
| PATCH | /v1/webhooks/{id} |
webhooks:write |
|
| DELETE | /v1/webhooks/{id} |
webhooks:write |
|
| GET | /v1/webhooks/{id}/deliveries |
webhooks:read |
|
| POST | /v1/webhooks/{id}/deliveries/{deliveryId}/redeliver |
webhooks:write |
|
| POST | /v1/webhooks/{id}/ping |
webhooks:write |
|
| POST | /v1/webhooks/{id}/rotate-secret |
webhooks:write |
Every failure returns the same envelope, and code is the stable,
machine-readable part — branch on it rather than on message, which is prose and
may be reworded:
The envelope and the full code enum are published in the OpenAPI spec as
ApiErrorBodyEntity (source: ApiErrorCode in src/common/errors/api-error.ts).
Each operation documents only the statuses it can actually return, and each status
carries one example per code it can carry — the real message, with the matching
statusCode and error — so Swagger UI and a Postman import show the body you
would really receive. Codes are never renamed once published; new ones may be
added, so treat an unrecognised code as its HTTP status.
A few that are easy to confuse:
| Code | Status | Means |
|---|---|---|
insufficient_scope |
403 | The API key lacks the scope. Re-provision the key |
account_disabled |
403 | An operator disabled this fiat account. Not a key problem |
gateway_required |
403 | The request did not arrive through APISIX |
admin_console_only |
403 | The route belongs to the platform console (/v1/admin, starting an alias recovery). No API key can call it |
idempotency_conflict |
409 | This Idempotency-Key (or payment-intent memo) already produced a resource for a different request. Repeat the original request, or use a new key |
kyc_state_invalid |
409 | An illegal KYC state transition — not a duplicate request |
operation_in_flight |
409 | A conflicting operation is still settling |
payload_expired |
409 | The delivery body is past retention and cannot be re-sent |
provider_unavailable |
502/503/504 | BlindPay or Horizon is unreachable. Retry |
misconfigured |
503 | A server-side configuration error. Retrying will not help |
APISIX load-balances across instances, so every background timer runs on every
replica. Status changes are already safe — each one is a guarded updateMany
compare-and-swap — but duplicate ticks would multiply Horizon calls against a
rate-limited API. So each timer takes a PostgreSQL transaction-level advisory
lock (AdvisoryLockService) and skips its tick when another replica holds it:
| Timer | Lock key |
|---|---|
SettlementObserverService |
SettlementObserver |
StellarObserverService |
PaymentIntentObserver |
RequestLogRetentionService |
RequestLogRetention |
| Webhook delivery sweeper | WebhookDeliverySweeper |
RateLimitPruneService |
RateLimitPrune |
AliasChallengeSweeperService |
AliasChallengeSweeper |
pg_try_advisory_xact_lock never blocks, and it is released when the
transaction ends, even on a crash or a dropped connection. Unlike a session-level
lock, it also works behind PgBouncer in transaction-pooling mode.
Lock ids live in the AdvisoryLockKey enum. Do not renumber an existing id —
during a rolling deploy, old and new replicas would take different locks — and do
not reuse a retired one.
A payment is confirmed against the Stellar network in one place
(StellarVerifierService): the transaction must be successful, contain a
native (XLM) payment to the intent's destination for the exact amount,
— when the intent has a memo — carry a matching memo (memo_type: id), and
have closed no earlier than a minute before the intent was created
(TX_CREATED_AT_SKEW_MS). The age floor is what stops an old on-chain payment
with the same terms from settling a new intent.
Two paths use that single rule:
- Manual:
POST /v1/payment-intents/:id/validatewith{ "txHash": "<64-hex>" }. On a match the intent is set toSUCCEEDED(andtxHashsaved) and aPAYMENT_INTENT_SUCCEEDEDwebhook fires. A tx that failed on-chain marks the intentFAILEDonly when it was this intent's own payment — same memo, destination and asset. Any other transaction, failed or not, is a mismatch that leaves the status unchanged, so the correct tx can still be submitted. AtxHashreported withPATCH /v1/payment-intents/:idnever settles an intent on its own: it must be a 64-character hex hash, is stored lowercase, and is unique only among the calling consumer's intents (409 idempotency_conflicton a clash with another of them). - Automatic (permanent observer):
StellarObserverServicepolls Horizon everyOBSERVER_INTERVAL_MSforPENDINGintents — by reportedtxHash, or by scanning payments to the destination — and finalizes matches the same way, so statuses change and events fire without anyone calling the API. One tick takes at mostOBSERVER_MAX_INTENTS_PER_CONSUMER(10) intents per consumer and never scans an expired one, so one consumer cannot delay everyone else's settlement. Disable for local dev withOBSERVER_ENABLED=false.
Expiry checks the chain first. An intent past its lifetime is verified once
more before it is marked EXPIRED: if its payment is on-chain it settles as
SUCCEEDED instead, and if Horizon cannot be reached it is left for the next
tick. When that payment's hash already sits on another of the same consumer's
intents, the intent is expired rather than retried forever. A payment verified
after expiry, by the observer or by validate, still
moves an EXPIRED intent to SUCCEEDED and fires PAYMENT_INTENT_SUCCEEDED, so
do not treat EXPIRED as final. The scan reads the destination's payments back to
the intent's creation, at most 1,000 (5 pages of 200); if a destination receives
more than that during an intent's lifetime, call validate with the hash.
Every inbound request except /v1/health and /docs is appended to
request_log by LoggingInterceptor, and powers the dashboard API logs
view (GET /v1/logs). Rows include path, status, duration, and — when present —
the payer's ip / userAgent.
Dashboard traffic (X-Cosmos-Internal) is recorded and flagged
(request_log.internal), not skipped, and the API-log view filters on that
column, so no request header can keep traffic out of the log.
Rows are not kept forever. RequestLogRetentionService deletes rows
older than REQUEST_LOG_RETENTION_DAYS (default 30) on a timer
(REQUEST_LOG_PRUNE_INTERVAL_MS, default 1h). Each cycle deletes in short
REQUEST_LOG_PRUNE_BATCH_SIZE chunks (default 1000) and keeps looping until
the backlog is gone or REQUEST_LOG_PRUNE_MAX_PER_CYCLE (default 50000) is
hit, so a large history can catch up without holding one long table lock. Set
REQUEST_LOG_RETENTION_DAYS=0 to disable the prune entirely (the service logs
that at boot). The composite index on (consumer, createdAt) keeps the
dashboard query fast as volume grows.
request_log only records requests that reached this service. It cannot see a
wallet that crashed on its send screen, a signature the user cancelled or a
dashboard page that failed before sending anything, so the clients report those
events themselves to POST /v1/activity/events.
- Batched. Clients queue events and flush them, so an offline wallet sends
them on the next launch. Up to
ACTIVITY_MAX_BATCH(100) per request. - Safe to retry. An event may carry the client's own
eventId;(consumerId, eventId)is unique and duplicates are skipped. The response reportsacceptedandduplicates. - Attributed by the gateway. Rows are written under the consumer APISIX authenticated; there is no body field for it.
- Tolerant of bad payloads. An over-long
messageis truncated and an over-sizedpropsis replaced with{"_dropped": "props_too_large"}instead of rejecting the whole batch. - Clamped timestamps.
occurredAtis replaced with the receipt time when it is more than five minutes ahead or more than seven days behind. Both times are kept:at(the client's) andreceivedAt.
Reading it back:
| Route | Scope | Returns |
|---|---|---|
GET /v1/activity/events |
activity:read |
The feed, newest first. Filters: source, level, category, type (prefix), network, since/until |
GET /v1/activity/summary |
activity:read |
Counts per level/source/category, top event types, top errors, sessions, devices, a daily series |
level on the feed is a minimum, not an exact match: level=warn returns
warnings and errors.
activity_event holds an IP, a user agent and whatever the client attached, so
it is pruned by the same job and in the same bounded batches as request_log —
ACTIVITY_RETENTION_DAYS, default 30, 0 to keep events forever.
Each integrator (APISIX consumer) registers one or more webhook endpoints. When a
payment intent changes, the platform fires a domain event; the dispatcher
fans it out to every enabled endpoint of that consumer subscribed to the event
type (empty subscription = all), records each attempt for traceability, and
retries with linear backoff (WEBHOOK_* env).
Event types: PAYMENT_INTENT_CREATED, PAYMENT_INTENT_UPDATED,
PAYMENT_INTENT_SUCCEEDED, PAYMENT_INTENT_FAILED, PAYMENT_INTENT_CANCELLED,
PAYMENT_INTENT_DELETED, SWAP_CREATED, SWAP_SUBMITTED, SWAP_SUCCEEDED,
SWAP_FAILED, LIQUIDITY_CREATED, LIQUIDITY_SUBMITTED, LIQUIDITY_SUCCEEDED,
LIQUIDITY_FAILED, plus the BlindPay-sourced RECEIVER_UPDATED, PAYIN_CREATED,
PAYIN_UPDATED, PAYIN_COMPLETED, PAYOUT_CREATED, PAYOUT_UPDATED and
PAYOUT_COMPLETED. The authoritative list is the WebhookEventType enum in
prisma/schema.prisma.
BlindPay-sourced bodies. RECEIVER_UPDATED / PAYIN_* / PAYOUT_* carry
identity and state only — ids, status, amounts, rails — never personal data. The
provider object is not forwarded, because a receiver payload is a full KYC
dossier and subscribing only needs webhooks:write. Fetch the details from the
API with a key that holds kyc:read / onramp:read / offramp:read. The field
allowlist is in src/blindpay/blindpay-event-redaction.ts.
Delivery is decoupled via NestJS EventEmitter2 (webhook.event), so emitting a
notification never blocks the API request that triggered it.
Outbound destination policy (SSRF): endpoints must use https and resolve
only to public addresses. Registration rejects loopback, RFC1918 private ranges,
link-local (169.254.0.0/16, including cloud metadata 169.254.169.254), and
known metadata hostnames. Every host-dependent refusal gives the same answer
— "the host is not an allowed destination" — and the reason goes to the log
instead: telling "does not resolve here" from "resolves to 10.0.4.7" from
"resolves to the metadata service" would let anyone who can register an endpoint
map the network this service runs in, one URL at a time. A malformed URL, a
wrong scheme, credentials or a missing host still say exactly what is wrong:
those describe the string that was sent, not the network. The same check runs again immediately before each
delivery (DNS can change after register). The HTTP client uses redirect: manual
(never follows 3xx), connect/read timeouts from env, and a max response body
size.
| Variable | Default | Meaning |
|---|---|---|
WEBHOOK_CONNECT_TIMEOUT_MS |
3000 |
Connect budget (part of AbortSignal timeout) |
WEBHOOK_READ_TIMEOUT_MS |
5000 |
Read budget (part of AbortSignal timeout) |
WEBHOOK_MAX_RESPONSE_BYTES |
65536 |
Cap on drained response body |
WEBHOOK_TIMEOUT_MS |
5000 |
Legacy fallback if the split timeouts are unset |
WEBHOOK_MAX_ATTEMPTS / WEBHOOK_BACKOFF_MS |
3 / 2000 |
In-process retry loop, per delivery attempt |
WEBHOOK_SWEEP_ENABLED |
true |
Recovers deliveries stranded by a crash. The incident switch — set false to stop redelivery to an integrator that is melting down |
WEBHOOK_SWEEP_INTERVAL_MS |
60000 |
How often a replica tries to sweep (only one wins per tick) |
WEBHOOK_PAYLOAD_RETENTION_DAYS |
30 |
After this, the stored body of a settled delivery is replaced with a redaction marker. 0 keeps bodies forever |
A delivery can be attempted up to 9 times, not 3. WEBHOOK_MAX_ATTEMPTS
bounds one in-process retry loop. The sweeper then picks up deliveries still
under WEBHOOK_MAX_ATTEMPTS × 3 total attempts, spread over hours, so a delivery
interrupted by a pod restart is not lost.
Redelivery only works within the retention window. After
WEBHOOK_PAYLOAD_RETENTION_DAYS the stored body is cleared (the delivery log is
kept). The sweeper skips those rows, and
POST /v1/webhooks/:id/deliveries/:id/redeliver returns 409 payload_expired.
Receiving webhooks. Any 2xx acknowledges. Answer within
WEBHOOK_READ_TIMEOUT_MS (5s default). Order is not guaranteed, so reconcile
against the API. Deduplicate on the event id; a redelivery reuses the original
id (at-least-once delivery).
Migrating existing endpoints: after deploy, run
npm run webhooks:audit-destinationsUnsafe rows get destinationBlocked=true and enabled=false. Integrators fix
the URL with PATCH /v1/webhooks/:id { "url": "https://…" } (validation runs
again and clears the flag), or re-enable after DNS is public.
Payload (POST body to the integrator's URL):
{
"id": "evt_...", // stable event id (use for idempotency)
"type": "PAYMENT_INTENT_SUCCEEDED",
"createdAt": "2026-...",
"data": { /* the payment intent */ }
}Headers:
X-Cosmos-Signature: t=<unixSeconds>,v1=<hexHmacSha256>— HMAC-SHA256 of${t}.${rawBody}using the endpoint'swhsec_...secret.X-Cosmos-Event,X-Cosmos-Event-Id,X-Cosmos-Delivery.
Verifying the signature (integrator side):
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody: string, header: string, secret: string): boolean {
const [t, v1] = header.split(',').map((p) => p.split('=')[1]);
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}The signing secret is returned once on POST /webhooks (and on
rotate-secret); list/get responses never include it. Every attempt is stored
(webhook_delivery) with status, attempts, response code and error — query it
via GET /webhooks/:id/deliveries and re-send with the redeliver route.
List, get and update return exactly the documented endpoint fields, and create
and rotate-secret add secret. Nothing else on the row leaves the service — not
consumerId, and not the previousSecret / previousSecretExpiresAt columns an
earlier grace-window rotation wrote.
ping and redeliver are rate limited, per consumer and client address:
POST /v1/webhooks/:id/ping 20 and
POST /v1/webhooks/:id/deliveries/:deliveryId/redeliver 30 per 10 minutes
(429 rate_limited). Both make this service send signed requests to a URL you
chose, and redeliver runs the whole retry loop inside the request. For a large
backlog, let the sweeper retry instead of redelivering one by one.
Security note: GET /docs, /docs/json, and /docs/yaml are mounted as
Express middleware, not Nest controllers, so they do not pass through
ApisixGuard or PermissionsGuard — anyone who can reach the service port can
fetch the spec. In production, docs are off by default (NODE_ENV=production
and no SWAGGER_ENABLED). Set SWAGGER_ENABLED=true only on a trusted network.
Export the spec to files — no database or real gateway secret is needed:
npm run openapi:generate
# writes openapi/openapi.json and openapi/openapi.yamlCI regenerates both committed files and rejects drift. Run the same check before committing a controller or DTO change:
npm run openapi:checkPaths in the spec already include the version (/v1/...). To set a gateway host
in the spec's servers, set OPENAPI_SERVER_URL before generating:
OPENAPI_SERVER_URL=https://gateway.example.com npm run openapi:generateUsing it from Postman. Import openapi/openapi.json, or
http://localhost:3000/docs/json from a running service. The spec offers two
servers and two security requirements; tools that pick one take the first of each:
| Calling | Server | Auth |
|---|---|---|
| This service directly (local development) | http://localhost:{port} (port defaults to 3000) |
X-Gateway-Secret and X-Consumer-Username, together |
| Through the APISIX gateway | OPENAPI_SERVER_URL, listed first when set |
Authorization: Bearer <api key> |
The committed spec is generated without OPENAPI_SERVER_URL, so it defaults to
the direct pair; generate it with the variable set for a collection that defaults
to the gateway. Postman keeps one API key per request: if an import sets only
X-Gateway-Secret, add X-Consumer-Username as a collection header. The health
probes are published with security: [].
Every operation carries vendor extensions that say what it is:
x-cosmos-rate-limit (its budgets — it can answer 429), x-cosmos-upstream
(the provider it calls — it can answer 502/503/504), x-cosmos-public and
x-cosmos-public-key.
npm run openapi:generate refuses to write a spec in which an operation has no
summary, a failure has no body or example, an example's statusCode disagrees
with the status it documents, or a 429 sits on a route with no budget. Whenever
you add or change a route, read its regenerated operation — see CLAUDE.md.
Per SEP-7, the tx and pay operations
take different parameters and produce different responses, so each has
its own endpoint, DTO and response schema. The service holds no keys — it only
assembles the request for the client's wallet (returns uri + qr, plus xdr
for tx). Asset defaults to native XLM when assetCode is omitted (or
XLM/native); any other asset requires assetIssuer.
Network is dictated by the API key type the gateway forwards: a prod key →
public (mainnet), a dev key → testnet. STELLAR_NETWORK is only a fallback for
local dev without the gateway. Each intent stores its own network and all Horizon
calls (build, validation, observer) target it. Every intent is stored
(payment_intent table) and scoped to the calling consumer:
PENDING → SUBMITTED → SUCCEEDED/FAILED/CANCELLED/EXPIRED. The one way out of a
final status is EXPIRED → SUCCEEDED, on a payment verified on-chain.
The memo is a mandatory MEMO_ID — it identifies the payment on-chain and
makes creation idempotent: (consumer, memo) is unique, so re-creating with
the same memo and the same terms returns the original intent. The same memo
with any different term — kind, network, destination, amount, asset, msg,
callback, or source for tx — is 409 idempotency_conflict, and the error
says nothing about the stored intent. This matters under the shared public key,
where every anonymous wallet is the same consumer. Both builders share a budget
of 30 calls a minute per consumer and client address (429 rate_limited):
each one reads the payer's account from Horizon and writes a row, and on the
shared public key the address is all that separates one anonymous wallet from
the next. If you don't pass memo, a
random uint64 is generated.
POST /v1/payment-intents/tx — the payer (source) is known, so we build
the unsigned TransactionEnvelope and a web+stellar:tx?xdr=... URI.
// request (source, destination, amount required)
{
"source": "G...", "destination": "G...", "amount": "120.1234567",
"assetCode": "USDC", "assetIssuer": "G...", // optional (native if omitted)
"memo": "123456789", // optional MEMO_ID (auto-generated if omitted)
"msg": "Order #24", "callback": "url:https://…" // optional SEP-7 extras
}
// response → { id, kind: "TX", memo, xdr, uri: "web+stellar:tx?xdr=…", qr, network, … }POST /v1/payment-intents/pay — no source, so we return only a
web+stellar:pay?destination=... URI (the wallet chooses the source asset/path).
// request (only destination required; amount optional → donations)
{
"destination": "G...", "amount": "120.1234567", // amount optional
"assetCode": "USD", "assetIssuer": "G...", // optional (native if omitted)
"memo": "123456789", // optional MEMO_ID (auto-generated if omitted)
"msg": "pay me with lumens", "callback": "url:https://…"
}
// response → { id, kind: "PAY", memo, xdr: null, uri: "web+stellar:pay?destination=…&memo=…&memo_type=MEMO_ID", qr, network, … }Example tx response:
{
"id": "clx...", // persisted intent id
"status": "PENDING",
"network": "testnet",
"source": "G...",
"destination": "G...",
"amount": "25.5",
"memo": "123456789",
"xdr": "AAAA...", // unsigned transaction envelope
"uri": "web+stellar:tx?xdr=...", // SEP-7 deep link
"qr": "data:image/png;base64,...", // QR of the SEP-7 URI (derived from uri)
"createdAt": "2026-...",
"updatedAt": "2026-..."
}Network/Horizon/fee/timeout are configured via STELLAR_* env vars
(see .env.example). Defaults to testnet for safety — set
STELLAR_NETWORK=public for mainnet (real funds).
The open-source wallet ships one API key that everybody shares, so anyone can
swap, add liquidity or create a pay link without registering. Those calls pay the
community plan's commission (150 bps, the highest rate); registering gets a
lower one. The gateway injects the rate exactly as it does for a private key
(see resolvePlanCommissionBps).
The difference is tenancy. Every anonymous caller arrives as the same APISIX consumer, and read endpoints filter rows by consumer:
// swaps.service.ts
where: { id, consumer: { apisixUsername: consumer.username } }So GET /v1/swaps under the public key would return every anonymous user's swap
history. Scopes cannot prevent this, because everyone holds the same key — and
POST /v1/swaps/quote needs swaps:read, the same scope that lists the history.
PublicKeyGuard is an allowlist. The public consumer is refused on every
route that does not carry @AllowPublicKey(), so new routes are closed to it by
default.
Reachable with the public key today:
| Route | Why it is safe |
|---|---|
POST /v1/swaps/quote |
Prices a path from Horizon; a pure function of the request |
POST /v1/swaps |
Builds an unsigned envelope the caller signs |
POST /v1/swaps/:id/submit |
Broadcasts a caller-signed envelope — nothing about the swap, not even its status, is answered until the body is that swap's envelope carrying a signature; rate limited |
POST /v1/liquidity-pools/deposit | withdraw |
Build unsigned envelopes |
POST /v1/liquidity-pools/operations/:id/submit |
Broadcasts a caller-signed envelope, under the same checks as swap submit; rate limited |
GET /v1/liquidity-pools | /:poolId | /positions |
Public on-chain data read from Horizon |
POST /v1/payment-intents/tx | pay |
Build a SEP-7 intent from the request |
POST /v1/activity/events |
Telemetry ingest — see below |
GET /v1/assets |
The public asset catalog |
GET /v1/aliases/resolve/:name | availability/:name | by-address/:address |
A payer resolving a handle is the anonymous caller this key exists for; the answer is a pure function of the request and never includes the owner's mailbox |
Refused: GET /v1/swaps, GET /v1/swaps/:id,
GET /v1/liquidity-pools/operations{,/:id}, GET /v1/activity/events,
GET /v1/activity/summary, every payment-intent read, every alias owner route
(claim, list, add or remove an address, release, recovery), and everything under
/v1/kyc, /v1/onramp, /v1/offramp and /v1/webhooks. A wallet without an
account reads its history from Horizon instead.
Telemetry is allowed so crash reports from wallets without an account still arrive. Events on this key are anonymous (one shared consumer), so the wallet strips address, destination, amount and txHash before sending.
The guard identifies the public consumer by either the forwarded role
(X-Consumer-Role: public) or the APISIX_PUBLIC_CONSUMER username. Set
both: if the gateway stops forwarding roles, the username still matches, and
without the username the guard depends only on a header.
Stellar has no dedicated "swap" operation. Asset exchange is done with a
PathPaymentStrictSend, which Horizon automatically routes through the best
available combination of the Stellar DEX order books and AMM liquidity
pools. Cosmos Pay wraps that into a swap flow that is, like payment intents,
completely non-custodial — funds never pass through the service. It only:
- Quotes by querying Horizon's strict-send path search.
- Builds the unsigned transaction (an optional platform-fee payment + the
path payment) and returns its
xdr+ SEP-7txURI + QR. - Relays the transaction the customer signs in their own wallet.
quote → build XDR → customer signs in wallet → POST /submit → Stellar executes
The network is dictated by the API key type (prod → public, dev → testnet), the
same as payment intents, and every swap is persisted (swap table) and scoped
to the calling consumer (PENDING → SUBMITTED → SUCCEEDED/FAILED).
Fee (per-organization, enforced server-side). The commission is the rate of
the calling organization's plan, injected by the gateway as a trusted header
(X-Plan-Swap-Fee-Bps) that the dev platform derives from the org's plan. It is
never a request parameter, and APISIX overwrites any client-supplied copy, so
the rate cannot be bypassed or undercut. The fee is taken from the source asset
and paid to the platform wallet (STELLAR_SWAP_FEE_WALLET) as a first payment
operation; the remainder is routed through the swap. If a plan fee applies but
no platform wallet is configured, swap creation fails with 503 (operator
misconfiguration). STELLAR_SWAP_FEE_BPS is only a fallback for local dev without
the gateway (and is itself disabled when no wallet is set).
Slippage. The quote's estimate, reduced by slippageBps (default
STELLAR_SWAP_SLIPPAGE_BPS, capped by STELLAR_SWAP_MAX_SLIPPAGE_BPS), becomes
the path payment's on-chain destMin — so the swap reverts rather than
delivering less than the caller agreed to accept.
Trustline. A non-native destination asset must already be trusted by the destination account; the build step checks this and returns a clear error otherwise. (XLM needs no trustline.)
POST /v1/swaps/quote — price only, nothing persisted (swaps:read).
// request — sell 100 XLM for USDC
{
"amount": "100", // gross source amount (fee comes out of this)
"destAssetCode": "USDC",
"destAssetIssuer": "GA5ZSEJYB37JRC5AVCIA5MOP4RHTR6F3DSZL5A3W4G4M4N4A5U4QY3T6",
"slippageBps": 50 // optional; defaults to the service setting
// sourceAssetCode / sourceAssetIssuer omitted → native XLM
}
// response
{
"network": "public",
"source": { "asset": "native", "issuer": null, "amount": "100" },
"fee": { "asset": "native", "issuer": null, "amount": "0.5", "bps": 50, "wallet": "G..." },
"swap": { "asset": "native", "issuer": null, "amount": "99.5" },
"destination": { "asset": "USDC", "issuer": "G...", "estimated": "24.81", "minimum": "24.68595", "slippageBps": 50 },
"path": [] // intermediate hops chosen by the router (may be empty)
}POST /v1/swaps — build the signable transaction (swaps:write). Takes the
same fields plus source (the paying/signing account); destination defaults to
source (a self-swap) and an optional memo (MEMO_ID) is echoed on-chain.
Optional idempotency: send an Idempotency-Key header (preferred) or
idempotencyKey in the body. A retry with the same key and the same request
— network, source, destination, both assets, amount, slippage and memo — returns
the existing swap (id + txHash) instead of building another transaction.
The same key with a different request is 409 idempotency_conflict, and the
error says nothing about the stored swap. Liquidity deposits and withdrawals
follow the same rule, comparing the operation's kind as well. Without a key, the
unique (network, txHash) constraint still rejects a byte-identical rebuild
with 409 (sequence / XDR collision). When
STELLAR_SWAP_SINGLE_INFLIGHT=true, a second non-expired PENDING swap for the
same (consumer, source, network) also returns 409 naming the existing id
(default off — concurrent distinct swaps from one account remain allowed).
Only a swap that may already be on-chain holds that guard: a row whose
sequence number the account has not used yet cannot have settled, and the swap
being built now takes the same number, so at most one of the two ever can. Any
caller may name any source, so without that test a single dust swap froze
swapping for a stranger's account for a whole timeout window — and under the
shared public key, for as long as the attacker kept repeating it.
// response → { id, status: "PENDING", network, sendAmount, feeAmount, swapAmount,
// destEstimated, destMin, path, xdr, uri: "web+stellar:tx?xdr=…", qr, txHash, … }Quoting and building are rate limited too, per consumer and client address: 60 quotes a minute and 20 builds a minute, in separate buckets from submit's. A quote persists nothing and still costs a strict-send path search, the most expensive call this service makes of Horizon — and that per-IP budget is shared by swaps, liquidity pools and payment intents alike, so a price polled in a loop degraded all three for every anonymous caller at once.
POST /v1/swaps/:id/submit — relay the signed envelope (swaps:write).
// request
{ "signedXdr": "AAAAAgAAA…(signed base64 XDR)…" }
// response
{ "submitted": true, "status": "SUCCEEDED", "txHash": "…", "swap": { … } }
// on a network rejection → { "submitted": false, "status": "FAILED", "reason": "…", "resultCodes": ["op_under_dest_min"], "swap": { … } }Before broadcasting, the service checks that the signed transaction's hash
matches the one it built, so it never relays an arbitrary transaction. A swap
fires SWAP_CREATED / SWAP_SUBMITTED / SWAP_SUCCEEDED /
SWAP_FAILED webhook events through the same dispatcher.
Submit is strict about what it relays. Nothing about the swap — not even its
status — is answered until signedXdr parses, hashes to the swap's txHash and
carries at least one signature, so the unsigned xdr from the create response is
400 validation_failed. A swap whose envelope is past its time bounds
(STELLAR_TX_TIMEOUT, 300 s by default) is 400 invalid_state_transition and is
not broadcast; if it reached the network in time, the observer still settles it.
After a network rejection the same envelope may be resubmitted at most 3
times, then build a new swap — a retry after 503 provider_unavailable does not
count. The route allows 20 calls a minute per consumer and client address
(429 rate_limited); under the shared public key every anonymous wallet is one
consumer, so wallets behind one NAT share that budget.
POST /v1/liquidity-pools/operations/:id/submit follows the same rules, with a
bucket of its own, and POST /v1/liquidity-pools/deposit · /withdraw share one
budget of 20 builds a minute — the two directions of one flow, so separate
buckets would only let a loop alternate them and take both.
An alias lets a payer type emanuel250 instead of GA5ZSE…. Payers trust that
name right before sending money, so the rules below are strict: a mistake means a
payment to the wrong account.
wallet ──1. POST /v1/aliases/challenges {name, address, network} ──▶ nonce + the EXACT message to sign
wallet ──2. signs SHA-256(domain ‖ 0x00 ‖ uint32be(length) ‖ message) with that address's key
wallet ──3. POST /v1/aliases {name, email, nonce, signature} ────────▶ alias bound to the calling consumer
- Sign the exact message the service returns. Do not rebuild it on the client.
- The signature covers a domain-tagged digest, never a transaction. Nothing
signed in this flow can be submitted to the network, and the domain
(
Cosmos Pay alias claim v1) is unique to this feature, so a signature obtained by another dapp cannot be used as a claim. - The purpose is inside the signed bytes (
CLAIM,ADD_ADDRESS,RECOVER), so a signature collected to add an address cannot be replayed to finish a recovery. - The address comes from the challenge, not from the claim body. The claim has no address field, so nobody can sign for one address and register another.
- Challenges are single-use and last five minutes. The signature is verified before the challenge is spent, so an invalid signature cannot consume someone else's nonce, and spending it is a compare-and-swap.
- A race is settled by the unique index on
alias.name, not by a pre-check; the loser gets409 alias_taken.
Lowercase a-z, 0-9 and _ (never at either end), 3–32 characters, folded to
lowercase before uniqueness is decided. No Unicode: a homoglyph set is unbounded,
and no normalization makes a Cyrillic а safe to render next to an amount. Also
refused: reserved words (admin, support, cosmospay, stellar, …) and
anything that looks like a Stellar account (g or m followed by 20 or more
base32 characters). The rule is in src/aliases/alias-name.ts.
An alias points at up to 20 addresses across networks — a phone, a desktop, a cold
wallet, testnet — with exactly one primary per network, enforced by a partial
unique index. Adding an address takes two proofs: the caller owns the alias,
and the new address signs its own ADD_ADDRESS challenge. The last remaining
address cannot be removed (release the alias instead), and one consumer may hold
at most 25 aliases.
A SUSPENDED alias (an operator hold) resolves to nothing.
A claim records a recovery email so that losing a key does not mean losing the name. Recovery works like this:
- The platform console calls
POST /v1/aliases/:name/recovery {email}. The response is identical whether or not the handle and mailbox matched; on a match it carries a single-use token (30 minutes, stored only as a SHA-256), which the console emails. This service sends no mail. - The user gets a
RECOVERchallenge for the new key and callsPOST /v1/aliases/:name/recovery/complete {token, address, network, nonce, signature}with their own API key. Both proofs are required: the token proves the mailbox, the signature proves the key. - Ownership moves to the calling consumer and every previous address is removed, so whoever holds the old keys stops receiving payments.
Step 1 is console-only because the token proves control of the mailbox, so it
must only reach whoever sends the email. ConsoleOnlyGuard refuses every API-key
caller with 403 admin_console_only before the alias is looked up, and the route
is not in the published contract. A suspended alias cannot be recovered.
A recovery token can be presented five times. A presentation whose challenge
or signature fails still uses one, and the sixth is refused; the owner can start
another recovery. A token that matches no live recovery of that alias gets the
same 400 alias_recovery_invalid and changes nothing, so nobody can burn an
owner's recovery by sending junk. POST /v1/aliases/:name/recovery/complete
allows 10 calls and POST /v1/aliases/challenges 30 calls per 10 minutes, per
consumer and client address (429 rate_limited).
Expired challenges and recoveries are deleted a day after they expire by
AliasChallengeSweeperService (hourly, one replica per tick).
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /v1/aliases/resolve/:name |
payments:read · public key |
The addresses an alias resolves to (?network= filters) |
| GET | /v1/aliases/availability/:name |
payments:read · public key |
Whether a handle is claimable, and if not why |
| GET | /v1/aliases/by-address/:address |
payments:read · public key |
The aliases pointing at an address |
| POST | /v1/aliases/challenges |
payments:write |
A nonce and the exact message to sign |
| POST | /v1/aliases |
payments:write |
Claim an alias with a signature |
| GET | /v1/aliases |
payments:read |
The caller's aliases |
| POST | /v1/aliases/:name/addresses |
payments:write |
Add an address, signed by that address |
| DELETE | /v1/aliases/:name/addresses/:addressId |
payments:write |
Remove an address |
| DELETE | /v1/aliases/:name |
payments:write |
Release the alias |
| POST | /v1/aliases/:name/recovery |
platform console only | Start a recovery → a token for the console to email |
| POST | /v1/aliases/:name/recovery/complete |
payments:write |
Finish a recovery with the token and the new key's signature |
In addition to on-chain payment intents, the service integrates
BlindPay to move money between fiat and
stablecoins: cash in (onramp / payin), cash out (offramp / payout), and
the mandatory KYC (BlindPay receivers) behind both. We run one platform
BlindPay instance per API-key environment — production for prod keys
(BLINDPAY_API_KEY + BLINDPAY_INSTANCE_ID), development for dev keys (the
_DEV variables); every receiver/wallet/bank-account/payin/payout is mirrored in our Postgres and
scoped to the calling APISIX consumer, so each integrator only ever sees their
own records. The service never holds blockchain keys — offramp returns the
artifact to sign (EVM approve contract / Stellar XDR) and accepts the signed tx
back, exactly like payment intents.
State changes are synced from BlindPay's Svix webhooks (verified over the raw
body) and re-emitted to the integrator's own webhook endpoints as new event
types (RECEIVER_UPDATED, PAYIN_*, PAYOUT_*) through the existing dispatcher.
| Method | Path | Scope | Description |
|---|---|---|---|
| POST | /v1/kyc/receivers |
kyc:write |
Create a receiver (start KYC/KYB) |
| GET | /v1/kyc/receivers · /:id |
kyc:read |
List / get (get refreshes KYC status) |
| PATCH | /v1/kyc/receivers/:id |
kyc:write |
Update a receiver (once it is at BlindPay, identity fields need an elevated key) |
| DELETE | /v1/kyc/receivers/:id |
kyc:write |
Delete a receiver |
| POST | /v1/kyc/upload |
kyc:write |
Upload a KYC document → file_url |
| GET | /v1/kyc/rails · /v1/kyc/bank-details?rail= |
kyc:read |
Rail catalog / required fields |
| POST | /v1/kyc/receivers/:id/wallets |
kyc:write |
Register a blockchain wallet |
| GET | /v1/kyc/receivers/:id/wallets/sign-message |
kyc:read |
Message to sign (secure EOA flow) |
| POST | /v1/kyc/receivers/:id/bank-accounts |
kyc:write |
Add a fiat bank account (any rail) |
| POST | /v1/onramp/quotes |
onramp:write |
Price a payin (expires ~5 min) |
| POST | /v1/onramp/payins |
onramp:write |
Create a payin → funding instructions |
| GET | /v1/onramp/payins · /:id |
onramp:read |
List / get (get refreshes status) |
| POST | /v1/onramp/trustline |
onramp:write |
Build an unsigned Stellar trustline XDR |
| POST | /v1/onramp/receivers/:id/virtual-accounts |
onramp:write |
Create a virtual account |
| POST | /v1/offramp/quotes |
offramp:write |
Price a payout (EVM → approve contract) |
| POST | /v1/offramp/payouts/authorize |
offramp:write |
Build the unsigned Stellar/Solana payout tx |
| POST | /v1/offramp/payouts |
offramp:write |
Create a payout from a quote |
| GET | /v1/offramp/payouts · /:id |
offramp:read |
List / get (get refreshes status) |
| POST | /v1/offramp/payouts/:id/documents |
offramp:write |
Attach a compliance document |
| POST | /v1/blindpay/webhooks |
public | Inbound BlindPay (Svix) webhook |
Amounts are integers in minor units (e.g. $123.45 → 12345). Configure
the BlindPay dashboard webhook to <gateway>/v1/blindpay/webhooks and set
BLINDPAY_WEBHOOK_SECRET to that endpoint's signing secret — the whole
whsec_… value. Boot fails when its key decodes to fewer than 24 bytes, and the
verifier refuses such a key regardless: invalid base64 decodes to an empty key,
and anyone can sign with that. Leave the
BLINDPAY_* vars blank to disable the feature: those routes then return 503
misconfigured, and so does the inbound webhook while BLINDPAY_WEBHOOK_SECRET
is unset. See .env.example.
A dev key never reaches the production instance. The key's environment picks
the BlindPay instance the way it picks the Stellar network, and every mirrored row
records the instance it came from, so a tenant's dev and prod keys — one
consumer — see separate receivers, wallets, bank accounts, quotes, payins and
payouts. With no development instance configured, BlindPay routes answer dev
keys with 503 misconfigured. Point both instances' dashboard webhooks at the
same <gateway>/v1/blindpay/webhooks and set BLINDPAY_WEBHOOK_SECRET_DEV for
the development one: the secret a delivery verifies against is what says which
instance sent it.
Identity is reviewed before it reaches BlindPay, including edits. Until a
receiver is enabled, a PATCH that touches KYC data sends it back to
pending_review. Once it exists at BlindPay, a tenant key may change only
external_id and image_url; any other field is 403 kyc_review_required
unless the key is elevated (X-Consumer-Role: admin), because that PUT rewrites
the identity at the provider directly.
An approval is pinned to the dossier that was reviewed. A receiver read
carries dossierVersion, which counts every edit to the submitted KYC data.
Send it back as expected_version when approving, and data that changed since
you read it is 409 kyc_state_invalid rather than an approval of a dossier
nobody saw — an edit leaves the status at pending_review, so the approval
itself could not tell. What was signed off is kept as reviewedVersion, and
POST /v1/kyc/receivers/:id/enable refuses to create the receiver at BlindPay
while the two differ.
The fiat routes have budgets. Every write the provider keeps is limited per
consumer and client address, and every BlindPay-backed route also counts against
a per-consumer ceiling of 60 provider requests a minute: one instance serves
every tenant on the key, so a tenant looping quotes fails other tenants' payins.
Over budget is 429 rate_limited with Retry-After.
| Route | Budget (per consumer + client address) |
|---|---|
POST /v1/kyc/upload |
20 per 10 min |
POST /v1/kyc/terms-of-service |
10 per 10 min |
POST /v1/onramp/quotes · POST /v1/offramp/quotes |
30 a minute, separate buckets |
POST /v1/onramp/payins |
10 a minute |
POST /v1/offramp/payouts/authorize · POST /v1/offramp/payouts |
10 a minute, shared |
POST /v1/offramp/payouts/:id/documents |
20 per 10 min |
POST /v1/onramp/trustline |
20 a minute |
The terms-of-service flow sends the user to BlindPay and back to a redirect_url
the integrator supplies. To avoid an open redirect, every redirect_url passes
two checks:
| Layer | Rule | Where |
|---|---|---|
| Shape | an absolute https URL with no embedded credentials (user:pass@), no fragment (#…), and no backslash, whitespace or control character |
@IsRedirectUrl() on every DTO that carries one, and again in the service layer |
| Host | on the calling consumer's allow-list — the exact host, or a subdomain at a label boundary (app.acme.com matches acme.com; evilacme.com does not) |
KYC_REDIRECT_URL_WHITELIST, enforced in the service layer |
KYC_REDIRECT_URL_WHITELIST={"cosmos_acme":["acme.com","app.acme.com"]}
The shape rules are what the host check is worth. A backslash is read as /
inside the authority by a WHATWG parser and as part of the userinfo by others, so
https://app.acme.com\@evil.test has two honest readings and this service is not
the last to read it — the value goes to BlindPay, comes back on a hosted page and
ends in a browser. Whitespace and control characters are the same class, a
fragment swallows the ?tos_id= the provider appends, and credentials move the
host to the far side of the @.
It fails closed: a consumer with no entry cannot use a redirect at all, and a
host with a trailing dot or in IDN form is refused rather than normalized. Every
route that takes a redirect_url checks it, including the admin approval, which
uses the list of the receiver's own consumer. A refused scheme or host is a
400.
Everything under /v1/pollar is gone — the OAuth bridge (/v1/pollar/oauth/*), wallet
provisioning and trustlines (/v1/pollar/wallets/*) and /v1/pollar/users — along with the
error codes pollar_identity_required, pollar_identity_mismatch and
elevated_key_required, and every POLLAR_* variable. The routes answer 404 now.
- Migration
20260927120000_remove_pollardropspollar_oauth_sessionandpollar_user_wallet. It cannot be undone: back the two tables up first if you need their history. - Delete the APISIX routes for
/v1/pollar/*, the callback route with key-auth off in particular, and unset thePOLLAR_*variables — they are ignored. - Keys may still carry
pollar:*scopes. Nothing checks them any more. - Advisory lock ids
881_005and881_007are retired, and never reused. - Wallets: Cosmos Wallet removes any Pollar wallet from a device the next time it starts. The funds stay with Pollar, at the same address.
Most of these change nothing for a well-behaved caller; check the "Who notices" column before deploying.
| Change | Who notices | Why |
|---|---|---|
POST /v1/aliases/:name/recovery is platform-console only: an API key gets 403 admin_console_only, and the route left the published contract |
Anyone who started recoveries with an API key | The response carries the recovery token, which proves control of the owner's mailbox |
Completing a recovery on a SUSPENDED alias is a 404 |
Nobody legitimate | A token issued before a suspension could bypass the operator hold |
@Public() routes (BlindPay webhook, health) ignore X-Consumer-Username |
Dashboards: those requests now log as anonymous | Those routes have no key-auth, so the header came from the client |
Refusals by AdminGuard and ConsoleOnlyGuard are logged at warn |
Operators | Guards run before the access log, so refused requests left no trace |
Both POST …/trustlines routes share a 429 budget of 20 calls per 10 minutes |
Scripts that add trustlines in bulk | Each trustline locks 0.5 XLM of the operator's funding wallet |
GET /v1/offramp/payouts/:id no longer returns raw, consumerId, receiverId, quoteId, bankAccountId or updatedAt; the virtual-account create response no longer returns raw, receiverId, consumerId or updatedAt |
Callers reading those fields | raw is BlindPay's stored object, with bank and beneficiary data |
POST /v1/kyc/upload returns 400 for more than 4 text fields, a field over 1 KiB, a second file, or file bytes that do not match the declared type |
Nobody sending a well-formed upload | Fields were unbounded and the type check trusted the client's Content-Type |
POST /v1/payment-intents/tx and /pay: the same memo with any different term is 409 idempotency_conflict. An identical retry still returns the stored intent (2 and 2.0 are the same amount) |
Callers reusing one memo for different payments | Under the shared public key, a memo someone else created first returned their intent |
POST /v1/payment-intents/:id/validate marks FAILED only for a failed tx that is this intent's own payment; any other failed tx is valid: false with the status unchanged. A tx that closed more than 60 s before the intent was created is refused ("Transaction predates this payment intent") — on validate, on PATCH {status: SUCCEEDED}, and in the observer |
Nobody legitimate | Any failed transaction could fail an intent, and an old payment with the same terms could settle a new one |
PATCH /v1/payment-intents/:id changing txHash on a terminal intent is 400 invalid_state_transition; a status change racing the write is 409 operation_in_flight |
Nobody legitimate | It could rewrite the settlement evidence of a SUCCEEDED intent |
| The payment-intent observer reconciles at most 10 intents per consumer per tick and never scans expired rows | Operators watching observer throughput | One consumer could delay every other tenant's settlement |
POST /v1/swaps, /v1/liquidity-pools/deposit and /withdraw: a reused Idempotency-Key with a different request — a different memo or slippage, the other network, or a deposit key reused for a withdrawal — is 409 idempotency_conflict. A replay carrying an invalid asset, slippage or memo now gets the normal 400 |
Clients reusing one key for different operations | Under the shared public key, someone could pre-create an envelope under a guessable key and have it returned to another user's retry |
POST /v1/liquidity-pools/withdraw no longer answers 409 operation_in_flight for an in-flight withdrawal whose sequence number the account has not used yet (an unsigned or abandoned envelope) |
Wallet users who were blocked | An envelope built for someone else's account could block withdrawals from that position indefinitely |
The settlement observer takes at most 10 rows per consumer per table per tick, and GET /v1/liquidity-pools/positions reads Horizon through one paged listing instead of one request per pool |
Operators | One consumer could delay everyone else's settlement, and many pool shares meant unbounded Horizon calls |
GET /v1/onramp/payins/:id no longer returns receiverId or updatedAt — the same shape GET /v1/onramp/payins returns |
Callers reading those two fields from the single-payin read | The same payin could come back in two shapes |
POST /v1/kyc/upload with a file over 10 MiB is 413 with code: "payload_too_large"; it was internal_error |
Integrators branching on code |
It is a client-side limit, not a server error |
POST /v1/liquidity-pools/deposit, /withdraw, GET /v1/liquidity-pools/operations, /operations/:id, POST /v1/liquidity-pools/operations/:id/submit and the LIQUIDITY_* webhooks now carry memo (the caller's MEMO_ID, or null). Operations built before migration 20260915120000_liquidity_pool_operation_memo report null even when their envelope carries one |
Nobody, unless a client rejects unknown fields | The memo was only stored inside the XDR |
The published contract for GET /v1/swaps and GET /v1/liquidity-pools/operations no longer lists qr or commissionMemo on list items. The responses are unchanged — those two fields were never sent there; read the single item for them |
Clients generated from the OpenAPI spec | The contract declared list items with the single-item shape |
The service refuses to boot when APISIX_GATEWAY_SECRET is a placeholder — the value .env.example used to ship, or anything containing replace-with, change-me, your-secret or placeholder — and .env.example now leaves it empty |
Deployments still running the value copied from .env.example |
That value is public and long enough to pass the 32-character floor, so anyone who could reach the service could name any consumer and reach /v1/admin |
The service refuses to boot when BLINDPAY_WEBHOOK_SECRET is set but its key (the base64 after whsec_) is malformed or decodes to fewer than 24 bytes, and POST /v1/blindpay/webhooks rejects every delivery while the configured key is unusable |
Deployments with a truncated or mistyped secret, whose BlindPay webhooks were already failing | Node decodes invalid base64 to a short or empty HMAC key without an error, and a delivery signed with an empty key can be forged by anyone |
GET /v1/health/readiness answers a failed check with the standard error envelope (error: "Service Unavailable"); it used to put the health report, database error message included, in error |
Probes that read the report from the body instead of the status code | The route is @Public(), and Prisma's message names the database host and user |
POST /v1/onramp/receivers/:id/virtual-accounts is 403 account_disabled when the receiver, or the receiver that owns blockchain_wallet_id, is disabled |
Nobody legitimate | It was the one fiat operation the kill switch did not cover: a disabled account could still open a new deposit rail |
POST /v1/swaps/:id/submit and POST /v1/liquidity-pools/operations/:id/submit check the envelope before anything else: a body that does not parse, is not the row's envelope, or carries no signatures is 400 validation_failed whatever the row's status. An arbitrary signedXdr no longer returns a SUCCEEDED row, and an EXPIRED row answers a mismatched body with validation_failed instead of invalid_state_transition |
Clients that submitted the unsigned xdr and relied on the tx_bad_auth rejection |
Signatures do not change a transaction's hash, so the unsigned envelope could be relayed and rejected in a loop, and under the shared public key a row id alone read a settled row |
Both submit routes refuse an envelope past its time bounds (400 invalid_state_transition, not broadcast; the observer still settles it if it landed) and a FAILED row already resubmitted 3 times (400 invalid_state_transition: build a new one). A retry after 503 provider_unavailable does not count |
Clients that retry submit in a loop: stop on invalid_state_transition |
Every rejected resubmit was a Horizon submission and a new terminal webhook event, with no limit |
Both submit routes allow 20 calls a minute per consumer and client address, in separate buckets (429 rate_limited) |
Wallets behind one NAT sharing the public key | The routes take the shared public key, and each call can broadcast to Horizon |
GET /v1/webhooks, GET /v1/webhooks/:id and PATCH /v1/webhooks/:id return only the documented endpoint fields; POST /v1/webhooks and POST /v1/webhooks/:id/rotate-secret return those plus secret. consumerId, previousSecret and previousSecretExpiresAt left all five |
Callers reading those fields | previousSecret is a signing secret an integrator may still accept, and a key with only webhooks:read could read it |
A recovery token that matches no live recovery of the alias no longer counts against it. A live token uses an attempt on every presentation, including one whose challenge or signature then fails; after five it is 400 alias_recovery_invalid |
Nobody legitimate | Alias names are public, so five junk tokens from any key burned every recovery the console started |
POST /v1/aliases/:name/recovery/complete (10 per 10 min), POST /v1/aliases/challenges (30), POST /v1/webhooks/:id/ping (20) and POST /v1/webhooks/:id/deliveries/:deliveryId/redeliver (30) are 429 rate_limited over budget, per consumer and client address |
Scripts that loop these routes | Each call stores a row, tries a recovery token, or sends requests to a URL the caller chose |
PATCH /v1/payment-intents/:id requires txHash to be a 64-character hex Stellar transaction hash (anything else is 400) and stores it lowercase; POST /v1/payment-intents/:id/validate lowercases its own. A hash is unique among one consumer's intents instead of across all tenants, and a hash already on another of your intents is 409 idempotency_conflict (it was 500) |
Callers sending placeholder or truncated hashes | Any tenant could park another tenant's transaction hash on an intent of its own; the other tenant's settlement then hit the global index, answered 500, and the paid intent expired without PAYMENT_INTENT_SUCCEEDED |
An EXPIRED intent moves to SUCCEEDED when its payment is verified on-chain: by the observer, which now checks the chain before expiring, or by POST /v1/payment-intents/:id/validate and PATCH {status: SUCCEEDED}, which answer 200 instead of 400 invalid_state_transition. PAYMENT_INTENT_SUCCEEDED can follow the update EXPIRED emitted |
Webhook consumers that treat EXPIRED as final |
Expiry never looked at the chain, and the verifier read only the 50 newest payments to the destination, so a late or buried payment left a paid intent EXPIRED for good |
Swap, liquidity-pool operation, payment-intent and customer responses return only their documented fields, plus expiresAt on swaps and payment intents, now documented. consumerId and the settlement bookkeeping (settlementEpoch, lastCheckedAt, notFoundStreak, sharesReceived, settledAmountA/B, horizonCursor) are no longer sent |
Callers reading those fields | They are internal, and several of these routes are reachable with the shared public key |
PATCH /v1/kyc/receivers/:id on a receiver that already exists at BlindPay is 403 kyc_review_required for any field but external_id and image_url, unless the key is elevated (X-Consumer-Role: admin) |
Integrators correcting a live receiver's identity with a tenant key: send it through the reviewer | The PUT sent never-reviewed identity data straight to a regulated provider, while the same edit before enabling re-enters review |
BlindPay routes use the instance of the caller's key environment: prod keys the unsuffixed BLINDPAY_* instance, dev keys the BLINDPAY_*_DEV one, and a dev key with no development instance configured gets 503 misconfigured. Receivers, wallets, bank accounts, virtual accounts, quotes, payins and payouts are only read and executed on that instance |
Anyone using BlindPay with dev keys |
A dev key operated the production instance: it could list and delete real KYC identities and create live payouts |
A testnet login no longer provisions its user a mainnet wallet: network_wallets on a testnet redemption lists the testnet wallet only. A mainnet login still provisions testnet |
Anyone reading a mainnet entry from a testnet login | A dev key anyone can mint spent the operator's real XLM on a mainnet reserve per login |
POST /v1/kyc/receivers/:id/approve accepts expected_version (the dossierVersion you read) and answers 409 kyc_state_invalid when the KYC data changed since. POST /v1/kyc/receivers/:id/enable refuses a dossier that is not the one approved, and receiver reads carry dossierVersion and reviewedVersion |
Reviewers, once they start sending expected_version; nobody else — the field is optional |
A review is a person reading the data and then approving it, and an edit in between leaves the status at pending_review, so the approval landed on a dossier nobody had seen and enable sent it to a regulated provider |
POST /v1/kyc/upload, /v1/kyc/terms-of-service, the onramp and offramp writes, POST /v1/payment-intents/tx and /pay, POST /v1/swaps/quote and /v1/swaps, and POST /v1/liquidity-pools/deposit and /withdraw now answer 429 rate_limited over budget, per consumer and client address. Every BlindPay-backed route also counts against a per-consumer ceiling of 60 provider requests a minute |
Scripts that loop those routes; a batch importer above the ceiling should hold its own key | They had no limit at all: each one either leaves something behind at the provider that no error refunds, or spends the per-IP Horizon budget every route here shares. Only the builders' submits were capped |
POST /v1/swaps no longer answers 409 operation_in_flight for a PENDING swap whose sequence number the account has not used yet (an unsigned or abandoned envelope). Only applies where STELLAR_SWAP_SINGLE_INFLIGHT=true |
Wallet users who were blocked | Any caller may name any source, so one dust swap froze a stranger's account for a timeout window at a time — the twin of the liquidity-pool fix above |
A webhook destination refused for its host — unresolvable, private, link-local, metadata — is one 400 with one message; the reason is in the service log. A malformed URL, a non-https scheme, credentials or a missing host still say what is wrong |
Integrators who read the reason out of the response | Registering an endpoint resolves a name this service can reach, so per-reason answers let anyone map the internal network one URL at a time |
redirect_url is refused when it carries a fragment, a backslash, whitespace or a control character; https with no embedded credentials was already required |
Nobody sending a plain URL | https://app.acme.com\@evil.test names a different host depending on who parses it, and the value is read again by BlindPay and by a browser |
POST /v1/wallet/auth/oauth/claim: an Authentik sign-in whose email the provider has not confirmed (email_verified not true) completes the callback and answers verify_email with a code sent to that inbox, new account or not, instead of failing with email_unverified. No ID token is released for it, so it cannot start a SEP-30 recovery, and it shares the per-address cooldown of POST /v1/wallet/auth/email/start (400 wallet_login_code_cooldown). Run migration 20260926120000_wallet_auth_unverified_email first |
Wallets: handle verify_email on a new account too |
The person was left on a dead-end page; the code proves the address the provider did not |
POST /v1/wallet/auth/finish and POST /v1/wallet/recovery/setup read the session token from X-Wallet-Session: {sessionToken}. Authorization: Bearer is still read, but only reaches the service on a direct call |
Wallets: send X-Wallet-Session next to the API key |
The gateway strips Authorization (and apikey) before proxying, so through APISIX the token never arrived and both routes answered 401 wallet_session_invalid |
An Authentik wallet sign-in asks for max_age=300 instead of prompt=login, and the ID token's auth_time must be within those 5 minutes (else the callback fails with profile_invalid). With Google / GitHub as Authentik sources, set default-source-authentication to Authentication: No requirement |
Operators running Authentik with social sources | Under prompt=login Authentik asked a browser with no session to log in twice, and the second login through a source was refused with "Flow does not apply to current user" |
POST /v1/wallet/auth/finish and PUT /v1/wallet/backup also accept a v: 3 backup box: the seed under a random data key, and that key sealed once per door in slots (kind: "password" or kind: "passkey", at most 8). Every password door is held to the same PBKDF2 floor a v: 2 box is; a passkey door has no cost, because its key is the authenticator's WebAuthn PRF output. v: 2 boxes are unchanged |
Wallets: a passkey-only backup is valid, and a wallet that wrote one needs this server | Lets a person restore with a passkey instead of typing the original password, without this service ever holding a key that opens the box |
POST /v1/wallet/auth/oauth/authorize accepts an optional returnTo. When it is listed in WALLET_AUTH_RETURN_URLS, GET /v1/wallet/auth/oauth/callback/{provider} answers 302 to it with ?state=… (plus &error=<reason> on failure) instead of rendering the page; one that is not listed is 400 wallet_return_url_not_allowed. Only the state travels — the handshake is still redeemed with the PKCE verifier. Run migration 20260927180000_wallet_auth_return_to first |
Native wallets (desktop and mobile): send returnTo and register that URL with the OS |
A platform auth session (ASWebAuthenticationSession, a Custom Tab, a desktop deep link or loopback listener) closes only when the browser reaches the app's own URL, so the person was left on the page and had to dismiss it by hand |
GET /v1/wallet/auth/providers also returns mfaSettingsUrl: the page of a person's Authentik account where they add or remove a second factor (security key or passkey, authenticator app, recovery codes), through the Authentik login when there is no session; null without Authentik. A second factor is optional on the wallet sign-in — deploy/authentik/wallet-sign-in.yaml sets the MFA stage back to skip, asks whoever has a factor for it after the password, lets a passkey sign in from the username screen, and offers whoever has none a choice after the password (not now, a security key, an authenticator app). It also adds Google / GitHub to the sign-up page above its form. Signing in and signing up with a password are unchanged |
Operators running Authentik: import the blueprint. Wallets: offer the URL as a setting | A second factor was either forced on everyone or unreachable: the wallet's users never visit Authentik's own settings, the setup flows refuse a browser with no Authentik session, and the identification stage's passwordless button pointed at the same flow, so it only reloaded the page |
Deploy notes that come with it:
- Migration
20260910120000_aliasescreatesalias,alias_address,alias_challengeandalias_recovery. Runmigrate deploybefore the new build serves traffic. - A new advisory lock id,
881_008(AliasChallengeSweeper). Nothing to configure. - Set
NODE_ENV=productionin production..env.exampleshipsdevelopment, and two protections key on it: a request missingX-Plan-Swap-Fee-Bpsis a503only in production (anywhere else swaps silently fall back toSTELLAR_SWAP_FEE_BPS), and/docs— outside every guard — is off by default only in production. - The settlement observer's log lines changed to
Settlement observer started (every Nms),Settlement observer (OBSERVER_ENABLED=false) disabled, andSettlementObserverService cycle failedaterror. Update alerts that match the old wording.OBSERVER_ENABLED,OBSERVER_INTERVAL_MSand the advisory lock are unchanged. - Migration
20260915120000_liquidity_pool_operation_memoadds the nullableliquidity_pool_operation.memocolumn: no table rewrite, only a brief exclusive lock. There is no backfill — older rows keep their memo in base64 XDR, which SQL cannot decode, and the service falls back to the envelope for them. - Two variables are now checked at boot. A placeholder
APISIX_GATEWAY_SECRET, or aBLINDPAY_WEBHOOK_SECRETwhose key does not decode to at least 24 bytes, stops the service from starting with an error that names the variable. Replace a placeholder gateway secret on the APISIX route and here in the same change (openssl rand -hex 32); a mismatch makes every request fail as not coming from the gateway. - Migration
20260915150000_payment_intent_tx_hash_per_consumerreplaces the unique index onpayment_intent."txHash"with one on("consumerId", "txHash"). It is notCONCURRENTLY:payment_intentis write-locked while the index builds. There is no backfill. - Stored
webhook_endpoint.previousSecretvalues are no longer returned, but nothing clears them. If a rotation on an earlier release left one behind and you want it gone from the database, null the two columns yourself. - Migration
20260915160000_blindpay_environmentaddsenvironment(default'prod') to the seven BlindPay mirror tables — a catalog-only change, no table rewrite — so existing rows are labelled production. If your unsuffixedBLINDPAY_*variables pointed at a BlindPay development instance, move them to the_DEVvariables and relabel the rows (UPDATE … SET environment = 'dev'onblindpay_receiver,blindpay_blockchain_wallet,blindpay_bank_account,blindpay_virtual_account,payin,payoutandblindpay_quote), orprodkeys keep reading them. - Configure the development BlindPay instance (
BLINDPAY_API_KEY_DEV,BLINDPAY_INSTANCE_ID_DEV,BLINDPAY_WEBHOOK_SECRET_DEV) ifdevkeys use BlindPay, and point its dashboard webhook at the same/v1/blindpay/webhooksURL. - Migration
20260915200000_receiver_dossier_versionaddsdossierVersion(default1) andreviewedVersiontoblindpay_receiver— a catalog-only change, no table rewrite — and backfillsreviewedVersionfor every receiver already past the review gate, so theirenablekeeps working. Receivers still ininactiveorpending_reviewkeepNULL, which is the truth about them. - New
429s on routes that never returned one. The budgets in the table above apply from this release; a client that loops KYC uploads, quotes, payins, payouts, intent builds, swap quotes or pool builds needs to honourRetry-After.RATE_LIMIT_ENABLED=falseturns the limiter off during an incident.
Nothing changed on the wire; the published contract did. Regenerate any client
built from openapi/openapi.json:
- Each operation lists only the failures it can return.
409appears only where the route documents a conflict of its own,429only on rate-limited routes,502/503/504only where the route calls a provider, and the health probes list no401/403. Shared failures are$refs intocomponents.responses. - Every failure example is real for its status. The spec used to show one
409 idempotency_conflictunder every status of every route. X-Gateway-SecretandX-Consumer-Usernameare one security requirement (both headers), withAuthorization: Bearerpublished as the alternative for calls through the gateway. They used to be two alternatives, which told tools that either header alone was enough.- The
503ofGET /v1/health/readinessis documented as the error envelope. It used to be documented as the Terminus report, which the exception filter never returns.
The service now runs on NestJS 12 and TypeScript 6 and requires Node 24.9 or
later (engines; CI pins node-version: 24). Update deploy targets to match.
NestJS 12 is published as ESM, and Jest can only load it on Node >= 24.9 with
--experimental-vm-modules, so the test scripts run Jest through Node directly:
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
The published OpenAPI contract gained richer health schemas from
@nestjs/terminus@12 (status enums and responseTime). No business route or
schema changed.
PublicKeyGuard (global, after PermissionsGuard) and the @AllowPublicKey()
decorator are new. Existing keys are not affected. At deploy time:
- Set
APISIX_PUBLIC_CONSUMERto the username the dev platform provisions for the public key, on every deployment that publishes one. Without it the guard relies only on the forwardedX-Consumer-Role. - Create the public key with
role: publicand only the scopes the allowlisted routes need. Extra scopes such askyc:*would not open those routes, but a key everyone holds should not carry them.
See "The shared public API key" above.
A curated list of the (code, issuer) pairs this platform supports, per network, with the issuing organization. It requires no scope, since it holds no tenant data, but it does require an authenticated consumer (the shared public key works).
npm run assets:verify checks every row against live Horizon: that the pair
exists on its network, that contract matches Horizon's contract_id, and that
the issuer flags match the chain. Run it when editing the registry; it needs
internet access, so it is not part of the unit tests.
POST /v1/activity/events accepts telemetry from the wallet and the developer
dashboard; GET /v1/activity/events and GET /v1/activity/summary read it back.
No existing response changed. At deploy time:
- Migration
20260906140000_activity_eventcreatesactivity_event(append-only,consumerId-scoped, unique on(consumerId, eventId)). - The scopes
activity:writeandactivity:readare new. Existing keys do not get them automatically and receiveinsufficient_scope. The developer platform grants both to wallet-provisioned keys and re-applies them on rotation; add them to keys created by hand. ACTIVITY_RETENTION_DAYS(default 30) joins the retention job. These rows hold personal data, like the access log.
A 429 used to report code: "provider_unavailable". It now reports
code: "rate_limited" (ApiErrorCode.RateLimited, part of the published enum).
Branch on that if you retry on throttling.
When BlindPay is not configured, two responses changed:
| Request | Was | Now |
|---|---|---|
A route that calls BlindPay — under /v1/kyc, /v1/onramp or /v1/offramp — while BLINDPAY_API_KEY or BLINDPAY_INSTANCE_ID is unset |
503 provider_unavailable |
503 misconfigured |
POST /v1/blindpay/webhooks while BLINDPAY_WEBHOOK_SECRET is unset |
400 validation_failed |
503 misconfigured |
Both are deployment configuration errors that a retry cannot fix. Svix retries any non-2xx, so webhook delivery is unchanged.
Three published response shapes changed under /v1 (there is no /v2), so tell
integrators before you deploy.
| Endpoint | Was | Now | Why |
|---|---|---|---|
GET /v1/webhooks |
bare array, silently clamped at 100 | { data, total, take, skip } |
Results were capped at 100 with no total to page against |
GET /v1/products |
bare array, whole table | { data, total, take, skip } |
Unbounded read |
GET /v1/webhooks/:id/deliveries and the redelivery response |
included payload |
payload removed |
A RECEIVER_UPDATED body is a full KYC dossier and these routes are gated on webhooks:read, not kyc:read |
Callers that iterate the response or read delivery.payload will break: read
res.data instead, and fetch KYC details from the KYC endpoints with a key that
holds kyc:read.
RECEIVER_UPDATED / PAYIN_* / PAYOUT_* webhook bodies also narrowed to
identity and state — see the Webhooks section.
It ships as two files that must be applied in order:
20260901120000_audit_hardening— the correctness work: a new column, a de-duplicatingDELETEonliquidity_pool_operation, twoUNIQUEindexes, two new tables. The DELETE and the unique index run in one transaction under aSHARE ROW EXCLUSIVElock, so writers to that table block for a few milliseconds.20260901120100_audit_hardening_indexes— nine additive indexes, builtCONCURRENTLYso the deploy does not block writes onpayment_intent,swap,webhook_deliveryorrequest_log. No maintenance window needed.
They are separate files because PostgreSQL does not allow
CREATE INDEX CONCURRENTLY inside a transaction, and the first file needs one.
If the second file fails partway, it can leave an invalid index that
IF NOT EXISTS treats as present. Find it, drop it, and re-run:
SELECT c.relname FROM pg_class c JOIN pg_index i ON i.indexrelid = c.oid
WHERE NOT i.indisvalid;Delete the variable. It is no longer read, and the matching
COSMOS_ADMIN_API_SECRET / COSMOS_ADMIN_API_SECRET_READ in the developer
platform go with it.
It was a second admin check on top of the developer platform's own role check,
and deployments that skipped it got 401 admin_credentials_required on
cross-tenant reads from the console. Now /v1/admin accepts a request only when
it comes from the platform console, which two things on the request establish:
X-Gateway-SecretmatchesAPISIX_GATEWAY_SECRET— checked byApisixGuardas on every other route. Only the gateway and the console backend hold it.X-Cosmos-Internalis present. APISIX strips it from every request it proxies (proxy-rewrite.headers.remove), so an API-key caller cannot carry it; only a direct call from a backend holding the gateway secret can.
Point 2 depends on the gateway route configuration in the developer-platform
repo, not on a secret held by this service. In exchange, the console is the only
place that decides who is a platform admin, and audit rows name the console
account that acted (cosmos_<userId>) and its platform role, on every mutation
and every read.
What this changes for a caller:
| Was | Now |
|---|---|
401 admin_credentials_required without a Bearer secret |
403 admin_console_only for anything that is not a console call |
403 admin_role_required for a read credential on a mutation |
gone — the console already decided the account may act |
actorId / actorRole on an audit row named the credential |
they name the console account and its platform role |
To call /v1/admin directly (from an ops script, for example), send
X-Gateway-Secret, X-Consumer-Username and X-Cosmos-Internal: 1; add
X-Cosmos-Admin-Role: owner to label the audit row. Keep the service off the
public internet.
The service refuses to boot with a shorter secret. It now also protects
/v1/admin (see above). Generate one with openssl rand -hex 32 and update
APISIX at the same time.
A deployment upgrading from v0.1.5 loses the following behaviour. Every item is
visible to integrators, so plan the upgrade around them.
Was on v0.1.5 |
Now |
|---|---|
POST /v1/webhooks/:id/rotate-secret accepted graceSeconds and kept the old secret verifying for WEBHOOK_SECRET_GRACE_SECONDS |
The secret is swapped outright; the previous one stops verifying immediately. Update the receiver's stored secret in the same window as the rotate call. |
A leased retry worker delivered webhooks (maxAttempts / nextAttemptAt / leaseUntil, status RETRYING) |
The delivery sweeper does, with WEBHOOK_MAX_ATTEMPTS back to 3 per in-process loop (a real ceiling of 9 across sweeps). WEBHOOK_MAX_BACKOFF_MS, WEBHOOK_WORKER_*, WEBHOOK_LEASE_MS, WEBHOOK_FANOUT_CONCURRENCY and WEBHOOK_PAUSE_AFTER_FAILURES are gone, and no delivery is ever written as RETRYING. |
SWAP_EXPIRED and LIQUIDITY_EXPIRED were emitted |
Neither is emitted. Expiry is still recorded on the row; poll it, or subscribe to the *_FAILED events. |
GET /v1/products filtered on kind, active and reference, and DELETE took hard=true |
Neither exists. Deletes are soft (active=false). |
GET /v1/products and GET /v1/customers defaulted to take=20 |
Both default to take=100 (still the maximum), so an unparameterised call returns more rows than it did. |
analytics.apiLogs / analytics.webhookLogs returned { data, total } and honoured take only |
Both are paginated like every other list: take + skip in, { data, total, take, skip, hasMore } out. The overview's date-range filters are gone. |
/v1/health reported a Stellar readiness indicator alongside the database |
It reports the database only. |
STELLAR_HTTP_TIMEOUT_MS, STELLAR_MAX_ATTEMPTS, STELLAR_RETRY_BASE_MS bounded Horizon calls |
Horizon bounding lives in stellar/stellar.constants.ts and is not configurable by environment. Those three variables are no longer read or validated. |
Nothing is dropped from the database. The columns, indexes and enum values
those features added (webhook_delivery.maxAttempts / nextAttemptAt /
leaseUntil, webhook_endpoint.previousSecret*, swap and
liquidity_pool_operation lastCheckedAt / notFoundStreak, the
horizon_account_cursor table, RETRYING, SWAP_EXPIRED, LIQUIDITY_EXPIRED)
are still declared in schema.prisma and present after migrate deploy; they
are just no longer written. Removing them would need a destructive migration
(PostgreSQL cannot drop an enum value without recreating the type).
Every variable read from process.env in src/ is validated at boot by
src/config/env.validation.ts (fail-fast). Copy .env.example and adjust
at least DATABASE_URL and APISIX_GATEWAY_SECRET.
| Variable | Required | Default | Effect |
|---|---|---|---|
NODE_ENV |
no | development |
Must be development, test, or production. Set production in production — the fail-closed plan-fee check and docs-off-by-default both key on it |
PORT |
no | 3000 |
HTTP listen port |
DATABASE_URL |
yes | — | PostgreSQL connection for Prisma |
APISIX_GATEWAY_SECRET |
yes | — | Shared secret proving the request came through APISIX. Minimum 32 characters; a placeholder is refused at boot |
APISIX_GATEWAY_SECRET_HEADER |
no | x-gateway-secret |
Header name for the gateway secret |
APISIX_CONSUMER_HEADER |
no | x-consumer-username |
Authenticated consumer username |
APISIX_CREDENTIAL_HEADER |
no | x-credential-identifier |
Credential id from key-auth |
APISIX_ENVIRONMENT_HEADER |
no | x-consumer-env |
Key environment (dev / prod) |
APISIX_ROLE_HEADER |
no | x-consumer-role |
Consumer role forwarded by gateway |
APISIX_PERMISSIONS_HEADER |
no | x-consumer-permissions |
Permission list forwarded by gateway |
APISIX_ORGANIZATION_HEADER |
no | x-consumer-org |
Organization id |
APISIX_PLAN_HEADER |
no | x-consumer-plan |
Organization plan |
APISIX_SWAP_FEE_BPS_HEADER |
no | x-plan-swap-fee-bps |
Plan swap fee (bps) |
APISIX_EMAIL_HEADER |
no | x-consumer-email |
Verified email of the key's account, forwarded by the gateway. Nothing in this service depends on it today |
APISIX_PUBLIC_CONSUMER |
no | — | Username of the shared public consumer (see above). Set it wherever a public key is published |
STELLAR_NETWORK |
no | testnet |
Fallback Stellar network (public / testnet) |
STELLAR_HORIZON_URL_PUBLIC |
no | https://horizon.stellar.org |
Mainnet Horizon base URL |
STELLAR_HORIZON_URL_TESTNET |
no | https://horizon-testnet.stellar.org |
Testnet Horizon base URL |
STELLAR_BASE_FEE |
no | 100 |
Stellar base fee (stroops) for tx builds |
STELLAR_TX_TIMEOUT |
no | 300 |
Transaction timeout (seconds) |
STELLAR_SWAP_FEE_WALLET |
when fee > 0 | — | Platform G... account for swap fees |
STELLAR_SWAP_FEE_BPS |
no | 50 |
Swap fee in basis points |
STELLAR_SWAP_SLIPPAGE_BPS |
no | 50 |
Default swap slippage tolerance (bps) |
STELLAR_SWAP_MAX_SLIPPAGE_BPS |
no | 500 |
Hard cap on caller slippage (bps) |
STELLAR_SWAP_SINGLE_INFLIGHT |
no | false |
When true, 409 if a non-expired PENDING swap already exists for the same source |
OBSERVER_ENABLED |
no | true |
true / false — on-chain reconciler |
OBSERVER_INTERVAL_MS |
no | 15000 |
Observer poll interval (ms, min 1000) |
OBSERVER_BATCH_SIZE |
no | 50 |
Max intents/swaps per observer tick |
PAYMENT_INTENT_TTL_SECONDS |
no | 3600 |
Unpaid intent lifetime before EXPIRED |
WEBHOOK_TIMEOUT_MS |
no | 5000 |
Legacy webhook timeout fallback (ms) |
WEBHOOK_CONNECT_TIMEOUT_MS |
no | 3000 |
Outbound webhook connect budget (ms) |
WEBHOOK_READ_TIMEOUT_MS |
no | 5000 |
Outbound webhook read budget (ms) |
WEBHOOK_MAX_RESPONSE_BYTES |
no | 65536 |
Max drained webhook response body |
WEBHOOK_MAX_ATTEMPTS |
no | 3 |
Delivery retry count |
WEBHOOK_BACKOFF_MS |
no | 2000 |
Linear backoff between retries (ms) |
WEBHOOK_SIGNATURE_HEADER |
no | x-cosmos-signature |
HMAC header sent to integrators |
WEBHOOK_SWEEP_ENABLED |
no | true |
Recover deliveries stranded by a crash. Incident switch |
WEBHOOK_SWEEP_INTERVAL_MS |
no | 60000 |
Sweeper interval (ms, min 1000) |
WEBHOOK_PAYLOAD_RETENTION_DAYS |
no | 30 |
Days to keep a settled delivery body before redacting it. 0 keeps it forever |
REQUEST_LOG_RETENTION_DAYS |
no | 30 |
Days to keep request_log rows (payer IP / user-agent). 0 disables the prune |
ACTIVITY_RETENTION_DAYS |
no | 30 |
Days to keep activity_event rows (client IP / user-agent / props). Pruned by the same job. 0 keeps events forever |
REQUEST_LOG_PRUNE_INTERVAL_MS |
no | 3600000 |
Retention timer interval (ms) |
REQUEST_LOG_PRUNE_BATCH_SIZE |
no | 1000 |
Rows per delete batch (keeps each lock short) |
REQUEST_LOG_PRUNE_MAX_PER_CYCLE |
no | 50000 |
Hard cap on rows examined per tick |
SWAGGER_ENABLED |
no | off in production |
Publish /docs (Express middleware, no guards) |
OPENAPI_SERVER_URL |
no | — | Gateway host stamped into exported OpenAPI |
BLINDPAY_API_KEY |
no | — | API key of the production BlindPay instance, served to prod keys |
BLINDPAY_INSTANCE_ID |
when API key set | — | BlindPay instance id (in_...) |
BLINDPAY_BASE_URL |
no | https://api.blindpay.com/v1 |
BlindPay API base URL |
BLINDPAY_WEBHOOK_SECRET |
when API key set | — | Svix secret for inbound BlindPay webhooks: the whole whsec_… value, whose key must decode to at least 24 bytes (checked at boot) |
BLINDPAY_API_KEY_DEV |
no | — | API key of the development BlindPay instance, served to dev keys. Unset: BlindPay routes answer dev keys 503 misconfigured |
BLINDPAY_INSTANCE_ID_DEV |
when dev API key set | — | Development instance id (in_...) |
BLINDPAY_WEBHOOK_SECRET_DEV |
when dev API key set | — | Svix secret of the development instance's webhook endpoint; same rules as BLINDPAY_WEBHOOK_SECRET |
BLINDPAY_TIMEOUT_MS |
no | 15000 |
BlindPay HTTP client timeout (ms) |
DEFINDEX_API_KEY |
no | — | DeFindex server API key; empty disables DeFindex routes |
DEFINDEX_BASE_URL |
no | https://api.defindex.io |
DeFindex API base URL |
DEFINDEX_TIMEOUT_MS |
no | 30000 |
DeFindex HTTP timeout (ms) |
KYC_REDIRECT_URL_WHITELIST |
no | — | Per-consumer KYC redirect host allow-list |
WALLET_AUTH_RETURN_URLS |
no | — | Comma-separated app URLs the wallet sign-in callback may redirect to (returnTo on POST /v1/wallet/auth/oauth/authorize): a custom scheme, a universal/app link, or http://127.0.0.1/… (any port). Exact match; an entry that is plain http off loopback, carries a query or uses javascript:/data:/file: is refused at boot. Unset, every callback renders the page and a returnTo is 400 wallet_return_url_not_allowed |
RATE_LIMIT_ENABLED |
no | true |
Per-address caps on the routes that spend XLM. Incident switch |
RATE_LIMIT_PRUNE_INTERVAL_MS |
no | 600000 |
Counter-window prune interval (ms, min 1000) |
Legacy STELLAR_HORIZON_URL is rejected at boot — use
STELLAR_HORIZON_URL_PUBLIC / STELLAR_HORIZON_URL_TESTNET instead.
cp .env.example .env # set DATABASE_URL and a strong APISIX_GATEWAY_SECRET
npm install
npm run db:generate # prisma generate
npm run db:migrate # create the schema (needs a running Postgres)
npm run start:devGenerate a secret:
openssl rand -hex 32Run the same checks CI runs (no database needed — Prisma is mocked):
npm run lint
npm test # unit suites (src/**/*.spec.ts)
npm run test:e2e # e2e suites (test/*.e2e-spec.ts)
npm run openapi:check # the committed contract matches the controllers
npm run readme:check # the seven READMEs match in structure and list every routeThe dev platform's route helper (paydev/src/utils/apisix.ts) already converts
Authorization: Bearer <token> into the apikey header, validates key-auth,
and strips credentials before proxying. To point a route at this service, add the
gateway secret injection to the proxy-rewrite plugin so the header arrives
here — and remove any client-supplied copy:
"proxy-rewrite": {
"regex_uri": ["^/payments-api/(.*)", "/v1/$1"],
"headers": {
"set": {
// must equal APISIX_GATEWAY_SECRET in this service's environment
"X-Gateway-Secret": "<the-shared-secret>"
},
"remove": [
// credentials
"Authorization", "apikey", "X-API-KEY",
// ── Authorization inputs: this service trusts them as-is. ──
// key-auth does not overwrite them, so whatever the client sends arrives
// here unless it is removed below (the route then sets them from the
// consumer's metadata). Leaving any of them out is a privilege escalation:
//
// X-Consumer-Role: admin → bypasses every scope check
// X-Consumer-Permissions → grants arbitrary scopes
// X-Consumer-Env: prod → moves the caller onto Stellar MAINNET
// X-Plan-Swap-Fee-Bps: 0 → zero platform commission on swaps
// and liquidity-pool withdrawals
// X-Consumer-Org → attribution / plan resolution
// X-Consumer-Plan → plan tier
// X-Cosmos-Internal → admits the call to /v1/admin (every
// tenant's data, read and write)
// X-Cosmos-Admin-Role → labels the admin audit trail
// X-Cosmos-Tos-Cooldown-Ms → relaxes the KYC email resend limit
"X-Consumer-Role",
"X-Consumer-Permissions",
"X-Consumer-Env",
"X-Plan-Swap-Fee-Bps",
"X-Consumer-Org",
"X-Consumer-Plan",
"X-Cosmos-Internal",
"X-Cosmos-Admin-Role",
"X-Cosmos-Tos-Cooldown-Ms"
]
}
}key-auth forwards X-Consumer-Username / X-Credential-Identifier to the
upstream after a successful auth, overwriting any client-supplied copy, and the
guard relies on that.
The remove list is a security control, and it cannot be verified from this repository. This service accepts every header in it at face value;
X-Gateway-Secretonly proves the request came through a gateway, not that those values are honest. Review the list whenever a route is added or copied — a route that does not stripX-Cosmos-Internalgives every API key access to/v1/admin. Keep the service on a private network so APISIX is the only way in; the shared secret is a second layer, not the only one.In production, a missing
X-Plan-Swap-Fee-Bpsreturns503instead of falling back to the environment default.
{ "statusCode": 409, "code": "idempotency_conflict", "error": "Conflict", "message": "A swap already exists for this Idempotency-Key", "path": "/v1/swaps", "timestamp": "2026-09-01T12:00:00.000Z" }